| index 8854054b2..74d00db80 100644 |
| --- a/internal/edge/homemap_test.go |
| +++ b/internal/edge/homemap_test.go |
| @@ -1138,6 +1138,302 @@ func TestHTTPFallbackRedirectsARemoteHomedHost(t *testing.T) { |
| } |
| } |
| |
| +// ---- containment: the per-home-box in-flight cap -------------------------- |
| + |
| +// backhaulCap lowers the in-flight cap for one test and restores it after — |
| +// the package-seam pattern backhaulPort and homeMapInterval already follow. |
| +func backhaulCap(t *testing.T, n int) { |
| + t.Helper() |
| + old := backhaulMaxInflight |
| + backhaulMaxInflight = n |
| + t.Cleanup(func() { backhaulMaxInflight = old }) |
| +} |
| + |
| +// backhaulGetter drives requests for host at one edge over a single reused |
| +// server, so several can be in flight at once (hopRequest stands a server per |
| +// call, which is fine for one-shot tests and useless for a concurrency one). |
| +func backhaulGetter(t *testing.T, e *Edge) func(host string) (*http.Response, error) { |
| + t.Helper() |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return func(host string) (*http.Response, error) { |
| + req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + req.Host = host |
| + return http.DefaultClient.Do(req) |
| + } |
| +} |
| + |
| +// The cap itself: with two hops held open toward one home box, a third is |
| +// answered 503 + Retry-After: 2 HERE — no dial, no cross-region bytes — and |
| +// the slot a finished hop gives back is immediately usable again. |
| +// |
| +// This is what bounds a scanner's forged-Host flood: egress is (concurrent |
| +// streams × per-stream bandwidth), and this caps the first factor per box. |
| +func TestBackhaulRefusesAtInflightCap(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + backhaulCap(t, 2) |
| + |
| + started, hold := make(chan struct{}, 4), make(chan struct{}) |
| + rec, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + started <- struct{}{} |
| + <-hold |
| + fmt.Fprint(w, homeEdgeBody) |
| + })) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + get := backhaulGetter(t, e) |
| + |
| + done := make(chan error, 2) |
| + for i := 0; i < 2; i++ { |
| + go func() { |
| + resp, err := get("example.com") |
| + if err != nil { |
| + done <- err |
| + return |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + done <- fmt.Errorf("held hop: status = %d, want 200", resp.StatusCode) |
| + return |
| + } |
| + body, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + done <- err |
| + return |
| + } |
| + if string(body) != homeEdgeBody { |
| + done <- fmt.Errorf("held hop: body = %q", body) |
| + return |
| + } |
| + done <- nil |
| + }() |
| + } |
| + for i := 0; i < 2; i++ { |
| + select { |
| + case <-started: |
| + case err := <-done: |
| + t.Fatalf("a hop finished before the cap was full: %v", err) |
| + case <-time.After(15 * time.Second): |
| + t.Fatal("the two hops that fill the cap never reached the home edge") |
| + } |
| + } |
| + |
| + // Both slots are held. The third request must be refused by THIS box. |
| + dialled := rec.get().n |
| + resp, err := get("example.com") |
| + if err != nil { |
| + t.Fatalf("third request: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("status = %d, want 503 at the in-flight cap", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Retry-After"); got != "2" { |
| + t.Fatalf("Retry-After = %q, want 2 — the cap clears as hops finish", got) |
| + } |
| + if got := rec.get().n; got != dialled { |
| + t.Fatalf("the home edge saw %d request(s), want %d — a refusal must cost no dial and no cross-region bytes", got, dialled) |
| + } |
| + |
| + // Released slots are usable again: the cap is a bound on CONCURRENCY, not |
| + // a quota that a box burns through and never gets back. |
| + close(hold) |
| + for i := 0; i < 2; i++ { |
| + select { |
| + case err := <-done: |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case <-time.After(15 * time.Second): |
| + t.Fatal("a held hop never completed after its home edge was released") |
| + } |
| + } |
| + after, err := get("example.com") |
| + if err != nil { |
| + t.Fatalf("fourth request: %v", err) |
| + } |
| + defer after.Body.Close() |
| + if after.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d after the held hops finished, want 200", after.StatusCode) |
| + } |
| + if got := rec.get().n; got != dialled+1 { |
| + t.Fatalf("the home edge saw %d request(s), want %d — the fourth request must dial normally", got, dialled+1) |
| + } |
| +} |
| + |
| +// The cap is per HOME BOX: one saturated box must not stop this edge serving |
| +// hosts homed on any other. A deployment-wide counter would let one busy (or |
| +// one attacked) box take the whole mesh down. |
| +func TestBackhaulInflightCapIsPerHomeBox(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + backhaulCap(t, 1) |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "carol", eip, "example.com") |
| + // Betty's EIP is never dialled in this test — her slot is filled directly |
| + // below, so the refusal happens before any dial. (TEST-NET-1: a dial |
| + // would hang for the whole 5s budget and be visible as a failure.) |
| + homedOn(t, m, "betty", "192.0.2.5", "saturated.test") |
| + |
| + release, ok := e.backhaulAcquire("betty") |
| + if !ok { |
| + t.Fatal("the first slot toward a box must always be available") |
| + } |
| + t.Cleanup(release) |
| + |
| + if resp := hopRequest(t, e, "saturated.test", nil); resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("status = %d, want 503 — betty's only slot is held", resp.StatusCode) |
| + } |
| + resp := hopRequest(t, e, "example.com", nil) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200 — carol's cap is her own", resp.StatusCode) |
| + } |
| + if body := readBody(t, resp); body != homeEdgeBody { |
| + t.Fatalf("body = %q, want the home edge's own answer", body) |
| + } |
| + if n := rec.get().n; n != 1 { |
| + t.Fatalf("carol saw %d request(s), want exactly one", n) |
| + } |
| +} |
| + |
| +// A slot is held for the LIFE of the stream, not until the response headers |
| +// arrive: a long-lived stream (a WebSocket, an SSE feed, a large download) is |
| +// precisely the sustained cross-region egress the cap exists to bound, so it |
| +// must count against the cap for as long as it flows. |
| +func TestBackhaulSlotHeldForStreamLife(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + backhaulCap(t, 1) |
| + |
| + streaming, finish := make(chan struct{}), make(chan struct{}) |
| + _, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + fmt.Fprint(w, "first ") |
| + w.(http.Flusher).Flush() // headers and a first chunk are on the wire |
| + close(streaming) |
| + <-finish |
| + fmt.Fprint(w, "last") |
| + })) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + get := backhaulGetter(t, e) |
| + |
| + stream, err := get("example.com") |
| + if err != nil { |
| + t.Fatalf("stream request: %v", err) |
| + } |
| + defer stream.Body.Close() |
| + if stream.StatusCode != http.StatusOK { |
| + t.Fatalf("stream status = %d, want 200", stream.StatusCode) |
| + } |
| + select { |
| + case <-streaming: |
| + case <-time.After(15 * time.Second): |
| + t.Fatal("the streaming hop never reached the home edge") |
| + } |
| + |
| + // Headers have arrived and the body is still flowing: the slot is held. |
| + busy, err := get("example.com") |
| + if err != nil { |
| + t.Fatalf("second request: %v", err) |
| + } |
| + defer busy.Body.Close() |
| + if busy.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("status = %d while a hop is mid-stream, want 503 — the slot is held for the stream's life", busy.StatusCode) |
| + } |
| + |
| + close(finish) |
| + body, err := io.ReadAll(stream.Body) |
| + if err != nil { |
| + t.Fatalf("read stream: %v", err) |
| + } |
| + if string(body) != "first last" { |
| + t.Fatalf("stream body = %q, want both chunks", body) |
| + } |
| + stream.Body.Close() |
| + |
| + // And the moment it ends, the slot is back. |
| + after, err := get("example.com") |
| + if err != nil { |
| + t.Fatalf("third request: %v", err) |
| + } |
| + defer after.Body.Close() |
| + if after.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d after the stream ended, want 200 — the slot must have been released", after.StatusCode) |
| + } |
| +} |
| + |
| +// Saturation is one throttled journal line per home box, never silence and |
| +// never one line per refused request: a flood is a STANDING condition, and |
| +// the key is the box (bounded by the deployment) and never the host (which a |
| +// stranger picks, and which a catch-all home would multiply without limit). |
| +func TestBackhaulSaturationLogsThrottledPerBox(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "local.test") |
| + backhaulCap(t, 1) |
| + // Neither EIP is ever dialled: both boxes' only slot is held below, so |
| + // every request in this test is refused before the dial. |
| + homedOn(t, m, "betty", "192.0.2.5", "betty.test") |
| + homedOn(t, m, "carol", "192.0.2.6", "carol.test") |
| + for _, box := range []string{"betty", "carol"} { |
| + release, ok := e.backhaulAcquire(box) |
| + if !ok { |
| + t.Fatalf("%s: the first slot toward a box must always be available", box) |
| + } |
| + t.Cleanup(release) |
| + } |
| + |
| + buf := captureLog(t) |
| + for _, host := range []string{"betty.test", "betty.test", "carol.test"} { |
| + if resp := hopRequest(t, e, host, nil); resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("%s: status = %d, want 503", host, resp.StatusCode) |
| + } |
| + } |
| + |
| + got := buf.String() |
| + // logThrottled prints no key of its own, so key isolation is observed as |
| + // TWO first occurrences (one bucket per box) with betty's second refusal |
| + // throttled inside her own bucket. |
| + if n := strings.Count(got, "occurrence #1"); n != 2 { |
| + t.Fatalf("%d first-occurrence line(s), want one per home box (two buckets): %s", n, got) |
| + } |
| + if strings.Contains(got, "occurrence #2") { |
| + t.Fatalf("log output = %q, want the second refusal against the same box throttled", got) |
| + } |
| + if n := strings.Count(got, "in-flight cap"); n != 2 { |
| + t.Fatalf("%d saturation line(s), want exactly two: %s", n, got) |
| + } |
| + if !strings.Contains(got, "betty") || !strings.Contains(got, "carol") { |
| + t.Fatalf("log output = %q, want each saturated home box named", got) |
| + } |
| +} |
| + |
| +// The knob: unset, unparseable and non-positive all mean the default. There |
| +// is deliberately no "off" — an unbounded cross-region path is what the cap |
| +// closes — and a typo must not silently zero-cap a deployment either. |
| +func TestBackhaulMaxInflightFromEnv(t *testing.T) { |
| + for _, tc := range []struct { |
| + set bool |
| + raw string |
| + want int |
| + }{ |
| + {set: false, want: defaultBackhaulMaxInflight}, |
| + {set: true, raw: "", want: defaultBackhaulMaxInflight}, |
| + {set: true, raw: " ", want: defaultBackhaulMaxInflight}, |
| + {set: true, raw: "sixty-four", want: defaultBackhaulMaxInflight}, |
| + {set: true, raw: "0", want: defaultBackhaulMaxInflight}, |
| + {set: true, raw: "-5", want: defaultBackhaulMaxInflight}, |
| + {set: true, raw: "128", want: 128}, |
| + {set: true, raw: " 8 ", want: 8}, |
| + } { |
| + if tc.set { |
| + t.Setenv("CARLOS_BACKHAUL_MAX_INFLIGHT", tc.raw) |
| + } else { |
| + os.Unsetenv("CARLOS_BACKHAUL_MAX_INFLIGHT") |
| + } |
| + if got := backhaulInflightFromEnv(); got != tc.want { |
| + t.Fatalf("CARLOS_BACKHAUL_MAX_INFLIGHT=%q (set=%v) → %d, want %d", tc.raw, tc.set, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| // oneKeyFailingStore fails Get for one named key — a single unreadable object |
| // mid-poll, the case the carry-forward exists for. |
| type oneKeyFailingStore struct { |