edge: home map mirror + one-hop acceptance
Task 3 of the multi-region backhaul plan (spec 2026-08-20, Part 3): the RECEIVING half of edge-to-edge backhaul, on the production request path. - internal/edge/homemap.go: homeMap mirrors control/edges/ on the cert mirror's 30s list-then-get, carry-forward, last-good idiom (copied, not reinvented) and folds every box's file into one host -> home answer. The fold applies lookupUpstream's one-label wildcard rule (Task 1 publishes registry hosts verbatim, "*.parent" rows included), reads only role:"home" and ignores roles this build does not know, and excludes OWN-BOX entries, blank-EIP boxes and duplicate claims (first box in sorted order wins, identically on every edge). The mesh keys ride the same refresh — verified per request from cache, never a store read — so who homes a host and what proves a hop come from one view of the bucket. - Hop acceptance in ServeHTTP: X-Carlos-Hop + a signature that passes verifyHopEitherKey (dual-accept, never the single-key verifyHop) is served locally with the inbound X-Forwarded-For believed and this edge appended to it; the same headers without a valid signature are an ordinary client with every X-Forwarded-* stripped. A verified hop naming a host this box does not serve gets 503 + Retry-After: 2 from inside the lookupUpstream-miss branch, ahead of where Task 4's dial goes — one hop, ever, is structural, not a policy check. The refusal is reachable only by a holder of the mesh key, so a stranger asking the same question still gets the unchanged 404. - setForwarded replaces pr.SetXForwarded() in all five proxy Rewrite funcs: ReverseProxy deletes inbound X-Forwarded-* before Rewrite runs, so a verified hop's chain has to be restored onto the outbound request for SetXForwarded to append to it. Gated on a context mark nothing outside the package can set. - WithBoxLabel hands the edge its own box label from the agent, the same metricsBoxLabel call the edges publisher uses; without it the map offers nothing rather than risk dialing itself. Single-box inertness: the only edges file is this box's own, own-box exclusion drops it, Home() never offers a target, and nothing signs hops — the verified branch is unreachable until a second box publishes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+1241
−6
internal/edge/edge.go+60 −5internal/edge/homemap.go+564 −0internal/edge/homemap_test.go+530 −0internal/edge/mesh_test.go+79 −0internal/host/agent.go+8 −1
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 29c0a6568..2b57d2d1a 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -168,6 +168,17 @@ 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] |
| } |
| // RunOption configures the Edge that Run/RunWithActivator* builds, before it |
| @@ -181,6 +192,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 +801,29 @@ 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 Task 4 adds lives further down this same branch, |
| + // so an accepted hop physically cannot reach it. |
| + staleHopRefusal(w) |
| + return |
| + } |
| e.unknownHost(w, r) |
| return |
| } |
| @@ -1364,7 +1404,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 +1429,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 +1516,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 +1601,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 +1655,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 |
| @@ -1818,6 +1858,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..7ea68def7 |
| --- /dev/null |
| +++ b/internal/edge/homemap.go |
| @@ -0,0 +1,564 @@ |
| +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. What it must NOT buy is the ability to state who the client |
| +// was, which is why verification gates exactly one thing: whether the |
| +// inbound X-Forwarded-For is believed. |
| +// |
| +// - XFF is trusted as an APPENDED element, never as a replacement. On a |
| +// verified hop the inbound chain is carried onto the outbound request and |
| +// the sending edge's own address is appended to it (setForwarded, below), |
| +// so the app sees "<client>, <sending edge>" — the same shape a |
| +// single-region app already sees, with one more hop in it. On anything |
| +// else 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) == "" { |
| + 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), and |
| +// converge heals it within a publish cycle. |
| +// |
| +// 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's documented "append |
| +// to an existing value" behaviour never fires: an app behind this edge sees |
| +// exactly one 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 actually wants is the one that edge recorded. |
| +// |
| +// So on a verified hop — and only there — the inbound chain is copied onto the |
| +// outbound request first, and SetXForwarded then appends the sending edge to |
| +// it. Multiple inbound X-Forwarded-For lines are joined, which is what |
| +// list-valued header semantics already say they mean (RFC 9110 §5.3). |
| +// |
| +// X-Forwarded-Host and X-Forwarded-Proto need no such restore: the hop carries |
| +// the original Host header and is itself TLS, so SetXForwarded recomputes both |
| +// correctly at this edge. |
| +func setForwarded(pr *httputil.ProxyRequest) { |
| + if hopVerified(pr.In.Context()) { |
| + if chain := pr.In.Header.Values("X-Forwarded-For"); len(chain) > 0 { |
| + pr.Out.Header.Set("X-Forwarded-For", strings.Join(chain, ", ")) |
| + } |
| + } |
| + pr.SetXForwarded() |
| +} |
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go| new file mode 100644 |
| index 000000000..b490a4522 |
| --- /dev/null |
| +++ b/internal/edge/homemap_test.go |
| @@ -0,0 +1,530 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "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) |
| + if !strings.HasPrefix(body, "xff=203.0.113.9, ") { |
| + t.Fatalf("body = %q — a verified hop's inbound XFF must be kept and the sending edge appended to it", 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 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 with NO hop header keeps today's answer exactly: the |
| +// stale-map 503 is reachable only by a party holding the mesh key, so it |
| +// never tells a stranger whether a host exists elsewhere in the deployment. |
| +func TestUnknownHostWithoutAHopIsStillThe404(t *testing.T) { |
| + e, m, _ := hopEdge(t, "annie", "app.test") |
| + putJSON(t, m.s, "control/edges/betty.json", |
| + `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| + resp := hopRequest(t, e, "moved.test", nil) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("status = %d, want the unchanged 404 for an unauthenticated stranger", 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) |
| + } |
| +} |
| + |
| +// 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_test.go b/internal/edge/mesh_test.go| index 59be378da..e873dedbf 100644 |
| --- a/internal/edge/mesh_test.go |
| +++ b/internal/edge/mesh_test.go |
| @@ -308,3 +308,82 @@ func TestLoadMeshKeysToleratesAnUnreadableNext(t *testing.T) { |
| 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/host/agent.go b/internal/host/agent.go| index 4438131c2..8a609ae33 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -361,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). |