| index 3fe510c9c..2bfd2edb1 100644 |
| --- a/internal/edge/homemap_test.go |
| +++ b/internal/edge/homemap_test.go |
| @@ -1,10 +1,14 @@ |
| package edge |
| |
| import ( |
| + "bufio" |
| "context" |
| + "crypto/tls" |
| + "crypto/x509" |
| "errors" |
| "fmt" |
| "io" |
| + "net" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| @@ -495,17 +499,22 @@ func TestHopWithoutAMirroredMeshKeyIsAnOrdinaryClient(t *testing.T) { |
| } |
| } |
| |
| -// An unserved host with NO hop header keeps today's answer exactly: the |
| -// stale-map 503 is reachable only by a party holding the mesh key, so it |
| -// never tells a stranger whether a host exists elsewhere in the deployment. |
| -func TestUnknownHostWithoutAHopIsStillThe404(t *testing.T) { |
| +// An unserved host that NO box homes keeps today's answer exactly: 404, with |
| +// no Retry-After, and nothing said about the rest of the deployment. |
| +// |
| +// The home map here is deliberately non-empty and deliberately about a |
| +// DIFFERENT host: the map is consulted (Task 4's backhaul) and misses, which |
| +// is the only route to this answer now that a homed host is dialled instead. |
| +// The stale-map 503 stays reachable only by a party holding the mesh key, so |
| +// the two answers still cannot be told apart by a stranger. |
| +func TestUnknownHostWithNoHomeIsStillThe404(t *testing.T) { |
| e, m, _ := hopEdge(t, "annie", "app.test") |
| putJSON(t, m.s, "control/edges/betty.json", |
| `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`) |
| refreshHomeMap(t, m) |
| - resp := hopRequest(t, e, "moved.test", nil) |
| + resp := hopRequest(t, e, "nowhere.test", nil) |
| if resp.StatusCode != http.StatusNotFound { |
| - t.Fatalf("status = %d, want the unchanged 404 for an unauthenticated stranger", resp.StatusCode) |
| + t.Fatalf("status = %d, want the unchanged 404 for a host nothing in the deployment homes", resp.StatusCode) |
| } |
| if got := resp.Header.Get("Retry-After"); got != "" { |
| t.Fatalf("Retry-After = %q — a stranger must not be told to come back", got) |
| @@ -525,6 +534,393 @@ func TestOrdinaryClientForgedXFFIsStillDiscarded(t *testing.T) { |
| } |
| } |
| |
| +// ---- backhaul dialing (the sending half) ------------------------------------ |
| + |
| +// homeEdgeSeen is what a fake home edge was handed on the wire. |
| +type homeEdgeSeen struct { |
| + n int |
| + sni, host, hop, auth string |
| + xff []string |
| +} |
| + |
| +type homeEdgeRecord struct { |
| + mu sync.Mutex |
| + seen homeEdgeSeen |
| +} |
| + |
| +func (h *homeEdgeRecord) get() homeEdgeSeen { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + return h.seen |
| +} |
| + |
| +const homeEdgeBody = "served by the home edge" |
| + |
| +// fakeHomeEdge stands in for the box that homes a host: a TLS server that |
| +// records the ClientHello's SNI and the trust-bearing headers of whatever |
| +// arrives, then runs h (a plain body writer unless the test needs a protocol |
| +// switch). |
| +// |
| +// It also arms the two dial seams for the test's lifetime — backhaulPort, so |
| +// the dial reaches an ephemeral httptest port instead of 443, and |
| +// backhaulRootCAs, so this server's self-signed certificate is trusted the way |
| +// a real home edge's publicly-trusted one is. Nothing about verification is |
| +// weakened: httptest's certificate names example.com, so every test below asks |
| +// for exactly that host and a name mismatch would fail the handshake here just |
| +// as it would in production. |
| +func fakeHomeEdge(t *testing.T, h http.Handler) (*homeEdgeRecord, string) { |
| + t.Helper() |
| + rec := &homeEdgeRecord{} |
| + if h == nil { |
| + h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, homeEdgeBody) }) |
| + } |
| + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + rec.mu.Lock() |
| + rec.seen.n++ |
| + rec.seen.host = r.Host |
| + rec.seen.hop = r.Header.Get(hopHeader) |
| + rec.seen.auth = r.Header.Get(hopAuthHeader) |
| + rec.seen.xff = r.Header.Values("X-Forwarded-For") |
| + rec.mu.Unlock() |
| + h.ServeHTTP(w, r) |
| + })) |
| + ts.TLS = &tls.Config{GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) { |
| + rec.mu.Lock() |
| + rec.seen.sni = chi.ServerName |
| + rec.mu.Unlock() |
| + return nil, nil // the server's own config, unchanged — this hook only watches |
| + }} |
| + ts.StartTLS() |
| + t.Cleanup(ts.Close) |
| + |
| + eip, port, err := net.SplitHostPort(ts.Listener.Addr().String()) |
| + if err != nil { |
| + t.Fatalf("split home edge addr: %v", err) |
| + } |
| + pool := x509.NewCertPool() |
| + pool.AddCert(ts.Certificate()) |
| + oldPort, oldCAs := backhaulPort, backhaulRootCAs |
| + backhaulPort, backhaulRootCAs = port, pool |
| + t.Cleanup(func() { backhaulPort, backhaulRootCAs = oldPort, oldCAs }) |
| + return rec, eip |
| +} |
| + |
| +// homedOn publishes one box's edges file naming hosts as its own, and lands it |
| +// in the map — the second box a single-box deployment never has. |
| +func homedOn(t *testing.T, m *homeMap, box, eip string, hosts ...string) { |
| + t.Helper() |
| + entries := make([]string, 0, len(hosts)) |
| + for _, h := range hosts { |
| + entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\"}", h)) |
| + } |
| + putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf( |
| + `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ","))) |
| + refreshHomeMap(t, m) |
| +} |
| + |
| +// The whole of the sending half in one request: a host this box does not serve |
| +// but another box homes reaches that box over real TLS, with the SNI it will |
| +// need to present a certificate for, the real Host header, a hop signature the |
| +// home edge can verify, and an X-Forwarded-For that names only the client. |
| +func TestBackhaulDialsTheHomeEdgeOverVerifiedTLS(t *testing.T) { |
| + e, m, key := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + resp := hopRequest(t, e, "example.com", nil) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200 — a remote-homed host must be served through its home edge", resp.StatusCode) |
| + } |
| + if body := readBody(t, resp); body != homeEdgeBody { |
| + t.Fatalf("body = %q, want %q", body, homeEdgeBody) |
| + } |
| + seen := rec.get() |
| + if seen.n != 1 { |
| + t.Fatalf("home edge saw %d request(s), want exactly one — one hop, ever", seen.n) |
| + } |
| + if seen.sni != "example.com" { |
| + t.Fatalf("SNI = %q, want the request host — the home edge answers with the cert for the host being asked for", seen.sni) |
| + } |
| + if seen.host != "example.com" { |
| + t.Fatalf("Host = %q, want the real hostname — the home edge routes on it", seen.host) |
| + } |
| + if seen.hop != "1" { |
| + t.Fatalf("%s = %q, want 1", hopHeader, seen.hop) |
| + } |
| + if !verifyHopEitherKey(key, nil, "example.com", seen.auth, time.Now()) { |
| + t.Fatalf("%s = %q did not verify under the deployment's mesh key", hopAuthHeader, seen.auth) |
| + } |
| + // One element, the client. Neither this edge's own address nor the home EIP |
| + // may be appended: the home edge restores this chain verbatim on a verified |
| + // hop, and every consumer takes the LAST element as the caller |
| + // (setForwarded, homemap.go). |
| + if len(seen.xff) != 1 || strings.Contains(seen.xff[0], ",") { |
| + t.Fatalf("X-Forwarded-For = %q, want a single-element chain naming only the client", seen.xff) |
| + } |
| +} |
| + |
| +// Precedence, first rule: a host served HERE is served here, whatever the home |
| +// map says about it. The map is poisoned to name a reachable home for exactly |
| +// that host, so a lookup in the wrong order would show up as a dial. |
| +func TestLocalHitIgnoresTheHomeMap(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "example.com") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + resp := hopRequest(t, e, "example.com", nil) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + if body := readBody(t, resp); !strings.HasPrefix(body, "xff=") { |
| + t.Fatalf("body = %q, want the LOCAL backend's answer — a local hit must never consult the home map", body) |
| + } |
| + if n := rec.get().n; n != 0 { |
| + t.Fatalf("home edge saw %d request(s), want none", n) |
| + } |
| +} |
| + |
| +// One hop, ever: a request that ARRIVED as a verified hop is refused, never |
| +// forwarded onward, even when the map offers a live target for it. The fake |
| +// home edge is the zero-call witness — a re-forward would answer 200 here. |
| +func TestArrivedHopNeverDialsTheHomeEdge(t *testing.T) { |
| + e, m, key := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + resp := hopRequest(t, e, "example.com", map[string]string{ |
| + hopHeader: "1", |
| + hopAuthHeader: signHop(key, "example.com", time.Now()), |
| + }) |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("status = %d, want 503 — an arrived hop is refused, not re-forwarded", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Retry-After"); got != "2" { |
| + t.Fatalf("Retry-After = %q, want 2", got) |
| + } |
| + if n := rec.get().n; n != 0 { |
| + t.Fatalf("home edge saw %d request(s), want none — a hop must never become a second hop", n) |
| + } |
| +} |
| + |
| +// The backhaul's own guard, exercised directly rather than through ServeHTTP's |
| +// branch above it: if this call ever moves, an arrived hop must still fail |
| +// closed here instead of being forwarded. |
| +func TestBackhaulHelperRefusesAnArrivedHopOnItsOwn(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + r := httptest.NewRequest("GET", "http://example.com/", nil) |
| + r = r.WithContext(context.WithValue(r.Context(), hopVerifiedKey{}, true)) |
| + w := httptest.NewRecorder() |
| + if !e.backhaul(w, r, "example.com") { |
| + t.Fatal("backhaul must ANSWER an arrived hop, not fall through to the unknown-host path") |
| + } |
| + if w.Code != http.StatusServiceUnavailable { |
| + t.Fatalf("status = %d, want the stale-map refusal", w.Code) |
| + } |
| + if n := rec.get().n; n != 0 { |
| + t.Fatalf("home edge saw %d request(s), want none", n) |
| + } |
| +} |
| + |
| +// Fail closed on the SENDING side too: with no mesh key mirrored there is |
| +// nothing to prove the hop with, and signing under an empty key would cross a |
| +// region only to be served as an anonymous client. The request gets today's |
| +// unknown-host answer instead. |
| +func TestBackhaulRefusesToDialWithoutAMeshKey(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + if err := m.s.Delete(context.Background(), meshKeyKey); err != nil { |
| + t.Fatalf("delete mesh key: %v", err) |
| + } |
| + refreshHomeMap(t, m) |
| + if cur, next := m.meshKeys(); len(cur) != 0 || len(next) != 0 { |
| + t.Fatal("precondition: the mirror must hold no key") |
| + } |
| + |
| + resp := hopRequest(t, e, "example.com", nil) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("status = %d, want the unchanged 404 — an unprovable hop is not dialled", resp.StatusCode) |
| + } |
| + if n := rec.get().n; n != 0 { |
| + t.Fatalf("home edge saw %d request(s), want none — never sign with an empty key", n) |
| + } |
| +} |
| + |
| +// A catch-all row in another box's edges file resolves through the same |
| +// one-label wildcard rule lookupUpstream uses — but the signature must be over |
| +// the CONCRETE request host, because that is what the home edge verifies |
| +// against. Signing the map KEY would produce a MAC for "*.com", which is not a |
| +// request host under any canonicalization. |
| +func TestBackhaulSignsTheConcreteHostNotTheWildcardKey(t *testing.T) { |
| + e, m, key := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "*.com") |
| + if _, _, ok := m.Home("example.com"); !ok { |
| + t.Fatal("precondition: the wildcard row must offer a home for example.com") |
| + } |
| + |
| + if resp := hopRequest(t, e, "example.com", nil); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + seen := rec.get() |
| + if !verifyHop(key, "example.com", seen.auth, time.Now()) { |
| + t.Fatalf("%s = %q must verify against the concrete request host", hopAuthHeader, seen.auth) |
| + } |
| + if verifyHop(key, "*.com", seen.auth, time.Now()) { |
| + t.Fatalf("%s = %q verifies against the wildcard map key — the MAC must bind the concrete host", hopAuthHeader, seen.auth) |
| + } |
| +} |
| + |
| +// A WebSocket must compose through the hop with no code of its own: the dial |
| +// branch sits above ServeHTTP's cache/compression wrapping, so the client's |
| +// hijackable writer reaches httputil.ReverseProxy's protocol-switch path, and |
| +// the transport speaks HTTP/1.1 (no ForceAttemptHTTP2) so that path works. |
| +// Bytes must then flow both ways with no whole-body deadline cutting them off. |
| +func TestBackhaulCarriesAWebSocketUpgrade(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hj, ok := w.(http.Hijacker) |
| + if !ok { |
| + http.Error(w, "not hijackable", http.StatusInternalServerError) |
| + return |
| + } |
| + conn, buf, err := hj.Hijack() |
| + if err != nil { |
| + return |
| + } |
| + defer conn.Close() |
| + buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") |
| + if err := buf.Flush(); err != nil { |
| + return |
| + } |
| + line, err := buf.ReadString('\n') |
| + if err != nil { |
| + return |
| + } |
| + buf.WriteString("home:" + line) |
| + buf.Flush() |
| + })) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://")) |
| + if err != nil { |
| + t.Fatalf("dial edge: %v", err) |
| + } |
| + defer c.Close() |
| + if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { |
| + t.Fatalf("set deadline: %v", err) |
| + } |
| + if _, err := fmt.Fprint(c, "GET /socket HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); err != nil { |
| + t.Fatalf("write handshake: %v", err) |
| + } |
| + br := bufio.NewReader(c) |
| + resp, err := http.ReadResponse(br, nil) |
| + if err != nil { |
| + t.Fatalf("read handshake response: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSwitchingProtocols { |
| + t.Fatalf("handshake status = %d, want 101 — an upgrade must switch protocols through the hop", resp.StatusCode) |
| + } |
| + if _, err := fmt.Fprint(c, "ping\n"); err != nil { |
| + t.Fatalf("write post-upgrade frame: %v", err) |
| + } |
| + line, err := br.ReadString('\n') |
| + if err != nil { |
| + t.Fatalf("read post-upgrade frame: %v", err) |
| + } |
| + if line != "home:ping\n" { |
| + t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways across the backhaul", line, "home:ping\n") |
| + } |
| + if seen := rec.get(); seen.hop != "1" { |
| + t.Fatalf("%s = %q — an upgrade is a hop like any other", hopHeader, seen.hop) |
| + } |
| +} |
| + |
| +// A home whose edge cannot be reached is a bad gateway, not a 503: this box |
| +// believed a specific box was there. The unreachable target here is the |
| +// documented cert-less bootstrap window's shape too — a dial or handshake that |
| +// does not complete, healed by the next mirror poll. |
| +func TestBackhaulToAnUnreachableHomeIsA502(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + // A port nothing listens on, reached through the same seam the fake home |
| + // edge uses. 127.0.0.1 refuses immediately rather than hanging for the dial |
| + // budget. |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatalf("listen: %v", err) |
| + } |
| + eip, port, err := net.SplitHostPort(ln.Addr().String()) |
| + if err != nil { |
| + t.Fatalf("split: %v", err) |
| + } |
| + ln.Close() |
| + old := backhaulPort |
| + backhaulPort = port |
| + t.Cleanup(func() { backhaulPort = old }) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + resp := hopRequest(t, e, "example.com", nil) |
| + if resp.StatusCode != http.StatusBadGateway { |
| + t.Fatalf("status = %d, want 502 for a home edge that could not be reached", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Retry-After"); got != "" { |
| + t.Fatalf("Retry-After = %q — 503 + Retry-After is reserved for knowing nobody is home", got) |
| + } |
| +} |
| + |
| +// One transport per home BOX. The pool is what makes a backhaul cost one |
| +// handshake rather than one per request; keying it per box rather than per |
| +// host is what keeps a catch-all home — where every invented subdomain is a |
| +// backhaul target — from growing the map without limit. Per-host identity |
| +// still exists, one layer down: the outbound authority is the hostname, so SNI |
| +// and pooled connections are per host inside the box's transport (the SNI |
| +// assertion in TestBackhaulDialsTheHomeEdgeOverVerifiedTLS is that half). |
| +func TestBackhaulTransportsAreCachedPerHomeBox(t *testing.T) { |
| + e, _, _ := hopEdge(t, "annie", "local.test") |
| + a := e.backhaulTransport("1.1.1.1") |
| + if b := e.backhaulTransport("1.1.1.1"); b != a { |
| + t.Fatal("the same home box must reuse one transport, or every request pays a fresh TLS handshake") |
| + } |
| + if b := e.backhaulTransport("2.2.2.2"); b == a { |
| + t.Fatal("a different home box must get its own transport — the dial address is baked into it") |
| + } |
| + if sn := a.TLSClientConfig.ServerName; sn != "" { |
| + t.Fatalf("ServerName = %q, want it unset — pinning one host per transport is what a wildcard home would multiply", sn) |
| + } |
| + if a.TLSClientConfig.InsecureSkipVerify { |
| + t.Fatal("the hop verifies the home edge's certificate — always") |
| + } |
| +} |
| + |
| +// Two hosts homed on one box share that box's transport, and each still |
| +// reaches the home edge under its OWN name: the shared transport is the memory |
| +// property, the per-host SNI is the correctness one. |
| +func TestBackhaulSharesOneTransportAcrossAWildcardHome(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "*.com") |
| + |
| + if resp := hopRequest(t, e, "example.com", nil); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + if sni := rec.get().sni; sni != "example.com" { |
| + t.Fatalf("SNI = %q, want the concrete request host", sni) |
| + } |
| + n := 0 |
| + e.backhaulTransports.Range(func(_, _ any) bool { n++; return true }) |
| + if n != 1 { |
| + t.Fatalf("%d transports cached for one home box, want 1 — a catch-all home must not mint one per hostname", n) |
| + } |
| + if _, ok := e.backhaulTransports.Load(eip); !ok { |
| + t.Fatalf("the cached transport must be keyed by the home box's EIP (%s)", eip) |
| + } |
| +} |
| + |
| // oneKeyFailingStore fails Get for one named key — a single unreadable object |
| // mid-poll, the case the carry-forward exists for. |
| type oneKeyFailingStore struct { |