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
3import (
4 "bufio"
5 "context"
6 "crypto/tls"
7 "crypto/x509"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 "net/http"
13 "net/http/httptest"
14 "os"
15 "strings"
16 "sync"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/platform/internal/edgeproof"
21 "github.com/carlosframework/platform/internal/lbhealth"
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26// putJSON writes a control-plane object body verbatim, replacing whatever is
27// there. release.Store.Put with an empty ifMatch is create-only, so a test
28// that rewrites a key (the last-good and stale-file cases below) has to carry
29// the etag — the same putReplacing shape certsync.go uses in production.
30func putJSON(t *testing.T, s release.Store, key, body string) {
31 t.Helper()
32 _, etag, err := s.Get(context.Background(), key)
33 switch {
34 case errors.Is(err, os.ErrNotExist):
35 etag = ""
36 case err != nil:
37 t.Fatalf("get %s: %v", key, err)
38 }
39 if _, err := s.Put(context.Background(), key, []byte(body), etag); err != nil {
40 t.Fatalf("put %s: %v", key, err)
41 }
42}
43
44func refreshHomeMap(t *testing.T, m *homeMap) {
45 t.Helper()
46 if err := m.Refresh(context.Background()); err != nil {
47 t.Fatalf("refresh: %v", err)
48 }
49}
50
51func TestHomeMapFoldsAndExcludesOwnBox(t *testing.T) {
52 s := meshTestStore(t)
53 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"a.test":{"role":"home"}}}`)
54 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
55 m := newHomeMap(s, "annie")
56 refreshHomeMap(t, m)
57 if eip, box, ok := m.Home("b.test"); !ok || eip != "2.2.2.2" || box != "betty" {
58 t.Fatalf("b.test home = %s/%s/%v", eip, box, ok)
59 }
60 if _, _, ok := m.Home("a.test"); ok {
61 t.Fatal("own-box hosts must not offer a backhaul target")
62 }
63}
64
65// Task 1's writer publishes registry row hosts VERBATIM, catch-all rows
66// included, so the reader owes lookupUpstream's one-label wildcard rule: an
67// exact match wins, then "*.<parent>" — and only ONE label, so a two-label
68// host misses exactly as it does locally.
69func TestHomeMapAppliesOneLabelWildcardRule(t *testing.T) {
70 s := meshTestStore(t)
71 putJSON(t, s, "control/edges/betty.json",
72 `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.apps.test":{"role":"home"},"exact.apps.test":{"role":"home"}}}`)
73 m := newHomeMap(s, "annie")
74 refreshHomeMap(t, m)
75
76 // Exact form.
77 if eip, _, ok := m.Home("exact.apps.test"); !ok || eip != "2.2.2.2" {
78 t.Fatalf("exact host = %s/%v, want the home edge", eip, ok)
79 }
80 // Wildcard form: one label under the catch-all parent.
81 if eip, box, ok := m.Home("paul.apps.test"); !ok || eip != "2.2.2.2" || box != "betty" {
82 t.Fatalf("one-label wildcard host = %s/%s/%v", eip, box, ok)
83 }
84 // Two labels: a miss here exactly as in lookupUpstream, which composes
85 // "*.b.apps.test" and finds nothing.
86 if _, _, ok := m.Home("a.b.apps.test"); ok {
87 t.Fatal("a two-label host must not match a one-label wildcard")
88 }
89 // The wildcard key itself is not a request host and must not resolve as
90 // one via the fallback ("*.apps.test" composes "*.apps.test" again).
91 if _, _, ok := m.Home("apps.test"); ok {
92 t.Fatal("the catch-all parent must not match its own wildcard")
93 }
94}
95
96// The role object is read-local anticipation (Task 1). Only "home" routes;
97// anything else is a role this build does not implement, and an unknown role
98// must neither route nor fail the poll.
99func TestHomeMapIgnoresUnknownRoles(t *testing.T) {
100 s := meshTestStore(t)
101 putJSON(t, s, "control/edges/betty.json",
102 `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"read.test":{"role":"read"},"home.test":{"role":"home"},"blank.test":{}}}`)
103 m := newHomeMap(s, "annie")
104 refreshHomeMap(t, m) // an unknown role is not an error
105 if _, _, ok := m.Home("read.test"); ok {
106 t.Fatal("role:\"read\" is not a home — it must not offer a backhaul target")
107 }
108 if _, _, ok := m.Home("blank.test"); ok {
109 t.Fatal("an absent role is not a home")
110 }
111 if _, _, ok := m.Home("home.test"); !ok {
112 t.Fatal("role:\"home\" must still route alongside roles this build ignores")
113 }
114}
115
116// A box with no discoverable public IP publishes its hosts list with eip:""
117// (Task 1's DR-runbook case). There is nothing to dial, so it must never be
118// offered as a target — a "" EIP would otherwise become a dial to port 443 of
119// nothing.
120func TestHomeMapSkipsBoxesWithNoEIP(t *testing.T) {
121 s := meshTestStore(t)
122 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"","hosts":{"b.test":{"role":"home"}}}`)
123 m := newHomeMap(s, "annie")
124 refreshHomeMap(t, m)
125 if _, _, ok := m.Home("b.test"); ok {
126 t.Fatal("a box with no EIP has no dial address and must not be a backhaul target")
127 }
128}
129
130// Two boxes claiming the same host is a placement bug, but every edge must
131// still pick the SAME one — a coin flip per box would split traffic across a
132// move instead of converging on it.
133func TestHomeMapPicksTheSameHomeOnADuplicateClaim(t *testing.T) {
134 s := meshTestStore(t)
135 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
136 putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"b.test":{"role":"home"}}}`)
137 for i := 0; i < 5; i++ {
138 m := newHomeMap(s, "annie")
139 refreshHomeMap(t, m)
140 if _, box, _ := m.Home("b.test"); box != "betty" {
141 t.Fatalf("duplicate claim resolved to %q, want the deterministic first (betty)", box)
142 }
143 }
144}
145
146// A box that is retired (its file deleted) must LEAVE the map — the fold is a
147// full rebuild from each list, never a merge into the previous one.
148func TestHomeMapForgetsADeletedEdgesFile(t *testing.T) {
149 s := meshTestStore(t)
150 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
151 m := newHomeMap(s, "annie")
152 refreshHomeMap(t, m)
153 if _, _, ok := m.Home("b.test"); !ok {
154 t.Fatal("precondition: b.test should be homed on betty")
155 }
156 if err := s.Delete(context.Background(), "control/edges/betty.json"); err != nil {
157 t.Fatalf("delete: %v", err)
158 }
159 refreshHomeMap(t, m)
160 if _, _, ok := m.Home("b.test"); ok {
161 t.Fatal("a retired box's hosts must leave the map on the next poll")
162 }
163}
164
165// One unreadable object is not a bad poll: the previous copy of that box's
166// file carries forward and every other box's file still lands, exactly as the
167// cert mirror does it.
168func TestHomeMapCarriesForwardOneUnreadableFile(t *testing.T) {
169 inner := meshTestStore(t)
170 s := &oneKeyFailingStore{Store: inner}
171 putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
172 m := newHomeMap(s, "annie")
173 refreshHomeMap(t, m)
174
175 // betty's file goes unreadable; carol appears in the same poll.
176 putJSON(t, inner, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"c.test":{"role":"home"}}}`)
177 s.failKey("control/edges/betty.json")
178 refreshHomeMap(t, m)
179
180 if eip, _, ok := m.Home("b.test"); !ok || eip != "2.2.2.2" {
181 t.Fatalf("b.test = %s/%v — the previous copy of an unreadable file must carry forward", eip, ok)
182 }
183 if _, _, ok := m.Home("c.test"); !ok {
184 t.Fatal("one bad object must not discard the rest of the rebuild")
185 }
186}
187
188// A corrupt body is the same class of fault as an unreadable one: carry the
189// last good parse forward rather than dropping the box off the map.
190func TestHomeMapCarriesForwardACorruptFile(t *testing.T) {
191 s := meshTestStore(t)
192 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
193 m := newHomeMap(s, "annie")
194 refreshHomeMap(t, m)
195 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty",`)
196 refreshHomeMap(t, m)
197 if eip, _, ok := m.Home("b.test"); !ok || eip != "2.2.2.2" {
198 t.Fatalf("b.test = %s/%v — a corrupt file must leave the last good parse standing", eip, ok)
199 }
200}
201
202// A failed List is a bucket fault: last-good stands and the error is returned
203// for the loop to log. An empty map here would silently stop every backhaul
204// in the deployment on one blip.
205func TestHomeMapKeepsLastGoodWhenTheBucketIsDown(t *testing.T) {
206 inner := meshTestStore(t)
207 s := &listFailingStore{Store: inner}
208 putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
209 m := newHomeMap(s, "annie")
210 refreshHomeMap(t, m)
211
212 s.setFail(true)
213 if err := m.Refresh(context.Background()); err == nil {
214 t.Fatal("a failed List must be reported, not swallowed")
215 }
216 if _, _, ok := m.Home("b.test"); !ok {
217 t.Fatal("last-good must stand through a bucket blip")
218 }
219}
220
221// An edge with no box identity of its own cannot exclude itself, so it must
222// never offer a target at all — fail closed rather than backhaul to itself.
223func TestHomeMapWithoutASelfLabelNeverOffersATarget(t *testing.T) {
224 s := meshTestStore(t)
225 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
226 m := newHomeMap(s, "")
227 refreshHomeMap(t, m)
228 if _, _, ok := m.Home("b.test"); ok {
229 t.Fatal("an edge that does not know its own box label must not backhaul")
230 }
231}
232
233// A nil map (no deployment store, so no mesh at all) answers like an empty
234// one rather than panicking on the request path.
235func TestNilHomeMapIsInert(t *testing.T) {
236 var m *homeMap
237 if _, _, ok := m.Home("b.test"); ok {
238 t.Fatal("a nil home map must offer nothing")
239 }
240 if m.KnownHost("b.test") {
241 t.Fatal("a nil home map knows no host")
242 }
243 if cur, next := m.meshKeys(); cur != nil || next != nil {
244 t.Fatal("a nil home map holds no mesh keys")
245 }
246}
247
248// KnownHost is the membership question the three cert gates ask through
249// Edge.deploymentHost (#277), and it is EXACT-only where Home is not. Home's
250// one-label fallback is what makes an invented subdomain under a remote
251// catch-all DIALABLE; letting the same fallback into a cert gate would let
252// that invented label drive an ACME order and spend a read-through budget,
253// which is precisely what PR #180's Allowed rule refuses. The two answers
254// diverging on the same map, in the same test, is the pin.
255func TestKnownHostExactOnly(t *testing.T) {
256 s := meshTestStore(t)
257 putJSON(t, s, "control/edges/annie.json",
258 `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"own.test":{"role":"home"}}}`)
259 putJSON(t, s, "control/edges/betty.json",
260 `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.apps.test":{"role":"home"},"exact.apps.test":{"role":"home"}}}`)
261 m := newHomeMap(s, "annie")
262 refreshHomeMap(t, m)
263
264 if !m.KnownHost("exact.apps.test") {
265 t.Fatal("an exactly-named remote-homed host is the whole point of the predicate")
266 }
267 // Precondition: the fallback Home applies is live on this very map — so
268 // the refusal below is KnownHost's own, not an empty map's.
269 if _, _, ok := m.Home("invented.apps.test"); !ok {
270 t.Fatal("test bug: the one-label wildcard rule did not apply")
271 }
272 if m.KnownHost("invented.apps.test") {
273 t.Fatal("a label under a mirrored catch-all must never be admitted — that is #180's rule")
274 }
275 if m.KnownHost("*.apps.test") {
276 t.Fatal("the catch-all key itself is not a certifiable name")
277 }
278 if m.KnownHost("stranger.example") {
279 t.Fatal("a host no box in the deployment homes is a stranger")
280 }
281 // Own-box rows are excluded by the fold and stay excluded here. Harmless:
282 // deploymentHost's other half (Allowed) is what admits this box's routes.
283 if m.KnownHost("own.test") {
284 t.Fatal("the fold excludes this box's own file, so KnownHost inherits that exclusion")
285 }
286 // Nothing polled yet: no membership, and no panic on the gate's path.
287 if newHomeMap(s, "annie").KnownHost("exact.apps.test") {
288 t.Fatal("a map that has never polled knows no host")
289 }
290}
291
292// A box publishing eip:"" is in the fold's no-dial exclusion, and KnownHost
293// inherits it: there is no DNS answer pointing at such a box, so a validation
294// for its hosts has nowhere legitimate to land.
295func TestKnownHostSkipsBoxesWithNoEIP(t *testing.T) {
296 s := meshTestStore(t)
297 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"","hosts":{"b.test":{"role":"home"}}}`)
298 m := newHomeMap(s, "annie")
299 refreshHomeMap(t, m)
300 if m.KnownHost("b.test") {
301 t.Fatal("a box with no public IP offers no validation target")
302 }
303}
304
305// ---- hop acceptance ---------------------------------------------------------
306
307// forwardEcho is an upstream that reports back exactly the trust-bearing
308// headers it was handed, so a test can assert both what survived and what was
309// stripped on the way in.
310func forwardEcho(t *testing.T) *httptest.Server {
311 t.Helper()
312 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
313 fmt.Fprintf(w, "xff=%s|xfh=%s|hop=%s|auth=%s",
314 r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Host"),
315 r.Header.Get(hopHeader), r.Header.Get(hopAuthHeader))
316 }))
317 t.Cleanup(ts.Close)
318 return ts
319}
320
321// hopEdge builds an edge serving hosts from one echo backend, with a mesh key
322// minted and a home map armed over the same store — the receiving box of a
323// backhaul hop.
324func hopEdge(t *testing.T, self string, hosts ...string) (*Edge, *homeMap, []byte) {
325 t.Helper()
326 s := meshTestStore(t)
327 if err := mintMeshKey(context.Background(), s); err != nil {
328 t.Fatalf("mint: %v", err)
329 }
330 backend := forwardEcho(t)
331 reg := openReg(t)
332 for _, h := range hosts {
333 if err := reg.Upsert(registry.Route{Host: h, Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
334 t.Fatalf("upsert %s: %v", h, err)
335 }
336 }
337 e, err := New(reg, nil)
338 if err != nil {
339 t.Fatalf("New: %v", err)
340 }
341 t.Cleanup(e.probers.Stop)
342 m := newHomeMap(s, self)
343 refreshHomeMap(t, m)
344 e.homes.Store(m)
345 key, _ := m.meshKeys()
346 if len(key) == 0 {
347 t.Fatal("precondition: the mesh key must be mirrored")
348 }
349 return e, m, key
350}
351
352// hopRequest drives one request at the edge with the given headers.
353func hopRequest(t *testing.T, e *Edge, host string, headers map[string]string) *http.Response {
354 t.Helper()
355 srv := httptest.NewServer(e)
356 t.Cleanup(srv.Close)
357 req, err := http.NewRequest("GET", srv.URL+"/", nil)
358 if err != nil {
359 t.Fatalf("new request: %v", err)
360 }
361 req.Host = host
362 for k, v := range headers {
363 req.Header.Set(k, v)
364 }
365 resp, err := http.DefaultClient.Do(req)
366 if err != nil {
367 t.Fatalf("do: %v", err)
368 }
369 t.Cleanup(func() { resp.Body.Close() })
370 return resp
371}
372
373func readBody(t *testing.T, resp *http.Response) string {
374 t.Helper()
375 b, err := io.ReadAll(resp.Body)
376 if err != nil {
377 t.Fatalf("read body: %v", err)
378 }
379 return string(b)
380}
381
382// A verified hop is served locally, and the chain the sending edge appended
383// is the one thing an ordinary client is never trusted for.
384func TestVerifiedHopIsServedLocallyAndHonoursTheInboundXFF(t *testing.T) {
385 e, _, key := hopEdge(t, "annie", "app.test")
386 resp := hopRequest(t, e, "app.test", map[string]string{
387 hopHeader: "1",
388 hopAuthHeader: signHop(key, "app.test", time.Now()),
389 "X-Forwarded-For": "203.0.113.9",
390 })
391 if resp.StatusCode != http.StatusOK {
392 t.Fatalf("status = %d, want 200", resp.StatusCode)
393 }
394 body := readBody(t, resp)
395 // Verbatim, with NO element appended: the last element must still be the
396 // client. internal/console/authcli.go's clientIP takes the last element as
397 // the caller and every tenant framework's rightmost-public heuristic does
398 // the same, so appending this edge's peer (the sibling edge) would make a
399 // backhauled request look as though infrastructure called it.
400 if !strings.HasPrefix(body, "xff=203.0.113.9|") {
401 t.Fatalf("body = %q — a verified hop's inbound XFF must pass through verbatim, last element still the client", body)
402 }
403 // The hop's own trust headers are the edge's vocabulary, never the app's.
404 if !strings.Contains(body, "|hop=|auth=") {
405 t.Fatalf("body = %q — the hop headers must not reach the app", body)
406 }
407}
408
409// The MAC binds the CONCRETE request host, and a catch-all route serves it
410// through lookupUpstream's wildcard fallback — the two halves of carry C1 on
411// the receiving side.
412func TestVerifiedHopServesAWildcardRoutedHost(t *testing.T) {
413 e, _, key := hopEdge(t, "annie", "*.apps.test")
414 resp := hopRequest(t, e, "paul.apps.test", map[string]string{
415 hopHeader: "1",
416 hopAuthHeader: signHop(key, "paul.apps.test", time.Now()),
417 })
418 if resp.StatusCode != http.StatusOK {
419 t.Fatalf("status = %d, want 200 — a catch-all route serves a hop like any other client", resp.StatusCode)
420 }
421}
422
423// The stale-map refusal: the sender's map named us for a host we do not
424// serve. 503 + Retry-After: 2, and structurally never a re-forward — the home
425// map here is POISONED to name another box for exactly that host, so anything
426// that consulted it would leave through a dial instead.
427func TestVerifiedHopForAnUnservedHostRefusesWithoutForwarding(t *testing.T) {
428 e, m, key := hopEdge(t, "annie", "app.test")
429 putJSON(t, m.s, "control/edges/betty.json",
430 `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`)
431 refreshHomeMap(t, m)
432 if _, _, ok := m.Home("moved.test"); !ok {
433 t.Fatal("precondition: the map must name a remote home for moved.test")
434 }
435
436 resp := hopRequest(t, e, "moved.test", map[string]string{
437 hopHeader: "1",
438 hopAuthHeader: signHop(key, "moved.test", time.Now()),
439 })
440 if resp.StatusCode != http.StatusServiceUnavailable {
441 t.Fatalf("status = %d, want 503 for a verified hop naming a host we do not serve", resp.StatusCode)
442 }
443 if got := resp.Header.Get("Retry-After"); got != "2" {
444 t.Fatalf("Retry-After = %q, want 2", got)
445 }
446 if body := readBody(t, resp); strings.Contains(body, "moved.test") || strings.Contains(body, "betty") {
447 t.Fatalf("body = %q — the refusal must not name the host or where it went", body)
448 }
449}
450
451// Task 1: the refusal used to be silent. A home move stuck mid-way can leave
452// a sender retrying into this refusal for minutes (the loop cadences,
453// edgesfile.go/homemap.go's own docs), so it now logs — but first-and-
454// every-tenth, logPartialPoll's own cadence, or a stuck move would fill the
455// journal with one line per hop.
456func TestStaleHopRefusalLogsFirstAndEveryTenth(t *testing.T) {
457 e, m, key := hopEdge(t, "annie", "app.test")
458 putJSON(t, m.s, "control/edges/betty.json",
459 `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`)
460 refreshHomeMap(t, m)
461
462 buf := captureLog(t)
463 for i := 0; i < 10; i++ {
464 resp := hopRequest(t, e, "moved.test", map[string]string{
465 hopHeader: "1",
466 hopAuthHeader: signHop(key, "moved.test", time.Now()),
467 })
468 if resp.StatusCode != http.StatusServiceUnavailable {
469 t.Fatalf("refusal #%d: status = %d, want 503", i+1, resp.StatusCode)
470 }
471 }
472 got := buf.String()
473 if n := strings.Count(got, "moved.test"); n != 2 {
474 t.Fatalf("log mentions the stale host %d time(s), want exactly 2 (occurrence #1 and #10, not one line per refusal): %s", n, got)
475 }
476 if !strings.Contains(got, "(#1)") {
477 t.Fatalf("log output = %q, want the first occurrence logged immediately", got)
478 }
479 if !strings.Contains(got, "(#10)") {
480 t.Fatalf("log output = %q, want the tenth occurrence logged", got)
481 }
482 if !strings.Contains(got, "box annie") {
483 t.Fatalf("log output = %q, want this box's own label named in the line", got)
484 }
485}
486
487// Review catch on this task: host is client-controlled (a wildcard-homed
488// route lets a sending box mint a signed hop for any invented subdomain,
489// staleHopN's own doc), so staleHopN must not grow without bound. Drives
490// logStaleHop directly rather than through HTTP, so cap-plus-a-few distinct
491// hosts stays a cheap, focused test of the invariant: the map's key count
492// stops at cap+1 (every tracked key plus the one shared overflow bucket),
493// and hosts past the cap accumulate under that bucket instead of growing
494// the map further.
495func TestStaleHopNIsCappedWithAnOverflowBucket(t *testing.T) {
496 _, m, _ := hopEdge(t, "annie", "local.test")
497
498 for i := 0; i < staleHopKeyCap; i++ {
499 m.logStaleHop(fmt.Sprintf("host-%d.example.com", i))
500 }
501 if got := len(m.staleHopN); got != staleHopKeyCap {
502 t.Fatalf("staleHopN has %d key(s) at the cap, want exactly %d", got, staleHopKeyCap)
503 }
504
505 // Three MORE distinct hosts, past the cap.
506 for i := 0; i < 3; i++ {
507 m.logStaleHop(fmt.Sprintf("overflow-%d.example.com", i))
508 }
509 if got, want := len(m.staleHopN), staleHopKeyCap+1; got != want {
510 t.Fatalf("staleHopN has %d key(s) past the cap, want %d (cap distinct keys + one overflow bucket, not %d+3)", got, want, staleHopKeyCap)
511 }
512 const overflowKey = "annie (other)"
513 if n := m.staleHopN[overflowKey]; n != 3 {
514 t.Fatalf("overflow key %q count = %d, want 3 — each new host past the cap must fold into the shared bucket, not mint its own", overflowKey, n)
515 }
516
517 // A key that was already tracked BEFORE the cap keeps its own identity
518 // and cadence — the cap only refuses NEW keys, never demotes an
519 // existing one.
520 m.logStaleHop("host-0.example.com")
521 if n := m.staleHopN["annie host-0.example.com"]; n != 2 {
522 t.Fatalf("a pre-cap key's count = %d, want 2 — it must keep incrementing under its own key, not fold into overflow", n)
523 }
524}
525
526// Verification failure is not an error path: it is "you are an ordinary
527// client", with every forwarding claim you made discarded.
528func TestUnverifiedHopIsServedAsAnOrdinaryClientWithForwardedHeadersStripped(t *testing.T) {
529 e, _, key := hopEdge(t, "annie", "app.test")
530 tampered := []byte(signHop(key, "app.test", time.Now()))
531 tampered[len(tampered)-1] ^= 'f' ^ '0' // flip the last hex nibble
532
533 resp := hopRequest(t, e, "app.test", map[string]string{
534 hopHeader: "1",
535 hopAuthHeader: string(tampered),
536 "X-Forwarded-For": "203.0.113.9",
537 "X-Forwarded-Host": "evil.test",
538 "X-Forwarded-Proto": "https",
539 })
540 if resp.StatusCode != http.StatusOK {
541 t.Fatalf("status = %d, want 200 — an unverified hop is an ordinary client, not a rejection", resp.StatusCode)
542 }
543 body := readBody(t, resp)
544 if strings.Contains(body, "203.0.113.9") {
545 t.Fatalf("body = %q — an unverified sender's X-Forwarded-For must not be honoured", body)
546 }
547 if strings.Contains(body, "evil.test") {
548 t.Fatalf("body = %q — an unverified sender's X-Forwarded-Host must not be honoured", body)
549 }
550 if !strings.Contains(body, "|hop=|auth=") {
551 t.Fatalf("body = %q — the hop headers must be stripped either way", body)
552 }
553}
554
555// The strip is at the header level, not just a proxy side effect: every route
556// class behind the edge (static trees, the status host, the activator) reads
557// the same request.
558func TestAcceptHopStripsEveryForwardedHeaderOnFailedVerification(t *testing.T) {
559 e, _, _ := hopEdge(t, "annie", "app.test")
560 r := httptest.NewRequest("GET", "http://app.test/", nil)
561 r.Header.Set(hopHeader, "1")
562 r.Header.Set(hopAuthHeader, "0:deadbeef")
563 r.Header.Set("Forwarded", "for=203.0.113.9")
564 r.Header.Set("X-Forwarded-For", "203.0.113.9")
565 r.Header.Set("X-Forwarded-Host", "evil.test")
566 r.Header.Set("X-Forwarded-Proto", "https")
567
568 r, verified := e.acceptHop(r, "app.test")
569 if verified {
570 t.Fatal("a garbage auth header must not verify")
571 }
572 for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", hopHeader, hopAuthHeader} {
573 if got := r.Header.Get(h); got != "" {
574 t.Fatalf("%s survived an unverified hop as %q", h, got)
575 }
576 }
577 if hopVerified(r.Context()) {
578 t.Fatal("an unverified request must not carry the hop mark")
579 }
580
581 // The half-formed case: a signature with no X-Carlos-Hop beside it. It is
582 // not a hop and must not be treated as one — but the header is still this
583 // edge's vocabulary, and leaving it on the request would let an app that
584 // reflects its request headers launder it back onto the wire (the
585 // scrubInboundPurgeHeader precedent, purge_header.go).
586 stray := httptest.NewRequest("GET", "http://app.test/", nil)
587 stray.Header.Set(hopAuthHeader, "0:deadbeef")
588 stray, verified = e.acceptHop(stray, "app.test")
589 if verified {
590 t.Fatal("an auth header with no hop marker is not a hop")
591 }
592 if got := stray.Header.Get(hopAuthHeader); got != "" {
593 t.Fatalf("%s survived without a hop marker as %q — hop vocabulary must never reach the app", hopAuthHeader, got)
594 }
595}
596
597// The MAC is over the host, so a signature minted for one hop cannot be
598// replayed against another — and the timestamp bounds it in time.
599func TestHopVerificationIsBoundToTheHostAndTheClock(t *testing.T) {
600 e, _, key := hopEdge(t, "annie", "app.test", "other.test")
601
602 wrongHost := hopRequest(t, e, "app.test", map[string]string{
603 hopHeader: "1",
604 hopAuthHeader: signHop(key, "other.test", time.Now()),
605 "X-Forwarded-For": "203.0.113.9",
606 })
607 if body := readBody(t, wrongHost); strings.Contains(body, "203.0.113.9") {
608 t.Fatalf("body = %q — a signature minted for another host must not verify", body)
609 }
610
611 stale := hopRequest(t, e, "app.test", map[string]string{
612 hopHeader: "1",
613 hopAuthHeader: signHop(key, "app.test", time.Now().Add(-2*hopSkew)),
614 "X-Forwarded-For": "203.0.113.9",
615 })
616 if body := readBody(t, stale); strings.Contains(body, "203.0.113.9") {
617 t.Fatalf("body = %q — a signature outside the skew bound must not verify", body)
618 }
619}
620
621// Fail closed: with no mesh key mirrored (a brand new deployment, or a box
622// whose bucket is unreachable at boot) nothing verifies.
623func TestHopWithoutAMirroredMeshKeyIsAnOrdinaryClient(t *testing.T) {
624 e, _, key := hopEdge(t, "annie", "app.test")
625 e.homes.Store(nil) // no mesh at all — the no-deployment-store case
626 resp := hopRequest(t, e, "app.test", map[string]string{
627 hopHeader: "1",
628 hopAuthHeader: signHop(key, "app.test", time.Now()),
629 "X-Forwarded-For": "203.0.113.9",
630 })
631 if resp.StatusCode != http.StatusOK {
632 t.Fatalf("status = %d, want 200", resp.StatusCode)
633 }
634 if body := readBody(t, resp); strings.Contains(body, "203.0.113.9") {
635 t.Fatalf("body = %q — with no key to verify against, the sender is an ordinary client", body)
636 }
637}
638
639// An unserved host that NO box homes keeps today's answer exactly: 404, with
640// no Retry-After, and nothing said about the rest of the deployment.
641//
642// The home map here is deliberately non-empty and deliberately about a
643// DIFFERENT host: the map is consulted (Task 4's backhaul) and misses, which
644// is the only route to this answer now that a homed host is dialled instead.
645// The stale-map 503 stays reachable only by a party holding the mesh key, so
646// the two answers still cannot be told apart by a stranger.
647func TestUnknownHostWithNoHomeIsStillThe404(t *testing.T) {
648 e, m, _ := hopEdge(t, "annie", "app.test")
649 putJSON(t, m.s, "control/edges/betty.json",
650 `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`)
651 refreshHomeMap(t, m)
652 resp := hopRequest(t, e, "nowhere.test", nil)
653 if resp.StatusCode != http.StatusNotFound {
654 t.Fatalf("status = %d, want the unchanged 404 for a host nothing in the deployment homes", resp.StatusCode)
655 }
656 if got := resp.Header.Get("Retry-After"); got != "" {
657 t.Fatalf("Retry-After = %q — a stranger must not be told to come back", got)
658 }
659}
660
661// Regression pin: an ordinary client's forged X-Forwarded-For is no more
662// trusted after this change than before it.
663func TestOrdinaryClientForgedXFFIsStillDiscarded(t *testing.T) {
664 e, _, _ := hopEdge(t, "annie", "app.test")
665 resp := hopRequest(t, e, "app.test", map[string]string{"X-Forwarded-For": "203.0.113.9"})
666 if resp.StatusCode != http.StatusOK {
667 t.Fatalf("status = %d, want 200", resp.StatusCode)
668 }
669 if body := readBody(t, resp); strings.Contains(body, "203.0.113.9") {
670 t.Fatalf("body = %q — a client-supplied XFF is never honoured", body)
671 }
672}
673
674// ---- backhaul dialing (the sending half) ------------------------------------
675
676// homeEdgeSeen is what a fake home edge was handed on the wire.
677type homeEdgeSeen struct {
678 n int
679 sni, host, hop, auth string
680 proof string
681 xff []string
682}
683
684type homeEdgeRecord struct {
685 mu sync.Mutex
686 seen homeEdgeSeen
687}
688
689func (h *homeEdgeRecord) get() homeEdgeSeen {
690 h.mu.Lock()
691 defer h.mu.Unlock()
692 return h.seen
693}
694
695const homeEdgeBody = "served by the home edge"
696
697// fakeHomeEdge stands in for the box that homes a host: a TLS server that
698// records the ClientHello's SNI and the trust-bearing headers of whatever
699// arrives, then runs h (a plain body writer unless the test needs a protocol
700// switch).
701//
702// It also arms the two dial seams for the test's lifetime — backhaulPort, so
703// the dial reaches an ephemeral httptest port instead of 443, and
704// backhaulRootCAs, so this server's self-signed certificate is trusted the way
705// a real home edge's publicly-trusted one is. Nothing about verification is
706// weakened: httptest's certificate names example.com, so every test below asks
707// for exactly that host and a name mismatch would fail the handshake here just
708// as it would in production.
709func fakeHomeEdge(t *testing.T, h http.Handler) (*homeEdgeRecord, string) {
710 t.Helper()
711 rec := &homeEdgeRecord{}
712 if h == nil {
713 h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, homeEdgeBody) })
714 }
715 ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
716 rec.mu.Lock()
717 rec.seen.n++
718 rec.seen.host = r.Host
719 rec.seen.hop = r.Header.Get(hopHeader)
720 rec.seen.auth = r.Header.Get(hopAuthHeader)
721 rec.seen.xff = r.Header.Values("X-Forwarded-For")
722 rec.seen.proof = r.Header.Get(edgeproof.Header)
723 rec.mu.Unlock()
724 h.ServeHTTP(w, r)
725 }))
726 ts.TLS = &tls.Config{GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
727 rec.mu.Lock()
728 rec.seen.sni = chi.ServerName
729 rec.mu.Unlock()
730 return nil, nil // the server's own config, unchanged — this hook only watches
731 }}
732 ts.StartTLS()
733 t.Cleanup(ts.Close)
734
735 eip, port, err := net.SplitHostPort(ts.Listener.Addr().String())
736 if err != nil {
737 t.Fatalf("split home edge addr: %v", err)
738 }
739 pool := x509.NewCertPool()
740 pool.AddCert(ts.Certificate())
741 oldPort, oldCAs := backhaulPort, backhaulRootCAs
742 backhaulPort, backhaulRootCAs = port, pool
743 t.Cleanup(func() { backhaulPort, backhaulRootCAs = oldPort, oldCAs })
744 return rec, eip
745}
746
747// homedOn publishes one box's edges file naming hosts as its own, and lands it
748// in the map — the second box a single-box deployment never has.
749func homedOn(t *testing.T, m *homeMap, box, eip string, hosts ...string) {
750 t.Helper()
751 entries := make([]string, 0, len(hosts))
752 for _, h := range hosts {
753 entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\"}", h))
754 }
755 putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf(
756 `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ",")))
757 refreshHomeMap(t, m)
758}
759
760// The whole of the sending half in one request: a host this box does not serve
761// but another box homes reaches that box over real TLS, with the SNI it will
762// need to present a certificate for, the real Host header, a hop signature the
763// home edge can verify, and an X-Forwarded-For that names only the client.
764func TestBackhaulDialsTheHomeEdgeOverVerifiedTLS(t *testing.T) {
765 e, m, key := hopEdge(t, "annie", "local.test")
766 rec, eip := fakeHomeEdge(t, nil)
767 homedOn(t, m, "betty", eip, "example.com")
768
769 resp := hopRequest(t, e, "example.com", nil)
770 if resp.StatusCode != http.StatusOK {
771 t.Fatalf("status = %d, want 200 — a remote-homed host must be served through its home edge", resp.StatusCode)
772 }
773 if body := readBody(t, resp); body != homeEdgeBody {
774 t.Fatalf("body = %q, want %q", body, homeEdgeBody)
775 }
776 seen := rec.get()
777 if seen.n != 1 {
778 t.Fatalf("home edge saw %d request(s), want exactly one — one hop, ever", seen.n)
779 }
780 if seen.sni != "example.com" {
781 t.Fatalf("SNI = %q, want the request host — the home edge answers with the cert for the host being asked for", seen.sni)
782 }
783 if seen.host != "example.com" {
784 t.Fatalf("Host = %q, want the real hostname — the home edge routes on it", seen.host)
785 }
786 if seen.hop != "1" {
787 t.Fatalf("%s = %q, want 1", hopHeader, seen.hop)
788 }
789 if !verifyHopEitherKey(key, nil, "example.com", seen.auth, time.Now()) {
790 t.Fatalf("%s = %q did not verify under the deployment's mesh key", hopAuthHeader, seen.auth)
791 }
792 // One element, the client. Neither this edge's own address nor the home EIP
793 // may be appended: the home edge restores this chain verbatim on a verified
794 // hop, and every consumer takes the LAST element as the caller
795 // (setForwarded, homemap.go).
796 if len(seen.xff) != 1 || strings.Contains(seen.xff[0], ",") {
797 t.Fatalf("X-Forwarded-For = %q, want a single-element chain naming only the client", seen.xff)
798 }
799}
800
801// Precedence, first rule: a host served HERE is served here, whatever the home
802// map says about it. The map is poisoned to name a reachable home for exactly
803// that host, so a lookup in the wrong order would show up as a dial.
804func TestLocalHitIgnoresTheHomeMap(t *testing.T) {
805 e, m, _ := hopEdge(t, "annie", "example.com")
806 rec, eip := fakeHomeEdge(t, nil)
807 homedOn(t, m, "betty", eip, "example.com")
808
809 resp := hopRequest(t, e, "example.com", nil)
810 if resp.StatusCode != http.StatusOK {
811 t.Fatalf("status = %d, want 200", resp.StatusCode)
812 }
813 if body := readBody(t, resp); !strings.HasPrefix(body, "xff=") {
814 t.Fatalf("body = %q, want the LOCAL backend's answer — a local hit must never consult the home map", body)
815 }
816 if n := rec.get().n; n != 0 {
817 t.Fatalf("home edge saw %d request(s), want none", n)
818 }
819}
820
821// One hop, ever: a request that ARRIVED as a verified hop is refused, never
822// forwarded onward, even when the map offers a live target for it. The fake
823// home edge is the zero-call witness — a re-forward would answer 200 here.
824func TestArrivedHopNeverDialsTheHomeEdge(t *testing.T) {
825 e, m, key := hopEdge(t, "annie", "local.test")
826 rec, eip := fakeHomeEdge(t, nil)
827 homedOn(t, m, "betty", eip, "example.com")
828
829 resp := hopRequest(t, e, "example.com", map[string]string{
830 hopHeader: "1",
831 hopAuthHeader: signHop(key, "example.com", time.Now()),
832 })
833 if resp.StatusCode != http.StatusServiceUnavailable {
834 t.Fatalf("status = %d, want 503 — an arrived hop is refused, not re-forwarded", resp.StatusCode)
835 }
836 if got := resp.Header.Get("Retry-After"); got != "2" {
837 t.Fatalf("Retry-After = %q, want 2", got)
838 }
839 if n := rec.get().n; n != 0 {
840 t.Fatalf("home edge saw %d request(s), want none — a hop must never become a second hop", n)
841 }
842}
843
844// The backhaul's own guard, exercised directly rather than through ServeHTTP's
845// branch above it: if this call ever moves, an arrived hop must still fail
846// closed here instead of being forwarded.
847func TestBackhaulHelperRefusesAnArrivedHopOnItsOwn(t *testing.T) {
848 e, m, _ := hopEdge(t, "annie", "local.test")
849 rec, eip := fakeHomeEdge(t, nil)
850 homedOn(t, m, "betty", eip, "example.com")
851
852 r := httptest.NewRequest("GET", "http://example.com/", nil)
853 r = r.WithContext(context.WithValue(r.Context(), hopVerifiedKey{}, true))
854 w := httptest.NewRecorder()
855 if !e.backhaul(w, r, "example.com") {
856 t.Fatal("backhaul must ANSWER an arrived hop, not fall through to the unknown-host path")
857 }
858 if w.Code != http.StatusServiceUnavailable {
859 t.Fatalf("status = %d, want the stale-map refusal", w.Code)
860 }
861 if n := rec.get().n; n != 0 {
862 t.Fatalf("home edge saw %d request(s), want none", n)
863 }
864}
865
866// Fail closed on the SENDING side too: with no mesh key mirrored there is
867// nothing to prove the hop with, and signing under an empty key would cross a
868// region only to be served as an anonymous client. The request gets today's
869// unknown-host answer instead.
870func TestBackhaulRefusesToDialWithoutAMeshKey(t *testing.T) {
871 e, m, _ := hopEdge(t, "annie", "local.test")
872 rec, eip := fakeHomeEdge(t, nil)
873 homedOn(t, m, "betty", eip, "example.com")
874 if err := m.s.Delete(context.Background(), meshKeyKey); err != nil {
875 t.Fatalf("delete mesh key: %v", err)
876 }
877 refreshHomeMap(t, m)
878 if cur, next := m.meshKeys(); len(cur) != 0 || len(next) != 0 {
879 t.Fatal("precondition: the mirror must hold no key")
880 }
881
882 resp := hopRequest(t, e, "example.com", nil)
883 if resp.StatusCode != http.StatusNotFound {
884 t.Fatalf("status = %d, want the unchanged 404 — an unprovable hop is not dialled", resp.StatusCode)
885 }
886 if n := rec.get().n; n != 0 {
887 t.Fatalf("home edge saw %d request(s), want none — never sign with an empty key", n)
888 }
889}
890
891// A stranger forging the hop vocabulary for a remote-homed host is backhauled
892// like any other client, and gains exactly nothing by having tried.
893//
894// Two halves have to hold at once and they meet only here. acceptHop deletes
895// both hop headers and — verification having failed — strips every forwarding
896// claim the forger made, so the request reaching this point is an ordinary
897// one; backhaul then mints a FRESH signature under the deployment's own key,
898// so the home edge sees a hop it can verify and an X-Forwarded-For naming the
899// forger. What the forger must NOT get is the stale-map 503 (which would tell
900// them the host lives elsewhere), a chain of their own choosing, or their
901// garbage signature carried onward under this edge's name.
902func TestForgedHopForARemoteHostIsBackhauledAsAnOrdinaryClient(t *testing.T) {
903 e, m, key := hopEdge(t, "annie", "local.test")
904 rec, eip := fakeHomeEdge(t, nil)
905 homedOn(t, m, "betty", eip, "example.com")
906
907 const garbage = "0:deadbeef"
908 resp := hopRequest(t, e, "example.com", map[string]string{
909 hopHeader: "1",
910 hopAuthHeader: garbage,
911 "X-Forwarded-For": "203.0.113.9",
912 })
913 if resp.StatusCode != http.StatusOK {
914 t.Fatalf("status = %d, want 200 — a forged hop is an ordinary client, not a refusal", resp.StatusCode)
915 }
916 if got := resp.Header.Get("Retry-After"); got != "" {
917 t.Fatalf("Retry-After = %q — the stale-map answer must stay unreachable without the mesh key", got)
918 }
919 if body := readBody(t, resp); body != homeEdgeBody {
920 t.Fatalf("body = %q, want the home edge's answer %q", body, homeEdgeBody)
921 }
922 seen := rec.get()
923 if seen.n != 1 {
924 t.Fatalf("home edge saw %d request(s), want exactly one", seen.n)
925 }
926 if seen.auth == garbage {
927 t.Fatalf("%s = %q — the forger's signature must never be forwarded under this edge's name", hopAuthHeader, seen.auth)
928 }
929 if seen.hop != "1" || !verifyHopEitherKey(key, nil, "example.com", seen.auth, time.Now()) {
930 t.Fatalf("hop=%q auth=%q — the hop must carry a FRESH signature this edge minted", seen.hop, seen.auth)
931 }
932 // The forger's claimed chain is gone and the one that arrives names the
933 // forger's own address, once. Anything else would let a stranger choose
934 // what the app (and the console's rate limiter) believes about who called.
935 if len(seen.xff) != 1 || strings.Contains(seen.xff[0], ",") {
936 t.Fatalf("X-Forwarded-For = %q, want a single-element chain", seen.xff)
937 }
938 if strings.Contains(seen.xff[0], "203.0.113.9") {
939 t.Fatalf("X-Forwarded-For = %q — a forged chain must be discarded, not carried", seen.xff)
940 }
941 if ip := net.ParseIP(seen.xff[0]); ip == nil || !ip.IsLoopback() {
942 t.Fatalf("X-Forwarded-For = %q, want the forger's own (loopback) address", seen.xff)
943 }
944}
945
946// A catch-all row in another box's edges file resolves through the same
947// one-label wildcard rule lookupUpstream uses — but the signature must be over
948// the CONCRETE request host, because that is what the home edge verifies
949// against. Signing the map KEY would produce a MAC for "*.com", which is not a
950// request host under any canonicalization.
951func TestBackhaulSignsTheConcreteHostNotTheWildcardKey(t *testing.T) {
952 e, m, key := hopEdge(t, "annie", "local.test")
953 rec, eip := fakeHomeEdge(t, nil)
954 homedOn(t, m, "betty", eip, "*.com")
955 if _, _, ok := m.Home("example.com"); !ok {
956 t.Fatal("precondition: the wildcard row must offer a home for example.com")
957 }
958
959 if resp := hopRequest(t, e, "example.com", nil); resp.StatusCode != http.StatusOK {
960 t.Fatalf("status = %d, want 200", resp.StatusCode)
961 }
962 seen := rec.get()
963 if !verifyHop(key, "example.com", seen.auth, time.Now()) {
964 t.Fatalf("%s = %q must verify against the concrete request host", hopAuthHeader, seen.auth)
965 }
966 if verifyHop(key, "*.com", seen.auth, time.Now()) {
967 t.Fatalf("%s = %q verifies against the wildcard map key — the MAC must bind the concrete host", hopAuthHeader, seen.auth)
968 }
969}
970
971// A WebSocket must compose through the hop with no code of its own: the dial
972// branch sits above ServeHTTP's cache/compression wrapping, so the client's
973// hijackable writer reaches httputil.ReverseProxy's protocol-switch path, and
974// the transport speaks HTTP/1.1 (no ForceAttemptHTTP2) so that path works.
975// Bytes must then flow both ways with no whole-body deadline cutting them off.
976func TestBackhaulCarriesAWebSocketUpgrade(t *testing.T) {
977 e, m, _ := hopEdge(t, "annie", "local.test")
978 rec, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
979 hj, ok := w.(http.Hijacker)
980 if !ok {
981 http.Error(w, "not hijackable", http.StatusInternalServerError)
982 return
983 }
984 conn, buf, err := hj.Hijack()
985 if err != nil {
986 return
987 }
988 defer conn.Close()
989 buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
990 if err := buf.Flush(); err != nil {
991 return
992 }
993 line, err := buf.ReadString('\n')
994 if err != nil {
995 return
996 }
997 buf.WriteString("home:" + line)
998 buf.Flush()
999 }))
1000 homedOn(t, m, "betty", eip, "example.com")
1001
1002 srv := httptest.NewServer(e)
1003 t.Cleanup(srv.Close)
1004 c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
1005 if err != nil {
1006 t.Fatalf("dial edge: %v", err)
1007 }
1008 defer c.Close()
1009 if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
1010 t.Fatalf("set deadline: %v", err)
1011 }
1012 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 {
1013 t.Fatalf("write handshake: %v", err)
1014 }
1015 br := bufio.NewReader(c)
1016 resp, err := http.ReadResponse(br, nil)
1017 if err != nil {
1018 t.Fatalf("read handshake response: %v", err)
1019 }
1020 if resp.StatusCode != http.StatusSwitchingProtocols {
1021 t.Fatalf("handshake status = %d, want 101 — an upgrade must switch protocols through the hop", resp.StatusCode)
1022 }
1023 if _, err := fmt.Fprint(c, "ping\n"); err != nil {
1024 t.Fatalf("write post-upgrade frame: %v", err)
1025 }
1026 line, err := br.ReadString('\n')
1027 if err != nil {
1028 t.Fatalf("read post-upgrade frame: %v", err)
1029 }
1030 if line != "home:ping\n" {
1031 t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways across the backhaul", line, "home:ping\n")
1032 }
1033 if seen := rec.get(); seen.hop != "1" {
1034 t.Fatalf("%s = %q — an upgrade is a hop like any other", hopHeader, seen.hop)
1035 }
1036}
1037
1038// A home whose edge cannot be reached is a bad gateway, not a 503: this box
1039// believed a specific box was there. The unreachable target here is the
1040// documented cert-less bootstrap window's shape too — a dial or handshake that
1041// does not complete, healed by the next mirror poll.
1042func TestBackhaulToAnUnreachableHomeIsA502(t *testing.T) {
1043 e, m, _ := hopEdge(t, "annie", "local.test")
1044 // A port nothing listens on, reached through the same seam the fake home
1045 // edge uses. 127.0.0.1 refuses immediately rather than hanging for the dial
1046 // budget.
1047 ln, err := net.Listen("tcp", "127.0.0.1:0")
1048 if err != nil {
1049 t.Fatalf("listen: %v", err)
1050 }
1051 eip, port, err := net.SplitHostPort(ln.Addr().String())
1052 if err != nil {
1053 t.Fatalf("split: %v", err)
1054 }
1055 ln.Close()
1056 old := backhaulPort
1057 backhaulPort = port
1058 t.Cleanup(func() { backhaulPort = old })
1059 homedOn(t, m, "betty", eip, "example.com")
1060
1061 resp := hopRequest(t, e, "example.com", nil)
1062 if resp.StatusCode != http.StatusBadGateway {
1063 t.Fatalf("status = %d, want 502 for a home edge that could not be reached", resp.StatusCode)
1064 }
1065 if got := resp.Header.Get("Retry-After"); got != "" {
1066 t.Fatalf("Retry-After = %q — 503 + Retry-After is reserved for knowing nobody is home", got)
1067 }
1068}
1069
1070// Task 1: an unreachable home used to log unthrottled, once per failed dial —
1071// a home box down for any real stretch would fill the journal with one line
1072// per request. It now goes through e.logThrottled, keyed "backhaul-error
1073// "+box, so a run of failures against the SAME home box produces one line
1074// (occurrence #1), not one per failure.
1075func TestBackhaulErrorHandlerRoutesThroughLogThrottled(t *testing.T) {
1076 e, m, _ := hopEdge(t, "annie", "local.test")
1077 ln, err := net.Listen("tcp", "127.0.0.1:0")
1078 if err != nil {
1079 t.Fatalf("listen: %v", err)
1080 }
1081 eip, port, err := net.SplitHostPort(ln.Addr().String())
1082 if err != nil {
1083 t.Fatalf("split: %v", err)
1084 }
1085 ln.Close()
1086 old := backhaulPort
1087 backhaulPort = port
1088 t.Cleanup(func() { backhaulPort = old })
1089 homedOn(t, m, "betty", eip, "example.com")
1090
1091 buf := captureLog(t)
1092 for i := 0; i < 3; i++ {
1093 resp := hopRequest(t, e, "example.com", nil)
1094 if resp.StatusCode != http.StatusBadGateway {
1095 t.Fatalf("attempt #%d: status = %d, want 502", i+1, resp.StatusCode)
1096 }
1097 }
1098 got := buf.String()
1099 // logThrottled's own format is "edge: <msg> (occurrence #N)" — it prints
1100 // no key of its own, so the key's effect is observed as throttling: three
1101 // failed dials against the SAME "backhaul-error "+box key must produce
1102 // exactly one logged line (#1), not three.
1103 if n := strings.Count(got, "backhaul"); n != 1 {
1104 t.Fatalf("log mentions the backhaul failure %d time(s), want exactly 1 (logThrottled's first-and-every-300th, not one line per failed dial): %s", n, got)
1105 }
1106 if !strings.Contains(got, "occurrence #1") {
1107 t.Fatalf("log output = %q, want the first occurrence logged (logThrottled's own format)", got)
1108 }
1109 if strings.Contains(got, "occurrence #2") || strings.Contains(got, "occurrence #3") {
1110 t.Fatalf("log output = %q, want the second and third failures throttled, not logged again", got)
1111 }
1112 if !strings.Contains(got, "example.com") || !strings.Contains(got, "betty") {
1113 t.Fatalf("log output = %q, want the host and the home box named", got)
1114 }
1115}
1116
1117// One transport per home BOX. The pool is what makes a backhaul cost one
1118// handshake rather than one per request; keying it per box rather than per
1119// host is what keeps a catch-all home — where every invented subdomain is a
1120// backhaul target — from growing the map without limit. Per-host identity
1121// still exists, one layer down: the outbound authority is the hostname, so SNI
1122// and pooled connections are per host inside the box's transport (the SNI
1123// assertion in TestBackhaulDialsTheHomeEdgeOverVerifiedTLS is that half).
1124func TestBackhaulTransportsAreCachedPerHomeBox(t *testing.T) {
1125 e, _, _ := hopEdge(t, "annie", "local.test")
1126 a := e.backhaulTransport("1.1.1.1")
1127 if b := e.backhaulTransport("1.1.1.1"); b != a {
1128 t.Fatal("the same home box must reuse one transport, or every request pays a fresh TLS handshake")
1129 }
1130 if b := e.backhaulTransport("2.2.2.2"); b == a {
1131 t.Fatal("a different home box must get its own transport — the dial address is baked into it")
1132 }
1133 if sn := a.TLSClientConfig.ServerName; sn != "" {
1134 t.Fatalf("ServerName = %q, want it unset — pinning one host per transport is what a wildcard home would multiply", sn)
1135 }
1136 if a.TLSClientConfig.InsecureSkipVerify {
1137 t.Fatal("the hop verifies the home edge's certificate — always")
1138 }
1139}
1140
1141// Two hosts homed on one box share that box's transport, and each still
1142// reaches the home edge under its OWN name: the shared transport is the memory
1143// property, the per-host SNI is the correctness one.
1144func TestBackhaulSharesOneTransportAcrossAWildcardHome(t *testing.T) {
1145 e, m, _ := hopEdge(t, "annie", "local.test")
1146 rec, eip := fakeHomeEdge(t, nil)
1147 homedOn(t, m, "betty", eip, "*.com")
1148
1149 if resp := hopRequest(t, e, "example.com", nil); resp.StatusCode != http.StatusOK {
1150 t.Fatalf("status = %d, want 200", resp.StatusCode)
1151 }
1152 if sni := rec.get().sni; sni != "example.com" {
1153 t.Fatalf("SNI = %q, want the concrete request host", sni)
1154 }
1155 n := 0
1156 e.backhaulTransports.Range(func(_, _ any) bool { n++; return true })
1157 if n != 1 {
1158 t.Fatalf("%d transports cached for one home box, want 1 — a catch-all home must not mint one per hostname", n)
1159 }
1160 if _, ok := e.backhaulTransports.Load(eip); !ok {
1161 t.Fatalf("the cached transport must be keyed by the home box's EIP (%s)", eip)
1162 }
1163}
1164
1165// The :80 half of the backhaul, carried from Task 4's review. A host homed on
1166// another box is answered on 443 (the hop), so plain http:// must redirect it
1167// there like any other routable host — otherwise a first-time visitor typing a
1168// bare hostname in the wrong region gets a 404 for an app that is up.
1169//
1170// The redirect grants nothing that the 443 answer does not: it is a 302 to the
1171// SAME host, never an ACME order (httpFallbackHandler's own doc records why
1172// this gate is wider than Allowed's). And a verified hop can never arrive
1173// here — the sending edge dials 443 — so there is no hop logic on this path.
1174func TestHTTPFallbackRedirectsARemoteHomedHost(t *testing.T) {
1175 e, m, _ := hopEdge(t, "annie") // serves nothing itself
1176 homedOn(t, m, "betty", "2.2.2.2", "remote.test", "*.wild.test")
1177 h := e.httpFallbackHandler()
1178
1179 for _, host := range []string{"remote.test", "paul.wild.test"} {
1180 req := httptest.NewRequest(http.MethodGet, "http://"+host+"/deep?q=1", nil)
1181 req.Host = host
1182 rec := httptest.NewRecorder()
1183 h.ServeHTTP(rec, req)
1184 if rec.Code != http.StatusFound {
1185 t.Fatalf("%s: status = %d, want 302 — the host is homed on another box and 443 backhauls it", host, rec.Code)
1186 }
1187 if loc := rec.Header().Get("Location"); loc != "https://"+host+"/deep?q=1" {
1188 t.Fatalf("%s: Location = %q", host, loc)
1189 }
1190 }
1191
1192 // A host nothing in the deployment homes keeps today's answer exactly.
1193 req := httptest.NewRequest(http.MethodGet, "http://nowhere.test/", nil)
1194 req.Host = "nowhere.test"
1195 rec := httptest.NewRecorder()
1196 h.ServeHTTP(rec, req)
1197 if rec.Code != http.StatusNotFound {
1198 t.Fatalf("nowhere.test: status = %d, want the unchanged 404", rec.Code)
1199 }
1200 if loc := rec.Header().Get("Location"); loc != "" {
1201 t.Fatalf("nowhere.test: Location = %q, want none", loc)
1202 }
1203}
1204
1205// ---- containment: the per-home-box in-flight cap --------------------------
1206
1207// backhaulCap lowers the in-flight cap for one test and restores it after —
1208// the package-seam pattern backhaulPort and homeMapInterval already follow.
1209func backhaulCap(t *testing.T, n int64) {
1210 t.Helper()
1211 old := backhaulMaxInflight.Load()
1212 backhaulMaxInflight.Store(n)
1213 t.Cleanup(func() { backhaulMaxInflight.Store(old) })
1214}
1215
1216// backhaulGetter drives requests for host at one edge over a single reused
1217// server, so several can be in flight at once (hopRequest stands a server per
1218// call, which is fine for one-shot tests and useless for a concurrency one).
1219func backhaulGetter(t *testing.T, e *Edge) func(host string) (*http.Response, error) {
1220 t.Helper()
1221 srv := httptest.NewServer(e)
1222 t.Cleanup(srv.Close)
1223 return func(host string) (*http.Response, error) {
1224 req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
1225 if err != nil {
1226 return nil, err
1227 }
1228 req.Host = host
1229 return http.DefaultClient.Do(req)
1230 }
1231}
1232
1233// The cap itself: with two hops held open toward one home box, a third is
1234// answered 503 + Retry-After: 2 HERE — no dial, no cross-region bytes — and
1235// the slot a finished hop gives back is immediately usable again.
1236//
1237// This is what bounds a scanner's forged-Host flood: egress is (concurrent
1238// streams × per-stream bandwidth), and this caps the first factor per box.
1239func TestBackhaulRefusesAtInflightCap(t *testing.T) {
1240 e, m, _ := hopEdge(t, "annie", "local.test")
1241 backhaulCap(t, 2)
1242
1243 started, hold := make(chan struct{}, 4), make(chan struct{})
1244 rec, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1245 started <- struct{}{}
1246 <-hold
1247 fmt.Fprint(w, homeEdgeBody)
1248 }))
1249 homedOn(t, m, "betty", eip, "example.com")
1250 get := backhaulGetter(t, e)
1251
1252 done := make(chan error, 2)
1253 for i := 0; i < 2; i++ {
1254 go func() {
1255 resp, err := get("example.com")
1256 if err != nil {
1257 done <- err
1258 return
1259 }
1260 defer resp.Body.Close()
1261 if resp.StatusCode != http.StatusOK {
1262 done <- fmt.Errorf("held hop: status = %d, want 200", resp.StatusCode)
1263 return
1264 }
1265 body, err := io.ReadAll(resp.Body)
1266 if err != nil {
1267 done <- err
1268 return
1269 }
1270 if string(body) != homeEdgeBody {
1271 done <- fmt.Errorf("held hop: body = %q", body)
1272 return
1273 }
1274 done <- nil
1275 }()
1276 }
1277 for i := 0; i < 2; i++ {
1278 select {
1279 case <-started:
1280 case err := <-done:
1281 t.Fatalf("a hop finished before the cap was full: %v", err)
1282 case <-time.After(15 * time.Second):
1283 t.Fatal("the two hops that fill the cap never reached the home edge")
1284 }
1285 }
1286
1287 // Both slots are held. The third request must be refused by THIS box.
1288 dialled := rec.get().n
1289 resp, err := get("example.com")
1290 if err != nil {
1291 t.Fatalf("third request: %v", err)
1292 }
1293 defer resp.Body.Close()
1294 if resp.StatusCode != http.StatusServiceUnavailable {
1295 t.Fatalf("status = %d, want 503 at the in-flight cap", resp.StatusCode)
1296 }
1297 if got := resp.Header.Get("Retry-After"); got != "2" {
1298 t.Fatalf("Retry-After = %q, want 2 — the cap clears as hops finish", got)
1299 }
1300 if got := rec.get().n; got != dialled {
1301 t.Fatalf("the home edge saw %d request(s), want %d — a refusal must cost no dial and no cross-region bytes", got, dialled)
1302 }
1303
1304 // Released slots are usable again: the cap is a bound on CONCURRENCY, not
1305 // a quota that a box burns through and never gets back.
1306 close(hold)
1307 for i := 0; i < 2; i++ {
1308 select {
1309 case err := <-done:
1310 if err != nil {
1311 t.Fatal(err)
1312 }
1313 case <-time.After(15 * time.Second):
1314 t.Fatal("a held hop never completed after its home edge was released")
1315 }
1316 }
1317 // Retried for a bounded window rather than asserted on the first attempt:
1318 // a held hop's client-side goroutine reports completion when the BODY is
1319 // read, which can be a hair before the edge's own deferred release runs.
1320 // The property under test is that the slots come back, not that they come
1321 // back within one scheduling quantum.
1322 var (
1323 after *http.Response
1324 lastErr error
1325 )
1326 deadline := time.Now().Add(10 * time.Second)
1327 for {
1328 after, lastErr = get("example.com")
1329 if lastErr != nil {
1330 t.Fatalf("fourth request: %v", lastErr)
1331 }
1332 if after.StatusCode == http.StatusOK {
1333 break
1334 }
1335 after.Body.Close()
1336 if after.StatusCode != http.StatusServiceUnavailable {
1337 t.Fatalf("status = %d after the held hops finished, want 200", after.StatusCode)
1338 }
1339 if time.Now().After(deadline) {
1340 t.Fatal("the cap never cleared after both held hops finished — a released slot was not given back")
1341 }
1342 time.Sleep(10 * time.Millisecond)
1343 }
1344 defer after.Body.Close()
1345 if got := rec.get().n; got < dialled+1 {
1346 t.Fatalf("the home edge saw %d request(s), want at least %d — the fourth request must dial normally", got, dialled+1)
1347 }
1348}
1349
1350// The cap is per HOME BOX: one saturated box must not stop this edge serving
1351// hosts homed on any other. A deployment-wide counter would let one busy (or
1352// one attacked) box take the whole mesh down.
1353func TestBackhaulInflightCapIsPerHomeBox(t *testing.T) {
1354 e, m, _ := hopEdge(t, "annie", "local.test")
1355 backhaulCap(t, 1)
1356 rec, eip := fakeHomeEdge(t, nil)
1357 homedOn(t, m, "carol", eip, "example.com")
1358 // Betty's EIP is never dialled in this test — her slot is filled directly
1359 // below, so the refusal happens before any dial. (TEST-NET-1: a dial
1360 // would hang for the whole 5s budget and be visible as a failure.)
1361 homedOn(t, m, "betty", "192.0.2.5", "saturated.test")
1362
1363 release, ok := e.backhaulAcquire("betty")
1364 if !ok {
1365 t.Fatal("the first slot toward a box must always be available")
1366 }
1367 t.Cleanup(release)
1368
1369 if resp := hopRequest(t, e, "saturated.test", nil); resp.StatusCode != http.StatusServiceUnavailable {
1370 t.Fatalf("status = %d, want 503 — betty's only slot is held", resp.StatusCode)
1371 }
1372 resp := hopRequest(t, e, "example.com", nil)
1373 if resp.StatusCode != http.StatusOK {
1374 t.Fatalf("status = %d, want 200 — carol's cap is her own", resp.StatusCode)
1375 }
1376 if body := readBody(t, resp); body != homeEdgeBody {
1377 t.Fatalf("body = %q, want the home edge's own answer", body)
1378 }
1379 if n := rec.get().n; n != 1 {
1380 t.Fatalf("carol saw %d request(s), want exactly one", n)
1381 }
1382}
1383
1384// A slot is held for the LIFE of the stream, not until the response headers
1385// arrive: a long-lived stream (a WebSocket, an SSE feed, a large download) is
1386// precisely the sustained cross-region egress the cap exists to bound, so it
1387// must count against the cap for as long as it flows.
1388func TestBackhaulSlotHeldForStreamLife(t *testing.T) {
1389 e, m, _ := hopEdge(t, "annie", "local.test")
1390 backhaulCap(t, 1)
1391
1392 streaming, finish := make(chan struct{}), make(chan struct{})
1393 _, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1394 fmt.Fprint(w, "first ")
1395 w.(http.Flusher).Flush() // headers and a first chunk are on the wire
1396 close(streaming)
1397 <-finish
1398 fmt.Fprint(w, "last")
1399 }))
1400 homedOn(t, m, "betty", eip, "example.com")
1401 get := backhaulGetter(t, e)
1402
1403 stream, err := get("example.com")
1404 if err != nil {
1405 t.Fatalf("stream request: %v", err)
1406 }
1407 defer stream.Body.Close()
1408 if stream.StatusCode != http.StatusOK {
1409 t.Fatalf("stream status = %d, want 200", stream.StatusCode)
1410 }
1411 select {
1412 case <-streaming:
1413 case <-time.After(15 * time.Second):
1414 t.Fatal("the streaming hop never reached the home edge")
1415 }
1416
1417 // Headers have arrived and the body is still flowing: the slot is held.
1418 busy, err := get("example.com")
1419 if err != nil {
1420 t.Fatalf("second request: %v", err)
1421 }
1422 defer busy.Body.Close()
1423 if busy.StatusCode != http.StatusServiceUnavailable {
1424 t.Fatalf("status = %d while a hop is mid-stream, want 503 — the slot is held for the stream's life", busy.StatusCode)
1425 }
1426
1427 close(finish)
1428 body, err := io.ReadAll(stream.Body)
1429 if err != nil {
1430 t.Fatalf("read stream: %v", err)
1431 }
1432 if string(body) != "first last" {
1433 t.Fatalf("stream body = %q, want both chunks", body)
1434 }
1435 stream.Body.Close()
1436
1437 // And the moment it ends, the slot is back.
1438 after, err := get("example.com")
1439 if err != nil {
1440 t.Fatalf("third request: %v", err)
1441 }
1442 defer after.Body.Close()
1443 if after.StatusCode != http.StatusOK {
1444 t.Fatalf("status = %d after the stream ended, want 200 — the slot must have been released", after.StatusCode)
1445 }
1446}
1447
1448// Saturation is one throttled journal line per home box, never silence and
1449// never one line per refused request: a flood is a STANDING condition, and
1450// the key is the box (bounded by the deployment) and never the host (which a
1451// stranger picks, and which a catch-all home would multiply without limit).
1452func TestBackhaulSaturationLogsThrottledPerBox(t *testing.T) {
1453 e, m, _ := hopEdge(t, "annie", "local.test")
1454 backhaulCap(t, 1)
1455 // Neither EIP is ever dialled: both boxes' only slot is held below, so
1456 // every request in this test is refused before the dial.
1457 homedOn(t, m, "betty", "192.0.2.5", "betty.test")
1458 homedOn(t, m, "carol", "192.0.2.6", "carol.test")
1459 for _, box := range []string{"betty", "carol"} {
1460 release, ok := e.backhaulAcquire(box)
1461 if !ok {
1462 t.Fatalf("%s: the first slot toward a box must always be available", box)
1463 }
1464 t.Cleanup(release)
1465 }
1466
1467 buf := captureLog(t)
1468 for _, host := range []string{"betty.test", "betty.test", "carol.test"} {
1469 if resp := hopRequest(t, e, host, nil); resp.StatusCode != http.StatusServiceUnavailable {
1470 t.Fatalf("%s: status = %d, want 503", host, resp.StatusCode)
1471 }
1472 }
1473
1474 got := buf.String()
1475 // logThrottled prints no key of its own, so key isolation is observed as
1476 // TWO first occurrences (one bucket per box) with betty's second refusal
1477 // throttled inside her own bucket.
1478 if n := strings.Count(got, "occurrence #1"); n != 2 {
1479 t.Fatalf("%d first-occurrence line(s), want one per home box (two buckets): %s", n, got)
1480 }
1481 if strings.Contains(got, "occurrence #2") {
1482 t.Fatalf("log output = %q, want the second refusal against the same box throttled", got)
1483 }
1484 if n := strings.Count(got, "in-flight cap"); n != 2 {
1485 t.Fatalf("%d saturation line(s), want exactly two: %s", n, got)
1486 }
1487 if !strings.Contains(got, "betty") || !strings.Contains(got, "carol") {
1488 t.Fatalf("log output = %q, want each saturated home box named", got)
1489 }
1490}
1491
1492// The knob: unset, unparseable and non-positive all mean the default. There
1493// is deliberately no "off" — an unbounded cross-region path is what the cap
1494// closes — and a typo must not silently zero-cap a deployment either.
1495func TestBackhaulMaxInflightFromEnv(t *testing.T) {
1496 // The ambient value is captured and restored by hand: t.Setenv restores
1497 // what IT set, but the unset case below has to clear the variable, and an
1498 // operator's own CARLOS_BACKHAUL_MAX_INFLIGHT must survive this test.
1499 ambient, had := os.LookupEnv("CARLOS_BACKHAUL_MAX_INFLIGHT")
1500 t.Cleanup(func() {
1501 if had {
1502 os.Setenv("CARLOS_BACKHAUL_MAX_INFLIGHT", ambient)
1503 return
1504 }
1505 os.Unsetenv("CARLOS_BACKHAUL_MAX_INFLIGHT")
1506 })
1507
1508 for _, tc := range []struct {
1509 set bool
1510 raw string
1511 want int
1512 }{
1513 {set: false, want: defaultBackhaulMaxInflight},
1514 {set: true, raw: "", want: defaultBackhaulMaxInflight},
1515 {set: true, raw: " ", want: defaultBackhaulMaxInflight},
1516 {set: true, raw: "sixty-four", want: defaultBackhaulMaxInflight},
1517 {set: true, raw: "0", want: defaultBackhaulMaxInflight},
1518 {set: true, raw: "-5", want: defaultBackhaulMaxInflight},
1519 {set: true, raw: "128", want: 128},
1520 {set: true, raw: " 8 ", want: 8},
1521 } {
1522 if tc.set {
1523 t.Setenv("CARLOS_BACKHAUL_MAX_INFLIGHT", tc.raw)
1524 } else {
1525 os.Unsetenv("CARLOS_BACKHAUL_MAX_INFLIGHT")
1526 }
1527 if got := backhaulInflightFromEnv(); got != tc.want {
1528 t.Fatalf("CARLOS_BACKHAUL_MAX_INFLIGHT=%q (set=%v) → %d, want %d", tc.raw, tc.set, got, tc.want)
1529 }
1530 }
1531}
1532
1533// oneKeyFailingStore fails Get for one named key — a single unreadable object
1534// mid-poll, the case the carry-forward exists for.
1535type oneKeyFailingStore struct {
1536 release.Store
1537 mu sync.Mutex
1538 bad string
1539}
1540
1541func (o *oneKeyFailingStore) failKey(key string) {
1542 o.mu.Lock()
1543 o.bad = key
1544 o.mu.Unlock()
1545}
1546
1547func (o *oneKeyFailingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1548 o.mu.Lock()
1549 bad := o.bad
1550 o.mu.Unlock()
1551 if key == bad {
1552 return nil, "", errors.New("bucket down for this object")
1553 }
1554 return o.Store.Get(ctx, key)
1555}
1556
1557// ---- owners (#175 P7, the edge-local door) ---------------------------------
1558
1559// Owner answers through the same lookup as Home, exact and one-label
1560// wildcard, and its dial target is Home's for the same host.
1561func TestOwnerFoldsThroughExactAndWildcardLookup(t *testing.T) {
1562 s := meshTestStore(t)
1563 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{`+
1564 `"acme.go.tito.io":{"role":"home","account":"bae","app":"titogo"},`+
1565 `"*.classic.tito.io":{"role":"home","account":"bae","app":"classic"}}}`)
1566 m := newHomeMap(s, "annie")
1567 refreshHomeMap(t, m)
1568 for _, c := range []struct{ host, app string }{
1569 {"acme.go.tito.io", "titogo"},
1570 {"ti.classic.tito.io", "classic"},
1571 } {
1572 acct, app, eip, box, ok := m.Owner(c.host)
1573 if !ok || acct != "bae" || app != c.app {
1574 t.Fatalf("Owner(%s) = %s/%s %v, want bae/%s", c.host, acct, app, ok, c.app)
1575 }
1576 heip, hbox, hok := m.Home(c.host)
1577 if !hok || eip != heip || box != hbox {
1578 t.Fatalf("Owner(%s) dials %s/%s, Home dials %s/%s", c.host, eip, box, heip, hbox)
1579 }
1580 }
1581 if _, _, _, _, ok := m.Owner("a.b.classic.tito.io"); ok {
1582 t.Fatal("a two-label host must miss the one-label wildcard, as Home does")
1583 }
1584}
1585
1586// No published owner (a mixed hostname, or a publisher that predates the
1587// field), a cert-only entry, an own-box entry, or a nil map: ok is false and
1588// nothing else is answered.
1589func TestOwnerIsFalseWithoutAnOwnerOrADialTarget(t *testing.T) {
1590 s := meshTestStore(t)
1591 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"own.test":{"role":"home","account":"bae","app":"titogo"}}}`)
1592 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{`+
1593 `"mixed.test":{"role":"home"},`+
1594 `"halfowned.test":{"role":"home","account":"bae"},`+
1595 `"*.certonly.test":{"role":"wildcard","wildcard":true,"account":"bae","app":"titogo"}}}`)
1596 m := newHomeMap(s, "annie")
1597 refreshHomeMap(t, m)
1598 for _, host := range []string{"mixed.test", "halfowned.test", "x.certonly.test", "own.test", "nowhere.test"} {
1599 if acct, app, eip, box, ok := m.Owner(host); ok || acct != "" || app != "" || eip != "" || box != "" {
1600 t.Errorf("Owner(%s) = %q/%q %q/%q %v, want zeros and false", host, acct, app, eip, box, ok)
1601 }
1602 }
1603 // An exact entry with no owner is the winner: the lookup does not fall
1604 // through to a wildcard that would have named one.
1605 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{`+
1606 `"exact.wild.test":{"role":"home"},"*.wild.test":{"role":"home","account":"bae","app":"titogo"}}}`)
1607 refreshHomeMap(t, m)
1608 if _, _, _, _, ok := m.Owner("exact.wild.test"); ok {
1609 t.Error("an ownerless exact entry borrowed the wildcard's owner")
1610 }
1611 var nilMap *homeMap
1612 if acct, app, eip, box, ok := nilMap.Owner("acme.go.tito.io"); ok || acct != "" || app != "" || eip != "" || box != "" {
1613 t.Error("a nil map answered an owner")
1614 }
1615}
1616
1617// A route replacing an alias on a name collision carries the ROUTE's owner,
1618// whatever box sorted first. The owner is never merged across entries.
1619func TestOwnerOfARouteReplacingAnAliasIsTheRoutes(t *testing.T) {
1620 s := meshTestStore(t)
1621 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shared.test":{"role":"home","alias":true,"account":"bae","app":"squatter"},`+
1622 `"ownerless.test":{"role":"home","alias":true,"account":"bae","app":"titogo"}}}`)
1623 putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"shared.test":{"role":"home","account":"bae","app":"titogo"},`+
1624 `"ownerless.test":{"role":"home"}}}`)
1625 m := newHomeMap(s, "annie")
1626 refreshHomeMap(t, m)
1627 acct, app, eip, box, ok := m.Owner("shared.test")
1628 if !ok || acct != "bae" || app != "titogo" || eip != "3.3.3.3" || box != "carol" {
1629 t.Fatalf("Owner(shared.test) = %s/%s %s/%s %v, want the route's bae/titogo on carol", acct, app, eip, box, ok)
1630 }
1631 // The ownerless route on carol replaces betty's owned alias: its lack of
1632 // an owner is not filled in from the alias it beat.
1633 if _, _, _, box, ok := m.Owner("ownerless.test"); ok {
1634 t.Fatalf("Owner(ownerless.test) = %s, true; the losing alias's owner leaked into the winner", box)
1635 }
1636 if _, box, _ := m.Home("ownerless.test"); box != "carol" {
1637 t.Fatalf("Home(ownerless.test) = %s, want carol", box)
1638 }
1639}
1640
1641func TestHomeMapHigherStampWinsWhateverTheSortOrder(t *testing.T) {
1642 s := meshTestStore(t)
1643 // "annie" sorts first and is the dead box; "betty" holds the newer installation.
1644 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","stamp":41}}}`)
1645 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home","stamp":57}}}`)
1646 m := newHomeMap(s, "carol")
1647 refreshHomeMap(t, m)
1648 if eip, box, ok := m.Home("shop.test"); !ok || box != "betty" || eip != "2.2.2.2" {
1649 t.Fatalf("Home = %q %q %v, want betty", eip, box, ok)
1650 }
1651 // And the other way round, so it is the stamp and not the name.
1652 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","stamp":90}}}`)
1653 refreshHomeMap(t, m)
1654 if _, box, _ := m.Home("shop.test"); box != "annie" {
1655 t.Fatalf("Home box = %q, want annie", box)
1656 }
1657}
1658
1659func TestHomeMapStampedBeatsUnstamped(t *testing.T) {
1660 s := meshTestStore(t)
1661 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home"}}}`)
1662 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home","stamp":3}}}`)
1663 m := newHomeMap(s, "carol")
1664 refreshHomeMap(t, m)
1665 if _, box, _ := m.Home("shop.test"); box != "betty" {
1666 t.Fatalf("Home box = %q, want betty", box)
1667 }
1668}
1669
1670func TestHomeMapUnstampedKeepsTodaysRule(t *testing.T) {
1671 s := meshTestStore(t)
1672 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","alias":true}}}`)
1673 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home"}}}`)
1674 m := newHomeMap(s, "carol")
1675 refreshHomeMap(t, m)
1676 if _, box, _ := m.Home("shop.test"); box != "betty" {
1677 t.Fatalf("a route still beats an alias: got %q", box)
1678 }
1679 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home"}}}`)
1680 refreshHomeMap(t, m)
1681 if _, box, _ := m.Home("shop.test"); box != "annie" {
1682 t.Fatalf("two plain routes: first box in sorted order; got %q", box)
1683 }
1684}
1685
1686func TestHomeMapAStampedAliasDoesNotBeatAnUnstampedRoute(t *testing.T) {
1687 s := meshTestStore(t)
1688 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home"}}}`)
1689 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home","alias":true,"stamp":9}}}`)
1690 m := newHomeMap(s, "carol")
1691 refreshHomeMap(t, m)
1692 if _, box, _ := m.Home("shop.test"); box != "annie" {
1693 t.Fatalf("got %q: #287's rule (a route beats an alias) comes before stamps", box)
1694 }
1695}
1696
1697func TestHomeMapAStampedEntryWithNoDialAddressDoesNotWin(t *testing.T) {
1698 s := meshTestStore(t)
1699 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","stamp":2}}}`)
1700 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"","hosts":{"shop.test":{"role":"home","stamp":99}}}`)
1701 m := newHomeMap(s, "carol")
1702 refreshHomeMap(t, m)
1703 if eip, box, ok := m.Home("shop.test"); !ok || box != "annie" || eip != "1.1.1.1" {
1704 t.Fatalf("Home = %q %q %v, want annie: a box nothing can dial is skipped today and stays skipped", eip, box, ok)
1705 }
1706}
1707
1708func file(box, eip string, hosts map[string]edgesHostView) edgesFileView {
1709 return edgesFileView{V: 1, Box: box, EIP: eip, Hosts: hosts}
1710}
1711
1712func TestHomeMapReportsTwoBoxesAtTheSameStamp(t *testing.T) {
1713 files := map[string]edgesFileView{
1714 "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{
1715 "tied.test": {Role: "home", Stamp: 7}, "fine.test": {Role: "home", Stamp: 7}, "plain.test": {Role: "home"}}),
1716 "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{
1717 "tied.test": {Role: "home", Stamp: 7}, "fine.test": {Role: "home", Stamp: 9}, "plain.test": {Role: "home"}}),
1718 }
1719 hosts, tied := foldHomes(files, "carol", false)
1720 if len(tied) != 1 || tied[0] != "tied.test" {
1721 t.Fatalf("tied = %v, want [tied.test]: unstamped duplicates are normal (static sites run on every box) and are not reported", tied)
1722 }
1723 if hosts["tied.test"].box != "annie" {
1724 t.Fatalf("a tie keeps the first box in sorted order; got %q", hosts["tied.test"].box)
1725 }
1726 if hosts["fine.test"].box != "betty" {
1727 t.Fatalf("fine.test box = %q", hosts["fine.test"].box)
1728 }
1729}
1730
1731// A tie between two boxes is reported even when a third box holds a higher
1732// stamp and wins the dial: the report does not depend on the winner.
1733func TestHomeMapReportsATieBelowTheWinner(t *testing.T) {
1734 files := map[string]edgesFileView{
1735 "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 9}}),
1736 "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1737 "control/edges/carol.json": file("carol", "3.3.3.3", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1738 }
1739 hosts, tied := foldHomes(files, "dave", false)
1740 if len(tied) != 1 || tied[0] != "h.test" {
1741 t.Fatalf("tied = %v, want [h.test]", tied)
1742 }
1743 if hosts["h.test"].box != "annie" {
1744 t.Fatalf("the highest stamp still wins the dial; got %q", hosts["h.test"].box)
1745 }
1746}
1747
1748// In a two-box region each box excludes itself from routing. It must
1749// still see that it and its neighbour publish one host at one stamp.
1750func TestHomeMapReportsATieWithThisBoxItself(t *testing.T) {
1751 files := map[string]edgesFileView{
1752 "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1753 "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1754 }
1755 hosts, tied := foldHomes(files, "annie", false)
1756 if len(tied) != 1 || tied[0] != "h.test" {
1757 t.Fatalf("tied = %v, want [h.test]", tied)
1758 }
1759 if hosts["h.test"].box != "betty" {
1760 t.Fatalf("this box is still excluded from its own routing; got %q", hosts["h.test"].box)
1761 }
1762}
1763
1764// A route catch-all here does not beat another box's EXACT host: the
1765// precedence one box keeps (exact, then "*.<parent>") holds across boxes.
1766// A label nobody serves exactly still falls to the local catch-all.
1767func TestLocalWildcardYieldsToARemoteExactHost(t *testing.T) {
1768 e, m, _ := hopEdge(t, "annie", "*.example.com")
1769 rec, eip := fakeHomeEdge(t, nil)
1770 homedOn(t, m, "betty", eip, "acme.example.com")
1771
1772 resp := hopRequest(t, e, "acme.example.com", nil)
1773 if body := readBody(t, resp); resp.StatusCode != http.StatusOK || body != homeEdgeBody {
1774 t.Fatalf("acme.example.com = %d %q, want the home edge's answer — another box serves it exactly", resp.StatusCode, body)
1775 }
1776 if n := rec.get().n; n != 1 {
1777 t.Fatalf("home edge saw %d request(s), want 1", n)
1778 }
1779
1780 resp = hopRequest(t, e, "other.example.com", nil)
1781 if body := readBody(t, resp); resp.StatusCode != http.StatusOK || !strings.HasPrefix(body, "xff=") {
1782 t.Fatalf("other.example.com = %d %q, want the LOCAL catch-all — nobody serves it exactly", resp.StatusCode, body)
1783 }
1784 if n := rec.get().n; n != 1 {
1785 t.Fatalf("home edge saw %d request(s), want still 1", n)
1786 }
1787}
1788
1789// ---- #336: only the hosts half is gated on the change index ------------------
1790
1791// armedHomeMap is a home map over s with the change index armed (one watch over
1792// the same store, the agent's in production) and a clock that starts now, so
1793// the health state's real-time reads agree with it.
1794func armedHomeMap(t *testing.T, s *changeIndexStore, self string) (*homeMap, *testClock) {
1795 t.Helper()
1796 w := release.NewChangeWatch(s)
1797 clk := &testClock{t: time.Now()}
1798 m := newHomeMap(s, self)
1799 m.watch = func() *release.ChangeWatch { return w }
1800 m.now = clk.now
1801 return m, clk
1802}
1803
1804func homePoll(t *testing.T, m *homeMap) {
1805 t.Helper()
1806 if err := m.poll(context.Background()); err != nil {
1807 t.Fatalf("poll: %v", err)
1808 }
1809}
1810
1811func edgesReads(s *changeIndexStore) int {
1812 return s.count("List", edgesPrefix) + s.count("ListETags", edgesPrefix) + s.count("Get", edgesPrefix)
1813}
1814
1815// codex 8: a mesh-key rotation writes control/edge-mesh-key, which bumps no
1816// scope, so the keys must not wait on control/edges.
1817func TestHomeMapKeysPolledIndependently(t *testing.T) {
1818 t.Parallel()
1819 s := newChangeIndexStore(t)
1820 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
1821 putJSON(t, s, meshKeyKey, strings.Repeat("aa", 32))
1822 m, clk := armedHomeMap(t, s, "annie")
1823 homePoll(t, m)
1824 if _, _, ok := m.Home("b.test"); !ok {
1825 t.Fatal("precondition: the first poll folds the edges files")
1826 }
1827
1828 putJSON(t, s, meshKeyKey, strings.Repeat("bb", 32))
1829 s.reset()
1830 clk.advance(homeMapInterval)
1831 homePoll(t, m)
1832 if cur, _ := m.meshKeys(); string(cur) != strings.Repeat("\xbb", 32) {
1833 t.Fatalf("the rotated mesh key must load within one poll, got %x", cur)
1834 }
1835 if n := edgesReads(s); n != 0 {
1836 t.Fatalf("control/edges did not move, yet the poll made %d read(s) under it", n)
1837 }
1838 if n := s.count("Get", meshKeyKey); n < 1 {
1839 t.Fatal("the mesh key must be read on every poll")
1840 }
1841 if _, _, ok := m.Home("b.test"); !ok {
1842 t.Fatal("a skipped hosts read must keep the fold")
1843 }
1844}
1845
1846// codex 8: the five-minute refresh heartbeat holds across polls whose hosts
1847// read is skipped, and the forwarding latch stays set.
1848func TestHomeMapHeartbeatOnUnchangedPoll(t *testing.T) {
1849 t.Parallel()
1850 s := newChangeIndexStore(t)
1851 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
1852 putJSON(t, s, meshKeyKey, strings.Repeat("aa", 32))
1853 m, clk := armedHomeMap(t, s, "annie")
1854 health := &lbhealth.State{}
1855 m.health = health
1856 homePoll(t, m)
1857 for i := 0; i < 4; i++ {
1858 s.reset()
1859 clk.advance(lbhealth.RefreshWindow - time.Minute)
1860 homePoll(t, m)
1861 if n := edgesReads(s); n != 0 {
1862 t.Fatalf("unchanged poll %d made %d read(s) under control/edges", i, n)
1863 }
1864 for _, c := range health.Failing(clk.now()) {
1865 if c == lbhealth.CondRefresh || c == lbhealth.CondForwarding {
1866 t.Fatalf("unchanged poll %d: health fails %q — a skipped hosts read must still beat", i, c)
1867 }
1868 }
1869 }
1870}
1871
1872// The hosts half re-reads when control/edges moves, and at the backstop with
1873// the index unmoved.
1874func TestHomeMapHostsReadOnMoveAndAtBackstop(t *testing.T) {
1875 t.Parallel()
1876 s := newChangeIndexStore(t)
1877 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
1878 m, clk := armedHomeMap(t, s, "annie")
1879 homePoll(t, m)
1880
1881 putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"c.test":{"role":"home"}}}`)
1882 s.reset()
1883 clk.advance(homeMapInterval)
1884 homePoll(t, m)
1885 if _, _, ok := m.Home("c.test"); !ok {
1886 t.Fatal("a moved control/edges must be read on the next poll")
1887 }
1888 if n := s.count("List", edgesPrefix); n != 1 {
1889 t.Fatalf("moved: %d list(s) of control/edges, want 1", n)
1890 }
1891
1892 s.reset()
1893 clk.advance(release.ChangeIndexBackstop*9/10 - time.Minute)
1894 homePoll(t, m)
1895 if n := edgesReads(s); n != 0 {
1896 t.Fatalf("young and unmoved: %d read(s), want none", n)
1897 }
1898 s.reset()
1899 clk.advance(release.ChangeIndexBackstop*2/10 + 2*time.Minute)
1900 homePoll(t, m)
1901 if n := s.count("List", edgesPrefix); n != 1 {
1902 t.Fatalf("at the backstop: %d list(s) of control/edges, want 1", n)
1903 }
1904}
1905
1906// Disarmed, every poll reads the hosts as today.
1907func TestHomeMapDisarmedPollReadsHosts(t *testing.T) {
1908 t.Parallel()
1909 s := newChangeIndexStore(t)
1910 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
1911 m := newHomeMap(s, "annie")
1912 m.watch = func() *release.ChangeWatch { return nil }
1913 for i := 0; i < 2; i++ {
1914 s.reset()
1915 homePoll(t, m)
1916 if n := s.count("List", edgesPrefix); n != 1 {
1917 t.Fatalf("disarmed poll %d: %d list(s), want 1", i, n)
1918 }
1919 if n := s.count("GetIfChanged", ""); n != 0 {
1920 t.Fatalf("disarmed poll %d read the index", i)
1921 }
1922 }
1923}
1924
1925// codex 8 again: a hung index GET elsewhere in the process holds the shared
1926// watch's poll lock, and the home map must still finish its poll and stamp
1927// RefreshAttempted, or the load balancer pulls a working box after five
1928// minutes.
1929func TestHomeMapStampsRefreshWhileTheWatchIsStuck(t *testing.T) {
1930 t.Parallel()
1931 s := newChangeIndexStore(t)
1932 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
1933 putJSON(t, s, meshKeyKey, strings.Repeat("aa", 32))
1934 m, clk := armedHomeMap(t, s, "annie")
1935 m.watchTimeout = 50 * time.Millisecond
1936 health := &lbhealth.State{}
1937 m.health = health
1938 homePoll(t, m)
1939
1940 entered, release := s.blockIndex(t)
1941 go func() { _, _ = m.watch().Poll(context.Background()) }()
1942 <-entered
1943 for i := 0; i < 2; i++ {
1944 clk.advance(lbhealth.RefreshWindow - time.Minute)
1945 done := make(chan error, 1)
1946 go func() { done <- m.poll(context.Background()) }()
1947 select {
1948 case err := <-done:
1949 if err != nil {
1950 t.Fatalf("poll %d: %v", i, err)
1951 }
1952 case <-time.After(5 * time.Second):
1953 release()
1954 t.Fatalf("poll %d waited on the stuck watch", i)
1955 }
1956 for _, c := range health.Failing(clk.now()) {
1957 if c == lbhealth.CondRefresh {
1958 t.Fatalf("poll %d: the refresh heartbeat lapsed behind a stuck watch", i)
1959 }
1960 }
1961 }
1962}
1963