diff --git a/e2e/wildcard_test.go b/e2e/wildcard_test.go new file mode 100644 index 0000000..2882e97 --- /dev/null +++ b/e2e/wildcard_test.go @@ -0,0 +1,66 @@ +package e2e + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" + + "git.veillax.com/Veillax/blacktip" + "git.veillax.com/Veillax/reef/model" +) + +// A website on every address does not take the port from a website that names an address: each +// listens where it should, and the named address answers only for its own site. +func TestWildcardWebsiteStepsAsideForANamedAddress(t *testing.T) { + w := setup(t, blacktip.TraefikConfig{}) + w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil) + wild, named := webBackend(t, "wild", 200), webBackend(t, "named", 200) + port := freePort(t) + + w.open(model.Mapping{Proto: "http", BindIP: "0.0.0.0", Port: port, Hostname: "wild.example.test", Targets: tgt(w.site, wild)}) + w.open(model.Mapping{Proto: "http", BindIP: "127.0.0.2", Port: port, Hostname: "named.example.test", Targets: tgt(w.site, named)}) + + ask := func(ip, host string) (int, string) { + c := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DisableKeepAlives: true, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port))) + }}} + req, _ := http.NewRequest("GET", "http://"+host+"/", nil) + resp, err := c.Do(req) + if err != nil { + return 0, err.Error() + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + var body map[string]string + json.Unmarshal(b, &body) + return resp.StatusCode, body["backend"] + } + wait := func(ip, host, wantBackend string) { + t.Helper() + var code int + var got string + for i := 0; i < 60; i++ { + if code, got = ask(ip, host); code == 200 && got == wantBackend { + return + } + time.Sleep(150 * time.Millisecond) + } + t.Fatalf("%s via %s: status %d backend %q, want %q", host, ip, code, got, wantBackend) + } + + wait("127.0.0.2", "named.example.test", "named") // the named address serves its own site + wait("127.0.0.1", "wild.example.test", "wild") // the wildcard still covers the other addresses + // and the wildcard gave up the named address: its site is not served there + if code, _ := ask("127.0.0.2", "wild.example.test"); code == 200 { + t.Errorf("the wildcard website still answers on the address another website names") + } + if code, _ := ask("127.0.0.1", "named.example.test"); code == 200 { + t.Errorf("a website answers on an address it was not given") + } +} diff --git a/mappings.go b/mappings.go index e5a24f2..1270e49 100644 --- a/mappings.go +++ b/mappings.go @@ -204,8 +204,24 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error { if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname { continue // same listener, different hostname } + if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) { + continue // a website on every address steps aside for one that names an address + } return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name) } + // an https resource with the redirect on also answers on port 80 at its address, so a raw port + // there would clash with it (and the other way round) + for _, o := range s.st.Mappings { + if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) { + continue + } + switch { + case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort(): + return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name) + case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort(): + return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", o.BindIP, o.Name) + } + } if m.Name == "" { if m.Hostname != "" { m.Name = m.Hostname diff --git a/mappings_test.go b/mappings_test.go new file mode 100644 index 0000000..b0ed9d7 --- /dev/null +++ b/mappings_test.go @@ -0,0 +1,76 @@ +package hammerhead + +import ( + "encoding/json" + "strings" + "testing" +) + +// websites share a port by hostname, and a website on every address steps aside for one that names an address +func TestWebsitesShareAPortAndWildcardsStepAside(t *testing.T) { + s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + do := apiClient(t, s) + var me struct{ Orgs []struct{ ID string } } + _, b := do("GET", "/api/me", nil) + json.Unmarshal(b, &me) + org := me.Orgs[0].ID + var edge, site struct{ ID string } + _, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"}) + json.Unmarshal(b, &edge) + _, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"}) + json.Unmarshal(b, &site) + if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 { + t.Fatalf("domain: %d %s", code, b) + } + base := "/api/orgs/" + org + "/mappings" + open := func(m map[string]any) (int, string) { + m["edgeId"] = edge.ID + m["enabled"] = true + m["targets"] = []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}} + code, b := do("POST", base, m) + return code, string(b) + } + want := func(code int, got int, body, what string) { + t.Helper() + if code != got { + t.Errorf("%s: got %d, want %d (%s)", what, got, code, body) + } + } + + c, body := open(map[string]any{"proto": "https", "bindIp": "0.0.0.0", "port": 443, "hostname": "a.example.test"}) + want(200, c, body, "first website on every address") + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"}) + want(200, c, body, "a website naming an address shares the port with the wildcard one") + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.11", "port": 443, "hostname": "c.example.test"}) + want(200, c, body, "a second named address") + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "d.example.test"}) + want(200, c, body, "another hostname on a named address") + // still refused: the same hostname twice on one listener, a raw port on a website's port, and another protocol + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"}) + want(400, c, body, "the same hostname twice on one address") + c, body = open(map[string]any{"proto": "tcp", "bindIp": "0.0.0.0", "port": 443}) + want(400, c, body, "a raw port on top of websites") + c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.10", "port": 443, "hostname": "e.example.test"}) + want(400, c, body, "http on an https port") + + // the HTTP to HTTPS redirect also uses port 80 at its address + c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.20", "port": 80}) + want(200, c, body, "a raw port 80 on its own address") + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test", "redirectHttp": true}) + want(400, c, body, "a redirect onto a raw port 80") + if !strings.Contains(body, "port 80") { + t.Errorf("the error should name port 80: %s", body) + } + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test"}) + want(200, c, body, "the same website without the redirect") + c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.21", "port": 443, "hostname": "g.example.test", "redirectHttp": true}) + want(200, c, body, "a redirect where port 80 is free") + c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.21", "port": 80}) + want(400, c, body, "a raw port 80 where a redirect lives") + c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.21", "port": 80, "hostname": "h.example.test"}) + want(200, c, body, "an http website beside a redirect on port 80") +}