carlos / platform Public

Merge pull request #252 from carlosframework/multi-region-backhaul

Multi-region backhaul: home maps, mesh key, one-hop dialing, serving classes (#238 Part 3)
Paul Campbell pushed by paul@keymail.dev 920d599480e2bdea157e6fc2bef4149f75ec011d parent eab8c78parent 3aea65c
16 files changed, +5010 −36
  • docs/superpowers/plans/2026-08-22-multi-region-backhaul.md +377 −11
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +65 −7
  • internal/edge/backhaul_e2e_test.go +870 −0
  • internal/edge/edge.go +346 −9
  • internal/edge/homemap.go +602 −0
  • internal/edge/homemap_test.go +1041 −0
  • internal/edge/mesh.go +303 −0
  • internal/edge/mesh_test.go +389 −0
  • internal/edge/storedoor_test.go +12 −5
  • internal/host/agent.go +16 −1
  • internal/host/edgesfile.go +153 −0
  • internal/host/edgesfile_test.go +270 −0
  • internal/host/housekeeper.go +48 −0
  • internal/host/housekeeper_test.go +49 −0
  • internal/provision/reconcile.go +183 −3
  • internal/provision/reconcile_test.go +286 −0
diff --git a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
index f99fb432d..79e338ea9 100644
--- a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
+++ b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
@@ -29,7 +29,11 @@ as the house idiom for bucket-fed edge state.
- Spec: `docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md`
Part 3. Key shapes verbatim: `control/edges/<box>.json`,
`control/edge-mesh-key`. Hop headers verbatim: `X-Carlos-Hop: 1` and
- `X-Carlos-Hop-Auth: <hex hmac>`.
+ `X-Carlos-Hop-Auth: <unix>:<hex>` (**amendment, 2026-08-22, T7**: this
+ line originally read `<hex hmac>`, omitting the leading unix timestamp
+ `signHop` actually prepends — File Structure's own Task 2 Interfaces
+ section below had the right shape all along; ledgered as a Task 2 minor
+ finding, fixed here rather than left to drift from the code).
- **Nothing writes `control/serial`.** Task 1 amends the spec's Part 3
line that said the edges file is serial-bumped (same correction the
certs took in stage 1-2): edges files ride their own 30s poll.
@@ -177,8 +181,18 @@ git commit -m "host: per-box edges file — what this box homes, for the fleet's
**Files:**
- Create: `internal/edge/mesh.go`
- Test: `internal/edge/mesh_test.go`
-- Modify: `internal/host/agent.go` (the `runHousekeeperDuties` call at
- ~:1632 gains the mint duty beside `prune`)
+- Modify: `internal/host/housekeeper.go` (a standalone `mintMeshKeyLoop`,
+ armed beside `edgesFileLoop` in `RunAgent` — **Amendment (2026-08-22,
+ review):** the line above originally read "the `runHousekeeperDuties`
+ call at ~:1632 gains the mint duty beside `prune`", i.e. folded into
+ `retentionLoop`'s tick; that is wrong and this File Structure section's
+ own `internal/host/housekeeper.go` entry ("mesh-key mint duty, T2,
+ additive") was the plan's real intent all along. `retentionLoop` returns
+ early — never arming at all — on a box without `CARLOS_RELEASE_PUBKEY`,
+ and such a box still wins the housekeeper election, so a mint duty living
+ only inside it would silently never run fleet-wide. The mint needs its
+ own loop, gated on nothing but deployment-store presence, mirroring
+ `edgesFileLoop`'s shape.)
- Modify: `internal/host/housekeeper_test.go` (duty-count expectations,
if any assert exact duty sets)
@@ -241,15 +255,22 @@ func TestHopSignatureRoundTripsAndBoundsSkew(t *testing.T) {
```
- [ ] **Step 2: verify failure. Step 3: implement** per the interfaces
- above; wire the host-side duty:
+ above; wire the host-side duty as its own loop (**Amendment
+ (2026-08-22, review):** NOT folded into `retentionLoop`'s
+ `runHousekeeperDuties(housekeeperGate, prune, mintMesh)` call as this
+ step originally showed — see the Files section above for why):
```go
-runHousekeeperDuties(housekeeperGate, prune, mintMesh)
+runHousekeeperDuties(housekeeperGate, mint)
```
- where `mintMesh` closes over the deployment store and calls
- `edge.MintMeshKey`; a mint error logs and does not fail the tick
- (idempotent next tick). Single-box inertness: key mints once, nothing
+ called from `mintMeshKeyLoop`'s own `for { sleep; ... }`, armed
+ unconditionally on store presence (mirroring `edgesFileLoop`) rather
+ than riding `retentionLoop`'s `CARLOS_RELEASE_PUBKEY` gate.
+
+ `mint` closes over the deployment store and calls `edge.MintMeshKey`;
+ a mint error logs and does not fail the tick (idempotent next tick).
+ Single-box inertness: key mints once, nothing
reads it until Tasks 3-4.
- [ ] **Step 4:** package gates both packages. **Step 5: Commit**
`edge+host: mesh key — housekeeper mints create-only, HMAC hop vocabulary`.
@@ -438,15 +459,360 @@ func TestHomeMapFoldsAndExcludesOwnBox(t *testing.T) {
- Modify: the spec (Part 3 rows → implemented-dormant with commits);
this plan (rollout notes below).
-- [ ] Fill the rollout notes: stage-5 arming now needs — per-box edges
+- [x] Fill the rollout notes: stage-5 arming now needs — per-box edges
files verified present for every armed box BEFORE steering (plan 3)
ever points DNS at a second edge; mesh-key presence check; the
backhaul path exercised on the dev/Kass deployment before flagship AU
(first two-real-boxes deployment); #244's bare-edge note extends to
the mesh key (a bare edge with a bucket can read it — same trust
envelope as certs, record it).
-- [ ] Full gate; commit `docs: backhaul rollout notes`.
+- [x] Full gate; commit `docs: backhaul rollout notes`.
## Rollout notes (filled by Task 7 at execution)
-- (filled at execution)
+Synthesized from the SDD ledger (`progress.md`) across all six
+implementation tasks and their reviews. This plan ships stages 1–3 (the
+spec's Part 3 rows) dormant on single-box deployments; nothing below is
+armed by merging this branch — it is the checklist and the open-questions
+list for the rollout that follows, including the stage-5/6 arming this
+plan's own spec Part 4 describes.
+
+### Gates from the whole-branch final review (2026-08-22)
+
+The whole-branch final review (2026-08-22) marked two items
+MUST-FIX-BEFORE-STAGE-5. Recorded here as gates on the checklist below,
+not left to stand alone in the review record:
+
+a. **Seed the edges-file change-gate (`*last`) from the bucket at loop
+ arm.** `edgesFilePass`'s change-gate (`internal/host/edgesfile.go`,
+ Task 1) compares each pass's body against `*last`, but `*last` starts
+ nil at process start, in memory only. Two consequences follow from
+ that until it is seeded: a box that restarts during a transient
+ `readPublicIP()` miss (an IMDS blip) publishes `"eip":""` over its own
+ last-good file instead of falling back to it — the `eip == "" && *last
+ != nil` carry-forward this file already has only helps once `*last`
+ is populated, and right after a restart it never is — and separately,
+ every restart forces one gate-crossing write regardless of whether
+ anything about the box's routes actually changed, churning the
+ object's ETag on every box bounce. Seeding `*last` from the bucket's
+ current `control/edges/<box>.json` at loop arm (read-old-then-loop,
+ the same shape the loop already uses for its steady-state reads)
+ closes both. Land this as the FIRST commit of the stage-5 arming
+ branch, before anything else in the checklist below runs against a
+ real second box.
+b. **Visibility for the two stuck-state paths before steering sends
+ sustained traffic.** Both are silent today. `staleHopRefusal`
+ (`internal/edge/homemap.go:551`) answers a sender stuck dialing a home
+ box whose map has moved on with a correct 503, but nothing is LOGGED
+ on either end, so a sender looping into it during a stuck map
+ propagation produces zero signal. The backhaul 502 `ErrorHandler` is
+ unthrottled the other direction — one log line per failed dial, not a
+ throttled cadence (detail and cross-ref: item 14 below). Neither is a
+ correctness bug, but both need eyes before Part 4 (steering) can
+ legitimately point real, sustained traffic at this path — the
+ dormant/low-volume window this plan ships into is where to add the
+ logging, not after a stuck-move incident goes unseen in production.
+
+### Stage-5 arming checklist (before flagship AU, before any real second box)
+
+1. **Per-box edges files verified present for every armed box, BEFORE
+ plan 3 (steering) ever points DNS at a second edge.** The home map is
+ built by folding `control/edges/<box>.json` for every box the mirror
+ can list (`internal/edge/homemap.go`, Task 3); a box that never
+ published one is invisible to every other edge's map, so a route
+ homed there is neither served locally by strangers' hellos (fine) nor
+ backhaul-reachable (NOT fine, once steering can legitimately land
+ traffic on a box that has nothing to route it with). No CLI/console
+ surface exists for this today — same box-local-only shape as the
+ registry-snapshot check plan 1's rollout notes carry — so it is a
+ direct-bucket list, consistent with CLAUDE.md's operate-via-API-and-CLI
+ carve-out for platform-internal work:
+
+ ```bash
+ aws --profile <profile> s3 ls s3://<control-bucket>/control/edges/
+ ```
+
+ Expect exactly one `<box>.json` per box that is supposed to be armed.
+ A missing entry means that box's `edgesFileLoop` has not ticked yet
+ (5min + jitter cadence) or is not running — resolve before arming
+ steering, not after.
+2. **Mesh-key presence check.** `control/edge-mesh-key` must exist and
+ every armed box's mirror must have it loaded before any hop can verify
+ (`internal/edge/mesh.go`, Task 2). Same direct-bucket check:
+ `aws s3 ls s3://<control-bucket>/control/edge-mesh-key`. Absence means
+ no box has yet won the housekeeper election with `mintMeshKeyLoop`
+ armed — check the housekeeper's acquisition log line
+ (`internal/host/housekeeper.go`) on whichever box should hold it.
+ **Rotation runbook pointer:** there is no standalone rotation runbook
+ document yet — the only written procedure is this plan's own Task 2
+ Global Constraints section above (`control/edge-mesh-key.next`,
+ dual-accept read on both `verifyHop` callers, promotion = operator
+ copies `.next` over the key and deletes `.next`). File a real runbook
+ doc before the first live rotation rather than pointing operators at a
+ plan file; until then, this paragraph IS the pointer. **Propagation
+ window is ≤1 poll** (30s, the home-map/mesh-key mirror's shared
+ cadence) per Task 2's ledgered honest-header fix (`0c07490`) — during
+ that window some edges verify hops under the old key, some under
+ `.next`; dual-accept on both is what makes that safe, not a race to
+ avoid. **A corrupt key is operator-delete only.** `mintMeshKey` is
+ create-only (Task 2) — it never overwrites an existing
+ `control/edge-mesh-key`, so a key that gets corrupted in the bucket
+ (a bad manual edit, a truncated write) is never self-repaired by any
+ box's `mintMeshKeyLoop`. Recovery is manual: delete the object; the
+ next box to hold the housekeeper lease re-mints on its next tick.
+ Worth knowing before touching that key by hand during an arming
+ window, not just at a real rotation.
+3. **Exercise the backhaul path on dev/Kass before flagship AU.**
+ Flagship AU is the spec's designated first target because a steering
+ mistake there "burns nobody's ticket sales" (spec Part 1 decisions) —
+ but nothing in this branch has run across two REAL boxes; Task 6's
+ e2e (`67de75f`) is in-process with edges files written directly into a
+ fake store (see the spec's Part 3 residual note, and Task 6's report
+ §4.1). The dev/Kass deployment is the first two-real-boxes proving
+ ground: arm a second box there, verify items 1–2 above hold live (not
+ just in a fake store), drive one real cross-box hop with `curl
+ --resolve` against the far box's EIP for a host homed on the near box,
+ and confirm the marker/XFF/one-hop behaviour Task 6 pinned in-process
+ also holds over a real network hop — before repeating any of this on
+ flagship.
+4. **#244's bare-edge note extends to the mesh key.** Plan 1's rollout
+ notes (item 5) ruled that a standalone `carlos edge` run outside the
+ agent on a bucket-backed box is ungated and publishes to
+ `control/certs/**` unconditionally — "don't run a bare `carlos edge`
+ against a deployment bucket on a multi-edge deployment," tracked as
+ issue #244. The same box, run the same way, also mirrors
+ `control/edges/` and `control/edge-mesh-key` (Task 3's home-map mirror
+ has no issuer gate — it is read-only bucket state, not the issuance
+ path) — so a bare edge can read the mesh key and the whole
+ deployment's home map exactly as it can read certs. This is not a new
+ hole: it is the same trust statement Part 2 already makes explicit
+ ("any box with deployment-bucket read already serves this deployment's
+ traffic... holding TLS keys does not extend what a compromised box can
+ do") extended to the one new key type Part 3 adds. Recorded here so
+ #244's operational rule ("don't run a bare `carlos edge` against a
+ deployment bucket on a multi-edge deployment") is read as covering the
+ mesh key too, not just certs.
+
+### Open ruling requested for Paul
+
+5. **T7 egress ruling — under-metering of backhauled egress, including
+ an abuse case.** By Task 3's hard constraint (T3 review) the ONLY
+ usage meter (`edge.go:840`) sits below a successful local
+ `lookupUpstream`; a backhauling edge misses it by construction, so a
+ route's cross-region backhaul traffic is billed (AWS egress) on the
+ SENDING box's account but never metered (CARLOS usage/billing) on
+ either box — the bytes leave the sending box's NIC twice as expensive
+ as a locally-served request and the platform's own metering sees none
+ of it. Task 4 (`bdcf6f6`..`0193912`) confirmed this is not merely a
+ missed-revenue gap: it is an **abuse surface**. Backhaul dials any
+ host the home map names, on an unverified hello, before any hop trust
+ check applies to the INBOUND request that triggered the dial — so a
+ scanner sending forged `Host:` headers at a non-home edge for hosts
+ that happen to be real elsewhere in the deployment generates real
+ cross-region egress cost on the sending box's AWS bill, with no
+ member, no account, and no CARLOS usage row to attribute it to.
+ **Containment option on the table, not yet decided:** a per-home-box
+ in-flight semaphore bounding concurrent backhaul dials toward any one
+ home box — cheap (one counter, no new storage), does not fix
+ attribution, but bounds the blast radius of a scanning flood to a
+ fixed number of concurrent cross-region dials rather than unbounded.
+ This needs a ruling before Part 4 (steering) sends real, sustained
+ traffic through this path — today it is dormant/low-volume by
+ construction (no steering, no DNS pointing anywhere but the home
+ region), which is the window in which to decide, not after. Cross-refs
+ unit-economics (the meter placement is `internal/usage`'s territory,
+ not `internal/edge`'s to relocate unilaterally) and the object of this
+ ruling should be recorded as a numbered issue once Paul rules on the
+ containment approach.
+
+### Constraint for Part 4 / plan 3 (steering) to honor
+
+6. **Pool narrowing: steered non-shared-pool routes must not get
+ out-of-pool latency records.** Task 5 (`15c3673`..`af006be`) confirmed
+ `inScope` tests `poolServed` FIRST and unchanged — the everywhere-class
+ widening in this plan only ever widens the REGION axis, never the pool
+ axis, so a `tito`-pool static record is still refused by a box serving
+ a different pool. Part 4's Route53 record generation must respect the
+ same boundary: a steered route in a non-shared pool must only get
+ latency records for the armed edges that actually serve its pool, not
+ every armed edge in the deployment — otherwise DNS would advertise a
+ path to a box that has never adopted the route and cannot serve it
+ (the everywhere-class widening's pool refusal would make that box 404
+ or refuse the hop, not backhaul it, since the route never landed
+ there). Stated as a plan-3 design constraint, not implemented here.
+
+### Operator/dashboard notes at second-region arming
+
+7. **`DNSStatus: "elsewhere"` false-alarm risk at second-region arming.**
+ `dnsStatus` (`internal/domains/reconcile.go:465`) classifies a route's
+ resolved IPs against `cfg.EdgeIPs` — the deployment's edge address list,
+ sourced from `PLATFORM_EDGE_IPS` per box (`internal/host/agent.go:1371`).
+ Each box runs its OWN `domains.Reconcile` pass and stamps its OWN
+ registry. Once Task 5's everywhere-class adoption puts the SAME static
+ routes on every box (not just the home box), every armed box now
+ independently DNS-checks those hosts — and any box whose own
+ `PLATFORM_EDGE_IPS` has not been updated to include the new region's
+ EIP will read a real, correctly-resolving DNS answer (still pointing
+ only at the original region, since Part 4 steering has not run yet) as
+ `"elsewhere"`, because the resolved IP is not in that box's edge set.
+ **Rollout action:** update `PLATFORM_EDGE_IPS` on every box to the
+ full deployment-wide edge IP list at the same time a new region is
+ armed, not after — otherwise the fleet dashboard (which aggregates
+ per-box readings) shows real DNS noise for every everywhere-class host
+ the instant the second box starts reconciling them, indistinguishable
+ at a glance from an actual DNS problem.
+8. **Legacy public-IP single-`Addr` rows: enumerate before regions
+ apply.** Task 5's `publiclyDialable` gate reclassifies any addr record
+ whose upstreams are ALL public IP literals as everywhere-class the
+ moment a second box exists — no code deploy required, just another
+ box appearing. Loopback and RFC1918/ULA private addrs are excluded by
+ design (home-boxed, unchanged), but a legacy single-`Addr` row that
+ happens to carry a real public IP literal would silently start being
+ probed and served from every armed box the day regions apply. Expected
+ empty in both production deployments today, but unverified — enumerate
+ before the first regions-apply:
+ ```sql
+ -- against each deployment's registry (read-only)
+ SELECT host, addr FROM routes WHERE addr <> '' AND kind = '';
+ ```
+ then eyeball each `addr` against `publiclyDialable`'s rule
+ (`net.ParseIP` non-nil, `IsGlobalUnicast()`, not private/loopback/
+ link-local) — any hit is a route to look at BEFORE arming, not after.
+
+ The same before-first-regions-apply window wants a second, adjacent
+ scan that the SQL above cannot reach: bucket **records**
+ (`apps/<sqid>/<app>/instances/<host>.json`, `Region` field —
+ `internal/provision/provision.go:40`) whose region does not match the
+ deployment's own single region. That mismatch is the only shape that
+ would make Task 5's widened everywhere-class adoption gate fire on the
+ very first roll, on a record no one has looked at since it was
+ written, since adoption reads the ROW's region, not the box's
+ assumption of it. Expect empty, same as the addr scan above, but
+ unverified — enumerate with a bucket-wide key scan in the spirit of
+ the bucket-writer audit's own enumeration method
+ (`docs/superpowers/specs/2026-08-20-bucket-writer-audit.md`):
+
+ ```bash
+ aws --profile <profile> s3 sync s3://<control-bucket>/apps/ /tmp/apps-scan/ \
+ --exclude "*" --include "*/instances/*.json"
+ grep -rl '"region"' /tmp/apps-scan | xargs -r grep -L '"region":"<deployment-region>"'
+ ```
+
+ Any file the second `grep` prints carries a region other than the
+ deployment's own — a record to look at BEFORE arming, not after.
+9. **Edges-file `role: "home"` over-claims for static hosts — wants a
+ `"replica"` role before Part 4 leans on the map.** Task 5's own
+ concern (report §4.1): once static routes are everywhere-class, EVERY
+ box that adopts one publishes it with `role: "home"` in its edges
+ file (Task 1's schema has only ever written `"home"`), so the home map
+ folds multiple boxes all claiming to home the same static host. The
+ fold's duplicate rule (first box in sorted order wins) makes this
+ harmless TODAY, because static routes never backhaul (Part 3's table:
+ Everywhere class is "adopt and serve locally... zero backhaul") — the
+ role is never consulted for a routing decision on this class. It stops
+ being harmless the moment Part 4's read-local anticipation (spec
+ "Anticipated, not built" §1) starts reading the `role` field for
+ real — a reader that cannot distinguish "the box that actually homes
+ this process" from "a box that adopted a static copy" cannot implement
+ read-local correctly. The role vocabulary already has the room (spec:
+ `{"role":"home"}` object form, not a bool) — this is a note for
+ whoever designs Part 4's writer change, not a code change here.
+10. **Domains: a static app's apex row appears on far boxes — operator-
+ surprise note, not a DNS-writer change.** Task 5 confirmed (report
+ §4.1, item 3) that `domains.Reconcile` mints the platform apex on a
+ far box too, since Task 5 makes its source route exist there. DNS
+ itself still points only at the home region until Part 4 steering —
+ **this is NOT a new DNS writer and does not change what answers a
+ real client sees**, disproving any read of it as a DNS-authority
+ change. It is purely a local-registry/console-visibility surprise: an
+ operator inspecting a far box's own domain rows will now see an apex
+ entry for an app they may not expect that box to know about at all.
+ Worth a line in the fleet dashboard / `carlos apps` docs before
+ stage 5, not a blocker.
+
+### Cross-referenced issues
+
+11. **#250 — everywhere-class rows with operator-touched
+ Backing/Tunnel/Caps survive record deletion on far boxes ("far-box
+ survivors").** Filed during Task 5's review round 1 (fix commit
+ `af006be`). Since `15c3673`, a static or public-addr record is
+ materialized on every platform box, and the removal phase reads the
+ class off the ROW (`everywhereRoute`), which deliberately answers
+ "home-boxed" for any row carrying `Backing`, `Tunnel` or `Caps` — a
+ conservative choice, but it means such a row is never retired once
+ touched. **Corrected reproduction** (the plain `-grant`/`-backing`
+ phrasing this note originally carried does not hold: both rails
+ refuse non-instance rows — `grantRail`, `cli.go:1197`, refuses
+ anything but a unit-backed instance route; `backingRail`, `cli.go:1370`,
+ refuses any non-instance kind outright — so neither reaches a static
+ row directly):
+ - a public-addr **instance** route (the Everywhere-probed class) on a
+ far box: `carlos route -backing unit` succeeds (kind `instance`,
+ not Platform), then `-grant <token>` succeeds (kind `instance`,
+ `Backing == unit`) — both rails clear because the row really is an
+ instance row, just a far-box copy of one;
+ - **or any everywhere-class row at all, static included**, touched
+ with `-tunnel <name>`: there is no `tunnelRail` — the tunnel axis
+ (`cli.go:951-974`) checks only `platformCanaryOwnedRow`, no kind
+ check exists, so a far box's static copy takes a tunnel stamp as
+ readily as an instance row.
+ Either path leaves `Backing`/`Tunnel` non-empty on the far copy, and
+ deleting the record then retires the home box's route while the far
+ box keeps serving the retired site forever, grants intact, with no
+ record left anywhere to explain why. The gate stays conservative on
+ purpose (the alternative hands an unattended bucket-record deletion
+ the capability-grant purge's human-gated contract), so the fix is
+ visibility + a supported cleanup verb, not a gate change. Relevant to
+ this rollout because everywhere-class adoption (this plan) is what
+ makes far-box copies routine for the first time — #250 was inert
+ before this branch.
+12. **#179 — deployment-wide secrets key, tracked as a mesh blocker.**
+ Task 5's concern (report §4.1, item 2): `appconfig.Sync` now renders
+ an everywhere-class app's `<host>.env` on every box that adopts it,
+ which is more copies of a tenant's rendered config at rest — not a
+ new READ capability, since every box already holds the
+ deployment-wide secrets key and could always read the whole store,
+ the exact coupling #179 tracks. #179 is a pre-existing blocker (also
+ surfaced on the trust-placement branch's mesh design work), not
+ something this branch introduces; recorded here as the cross-ref
+ Task 5's review asked for, not as new scope for this plan.
+
+### Deferred code-quality items worth a rollout-time watch
+
+13. **`backhaulTransports` is keyed by EIP and never evicts.** Task 4's
+ review flagged this as a minor, deferred item, and it turns real once
+ boxes actually get replaced: an EIP re-association (box replacement,
+ which the fleet already does routinely — see the IMDS-blip EIP-hold
+ logic Task 1 built) leaves the OLD EIP's cached `*http.Transport`
+ (`internal/edge/edge.go:184`, `backhaulTransports sync.Map`) stranded
+ in the map forever rather than pruned — bounded by the number of
+ distinct EIPs a deployment's boxes have ever held over its lifetime,
+ not by anything that self-cleans. Not a correctness bug (a new EIP
+ gets its own entry; the stale one is just dead weight), but worth
+ revisiting once box replacement during multi-region operation is a
+ routine event rather than a hypothetical.
+14. **The backhaul 502 `ErrorHandler` is unthrottled**
+ (`logThrottled` key `"backhaul-error "+box`, per-box but every dial
+ failure still logs). During the dev/Kass and flagship-AU rollout
+ exercises (item 3 above), a misconfigured home map or a genuinely
+ dead box will produce one line per failed request rather than a
+ throttled cadence — expected noise during the arming window, not a
+ defect, but flagged so it isn't mistaken for something worse the
+ first time it's seen live. **`staleHopRefusal` (`internal/edge/homemap.go:551`)
+ is silent in the same neighbourhood, the other direction** — a
+ sender stuck dialing a home box whose map has moved on gets its 503
+ answered correctly but nothing is LOGGED on either end, so a sender
+ looping into that refusal (map propagation stuck, a box that never
+ converged the move) produces zero signal that anything is wrong.
+ Task 3's review named this the "converge is not healing this" case
+ and wanted the same first-and-every-tenth cadence the mirror's
+ partial-poll logging already uses elsewhere in this branch — not
+ fixed here, but worth doing the next time anyone is in that file
+ rather than left to be rediscovered blind during a real stuck-move
+ incident.
+
+**Full gate** (env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod
+GOPROXY=off`): `go build ./...`, `go vet ./...`, `GOOS=darwin go build
+./...`, `go test ./... -count=1`, `gofmt -l` on touched files — all
+clean; this task's changes are docs-only (spec + this plan file), so
+the gate is trivially green (no `.go` file in the diff).
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
index 0cd27bb3a..19e15f475 100644
--- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
+++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
@@ -74,8 +74,8 @@ in the plan's rollout notes, not code.
| `releases/**` retention prune | every box | moves behind the **housekeeper lease** — five concurrent pruners racing a promote is a bug class we refuse to have. **Implemented, dormant** (`45a2ea6`..`aadd18e`) |
| usage metering day objects | the (single) edge | **audit**: if day-object keys are not box-scoped, they become `…/<box>/…` in the same shape as snapshots. **Confirmed already box-scoped** (`usage/edge/<box>/days/`, bucket-writer audit) |
| `control/certs/**` (new; carries HTTP-01 tokens as `<token>+http-01` and tls-alpn-01 token certs as `<host>+token` — no separate acme-challenges prefix) | housekeeper | Part 2 — **implemented, dormant** (`7eff14b`..`f7e67fe`) |
-| `control/edges/<box>.json` (new) | each box, own key only | Part 3 |
-| `control/edge-mesh-key` (new) | housekeeper mints if absent (create-only CAS) | Part 3 |
+| `control/edges/<box>.json` (new) | each box, own key only | Part 3 — **implemented, dormant** (`23a2b0c`..`e964df3`) |
+| `control/edge-mesh-key` (new) | housekeeper mints if absent (create-only CAS) | Part 3 — **implemented, dormant** (`fc2bad7`, `0c07490`) |
Stage-1 audit deliverable: an enumerated list of EVERY bucket writer in
the codebase with its ruling from the table above, checked into the spec's
@@ -173,8 +173,49 @@ over with identical machinery. Distributed certs keep every edge serving
through the gap; a dead issuer costs nothing until the earliest renewal
window ~30 days out.
+**The serving chain, and the last layer's boot-outage rationale.**
+`GetCertificate` on a non-issuer edge tries, in order: the wildcard
+handler, then this mirror, then the box's own local autocert manager,
+then — last — a direct read of that box's own on-disk autocert cache
+(`internal/edge/certsync.go`, `localCertificate`). That last layer exists
+because it is not redundant with the mirror: it is what a box serves when
+the bucket is unreachable at boot (an empty mirror) but its own disk
+still holds yesterday's certs, and what covers the ~20 minute window
+after a boot or a crash while the housekeeper election has not yet
+resolved. **Amendment (2026-08-22, T7):** that last-resort read is
+negatively memoized for `localCertTTL` (30s) — a name whose disk lookup
+comes back empty is not re-read from disk for 30s. Harmless in practice:
+the mirror is consulted first and answers ahead of this layer whenever it
+has anything, so a memoized nil here only matters in the narrow window
+where BOTH the mirror and this box's own disk have nothing for the name —
+it cannot cost a handshake the mirror could have served. Recorded because
+Task 6's two-edge e2e (`67de75f`) is the first thing to demonstrate the
+negative memoization directly (its P1 subtest), not because it changed
+behaviour.
+
## Part 3 — serving classes and backhaul
+**Implementation status (2026-08-22): implemented, dormant.** The per-box
+edges file (commits `23a2b0c`..`e964df3`) and the housekeeper-minted,
+dual-accept mesh key (`fc2bad7`, `0c07490`) are Part 1's writer-map rows
+above. The home-map mirror and one-hop request-path acceptance
+(`42844ae`..`67c2034`), backhaul dialing with a real TLS hop and a
+per-home-box cached transport (`bdcf6f6`..`0193912`), everywhere-class
+adoption for static/bake routes plus the paired `:80` remote-homed
+redirect (`15c3673`..`af006be`), and the two-edge e2e proving one
+marker-body backhaul, the poisoned-map 502, the stale-map 503 with no
+re-dial, and a tampered-auth hop degrading to an ordinary client
+(`67de75f`) are Part 3's own rows above. Single-box deployments: the home
+map holds only the local box, `Home()` never offers a backhaul target,
+the mesh key mints once and sits unused, and everywhere-class adoption
+outcomes are byte-identical to today's (see each task's single-box
+inertness note). Residual: nothing in this branch has run across two
+REAL boxes — the e2e is in-process with edges files written directly into
+a fake store — tracked as a rollout gate, not a blocker (see the plan's
+rollout notes). Egress attribution for backhauled bytes is an open
+ruling, not a defect: see the plan's rollout notes, T7 egress ruling
+request.
+
| Class | Kinds | Behaviour on a non-home edge |
|---|---|---|
| Everywhere | static, bake-served | adopt and serve locally from converged artifacts — zero backhaul |
@@ -182,11 +223,22 @@ window ~30 days out.
| Home-boxed | exec, unit, socket-activated, tunnel-backed | backhaul to the home edge. The process exists on exactly one box — unchanged invariant |
**Home resolution.** Each box maintains `control/edges/<box>.json`: its
-EIP and a map `host → {role}` for the routes it homes, written by its
-converge loop, serial-bumped on change. v1 writes only `role: "home"`.
-Every edge folds these files into a deployment-wide host→home map. The
-file doubles as the DR runbook's explicit statement of what a dead box
-was serving.
+EIP and a map `host → {role}` for the routes it homes. v1 writes only
+`role: "home"`. **Amendment (2026-08-22):** written by each box's own
+loop (the registry snapshot loop's arming site and cadence, 5min +
+jitter), change-gated — a same-content refresh with only a new
+timestamp writes nothing. Edge writes do NOT bump `control/serial` —
+the serial stays console-written per the writer map, matching Part 2's
+certs ruling; edges mirror the `control/edges/` prefix on their own
+30-second poll, the same cadence the certs mirror uses. Every edge
+folds these files into a deployment-wide host→home map. The file
+doubles as the DR runbook's explicit statement of what a dead box was
+serving. **Keys are registry row hosts verbatim, including `*.parent`
+catch-all rows** — this writer performs no wildcard expansion, so a
+reader that needs exact-host answers must apply `lookupUpstream`'s
+one-label wildcard rule (`internal/edge/edge.go`) itself: an exact
+match wins, and on a miss the first label's leading dot is swapped for
+`*` before trying again.
**The hop.** A remote edge dials `home-EIP:443` with ordinary SNI — the
home edge presents the same bucket-distributed cert. Two headers make it
@@ -201,6 +253,12 @@ forwarded headers stripped, normal serving rules. Timestamp skew bound
±30s (boxes run NTP). Key rotation: dual-accept window, old + new both
verify until every edge converges the new key.
+**Amendment (2026-08-22):** Task 3 shipped verbatim-restore, not append —
+on a verified hop the receiving edge replaces its own computed
+`X-Forwarded-For` with the sending edge's inbound chain unchanged (the
+last element stays the client); the sending edge's own address is never
+appended (`internal/edge/homemap.go`'s `setForwarded`).
+
**Hibernation** composes unchanged: the home edge owns wake (wake-once
singleflight, #199), and a backhaul hop is just another first client.
diff --git a/internal/edge/backhaul_e2e_test.go b/internal/edge/backhaul_e2e_test.go
new file mode 100644
index 000000000..98eca40ba
--- /dev/null
+++ b/internal/edge/backhaul_e2e_test.go
@@ -0,0 +1,870 @@
+package edge
+
+// Task 6 of the multi-region backhaul plan: the acceptance test the whole of
+// Part 3 exists for — ONE control bucket, TWO edges, and a request that lands
+// in the wrong region and is served anyway.
+//
+// Edge A ("annie") homes app.backhaul.test and runs the tenant's app behind it.
+// Edge B ("betty") has never heard of that host: no registry route, no
+// certificate of its own, no way to reach the app except through A. Everything
+// between the two is the bucket — the per-box edges files (Task 1's schema,
+// written here directly because the publishing loop lives in internal/host),
+// the mesh key (Task 2), the certs Part 2 distributes — and one TLS hop.
+//
+// What makes this an e2e rather than a bigger unit test is that BOTH doors are
+// real and neither is stubbed:
+//
+// - the client completes a real TLS handshake with the SENDING edge, with a
+// real x509 verification against the test CA, for a host that edge does not
+// route. That is the invariant the whole feature rides on and the one the
+// unit tests can only assert one branch of at a time: certMirror must
+// answer a hello for a host this box has no route for, or the client never
+// gets far enough to be backhauled at all (P1 below);
+// - the hop itself is a second real TLS handshake, from B's own transport to
+// A's front door, verified the same way.
+//
+// The two edges are wired exactly as RunWithActivatorReady wires one (raw local
+// cache kept aside, publishing wrapper over it, mirror built from the RAW
+// handle, autocert behind the mirror, the :80 chain composed in the same
+// order), and every poll is DRIVEN — Refresh is called, never waited for. There
+// are no sleeps in this file.
+//
+// Neither edge is the deployment's certificate issuer. That is deliberate and
+// it is the sharp case, not a convenience: issuance is Part 2's subject, while
+// what Part 3 needs is that a box which may never talk to a CA can still answer
+// handshakes for hosts it does not serve. Both managers point at a dead ACME
+// directory as well, so nothing here can reach a CA even by accident.
+//
+// Conventions are the neighbouring harnesses' (certsync_e2e_test.go for the
+// two-edge shape and the e2e* naming, homemap_test.go for the hop vocabulary
+// helpers): real loopback listeners, helpers reused rather than re-cut, bounds
+// asserted rather than narrated. No t.Parallel anywhere — this package's tests
+// swap package-level seams (backhaulPort, backhaulRootCAs) and the log writer.
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "fmt"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ // e2eHomedHost is the host the whole scenario turns on: routed on edge A,
+ // unknown to edge B, homed on A in every box's map.
+ e2eHomedHost = "app.backhaul.test"
+ // e2eMovedHost is homed on A in the MAP but no longer routed there — the
+ // stale-map case, which is what a placement move looks like from the
+ // sending edge for one publish cycle.
+ e2eMovedHost = "moved.backhaul.test"
+ // e2eDeadHost is homed on a box that answers nothing: the poisoned map.
+ e2eDeadHost = "dead.backhaul.test"
+ // e2eUnhomedHost is in nobody's edges file and nobody's registry — the
+ // answer for it must stay the plain unknown-host 404 on every box.
+ e2eUnhomedHost = "nothere.backhaul.test"
+ // e2eSenderHost is edge B's OWN route, so B is a real box with traffic of
+ // its own rather than a bare forwarder.
+ e2eSenderHost = "local.betty.test"
+
+ e2eHomeMarker = "served by the home edge's own app"
+ e2eSenderMarker = "served by the sending edge's own app"
+)
+
+// ---- the tenant app ----------------------------------------------------------
+
+// e2eApp is a tenant's app behind an edge: a marker body that says WHICH box's
+// upstream produced it, plus the trust-bearing headers it was handed. One
+// response therefore proves both halves at once — that the bytes crossed the
+// hop, and what the app was told about who called.
+//
+// X-Forwarded-For is rendered from Values(), joined visibly, because the
+// property under test is the LENGTH of the chain as much as its content: a
+// backhaul that appended the sending edge would show up here as a second
+// element (or a second header line), and Get() would hide it.
+func e2eApp(t *testing.T, marker string) *httptest.Server {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "%s|xff=%s|xfh=%s|hop=%s|auth=%s", marker,
+ strings.Join(r.Header.Values("X-Forwarded-For"), " + "),
+ r.Header.Get("X-Forwarded-Host"),
+ r.Header.Get(hopHeader), r.Header.Get(hopAuthHeader))
+ }))
+ t.Cleanup(ts.Close)
+ return ts
+}
+
+// e2eSeen is one parsed e2eApp response.
+type e2eSeen struct {
+ marker, xff, xfh, hop, auth string
+}
+
+func e2eParseApp(t *testing.T, body string) e2eSeen {
+ t.Helper()
+ parts := strings.Split(body, "|")
+ if len(parts) != 5 {
+ t.Fatalf("body %q did not come from the tenant app at all", body)
+ }
+ seen := e2eSeen{marker: parts[0]}
+ for _, p := range parts[1:] {
+ k, v, _ := strings.Cut(p, "=")
+ switch k {
+ case "xff":
+ seen.xff = v
+ case "xfh":
+ seen.xfh = v
+ case "hop":
+ seen.hop = v
+ case "auth":
+ seen.auth = v
+ }
+ }
+ return seen
+}
+
+// ---- the deployment's certificate authority ----------------------------------
+
+// e2eCA stands in for the public CA Part 2's issuer orders from. A real CA
+// rather than a self-signed leaf per host, because every handshake in this file
+// is verified the way a browser verifies one — RootCAs, hostname, expiry — and
+// a leaf trusted as its own root would quietly excuse a chain that production
+// would reject.
+type e2eCA struct {
+ root *x509.Certificate
+ key *ecdsa.PrivateKey
+ pool *x509.CertPool
+}
+
+func newE2ECA(t *testing.T) *e2eCA {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "CARLOS backhaul test CA"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageCertSign,
+ BasicConstraintsValid: true,
+ IsCA: true,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool := x509.NewCertPool()
+ pool.AddCert(root)
+ return &e2eCA{root: root, key: key, pool: pool}
+}
+
+// publish mints a certificate for host and writes it to control/certs/<host>,
+// in autocert's own cache-entry shape and through putCert — byte-for-byte what
+// the issuer's publishingCache leaves in the bucket. Every edge in the
+// deployment then serves it; none of them issued it.
+func (ca *e2eCA) publish(t *testing.T, s release.Store, host string) *x509.Certificate {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 96))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{CommonName: host},
+ DNSNames: []string{host},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.root, &key.PublicKey, ca.key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ leaf, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := encodeWildcardPEM(&tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ putBucket(t, s, host, raw)
+ return leaf
+}
+
+// ---- one edge ----------------------------------------------------------------
+
+// backhaulEdge is one box: a real Edge over its OWN handle on the shared
+// bucket, with the full handshake chain and the full :80 chain composed the way
+// RunWithActivatorReady composes them, on real loopback listeners.
+type backhaulEdge struct {
+ edge *Edge
+ store release.Store
+ homes *homeMap
+ mirror *certMirror
+ mgr *autocert.Manager
+ // local is the RAW autocert cache — this box's own disk, which starts
+ // empty and is only ever written by the mirror.
+ local autocert.Cache
+
+ tlsAddr, plainAddr string
+ // calls counts requests that reached this box's 443 door, refusals
+ // included: "exactly one dial" and "never dialled at all" are both this
+ // number moving, or not, on the OTHER box.
+ calls atomic.Int64
+ // plainCalls is the same for :80.
+ plainCalls atomic.Int64
+
+ client *http.Client
+}
+
+// newBackhaulEdge builds one box. routes are host → upstream address; anything
+// not listed is a host this box genuinely does not serve.
+func newBackhaulEdge(t *testing.T, bucketDir, box string, ca *e2eCA, from *net.TCPAddr, routes map[string]string) *backhaulEdge {
+ t.Helper()
+ be := &backhaulEdge{store: release.NewDirStore(bucketDir)}
+ reg := openReg(t)
+ for host, addr := range routes {
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: addr, Status: "ready"}); err != nil {
+ t.Fatalf("upsert %s: %v", host, err)
+ }
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ t.Cleanup(e.probers.Stop)
+ be.edge = e
+ e.staticStore = be.store
+ WithBoxLabel(box)(e)
+ // Not the issuer, and never able to become one: the gate is shut and the
+ // manager below points at a dead directory. Everything this box serves on
+ // 443 therefore came out of the bucket.
+ WithIssuerGate(func() (context.Context, bool) { return nil, false })(e)
+
+ // The RAW cache, kept before the publishing wrapper goes on (edge.go).
+ be.local = autocert.DirCache(t.TempDir())
+ cache := e.armCertIssuance(be.local)
+ be.mirror = e.newCertMirror(be.local)
+ if be.mirror == nil {
+ t.Fatal("a store-backed edge must have a mirror to serve from")
+ }
+ be.mgr = offlineManager(t, cache, e.issuerHostPolicy())
+ tlsCfg := be.mgr.TLSConfig()
+ tlsCfg.GetCertificate = be.mirror.wrapGetCertificate(tlsCfg.GetCertificate)
+
+ be.homes = e.newHomeMap()
+ if be.homes == nil {
+ t.Fatal("a store-backed edge must have a home map")
+ }
+ e.homes.Store(be.homes)
+
+ // 443: the edge itself, counted.
+ https := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ be.calls.Add(1)
+ e.ServeHTTP(w, r)
+ }))
+ https.TLS = tlsCfg
+ https.StartTLS()
+ t.Cleanup(https.Close)
+ be.tlsAddr = https.Listener.Addr().String()
+
+ // :80, outermost first, exactly as RunWithActivatorReady composes it: the
+ // mirror's challenge handler, then autocert's, then the redirect fallback.
+ // Composed ONCE, before anything listens, for the reason edge.go composes
+ // it there — HTTPHandler is what arms autocert's HTTP-01 path.
+ acmeAndFallback := be.mirror.challengeHandler(be.mgr.HTTPHandler(e.httpFallbackHandler()))
+ plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ be.plainCalls.Add(1)
+ acmeAndFallback.ServeHTTP(w, r)
+ }))
+ t.Cleanup(plain.Close)
+ be.plainAddr = plain.Listener.Addr().String()
+
+ be.client = e2eClientFor(be, ca, from)
+ return be
+}
+
+// e2eClientFor is a client that resolves every hostname to ONE box's two
+// listeners — the deployment's DNS, in other words, pointed wherever the test
+// wants this request to land. https connections are handshaken here rather than
+// by the transport so that the client verifies the edge's certificate against
+// the test CA, by name, exactly as a browser would: a mirrored certificate that
+// did not actually cover the host would fail here and not quietly downstream.
+func e2eClientFor(be *backhaulEdge, ca *e2eCA, from *net.TCPAddr) *http.Client {
+ dialer := &net.Dialer{Timeout: 10 * time.Second, LocalAddr: from}
+ return &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
+ return dialer.DialContext(ctx, network, be.plainAddr)
+ },
+ DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ name, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := dialer.DialContext(ctx, network, be.tlsAddr)
+ if err != nil {
+ return nil, err
+ }
+ conn := tls.Client(raw, &tls.Config{ServerName: name, RootCAs: ca.pool, MinVersion: tls.VersionTLS12})
+ if err := conn.HandshakeContext(ctx); err != nil {
+ raw.Close()
+ return nil, err
+ }
+ return conn, nil
+ },
+ },
+ }
+}
+
+// ---- the scene ---------------------------------------------------------------
+
+type backhaulScene struct {
+ ca *e2eCA
+ seed release.Store // the housekeeper's handle: what writes the control plane
+ a, b *backhaulEdge
+ key []byte // the deployment's mesh key, as both edges mirror it
+ // leaf is the certificate the bucket carries per host.
+ leaf map[string]*x509.Certificate
+ // clientIP is the source address the test client dials from, or "" if the
+ // platform would not give us a second loopback address.
+ clientIP string
+ deadEIP string
+}
+
+func newBackhaulScene(t *testing.T) *backhaulScene {
+ t.Helper()
+ e2eQuietLogs(t)
+ bucket := t.TempDir()
+ s := &backhaulScene{
+ seed: release.NewDirStore(bucket),
+ ca: newE2ECA(t),
+ leaf: map[string]*x509.Certificate{},
+ deadEIP: e2eDeadEIP(t),
+ }
+
+ // The housekeeper's one-time mint (Task 2), and the certs Part 2's issuer
+ // publishes. Both edges read them; neither wrote them.
+ if err := MintMeshKey(context.Background(), s.seed); err != nil {
+ t.Fatalf("mint mesh key: %v", err)
+ }
+ for _, host := range []string{e2eHomedHost, e2eMovedHost, e2eDeadHost, e2eUnhomedHost, e2eSenderHost} {
+ s.leaf[host] = s.ca.publish(t, s.seed, host)
+ }
+
+ from := e2eClientAddr(t)
+ if from != nil {
+ s.clientIP = from.IP.String()
+ }
+ s.a = newBackhaulEdge(t, bucket, "annie", s.ca, from, map[string]string{
+ e2eHomedHost: e2eApp(t, e2eHomeMarker).Listener.Addr().String(),
+ })
+ s.b = newBackhaulEdge(t, bucket, "betty", s.ca, from, map[string]string{
+ e2eSenderHost: e2eApp(t, e2eSenderMarker).Listener.Addr().String(),
+ })
+
+ // The dial seams, armed for the test's lifetime: 443 is an ephemeral port
+ // here, and the home edge's certificate is signed by the test CA rather
+ // than a public one. Nothing about verification is weakened — the hop still
+ // checks the name and the chain (see backhaulRootCAs' own doc).
+ //
+ // One consequence of backhaulPort being a single global: every box in this
+ // scene is dialled on the SAME port, so a box's EIP is the whole of its
+ // identity on the wire. That is why the dead box below gets an address of
+ // its own rather than a port of its own.
+ eip, port, err := net.SplitHostPort(s.a.tlsAddr)
+ if err != nil {
+ t.Fatalf("split home edge address: %v", err)
+ }
+ oldPort, oldCAs := backhaulPort, backhaulRootCAs
+ backhaulPort, backhaulRootCAs = port, s.ca.pool
+ t.Cleanup(func() { backhaulPort, backhaulRootCAs = oldPort, oldCAs })
+
+ // The per-box edges files, Task 1's schema, written straight into the store
+ // (the publishing loop is internal/host's and is not in this process).
+ bEIP, _, err := net.SplitHostPort(s.b.tlsAddr)
+ if err != nil {
+ t.Fatalf("split sending edge address: %v", err)
+ }
+ s.publishEdgesFile(t, "annie", eip, e2eHomedHost, e2eMovedHost)
+ s.publishEdgesFile(t, "betty", bEIP, e2eSenderHost)
+ s.publishEdgesFile(t, "carol", s.deadEIP, e2eDeadHost)
+ return s
+}
+
+// publishEdgesFile writes one box's control/edges/<box>.json — homedOn's shape,
+// written through the seed handle so neither edge is the publisher of its own
+// map (which is the production writer arrangement: the box's own host loop
+// writes it, and every edge only ever READS the prefix).
+func (s *backhaulScene) publishEdgesFile(t *testing.T, box, eip string, hosts ...string) {
+ t.Helper()
+ entries := make([]string, 0, len(hosts))
+ for _, h := range hosts {
+ entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\"}", h))
+ }
+ putJSON(t, s.seed, "control/edges/"+box+".json", fmt.Sprintf(
+ `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ",")))
+}
+
+// converge drives every mirror and every map ONE poll forward. Explicit, never
+// waited for: the loops (certMirror.run, homeMap.run) are 30s tickers, and a
+// test that slept for them would be testing the clock.
+func (s *backhaulScene) converge(t *testing.T) {
+ t.Helper()
+ for _, be := range []*backhaulEdge{s.a, s.b} {
+ if err := be.mirror.Refresh(context.Background()); err != nil {
+ t.Fatalf("cert mirror refresh: %v", err)
+ }
+ refreshHomeMap(t, be.homes)
+ }
+ key, next := s.a.homes.meshKeys()
+ if len(key) == 0 || len(next) != 0 {
+ t.Fatalf("mesh keys mirrored as current=%d next=%d bytes, want one current key", len(key), len(next))
+ }
+ s.key = key
+}
+
+// get drives one client request at one box and reads the whole body. follow
+// says whether redirects are followed — the :80 leg wants both answers.
+func (s *backhaulScene) get(t *testing.T, be *backhaulEdge, url string, headers map[string]string, follow bool) (*http.Response, string) {
+ t.Helper()
+ resp, err := s.try(be, url, headers, follow)
+ if err != nil {
+ t.Fatalf("GET %s: %v", url, err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ t.Fatalf("read body of %s: %v", url, err)
+ }
+ return resp, string(body)
+}
+
+func (s *backhaulScene) try(be *backhaulEdge, url string, headers map[string]string, follow bool) (*http.Response, error) {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ client := be.client
+ if !follow {
+ c := *be.client
+ c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+ client = &c
+ }
+ return client.Do(req)
+}
+
+// e2eClientAddr is the source address the test client dials FROM: a second
+// loopback address, so the client is distinguishable ON THE WIRE from the
+// sending edge, which dials the home edge from the default one. That is what
+// turns "X-Forwarded-For names the client, not the edge that forwarded it" into
+// an assertion rather than a coincidence of both being 127.0.0.1.
+//
+// nil on a platform that will not hand out a second loopback address; the
+// chain-LENGTH assertions still hold there, and the identity ones are skipped
+// explicitly rather than silently passing.
+func e2eClientAddr(t *testing.T) *net.TCPAddr {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.2:0")
+ if err != nil {
+ return nil
+ }
+ ln.Close()
+ return &net.TCPAddr{IP: net.ParseIP("127.0.0.2")}
+}
+
+// e2eDeadEIP is an address nothing listens on, for the poisoned-map case: a
+// loopback one when the platform allows it, so the dial is REFUSED immediately
+// rather than hanging. The documentation address is the fallback, and costs the
+// backhaul dial timeout instead — the same 502, more slowly.
+func e2eDeadEIP(t *testing.T) string {
+ t.Helper()
+ if ln, err := net.Listen("tcp", "127.0.0.9:0"); err == nil {
+ ln.Close()
+ return "127.0.0.9"
+ }
+ return "192.0.2.1" // TEST-NET-1, RFC 5737
+}
+
+// ---- the scenario ------------------------------------------------------------
+
+func TestBackhaulE2EAcrossTwoEdges(t *testing.T) {
+ s := newBackhaulScene(t)
+
+ // Before either mirror has polled, edge B holds nothing for the homed host:
+ // empty mirror, empty local cache, autocert gated shut. The handshake must
+ // FAIL — otherwise every success below could be autocert's own doing, or a
+ // certificate that was on B's disk all along.
+ if _, err := s.try(s.b, "https://"+e2eHomedHost+"/", nil, true); err == nil {
+ t.Fatal("the sending edge completed a handshake for a certificate no box has published to it yet")
+ }
+ s.converge(t)
+
+ t.Run("P1 the sending edge answers a hello for a host it does not route", func(t *testing.T) {
+ // The premise, asserted rather than assumed: B has no route for this
+ // host, will not issue for it, and its own manager refuses it outright.
+ if _, ok := s.b.edge.lookupUpstream(e2eHomedHost); ok {
+ t.Fatal("test bug: the sending edge must not route the host it is supposed to backhaul")
+ }
+ if s.b.edge.Allowed(e2eHomedHost) {
+ t.Fatal("test bug: the sending edge must not claim the host for issuance either")
+ }
+ if _, err := s.b.mgr.GetCertificate(ecdsaHello(e2eHomedHost)); err == nil {
+ t.Fatal("the sending edge's own autocert manager must refuse a host it does not serve")
+ }
+
+ // And yet a real client, verifying against the CA by name, completes the
+ // handshake — with the certificate the BUCKET carries. This is the
+ // invariant the feature rides on: without it a remote-homed host dies at
+ // the TLS layer on every edge but its home, and nothing downstream ever
+ // runs.
+ resp, _ := s.get(t, s.b, "https://"+e2eHomedHost+"/", nil, true)
+ if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
+ t.Fatal("no peer certificate — the handshake did not happen")
+ }
+ if got := resp.TLS.PeerCertificates[0]; !got.Equal(s.leaf[e2eHomedHost]) {
+ t.Fatalf("the sending edge served a certificate that is not the mirrored one (subject %q)", got.Subject.CommonName)
+ }
+ if c := s.b.mirror.certificate(e2eHomedHost, false); c == nil {
+ t.Fatal("the mirror map holds no certificate for the host — it cannot have been the source")
+ }
+ })
+
+ t.Run("the marker body backhauls, the chain names the client, one dial", func(t *testing.T) {
+ home, sender := s.a.calls.Load(), s.b.calls.Load()
+ resp, body := s.get(t, s.b, "https://"+e2eHomedHost+"/deep?q=1", nil, true)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — a remote-homed host is served through its home edge", resp.StatusCode)
+ }
+ seen := e2eParseApp(t, body)
+ if seen.marker != e2eHomeMarker {
+ t.Fatalf("marker = %q, want the HOME edge's app (%q)", seen.marker, e2eHomeMarker)
+ }
+ if got := s.a.calls.Load() - home; got != 1 {
+ t.Fatalf("the home edge saw %d request(s), want exactly one — one hop, ever", got)
+ }
+ if got := s.b.calls.Load() - sender; got != 1 {
+ t.Fatalf("the sending edge saw %d request(s) for one client request", got)
+ }
+ // The chain the app sees is a single element and it is the CLIENT's
+ // address — not the sending edge's, which is a different loopback
+ // address here precisely so the two can be told apart. Every consumer in
+ // this deployment takes the last element as the caller
+ // (internal/console/authcli.go's clientIP), so an appended edge would
+ // bill, rate-limit and log a real visitor as infrastructure.
+ if strings.Contains(seen.xff, "+") || strings.Contains(seen.xff, ",") {
+ t.Fatalf("X-Forwarded-For = %q, want a single-element chain", seen.xff)
+ }
+ if s.clientIP != "" && seen.xff != s.clientIP {
+ t.Fatalf("X-Forwarded-For = %q, want the client's own address %q", seen.xff, s.clientIP)
+ }
+ if seen.xfh != e2eHomedHost {
+ t.Fatalf("X-Forwarded-Host = %q, want the host the client asked for", seen.xfh)
+ }
+ // The hop vocabulary is the edges' own and stops at the home edge.
+ if seen.hop != "" || seen.auth != "" {
+ t.Fatalf("hop=%q auth=%q reached the tenant's app", seen.hop, seen.auth)
+ }
+ })
+
+ t.Run("the sending edge still serves its own routes without dialling", func(t *testing.T) {
+ home := s.a.calls.Load()
+ resp, body := s.get(t, s.b, "https://"+e2eSenderHost+"/", nil, true)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if seen := e2eParseApp(t, body); seen.marker != e2eSenderMarker {
+ t.Fatalf("marker = %q, want the sending edge's OWN app", seen.marker)
+ }
+ if got := s.a.calls.Load() - home; got != 0 {
+ t.Fatalf("the home edge saw %d request(s) for a host the sending edge serves itself", got)
+ }
+ })
+
+ t.Run("the :80 leg redirects, and the redirect it hands out backhauls", func(t *testing.T) {
+ // A first-time visitor typing a bare hostname lands on whichever edge
+ // DNS picked, on :80. Task 5's fix: a remote-homed host is routable, so
+ // it gets the ordinary redirect rather than a 404 for an app that is up.
+ plain := s.b.plainCalls.Load()
+ resp, _ := s.get(t, s.b, "http://"+e2eHomedHost+"/deep?q=1", nil, false)
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("status = %d, want 302 — :80 must send a remote-homed host to 443", resp.StatusCode)
+ }
+ if got := s.b.plainCalls.Load() - plain; got != 1 {
+ t.Fatalf("the sending edge's :80 door saw %d request(s), want one — this leg must be plain HTTP", got)
+ }
+ if got, want := resp.Header.Get("Location"), "https://"+e2eHomedHost+"/deep?q=1"; got != want {
+ t.Fatalf("Location = %q, want %q", got, want)
+ }
+ // A host nothing in the deployment homes keeps the unchanged 404 on the
+ // same handler, so the redirect is a routing answer and not a blanket one.
+ if got, _ := s.get(t, s.b, "http://"+e2eUnhomedHost+"/", nil, false); got.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want the unchanged 404 for a host nothing homes", got.StatusCode)
+ }
+
+ // Both legs, one client call: the visitor follows the redirect and ends
+ // on the home edge's app, having crossed :80 here and 443 there.
+ home := s.a.calls.Load()
+ final, body := s.get(t, s.b, "http://"+e2eHomedHost+"/deep?q=1", nil, true)
+ if final.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d after following the redirect, want 200", final.StatusCode)
+ }
+ if final.Request.URL.Scheme != "https" {
+ t.Fatalf("the followed request ended on %q, want https", final.Request.URL.Scheme)
+ }
+ if final.TLS == nil {
+ t.Fatal("the second leg was not TLS")
+ }
+ if seen := e2eParseApp(t, body); seen.marker != e2eHomeMarker {
+ t.Fatalf("marker = %q, want the home edge's app at the end of both legs", seen.marker)
+ }
+ if got := s.a.calls.Load() - home; got != 1 {
+ t.Fatalf("the home edge saw %d request(s) across the two legs, want one", got)
+ }
+ })
+
+ t.Run("a poisoned map is a bad gateway, and never a loop", func(t *testing.T) {
+ if _, box, ok := s.b.homes.Home(e2eDeadHost); !ok || box != "carol" {
+ t.Fatalf("test bug: the map must name the dead box for %s (got %q, %v)", e2eDeadHost, box, ok)
+ }
+ home, sender := s.a.calls.Load(), s.b.calls.Load()
+ resp, body := s.get(t, s.b, "https://"+e2eDeadHost+"/", nil, true)
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502 — this edge believed a specific box was there", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "" {
+ t.Fatalf("Retry-After = %q — that answer is reserved for KNOWING nobody is home", got)
+ }
+ if strings.Contains(body, e2eHomeMarker) {
+ t.Fatal("a dead home somehow served the app")
+ }
+ if got := s.b.calls.Load() - sender; got != 1 {
+ t.Fatalf("the sending edge handled %d request(s) for one client request — a failed dial must not be retried around the fleet", got)
+ }
+ if got := s.a.calls.Load() - home; got != 0 {
+ t.Fatalf("the live home edge saw %d request(s) for a host it does not home — a failed dial must not scan for a second target", got)
+ }
+ })
+
+ t.Run("a stale map gets the home edge's 503, and the sender does not re-dial", func(t *testing.T) {
+ // The map still homes this host on A; A no longer routes it. That is
+ // what one publish cycle of a placement move looks like from here.
+ if _, box, ok := s.b.homes.Home(e2eMovedHost); !ok || box != "annie" {
+ t.Fatalf("test bug: the map must still name the home edge for %s (got %q, %v)", e2eMovedHost, box, ok)
+ }
+ if _, ok := s.a.edge.lookupUpstream(e2eMovedHost); ok {
+ t.Fatal("test bug: the home edge must NOT serve the moved host")
+ }
+ home, sender := s.a.calls.Load(), s.b.calls.Load()
+ resp, body := s.get(t, s.b, "https://"+e2eMovedHost+"/", nil, true)
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want the home edge's 503", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "2" {
+ t.Fatalf("Retry-After = %q, want 2 — converge heals this within a publish cycle", got)
+ }
+ // The refusal reaches the client as the home edge wrote it, and says
+ // nothing about where the host went.
+ if strings.Contains(body, e2eMovedHost) || strings.Contains(body, "annie") || strings.Contains(body, "betty") {
+ t.Fatalf("body = %q — the refusal must not name the host or any box", body)
+ }
+ if got := s.a.calls.Load() - home; got != 1 {
+ t.Fatalf("the home edge saw %d request(s), want exactly one — a 503 is an answer, not a reason to try again", got)
+ }
+ if got := s.b.calls.Load() - sender; got != 1 {
+ t.Fatalf("the sending edge handled %d request(s) for one client request", got)
+ }
+ })
+
+ t.Run("a tampered hop reaches the home edge as an ordinary client", func(t *testing.T) {
+ // Tampering happens on the wire INTO the home edge: a genuine signature
+ // this deployment's own key produced, with one hex nibble flipped, plus
+ // the forwarding claims a tamperer would want believed.
+ tampered := []byte(signHop(s.key, e2eHomedHost, time.Now()))
+ tampered[len(tampered)-1] ^= 'f' ^ '0'
+ headers := map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: string(tampered),
+ "X-Forwarded-For": "203.0.113.9",
+ "X-Forwarded-Host": "evil.test",
+ "X-Forwarded-Proto": "https",
+ }
+
+ resp, body := s.get(t, s.a, "https://"+e2eHomedHost+"/", headers, true)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — a failed verification is 'you are an ordinary client', not a rejection", resp.StatusCode)
+ }
+ seen := e2eParseApp(t, body)
+ if seen.marker != e2eHomeMarker {
+ t.Fatalf("marker = %q, want the home edge's app", seen.marker)
+ }
+ if strings.Contains(seen.xff, "203.0.113.9") {
+ t.Fatalf("X-Forwarded-For = %q — a chain nothing vouched for must be discarded", seen.xff)
+ }
+ if s.clientIP != "" && seen.xff != s.clientIP {
+ t.Fatalf("X-Forwarded-For = %q, want the tamperer's own address %q", seen.xff, s.clientIP)
+ }
+ if seen.xfh == "evil.test" {
+ t.Fatalf("X-Forwarded-Host = %q — an unverified sender does not get to state it", seen.xfh)
+ }
+ if seen.hop != "" || seen.auth != "" {
+ t.Fatalf("hop=%q auth=%q reached the app — hop vocabulary is stripped either way", seen.hop, seen.auth)
+ }
+
+ // And no hop SEMANTICS at all: for a host the home edge does not serve,
+ // a tampered hop gets the ordinary unknown-host answer. The same request
+ // with an intact signature gets the stale-map refusal instead — the
+ // signature is the whole of the difference, which is what makes the 503
+ // unreachable to a stranger who cannot mint one.
+ bad := map[string]string{hopHeader: "1", hopAuthHeader: string(tampered)}
+ if got, _ := s.get(t, s.a, "https://"+e2eUnhomedHost+"/", bad, true); got.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want the unchanged 404 — a tampered hop earns no hop semantics", got.StatusCode)
+ } else if ra := got.Header.Get("Retry-After"); ra != "" {
+ t.Fatalf("Retry-After = %q — a stranger must not be told to come back", ra)
+ }
+ good := map[string]string{hopHeader: "1", hopAuthHeader: signHop(s.key, e2eUnhomedHost, time.Now())}
+ if got, _ := s.get(t, s.a, "https://"+e2eUnhomedHost+"/", good, true); got.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503 — an INTACT signature is what buys the stale-map refusal", got.StatusCode)
+ }
+ })
+}
+
+// ---- the single-box regression pin -------------------------------------------
+
+// Everything above is dormant on a deployment with one platform box, and this
+// is the pin that says so: an edge whose control/edges/ prefix holds only its
+// OWN file serves its own routes exactly as it always has, answers the
+// unchanged 404 for everything else, and never opens a connection to anything.
+//
+// The witness is a zero-call dialer — a listener that counts every connection
+// made to the backhaul port — with the single box's own edges file naming the
+// listener's ADDRESS as its EIP. So the one failure this pin exists to catch,
+// an edge that offers itself as a backhaul target, lands ON the counter rather
+// than merely failing somewhere else.
+//
+// The file lists a host the box does not route, which is what makes the counter
+// a real witness rather than a formality: a route removed between two publishes
+// leaves exactly that state for one cycle, it is the ONLY shape in which a
+// single box could dial at all (a host it serves is served locally, before the
+// map is ever consulted), and dropping own-box exclusion turns it into a dial to
+// this box's own front door.
+func TestBackhaulE2ESingleBoxNeverDials(t *testing.T) {
+ e2eQuietLogs(t)
+ bucket := t.TempDir()
+ seed := release.NewDirStore(bucket)
+ if err := MintMeshKey(context.Background(), seed); err != nil {
+ t.Fatalf("mint mesh key: %v", err)
+ }
+
+ const soloHost = "solo.backhaul.test"
+ // A host this box's own file still claims but its registry no longer
+ // serves — one publish cycle after a route was removed.
+ const soloStaleHost = "stale.backhaul.test"
+ app := e2eApp(t, e2eHomeMarker)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: soloHost, Kind: "service", Addr: app.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ t.Cleanup(e.probers.Stop)
+ e.staticStore = release.NewDirStore(bucket)
+ WithBoxLabel("solo")(e)
+
+ eip, dials := e2eDialCounter(t)
+ putJSON(t, seed, "control/edges/solo.json", fmt.Sprintf(
+ `{"v":1,"box":"solo","eip":%q,"hosts":{%q:{"role":"home"},%q:{"role":"home"}}}`,
+ eip, soloHost, soloStaleHost))
+
+ m := e.newHomeMap()
+ refreshHomeMap(t, m)
+ e.homes.Store(m)
+ for _, host := range []string{soloHost, soloStaleHost} {
+ if _, _, ok := m.Home(host); ok {
+ t.Fatalf("%s: the only box in a deployment must never be offered as a backhaul target for its own host", host)
+ }
+ }
+
+ if resp := hopRequest(t, e, soloHost, nil); resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — a single box serves its own routes as before", resp.StatusCode)
+ } else if seen := e2eParseApp(t, readBody(t, resp)); seen.marker != e2eHomeMarker {
+ t.Fatalf("marker = %q, want the box's own app", seen.marker)
+ }
+ for _, host := range []string{soloStaleHost, "nothing.backhaul.test"} {
+ if resp := hopRequest(t, e, host, nil); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("%s: status = %d, want the unchanged 404", host, resp.StatusCode)
+ }
+ }
+ if n := dials.Load(); n != 0 {
+ t.Fatalf("%d backhaul dial(s) from a single-box deployment, want none", n)
+ }
+ n := 0
+ e.backhaulTransports.Range(func(_, _ any) bool { n++; return true })
+ if n != 0 {
+ t.Fatalf("%d backhaul transport(s) built, want none — nothing was ever a target", n)
+ }
+}
+
+// e2eDialCounter is the zero-call witness: a listener on the backhaul port that
+// counts connections and answers nothing. It returns the address a dial would
+// have to name to reach it, and arms backhaulPort for the test's lifetime.
+func e2eDialCounter(t *testing.T) (string, *atomic.Int64) {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ var n atomic.Int64
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ n.Add(1)
+ conn.Close()
+ }
+ }()
+ host, port, err := net.SplitHostPort(ln.Addr().String())
+ if err != nil {
+ t.Fatalf("split: %v", err)
+ }
+ old := backhaulPort
+ backhaulPort = port
+ t.Cleanup(func() { backhaulPort = old })
+ return host, &n
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 29c0a6568..bb69f7d43 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -20,6 +20,8 @@ import (
"bufio"
"context"
"crypto/ed25519"
+ "crypto/tls"
+ "crypto/x509"
"encoding/json"
"errors"
"flag"
@@ -168,6 +170,24 @@ type Edge struct {
// every single-box deployment's behaviour to date: mint as always.
// Serving is never gated by it.
issuer IssuerGate
+ // box is this box's own label — the SAME derivation its edges file is
+ // published under (internal/host's metricsBoxLabel), handed down through
+ // WithBoxLabel for the reason the issuer gate is: that package imports
+ // this one and never the reverse. It is what the home map excludes its own
+ // hosts by; "" (bare `carlos edge`) leaves the map offering nothing.
+ box string
+ // homes, when non-nil, is the mirror of control/edges/ plus the mesh keys
+ // (homemap.go): who homes a host this box does not serve, and what proves
+ // an inbound hop. Atomic because it is armed in RunWithActivatorReady after
+ // construction, and read on the request path while that is happening.
+ homes atomic.Pointer[homeMap]
+ // backhaulTransports caches the outbound TLS transport of each backhaul
+ // target, keyed by the home box's EIP — see backhaulTransport. Bounded by
+ // the number of BOXES in the deployment, never by hostnames: a catch-all
+ // row in another box's edges file makes every subdomain under it a
+ // backhaul target, and keying per host would let a stranger's stream of
+ // invented hostnames grow this map without limit.
+ backhaulTransports sync.Map
}
// RunOption configures the Edge that Run/RunWithActivator* builds, before it
@@ -181,6 +201,14 @@ type RunOption func(*Edge)
// as it always has. See IssuerGate (certsync.go).
func WithIssuerGate(g IssuerGate) RunOption { return func(e *Edge) { e.issuer = g } }
+// WithBoxLabel hands the edge this box's own label, the one its edges file is
+// published under (internal/host's metricsBoxLabel — the same call the edges
+// publisher makes, so the two can never disagree about which file is ours).
+// Without it the home map excludes nothing and therefore offers nothing: an
+// edge that cannot recognise its own hosts must not hand out dial targets.
+// See homemap.go.
+func WithBoxLabel(label string) RunOption { return func(e *Edge) { e.box = label } }
+
// upstream is one served host: the handler for its backend, plus — when the
// activator manages it — the route itself, so a request can trigger the wake.
type upstream struct {
@@ -782,8 +810,38 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
e.tunnels.ServeHTTP(w, r)
return
}
+ // Backhaul hop acceptance (homemap.go). Deliberately placed HERE — after
+ // the reserved tunnel hostname's branch above, which stays byte-for-byte
+ // what it was (that hostname is not a registry route, so it can never
+ // appear in any box's edges file and can never be a hop's target), and
+ // before the route lookup, so that a verified hop naming a host we do not
+ // serve is answered by the stale-map refusal rather than the ordinary
+ // unknown-host answer.
+ //
+ // On a request carrying no X-Carlos-Hop this is one Header.Get and
+ // nothing else.
+ r, hopped := e.acceptHop(r, host)
+
up, ok := e.lookupUpstream(host)
if !ok {
+ if hopped {
+ // A PROVEN hop from a sibling edge, for a host that has moved (or
+ // never lived) here: its map is stale. 503 + Retry-After: 2, and
+ // this return is the structural half of "one hop, ever" — the
+ // backhaul dial below lives further down this same branch, so an
+ // accepted hop physically cannot reach it. (e.backhaul opens with
+ // the same refusal anyway, so moving this branch cannot open a
+ // forwarding loop either — see its doc.)
+ staleHopRefusal(w)
+ return
+ }
+ // Nothing here serves this host — but another box in the deployment
+ // may (backhaul, below). It answers false when no box homes the host,
+ // or when nothing could prove the hop, and today's unknown-host answer
+ // then follows exactly as it always has.
+ if e.backhaul(w, r, host) {
+ return
+ }
e.unknownHost(w, r)
return
}
@@ -1242,10 +1300,11 @@ func (e *Edge) unknownHost(w http.ResponseWriter, r *http.Request) {
// fallback URL instead of a redirect into a doomed TLS handshake (no cert
// will ever exist for them).
//
-// This gate uses lookupUpstream, NOT Allowed: "does something answer this
-// host on 443?" is a ROUTING question (lookupUpstream's job, including its
-// one-label wildcard-alias fallback), not an issuance question (Allowed's
-// job, deliberately exact-only — see its own doc comment). A wildcard-alias
+// This gate uses lookupUpstream (and, since the backhaul, the home map), NOT
+// Allowed: "does something answer this host on 443?" is a ROUTING question
+// (lookupUpstream's job, including its one-label wildcard-alias fallback, plus
+// the deployment's other edges), not an issuance question (Allowed's job,
+// deliberately exact-only — see its own doc comment). A wildcard-alias
// host like paul.woodstar.app resolves fine on 443 via the wildcard cert, so
// plain http:// must redirect it rather than 404. This is safe to widen past
// Allowed's exact match because a redirect grants nothing: it's a 302 to the
@@ -1271,6 +1330,20 @@ func (e *Edge) httpFallbackHandler() http.Handler {
if !routable {
_, routable = e.lookupUpstream(host)
}
+ if !routable {
+ // A host this box does not serve but another box HOMES: 443
+ // backhauls it (the hop, edge.go's backhaul), so :80 must send the
+ // visitor there rather than 404 an app that is up — a first-time
+ // visitor typing a bare hostname lands on whichever edge DNS
+ // picked, which in a steered deployment is routinely not the home.
+ // Home() is the same wildcard-capable lookup the dial uses, is
+ // nil-safe (no deployment store ⇒ no answer), and excludes this
+ // box's own hosts, so a single-box deployment is untouched.
+ //
+ // No hop logic here on purpose: the hop dials 443, so a verified
+ // hop can never arrive on this handler at all.
+ _, _, routable = e.homes.Load().Home(host)
+ }
if !routable {
e.unknownHost(w, r)
return
@@ -1364,7 +1437,7 @@ func unixProxy(socketPath string) *httputil.ReverseProxy {
dialer := &net.Dialer{}
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
- pr.SetXForwarded()
+ setForwarded(pr)
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = "carlos" // placeholder; the unix dialer ignores it
pr.Out.Host = pr.In.Host // backend sees the real hostname
@@ -1389,7 +1462,7 @@ func unixProxy(socketPath string) *httputil.ReverseProxy {
func tcpProxy(addr string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
- pr.SetXForwarded()
+ setForwarded(pr)
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = addr
pr.Out.Host = pr.In.Host // backend sees the real hostname
@@ -1476,7 +1549,7 @@ func multiProxy(host string, p *proberSet) *httputil.ReverseProxy {
dialer := &net.Dialer{Timeout: multiDialTimeout}
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
- pr.SetXForwarded()
+ setForwarded(pr)
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = multiDialHost // placeholder; the dialer below ignores it
pr.Out.Host = pr.In.Host // backend sees the real hostname
@@ -1561,7 +1634,7 @@ func multiProxy(host string, p *proberSet) *httputil.ReverseProxy {
func tunnelProxy(name string, hub *TunnelHub) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
- pr.SetXForwarded()
+ setForwarded(pr)
pr.Out.URL.Scheme = "https"
pr.Out.URL.Host = pr.In.Host // h2 :authority = the real hostname
pr.Out.Host = pr.In.Host
@@ -1615,7 +1688,7 @@ func fleetChannelFor(fleet, boxLabel string, hub *TunnelHub) string {
func fleetProxy(fleet, boxLabel string, hub *TunnelHub) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
- pr.SetXForwarded()
+ setForwarded(pr)
pr.Out.URL.Scheme = "https"
pr.Out.URL.Host = pr.In.Host // h2 :authority = the real hostname
pr.Out.Host = pr.In.Host
@@ -1643,6 +1716,255 @@ func fleetProxy(fleet, boxLabel string, hub *TunnelHub) *httputil.ReverseProxy {
}
}
+// ---- backhaul: the SENDING half of one-hop edge-to-edge forwarding ----------
+//
+// homemap.go holds the map (which OTHER box homes a host) and the RECEIVING
+// half (what proves an inbound hop, and what a stale one is answered with).
+// This is the other end: a request that landed on this edge for a host homed
+// elsewhere is forwarded to that box's edge — over ordinary, publicly-verified
+// TLS, exactly once — instead of dead-ending in the unknown-host answer.
+//
+// Three things are deliberately NOT done here, and each is a decision rather
+// than an omission:
+//
+// - NO METERING. The one usage meter (ServeHTTP, above) sits BELOW a
+// successful lookupUpstream, so a backhauled request misses it here by
+// construction while the HOME edge — which does serve the route — records
+// the request and its bytes exactly once. A second Record at this site
+// would double-count every backhauled request against the tenant. What
+// that leaves genuinely open is EGRESS ATTRIBUTION: the bytes cross the
+// internet twice and the second leg is billed to this box's account while
+// the meter runs on the home box's route. That is a unit-economics
+// question — the plan's Task 7 ruling — and not one to guess at from
+// inside a proxy.
+//
+// - NO CACHING AND NO COMPRESSION. This branch returns before ServeHTTP's
+// cache and gzip wrapping, so a backhauled response is neither stored nor
+// re-encoded here. That is load-bearing, not lazy: a tenant's Carlos-Purge
+// is honoured and stripped by the HOME edge's purgeModifier
+// (purge_header.go), so a copy cached on THIS box could never be
+// invalidated by the app that owns it. The home edge's own cache and
+// compression still apply and reach the client through the hop — as does
+// X-Carlos-Version, which the home edge stamps from the route's own row
+// (versionHeader is per-route, and this box has no row for the host), so
+// `carlos deploy`'s verification works through a backhaul with nothing
+// added here.
+//
+// - NO RETRY, AND NO SECOND TARGET. Home() answers with one box or none.
+// A failed dial is a 502 (see backhaulProxy's ErrorHandler), not a
+// scan for somewhere else to try: the process backing a home-boxed route
+// exists on exactly ONE box, so there is no second copy a retry could
+// reach.
+//
+// Single-box inertness: with one platform box Home() never offers a target
+// (own-box exclusion, homemap.go's foldHomes), so e.backhaul returns false on
+// every request and this whole path is dead code until a second box publishes
+// its edges file.
+
+// backhaulPort is the port a home edge answers on. Always 443 in production —
+// an edges file publishes a bare EIP (Task 1) and every edge's front door is
+// 443 — and a var only so a test can point the dial at an httptest server, the
+// same reason homeMapInterval is one.
+var backhaulPort = "443"
+
+// backhaulRootCAs is the trust anchor set the hop's TLS handshake verifies the
+// home edge's certificate against. nil — the system trust store, standard
+// verification — is the production answer and the only one: the home edge
+// presents the SAME publicly-trusted certificate it presents to any browser
+// (Part 2's bucket-distributed certs), so a hop needs no private CA, no
+// pinning, and above all no skipped verification. A var only so a test can
+// trust a fake home edge's self-signed cert.
+//
+// The bootstrap window this implies is stated rather than papered over: a box
+// that has just joined and whose cert for a host has not yet been distributed
+// presents something the dialer will not verify, so the hop fails the
+// handshake and the client gets a 502 until the cert mirror catches up. That
+// is the correct failure — the alternative, dialing without verification,
+// would make the hop's TLS decorative — and it heals on its own within a
+// mirror poll.
+var backhaulRootCAs *x509.CertPool
+
+// The hop's timeouts, per the plan. Deliberately no whole-body deadline: a
+// backhauled response may be a stream or an upgraded WebSocket that is
+// supposed to stay open for hours (the fleetapi client's ruling, same
+// reasoning), so the only bounds are on getting connected and on the home edge
+// beginning to answer.
+const (
+ backhaulDialTimeout = 5 * time.Second
+ backhaulHeaderTimeout = 30 * time.Second
+)
+
+// backhaul forwards a request for a host this box does not serve to the box
+// that does, and reports whether it answered. false means "not mine to
+// forward" and the caller falls through to the unchanged unknown-host answer:
+// either no box homes the host, or nothing could prove the hop.
+//
+// The hopVerified guard is FIRST, before the map is even consulted. ServeHTTP
+// already refuses an arrived hop above this call (that is the structural half
+// of "one hop, ever"), so this line is unreachable today — which is exactly
+// why it is here: if this call is ever moved, or that branch reordered, a hop
+// that arrived from a sibling edge fails closed with the stale-map refusal
+// instead of being forwarded onward. A forwarding loop must take TWO mistakes,
+// not one.
+func (e *Edge) backhaul(w http.ResponseWriter, r *http.Request, host string) bool {
+ if hopVerified(r.Context()) {
+ staleHopRefusal(w)
+ return true
+ }
+ m := e.homes.Load()
+ eip, box, ok := m.Home(host)
+ if !ok {
+ return false
+ }
+ // Sign with the mirror's cached pair, .next preferred (signingKey, mesh.go)
+ // — never a store read on the request path. NO KEY MEANS NO DIAL: signHop
+ // would happily HMAC under an empty key, the home edge would refuse the
+ // result, and the request would have crossed a region to be served as an
+ // ordinary client with its forwarding chain stripped. Falling through to
+ // the unknown-host answer is both cheaper and honest about the deployment
+ // being mid-bootstrap.
+ key := signingKey(m.meshKeys())
+ if len(key) == 0 {
+ e.logThrottled("backhaul-no-mesh-key", fmt.Sprintf("%s is homed on %s but no mesh key is mirrored — not dialling (the request gets the unknown-host answer until the key lands)", host, box))
+ return false
+ }
+ // The MAC is over the CONCRETE request host — never the wildcard map key
+ // that may have matched it — because the home edge verifies against its own
+ // literal request host (hopMAC canonicalizes both, mesh.go).
+ auth := signHop(key, host, time.Now())
+ backhaulProxy(host, eip, box, auth, e.backhaulTransport(eip)).ServeHTTP(w, r)
+ return true
+}
+
+// backhaulTransport is the cached outbound transport for one home box. The
+// TRANSPORT is what is cached and the ReverseProxy above is what is rebuilt
+// per request: a proxy value is two closures, while a transport owns the
+// connection pool, and a cross-region TCP+TLS handshake on every backhauled
+// page is a real, visible cost.
+//
+// Keyed by EIP and NOT by host, which is both the cheaper and the safer half
+// of that trade:
+//
+// - Nothing about the connection is per-host. The dial goes to the box, and
+// the TLS ServerName is derived per request from the outbound URL's
+// authority — backhaulProxy sets it to the real hostname — so one
+// transport serves every host homed on one box while each still gets its
+// own SNI, its own certificate verification, and (the stdlib pools by that
+// same authority) its own pooled connections.
+// - Per host, this map would be unbounded. A catch-all row in another box's
+// edges file homes every subdomain under it, so a stranger asking for a
+// stream of invented hostnames would mint a transport apiece. Per box it
+// is bounded by the deployment, and the pooled connections inside it are
+// bounded by MaxIdleConns and IdleConnTimeout as usual.
+//
+// A host whose home MOVES therefore lands on the new box's transport, with the
+// old box's pooled connections left to idle out — no equivalent of the pinning
+// multiProxy pays DisableKeepAlives to avoid, because the authority a
+// connection is pooled under names the host and the transport it lives in
+// names the box.
+//
+// HTTP/2 is not attempted, and that is on purpose: ForceAttemptHTTP2 stays
+// false (the stdlib disables the automatic upgrade anyway once TLSClientConfig
+// or a custom dialer is set), so the hop speaks HTTP/1.1 and
+// httputil.ReverseProxy's protocol-switch path — the one that carries a
+// WebSocket through — works exactly as it does for every other proxy in this
+// file.
+func (e *Edge) backhaulTransport(eip string) *http.Transport {
+ if tr, ok := e.backhaulTransports.Load(eip); ok {
+ return tr.(*http.Transport)
+ }
+ dialer := &net.Dialer{Timeout: backhaulDialTimeout}
+ tr := &http.Transport{
+ // The address is ignored, multiProxy-style: it is the outbound URL's
+ // authority, which is the HOST (for SNI, verification and pooling) and
+ // never where the bytes go. The home edge is at its EIP.
+ DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
+ return dialer.DialContext(ctx, network, net.JoinHostPort(eip, backhaulPort))
+ },
+ // No ServerName: the stdlib fills it from the outbound URL's authority,
+ // which is exactly the host being asked for. RootCAs nil is the system
+ // trust store — standard verification, see backhaulRootCAs.
+ TLSClientConfig: &tls.Config{
+ RootCAs: backhaulRootCAs,
+ MinVersion: tls.VersionTLS12,
+ },
+ TLSHandshakeTimeout: backhaulDialTimeout,
+ ResponseHeaderTimeout: backhaulHeaderTimeout,
+ // PER-HOST as well as in total, which is where this diverges from
+ // unixProxy/tcpProxy's bare MaxIdleConns: their pool key is one constant
+ // localhost authority, so the stdlib default of 2 idle conns per host is
+ // really 2 per BACKEND. Here the key is the hostname, so the default
+ // would cap each backhauled host at 2 — and the third concurrent request
+ // to a busy host would pay the cross-region TCP+TLS handshake this cache
+ // exists to avoid. Equal to MaxIdleConns so the total is the only bound.
+ MaxIdleConns: 32,
+ MaxIdleConnsPerHost: 32,
+ IdleConnTimeout: 90 * time.Second,
+ }
+ // Two requests for a box nobody has dialled yet can both build one; the
+ // loser's transport is simply dropped, having opened nothing and started no
+ // goroutine.
+ if existing, loaded := e.backhaulTransports.LoadOrStore(eip, tr); loaded {
+ return existing.(*http.Transport)
+ }
+ return tr
+}
+
+// backhaulProxy is the hop itself: an ordinary reverse proxy to the home box,
+// carrying the client's Host through unchanged so the home edge routes on the
+// real hostname, and carrying the two hop headers that make it a hop rather
+// than an anonymous request from a stranger with a data-centre IP.
+//
+// The outbound URL names the HOST, not the EIP, and the transport's dialer is
+// what turns that into bytes at the home box (backhaulTransport). The
+// authority is therefore doing three jobs at once and all three want the
+// hostname: the TLS ServerName the home edge selects a certificate by, the
+// name that certificate is verified against, and the key the connection is
+// pooled under.
+//
+// It takes the signature ready-made instead of minting one inside Rewrite, so
+// that the "no key, no dial" decision (backhaul, above) and the signing happen
+// at the SAME read of the mirror's key pair — a Rewrite that signed for itself
+// could still reach for a key that had gone away since the guard.
+//
+// X-Forwarded-For is setForwarded's single-element chain and nothing more: the
+// client that dialled THIS edge, with neither this edge's own address nor the
+// home EIP appended. That is the contract homemap.go's setForwarded documents
+// from the receiving end — the home edge restores this chain verbatim on a
+// verified hop, so the app's last XFF element is the real client, exactly as
+// it would be if the request had landed on the home box directly. (The restore
+// branch cannot fire on THIS side: backhaul only runs when the request did not
+// arrive as a verified hop.)
+func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper) *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ setForwarded(pr)
+ pr.Out.URL.Scheme = "https"
+ pr.Out.URL.Host = host // SNI + verification + pool key; the dial is the transport's
+ pr.Out.Host = pr.In.Host // the home edge routes on the real hostname
+ pr.Out.Header.Set(hopHeader, "1")
+ pr.Out.Header.Set(hopAuthHeader, auth)
+ // The same discipline every other Rewrite here keeps, and needed for
+ // the same reason one hop further along: the home edge scrubs it
+ // again before the app, but a header this edge forwards is a header
+ // this edge vouched for.
+ scrubInboundPurgeHeader(pr)
+ },
+ Transport: tr,
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ // 502, not the 503 + Retry-After that multiProxy and fleetProxy
+ // answer with: those two KNOW nobody is home (an empty live set, no
+ // connected channel), while this one believed a specific box was
+ // there and could not reach it. A failed dial, a TLS handshake
+ // against a cert the mirror has not distributed yet, or a home edge
+ // that hung up mid-response are all genuinely bad gateways — and the
+ // cert case heals itself on the next mirror poll.
+ log.Printf("edge: backhaul %s → %s (%s): %v", host, box, eip, err)
+ w.WriteHeader(http.StatusBadGateway)
+ },
+ }
+}
+
// dialUnixWithRetry masks the brief gap during an instance restart: while the
// old process has exited and the new one hasn't rebound the socket, dials fail
// with ENOENT/ECONNREFUSED. Retry those for a short window so no in-flight
@@ -1818,6 +2140,21 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
cancel()
}
+ // The home map and the mesh keys (homemap.go), armed beside the cert
+ // mirror and on the same terms: nil when there is no deployment store to
+ // mirror from, one synchronous refresh before anything listens so the
+ // first request does not meet an empty map, then the 30s loop. Stored
+ // before either listener binds, so ServeHTTP never sees a half-armed edge.
+ if hm := e.newHomeMap(); hm != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), homeMapBootTimeout)
+ if err := hm.Refresh(ctx); err != nil {
+ log.Printf("edge: first home map refresh failed: %v — hosts homed on another box are unreachable from here until a later poll lands", err)
+ }
+ cancel()
+ e.homes.Store(hm)
+ go hm.run(make(chan struct{})) // process-lifetime; never stopped, like the cert mirror's
+ }
+
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: certCache,
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
new file mode 100644
index 000000000..dec82865a
--- /dev/null
+++ b/internal/edge/homemap.go
@@ -0,0 +1,602 @@
+package edge
+
+// The home map, and the RECEIVING half of edge-to-edge backhaul (spec
+// docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md, Part 3,
+// Task 3).
+//
+// Two things live here because they are read on the same poll and used in the
+// same breath:
+//
+// - homeMap mirrors control/edges/ — the per-box files internal/host's
+// edgesFilePass publishes (its edgesFile shape, parsed here as
+// edgesFileView) — and folds them into one deployment-wide host→home
+// answer, Home(host). It is the same 30s list-then-get, carry-forward,
+// last-good mirror certsync.go's certMirror is; that shape survived heavy
+// review and is copied rather than reinvented. It NEVER writes: the
+// writer map gives control/edges/<box>.json to that box's own loop, and
+// nothing in this package bumps control/serial.
+//
+// - the mesh keys ride the SAME refresh (loadMeshKeys, mesh.go), so a
+// request path never reads the store: who homes a host and what key
+// proves a hop are answered from one poll's view of the bucket rather
+// than two.
+//
+// acceptHop is where a hop lands on the request path. The trust reasoning it
+// rests on — why the MAC binds the concrete host, why ±hopSkew is the replay
+// bound, and why dual-accept rotation means the request path must call
+// verifyHopEitherKey and never the single-key verifyHop — is written out in
+// mesh.go's file header; read that, this code does not re-derive it. Two
+// consequences of it are worth naming HERE because they are this file's
+// decisions rather than mesh.go's:
+//
+// - The replay envelope is deliberately loose and that is affordable. A
+// captured X-Carlos-Hop-Auth is good for at most 2×hopSkew against ONE
+// host, and what it buys a replayer is a request to a route the edge
+// already serves to the whole internet — no new reachability, no
+// privilege — PLUS, for that one host and that one window, XFF belief:
+// verification gates exactly one thing, whether the inbound
+// X-Forwarded-For is believed, and a valid MAC is what grants that
+// belief. What keeps the loose envelope affordable is not that XFF
+// belief is withheld — it is that capturing a valid Hop-Auth in the
+// first place requires breaking the hop's TLS or holding
+// control/edge-mesh-key outright, either of which already hands an
+// attacker strictly more than "who does this one host think the client
+// was for 2×hopSkew seconds."
+//
+// - XFF is trusted as an inbound chain to CARRY, never one to extend. On a
+// verified hop the chain the sending edge built is passed through
+// verbatim (setForwarded, below), so the app sees exactly what a
+// single-region app sees and the LAST element is still the client. The
+// backhaul leg is deliberately invisible in that header: this
+// deployment's own consumers take the last element as the caller
+// (internal/console/authcli.go's clientIP, and every tenant framework's
+// rightmost-public heuristic), so appending the sibling edge would make
+// every backhauled request look like it came from infrastructure. On
+// anything but a verified hop the chain is discarded outright: an
+// unverified sender is an ordinary client, and an ordinary client has
+// never been allowed to state its own address here.
+//
+// One hop, ever, is STRUCTURAL and not a policy check: a verified hop that
+// names a host this box does not serve leaves through staleHopRefusal (503 +
+// Retry-After: 2) from inside the lookupUpstream-miss branch, BEFORE the point
+// where Task 4 adds the backhaul dial. There is no path from an accepted hop
+// to Home() at all, so a re-forward cannot be introduced by adding a branch —
+// only by deleting one.
+//
+// Single-box inertness: with one platform box the only edges file is this
+// box's own, own-box exclusion drops it, and Home() therefore never offers a
+// target. Nothing signs hops either (Task 4 is the only signer, and it needs a
+// target), so acceptHop's verified branch is unreachable on a single-box
+// deployment — this task ships the receiving half, dormant.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "os"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// edgesPrefix is where every box publishes what it homes — one flat object
+// per box, control/edges/<box>.json (internal/host's edgesFileKey).
+const edgesPrefix = "control/edges/"
+
+// The hop's two headers, verbatim from the spec's Part 3. Task 2 left them
+// prose-only; this file is their first consumer, and Task 4's signer imports
+// these constants rather than spelling the strings a second time.
+const (
+ hopHeader = "X-Carlos-Hop"
+ hopAuthHeader = "X-Carlos-Hop-Auth"
+)
+
+// edgeRoleHome is the only role v1 writes, and the only one that routes.
+// Unknown roles (the spec's read-local anticipation) are IGNORED rather than
+// refused: a newer box publishing a role this build has never heard of must
+// not take its whole file — or the poll — down with it.
+const edgeRoleHome = "home"
+
+// homeMapInterval / homeMapTimeout / homeMapBootTimeout mirror the cert
+// mirror's cadence and budgets exactly (certsync.go), for the same reasons: a
+// home move is a placement event that converge heals, the poll runs on its own
+// goroutine and blocks nothing, and the ONE boot refresh must delay the front
+// door by seconds rather than minutes. Vars so tests can shorten them.
+var (
+ homeMapInterval = 30 * time.Second
+ homeMapTimeout = 2 * time.Minute
+ homeMapBootTimeout = 20 * time.Second
+)
+
+// edgesFileView is the READER's half of internal/host's edgesFile wire shape
+// (edgesfile.go). Deliberately a separate declaration rather than a shared
+// type: internal/host imports this package and never the reverse, and the
+// fields this side needs are the stable three (box, eip, hosts) plus the
+// version. Additive growth on the writer's side lands here as fields this
+// decoder ignores, which is the point.
+type edgesFileView struct {
+ V int `json:"v"`
+ Box string `json:"box"`
+ EIP string `json:"eip"`
+ Hosts map[string]edgesHostView `json:"hosts"`
+}
+
+// edgesHostView is the per-host object. An object rather than a bare string is
+// what makes the role field addable without a wire break — see edgeRoleHome.
+type edgesHostView struct {
+ Role string `json:"role"`
+}
+
+// homeEntry is one folded answer: where to dial, and which box that is.
+type homeEntry struct {
+ eip string
+ box string
+}
+
+// homeSnapshot is one poll's result, swapped in whole and immutable once
+// published, so a reader holds no lock past the pointer read.
+type homeSnapshot struct {
+ // files is the parsed per-box file, keyed by its BUCKET key. This is the
+ // carry-forward unit: an object that could not be read (or could not be
+ // parsed) this poll keeps its previous parse, per box, so one bad file
+ // never costs the deployment its whole map.
+ files map[string]edgesFileView
+ // hosts is the fold: host → home, own box already excluded.
+ hosts map[string]homeEntry
+}
+
+var emptyHomeSnapshot = &homeSnapshot{files: map[string]edgesFileView{}, hosts: map[string]homeEntry{}}
+
+type homeMap struct {
+ s release.Store
+ // self is THIS box's label — the same derivation the box's own edges file
+ // is published under (internal/host's metricsBoxLabel), handed down at
+ // edge construction through WithBoxLabel. "" means this process does not
+ // know which box it is, and Home then offers nothing at all: see its doc.
+ self string
+
+ // refreshMu serializes Refresh's whole body, for the reason certMirror's
+ // does — two overlapping polls could otherwise publish out of order, the
+ // slower one clobbering the fresher.
+ refreshMu sync.Mutex
+
+ mu sync.RWMutex
+ snap *homeSnapshot
+ // partialPolls counts polls that could not read (or parse) every object,
+ // for the first-and-every-tenth log cadence.
+ partialPolls int
+
+ // The mirrored mesh keys, read on the same refresh. Replaced only on a
+ // SUCCESSFUL load: a bucket blip leaves the last good key verifying hops,
+ // exactly as a blip leaves the last good host map routing them.
+ keyMu sync.RWMutex
+ keyCurrent []byte
+ keyNext []byte
+}
+
+func newHomeMap(s release.Store, self string) *homeMap {
+ return &homeMap{s: s, self: self, snap: emptyHomeSnapshot}
+}
+
+// newHomeMap is the Edge's map, or nil when there is nothing to mirror FROM —
+// the same no-deployment-store case that leaves the cert mirror nil. A box
+// with no deployment bucket is not part of any mesh: it offers no backhaul
+// target AND verifies no inbound hop (no key to verify against), which is the
+// fail-closed answer either way.
+func (e *Edge) newHomeMap() *homeMap {
+ if e.staticStore == nil {
+ return nil
+ }
+ return newHomeMap(e.staticStore, e.box)
+}
+
+// Refresh rebuilds the host fold and re-reads the mesh keys. The two halves
+// fail INDEPENDENTLY — a bucket that answered the edges list but not the key
+// object should still land the fold — so both run and their errors join.
+// Whatever fails leaves its own last-good in place; the caller logs.
+func (m *homeMap) Refresh(ctx context.Context) error {
+ m.refreshMu.Lock()
+ defer m.refreshMu.Unlock()
+ return errors.Join(m.refreshHosts(ctx), m.refreshKeys(ctx))
+}
+
+// refreshHosts rebuilds the whole map from one list plus a get per key — no
+// delta cleverness, for certMirror.Refresh's reason: a full rebuild is what
+// makes a RETIRED box's hosts actually leave the map.
+//
+// An error leaves the last-good snapshot in place. That matters as much here
+// as it does for certs: an empty map on a bucket blip would stop every
+// backhaul in the deployment at once, turning a blip into a regional outage
+// for every host homed elsewhere.
+func (m *homeMap) refreshHosts(ctx context.Context) error {
+ keys, err := m.s.List(ctx, edgesPrefix)
+ if err != nil {
+ return fmt.Errorf("list %s: %w", edgesPrefix, err)
+ }
+ prev := m.snapshot()
+ next := make(map[string]edgesFileView, len(keys))
+ // failed counts both fault classes; unread counts only the objects the
+ // BUCKET would not hand over, which is the one that says "this is not an
+ // object fault at all" when it covers everything (see below).
+ var tried, failed, unread int
+ var lastErr error
+ for _, key := range keys {
+ // The prefix is flat and every member is <box>.json. Anything else
+ // under it was not written by this deployment's edges loop.
+ name, ok := strings.CutPrefix(key, edgesPrefix)
+ if !ok || !strings.HasSuffix(name, ".json") || strings.Contains(name, "/") {
+ continue
+ }
+ tried++
+ body, _, err := m.s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ // Raced a delete between the list and the get — the box was
+ // retired mid-poll. Not a fault; it is simply gone.
+ continue
+ }
+ // ONE bad object is not a bad poll, whichever way it went bad.
+ // Aborting would discard every other box's file that landed this tick,
+ // and a permanently unreadable (or permanently corrupt) key would
+ // freeze the map forever. Carry this box's previous parse forward and
+ // keep going.
+ if err != nil {
+ unread++
+ } else if f, perr := parseEdgesFile(key, body); perr != nil {
+ err = perr
+ } else {
+ next[key] = f
+ continue
+ }
+ failed++
+ lastErr = err
+ if old, ok := prev.files[key]; ok {
+ next[key] = old
+ }
+ }
+ if tried > 0 && unread == tried {
+ // Nothing at all could be READ: a bucket fault, not an object fault,
+ // reported like a failed List — last-good stands. Note the bound is
+ // unread and not failed: a deployment whose only edges file is a
+ // corrupt one has a bucket that answered perfectly well, and calling
+ // that a bucket fault would report a blip that is not happening.
+ return fmt.Errorf("get %s*: every one of %d object(s) was unreadable, last: %w", edgesPrefix, tried, lastErr)
+ }
+ if failed > 0 {
+ m.logPartialPoll(failed, tried, lastErr)
+ }
+ snap := &homeSnapshot{files: next, hosts: foldHomes(next, m.self)}
+ m.mu.Lock()
+ m.snap = snap
+ m.mu.Unlock()
+ return nil
+}
+
+// parseEdgesFile decodes one box's file. A parse failure gets the same
+// treatment an unreadable object gets — carry the last good parse forward —
+// because a half-written body is no more usable than a body that never
+// arrived, and the fix for both is the publisher's next pass.
+func parseEdgesFile(key string, body []byte) (edgesFileView, error) {
+ var f edgesFileView
+ if err := json.Unmarshal(body, &f); err != nil {
+ return edgesFileView{}, fmt.Errorf("%s: %w", key, err)
+ }
+ if f.V < 1 {
+ // No version at all: not a file this reader wrote a decoder for. A
+ // HIGHER version is accepted on purpose — the schema grows additively
+ // (the plan's global constraint), so a newer box's file still carries
+ // the box/eip/hosts this reader needs, and refusing it would black out
+ // backhaul to every box that upgraded first.
+ return edgesFileView{}, fmt.Errorf("%s: unversioned edges file", key)
+ }
+ return f, nil
+}
+
+// logPartialPoll reports unreadable or unparseable files on the loop's own
+// first-and-every-tenth cadence: a permanently bad object would otherwise say
+// the same thing every 30 seconds forever.
+func (m *homeMap) logPartialPoll(failed, tried int, err error) {
+ m.mu.Lock()
+ m.partialPolls++
+ n := m.partialPolls
+ m.mu.Unlock()
+ if n == 1 || n%10 == 0 {
+ log.Printf("edge: %d of %d object(s) under %s unreadable this poll (#%d, last: %v) — the previous copy of each is carried forward", failed, tried, edgesPrefix, n, err)
+ }
+}
+
+// foldHomes turns the per-box files into one host→home map.
+//
+// Three exclusions, each of which is a request this box must never make:
+//
+// - OWN BOX. A host homed here is SERVED here; offering it as a backhaul
+// target would have the edge dial itself. Excluded by the file's own box
+// field rather than by its object name, because the field is what the
+// publisher stamps and what self is derived to match.
+// - NO EIP. Task 1 publishes a box with no discoverable public IP as
+// eip:"" so the DR runbook still gets its hosts list. There is nothing to
+// dial, so it is not a target.
+// - A ROLE THIS BUILD DOES NOT KNOW. Only "home" routes. The role field
+// exists so read-local roles can be added later; a reader that treated
+// every role as a home would send traffic to a box that had declared it
+// serves only reads.
+//
+// Iteration is over SORTED file keys so a duplicate claim — two boxes both
+// saying they home one host, which is a placement bug mid-move — resolves the
+// same way on every edge in the deployment. Convergent-but-wrong beats
+// split-and-wrong: the whole fleet aiming at one box is a condition that heals
+// on the next publish, where half the fleet aiming at each is a condition that
+// looks fine from any single box.
+func foldHomes(files map[string]edgesFileView, self string) map[string]homeEntry {
+ keys := make([]string, 0, len(files))
+ for k := range files {
+ keys = append(keys, k)
+ }
+ slices.Sort(keys)
+ hosts := make(map[string]homeEntry)
+ for _, k := range keys {
+ f := files[k]
+ if f.Box == "" || f.Box == self || f.EIP == "" {
+ continue
+ }
+ for host, h := range f.Hosts {
+ if host == "" || h.Role != edgeRoleHome {
+ continue
+ }
+ if _, dup := hosts[host]; dup {
+ continue // first box in sorted order wins, everywhere
+ }
+ hosts[host] = homeEntry{eip: f.EIP, box: f.Box}
+ }
+ }
+ return hosts
+}
+
+func (m *homeMap) snapshot() *homeSnapshot {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.snap
+}
+
+// Home answers "which OTHER box homes this host, and where do I dial it?".
+//
+// The lookup is lookupUpstream's, rule for rule, and that is load-bearing
+// rather than tidy: Task 1 publishes registry row hosts VERBATIM, catch-all
+// "*.<parent>" rows included, so an exact match wins and a miss retries with
+// the leading dot of the FIRST label swapped for "*" — one label, never two,
+// and never the parent matching its own wildcard. Deliberately NOT
+// case-folded, for the same reason: a host lookupUpstream would miss on case
+// must miss here too, or this edge would backhaul a request the home edge
+// would then refuse with a 503.
+//
+// A nil map (no deployment store) and a map with no self label both answer
+// "nothing". The self case is fail-closed on purpose: without knowing which
+// box it is, this edge cannot exclude its own hosts, and an edge that cannot
+// exclude itself must not be handing out dial targets at all.
+func (m *homeMap) Home(host string) (eip, box string, ok bool) {
+ if m == nil || m.self == "" {
+ return "", "", false
+ }
+ hosts := m.snapshot().hosts
+ if h, ok := hosts[host]; ok {
+ return h.eip, h.box, true
+ }
+ if i := strings.IndexByte(host, '.'); i > 0 {
+ if h, ok := hosts["*"+host[i:]]; ok {
+ return h.eip, h.box, true
+ }
+ }
+ return "", "", false
+}
+
+// refreshKeys re-reads the mesh key pair. loadMeshKeys is already tolerant of
+// a malformed .next and fail-closed on the current key (mesh.go); this only
+// adds the mirror's own posture on top — the pair is replaced ONLY on a clean
+// read, so a bucket blip leaves the last good key verifying hops.
+//
+// A clean read that finds NOTHING does replace the pair with nil, and that is
+// not a blip: an absent control/edge-mesh-key means no hop can be proven, and
+// refusing every hop (they degrade to ordinary clients) is the only safe
+// reading of "the deployment has no mesh key".
+func (m *homeMap) refreshKeys(ctx context.Context) error {
+ current, next, err := loadMeshKeys(ctx, m.s)
+ if err != nil {
+ return err
+ }
+ m.keyMu.Lock()
+ m.keyCurrent, m.keyNext = current, next
+ m.keyMu.Unlock()
+ return nil
+}
+
+// meshKeys is the request path's cached view of the key pair — never a store
+// read per request. Both may be nil (nothing minted yet, or nothing mirrored
+// yet), which verifyHopEitherKey answers as "no".
+func (m *homeMap) meshKeys() (current, next []byte) {
+ if m == nil {
+ return nil, nil
+ }
+ m.keyMu.RLock()
+ defer m.keyMu.RUnlock()
+ return m.keyCurrent, m.keyNext
+}
+
+// run polls until stop closes — the edge's standing background-loop shape,
+// the same one certMirror.run uses: fixed ticker, errors counted and logged at
+// the first and then every tenth, recovery logged once.
+func (m *homeMap) run(stop <-chan struct{}) {
+ log.Printf("edge: home map armed (%s poll of %s*, this box is %q) — hosts homed on another box are reachable through it", homeMapInterval, edgesPrefix, m.self)
+ tick := time.NewTicker(homeMapInterval)
+ defer tick.Stop()
+ errs := 0
+ for {
+ select {
+ case <-stop:
+ return
+ case <-tick.C:
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), homeMapTimeout)
+ err := m.Refresh(ctx)
+ cancel()
+ if err != nil {
+ errs++
+ if errs == 1 || errs%10 == 0 {
+ log.Printf("edge: home map refresh failed (#%d): %v — serving the last good map and key", errs, err)
+ }
+ continue
+ }
+ if errs > 0 {
+ log.Printf("edge: home map refresh recovered after %d failure(s)", errs)
+ errs = 0
+ }
+ }
+}
+
+// ---- hop acceptance on the request path -------------------------------------
+
+// hopVerifiedKey marks a request that arrived as a PROVEN hop from another
+// edge in this deployment's mesh. A context value rather than a header,
+// because a header is something a client can send: nothing outside this file
+// can put this mark on a request, which is exactly the property the XFF trust
+// below rests on.
+type hopVerifiedKey struct{}
+
+// hopVerified reports whether this request arrived as a verified hop. Task 4's
+// forwarding decision reads it too — a request that ARRIVED as a hop never
+// consults the home map.
+func hopVerified(ctx context.Context) bool {
+ v, _ := ctx.Value(hopVerifiedKey{}).(bool)
+ return v
+}
+
+// acceptHop is the whole of hop handling on the request path, and it runs
+// before the route lookup so that BOTH of its outcomes are decided ahead of
+// anything that serves:
+//
+// no hop header → nothing happens at all (the overwhelming case: one
+// Header.Get on a request that is not a hop).
+// verified hop → the mark goes on the context, the inbound
+// X-Forwarded-For is believed, and the caller serves
+// the route locally or refuses (staleHopRefusal).
+// unverified hop → every forwarded header is stripped and the sender is
+// an ordinary client from here on.
+//
+// The hop headers themselves are deleted in ALL hop cases, verified included:
+// they are the edge's vocabulary between boxes and have no business reaching a
+// tenant's app, and a stripped header cannot be laundered back out by an app
+// that reflects what it was sent (scrubInboundPurgeHeader, purge_header.go,
+// records what that class of bug already cost this package).
+//
+// host is the caller's already-port-stripped request host; canonicalization
+// into the exact bytes the MAC covers is hopMAC's job, in one place, so a
+// signer and a verifier can never disagree (mesh.go).
+func (e *Edge) acceptHop(r *http.Request, host string) (*http.Request, bool) {
+ if r.Header.Get(hopHeader) == "" {
+ // A signature with no marker beside it is not a hop — but it is still
+ // this edge's vocabulary, and the invariant above is "deleted in ALL
+ // hop cases" without an exception for the half a client made up.
+ // scrubInboundPurgeHeader deletes both purge headers unconditionally
+ // for exactly this reason (purge_header.go): an app that reflects what
+ // it was sent turns a client-supplied edge header into an
+ // app-DECLARED one, and no tenant's reflection bug should be able to
+ // put hop vocabulary back on the wire. The Get stays the fast path's
+ // single lookup — the Del only happens on a request that actually
+ // carries the stray header, which is never, in the ordinary case.
+ if r.Header.Get(hopAuthHeader) != "" {
+ r.Header.Del(hopAuthHeader)
+ }
+ return r, false
+ }
+ auth := r.Header.Get(hopAuthHeader)
+ r.Header.Del(hopHeader)
+ r.Header.Del(hopAuthHeader)
+
+ // verifyHopEitherKey, never verifyHop: the single-key form cannot accept a
+ // signer that has already moved to .next, and a rotation is precisely when
+ // refusing hops would hurt. Keys come from the mirror's cache — a hop must
+ // never cost a bucket read.
+ current, next := e.homes.Load().meshKeys()
+ if auth == "" || !verifyHopEitherKey(current, next, host, auth, time.Now()) {
+ stripForwardedHeaders(r)
+ return r, false
+ }
+ return r.WithContext(context.WithValue(r.Context(), hopVerifiedKey{}, true)), true
+}
+
+// stripForwardedHeaders discards every claim a request makes about who sent
+// it. httputil.ReverseProxy's Rewrite path already deletes these from the
+// OUTBOUND request, so for a proxied route this is belt and braces — but the
+// edge serves plenty that is not a ReverseProxy (static trees, the status
+// host, cached replays, the activator's dispatch), and all of them read this
+// same inbound request.
+func stripForwardedHeaders(r *http.Request) {
+ r.Header.Del("Forwarded")
+ r.Header.Del("X-Forwarded-For")
+ r.Header.Del("X-Forwarded-Host")
+ r.Header.Del("X-Forwarded-Proto")
+}
+
+// staleHopRefusal answers a VERIFIED hop that named a host this box does not
+// serve: the sending edge's home map is stale, mid-move. 503 + Retry-After: 2
+// is the same "come back shortly" answer a dark fleet gets (fleetProxy).
+// Retry-After: 2 is a keep-trying hint, not a heal estimate: there is no 2s
+// serial-converge path here (edges never bump control/serial), so what
+// actually heals a stale map is the edges-file cycle — the sending box's own
+// 5min+jitter publish loop (internal/host's edgesFileLoop) followed by this
+// box's 30s mirror poll — which can leave a sender retrying into this refusal
+// for minutes during a home move, not seconds.
+//
+// The body says nothing. It is reachable only by a party holding the mesh key
+// — an unauthenticated stranger asking for the same host gets the ordinary,
+// unchanged unknownHost answer — so it cannot tell an outsider that a host
+// exists somewhere else in the deployment. Keeping it contentless holds that
+// line for the ONE case where it could still leak: a peer whose key is valid
+// but whose map is wrong learns only "not here", never "here is where it
+// went".
+func staleHopRefusal(w http.ResponseWriter) {
+ w.Header().Set("Retry-After", "2")
+ http.Error(w, "not served here", http.StatusServiceUnavailable)
+}
+
+// setForwarded is what every proxy Rewrite in this package calls in place of
+// pr.SetXForwarded(), and it exists for one narrow case.
+//
+// ReverseProxy's Rewrite path deletes Forwarded and X-Forwarded-* from the
+// outbound request before Rewrite runs, so SetXForwarded always writes a
+// single address: the peer that dialled us. That is right for a client and
+// wrong for a verified hop, where the peer that dialled us is a sibling edge
+// and the address the app wants is the one that edge already recorded.
+//
+// So on a verified hop — and only there — SetXForwarded's answer is REPLACED
+// by the inbound chain, verbatim. Not appended to: the last element has to
+// stay the client. This deployment's own consumer says so in as many words
+// (internal/console/authcli.go's clientIP: "the LAST element is the one to
+// take"), and tenant frameworks' rightmost-public heuristics agree — a chain
+// of "<client>, <sending edge EIP>" would rate-limit every backhauled console
+// request as one bucket per region and show tenant apps infrastructure in
+// place of their caller. Passing the chain through unchanged means a
+// backhauled request is indistinguishable from a local one at the app, which
+// is the whole promise of the hop. Multiple inbound X-Forwarded-For lines are
+// joined, which is what list-valued header semantics already say they mean
+// (RFC 9110 §5.3).
+//
+// SetXForwarded still runs first, and is still what sets X-Forwarded-Host and
+// X-Forwarded-Proto: the hop carries the original Host header and is itself
+// TLS, so both are correct as computed here and need no restore. It touches
+// only pr.Out, so reading the inbound chain after it is the same read as
+// before it.
+func setForwarded(pr *httputil.ProxyRequest) {
+ pr.SetXForwarded()
+ if !hopVerified(pr.In.Context()) {
+ return
+ }
+ if chain := pr.In.Header.Values("X-Forwarded-For"); len(chain) > 0 {
+ pr.Out.Header.Set("X-Forwarded-For", strings.Join(chain, ", "))
+ }
+}
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go
new file mode 100644
index 000000000..687a85927
--- /dev/null
+++ b/internal/edge/homemap_test.go
@@ -0,0 +1,1041 @@
+package edge
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// putJSON writes a control-plane object body verbatim, replacing whatever is
+// there. release.Store.Put with an empty ifMatch is create-only, so a test
+// that rewrites a key (the last-good and stale-file cases below) has to carry
+// the etag — the same putReplacing shape certsync.go uses in production.
+func putJSON(t *testing.T, s release.Store, key, body string) {
+ t.Helper()
+ _, etag, err := s.Get(context.Background(), key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ t.Fatalf("get %s: %v", key, err)
+ }
+ if _, err := s.Put(context.Background(), key, []byte(body), etag); err != nil {
+ t.Fatalf("put %s: %v", key, err)
+ }
+}
+
+func refreshHomeMap(t *testing.T, m *homeMap) {
+ t.Helper()
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatalf("refresh: %v", err)
+ }
+}
+
+func TestHomeMapFoldsAndExcludesOwnBox(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"a.test":{"role":"home"}}}`)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+ if eip, box, ok := m.Home("b.test"); !ok || eip != "2.2.2.2" || box != "betty" {
+ t.Fatalf("b.test home = %s/%s/%v", eip, box, ok)
+ }
+ if _, _, ok := m.Home("a.test"); ok {
+ t.Fatal("own-box hosts must not offer a backhaul target")
+ }
+}
+
+// Task 1's writer publishes registry row hosts VERBATIM, catch-all rows
+// included, so the reader owes lookupUpstream's one-label wildcard rule: an
+// exact match wins, then "*.<parent>" — and only ONE label, so a two-label
+// host misses exactly as it does locally.
+func TestHomeMapAppliesOneLabelWildcardRule(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json",
+ `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.apps.test":{"role":"home"},"exact.apps.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+
+ // Exact form.
+ if eip, _, ok := m.Home("exact.apps.test"); !ok || eip != "2.2.2.2" {
+ t.Fatalf("exact host = %s/%v, want the home edge", eip, ok)
+ }
+ // Wildcard form: one label under the catch-all parent.
+ if eip, box, ok := m.Home("paul.apps.test"); !ok || eip != "2.2.2.2" || box != "betty" {
+ t.Fatalf("one-label wildcard host = %s/%s/%v", eip, box, ok)
+ }
+ // Two labels: a miss here exactly as in lookupUpstream, which composes
+ // "*.b.apps.test" and finds nothing.
+ if _, _, ok := m.Home("a.b.apps.test"); ok {
+ t.Fatal("a two-label host must not match a one-label wildcard")
+ }
+ // The wildcard key itself is not a request host and must not resolve as
+ // one via the fallback ("*.apps.test" composes "*.apps.test" again).
+ if _, _, ok := m.Home("apps.test"); ok {
+ t.Fatal("the catch-all parent must not match its own wildcard")
+ }
+}
+
+// The role object is read-local anticipation (Task 1). Only "home" routes;
+// anything else is a role this build does not implement, and an unknown role
+// must neither route nor fail the poll.
+func TestHomeMapIgnoresUnknownRoles(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json",
+ `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"read.test":{"role":"read"},"home.test":{"role":"home"},"blank.test":{}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m) // an unknown role is not an error
+ if _, _, ok := m.Home("read.test"); ok {
+ t.Fatal("role:\"read\" is not a home — it must not offer a backhaul target")
+ }
+ if _, _, ok := m.Home("blank.test"); ok {
+ t.Fatal("an absent role is not a home")
+ }
+ if _, _, ok := m.Home("home.test"); !ok {
+ t.Fatal("role:\"home\" must still route alongside roles this build ignores")
+ }
+}
+
+// A box with no discoverable public IP publishes its hosts list with eip:""
+// (Task 1's DR-runbook case). There is nothing to dial, so it must never be
+// offered as a target — a "" EIP would otherwise become a dial to port 443 of
+// nothing.
+func TestHomeMapSkipsBoxesWithNoEIP(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+ if _, _, ok := m.Home("b.test"); ok {
+ t.Fatal("a box with no EIP has no dial address and must not be a backhaul target")
+ }
+}
+
+// Two boxes claiming the same host is a placement bug, but every edge must
+// still pick the SAME one — a coin flip per box would split traffic across a
+// move instead of converging on it.
+func TestHomeMapPicksTheSameHomeOnADuplicateClaim(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"b.test":{"role":"home"}}}`)
+ for i := 0; i < 5; i++ {
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+ if _, box, _ := m.Home("b.test"); box != "betty" {
+ t.Fatalf("duplicate claim resolved to %q, want the deterministic first (betty)", box)
+ }
+ }
+}
+
+// A box that is retired (its file deleted) must LEAVE the map — the fold is a
+// full rebuild from each list, never a merge into the previous one.
+func TestHomeMapForgetsADeletedEdgesFile(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+ if _, _, ok := m.Home("b.test"); !ok {
+ t.Fatal("precondition: b.test should be homed on betty")
+ }
+ if err := s.Delete(context.Background(), "control/edges/betty.json"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ refreshHomeMap(t, m)
+ if _, _, ok := m.Home("b.test"); ok {
+ t.Fatal("a retired box's hosts must leave the map on the next poll")
+ }
+}
+
+// One unreadable object is not a bad poll: the previous copy of that box's
+// file carries forward and every other box's file still lands, exactly as the
+// cert mirror does it.
+func TestHomeMapCarriesForwardOneUnreadableFile(t *testing.T) {
+ inner := meshTestStore(t)
+ s := &oneKeyFailingStore{Store: inner}
+ putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+
+ // betty's file goes unreadable; carol appears in the same poll.
+ putJSON(t, inner, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"c.test":{"role":"home"}}}`)
+ s.failKey("control/edges/betty.json")
+ refreshHomeMap(t, m)
+
+ if eip, _, ok := m.Home("b.test"); !ok || eip != "2.2.2.2" {
+ t.Fatalf("b.test = %s/%v — the previous copy of an unreadable file must carry forward", eip, ok)
+ }
+ if _, _, ok := m.Home("c.test"); !ok {
+ t.Fatal("one bad object must not discard the rest of the rebuild")
+ }
+}
+
+// A corrupt body is the same class of fault as an unreadable one: carry the
+// last good parse forward rather than dropping the box off the map.
+func TestHomeMapCarriesForwardACorruptFile(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty",`)
+ refreshHomeMap(t, m)
+ if eip, _, ok := m.Home("b.test"); !ok || eip != "2.2.2.2" {
+ t.Fatalf("b.test = %s/%v — a corrupt file must leave the last good parse standing", eip, ok)
+ }
+}
+
+// A failed List is a bucket fault: last-good stands and the error is returned
+// for the loop to log. An empty map here would silently stop every backhaul
+// in the deployment on one blip.
+func TestHomeMapKeepsLastGoodWhenTheBucketIsDown(t *testing.T) {
+ inner := meshTestStore(t)
+ s := &listFailingStore{Store: inner}
+ putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "annie")
+ refreshHomeMap(t, m)
+
+ s.setFail(true)
+ if err := m.Refresh(context.Background()); err == nil {
+ t.Fatal("a failed List must be reported, not swallowed")
+ }
+ if _, _, ok := m.Home("b.test"); !ok {
+ t.Fatal("last-good must stand through a bucket blip")
+ }
+}
+
+// An edge with no box identity of its own cannot exclude itself, so it must
+// never offer a target at all — fail closed rather than backhaul to itself.
+func TestHomeMapWithoutASelfLabelNeverOffersATarget(t *testing.T) {
+ s := meshTestStore(t)
+ putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`)
+ m := newHomeMap(s, "")
+ refreshHomeMap(t, m)
+ if _, _, ok := m.Home("b.test"); ok {
+ t.Fatal("an edge that does not know its own box label must not backhaul")
+ }
+}
+
+// A nil map (no deployment store, so no mesh at all) answers like an empty
+// one rather than panicking on the request path.
+func TestNilHomeMapIsInert(t *testing.T) {
+ var m *homeMap
+ if _, _, ok := m.Home("b.test"); ok {
+ t.Fatal("a nil home map must offer nothing")
+ }
+ if cur, next := m.meshKeys(); cur != nil || next != nil {
+ t.Fatal("a nil home map holds no mesh keys")
+ }
+}
+
+// ---- hop acceptance ---------------------------------------------------------
+
+// forwardEcho is an upstream that reports back exactly the trust-bearing
+// headers it was handed, so a test can assert both what survived and what was
+// stripped on the way in.
+func forwardEcho(t *testing.T) *httptest.Server {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "xff=%s|xfh=%s|hop=%s|auth=%s",
+ r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Host"),
+ r.Header.Get(hopHeader), r.Header.Get(hopAuthHeader))
+ }))
+ t.Cleanup(ts.Close)
+ return ts
+}
+
+// hopEdge builds an edge serving hosts from one echo backend, with a mesh key
+// minted and a home map armed over the same store — the receiving box of a
+// backhaul hop.
+func hopEdge(t *testing.T, self string, hosts ...string) (*Edge, *homeMap, []byte) {
+ t.Helper()
+ s := meshTestStore(t)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatalf("mint: %v", err)
+ }
+ backend := forwardEcho(t)
+ reg := openReg(t)
+ for _, h := range hosts {
+ if err := reg.Upsert(registry.Route{Host: h, Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert %s: %v", h, err)
+ }
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ t.Cleanup(e.probers.Stop)
+ m := newHomeMap(s, self)
+ refreshHomeMap(t, m)
+ e.homes.Store(m)
+ key, _ := m.meshKeys()
+ if len(key) == 0 {
+ t.Fatal("precondition: the mesh key must be mirrored")
+ }
+ return e, m, key
+}
+
+// hopRequest drives one request at the edge with the given headers.
+func hopRequest(t *testing.T, e *Edge, host string, headers map[string]string) *http.Response {
+ t.Helper()
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ req, err := http.NewRequest("GET", srv.URL+"/", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = host
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func readBody(t *testing.T, resp *http.Response) string {
+ t.Helper()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ return string(b)
+}
+
+// A verified hop is served locally, and the chain the sending edge appended
+// is the one thing an ordinary client is never trusted for.
+func TestVerifiedHopIsServedLocallyAndHonoursTheInboundXFF(t *testing.T) {
+ e, _, key := hopEdge(t, "annie", "app.test")
+ resp := hopRequest(t, e, "app.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "app.test", time.Now()),
+ "X-Forwarded-For": "203.0.113.9",
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ body := readBody(t, resp)
+ // Verbatim, with NO element appended: the last element must still be the
+ // client. internal/console/authcli.go's clientIP takes the last element as
+ // the caller and every tenant framework's rightmost-public heuristic does
+ // the same, so appending this edge's peer (the sibling edge) would make a
+ // backhauled request look as though infrastructure called it.
+ if !strings.HasPrefix(body, "xff=203.0.113.9|") {
+ t.Fatalf("body = %q — a verified hop's inbound XFF must pass through verbatim, last element still the client", body)
+ }
+ // The hop's own trust headers are the edge's vocabulary, never the app's.
+ if !strings.Contains(body, "|hop=|auth=") {
+ t.Fatalf("body = %q — the hop headers must not reach the app", body)
+ }
+}
+
+// The MAC binds the CONCRETE request host, and a catch-all route serves it
+// through lookupUpstream's wildcard fallback — the two halves of carry C1 on
+// the receiving side.
+func TestVerifiedHopServesAWildcardRoutedHost(t *testing.T) {
+ e, _, key := hopEdge(t, "annie", "*.apps.test")
+ resp := hopRequest(t, e, "paul.apps.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "paul.apps.test", time.Now()),
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — a catch-all route serves a hop like any other client", resp.StatusCode)
+ }
+}
+
+// The stale-map refusal: the sender's map named us for a host we do not
+// serve. 503 + Retry-After: 2, and structurally never a re-forward — the home
+// map here is POISONED to name another box for exactly that host, so anything
+// that consulted it would leave through a dial instead.
+func TestVerifiedHopForAnUnservedHostRefusesWithoutForwarding(t *testing.T) {
+ e, m, key := hopEdge(t, "annie", "app.test")
+ putJSON(t, m.s, "control/edges/betty.json",
+ `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`)
+ refreshHomeMap(t, m)
+ if _, _, ok := m.Home("moved.test"); !ok {
+ t.Fatal("precondition: the map must name a remote home for moved.test")
+ }
+
+ resp := hopRequest(t, e, "moved.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "moved.test", time.Now()),
+ })
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503 for a verified hop naming a host we do not serve", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "2" {
+ t.Fatalf("Retry-After = %q, want 2", got)
+ }
+ if body := readBody(t, resp); strings.Contains(body, "moved.test") || strings.Contains(body, "betty") {
+ t.Fatalf("body = %q — the refusal must not name the host or where it went", body)
+ }
+}
+
+// Verification failure is not an error path: it is "you are an ordinary
+// client", with every forwarding claim you made discarded.
+func TestUnverifiedHopIsServedAsAnOrdinaryClientWithForwardedHeadersStripped(t *testing.T) {
+ e, _, key := hopEdge(t, "annie", "app.test")
+ tampered := []byte(signHop(key, "app.test", time.Now()))
+ tampered[len(tampered)-1] ^= 'f' ^ '0' // flip the last hex nibble
+
+ resp := hopRequest(t, e, "app.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: string(tampered),
+ "X-Forwarded-For": "203.0.113.9",
+ "X-Forwarded-Host": "evil.test",
+ "X-Forwarded-Proto": "https",
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — an unverified hop is an ordinary client, not a rejection", resp.StatusCode)
+ }
+ body := readBody(t, resp)
+ if strings.Contains(body, "203.0.113.9") {
+ t.Fatalf("body = %q — an unverified sender's X-Forwarded-For must not be honoured", body)
+ }
+ if strings.Contains(body, "evil.test") {
+ t.Fatalf("body = %q — an unverified sender's X-Forwarded-Host must not be honoured", body)
+ }
+ if !strings.Contains(body, "|hop=|auth=") {
+ t.Fatalf("body = %q — the hop headers must be stripped either way", body)
+ }
+}
+
+// The strip is at the header level, not just a proxy side effect: every route
+// class behind the edge (static trees, the status host, the activator) reads
+// the same request.
+func TestAcceptHopStripsEveryForwardedHeaderOnFailedVerification(t *testing.T) {
+ e, _, _ := hopEdge(t, "annie", "app.test")
+ r := httptest.NewRequest("GET", "http://app.test/", nil)
+ r.Header.Set(hopHeader, "1")
+ r.Header.Set(hopAuthHeader, "0:deadbeef")
+ r.Header.Set("Forwarded", "for=203.0.113.9")
+ r.Header.Set("X-Forwarded-For", "203.0.113.9")
+ r.Header.Set("X-Forwarded-Host", "evil.test")
+ r.Header.Set("X-Forwarded-Proto", "https")
+
+ r, verified := e.acceptHop(r, "app.test")
+ if verified {
+ t.Fatal("a garbage auth header must not verify")
+ }
+ for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", hopHeader, hopAuthHeader} {
+ if got := r.Header.Get(h); got != "" {
+ t.Fatalf("%s survived an unverified hop as %q", h, got)
+ }
+ }
+ if hopVerified(r.Context()) {
+ t.Fatal("an unverified request must not carry the hop mark")
+ }
+
+ // The half-formed case: a signature with no X-Carlos-Hop beside it. It is
+ // not a hop and must not be treated as one — but the header is still this
+ // edge's vocabulary, and leaving it on the request would let an app that
+ // reflects its request headers launder it back onto the wire (the
+ // scrubInboundPurgeHeader precedent, purge_header.go).
+ stray := httptest.NewRequest("GET", "http://app.test/", nil)
+ stray.Header.Set(hopAuthHeader, "0:deadbeef")
+ stray, verified = e.acceptHop(stray, "app.test")
+ if verified {
+ t.Fatal("an auth header with no hop marker is not a hop")
+ }
+ if got := stray.Header.Get(hopAuthHeader); got != "" {
+ t.Fatalf("%s survived without a hop marker as %q — hop vocabulary must never reach the app", hopAuthHeader, got)
+ }
+}
+
+// The MAC is over the host, so a signature minted for one hop cannot be
+// replayed against another — and the timestamp bounds it in time.
+func TestHopVerificationIsBoundToTheHostAndTheClock(t *testing.T) {
+ e, _, key := hopEdge(t, "annie", "app.test", "other.test")
+
+ wrongHost := hopRequest(t, e, "app.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "other.test", time.Now()),
+ "X-Forwarded-For": "203.0.113.9",
+ })
+ if body := readBody(t, wrongHost); strings.Contains(body, "203.0.113.9") {
+ t.Fatalf("body = %q — a signature minted for another host must not verify", body)
+ }
+
+ stale := hopRequest(t, e, "app.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "app.test", time.Now().Add(-2*hopSkew)),
+ "X-Forwarded-For": "203.0.113.9",
+ })
+ if body := readBody(t, stale); strings.Contains(body, "203.0.113.9") {
+ t.Fatalf("body = %q — a signature outside the skew bound must not verify", body)
+ }
+}
+
+// Fail closed: with no mesh key mirrored (a brand new deployment, or a box
+// whose bucket is unreachable at boot) nothing verifies.
+func TestHopWithoutAMirroredMeshKeyIsAnOrdinaryClient(t *testing.T) {
+ e, _, key := hopEdge(t, "annie", "app.test")
+ e.homes.Store(nil) // no mesh at all — the no-deployment-store case
+ resp := hopRequest(t, e, "app.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "app.test", time.Now()),
+ "X-Forwarded-For": "203.0.113.9",
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if body := readBody(t, resp); strings.Contains(body, "203.0.113.9") {
+ t.Fatalf("body = %q — with no key to verify against, the sender is an ordinary client", body)
+ }
+}
+
+// An unserved host that NO box homes keeps today's answer exactly: 404, with
+// no Retry-After, and nothing said about the rest of the deployment.
+//
+// The home map here is deliberately non-empty and deliberately about a
+// DIFFERENT host: the map is consulted (Task 4's backhaul) and misses, which
+// is the only route to this answer now that a homed host is dialled instead.
+// The stale-map 503 stays reachable only by a party holding the mesh key, so
+// the two answers still cannot be told apart by a stranger.
+func TestUnknownHostWithNoHomeIsStillThe404(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "app.test")
+ putJSON(t, m.s, "control/edges/betty.json",
+ `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`)
+ refreshHomeMap(t, m)
+ resp := hopRequest(t, e, "nowhere.test", nil)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want the unchanged 404 for a host nothing in the deployment homes", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "" {
+ t.Fatalf("Retry-After = %q — a stranger must not be told to come back", got)
+ }
+}
+
+// Regression pin: an ordinary client's forged X-Forwarded-For is no more
+// trusted after this change than before it.
+func TestOrdinaryClientForgedXFFIsStillDiscarded(t *testing.T) {
+ e, _, _ := hopEdge(t, "annie", "app.test")
+ resp := hopRequest(t, e, "app.test", map[string]string{"X-Forwarded-For": "203.0.113.9"})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if body := readBody(t, resp); strings.Contains(body, "203.0.113.9") {
+ t.Fatalf("body = %q — a client-supplied XFF is never honoured", body)
+ }
+}
+
+// ---- backhaul dialing (the sending half) ------------------------------------
+
+// homeEdgeSeen is what a fake home edge was handed on the wire.
+type homeEdgeSeen struct {
+ n int
+ sni, host, hop, auth string
+ xff []string
+}
+
+type homeEdgeRecord struct {
+ mu sync.Mutex
+ seen homeEdgeSeen
+}
+
+func (h *homeEdgeRecord) get() homeEdgeSeen {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.seen
+}
+
+const homeEdgeBody = "served by the home edge"
+
+// fakeHomeEdge stands in for the box that homes a host: a TLS server that
+// records the ClientHello's SNI and the trust-bearing headers of whatever
+// arrives, then runs h (a plain body writer unless the test needs a protocol
+// switch).
+//
+// It also arms the two dial seams for the test's lifetime — backhaulPort, so
+// the dial reaches an ephemeral httptest port instead of 443, and
+// backhaulRootCAs, so this server's self-signed certificate is trusted the way
+// a real home edge's publicly-trusted one is. Nothing about verification is
+// weakened: httptest's certificate names example.com, so every test below asks
+// for exactly that host and a name mismatch would fail the handshake here just
+// as it would in production.
+func fakeHomeEdge(t *testing.T, h http.Handler) (*homeEdgeRecord, string) {
+ t.Helper()
+ rec := &homeEdgeRecord{}
+ if h == nil {
+ h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, homeEdgeBody) })
+ }
+ ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ rec.mu.Lock()
+ rec.seen.n++
+ rec.seen.host = r.Host
+ rec.seen.hop = r.Header.Get(hopHeader)
+ rec.seen.auth = r.Header.Get(hopAuthHeader)
+ rec.seen.xff = r.Header.Values("X-Forwarded-For")
+ rec.mu.Unlock()
+ h.ServeHTTP(w, r)
+ }))
+ ts.TLS = &tls.Config{GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
+ rec.mu.Lock()
+ rec.seen.sni = chi.ServerName
+ rec.mu.Unlock()
+ return nil, nil // the server's own config, unchanged — this hook only watches
+ }}
+ ts.StartTLS()
+ t.Cleanup(ts.Close)
+
+ eip, port, err := net.SplitHostPort(ts.Listener.Addr().String())
+ if err != nil {
+ t.Fatalf("split home edge addr: %v", err)
+ }
+ pool := x509.NewCertPool()
+ pool.AddCert(ts.Certificate())
+ oldPort, oldCAs := backhaulPort, backhaulRootCAs
+ backhaulPort, backhaulRootCAs = port, pool
+ t.Cleanup(func() { backhaulPort, backhaulRootCAs = oldPort, oldCAs })
+ return rec, eip
+}
+
+// homedOn publishes one box's edges file naming hosts as its own, and lands it
+// in the map — the second box a single-box deployment never has.
+func homedOn(t *testing.T, m *homeMap, box, eip string, hosts ...string) {
+ t.Helper()
+ entries := make([]string, 0, len(hosts))
+ for _, h := range hosts {
+ entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\"}", h))
+ }
+ putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf(
+ `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ",")))
+ refreshHomeMap(t, m)
+}
+
+// The whole of the sending half in one request: a host this box does not serve
+// but another box homes reaches that box over real TLS, with the SNI it will
+// need to present a certificate for, the real Host header, a hop signature the
+// home edge can verify, and an X-Forwarded-For that names only the client.
+func TestBackhaulDialsTheHomeEdgeOverVerifiedTLS(t *testing.T) {
+ e, m, key := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "example.com")
+
+ resp := hopRequest(t, e, "example.com", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — a remote-homed host must be served through its home edge", resp.StatusCode)
+ }
+ if body := readBody(t, resp); body != homeEdgeBody {
+ t.Fatalf("body = %q, want %q", body, homeEdgeBody)
+ }
+ seen := rec.get()
+ if seen.n != 1 {
+ t.Fatalf("home edge saw %d request(s), want exactly one — one hop, ever", seen.n)
+ }
+ if seen.sni != "example.com" {
+ t.Fatalf("SNI = %q, want the request host — the home edge answers with the cert for the host being asked for", seen.sni)
+ }
+ if seen.host != "example.com" {
+ t.Fatalf("Host = %q, want the real hostname — the home edge routes on it", seen.host)
+ }
+ if seen.hop != "1" {
+ t.Fatalf("%s = %q, want 1", hopHeader, seen.hop)
+ }
+ if !verifyHopEitherKey(key, nil, "example.com", seen.auth, time.Now()) {
+ t.Fatalf("%s = %q did not verify under the deployment's mesh key", hopAuthHeader, seen.auth)
+ }
+ // One element, the client. Neither this edge's own address nor the home EIP
+ // may be appended: the home edge restores this chain verbatim on a verified
+ // hop, and every consumer takes the LAST element as the caller
+ // (setForwarded, homemap.go).
+ if len(seen.xff) != 1 || strings.Contains(seen.xff[0], ",") {
+ t.Fatalf("X-Forwarded-For = %q, want a single-element chain naming only the client", seen.xff)
+ }
+}
+
+// Precedence, first rule: a host served HERE is served here, whatever the home
+// map says about it. The map is poisoned to name a reachable home for exactly
+// that host, so a lookup in the wrong order would show up as a dial.
+func TestLocalHitIgnoresTheHomeMap(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "example.com")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "example.com")
+
+ resp := hopRequest(t, e, "example.com", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if body := readBody(t, resp); !strings.HasPrefix(body, "xff=") {
+ t.Fatalf("body = %q, want the LOCAL backend's answer — a local hit must never consult the home map", body)
+ }
+ if n := rec.get().n; n != 0 {
+ t.Fatalf("home edge saw %d request(s), want none", n)
+ }
+}
+
+// One hop, ever: a request that ARRIVED as a verified hop is refused, never
+// forwarded onward, even when the map offers a live target for it. The fake
+// home edge is the zero-call witness — a re-forward would answer 200 here.
+func TestArrivedHopNeverDialsTheHomeEdge(t *testing.T) {
+ e, m, key := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "example.com")
+
+ resp := hopRequest(t, e, "example.com", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "example.com", time.Now()),
+ })
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503 — an arrived hop is refused, not re-forwarded", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "2" {
+ t.Fatalf("Retry-After = %q, want 2", got)
+ }
+ if n := rec.get().n; n != 0 {
+ t.Fatalf("home edge saw %d request(s), want none — a hop must never become a second hop", n)
+ }
+}
+
+// The backhaul's own guard, exercised directly rather than through ServeHTTP's
+// branch above it: if this call ever moves, an arrived hop must still fail
+// closed here instead of being forwarded.
+func TestBackhaulHelperRefusesAnArrivedHopOnItsOwn(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "example.com")
+
+ r := httptest.NewRequest("GET", "http://example.com/", nil)
+ r = r.WithContext(context.WithValue(r.Context(), hopVerifiedKey{}, true))
+ w := httptest.NewRecorder()
+ if !e.backhaul(w, r, "example.com") {
+ t.Fatal("backhaul must ANSWER an arrived hop, not fall through to the unknown-host path")
+ }
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want the stale-map refusal", w.Code)
+ }
+ if n := rec.get().n; n != 0 {
+ t.Fatalf("home edge saw %d request(s), want none", n)
+ }
+}
+
+// Fail closed on the SENDING side too: with no mesh key mirrored there is
+// nothing to prove the hop with, and signing under an empty key would cross a
+// region only to be served as an anonymous client. The request gets today's
+// unknown-host answer instead.
+func TestBackhaulRefusesToDialWithoutAMeshKey(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "example.com")
+ if err := m.s.Delete(context.Background(), meshKeyKey); err != nil {
+ t.Fatalf("delete mesh key: %v", err)
+ }
+ refreshHomeMap(t, m)
+ if cur, next := m.meshKeys(); len(cur) != 0 || len(next) != 0 {
+ t.Fatal("precondition: the mirror must hold no key")
+ }
+
+ resp := hopRequest(t, e, "example.com", nil)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want the unchanged 404 — an unprovable hop is not dialled", resp.StatusCode)
+ }
+ if n := rec.get().n; n != 0 {
+ t.Fatalf("home edge saw %d request(s), want none — never sign with an empty key", n)
+ }
+}
+
+// A stranger forging the hop vocabulary for a remote-homed host is backhauled
+// like any other client, and gains exactly nothing by having tried.
+//
+// Two halves have to hold at once and they meet only here. acceptHop deletes
+// both hop headers and — verification having failed — strips every forwarding
+// claim the forger made, so the request reaching this point is an ordinary
+// one; backhaul then mints a FRESH signature under the deployment's own key,
+// so the home edge sees a hop it can verify and an X-Forwarded-For naming the
+// forger. What the forger must NOT get is the stale-map 503 (which would tell
+// them the host lives elsewhere), a chain of their own choosing, or their
+// garbage signature carried onward under this edge's name.
+func TestForgedHopForARemoteHostIsBackhauledAsAnOrdinaryClient(t *testing.T) {
+ e, m, key := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "example.com")
+
+ const garbage = "0:deadbeef"
+ resp := hopRequest(t, e, "example.com", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: garbage,
+ "X-Forwarded-For": "203.0.113.9",
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 — a forged hop is an ordinary client, not a refusal", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "" {
+ t.Fatalf("Retry-After = %q — the stale-map answer must stay unreachable without the mesh key", got)
+ }
+ if body := readBody(t, resp); body != homeEdgeBody {
+ t.Fatalf("body = %q, want the home edge's answer %q", body, homeEdgeBody)
+ }
+ seen := rec.get()
+ if seen.n != 1 {
+ t.Fatalf("home edge saw %d request(s), want exactly one", seen.n)
+ }
+ if seen.auth == garbage {
+ t.Fatalf("%s = %q — the forger's signature must never be forwarded under this edge's name", hopAuthHeader, seen.auth)
+ }
+ if seen.hop != "1" || !verifyHopEitherKey(key, nil, "example.com", seen.auth, time.Now()) {
+ t.Fatalf("hop=%q auth=%q — the hop must carry a FRESH signature this edge minted", seen.hop, seen.auth)
+ }
+ // The forger's claimed chain is gone and the one that arrives names the
+ // forger's own address, once. Anything else would let a stranger choose
+ // what the app (and the console's rate limiter) believes about who called.
+ if len(seen.xff) != 1 || strings.Contains(seen.xff[0], ",") {
+ t.Fatalf("X-Forwarded-For = %q, want a single-element chain", seen.xff)
+ }
+ if strings.Contains(seen.xff[0], "203.0.113.9") {
+ t.Fatalf("X-Forwarded-For = %q — a forged chain must be discarded, not carried", seen.xff)
+ }
+ if ip := net.ParseIP(seen.xff[0]); ip == nil || !ip.IsLoopback() {
+ t.Fatalf("X-Forwarded-For = %q, want the forger's own (loopback) address", seen.xff)
+ }
+}
+
+// A catch-all row in another box's edges file resolves through the same
+// one-label wildcard rule lookupUpstream uses — but the signature must be over
+// the CONCRETE request host, because that is what the home edge verifies
+// against. Signing the map KEY would produce a MAC for "*.com", which is not a
+// request host under any canonicalization.
+func TestBackhaulSignsTheConcreteHostNotTheWildcardKey(t *testing.T) {
+ e, m, key := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "*.com")
+ if _, _, ok := m.Home("example.com"); !ok {
+ t.Fatal("precondition: the wildcard row must offer a home for example.com")
+ }
+
+ if resp := hopRequest(t, e, "example.com", nil); resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ seen := rec.get()
+ if !verifyHop(key, "example.com", seen.auth, time.Now()) {
+ t.Fatalf("%s = %q must verify against the concrete request host", hopAuthHeader, seen.auth)
+ }
+ if verifyHop(key, "*.com", seen.auth, time.Now()) {
+ t.Fatalf("%s = %q verifies against the wildcard map key — the MAC must bind the concrete host", hopAuthHeader, seen.auth)
+ }
+}
+
+// A WebSocket must compose through the hop with no code of its own: the dial
+// branch sits above ServeHTTP's cache/compression wrapping, so the client's
+// hijackable writer reaches httputil.ReverseProxy's protocol-switch path, and
+// the transport speaks HTTP/1.1 (no ForceAttemptHTTP2) so that path works.
+// Bytes must then flow both ways with no whole-body deadline cutting them off.
+func TestBackhaulCarriesAWebSocketUpgrade(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ http.Error(w, "not hijackable", http.StatusInternalServerError)
+ return
+ }
+ conn, buf, err := hj.Hijack()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
+ if err := buf.Flush(); err != nil {
+ return
+ }
+ line, err := buf.ReadString('\n')
+ if err != nil {
+ return
+ }
+ buf.WriteString("home:" + line)
+ buf.Flush()
+ }))
+ homedOn(t, m, "betty", eip, "example.com")
+
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
+ if err != nil {
+ t.Fatalf("dial edge: %v", err)
+ }
+ defer c.Close()
+ if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
+ t.Fatalf("set deadline: %v", err)
+ }
+ if _, err := fmt.Fprint(c, "GET /socket HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); err != nil {
+ t.Fatalf("write handshake: %v", err)
+ }
+ br := bufio.NewReader(c)
+ resp, err := http.ReadResponse(br, nil)
+ if err != nil {
+ t.Fatalf("read handshake response: %v", err)
+ }
+ if resp.StatusCode != http.StatusSwitchingProtocols {
+ t.Fatalf("handshake status = %d, want 101 — an upgrade must switch protocols through the hop", resp.StatusCode)
+ }
+ if _, err := fmt.Fprint(c, "ping\n"); err != nil {
+ t.Fatalf("write post-upgrade frame: %v", err)
+ }
+ line, err := br.ReadString('\n')
+ if err != nil {
+ t.Fatalf("read post-upgrade frame: %v", err)
+ }
+ if line != "home:ping\n" {
+ t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways across the backhaul", line, "home:ping\n")
+ }
+ if seen := rec.get(); seen.hop != "1" {
+ t.Fatalf("%s = %q — an upgrade is a hop like any other", hopHeader, seen.hop)
+ }
+}
+
+// A home whose edge cannot be reached is a bad gateway, not a 503: this box
+// believed a specific box was there. The unreachable target here is the
+// documented cert-less bootstrap window's shape too — a dial or handshake that
+// does not complete, healed by the next mirror poll.
+func TestBackhaulToAnUnreachableHomeIsA502(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "local.test")
+ // A port nothing listens on, reached through the same seam the fake home
+ // edge uses. 127.0.0.1 refuses immediately rather than hanging for the dial
+ // budget.
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ eip, port, err := net.SplitHostPort(ln.Addr().String())
+ if err != nil {
+ t.Fatalf("split: %v", err)
+ }
+ ln.Close()
+ old := backhaulPort
+ backhaulPort = port
+ t.Cleanup(func() { backhaulPort = old })
+ homedOn(t, m, "betty", eip, "example.com")
+
+ resp := hopRequest(t, e, "example.com", nil)
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502 for a home edge that could not be reached", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Retry-After"); got != "" {
+ t.Fatalf("Retry-After = %q — 503 + Retry-After is reserved for knowing nobody is home", got)
+ }
+}
+
+// One transport per home BOX. The pool is what makes a backhaul cost one
+// handshake rather than one per request; keying it per box rather than per
+// host is what keeps a catch-all home — where every invented subdomain is a
+// backhaul target — from growing the map without limit. Per-host identity
+// still exists, one layer down: the outbound authority is the hostname, so SNI
+// and pooled connections are per host inside the box's transport (the SNI
+// assertion in TestBackhaulDialsTheHomeEdgeOverVerifiedTLS is that half).
+func TestBackhaulTransportsAreCachedPerHomeBox(t *testing.T) {
+ e, _, _ := hopEdge(t, "annie", "local.test")
+ a := e.backhaulTransport("1.1.1.1")
+ if b := e.backhaulTransport("1.1.1.1"); b != a {
+ t.Fatal("the same home box must reuse one transport, or every request pays a fresh TLS handshake")
+ }
+ if b := e.backhaulTransport("2.2.2.2"); b == a {
+ t.Fatal("a different home box must get its own transport — the dial address is baked into it")
+ }
+ if sn := a.TLSClientConfig.ServerName; sn != "" {
+ t.Fatalf("ServerName = %q, want it unset — pinning one host per transport is what a wildcard home would multiply", sn)
+ }
+ if a.TLSClientConfig.InsecureSkipVerify {
+ t.Fatal("the hop verifies the home edge's certificate — always")
+ }
+}
+
+// Two hosts homed on one box share that box's transport, and each still
+// reaches the home edge under its OWN name: the shared transport is the memory
+// property, the per-host SNI is the correctness one.
+func TestBackhaulSharesOneTransportAcrossAWildcardHome(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "local.test")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "*.com")
+
+ if resp := hopRequest(t, e, "example.com", nil); resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if sni := rec.get().sni; sni != "example.com" {
+ t.Fatalf("SNI = %q, want the concrete request host", sni)
+ }
+ n := 0
+ e.backhaulTransports.Range(func(_, _ any) bool { n++; return true })
+ if n != 1 {
+ t.Fatalf("%d transports cached for one home box, want 1 — a catch-all home must not mint one per hostname", n)
+ }
+ if _, ok := e.backhaulTransports.Load(eip); !ok {
+ t.Fatalf("the cached transport must be keyed by the home box's EIP (%s)", eip)
+ }
+}
+
+// The :80 half of the backhaul, carried from Task 4's review. A host homed on
+// another box is answered on 443 (the hop), so plain http:// must redirect it
+// there like any other routable host — otherwise a first-time visitor typing a
+// bare hostname in the wrong region gets a 404 for an app that is up.
+//
+// The redirect grants nothing that the 443 answer does not: it is a 302 to the
+// SAME host, never an ACME order (httpFallbackHandler's own doc records why
+// this gate is wider than Allowed's). And a verified hop can never arrive
+// here — the sending edge dials 443 — so there is no hop logic on this path.
+func TestHTTPFallbackRedirectsARemoteHomedHost(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie") // serves nothing itself
+ homedOn(t, m, "betty", "2.2.2.2", "remote.test", "*.wild.test")
+ h := e.httpFallbackHandler()
+
+ for _, host := range []string{"remote.test", "paul.wild.test"} {
+ req := httptest.NewRequest(http.MethodGet, "http://"+host+"/deep?q=1", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound {
+ t.Fatalf("%s: status = %d, want 302 — the host is homed on another box and 443 backhauls it", host, rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "https://"+host+"/deep?q=1" {
+ t.Fatalf("%s: Location = %q", host, loc)
+ }
+ }
+
+ // A host nothing in the deployment homes keeps today's answer exactly.
+ req := httptest.NewRequest(http.MethodGet, "http://nowhere.test/", nil)
+ req.Host = "nowhere.test"
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("nowhere.test: status = %d, want the unchanged 404", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "" {
+ t.Fatalf("nowhere.test: Location = %q, want none", loc)
+ }
+}
+
+// oneKeyFailingStore fails Get for one named key — a single unreadable object
+// mid-poll, the case the carry-forward exists for.
+type oneKeyFailingStore struct {
+ release.Store
+ mu sync.Mutex
+ bad string
+}
+
+func (o *oneKeyFailingStore) failKey(key string) {
+ o.mu.Lock()
+ o.bad = key
+ o.mu.Unlock()
+}
+
+func (o *oneKeyFailingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ o.mu.Lock()
+ bad := o.bad
+ o.mu.Unlock()
+ if key == bad {
+ return nil, "", errors.New("bucket down for this object")
+ }
+ return o.Store.Get(ctx, key)
+}
diff --git a/internal/edge/mesh.go b/internal/edge/mesh.go
new file mode 100644
index 000000000..25d14fe24
--- /dev/null
+++ b/internal/edge/mesh.go
@@ -0,0 +1,303 @@
+package edge
+
+// The mesh key — housekeeper-minted, edge-mirrored, HMAC hop vocabulary
+// (spec docs/superpowers/specs/2026-08-22-multi-region-backhaul-design.md,
+// Part 3, Task 2).
+//
+// A multi-region deployment has more than one edge; Tasks 3/4 have one edge
+// forward a request to another (a "hop") when it does not own the region a
+// request landed in. That hop has to prove it came from a box inside this
+// deployment's own mesh — the writer map's answer is the same shape the
+// housekeeper's other duties already use: exactly ONE box mints a shared
+// secret, every box mirrors it, and no second election is invented.
+//
+// - mintMeshKey is the housekeeper duty (wired as its own loop,
+// internal/host/housekeeper.go's mintMeshKeyLoop — see that func's
+// comment for why it is NOT folded into retentionLoop's tick): 32
+// random bytes, hex-encoded, written to control/edge-mesh-key with an
+// empty ifMatch — release.Store's create-only form. That IS the
+// compare-and-swap: two housekeepers racing the same tick (the
+// transient double-holder window housekeeper.go's poll cadence allows)
+// both attempt the Put, exactly one wins, and the loser's
+// release.ErrCASConflict is not a failure — it is proof a key already
+// exists, which is the only thing this func promises. Every later tick
+// calls it again and finds the same conflict, forever, for the
+// deployment's life: minting is a one-time event, not a duty that ever
+// "finishes" — and it stays SILENT after the first tick (see
+// mintMeshKey's own comment): the steady state of every tick but one,
+// forever, is not something worth a log line, exactly like the
+// housekeeper election's own contention.
+//
+// - signHop / verifyHop are the hop vocabulary Task 3/4 wire onto the
+// request path under the headers X-Carlos-Hop: 1 and X-Carlos-Hop-Auth:
+// <unix>:<hex>. The MAC binds the target host into the signature so one
+// hop's header cannot be replayed against another, and the timestamp
+// bounds replay in time (±hopSkew) independent of the host check. The
+// host is canonicalized (canonicalizeHopHost: lowercased, trailing dot
+// stripped, :port stripped) before it ever reaches the MAC, and the MAC
+// is always over the CONCRETE request host a hop is bound for — never a
+// wildcard map key (e.g. "*.example.com") — because Task 3 verifies
+// against the literal net/http request host and Task 4 signs the same;
+// canonicalizing in one place (hopMAC) is what keeps a signer's and a
+// verifier's idea of "host" from ever diverging on case, a trailing
+// root-label dot, or a stray port.
+//
+// - Rotation's READ side ships in this task, ahead of any code that
+// writes .next: control/edge-mesh-key.next mirrors a rotation
+// successor while one is in flight, loadMeshKeys reads both,
+// verifyHopEitherKey accepts a signature under either, and signingKey
+// is what a signer consults to prefer .next the instant it exists —
+// never waiting for a runbook step.
+//
+// What this removes is the PROMOTION window, not the PROPAGATION one.
+// A signer starts using .next as soon as its OWN box has picked it up;
+// a remote verifier accepts a .next-signed hop only once ITS OWN
+// mirror has too (Task 3/4's poll of the control/ prefix, up to ~30s
+// behind a write). Between those two moments a hop signed under .next
+// CAN be refused by a verifier that hasn't caught up — that is a real,
+// if short, window, and per Task 3's rules the cost of that refusal is
+// the request being served as an ordinary client (its X-Forwarded-For
+// stripped), not dropped: a degraded hop, not an outage. What
+// dual-accept actually buys is that the runbook's promotion step
+// itself (copy .next over control/edge-mesh-key, delete .next) needs
+// no synchronized cutover: because every verifier already accepts
+// EITHER key well before promotion happens, that write can land as an
+// ordinary, un-sequenced action with no box needing to be told "stop
+// signing with the old key" first.
+//
+// Single-box inertness: on a deployment with one platform box, this box is
+// always the housekeeper (housekeeper.go's housekeeperSole), so it mints its
+// own mesh key and nothing else ever reads it — Tasks 3/4 are the first
+// readers. Minting here is therefore inert today, by design: this task ships
+// the vocabulary and the mint, not a consumer.
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// meshKeyKey is the control-plane object the housekeeper mints once per
+// deployment. Named beside the storedoor ACL tripwire
+// (TestDoorNeverServesCertMaterial), which refuses to serve it — a fleet
+// box authenticating through the store door must never read this key.
+const meshKeyKey = "control/edge-mesh-key"
+
+// meshKeyNextKey is the rotation successor, mirrored alongside meshKeyKey
+// only while a rotation is in flight. Promoting it (copy over meshKeyKey,
+// delete this object) is a runbook step, not code this task ships — see the
+// file header.
+const meshKeyNextKey = meshKeyKey + ".next"
+
+// meshKeyBytes is the random key length before hex encoding: 256 bits,
+// matched to the HMAC-SHA256 hop signature below.
+const meshKeyBytes = 32
+
+// hopSkew bounds how far a hop signature's timestamp may drift from the
+// verifier's clock, in either direction: generous enough for ordinary box
+// clock skew and request latency, tight enough that a captured header stops
+// working well within a minute.
+const hopSkew = 30 * time.Second
+
+// mintMeshKey mints the deployment's mesh key if, and only if, nothing has
+// minted one yet. crypto/rand is required — this key authenticates hops
+// between platform boxes, so it gets the same random source every other
+// credential in this codebase does, never math/rand.
+//
+// A lost race (release.ErrCASConflict — a key already exists, from this
+// box's own earlier tick or another housekeeper that won the same race) is
+// deliberately SILENT, not logged even at "already minted": that outcome is
+// the steady state of every tick but the very first, forever, for the
+// deployment's whole life — one line every tick would be ~264 lines/day of
+// nothing happening, exactly the noise housekeeper.go's own election
+// contention refuses to log for the same reason. Only an actual, novel mint
+// gets a line.
+func mintMeshKey(ctx context.Context, s release.Store) error {
+ buf := make([]byte, meshKeyBytes)
+ if _, err := rand.Read(buf); err != nil {
+ return fmt.Errorf("mesh key: read random bytes: %w", err)
+ }
+ body := []byte(hex.EncodeToString(buf))
+ if _, err := s.Put(ctx, meshKeyKey, body, ""); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ return nil
+ }
+ return fmt.Errorf("mesh key: put: %w", err)
+ }
+ log.Printf("edge: mesh key: minted")
+ return nil
+}
+
+// MintMeshKey is mintMeshKey's exported wrapper — the seam
+// internal/host's housekeeper duty (mintMeshKeyLoop, housekeeper.go) calls
+// through, so the duty lives beside the housekeeper while the key's own
+// shape (its object name, its random-byte discipline) stays edge's to own.
+func MintMeshKey(ctx context.Context, s release.Store) error {
+ return mintMeshKey(ctx, s)
+}
+
+// getMeshKeyHex reads and hex-decodes one mirrored mesh-key object, folding
+// "does not exist" (os.ErrNotExist) into (nil, nil) — the ordinary case for
+// meshKeyNextKey outside a rotation window, and for meshKeyKey itself before
+// the housekeeper's first tick on a brand new deployment.
+func getMeshKeyHex(ctx context.Context, s release.Store, key string) ([]byte, error) {
+ body, _, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("mesh key: get %s: %w", key, err)
+ }
+ dec, err := hex.DecodeString(string(body))
+ if err != nil {
+ return nil, fmt.Errorf("mesh key: %s: not valid hex: %w", key, err)
+ }
+ return dec, nil
+}
+
+// loadMeshKeys is the dual-accept read side's key material access: it reads
+// BOTH control/edge-mesh-key and control/edge-mesh-key.next. current is nil
+// only before the housekeeper has minted anything; next is nil the whole
+// rest of the time — nearly always, since a rotation is a deliberate,
+// short-lived operator action.
+//
+// A decode failure on meshKeyNextKey does NOT propagate: .next is written
+// by hand during a rotation (there is no code in this task that writes it),
+// so one bad upload is a real possibility, and the safe response to "I
+// can't read the rotation successor" is to act as though no rotation is in
+// flight — not to take every hop fleet-wide down by refusing the perfectly
+// good current key alongside it. The failure is loud (an operator mistake
+// worth paging on), just not fatal to this call. A decode failure on
+// meshKeyKey itself DOES propagate: that is the live, day-to-day signing
+// and verifying key, and there is no safe fallback for it being unreadable.
+func loadMeshKeys(ctx context.Context, s release.Store) (current, next []byte, err error) {
+ current, err = getMeshKeyHex(ctx, s, meshKeyKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ next, nextErr := getMeshKeyHex(ctx, s, meshKeyNextKey)
+ if nextErr != nil {
+ log.Printf("edge: mesh key: %s is unreadable, treating as no rotation in flight until fixed: %v", meshKeyNextKey, nextErr)
+ next = nil
+ }
+ return current, next, nil
+}
+
+// signingKey is which of the two mirrored keys a signer should use: .next
+// the instant it exists, else current. A signer never waits for the
+// runbook's promotion step — verifyHopEitherKey's dual-accept on the reader
+// side is what makes that safe, so switching signers over early costs
+// nothing and shortens how long a rotation needs both keys mirrored.
+func signingKey(current, next []byte) []byte {
+ if len(next) > 0 {
+ return next
+ }
+ return current
+}
+
+// signHop produces the value of the X-Carlos-Hop-Auth header: an
+// HMAC-SHA256 over "host\nunix-timestamp", hex-encoded and prefixed with the
+// timestamp so a verifier does not have to guess it. host is bound into the
+// MAC so a signature minted for one hop cannot be replayed against another.
+// host must be the CONCRETE host a hop is bound for (a net/http request
+// host, e.g. from Task 4's forwarding decision) — never a wildcard home-map
+// key like "*.example.com" — because hopMAC canonicalizes it the same way a
+// verifier's literal request host is canonicalized, and a wildcard pattern
+// is not a request host under any canonicalization.
+func signHop(key []byte, host string, now time.Time) string {
+ ts := strconv.FormatInt(now.Unix(), 10)
+ return ts + ":" + hex.EncodeToString(hopMAC(key, host, ts))
+}
+
+// verifyHop checks an X-Carlos-Hop-Auth header against a single key: the
+// timestamp must parse and fall within ±hopSkew of now, and the MAC must
+// match under constant-time comparison (hmac.Equal) — a hop signature is
+// exactly the kind of secret-derived value a timing side channel could leak,
+// so no byte-wise or string-equality shortcut is taken anywhere in this
+// path.
+func verifyHop(key []byte, host, header string, now time.Time) bool {
+ ts, sig, ok := strings.Cut(header, ":")
+ if !ok || ts == "" || sig == "" {
+ return false
+ }
+ sec, err := strconv.ParseInt(ts, 10, 64)
+ if err != nil {
+ return false
+ }
+ skew := now.Sub(time.Unix(sec, 0))
+ if skew < 0 {
+ skew = -skew
+ }
+ if skew > hopSkew {
+ return false
+ }
+ got, err := hex.DecodeString(sig)
+ if err != nil {
+ return false
+ }
+ return hmac.Equal(got, hopMAC(key, host, ts))
+}
+
+// verifyHopEitherKey is the dual-accept read side of a rotation: it accepts
+// a signature made under either mirrored key, current or next, so neither a
+// straggling signer still on the old key nor one that has already switched
+// to .next (signingKey prefers it immediately) is ever refused while both
+// are mirrored. Task 3/4's request path calls this once it has loaded both
+// keys via loadMeshKeys.
+func verifyHopEitherKey(current, next []byte, host, header string, now time.Time) bool {
+ if len(next) > 0 && verifyHop(next, host, header, now) {
+ return true
+ }
+ if len(current) > 0 && verifyHop(current, host, header, now) {
+ return true
+ }
+ return false
+}
+
+// hopMAC is the MAC signHop and verifyHop both compute — one place so the
+// two can never drift out of sync on what bytes are actually signed, and
+// the one choke point that canonicalizes host before it enters the MAC.
+func hopMAC(key []byte, host, ts string) []byte {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(canonicalizeHopHost(host)))
+ mac.Write([]byte("\n"))
+ mac.Write([]byte(ts))
+ return mac.Sum(nil)
+}
+
+// canonicalizeHopHost normalizes a request host into the exact form the MAC
+// is computed over, so a signer and a verifier can never disagree on what
+// "host" means for the same request:
+//
+// - a :port is stripped via net.SplitHostPort when present — Task 3
+// verifies against a literal net/http request Host, which carries a
+// port when the client set one (or the request came in over a
+// non-default port), while a signer's own idea of the host (e.g. a
+// home-map lookup key) never does;
+// - lowercased — HTTP host headers are case-insensitive, but bytes
+// entering an HMAC are not, so a client or intermediary that
+// capitalizes a label must not be able to produce a request this MAC
+// treats as a different host;
+// - a single trailing root-label dot is stripped — "example.com." and
+// "example.com" name the same host, and a client is free to send
+// either.
+//
+// host is returned unchanged (net.SplitHostPort's error path) when it
+// carries no port — the common case, and not a failure.
+func canonicalizeHopHost(host string) string {
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ return strings.TrimSuffix(strings.ToLower(host), ".")
+}
diff --git a/internal/edge/mesh_test.go b/internal/edge/mesh_test.go
new file mode 100644
index 000000000..e873dedbf
--- /dev/null
+++ b/internal/edge/mesh_test.go
@@ -0,0 +1,389 @@
+package edge
+
+import (
+ "bytes"
+ "context"
+ "encoding/hex"
+ "log"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// captureLog redirects the standard logger for the duration of a test —
+// synchronous callers only (mintMeshKey, loadMeshKeys direct calls here are
+// never run concurrently with the read), same shape certsync_test.go uses.
+func captureLog(t *testing.T) *bytes.Buffer {
+ t.Helper()
+ var buf bytes.Buffer
+ prev := log.Writer()
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(prev) })
+ return &buf
+}
+
+// meshTestStore is a fresh directory-backed store, the same DirStore every
+// other edge test in this package uses in place of live S3.
+func meshTestStore(t *testing.T) release.Store {
+ t.Helper()
+ return release.NewDirStore(t.TempDir())
+}
+
+func TestMintMeshKeyIsCreateOnly(t *testing.T) {
+ s := meshTestStore(t)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatal(err)
+ }
+ first, _, _ := s.Get(context.Background(), meshKeyKey)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatalf("second mint must be success-by-other: %v", err)
+ }
+ second, _, _ := s.Get(context.Background(), meshKeyKey)
+ if !bytes.Equal(first, second) {
+ t.Fatal("second mint replaced the key")
+ }
+}
+
+// The exported wrapper is the seam internal/host's housekeeper duty calls
+// through — prove it actually reaches the same key, not a copy of the logic.
+func TestMintMeshKeyExportedWrapperMintsTheSameKey(t *testing.T) {
+ s := meshTestStore(t)
+ if err := MintMeshKey(context.Background(), s); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := s.Get(context.Background(), meshKeyKey)
+ if err != nil {
+ t.Fatalf("get %s: %v", meshKeyKey, err)
+ }
+ if len(body) != meshKeyBytes*2 { // hex-encoded
+ t.Fatalf("mesh key body length = %d, want %d (hex of %d random bytes)", len(body), meshKeyBytes*2, meshKeyBytes)
+ }
+}
+
+// A lost race is the steady state of every tick but the first, forever — it
+// must not log anything, matching housekeeper.go's own silence on election
+// contention. Only the first, real mint gets a line.
+func TestMintMeshKeySecondMintIsSilent(t *testing.T) {
+ buf := captureLog(t)
+ s := meshTestStore(t)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatal(err)
+ }
+ first := buf.String()
+ if !strings.Contains(first, "minted") {
+ t.Fatalf("first mint log = %q, want it to mention minting", first)
+ }
+ buf.Reset()
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatalf("second mint must be success-by-other: %v", err)
+ }
+ if got := buf.String(); got != "" {
+ t.Fatalf("second mint (a lost CAS race) logged %q, want silence", got)
+ }
+}
+
+func TestHopSignatureRoundTripsAndBoundsSkew(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ now := time.Unix(1_766_000_000, 0)
+ h := signHop(key, "app.example.com", now)
+ if !verifyHop(key, "app.example.com", h, now.Add(29*time.Second)) {
+ t.Fatal("within skew must verify")
+ }
+ if verifyHop(key, "app.example.com", h, now.Add(31*time.Second)) {
+ t.Fatal("past skew must refuse")
+ }
+ if verifyHop(key, "other.example.com", h, now) {
+ t.Fatal("host is bound into the mac")
+ }
+ if verifyHop([]byte("wrong-key-wrong-key-wrong-key-00"), "app.example.com", h, now) {
+ t.Fatal("wrong key must refuse")
+ }
+}
+
+// Skew is bounded in both directions — a clock that runs fast must be
+// refused exactly like one that runs slow.
+func TestHopSignatureRefusesFutureSkewToo(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ now := time.Unix(1_766_000_000, 0)
+ h := signHop(key, "app.example.com", now)
+ if verifyHop(key, "app.example.com", h, now.Add(-31*time.Second)) {
+ t.Fatal("verifying 31s before the signature's timestamp must refuse")
+ }
+ if !verifyHop(key, "app.example.com", h, now.Add(-29*time.Second)) {
+ t.Fatal("verifying 29s before the signature's timestamp must still verify")
+ }
+}
+
+// A malformed header (no ":", a non-numeric timestamp, non-hex mac) must
+// refuse, never panic — it arrives off the wire from a hop this box does not
+// control.
+func TestVerifyHopRefusesMalformedHeaders(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ now := time.Unix(1_766_000_000, 0)
+ for _, header := range []string{
+ "",
+ "nocolon",
+ "notanumber:deadbeef",
+ "1766000000:not-hex-zzzz",
+ "1766000000:",
+ } {
+ if verifyHop(key, "app.example.com", header, now) {
+ t.Fatalf("verifyHop(%q) = true, want false", header)
+ }
+ }
+}
+
+// The MAC is over the CANONICAL host, so a signer's and a verifier's
+// differing-but-equivalent forms of the same host must both verify against
+// the same signature: Task 4 signs one form (e.g. a home-map lookup key,
+// unlikely to carry a port), Task 3 verifies a literal net/http request Host
+// (may carry a port, whatever case and trailing dot the client sent) —
+// divergence here would be spurious refusals presenting as regional
+// flakiness, not a security property either side intends.
+func TestHopMACCanonicalizesHost(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ now := time.Unix(1_766_000_000, 0)
+ canonical := signHop(key, "app.example.com", now)
+
+ // A signature made for the canonical host verifies against every
+ // equivalent form a verifier might see the request host in.
+ for _, host := range []string{
+ "app.example.com",
+ "APP.EXAMPLE.COM",
+ "App.Example.Com",
+ "app.example.com.",
+ "app.example.com:443",
+ "app.example.com.:8443",
+ "APP.EXAMPLE.COM.:443",
+ } {
+ if !verifyHop(key, host, canonical, now) {
+ t.Fatalf("verifyHop(%q) against a signature made for the canonical host = false, want true", host)
+ }
+ }
+
+ // And signing under any of those same equivalent forms produces a
+ // signature that verifies against the canonical form too — the
+ // canonicalization is symmetric, not a one-sided allowance.
+ for _, host := range []string{"APP.EXAMPLE.COM", "app.example.com.", "app.example.com:9443"} {
+ h := signHop(key, host, now)
+ if !verifyHop(key, "app.example.com", h, now) {
+ t.Fatalf("a signature made for host %q must verify against the canonical form", host)
+ }
+ }
+
+ // A genuinely different host must still refuse.
+ if verifyHop(key, "other.example.com", canonical, now) {
+ t.Fatal("a different host must still refuse — canonicalization must not collapse distinct hosts")
+ }
+}
+
+// ---- rotation dual-accept (plan's global constraint: the READ side ships
+// now — verifyHopEitherKey and signingKey are what Task 3/4's request path
+// calls once it has loaded both mirrored keys) --------------------------------
+
+func TestSignHopPrefersNextKeyWhenPresent(t *testing.T) {
+ current := []byte("current-key-current-key-current")
+ next := []byte("next-key-next-key-next-key-next")
+ if got := signingKey(current, next); !bytes.Equal(got, next) {
+ t.Fatal("signingKey must prefer .next when it exists")
+ }
+ if got := signingKey(current, nil); !bytes.Equal(got, current) {
+ t.Fatal("signingKey must fall back to current when there is no .next")
+ }
+}
+
+func TestVerifyHopEitherKeyAcceptsSignaturesUnderCurrentOrNext(t *testing.T) {
+ current := []byte("current-key-current-key-current")
+ next := []byte("next-key-next-key-next-key-next-")
+ wrong := []byte("wrong-key-wrong-key-wrong-key-00")
+ now := time.Unix(1_766_000_000, 0)
+ host := "app.example.com"
+
+ // sign with .next -> verifies
+ hNext := signHop(signingKey(current, next), host, now)
+ if !verifyHopEitherKey(current, next, host, hNext, now) {
+ t.Fatal("a signature made with .next must verify while .next exists")
+ }
+
+ // sign with old key while .next exists -> still verifies (dual-accept)
+ hOld := signHop(current, host, now)
+ if !verifyHopEitherKey(current, next, host, hOld, now) {
+ t.Fatal("a signature made with the pre-rotation key must still verify while .next exists")
+ }
+
+ // wrong key -> refuses
+ hWrong := signHop(wrong, host, now)
+ if verifyHopEitherKey(current, next, host, hWrong, now) {
+ t.Fatal("a signature made with neither key must refuse")
+ }
+
+ // no rotation in flight (next is nil): only current verifies.
+ if !verifyHopEitherKey(current, nil, host, hOld, now) {
+ t.Fatal("current key must verify with no rotation in flight")
+ }
+ if verifyHopEitherKey(current, nil, host, hNext, now) {
+ t.Fatal("a .next signature must not verify once .next is gone")
+ }
+}
+
+func TestLoadMeshKeysReadsBothMirroredObjects(t *testing.T) {
+ ctx := context.Background()
+ s := meshTestStore(t)
+
+ // Before the housekeeper's first tick: neither object exists, and that
+ // is quiet — not an error.
+ current, next, err := loadMeshKeys(ctx, s)
+ if err != nil {
+ t.Fatalf("loadMeshKeys before minting: %v", err)
+ }
+ if current != nil || next != nil {
+ t.Fatalf("loadMeshKeys before minting = (%x, %x), want (nil, nil)", current, next)
+ }
+
+ if err := mintMeshKey(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ current, next, err = loadMeshKeys(ctx, s)
+ if err != nil {
+ t.Fatalf("loadMeshKeys after minting: %v", err)
+ }
+ if len(current) != meshKeyBytes {
+ t.Fatalf("current key length = %d, want %d raw bytes", len(current), meshKeyBytes)
+ }
+ if next != nil {
+ t.Fatal("next key must be nil outside a rotation window")
+ }
+
+ // A rotation in flight: .next mirrored alongside the current key.
+ nextBody := []byte(hex.EncodeToString(bytes.Repeat([]byte{0xab}, meshKeyBytes)))
+ if _, err := s.Put(ctx, meshKeyNextKey, nextBody, ""); err != nil {
+ t.Fatal(err)
+ }
+ current, next, err = loadMeshKeys(ctx, s)
+ if err != nil {
+ t.Fatalf("loadMeshKeys during rotation: %v", err)
+ }
+ if len(current) != meshKeyBytes || len(next) != meshKeyBytes {
+ t.Fatalf("key lengths = (%d, %d), want both %d", len(current), len(next), meshKeyBytes)
+ }
+}
+
+// A malformed .next — hand-written during a rotation, so a bad upload is a
+// real possibility — must not take the live current key down with it: the
+// safe response is to treat it as no rotation in flight (loudly logged),
+// never to refuse every hop fleet-wide over one bad object.
+func TestLoadMeshKeysToleratesAnUnreadableNext(t *testing.T) {
+ ctx := context.Background()
+ s := meshTestStore(t)
+ if err := mintMeshKey(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, meshKeyNextKey, []byte("not-valid-hex-zzzz"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureLog(t)
+ current, next, err := loadMeshKeys(ctx, s)
+ if err != nil {
+ t.Fatalf("loadMeshKeys with a malformed .next must not fail: %v", err)
+ }
+ if len(current) != meshKeyBytes {
+ t.Fatalf("current key length = %d, want %d — a malformed .next took the live key down with it", len(current), meshKeyBytes)
+ }
+ if next != nil {
+ t.Fatal("a malformed .next must be treated as absent (no rotation in flight), not surfaced")
+ }
+ if got := buf.String(); !strings.Contains(got, meshKeyNextKey) {
+ t.Fatalf("log = %q, want a loud mention of %s — this is an operator mistake worth paging on", got, meshKeyNextKey)
+ }
+
+ // And a signature made under the still-good current key verifies fine —
+ // the tolerance is not merely "does not error", it's "hop auth keeps
+ // working".
+ now := time.Unix(1_766_000_000, 0)
+ h := signHop(current, "app.example.com", now)
+ if !verifyHopEitherKey(current, next, "app.example.com", h, now) {
+ t.Fatal("hop auth must keep working off the current key despite the malformed .next")
+ }
+}
+
+// ---- the key half of the home-map mirror ------------------------------------
+//
+// Task 3 folds the mesh-key read into the home map's 30s poll rather than
+// giving it a loop of its own: one list-and-get pass keeps both halves of a
+// hop's answer (who homes this host, and what key proves the hop) from ever
+// being read from two different moments in the bucket's life.
+
+func TestHomeMapRefreshMirrorsTheMeshKeys(t *testing.T) {
+ ctx := context.Background()
+ s := meshTestStore(t)
+ m := newHomeMap(s, "annie")
+
+ // Before the housekeeper's first tick: no key, and every hop fails
+ // closed — refreshing is still not an error.
+ if err := m.Refresh(ctx); err != nil {
+ t.Fatalf("refresh before minting: %v", err)
+ }
+ if cur, next := m.meshKeys(); cur != nil || next != nil {
+ t.Fatalf("keys before minting = (%x, %x), want (nil, nil)", cur, next)
+ }
+
+ if err := mintMeshKey(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.Refresh(ctx); err != nil {
+ t.Fatalf("refresh after minting: %v", err)
+ }
+ cur, next := m.meshKeys()
+ if len(cur) != meshKeyBytes || next != nil {
+ t.Fatalf("keys after minting = (%d bytes, %v), want the current key alone", len(cur), next)
+ }
+
+ // A rotation in flight: dual-accept means a signature under EITHER key is
+ // good, which is what the request path calls (verifyHopEitherKey, never
+ // the single-key verifyHop).
+ nextBody := []byte(hex.EncodeToString(bytes.Repeat([]byte{0xcd}, meshKeyBytes)))
+ if _, err := s.Put(ctx, meshKeyNextKey, nextBody, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.Refresh(ctx); err != nil {
+ t.Fatalf("refresh during rotation: %v", err)
+ }
+ cur, next = m.meshKeys()
+ if len(cur) != meshKeyBytes || len(next) != meshKeyBytes {
+ t.Fatalf("keys during rotation = (%d, %d), want both mirrored", len(cur), len(next))
+ }
+ now := time.Now()
+ for _, k := range [][]byte{cur, next} {
+ if !verifyHopEitherKey(cur, next, "app.example.com", signHop(k, "app.example.com", now), now) {
+ t.Fatal("a hop signed under either mirrored key must verify while both are mirrored")
+ }
+ }
+}
+
+// The keys ride the same last-good posture as the host fold: a bucket blip
+// must not disarm hop verification fleet-wide.
+func TestHomeMapKeepsLastGoodMeshKeysWhenTheBucketIsDown(t *testing.T) {
+ ctx := context.Background()
+ inner := meshTestStore(t)
+ s := &listFailingStore{Store: inner}
+ if err := mintMeshKey(ctx, inner); err != nil {
+ t.Fatal(err)
+ }
+ m := newHomeMap(s, "annie")
+ if err := m.Refresh(ctx); err != nil {
+ t.Fatalf("refresh: %v", err)
+ }
+ good, _ := m.meshKeys()
+
+ s.setFail(true)
+ if err := m.Refresh(ctx); err == nil {
+ t.Fatal("a bucket blip must be reported, not swallowed")
+ }
+ cur, _ := m.meshKeys()
+ if !bytes.Equal(cur, good) {
+ t.Fatal("the last good mesh key must stand through a bucket blip")
+ }
+}
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index 0d3f8d50c..c85458132 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -536,10 +536,13 @@ func TestDoorNeverServesCertMaterial(t *testing.T) {
// The real Task 4/5 key shapes (certsync.go's certKey naming: bare host,
// the autocert account key, an HTTP-01 token response, a tls-alpn-01
- // token cert, a wildcard account key) plus the two other control/
+ // token cert, a wildcard account key) plus the three other control/
// entries the multi-region design reserves for the housekeeper alone
- // (bucket-writer-audit.md, "Not yet built" table): the mesh pairing key
- // and the housekeeper's own election lock.
+ // (bucket-writer-audit.md, "Not yet built" table): the mesh key, its
+ // rotation successor (Task 2's dual-accept — control/edge-mesh-key.next,
+ // mirrored only while a rotation is in flight, but readable through this
+ // door at every other time too and refused just as hard), and the
+ // housekeeper's own election lock.
certKeys := []string{
"control/certs/example.com",
"control/certs/acme_account+key",
@@ -547,16 +550,20 @@ func TestDoorNeverServesCertMaterial(t *testing.T) {
"control/certs/tok+http-01",
"control/certs/example.com+token",
"control/edge-mesh-key",
+ "control/edge-mesh-key.next",
"control/housekeeper-lease/lock.json",
}
- // Seed two of the seven with plausible-looking secret bytes: if the ACL
+ // Seed three of the eight with plausible-looking secret bytes: if the ACL
// ever let a Get through, these are the ones that would leak real
// material instead of a "not found" — and mixing seeded with unseeded
// keys through the identical-shape check below is what proves the door
// isn't just refusing absent keys, it's refusing to distinguish "exists"
- // from "doesn't" at all for this whole prefix.
+ // from "doesn't" at all for this whole prefix. The mesh key and its
+ // rotation successor are seeded as a pair, same secret shape, since a
+ // leak of either is the same failure.
seedObject(t, store, "control/certs/example.com", "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----")
seedObject(t, store, "control/edge-mesh-key", "fake-mesh-shared-secret")
+ seedObject(t, store, "control/edge-mesh-key.next", "fake-mesh-shared-secret")
type shape struct {
code int
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 59ac7428a..8a609ae33 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -321,6 +321,14 @@ func RunAgent(args []string) {
stopHousekeeper := startHousekeeper()
go retentionLoop(reg)
go registrySnapshotLoop(reg)
+ go edgesFileLoop(reg)
+ // The mesh key's own loop (multi-region backhaul spec, Part 3 Task 2) —
+ // deliberately NOT a duty riding retentionLoop's tick: that loop returns
+ // early, never arming at all, on a box missing CARLOS_RELEASE_PUBKEY,
+ // and such a box still WINS the housekeeper election. A mint duty living
+ // only inside retentionLoop would then silently never run fleet-wide.
+ // See mintMeshKeyLoop's own comment (housekeeper.go).
+ go mintMeshKeyLoop()
// The sweep reads the registry only through this closure — the stamped
// bespoke units to sample beside the carlos- ones, re-read each tick.
go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })
@@ -353,7 +361,14 @@ func RunAgent(args []string) {
// for seconds to minutes, and the context ends the instant this box
// stops being the housekeeper. Armed above, so this closure is live
// and correct by the time the edge reads it.
- edge.WithIssuerGate(housekeeperGate))
+ edge.WithIssuerGate(housekeeperGate),
+ // This box's own label, for the home map's own-box exclusion
+ // (internal/edge/homemap.go). metricsBoxLabel is the SAME call
+ // edgesFileLoop publishes under, deliberately: the map excludes our
+ // hosts by matching the box field of the file we ourselves wrote, so
+ // the two derivations must be one derivation. Without it the edge
+ // would offer nothing (fail-closed), never dial itself.
+ edge.WithBoxLabel(metricsBoxLabel(os.Getenv, readMachineID())))
// The edge has drained; take the children down too (they're this process's
// children — orphaning them would strand their leases until TTL expiry).
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
new file mode 100644
index 000000000..4fa38a39d
--- /dev/null
+++ b/internal/host/edgesfile.go
@@ -0,0 +1,153 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand/v2"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The multi-region ingress spec (2026-08-20, Part 3, closes the design half
+// of #238): every platform box publishes what it homes so any edge can fold
+// the fleet's per-box files into a deployment-wide host→home map. v1 writes
+// only `role: "home"` — the anticipated read-local role values are Task 3's
+// concern, not this file's.
+func edgesFileKey(box string) string { return "control/edges/" + box + ".json" }
+
+// edgeHost is the per-host entry in edgesFile.Hosts. An object, not a bare
+// string, is deliberately load-bearing: it is read-local anticipation —
+// Task 3's mirror and later roles (e.g. a "read" role) need somewhere to
+// grow into without a wire-shape break.
+type edgeHost struct {
+ Role string `json:"role"`
+}
+
+// edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3):
+//
+// {"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"2026-08-22T09:00:00Z","hosts":{"app.example.com":{"role":"home"}}}
+type edgesFile struct {
+ V int `json:"v"`
+ Box string `json:"box"`
+ EIP string `json:"eip"`
+ Generation string `json:"generation"`
+ Hosts map[string]edgeHost `json:"hosts"`
+}
+
+// edgesFilePass builds this box's edges file from the routes currently in
+// reg (every SERVED row on this box, ALL kinds — the serving-class split
+// belongs to the reader, Task 3, not to this writer) and publishes it at
+// edgesFileKey(box) when it changed.
+//
+// The change gate is the marshaled body WITH Generation cleared: Generation
+// is a fresh RFC3339 stamp every pass, so comparing the full body would
+// write on every tick even when the box's homed hosts and EIP are
+// identical — a timestamp-only refresh is not a change worth a bucket
+// write. *last holds the previous pass's gate body (also Generation-
+// cleared) and is updated only after a successful write, mirroring
+// registrySnapshotPass's *lastFP contract.
+//
+// eip may be "" (readPublicIP's "unknown" case, e.g. not on EC2, IMDS
+// disabled, or a transient metadata-endpoint failure). A blank reading is
+// NOT taken at face value: this box's public IP does not change tick to
+// tick, so "" almost always means the probe hiccuped, not that the address
+// vanished — publishing it verbatim would blank a known-good EIP for
+// every remote edge's dial address for up to one loop cadence. When eip is
+// "" and *last already holds a published file, this pass reuses that
+// file's EIP instead. "" only ever reaches the wire for a box that has
+// NEVER had a discoverable IP — the DR runbook's hosts list still gets
+// published in that case, just without a dial address.
+func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip string, last *[]byte) error {
+ routes, err := reg.List()
+ if err != nil {
+ return fmt.Errorf("edges: list: %w", err)
+ }
+ hosts := make(map[string]edgeHost, len(routes))
+ for _, r := range routes {
+ // edge.Reload (internal/edge/edge.go) is the deployment's own
+ // definition of "serves": it skips any row whose Status != "ready"
+ // because a pending/creating/failed row has no working backend and
+ // is never dialed by any edge, home or remote. Mirroring that gate
+ // here means this file never claims role:"home" for a host nothing
+ // actually serves. Hibernating routes stay Status "ready" (that is
+ // what makes wake-on-request possible), so nothing reachable is
+ // excluded by this filter.
+ if r.Status != "ready" {
+ continue
+ }
+ // Keys are registry row Host values verbatim, INCLUDING *.parent
+ // catch-all wildcard rows (Task 3's reconcile pass derives these
+ // from a claim's catch-all row, same as edge.go's upstream map) —
+ // this writer performs no expansion. A reader that needs exact-host
+ // answers must apply edge.go's lookupUpstream one-label wildcard
+ // rule itself: an exact match wins, and on a miss "*.<parent>"
+ // (the leading dot of the first label only) is tried next.
+ hosts[r.Host] = edgeHost{Role: "home"}
+ }
+
+ if eip == "" && *last != nil {
+ var prior edgesFile
+ if err := json.Unmarshal(*last, &prior); err == nil {
+ eip = prior.EIP
+ }
+ }
+
+ f := edgesFile{V: 1, Box: box, EIP: eip, Hosts: hosts}
+ gate, err := json.Marshal(f)
+ if err != nil {
+ return fmt.Errorf("edges: marshal: %w", err)
+ }
+ if bytes.Equal(gate, *last) {
+ return nil
+ }
+
+ f.Generation = time.Now().UTC().Format(time.RFC3339)
+ body, err := json.Marshal(f)
+ if err != nil {
+ return fmt.Errorf("edges: marshal: %w", err)
+ }
+ if err := putReplacing(ctx, s, edgesFileKey(box), body); err != nil {
+ return err
+ }
+ *last = gate
+ return nil
+}
+
+// edgesFileLoop publishes this box's edges file every 5 minutes + jitter —
+// the same cadence and arming site as registrySnapshotLoop (home moves are
+// placement events, not per-request events; the backhaul hop's stale-map
+// 503 + converge heals faster than this loop could anyway). Platform-box
+// job, retentionLoop's rules: skipped on fleet boxes, disabled with a log
+// line when there is no deployment bucket. Single-box deployments: the file
+// is published and nothing reads it yet — inert until Task 3's mirror
+// ships.
+func edgesFileLoop(reg *registry.Registry) {
+ ctx := context.Background()
+ if shouldSkipPublisherElection(os.Getenv) {
+ log.Printf("agent: edges file loop is a platform-box job — skipped on a fleet box")
+ return
+ }
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: edges file loop disabled: %v", err)
+ return
+ }
+ box := metricsBoxLabel(os.Getenv, readMachineID())
+ log.Printf("agent: edges file loop armed (5min + jitter, %s)", edgesFileKey(box))
+ var last []byte
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ eip := readPublicIP()
+ if err := edgesFilePass(ctx, store, reg, box, eip, &last); err != nil {
+ log.Printf("agent: edges file pass: %v", err)
+ }
+ }
+}
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
new file mode 100644
index 000000000..162db2870
--- /dev/null
+++ b/internal/host/edgesfile_test.go
@@ -0,0 +1,270 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func edgesTestRegistry(t *testing.T) *registry.Registry {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ return reg
+}
+
+// seedRoute upserts a minimal ready route under the given host — any served
+// kind does for these tests, since edgesFilePass folds every kind in
+// (the serving-class split is the reader's business, Task 3).
+func seedRoute(t *testing.T, reg *registry.Registry, host string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// putCountingStore wraps a release.Store and counts Put calls — the same
+// wrap-and-count idiom agent_test.go's countingStore uses for Get, applied
+// to Put so the change-gate test can pin "second identical pass writes
+// nothing" without inspecting store internals.
+type putCountingStore struct {
+ release.Store
+ puts int
+}
+
+func (p *putCountingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ p.puts++
+ return p.Store.Put(ctx, key, body, ifMatch)
+}
+
+func TestEdgesFilePublishesHomedHosts(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ var lastBody []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.V != 1 || f.Box != "annie" || f.EIP != "1.2.3.4" {
+ t.Fatalf("file = %+v", f)
+ }
+ if r, ok := f.Hosts["app.example.com"]; !ok || r.Role != "home" {
+ t.Fatalf("hosts = %+v", f.Hosts)
+ }
+ if f.Generation == "" {
+ t.Fatal("generation must be stamped")
+ }
+ if got, want := edgesFileKey("annie"), "control/edges/annie.json"; got != want {
+ t.Fatalf("edgesFileKey = %q, want %q", got, want)
+ }
+}
+
+// A box with no discoverable public IP still publishes the file, with
+// "eip":"" — the DR runbook's hosts list must not depend on IMDS working.
+func TestEdgesFilePublishesWithEmptyEIP(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ var lastBody []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.EIP != "" {
+ t.Fatalf("eip = %q, want empty", f.EIP)
+ }
+ if _, ok := f.Hosts["app.example.com"]; !ok {
+ t.Fatalf("hosts = %+v, want app.example.com present despite empty eip", f.Hosts)
+ }
+}
+
+func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) {
+ real := release.NewDirStore(t.TempDir())
+ s := &putCountingStore{Store: real}
+ reg := edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ var lastBody []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ if s.puts != 1 {
+ t.Fatalf("first pass puts = %d, want 1", s.puts)
+ }
+
+ // Second pass, identical registry state: a fresh generation timestamp
+ // would differ from the first pass's, but the change-gate excludes
+ // Generation, so this must not write.
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ if s.puts != 1 {
+ t.Fatalf("unchanged second pass puts = %d, want still 1 (no write)", s.puts)
+ }
+
+ // A registry change makes the next pass write again.
+ seedRoute(t, reg, "second.example.com")
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ if s.puts != 2 {
+ t.Fatalf("changed third pass puts = %d, want 2", s.puts)
+ }
+}
+
+// FINDING 1 (review, 2026-08-22): readPublicIP is a single 2s best-effort
+// IMDS attempt, re-run every tick — a transient throttle or blip returns ""
+// exactly like a box that never had a public IP at all. Without a hold, a
+// single hiccuped tick would blank a known-good EIP in the published file,
+// costing every remote edge its dial address for this box's home-boxed
+// hosts until the next successful tick (up to 5min + jitter). A pass
+// reporting "" after a pass that reported "1.2.3.4" must keep "1.2.3.4".
+func TestEdgesFileHoldsLastKnownGoodEIPOnTransientBlank(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ var last []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &last); err != nil {
+ t.Fatal(err)
+ }
+
+ // Simulate a transient IMDS blip: this tick's probe returns "".
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", &last); err != nil {
+ t.Fatal(err)
+ }
+
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.EIP != "1.2.3.4" {
+ t.Fatalf("eip after a blank probe = %q, want held value %q", f.EIP, "1.2.3.4")
+ }
+}
+
+// FINDING 2 (review, 2026-08-22, ruling): edge.Reload (internal/edge/edge.go)
+// skips any route whose Status != "ready" — that gate is the deployment's
+// own definition of "serves", so a pending/creating/failed row must not
+// claim role:"home" here either. Hibernating routes stay Status "ready",
+// so this filter excludes nothing a remote backhaul could actually reach.
+func TestEdgesFileExcludesNonReadyRows(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "ready.example.com")
+ if err := reg.Upsert(registry.Route{Host: "creating.example.com", Kind: "service", Addr: "10.0.0.2:3000", Status: "creating"}); err != nil {
+ t.Fatal(err)
+ }
+
+ var last []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &last); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := f.Hosts["ready.example.com"]; !ok {
+ t.Fatalf("hosts = %+v, want ready.example.com present", f.Hosts)
+ }
+ if _, ok := f.Hosts["creating.example.com"]; ok {
+ t.Fatalf("hosts = %+v, want creating.example.com ABSENT (not ready, nothing serves it yet)", f.Hosts)
+ }
+}
+
+// FINDING 3 (review, 2026-08-22): a catch-all claim's registry row is a real
+// "*.parent" host (Task 3's reconcile pass derives it the same way
+// edge.go's upstream map does). This writer performs no wildcard expansion
+// — the row's Host lands in Hosts verbatim, exactly like every other row —
+// so an exact-match reader must apply edge.go's lookupUpstream one-label
+// rule itself; that contract is documented at the call site and in the
+// spec, not enforced here.
+func TestEdgesFileWildcardRowPublishesVerbatim(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ if err := reg.Upsert(registry.Route{Host: "*.example.com", Kind: "service", Addr: "10.0.0.3:3000", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+
+ var last []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &last); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if r, ok := f.Hosts["*.example.com"]; !ok || r.Role != "home" {
+ t.Fatalf("hosts = %+v, want \"*.example.com\" present verbatim with role home", f.Hosts)
+ }
+}
+
+// FINDING 4 (review, 2026-08-22): every other test in this file round-trips
+// through the edgesFile struct on both the write and the assert side, so a
+// json-tag rename (e.g. "eip" -> "publicIP") would stay green everywhere
+// while breaking Task 3's parser, which reads the wire bytes independently
+// of this package's Go types. This test pins the RAW body against a
+// hand-written literal matching the brief's exact schema string — every
+// byte is fixed except the generation timestamp, which this fixed
+// signature has no clock seam to inject (edgesFilePass takes no `now`
+// parameter, per the brief's pinned interface); that one field is read
+// back and re-inserted into the literal rather than relaxed by a regex.
+func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ var last []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie-welding", "99.81.104.219", &last); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie-welding.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var probe struct {
+ Generation string `json:"generation"`
+ }
+ if err := json.Unmarshal(got, &probe); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := time.Parse(time.RFC3339, probe.Generation); err != nil {
+ t.Fatalf("generation %q is not RFC3339: %v", probe.Generation, err)
+ }
+
+ want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"` +
+ probe.Generation + `","hosts":{"app.example.com":{"role":"home"}}}`
+ if string(got) != want {
+ t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want)
+ }
+}
diff --git a/internal/host/housekeeper.go b/internal/host/housekeeper.go
index ab7386fa0..baf4442c4 100644
--- a/internal/host/housekeeper.go
+++ b/internal/host/housekeeper.go
@@ -58,10 +58,12 @@ import (
"context"
"errors"
"log"
+ "math/rand/v2"
"os"
"sync/atomic"
"time"
+ "github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
)
@@ -422,3 +424,49 @@ func runHousekeeperDuties(gate func() (context.Context, bool), duties ...houseke
}
}
}
+
+// mintMeshKeyLoop mints the deployment's mesh key (multi-region backhaul
+// spec, Part 3 Task 2) — a housekeeper duty, but deliberately its OWN loop
+// rather than a duty folded into retentionLoop's runHousekeeperDuties call
+// (agent.go's retentionLoop, gated on prune's cadence).
+//
+// The plan first drafted the mint as riding retentionLoop's tick; review
+// caught the trap: retentionLoop returns early — never arming its loop at
+// all, so runHousekeeperDuties is never even called — on a box missing
+// CARLOS_RELEASE_PUBKEY (release-signature verification, something wholly
+// unrelated to the mesh key). Critically, a box in that state still WINS
+// the housekeeper election (newHousekeeper/hk.run don't consult
+// CARLOS_RELEASE_PUBKEY at all), so the one box holding the lease would
+// simply never mint, and no other box would ever try either — the mesh key
+// would silently never exist, and Tasks 3/4's hop auth would never arm,
+// fleet-wide, with no error anywhere. This loop is gated on nothing but
+// deployment-store presence (shouldSkipPublisherElection, openDeployStore),
+// mirroring edgesfile.go's edgesFileLoop shape exactly, so minting is
+// available on every housekeeper independent of every other loop's
+// preconditions.
+//
+// mintMeshKey itself is create-only and idempotent (mesh.go's own comment):
+// every tick after the deployment's first successful mint finds the key
+// already there and does nothing, silently.
+func mintMeshKeyLoop() {
+ ctx := context.Background()
+ if shouldSkipPublisherElection(os.Getenv) {
+ log.Printf("agent: mesh key mint loop is a platform-box job — skipped on a fleet box")
+ return
+ }
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: mesh key mint loop disabled: %v", err)
+ return
+ }
+ log.Printf("agent: mesh key mint loop armed (5min + jitter) — mints only while this box holds the housekeeper lease")
+ mint := housekeeperDuty{"mesh key mint", func(ctx context.Context) error {
+ return edge.MintMeshKey(ctx, store)
+ }}
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ runHousekeeperDuties(housekeeperGate, mint)
+ }
+}
diff --git a/internal/host/housekeeper_test.go b/internal/host/housekeeper_test.go
index 13ec86c3e..31d19e64f 100644
--- a/internal/host/housekeeper_test.go
+++ b/internal/host/housekeeper_test.go
@@ -511,3 +511,52 @@ func TestHousekeeperHeldIsFalseBeforeAnyElectionIsArmed(t *testing.T) {
t.Fatal("housekeeperGate() reports held with no housekeeper armed")
}
}
+
+// ---- mintMeshKeyLoop (multi-region backhaul spec, Part 3 Task 2's review
+// fix): the mesh key's own arming loop, independent of retentionLoop -------
+
+// Mirrors TestRetentionLoopIsAPlatformBoxJob's shape (agent_test.go): a
+// fleet box must skip before ever opening a store.
+func TestMintMeshKeyLoopIsAPlatformBoxJob(t *testing.T) {
+ const skipLine = "agent: mesh key mint loop is a platform-box job — skipped on a fleet box"
+
+ logs := captureLog(t)
+ door := newDoorFake(t)
+ for k, v := range fleetDoorEnv(door.url) {
+ t.Setenv(k, v)
+ }
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+
+ mintMeshKeyLoop() // must return; a fleet box never reaches the loop
+
+ if got := logs(); !strings.Contains(got, skipLine) {
+ t.Fatalf("log = %q, want the skip line verbatim", got)
+ }
+ if hits := door.seen(); len(hits) != 0 {
+ t.Fatalf("door saw %+v, want zero — the skip must land BEFORE the store opens", hits)
+ }
+}
+
+// The regression guard for the Critical review finding: the mint must NOT be
+// gated on CARLOS_RELEASE_PUBKEY (retentionLoop's own gate, unrelated to the
+// mesh key) — a box missing that var still wins the housekeeper election, so
+// a mint duty tied to retentionLoop's arming would silently never run
+// fleet-wide. Proven by explicitly leaving CARLOS_RELEASE_PUBKEY unset and
+// confirming the loop still arms.
+func TestMintMeshKeyLoopArmsWithoutReleasePubkey(t *testing.T) {
+ t.Setenv("CARLOS_FLEET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
+ t.Setenv("CARLOS_RELEASE_PUBKEY", "") // deliberately unset — the whole point
+
+ buf := captureRacyLog(t)
+ // mintMeshKeyLoop never returns once armed: run it in its own goroutine
+ // (it parks in a time.Sleep for a minute, long past this test's end) and
+ // wait for the arming log line against a synchronized sink.
+ go mintMeshKeyLoop()
+
+ waitFor(t, "the mint loop to arm despite no CARLOS_RELEASE_PUBKEY", func() bool {
+ return strings.Contains(buf.String(), "agent: mesh key mint loop armed")
+ })
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 71ee5a3b7..8b532196a 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -1,7 +1,9 @@
package provision
// Reconcile turns bucket instance records into registry routes, for THIS
-// agent's own region and pool set. Spec:
+// agent's own region and pool set — plus, since the multi-region ingress
+// design's Part 3, the serving classes every platform box materializes
+// wherever they are homed (see "serving classes" below). Spec:
// docs/superpowers/specs/2026-08-04-instance-provisioning-design.md §4.
//
// DNS seam (spec §10): multi-region deployments write the per-slug CNAME at
@@ -14,6 +16,7 @@ import (
"errors"
"fmt"
"log"
+ "net"
"os"
"slices"
"strings"
@@ -52,6 +55,174 @@ type ReconcileConfig struct {
// only within an account, never globally.
type acctApp struct{ acct, app string }
+// ---- serving classes -------------------------------------------------------
+//
+// Which boxes materialize a record. The multi-region ingress design's Part 3
+// table, verbatim, and the ONE place this pass states it:
+//
+// Everywhere static (bake-served): the edge resolves the app's
+// channel pointer and serves the shipped tar out of the
+// deployment store every box already reads, so no box
+// needs the home box to serve it.
+// Everywhere, probed addr routes whose upstreams are ALL publicly dialable:
+// each edge runs its own prober and dials the backends
+// directly, so per-edge live sets are per-edge by design.
+// Home-boxed exec, unit-owned, socket-activated, tunnel- and
+// fleet-backed: the process exists on exactly ONE box,
+// and a request that lands elsewhere backhauls to it
+// (internal/edge's hop). Unchanged invariant.
+//
+// Everything here decides SCOPE only. Nothing about the route a record mints
+// changes with the box that mints it — routeFor stays the one renderer, so a
+// far box's copy and the home box's copy are the same route, column for
+// column, including the record's own Region.
+//
+// Single-box inertness: with one box there is no far box, every record is in
+// scope for the same reason it was before, and the widened half of the gate
+// never fires.
+
+// everywhereClass reports whether rec's serving CLASS is one every platform
+// box materializes. It answers the class question only — the tenancy
+// questions (pool, fleet placement) are inScope's, below.
+//
+// Conservative by construction: anything this function cannot positively
+// classify as servable from a box that holds nothing but the bucket is
+// home-boxed. That is the direction a wrong answer must fail in — a route that
+// stays home-boxed is served exactly as it is today (and backhauled from
+// elsewhere), while one wrongly promoted to everywhere is a box proxying to an
+// upstream it cannot reach.
+func everywhereClass(rec Record) bool {
+ // Unit names the systemd unit that owns this route's process (issue
+ // #110); adoption CYCLES it. That unit exists on the home box, so a far
+ // box adopting the row would be stamping authority over a unit name that
+ // means something else there, or nothing at all.
+ if rec.Unit != "" {
+ return false
+ }
+ // Belt and braces: ValidateShape already refuses hibernation on both
+ // classes below (a static route has no process to wake, an addr-backed one
+ // has no socket), and this pass validates every record before the gate. A
+ // hibernating route needs the activator and the socket only its home box
+ // has, so if that door ever moved, this must not be the hole it opens.
+ if rec.HibernateOrDefault() {
+ return false
+ }
+ if rec.KindOrDefault() == "static" {
+ return true
+ }
+ ups := rec.AddrsOrDefault()
+ if len(ups) == 0 {
+ return false // socket-backed: the exec child lives on one box
+ }
+ for _, a := range ups {
+ if !publiclyDialable(a) {
+ return false
+ }
+ }
+ return true
+}
+
+// everywhereRoute is everywhereClass read off a registry ROW instead of a
+// record — the removal phase's half of the same question, asked when the
+// record is gone and the row is all that is left to ask.
+//
+// The two must agree, or the widened mint gate leaves immortal routes: a far
+// box that mints an everywhere-class route and then cannot see it as one is a
+// box serving a retired site forever. They agree by construction today because
+// routeFor is the one renderer between them, and
+// TestEverywhereClassAgreesAcrossRecordAndRow locks that: a class rule added to
+// one of these functions alone recreates exactly the immortality bug this pair
+// was written to close, with every other test still green.
+//
+// They are separate functions rather than one over a shared shape because the
+// row carries columns no record can state (Backing, Tunnel, Caps), and a row
+// that has GROWN one of those since it was minted is deliberately read as
+// home-boxed here. State that cost honestly, because it is bigger than a stale
+// row: an operator who runs `carlos route -grant` (or -backing/-tunnel) against
+// the FAR box's copy takes that copy out of removal scope permanently. Deleting
+// the record then retires the route on its home box and leaves the far box
+// still SERVING the retired site, grants intact, with no record left anywhere
+// to explain it — so `carlos remove` stops being fleet-wide the moment anyone
+// touches a far copy. The alternative (removing rows carrying operator columns)
+// hands an unattended bucket-record deletion the grant purge registry.Delete
+// documents as human-gated, which is worse. The conservative gate stands; the
+// gap is tracked in the bookkeeping issue, not papered over here.
+func everywhereRoute(rt registry.Route) bool {
+ if rt.Unit != "" || rt.Hibernate || rt.Backing != "" || rt.Tunnel != "" || rt.Caps != "" {
+ return false
+ }
+ if rt.Kind == "static" {
+ return true
+ }
+ ups := rt.AddrList()
+ if len(ups) == 0 {
+ return false
+ }
+ for _, a := range ups {
+ if !publiclyDialable(a) {
+ return false
+ }
+ }
+ return true
+}
+
+// publiclyDialable reports whether one upstream is an address every edge in
+// the deployment can reach — the "publicly-reachable upstreams" half of the
+// spec's table.
+//
+// A NAME is not dialable for this purpose even if it resolves to a public
+// address today: validAddrsElement's doc comment already records why a name
+// cannot be judged at all here (it is free to move after the check), and the
+// legacy Addr column is full of names pointing at box-local backends. It stays
+// home-boxed, which is where those routes are serving from today.
+//
+// Loopback and link-local are the spec's own examples (another box's
+// 127.0.0.1 is not this box's). RFC1918 and ULA are refused for exactly the
+// same reason one layer out: a private address is private to ONE network, and
+// two boxes in two regions are two networks. IsGlobalUnicast covers the rest
+// (unspecified, multicast, loopback, link-local).
+func publiclyDialable(addr string) bool {
+ h, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ return false
+ }
+ ip := net.ParseIP(h)
+ if ip == nil {
+ return false
+ }
+ return ip.IsGlobalUnicast() && !ip.IsPrivate()
+}
+
+// inScope answers "is this region/pool pair this pass's business" — the one
+// gate the record scan and the removal phase both ask, so a record this box
+// mints is a record this box can retire.
+//
+// Pool is compared FIRST and never widened. It is a TENANCY boundary, not a
+// geography: a pool names the boxes a tenant's routes are allowed to land on
+// (customer fleets, dedicated boxes), and "everywhere" in the spec's table
+// means every box that already serves this tenant, not every box in the world.
+//
+// Region equality is the unchanged home-box test. It is widened for the
+// everywhere classes, and only between PLATFORM regions: a fleet placement
+// ("fleet/<name>", registry.FleetPlacement) is the other tenancy boundary and
+// is refused from both sides — the platform's boxes do not pick up a
+// customer fleet's routes, and a customer's box does not pick up the
+// platform's.
+func (cfg ReconcileConfig) inScope(region, pool string, everywhere bool) bool {
+ if !poolServed(cfg.Pools, pool) {
+ return false
+ }
+ if region == cfg.Region {
+ return true
+ }
+ if !everywhere {
+ return false
+ }
+ _, recordOnFleet := registry.FleetPlacement(region)
+ _, boxOnFleet := registry.FleetPlacement(cfg.Region)
+ return !recordOnFleet && !boxOnFleet
+}
+
// routeFor is the one answer to "what route does this record describe". The
// mint path builds from it and the adoption match test (design §4.2)
// compares against it — one function, so a shape the reconciler would mint
@@ -418,7 +589,10 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
protect[host] = true
continue
}
- if rec.Region != cfg.Region || !poolServed(cfg.Pools, rec.Pool) {
+ // Scope. For a home-boxed record this is the region/pool test it
+ // always was; for a static or public-addr one the region half widens
+ // to every platform box in the deployment (everywhereClass, above).
+ if !cfg.inScope(rec.Region, rec.Pool, everywhereClass(rec)) {
continue // another agent's jurisdiction, not this pass's business
}
// Two accounts writing the same host is a collision this pass
@@ -640,6 +814,12 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// absence from the record set this pass just read (the bucket's own
// decision to retire the instance, not a guess this pass made).
//
+ // Scope is asked through the SAME inScope as the record scan above, with
+ // the class read off the row (everywhereRoute) because the record whose
+ // class it was is exactly what is gone. Both halves must widen together:
+ // a far box that mints a static route under the widened mint gate and then
+ // judges it out of scope here would serve a retired site forever.
+ //
// What reaches reg.Delete here may carry Backing, Tunnel or Caps even
// though shapeMismatch refuses to ADOPT a row carrying any of them: those
// refusals cover the adoption instant only. `carlos route -backing/-unit/
@@ -650,7 +830,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// the adoption guard is not mistaken for a standing guarantee.
if !readFailed {
for _, rt := range routes {
- if !rt.Provisioned || rt.Region != cfg.Region || !poolServed(cfg.Pools, rt.Pool) {
+ if !rt.Provisioned || !cfg.inScope(rt.Region, rt.Pool, everywhereRoute(rt)) {
continue
}
if _, ok := recs[rt.Host]; ok {
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index f9193a80d..039ba4b19 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -1826,6 +1826,292 @@ func TestReconcileRefusesAdoptInstanceRouteForStaticRecord(t *testing.T) {
}
}
+// ---- serving classes (multi-region ingress design, Part 3's table) ---------
+//
+// The same deployment seen from two PLATFORM boxes: the record's home region
+// ("ie") and another one ("us"), both serving the tito pool. Everything below
+// asks the same question of one record — "does the far box materialize it?" —
+// and the answer is the record's serving class.
+
+func homeBoxCfg() ReconcileConfig {
+ return ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+}
+
+func farBoxCfg() ReconcileConfig {
+ return ReconcileConfig{Region: "us", Pools: []string{"tito"}, DataDir: "/data"}
+}
+
+// titoRecord is the tito-app fixture every serving-class case below mutates:
+// homed in "ie", in the "tito" pool, on the stable channel.
+func titoRecord(sqid, host string) Record {
+ return Record{Schema: 1, Host: host + ".go.tito.io", Slug: host, App: "tito",
+ Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now()}
+}
+
+// A static (bake-served) record is materialized by EVERY platform box in the
+// deployment, not only its home: the edge resolves the app's channel pointer
+// out of the same bucket every box already reads, so no box needs the home box
+// to serve it. Spec Part 3, class "Everywhere".
+func TestReconcileStaticIsMaterializedOnEveryPlatformBox(t *testing.T) {
+ s, sqid := setup(t)
+ rec := titoRecord(sqid, "site")
+ rec.Kind = "static"
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ if err := Reconcile(ctx, s, reg, farBoxCfg()); err != nil {
+ t.Fatal(err)
+ }
+ got := mustGet(t, reg, "site.go.tito.io")
+ if !got.Provisioned || got.Kind != "static" {
+ t.Fatalf("far box did not materialize the static record: %+v", *got)
+ }
+ // The row still carries the RECORD's region — the record is the desired
+ // state and routeFor is its one renderer, so a far box's copy and the home
+ // box's copy are the same route, byte for byte.
+ if got.Region != "ie" {
+ t.Errorf("Region = %q, want the record's own %q", got.Region, "ie")
+ }
+ if got.Socket != "" || got.DBPath != "" || got.Hibernate {
+ t.Errorf("a static route grew a process shape on the far box: %+v", *got)
+ }
+}
+
+// An addr route whose upstreams are all publicly dialable is materialized
+// everywhere too — every edge probes and dials the same public backends. Spec
+// Part 3, class "Everywhere, probed".
+func TestReconcilePublicAddrIsMaterializedOnEveryPlatformBox(t *testing.T) {
+ s, sqid := setup(t)
+ one := titoRecord(sqid, "pub")
+ one.Kind = "service"
+ one.Addr = "203.0.113.10:443"
+ many := titoRecord(sqid, "pubs")
+ many.Kind = "service"
+ many.Addrs = []string{"203.0.113.11:443", "198.51.100.7:443"}
+ for _, rec := range []Record{one, many} {
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reg := regWith(t)
+ if err := Reconcile(ctx, s, reg, farBoxCfg()); err != nil {
+ t.Fatal(err)
+ }
+ for _, host := range []string{"pub.go.tito.io", "pubs.go.tito.io"} {
+ if got := mustGet(t, reg, host); !got.Provisioned {
+ t.Errorf("%s: far box did not materialize a public-upstream record: %+v", host, *got)
+ }
+ }
+}
+
+// The home-boxed classes, unchanged: the process backing them exists on
+// exactly ONE box, so a far box must still refuse every one of them. This is
+// the refusal test the class change is only safe with.
+func TestReconcileHomeBoxedClassesStayHomeBoxed(t *testing.T) {
+ s, sqid := setup(t)
+ socketed := titoRecord(sqid, "exec") // no kind, no addr: the socket/exec shape
+ loopback := titoRecord(sqid, "loop")
+ loopback.Kind = "service"
+ loopback.Addr = "127.0.0.1:8090"
+ private := titoRecord(sqid, "priv")
+ private.Kind = "service"
+ private.Addrs = []string{"10.0.0.5:3000"}
+ unitOwned := titoRecord(sqid, "unit")
+ unitOwned.Addr = "203.0.113.20:8090"
+ unitOwned.Unit = "console.service"
+ named := titoRecord(sqid, "named")
+ named.Kind = "service"
+ named.Addr = "backend.example.com:443"
+ for _, rec := range []Record{socketed, loopback, private, unitOwned, named} {
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reg := regWith(t)
+ if err := Reconcile(ctx, s, reg, farBoxCfg()); err != nil {
+ t.Fatal(err)
+ }
+ for _, host := range []string{"exec.go.tito.io", "loop.go.tito.io", "priv.go.tito.io",
+ "unit.go.tito.io", "named.go.tito.io"} {
+ if rt, err := reg.Get(host); err != nil || rt != nil {
+ t.Errorf("%s: a home-boxed record materialized on a foreign box: %+v %v", host, rt, err)
+ }
+ }
+ // ...and every one of them still materializes on its OWN box.
+ if err := Reconcile(ctx, s, reg, homeBoxCfg()); err != nil {
+ t.Fatal(err)
+ }
+ for _, host := range []string{"exec.go.tito.io", "loop.go.tito.io", "priv.go.tito.io",
+ "unit.go.tito.io", "named.go.tito.io"} {
+ mustGet(t, reg, host)
+ }
+}
+
+// Pool is a TENANCY boundary, not a geography: widening the region half of the
+// gate must not widen the pool half. A static record in the tito pool stays
+// off a box that does not serve that pool, however far it travels.
+func TestReconcileEverywhereClassStillRespectsThePool(t *testing.T) {
+ s, sqid := setup(t)
+ rec := titoRecord(sqid, "site")
+ rec.Kind = "static"
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ // A far box serving only the shared "" pool (nil Pools), and one serving
+ // somebody else's pool.
+ for _, cfg := range []ReconcileConfig{
+ {Region: "us", DataDir: "/data"},
+ {Region: "us", Pools: []string{"eleven"}, DataDir: "/data"},
+ } {
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("site.go.tito.io"); err != nil || rt != nil {
+ t.Fatalf("pools %v: a static record crossed a pool boundary: %+v %v", cfg.Pools, rt, err)
+ }
+ }
+}
+
+// Fleet placement is the other tenancy boundary, and it is refused from BOTH
+// sides: a customer fleet's static site is not served by the platform's boxes,
+// and a customer's fleet box does not pick up the platform's static records.
+// (registry.FleetPlacement is the grammar — "fleet/<name>" in the Region.)
+func TestReconcileFleetPlacementIsNeverEverywhere(t *testing.T) {
+ s, sqid := setup(t)
+ onFleet := titoRecord(sqid, "pisite")
+ onFleet.Kind = "static"
+ onFleet.Region = "fleet/pi"
+ platform := titoRecord(sqid, "site")
+ platform.Kind = "static"
+ for _, rec := range []Record{onFleet, platform} {
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reg := regWith(t)
+
+ // A platform box must not serve the fleet's site...
+ if err := Reconcile(ctx, s, reg, farBoxCfg()); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("pisite.go.tito.io"); err != nil || rt != nil {
+ t.Errorf("a fleet-placed static record materialized on a platform box: %+v %v", rt, err)
+ }
+ // ...and the fleet's own box must not pick up the platform's.
+ fleetReg := regWith(t)
+ if err := Reconcile(ctx, s, fleetReg, ReconcileConfig{Region: "fleet/pi", Pools: []string{"tito"}, DataDir: "/data"}); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := fleetReg.Get("site.go.tito.io"); err != nil || rt != nil {
+ t.Errorf("a platform static record materialized on a customer fleet box: %+v %v", rt, err)
+ }
+ mustGet(t, fleetReg, "pisite.go.tito.io") // its own placement, unchanged
+}
+
+// The other half of adopting everywhere: retiring everywhere. A far box that
+// mints an everywhere-class route from a record must remove it when that
+// record goes, or the widened mint gate leaves an immortal route serving a
+// retired site on every box but its home.
+func TestReconcileRemovesEverywhereRouteWhenItsRecordGoes(t *testing.T) {
+ s, sqid := setup(t)
+ rec := titoRecord(sqid, "site")
+ rec.Kind = "static"
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := farBoxCfg()
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, "site.go.tito.io")
+
+ if err := DeleteRecord(ctx, s, sqid, "tito", "site.go.tito.io"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("site.go.tito.io"); err != nil || rt != nil {
+ t.Errorf("a far box's everywhere-class route outlived its record: %+v %v", rt, err)
+ }
+}
+
+// TestEverywhereClassAgreesAcrossRecordAndRow is the drift pin between the two
+// halves of the class rule: everywhereClass reads a RECORD (the mint gate) and
+// everywhereRoute reads a ROW (the removal gate, asked when the record is gone
+// and the row is all that is left). routeFor is the one renderer between them,
+// so the property holds by construction today — and it is exactly what a class
+// rule added to one function alone would break, silently, with every other test
+// in this file still green. That divergence is the immortality bug the pair was
+// written to close: a far box that mints an everywhere-class route and then
+// cannot see it as one serves a retired site forever.
+//
+// The fixture set is every shape the file already exercises, plus the two
+// directions a new rule would most plausibly be written into one side only (a
+// probe config, an explicitly non-hibernating addr record).
+func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) {
+ off := false
+ on := true
+ base := func(host string) Record {
+ return Record{Schema: 1, Host: host, Slug: "s", App: "tito", Account: "bac",
+ Pool: "tito", Region: "ie", Channel: "stable"}
+ }
+ with := func(host string, mut func(*Record)) Record {
+ rec := base(host)
+ mut(&rec)
+ return rec
+ }
+ fixtures := []struct {
+ name string
+ rec Record
+ }{
+ {"socket instance (no kind, no addr)", base("exec.go.tito.io")},
+ {"static", with("site.go.tito.io", func(r *Record) { r.Kind = "static" })},
+ {"static, status-named", with("named-site.go.tito.io", func(r *Record) { r.Kind, r.StatusNamed = "static", &on })},
+ {"public single addr", with("pub.go.tito.io", func(r *Record) { r.Kind, r.Addr = "service", "203.0.113.10:443" })},
+ {"public addrs set", with("pubs.go.tito.io", func(r *Record) {
+ r.Kind, r.Addrs = "service", []string{"203.0.113.11:443", "198.51.100.7:443"}
+ })},
+ {"public addrs with an explicit probe", with("probed.go.tito.io", func(r *Record) {
+ r.Kind, r.Addrs, r.Health = "service", []string{"203.0.113.12:443", "198.51.100.8:443"}, &Health{Path: "/healthz"}
+ })},
+ {"public addr, explicitly never hibernating", with("awake.go.tito.io", func(r *Record) {
+ r.Kind, r.Addr, r.Hibernate = "service", "203.0.113.13:443", &off
+ })},
+ {"loopback addr", with("loop.go.tito.io", func(r *Record) { r.Kind, r.Addr = "service", "127.0.0.1:8090" })},
+ {"private addrs", with("priv.go.tito.io", func(r *Record) { r.Kind, r.Addrs = "service", []string{"10.0.0.5:3000"} })},
+ {"mixed public and private addrs", with("mixed.go.tito.io", func(r *Record) {
+ r.Kind, r.Addrs = "service", []string{"203.0.113.14:443", "192.168.1.9:3000"}
+ })},
+ {"name-based addr", with("named.go.tito.io", func(r *Record) { r.Kind, r.Addr = "service", "backend.example.com:443" })},
+ {"unit-owned public addr", with("unit.go.tito.io", func(r *Record) {
+ r.Addr, r.Unit = "203.0.113.20:8090", "console.service"
+ })},
+ {"environment-bound static", with("env-site.go.tito.io", func(r *Record) { r.Kind, r.Environment = "static", "staging" })},
+ {"fleet-placed static", with("pisite.go.tito.io", func(r *Record) { r.Kind, r.Region = "static", "fleet/pi" })},
+ }
+ for _, f := range fixtures {
+ t.Run(f.name, func(t *testing.T) {
+ // Every fixture must be one the pass would actually materialize —
+ // a shape ValidateShape refuses never reaches either gate, so a
+ // property proved on one would prove nothing.
+ if err := ValidateShape(f.rec); err != nil {
+ t.Fatalf("fixture is not a shape this pass would ever see: %v", err)
+ }
+ fromRecord := everywhereClass(f.rec)
+ fromRow := everywhereRoute(routeFor(f.rec, "/data", `["seed"]`))
+ if fromRecord != fromRow {
+ t.Fatalf("class disagrees across the mint and removal gates: everywhereClass = %v, "+
+ "everywhereRoute(routeFor(...)) = %v — a record this box mints must be a row it can retire",
+ fromRecord, fromRow)
+ }
+ })
+ }
+}
+
// TestRouteForCarriesAddrsAndHealth is Task 3's mint-side proof: a record
// with an explicit multi-upstream Addrs set mints a route whose Addrs is the
// comma-joined string and whose Health carries a fully-materialized default