carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// Task 6 of the multi-region backhaul plan: the acceptance test the whole of
4// Part 3 exists for — ONE control bucket, TWO edges, and a request that lands
5// in the wrong region and is served anyway.
6//
7// Edge A ("annie") homes app.backhaul.test and runs the tenant's app behind it.
8// Edge B ("betty") has never heard of that host: no registry route, no
9// certificate of its own, no way to reach the app except through A. Everything
10// between the two is the bucket — the per-box edges files (Task 1's schema,
11// written here directly because the publishing loop lives in internal/host),
12// the mesh key (Task 2), the certs Part 2 distributes — and one TLS hop.
13//
14// What makes this an e2e rather than a bigger unit test is that BOTH doors are
15// real and neither is stubbed:
16//
17// - the client completes a real TLS handshake with the SENDING edge, with a
18// real x509 verification against the test CA, for a host that edge does not
19// route. That is the invariant the whole feature rides on and the one the
20// unit tests can only assert one branch of at a time: certMirror must
21// answer a hello for a host this box has no route for, or the client never
22// gets far enough to be backhauled at all (P1 below);
23// - the hop itself is a second real TLS handshake, from B's own transport to
24// A's front door, verified the same way.
25//
26// The two edges are wired exactly as RunWithActivatorReady wires one (raw local
27// cache kept aside, publishing wrapper over it, mirror built from the RAW
28// handle, autocert behind the mirror, the :80 chain composed in the same
29// order), and every poll is DRIVEN — Refresh is called, never waited for. There
30// are no sleeps in this file.
31//
32// Neither edge is the deployment's certificate issuer. That is deliberate and
33// it is the sharp case, not a convenience: issuance is Part 2's subject, while
34// what Part 3 needs is that a box which may never talk to a CA can still answer
35// handshakes for hosts it does not serve. Both managers point at a dead ACME
36// directory as well, so nothing here can reach a CA even by accident.
37//
38// Conventions are the neighbouring harnesses' (certsync_e2e_test.go for the
39// two-edge shape and the e2e* naming, homemap_test.go for the hop vocabulary
40// helpers): real loopback listeners, helpers reused rather than re-cut, bounds
41// asserted rather than narrated. No t.Parallel anywhere — this package's tests
42// swap package-level seams (backhaulPort, backhaulRootCAs) and the log writer.
43
44import (
45 "context"
46 "crypto/ecdsa"
47 "crypto/elliptic"
48 "crypto/rand"
49 "crypto/tls"
50 "crypto/x509"
51 "crypto/x509/pkix"
52 "fmt"
53 "io"
54 "math/big"
55 "net"
56 "net/http"
57 "net/http/httptest"
58 "strings"
59 "sync/atomic"
60 "testing"
61 "time"
62
63 "golang.org/x/crypto/acme/autocert"
64
65 "github.com/carlosframework/platform/internal/geoip"
66 "github.com/carlosframework/platform/internal/registry"
67 "github.com/carlosframework/platform/internal/release"
68)
69
70const (
71 // e2eHomedHost is the host the whole scenario turns on: routed on edge A,
72 // unknown to edge B, homed on A in every box's map.
73 e2eHomedHost = "app.backhaul.test"
74 // e2eMovedHost is homed on A in the MAP but no longer routed there — the
75 // stale-map case, which is what a placement move looks like from the
76 // sending edge for one publish cycle.
77 e2eMovedHost = "moved.backhaul.test"
78 // e2eDeadHost is homed on a box that answers nothing: the poisoned map.
79 e2eDeadHost = "dead.backhaul.test"
80 // e2eUnhomedHost is in nobody's edges file and nobody's registry — the
81 // answer for it must stay the plain unknown-host 404 on every box.
82 e2eUnhomedHost = "nothere.backhaul.test"
83 // e2eSenderHost is edge B's OWN route, so B is a real box with traffic of
84 // its own rather than a bare forwarder.
85 e2eSenderHost = "local.betty.test"
86
87 e2eHomeMarker = "served by the home edge's own app"
88 e2eSenderMarker = "served by the sending edge's own app"
89)
90
91// ---- the tenant app ----------------------------------------------------------
92
93// e2eApp is a tenant's app behind an edge: a marker body that says WHICH box's
94// upstream produced it, plus the trust-bearing headers it was handed. One
95// response therefore proves both halves at once — that the bytes crossed the
96// hop, and what the app was told about who called.
97//
98// X-Forwarded-For is rendered from Values(), joined visibly, because the
99// property under test is the LENGTH of the chain as much as its content: a
100// backhaul that appended the sending edge would show up here as a second
101// element (or a second header line), and Get() would hide it.
102func e2eApp(t *testing.T, marker string) *httptest.Server {
103 t.Helper()
104 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105 fmt.Fprintf(w, "%s|xff=%s|xfh=%s|hop=%s|auth=%s|geo=%s|hopgeo=%s", marker,
106 strings.Join(r.Header.Values("X-Forwarded-For"), " + "),
107 r.Header.Get("X-Forwarded-Host"),
108 r.Header.Get(hopHeader), r.Header.Get(hopAuthHeader),
109 r.Header.Get(geoip.HeaderCountry), r.Header.Get(geoip.HeaderHop))
110 }))
111 t.Cleanup(ts.Close)
112 return ts
113}
114
115// e2eSeen is one parsed e2eApp response.
116type e2eSeen struct {
117 marker, xff, xfh, hop, auth string
118 geoCountry, hopGeo string
119}
120
121func e2eParseApp(t *testing.T, body string) e2eSeen {
122 t.Helper()
123 parts := strings.Split(body, "|")
124 if len(parts) != 7 {
125 t.Fatalf("body %q did not come from the tenant app at all", body)
126 }
127 seen := e2eSeen{marker: parts[0]}
128 for _, p := range parts[1:] {
129 k, v, _ := strings.Cut(p, "=")
130 switch k {
131 case "xff":
132 seen.xff = v
133 case "xfh":
134 seen.xfh = v
135 case "hop":
136 seen.hop = v
137 case "auth":
138 seen.auth = v
139 case "geo":
140 seen.geoCountry = v
141 case "hopgeo":
142 seen.hopGeo = v
143 }
144 }
145 return seen
146}
147
148// ---- the deployment's certificate authority ----------------------------------
149
150// e2eCA stands in for the public CA Part 2's issuer orders from. A real CA
151// rather than a self-signed leaf per host, because every handshake in this file
152// is verified the way a browser verifies one — RootCAs, hostname, expiry — and
153// a leaf trusted as its own root would quietly excuse a chain that production
154// would reject.
155type e2eCA struct {
156 root *x509.Certificate
157 key *ecdsa.PrivateKey
158 pool *x509.CertPool
159}
160
161func newE2ECA(t *testing.T) *e2eCA {
162 t.Helper()
163 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
164 if err != nil {
165 t.Fatal(err)
166 }
167 tmpl := &x509.Certificate{
168 SerialNumber: big.NewInt(1),
169 Subject: pkix.Name{CommonName: "CARLOS backhaul test CA"},
170 NotBefore: time.Now().Add(-time.Hour),
171 NotAfter: time.Now().Add(365 * 24 * time.Hour),
172 KeyUsage: x509.KeyUsageCertSign,
173 BasicConstraintsValid: true,
174 IsCA: true,
175 }
176 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
177 if err != nil {
178 t.Fatal(err)
179 }
180 root, err := x509.ParseCertificate(der)
181 if err != nil {
182 t.Fatal(err)
183 }
184 pool := x509.NewCertPool()
185 pool.AddCert(root)
186 return &e2eCA{root: root, key: key, pool: pool}
187}
188
189// publish mints a certificate for host and writes it to control/certs/<host>,
190// in autocert's own cache-entry shape and through putCert — byte-for-byte what
191// the issuer's publishingCache leaves in the bucket. Every edge in the
192// deployment then serves it; none of them issued it.
193func (ca *e2eCA) publish(t *testing.T, s release.Store, host string) *x509.Certificate {
194 t.Helper()
195 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
196 if err != nil {
197 t.Fatal(err)
198 }
199 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 96))
200 if err != nil {
201 t.Fatal(err)
202 }
203 tmpl := &x509.Certificate{
204 SerialNumber: serial,
205 Subject: pkix.Name{CommonName: host},
206 DNSNames: []string{host},
207 NotBefore: time.Now().Add(-time.Hour),
208 NotAfter: time.Now().Add(90 * 24 * time.Hour),
209 KeyUsage: x509.KeyUsageDigitalSignature,
210 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
211 }
212 der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.root, &key.PublicKey, ca.key)
213 if err != nil {
214 t.Fatal(err)
215 }
216 leaf, err := x509.ParseCertificate(der)
217 if err != nil {
218 t.Fatal(err)
219 }
220 raw, err := encodeWildcardPEM(&tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key)
221 if err != nil {
222 t.Fatal(err)
223 }
224 putBucket(t, s, host, raw)
225 return leaf
226}
227
228// ---- one edge ----------------------------------------------------------------
229
230// backhaulEdge is one box: a real Edge over its OWN handle on the shared
231// bucket, with the full handshake chain and the full :80 chain composed the way
232// RunWithActivatorReady composes them, on real loopback listeners.
233type backhaulEdge struct {
234 edge *Edge
235 store release.Store
236 homes *homeMap
237 mirror *certMirror
238 mgr *autocert.Manager
239 // local is the RAW autocert cache — this box's own disk, which starts
240 // empty and is only ever written by the mirror.
241 local autocert.Cache
242
243 tlsAddr, plainAddr string
244 // calls counts requests that reached this box's 443 door, refusals
245 // included: "exactly one dial" and "never dialled at all" are both this
246 // number moving, or not, on the OTHER box.
247 calls atomic.Int64
248 // plainCalls is the same for :80.
249 plainCalls atomic.Int64
250 // peerAs, when set to an address, is what this box's 443 door reports as
251 // the TCP peer of every request (the port is kept). A sending box dials
252 // from its own public address in production; on loopback every box is
253 // 127.0.0.1, so a test that needs the peer to be that address says so
254 // here. Unset, the peer is the real one.
255 peerAs atomic.Value // string
256
257 client *http.Client
258}
259
260// newBackhaulEdge builds one box. routes are host → upstream address; anything
261// not listed is a host this box genuinely does not serve.
262func newBackhaulEdge(t *testing.T, bucketDir, box string, ca *e2eCA, from *net.TCPAddr, routes map[string]string) *backhaulEdge {
263 t.Helper()
264 be := &backhaulEdge{store: release.NewDirStore(bucketDir)}
265 reg := openReg(t)
266 for host, addr := range routes {
267 if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: addr, Status: "ready"}); err != nil {
268 t.Fatalf("upsert %s: %v", host, err)
269 }
270 }
271 e, err := New(reg, nil)
272 if err != nil {
273 t.Fatalf("New: %v", err)
274 }
275 t.Cleanup(e.probers.Stop)
276 be.edge = e
277 e.staticStore = be.store
278 WithBoxLabel(box)(e)
279 // Not the issuer, and never able to become one: the gate is shut and the
280 // manager below points at a dead directory. Everything this box serves on
281 // 443 therefore came out of the bucket.
282 WithIssuerGate(func() (context.Context, bool) { return nil, false })(e)
283
284 // The RAW cache, kept before the publishing wrapper goes on (edge.go).
285 be.local = autocert.DirCache(t.TempDir())
286 cache := e.armCertIssuance(be.local)
287 be.mirror = e.newCertMirror(be.local)
288 if be.mirror == nil {
289 t.Fatal("a store-backed edge must have a mirror to serve from")
290 }
291 be.mgr = offlineManager(t, cache, e.issuerHostPolicy())
292 tlsCfg := be.mgr.TLSConfig()
293 tlsCfg.GetCertificate = be.mirror.wrapGetCertificate(tlsCfg.GetCertificate)
294
295 be.homes = e.newHomeMap()
296 if be.homes == nil {
297 t.Fatal("a store-backed edge must have a home map")
298 }
299 e.homes.Store(be.homes)
300
301 // 443: the edge itself, counted.
302 https := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303 be.calls.Add(1)
304 if p, _ := be.peerAs.Load().(string); p != "" {
305 _, port, _ := net.SplitHostPort(r.RemoteAddr)
306 r.RemoteAddr = net.JoinHostPort(p, port)
307 }
308 e.ServeHTTP(w, r)
309 }))
310 https.TLS = tlsCfg
311 https.StartTLS()
312 t.Cleanup(https.Close)
313 be.tlsAddr = https.Listener.Addr().String()
314
315 // :80, outermost first, exactly as RunWithActivatorReady composes it: the
316 // mirror's challenge handler, then autocert's, then the redirect fallback.
317 // Composed ONCE, before anything listens, for the reason edge.go composes
318 // it there — HTTPHandler is what arms autocert's HTTP-01 path.
319 acmeAndFallback := be.mirror.challengeHandler(be.mgr.HTTPHandler(e.httpFallbackHandler()))
320 plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
321 be.plainCalls.Add(1)
322 acmeAndFallback.ServeHTTP(w, r)
323 }))
324 t.Cleanup(plain.Close)
325 be.plainAddr = plain.Listener.Addr().String()
326
327 be.client = e2eClientFor(be, ca, from)
328 return be
329}
330
331// e2eClientFor is a client that resolves every hostname to ONE box's two
332// listeners — the deployment's DNS, in other words, pointed wherever the test
333// wants this request to land. https connections are handshaken here rather than
334// by the transport so that the client verifies the edge's certificate against
335// the test CA, by name, exactly as a browser would: a mirrored certificate that
336// did not actually cover the host would fail here and not quietly downstream.
337func e2eClientFor(be *backhaulEdge, ca *e2eCA, from *net.TCPAddr) *http.Client {
338 dialer := &net.Dialer{Timeout: 10 * time.Second, LocalAddr: from}
339 return &http.Client{
340 Timeout: 30 * time.Second,
341 Transport: &http.Transport{
342 DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
343 return dialer.DialContext(ctx, network, be.plainAddr)
344 },
345 DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
346 name, _, err := net.SplitHostPort(addr)
347 if err != nil {
348 return nil, err
349 }
350 raw, err := dialer.DialContext(ctx, network, be.tlsAddr)
351 if err != nil {
352 return nil, err
353 }
354 conn := tls.Client(raw, &tls.Config{ServerName: name, RootCAs: ca.pool, MinVersion: tls.VersionTLS12})
355 if err := conn.HandshakeContext(ctx); err != nil {
356 raw.Close()
357 return nil, err
358 }
359 return conn, nil
360 },
361 },
362 }
363}
364
365// ---- the scene ---------------------------------------------------------------
366
367type backhaulScene struct {
368 ca *e2eCA
369 seed release.Store // the housekeeper's handle: what writes the control plane
370 a, b *backhaulEdge
371 key []byte // the deployment's mesh key, as both edges mirror it
372 // leaf is the certificate the bucket carries per host.
373 leaf map[string]*x509.Certificate
374 // clientIP is the source address the test client dials from, or "" if the
375 // platform would not give us a second loopback address.
376 clientIP string
377 deadEIP string
378}
379
380func newBackhaulScene(t *testing.T) *backhaulScene {
381 t.Helper()
382 e2eQuietLogs(t)
383 bucket := t.TempDir()
384 s := &backhaulScene{
385 seed: release.NewDirStore(bucket),
386 ca: newE2ECA(t),
387 leaf: map[string]*x509.Certificate{},
388 deadEIP: e2eDeadEIP(t),
389 }
390
391 // The housekeeper's one-time mint (Task 2), and the certs Part 2's issuer
392 // publishes. Both edges read them; neither wrote them.
393 if err := MintMeshKey(context.Background(), s.seed); err != nil {
394 t.Fatalf("mint mesh key: %v", err)
395 }
396 for _, host := range []string{e2eHomedHost, e2eMovedHost, e2eDeadHost, e2eUnhomedHost, e2eSenderHost} {
397 s.leaf[host] = s.ca.publish(t, s.seed, host)
398 }
399
400 from := e2eClientAddr(t)
401 if from != nil {
402 s.clientIP = from.IP.String()
403 }
404 s.a = newBackhaulEdge(t, bucket, "annie", s.ca, from, map[string]string{
405 e2eHomedHost: e2eApp(t, e2eHomeMarker).Listener.Addr().String(),
406 })
407 s.b = newBackhaulEdge(t, bucket, "betty", s.ca, from, map[string]string{
408 e2eSenderHost: e2eApp(t, e2eSenderMarker).Listener.Addr().String(),
409 })
410
411 // The dial seams, armed for the test's lifetime: 443 is an ephemeral port
412 // here, and the home edge's certificate is signed by the test CA rather
413 // than a public one. Nothing about verification is weakened — the hop still
414 // checks the name and the chain (see backhaulRootCAs' own doc).
415 //
416 // One consequence of backhaulPort being a single global: every box in this
417 // scene is dialled on the SAME port, so a box's EIP is the whole of its
418 // identity on the wire. That is why the dead box below gets an address of
419 // its own rather than a port of its own.
420 eip, port, err := net.SplitHostPort(s.a.tlsAddr)
421 if err != nil {
422 t.Fatalf("split home edge address: %v", err)
423 }
424 oldPort, oldCAs := backhaulPort, backhaulRootCAs
425 backhaulPort, backhaulRootCAs = port, s.ca.pool
426 t.Cleanup(func() { backhaulPort, backhaulRootCAs = oldPort, oldCAs })
427
428 // The per-box edges files, Task 1's schema, written straight into the store
429 // (the publishing loop is internal/host's and is not in this process).
430 bEIP, _, err := net.SplitHostPort(s.b.tlsAddr)
431 if err != nil {
432 t.Fatalf("split sending edge address: %v", err)
433 }
434 s.publishEdgesFile(t, "annie", eip, e2eHomedHost, e2eMovedHost)
435 s.publishEdgesFile(t, "betty", bEIP, e2eSenderHost)
436 s.publishEdgesFile(t, "carol", s.deadEIP, e2eDeadHost)
437 return s
438}
439
440// publishEdgesFile writes one box's control/edges/<box>.json — homedOn's shape,
441// written through the seed handle so neither edge is the publisher of its own
442// map (which is the production writer arrangement: the box's own host loop
443// writes it, and every edge only ever READS the prefix).
444func (s *backhaulScene) publishEdgesFile(t *testing.T, box, eip string, hosts ...string) {
445 t.Helper()
446 entries := make([]string, 0, len(hosts))
447 for _, h := range hosts {
448 entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\"}", h))
449 }
450 putJSON(t, s.seed, "control/edges/"+box+".json", fmt.Sprintf(
451 `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ",")))
452}
453
454// converge drives every mirror and every map ONE poll forward. Explicit, never
455// waited for: the loops (certMirror.run, homeMap.run) are 30s tickers, and a
456// test that slept for them would be testing the clock.
457func (s *backhaulScene) converge(t *testing.T) {
458 t.Helper()
459 for _, be := range []*backhaulEdge{s.a, s.b} {
460 if err := be.mirror.Refresh(context.Background()); err != nil {
461 t.Fatalf("cert mirror refresh: %v", err)
462 }
463 refreshHomeMap(t, be.homes)
464 }
465 key, next := s.a.homes.meshKeys()
466 if len(key) == 0 || len(next) != 0 {
467 t.Fatalf("mesh keys mirrored as current=%d next=%d bytes, want one current key", len(key), len(next))
468 }
469 s.key = key
470}
471
472// get drives one client request at one box and reads the whole body. follow
473// says whether redirects are followed — the :80 leg wants both answers.
474func (s *backhaulScene) get(t *testing.T, be *backhaulEdge, url string, headers map[string]string, follow bool) (*http.Response, string) {
475 t.Helper()
476 resp, err := s.try(be, url, headers, follow)
477 if err != nil {
478 t.Fatalf("GET %s: %v", url, err)
479 }
480 body, err := io.ReadAll(resp.Body)
481 resp.Body.Close()
482 if err != nil {
483 t.Fatalf("read body of %s: %v", url, err)
484 }
485 return resp, string(body)
486}
487
488func (s *backhaulScene) try(be *backhaulEdge, url string, headers map[string]string, follow bool) (*http.Response, error) {
489 req, err := http.NewRequest(http.MethodGet, url, nil)
490 if err != nil {
491 return nil, err
492 }
493 for k, v := range headers {
494 req.Header.Set(k, v)
495 }
496 client := be.client
497 if !follow {
498 c := *be.client
499 c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
500 client = &c
501 }
502 return client.Do(req)
503}
504
505// e2eClientAddr is the source address the test client dials FROM: a second
506// loopback address, so the client is distinguishable ON THE WIRE from the
507// sending edge, which dials the home edge from the default one. That is what
508// turns "X-Forwarded-For names the client, not the edge that forwarded it" into
509// an assertion rather than a coincidence of both being 127.0.0.1.
510//
511// nil on a platform that will not hand out a second loopback address; the
512// chain-LENGTH assertions still hold there, and the identity ones are skipped
513// explicitly rather than silently passing.
514func e2eClientAddr(t *testing.T) *net.TCPAddr {
515 t.Helper()
516 ln, err := net.Listen("tcp", "127.0.0.2:0")
517 if err != nil {
518 return nil
519 }
520 ln.Close()
521 return &net.TCPAddr{IP: net.ParseIP("127.0.0.2")}
522}
523
524// e2eDeadEIP is an address nothing listens on, for the poisoned-map case: a
525// loopback one when the platform allows it, so the dial is REFUSED immediately
526// rather than hanging. The documentation address is the fallback, and costs the
527// backhaul dial timeout instead — the same 502, more slowly.
528func e2eDeadEIP(t *testing.T) string {
529 t.Helper()
530 if ln, err := net.Listen("tcp", "127.0.0.9:0"); err == nil {
531 ln.Close()
532 return "127.0.0.9"
533 }
534 return "192.0.2.1" // TEST-NET-1, RFC 5737
535}
536
537// ---- the scenario ------------------------------------------------------------
538
539func TestBackhaulE2EAcrossTwoEdges(t *testing.T) {
540 s := newBackhaulScene(t)
541
542 // Before either mirror has polled, edge B holds nothing for the homed host:
543 // empty mirror, empty local cache, autocert gated shut. The handshake must
544 // FAIL — otherwise every success below could be autocert's own doing, or a
545 // certificate that was on B's disk all along.
546 if _, err := s.try(s.b, "https://"+e2eHomedHost+"/", nil, true); err == nil {
547 t.Fatal("the sending edge completed a handshake for a certificate no box has published to it yet")
548 }
549 s.converge(t)
550
551 t.Run("P1 the sending edge answers a hello for a host it does not route", func(t *testing.T) {
552 // The premise, asserted rather than assumed: B has no route for this
553 // host, will not issue for it, and its own manager refuses it outright.
554 if _, ok := s.b.edge.lookupUpstream(e2eHomedHost); ok {
555 t.Fatal("test bug: the sending edge must not route the host it is supposed to backhaul")
556 }
557 if s.b.edge.Allowed(e2eHomedHost) {
558 t.Fatal("test bug: the sending edge must not claim the host for issuance either")
559 }
560 if _, err := s.b.mgr.GetCertificate(ecdsaHello(e2eHomedHost)); err == nil {
561 t.Fatal("the sending edge's own autocert manager must refuse a host it does not serve")
562 }
563
564 // And yet a real client, verifying against the CA by name, completes the
565 // handshake — with the certificate the BUCKET carries. This is the
566 // invariant the feature rides on: without it a remote-homed host dies at
567 // the TLS layer on every edge but its home, and nothing downstream ever
568 // runs.
569 resp, _ := s.get(t, s.b, "https://"+e2eHomedHost+"/", nil, true)
570 if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
571 t.Fatal("no peer certificate — the handshake did not happen")
572 }
573 if got := resp.TLS.PeerCertificates[0]; !got.Equal(s.leaf[e2eHomedHost]) {
574 t.Fatalf("the sending edge served a certificate that is not the mirrored one (subject %q)", got.Subject.CommonName)
575 }
576 if c := s.b.mirror.certificate(e2eHomedHost, false); c == nil {
577 t.Fatal("the mirror map holds no certificate for the host — it cannot have been the source")
578 }
579 })
580
581 t.Run("the marker body backhauls, the chain names the client, one dial", func(t *testing.T) {
582 home, sender := s.a.calls.Load(), s.b.calls.Load()
583 resp, body := s.get(t, s.b, "https://"+e2eHomedHost+"/deep?q=1", nil, true)
584 if resp.StatusCode != http.StatusOK {
585 t.Fatalf("status = %d, want 200 — a remote-homed host is served through its home edge", resp.StatusCode)
586 }
587 seen := e2eParseApp(t, body)
588 if seen.marker != e2eHomeMarker {
589 t.Fatalf("marker = %q, want the HOME edge's app (%q)", seen.marker, e2eHomeMarker)
590 }
591 if got := s.a.calls.Load() - home; got != 1 {
592 t.Fatalf("the home edge saw %d request(s), want exactly one — one hop, ever", got)
593 }
594 if got := s.b.calls.Load() - sender; got != 1 {
595 t.Fatalf("the sending edge saw %d request(s) for one client request", got)
596 }
597 // The chain the app sees is a single element and it is the CLIENT's
598 // address — not the sending edge's, which is a different loopback
599 // address here precisely so the two can be told apart. Every consumer in
600 // this deployment takes the last element as the caller
601 // (internal/console/authcli.go's clientIP), so an appended edge would
602 // bill, rate-limit and log a real visitor as infrastructure.
603 if strings.Contains(seen.xff, "+") || strings.Contains(seen.xff, ",") {
604 t.Fatalf("X-Forwarded-For = %q, want a single-element chain", seen.xff)
605 }
606 if s.clientIP != "" && seen.xff != s.clientIP {
607 t.Fatalf("X-Forwarded-For = %q, want the client's own address %q", seen.xff, s.clientIP)
608 }
609 if seen.xfh != e2eHomedHost {
610 t.Fatalf("X-Forwarded-Host = %q, want the host the client asked for", seen.xfh)
611 }
612 // The hop vocabulary is the edges' own and stops at the home edge.
613 if seen.hop != "" || seen.auth != "" {
614 t.Fatalf("hop=%q auth=%q reached the tenant's app", seen.hop, seen.auth)
615 }
616 })
617
618 t.Run("the sending edge still serves its own routes without dialling", func(t *testing.T) {
619 home := s.a.calls.Load()
620 resp, body := s.get(t, s.b, "https://"+e2eSenderHost+"/", nil, true)
621 if resp.StatusCode != http.StatusOK {
622 t.Fatalf("status = %d, want 200", resp.StatusCode)
623 }
624 if seen := e2eParseApp(t, body); seen.marker != e2eSenderMarker {
625 t.Fatalf("marker = %q, want the sending edge's OWN app", seen.marker)
626 }
627 if got := s.a.calls.Load() - home; got != 0 {
628 t.Fatalf("the home edge saw %d request(s) for a host the sending edge serves itself", got)
629 }
630 })
631
632 t.Run("the :80 leg redirects, and the redirect it hands out backhauls", func(t *testing.T) {
633 // A first-time visitor typing a bare hostname lands on whichever edge
634 // DNS picked, on :80. Task 5's fix: a remote-homed host is routable, so
635 // it gets the ordinary redirect rather than a 404 for an app that is up.
636 plain := s.b.plainCalls.Load()
637 resp, _ := s.get(t, s.b, "http://"+e2eHomedHost+"/deep?q=1", nil, false)
638 if resp.StatusCode != http.StatusFound {
639 t.Fatalf("status = %d, want 302 — :80 must send a remote-homed host to 443", resp.StatusCode)
640 }
641 if got := s.b.plainCalls.Load() - plain; got != 1 {
642 t.Fatalf("the sending edge's :80 door saw %d request(s), want one — this leg must be plain HTTP", got)
643 }
644 if got, want := resp.Header.Get("Location"), "https://"+e2eHomedHost+"/deep?q=1"; got != want {
645 t.Fatalf("Location = %q, want %q", got, want)
646 }
647 // A host nothing in the deployment homes keeps the unchanged 404 on the
648 // same handler, so the redirect is a routing answer and not a blanket one.
649 if got, _ := s.get(t, s.b, "http://"+e2eUnhomedHost+"/", nil, false); got.StatusCode != http.StatusNotFound {
650 t.Fatalf("status = %d, want the unchanged 404 for a host nothing homes", got.StatusCode)
651 }
652
653 // Both legs, one client call: the visitor follows the redirect and ends
654 // on the home edge's app, having crossed :80 here and 443 there.
655 home := s.a.calls.Load()
656 final, body := s.get(t, s.b, "http://"+e2eHomedHost+"/deep?q=1", nil, true)
657 if final.StatusCode != http.StatusOK {
658 t.Fatalf("status = %d after following the redirect, want 200", final.StatusCode)
659 }
660 if final.Request.URL.Scheme != "https" {
661 t.Fatalf("the followed request ended on %q, want https", final.Request.URL.Scheme)
662 }
663 if final.TLS == nil {
664 t.Fatal("the second leg was not TLS")
665 }
666 if seen := e2eParseApp(t, body); seen.marker != e2eHomeMarker {
667 t.Fatalf("marker = %q, want the home edge's app at the end of both legs", seen.marker)
668 }
669 if got := s.a.calls.Load() - home; got != 1 {
670 t.Fatalf("the home edge saw %d request(s) across the two legs, want one", got)
671 }
672 })
673
674 t.Run("a poisoned map is a bad gateway, and never a loop", func(t *testing.T) {
675 if _, box, ok := s.b.homes.Home(e2eDeadHost); !ok || box != "carol" {
676 t.Fatalf("test bug: the map must name the dead box for %s (got %q, %v)", e2eDeadHost, box, ok)
677 }
678 home, sender := s.a.calls.Load(), s.b.calls.Load()
679 resp, body := s.get(t, s.b, "https://"+e2eDeadHost+"/", nil, true)
680 if resp.StatusCode != http.StatusBadGateway {
681 t.Fatalf("status = %d, want 502 — this edge believed a specific box was there", resp.StatusCode)
682 }
683 if got := resp.Header.Get("Retry-After"); got != "" {
684 t.Fatalf("Retry-After = %q — that answer is reserved for KNOWING nobody is home", got)
685 }
686 if strings.Contains(body, e2eHomeMarker) {
687 t.Fatal("a dead home somehow served the app")
688 }
689 if got := s.b.calls.Load() - sender; got != 1 {
690 t.Fatalf("the sending edge handled %d request(s) for one client request — a failed dial must not be retried around the fleet", got)
691 }
692 if got := s.a.calls.Load() - home; got != 0 {
693 t.Fatalf("the live home edge saw %d request(s) for a host it does not home — a failed dial must not scan for a second target", got)
694 }
695 })
696
697 t.Run("a stale map gets the home edge's 503, and the sender does not re-dial", func(t *testing.T) {
698 // The map still homes this host on A; A no longer routes it. That is
699 // what one publish cycle of a placement move looks like from here.
700 if _, box, ok := s.b.homes.Home(e2eMovedHost); !ok || box != "annie" {
701 t.Fatalf("test bug: the map must still name the home edge for %s (got %q, %v)", e2eMovedHost, box, ok)
702 }
703 if _, ok := s.a.edge.lookupUpstream(e2eMovedHost); ok {
704 t.Fatal("test bug: the home edge must NOT serve the moved host")
705 }
706 home, sender := s.a.calls.Load(), s.b.calls.Load()
707 resp, body := s.get(t, s.b, "https://"+e2eMovedHost+"/", nil, true)
708 if resp.StatusCode != http.StatusServiceUnavailable {
709 t.Fatalf("status = %d, want the home edge's 503", resp.StatusCode)
710 }
711 if got := resp.Header.Get("Retry-After"); got != "2" {
712 t.Fatalf("Retry-After = %q, want 2 — converge heals this within a publish cycle", got)
713 }
714 // The refusal reaches the client as the home edge wrote it, and says
715 // nothing about where the host went.
716 if strings.Contains(body, e2eMovedHost) || strings.Contains(body, "annie") || strings.Contains(body, "betty") {
717 t.Fatalf("body = %q — the refusal must not name the host or any box", body)
718 }
719 if got := s.a.calls.Load() - home; got != 1 {
720 t.Fatalf("the home edge saw %d request(s), want exactly one — a 503 is an answer, not a reason to try again", got)
721 }
722 if got := s.b.calls.Load() - sender; got != 1 {
723 t.Fatalf("the sending edge handled %d request(s) for one client request", got)
724 }
725 })
726
727 t.Run("a tampered hop reaches the home edge as an ordinary client", func(t *testing.T) {
728 // Tampering happens on the wire INTO the home edge: a genuine signature
729 // this deployment's own key produced, with one hex nibble flipped, plus
730 // the forwarding claims a tamperer would want believed.
731 tampered := []byte(signHop(s.key, e2eHomedHost, time.Now()))
732 tampered[len(tampered)-1] ^= 'f' ^ '0'
733 headers := map[string]string{
734 hopHeader: "1",
735 hopAuthHeader: string(tampered),
736 "X-Forwarded-For": "203.0.113.9",
737 "X-Forwarded-Host": "evil.test",
738 "X-Forwarded-Proto": "https",
739 }
740
741 resp, body := s.get(t, s.a, "https://"+e2eHomedHost+"/", headers, true)
742 if resp.StatusCode != http.StatusOK {
743 t.Fatalf("status = %d, want 200 — a failed verification is 'you are an ordinary client', not a rejection", resp.StatusCode)
744 }
745 seen := e2eParseApp(t, body)
746 if seen.marker != e2eHomeMarker {
747 t.Fatalf("marker = %q, want the home edge's app", seen.marker)
748 }
749 if strings.Contains(seen.xff, "203.0.113.9") {
750 t.Fatalf("X-Forwarded-For = %q — a chain nothing vouched for must be discarded", seen.xff)
751 }
752 if s.clientIP != "" && seen.xff != s.clientIP {
753 t.Fatalf("X-Forwarded-For = %q, want the tamperer's own address %q", seen.xff, s.clientIP)
754 }
755 if seen.xfh == "evil.test" {
756 t.Fatalf("X-Forwarded-Host = %q — an unverified sender does not get to state it", seen.xfh)
757 }
758 if seen.hop != "" || seen.auth != "" {
759 t.Fatalf("hop=%q auth=%q reached the app — hop vocabulary is stripped either way", seen.hop, seen.auth)
760 }
761
762 // And no hop SEMANTICS at all: for a host the home edge does not serve,
763 // a tampered hop gets the ordinary unknown-host answer. The same request
764 // with an intact signature gets the stale-map refusal instead — the
765 // signature is the whole of the difference, which is what makes the 503
766 // unreachable to a stranger who cannot mint one.
767 bad := map[string]string{hopHeader: "1", hopAuthHeader: string(tampered)}
768 if got, _ := s.get(t, s.a, "https://"+e2eUnhomedHost+"/", bad, true); got.StatusCode != http.StatusNotFound {
769 t.Fatalf("status = %d, want the unchanged 404 — a tampered hop earns no hop semantics", got.StatusCode)
770 } else if ra := got.Header.Get("Retry-After"); ra != "" {
771 t.Fatalf("Retry-After = %q — a stranger must not be told to come back", ra)
772 }
773 good := map[string]string{hopHeader: "1", hopAuthHeader: signHop(s.key, e2eUnhomedHost, time.Now())}
774 if got, _ := s.get(t, s.a, "https://"+e2eUnhomedHost+"/", good, true); got.StatusCode != http.StatusServiceUnavailable {
775 t.Fatalf("status = %d, want 503 — an INTACT signature is what buys the stale-map refusal", got.StatusCode)
776 }
777 })
778}
779
780// ---- attribution: the hop annotation, end to end -----------------------------
781
782// The attribution half of the egress ruling, on the real two-edge scene: a
783// backhauled request is metered ONCE, on the box that holds the route's
784// authoritative row, and that one row also carries the hop annotation. The
785// sending edge records nothing at all for the host — it has no route row and
786// therefore no account or app to bill, which is exactly why the annotation
787// lives here and not there.
788func TestHopServedRequestMetersOnceWithHopCounters(t *testing.T) {
789 s := newBackhaulScene(t)
790 s.converge(t)
791
792 home, sender := NewUsageMeter(), NewUsageMeter()
793 s.a.edge.EnableUsage(home)
794 s.b.edge.EnableUsage(sender)
795
796 resp, body := s.get(t, s.b, "https://"+e2eHomedHost+"/deep?q=1", nil, true)
797 if resp.StatusCode != http.StatusOK {
798 t.Fatalf("status = %d, want 200", resp.StatusCode)
799 }
800 if seen := e2eParseApp(t, body); seen.marker != e2eHomeMarker {
801 t.Fatalf("marker = %q, want the home edge's app", seen.marker)
802 }
803
804 rows := home.Drain()
805 row, ok := rows[e2eHomedHost]
806 if !ok {
807 t.Fatalf("the home edge metered nothing for %s: %+v", e2eHomedHost, rows)
808 }
809 if row.Requests != 1 {
810 t.Fatalf("row = %+v, want exactly one request — a hop is ONE metered exchange, not two", row)
811 }
812 if row.BytesOut == 0 {
813 t.Fatalf("row = %+v, want the wire bytes the home edge sent", row)
814 }
815 if row.HopRequests != 1 || row.HopBytesOut != row.BytesOut {
816 t.Fatalf("row = %+v, want the whole exchange annotated as hopped (hop_bytes_out == bytes_out)", row)
817 }
818 if row.HopBytesOut > row.BytesOut || row.HopRequests > row.Requests {
819 t.Fatalf("row = %+v — the hop counters are a subset of the totals", row)
820 }
821
822 // The sending edge is meterless for this host BY DESIGN: no route row, no
823 // identity, so anything it wrote would be unattributed — and a second
824 // Record for one exchange is the double count this arrangement refuses.
825 if got, ok := sender.Drain()[e2eHomedHost]; ok {
826 t.Fatalf("the sending edge metered %+v for a host it has no route for", got)
827 }
828}
829
830// ---- the single-box regression pin -------------------------------------------
831
832// Everything above is dormant on a deployment with one platform box, and this
833// is the pin that says so: an edge whose control/edges/ prefix holds only its
834// OWN file serves its own routes exactly as it always has, answers the
835// unchanged 404 for everything else, and never opens a connection to anything.
836//
837// The witness is a zero-call dialer — a listener that counts every connection
838// made to the backhaul port — with the single box's own edges file naming the
839// listener's ADDRESS as its EIP. So the one failure this pin exists to catch,
840// an edge that offers itself as a backhaul target, lands ON the counter rather
841// than merely failing somewhere else.
842//
843// The file lists a host the box does not route, which is what makes the counter
844// a real witness rather than a formality: a route removed between two publishes
845// leaves exactly that state for one cycle, it is the ONLY shape in which a
846// single box could dial at all (a host it serves is served locally, before the
847// map is ever consulted), and dropping own-box exclusion turns it into a dial to
848// this box's own front door.
849func TestBackhaulE2ESingleBoxNeverDials(t *testing.T) {
850 e2eQuietLogs(t)
851 bucket := t.TempDir()
852 seed := release.NewDirStore(bucket)
853 if err := MintMeshKey(context.Background(), seed); err != nil {
854 t.Fatalf("mint mesh key: %v", err)
855 }
856
857 const soloHost = "solo.backhaul.test"
858 // A host this box's own file still claims but its registry no longer
859 // serves — one publish cycle after a route was removed.
860 const soloStaleHost = "stale.backhaul.test"
861 app := e2eApp(t, e2eHomeMarker)
862 reg := openReg(t)
863 if err := reg.Upsert(registry.Route{Host: soloHost, Kind: "service", Addr: app.Listener.Addr().String(), Status: "ready"}); err != nil {
864 t.Fatalf("upsert: %v", err)
865 }
866 e, err := New(reg, nil)
867 if err != nil {
868 t.Fatalf("New: %v", err)
869 }
870 t.Cleanup(e.probers.Stop)
871 e.staticStore = release.NewDirStore(bucket)
872 WithBoxLabel("solo")(e)
873
874 eip, dials := e2eDialCounter(t)
875 putJSON(t, seed, "control/edges/solo.json", fmt.Sprintf(
876 `{"v":1,"box":"solo","eip":%q,"hosts":{%q:{"role":"home"},%q:{"role":"home"}}}`,
877 eip, soloHost, soloStaleHost))
878
879 m := e.newHomeMap()
880 refreshHomeMap(t, m)
881 e.homes.Store(m)
882 for _, host := range []string{soloHost, soloStaleHost} {
883 if _, _, ok := m.Home(host); ok {
884 t.Fatalf("%s: the only box in a deployment must never be offered as a backhaul target for its own host", host)
885 }
886 }
887
888 if resp := hopRequest(t, e, soloHost, nil); resp.StatusCode != http.StatusOK {
889 t.Fatalf("status = %d, want 200 — a single box serves its own routes as before", resp.StatusCode)
890 } else if seen := e2eParseApp(t, readBody(t, resp)); seen.marker != e2eHomeMarker {
891 t.Fatalf("marker = %q, want the box's own app", seen.marker)
892 }
893 for _, host := range []string{soloStaleHost, "nothing.backhaul.test"} {
894 if resp := hopRequest(t, e, host, nil); resp.StatusCode != http.StatusNotFound {
895 t.Fatalf("%s: status = %d, want the unchanged 404", host, resp.StatusCode)
896 }
897 }
898 if n := dials.Load(); n != 0 {
899 t.Fatalf("%d backhaul dial(s) from a single-box deployment, want none", n)
900 }
901 n := 0
902 e.backhaulTransports.Range(func(_, _ any) bool { n++; return true })
903 if n != 0 {
904 t.Fatalf("%d backhaul transport(s) built, want none — nothing was ever a target", n)
905 }
906}
907
908// e2eDialCounter is the zero-call witness: a listener on the backhaul port that
909// counts connections and answers nothing. It returns the address a dial would
910// have to name to reach it, and arms backhaulPort for the test's lifetime.
911func e2eDialCounter(t *testing.T) (string, *atomic.Int64) {
912 t.Helper()
913 ln, err := net.Listen("tcp", "127.0.0.1:0")
914 if err != nil {
915 t.Fatalf("listen: %v", err)
916 }
917 t.Cleanup(func() { ln.Close() })
918 var n atomic.Int64
919 go func() {
920 for {
921 conn, err := ln.Accept()
922 if err != nil {
923 return
924 }
925 n.Add(1)
926 conn.Close()
927 }
928 }()
929 host, port, err := net.SplitHostPort(ln.Addr().String())
930 if err != nil {
931 t.Fatalf("split: %v", err)
932 }
933 old := backhaulPort
934 backhaulPort = port
935 t.Cleanup(func() { backhaulPort = old })
936 return host, &n
937}
938