domains: a claim cannot capture a hostname another box serves as a route
An account could claim a hostname that is a real, unclaimed route on ANOTHER box. ReconcileAliases' own "a real route owns that host" check is per-box (byHost is only this box's registry), and the attach door deliberately does not verify a route exists at all — an operator-minted route has no provision record to read. Until aliases published fleet-wide that was inert: the alias materialized only on the claimant's box, which did not serve the name. It is not inert now. edgesfile.DeploymentRoutes folds every hostname a LIVE box publishes as a NON-alias home entry, on EdgeIPs' liveness rule and for the same reason. Alias entries are excluded deliberately — an alias IS a materialized claim, so counting one would make every claim reserve its own hostname against itself. ReconcileAliases takes the set and refuses on it, which also retro-cleans claims that materialized before this rule existed: the half neither the fold rule nor a door check can deliver. The catch-all guard keys on "*."+host, NEVER host. A first-route wildcard claim's own host is a real route on the claimant's own box by construction, so the wrong key would refuse every such claim on the deployment that owns it. Pinned by its own test. The door refuses too, byte-identically to the existing "hostname unavailable" 409 — now one package constant behind all three sites, because a distinct shape here would make the door an existence oracle for operator routes. A host this app already holds the claim on still passes: a first-route attach makes the host a real route, so refusing it would break every re-attach. Nil means "nothing known" and is permissive: a bucket the fold cannot read must not break ordinary attach. The total-miss tripwire is louder than EdgeIPs' — degrading here disarms a security guard silently. The fast-converge leg folds once per pass, uncached: on a fleet of under a dozen boxes that is a handful of Gets per serial bump, and a cache would need a staleness rule a security guard must not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 files changed,
+531
−21
internal/console/apidomains.go+69 −1internal/console/apidomains_test.go+82 −0internal/console/domainattach.go+1 −1internal/domains/aliases.go+34 −1internal/domains/aliases_test.go+155 −16internal/edgesfile/fold.go+77 −0internal/edgesfile/fold_test.go+102 −0internal/host/agent.go+11 −2
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 332aec398..3057928d2 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -46,6 +46,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -251,6 +252,21 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| + // Defence in depth for the fleet-wide route collision (see |
| + // deploymentRouteTaken): the authoritative refusal is the box-side |
| + // reconcile pass, which is the only place that can retro-clean a claim |
| + // written before this rule existed. This one just stops the claim being |
| + // written in the first place, so the tenant hears "no" at the door |
| + // instead of watching a hostname sit permanently un-materialized. |
| + // |
| + // Byte-identical to the "held by someone else" 409 below, on purpose: a |
| + // distinct status or message here would make the door an existence |
| + // oracle for operator-minted routes, which is precisely the neutrality |
| + // this file's header commits to. |
| + if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) { |
| + jsonError(w, http.StatusConflict, hostUnavailableMsg) |
| + return |
| + } |
| if in.Kind == "static" { |
| // The shared core decides alias-vs-first-route itself (readyRouteHost |
| // is ITS question — no pre-read here): a ready route makes this an |
| @@ -293,7 +309,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // Exactly these bytes, whoever holds it: naming the holder — or even |
| // distinguishing "held by another app of yours" from "held by a |
| // stranger" — would make this door a hostname-ownership oracle. |
| - jsonError(w, http.StatusConflict, "hostname unavailable") |
| + jsonError(w, http.StatusConflict, hostUnavailableMsg) |
| return |
| } else if err != nil { |
| log.Printf("console: api domains attach %s/%s %s: %v", acct.Sqid, app, host, err) |
| @@ -660,3 +676,55 @@ func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[stri |
| } |
| return readings |
| } |
| + |
| +// hostUnavailableMsg is the ONE refusal string behind every "you cannot have |
| +// this hostname" answer this package gives: the claim already belongs to |
| +// someone else, or a live box in this deployment already serves it as a real |
| +// route. Both reasons answer 409 with these exact bytes and nothing else. |
| +// |
| +// It is a constant rather than three literals because the neutrality is the |
| +// security property. The moment two of these refusals differ — a different |
| +// status, a different message, even different whitespace — a caller can |
| +// binary-search the deployment's hostnames: attach, read the shape of the |
| +// no, learn whether an operator-minted route exists for a name it does not |
| +// own. domains.ErrHostUnavailable carries the same string for the same |
| +// reason (claims.go). |
| +const hostUnavailableMsg = "hostname unavailable" |
| + |
| +// deploymentRouteTaken reports whether host is a REAL route — a non-alias |
| +// home entry — published by some live box in this deployment, and therefore |
| +// not this caller's to claim. |
| +// |
| +// Why the door needs this at all. An account can name any hostname it likes; |
| +// the claim's bucket key is the cross-account gate, so a name nobody has |
| +// CLAIMED is free even when a box is already serving it as an operator- or |
| +// reconciler-minted route. Those rows carry no provision record, so the door |
| +// has never been able to see them: it deliberately does not verify that a |
| +// route exists. Until aliases were published fleet-wide that was inert — |
| +// a claim on another box's route materialized only on the claimant's own |
| +// box, which did not serve the name. It is not inert now. |
| +// |
| +// A host this app ALREADY holds the claim on is its own and is allowed |
| +// through. That is not a softening of the rule, it is what makes the rule |
| +// survive contact with the first-route attach: such an attach materializes |
| +// the host as a real route on the claimant's own box, so from the second |
| +// call onward the fold reports the caller's own route back at them. Without |
| +// this, every re-attach would 409 — and titogo's router re-attaches every |
| +// hostname on each reload pass, so the steady state would be a permanent |
| +// refusal on hostnames this deployment itself granted. |
| +// |
| +// Best-effort, like the fold under it: an unreadable bucket answers "no |
| +// conflict", and the box-side reconcile pass stays the authoritative gate. |
| +// An unreadable CLAIM is likewise treated as "not ours" — the conservative |
| +// direction here is to refuse, and the caller who genuinely owns it loses |
| +// nothing but a retry. |
| +func (s *Server) deploymentRouteTaken(ctx context.Context, host, sqid, app string) bool { |
| + if !edgesfile.DeploymentRoutes(ctx, s.cfg.Releases, time.Now())[host] { |
| + return false |
| + } |
| + c, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| + if err == nil && c != nil && c.Account == sqid && c.App == app { |
| + return false |
| + } |
| + return true |
| +} |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 60c11aba2..f6f809042 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -26,6 +26,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1282,3 +1283,84 @@ func TestLegacyAppTokensHaveSeparateWildFailBudgets(t *testing.T) { |
| t.Fatalf("widgets' app token after titogo's exhausted its budget = %d: %s, want 422 (separate budget)", code, out) |
| } |
| } |
| + |
| +// TestAPIDomainAttachRefusesADeploymentRouteNeutrally: defence in depth for |
| +// the fleet-wide route collision. An operator-minted route carries no |
| +// instance record, so this door has never been able to see it and |
| +// deliberately does not verify that a route exists — which left a hostname |
| +// another box already SERVES free for anyone to claim. Now that aliases |
| +// publish fleet-wide, such a claim captures that traffic on every remote |
| +// edge, so the door refuses it. |
| +// |
| +// The refusal must be byte-identical to the "someone else holds it" 409 |
| +// asserted in TestAPIDomainAttachConflictNeutral: a distinct status or body |
| +// here would turn the door into an existence oracle for operator routes, |
| +// which is exactly the neutrality this file exists to keep. Both refusals |
| +// are compared against each other, not against a literal, so they cannot |
| +// drift apart without this failing. |
| +func TestAPIDomainAttachRefusesADeploymentRouteNeutrally(t *testing.T) { |
| + ctx := context.Background() |
| + fx, member := instanceFixture(t, "code-dom-deproute") |
| + |
| + // go.tito.io is a real, NON-ALIAS route published by a live box called |
| + // "annie". Both halves are required: DeploymentRoutes drops any edges |
| + // file whose box has no fresh status snapshot. |
| + if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), |
| + []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"go.tito.io":{"role":"home"}}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A hostname held by a different account: the existing refusal, and the |
| + // yardstick the new one is measured against. |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: "claimed-by-someone-else.com", Account: "cdef", App: "widgets", |
| + Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + takenCode, _, takenBody := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody("go.tito.io", "two.go.tito.io")) |
| + unavailCode, _, unavailBody := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody("claimed-by-someone-else.com", "two.go.tito.io")) |
| + |
| + if takenCode != http.StatusConflict { |
| + t.Fatalf("attach of another box's route = %d %s, want 409", takenCode, takenBody) |
| + } |
| + if takenCode != unavailCode || strings.TrimSpace(takenBody) != strings.TrimSpace(unavailBody) { |
| + t.Fatalf("refusal %d %q differs from the existing unavailable %d %q — that is an existence oracle", |
| + takenCode, takenBody, unavailCode, unavailBody) |
| + } |
| + // And nothing was written for the refused host. |
| + claimAbsent(t, fx, "go.tito.io") |
| +} |
| + |
| +// A hostname this very app ALREADY holds the claim on stays attachable. This |
| +// is not a softening of the rule above, it is what makes it survive a |
| +// first-route attach: such an attach materializes the host as a REAL route |
| +// on the claimant's own box, so from the second call onward the fold reports |
| +// the caller's own route back at them. titogo's router re-attaches every |
| +// hostname on each reload pass, so without this the steady state would be a |
| +// permanent 409 on hostnames this deployment itself granted. |
| +func TestAPIDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T) { |
| + ctx := context.Background() |
| + fx, member := instanceFixture(t, "code-dom-ownroute") |
| + if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), |
| + []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"mine.example.com":{"role":"home"}}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: "mine.example.com", Account: "bab", App: "titogo", |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := attachDomain(t, fx, member, "mine.example.com", "go.tito.io"); got.Created { |
| + t.Fatalf("re-attach of the caller's own hostname = %+v, want 200 created=false", got) |
| + } |
| +} |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 40dce96a9..c0f57f5f4 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -416,7 +416,7 @@ func (s *Server) rollbackAttach(ctx context.Context, acct *Account, app, host st |
| // diffed against each other to learn anything either one refuses to say. |
| func claimRefusal(host string, err error) *doorRefusal { |
| if errors.Is(err, domains.ErrHostUnavailable) { |
| - return &doorRefusal{http.StatusConflict, "hostname unavailable"} |
| + return &doorRefusal{http.StatusConflict, hostUnavailableMsg} |
| } |
| log.Printf("console: attach %s: claim write: %v", host, err) |
| return &doorRefusal{http.StatusInternalServerError, |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index 1a11a9b1a..77b23ce7b 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -31,7 +31,16 @@ const maxWildcardDomains = 32 |
| // registry); a claim only materializes when its primary route exists |
| // locally AND its account/app match the claim. Routes always win a host |
| // collision: an alias (plain or catch-all) must never shadow a real route. |
| -func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error { |
| +// |
| +// deploymentRoutes is every hostname a live box in this deployment publishes |
| +// as a REAL route (edgesfile.DeploymentRoutes). It extends "routes always |
| +// win" from this box to the whole fleet, which is the only place the rule |
| +// can be enforced at all: byHost below sees only local routes, and the |
| +// attach door cannot see the registry, because an operator-minted route has |
| +// no instance record to read. nil means "nothing known" and is PERMISSIVE by |
| +// contract — a bucket the fold could not read must never break ordinary |
| +// attach. |
| +func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool) error { |
| claims, err := ListClaims(ctx, s) |
| if err != nil { |
| return err |
| @@ -71,6 +80,20 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| // but this row now becomes a key in a file every edge folds |
| // (#287), so a hand-written claim must not reach it. |
| log.Printf("domains: alias %s refused: not a normalised hostname", c.Host) |
| + } else if deploymentRoutes[c.Host] { |
| + // Another box in this deployment serves this hostname as a |
| + // REAL route. byHost above can only see this box, and the |
| + // attach door cannot see the registry at all, so this is the |
| + // only layer that can refuse a claim on someone else's |
| + // operator-minted route. Because the desired set is rebuilt |
| + // every pass, a claim that materialized before this rule |
| + // existed is dropped here too. |
| + // |
| + // Ordered AFTER the shape check on purpose: both refusals are |
| + // log-only (no caller sees them, so there is no ordering |
| + // oracle here), and a malformed hostname should be named as |
| + // malformed rather than as somebody else's route. |
| + log.Printf("domains: alias %s refused: another box serves that hostname as a route", c.Host) |
| } else { |
| desired[c.Host] = true |
| if err := reg.UpsertAlias(registry.RouteAlias{ |
| @@ -137,6 +160,16 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| star := "*." + c.Host |
| if _, taken := byHost[star]; taken { |
| log.Printf("domains: catch-all %s skipped: a real route owns that host", star) |
| + } else if deploymentRoutes[star] { |
| + // THE STAR, never c.Host. A first-route claim's own host is |
| + // published as a real (non-alias) route by the claimant's OWN |
| + // box — that is what "first route" means — so keying this on |
| + // c.Host would refuse the catch-all for every wildcard |
| + // first-route claim in the deployment, on the very box that |
| + // owns it. The woodstar.app class, live today. What must not |
| + // be shadowed here is a real route literally NAMED "*.<host>", |
| + // which is what star asks about. |
| + log.Printf("domains: catch-all %s refused: another box serves that hostname as a route", star) |
| } else { |
| desired[star] = true |
| if err := reg.UpsertAlias(registry.RouteAlias{ |
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go| index 58f39f12d..2507395f8 100644 |
| --- a/internal/domains/aliases_test.go |
| +++ b/internal/domains/aliases_test.go |
| @@ -77,7 +77,7 @@ func TestReconcileAliasesMaterializes(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -103,7 +103,7 @@ func TestReconcileAliasesOwnershipGate(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "somethingelse", Route: "canary/live", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -129,7 +129,7 @@ func TestReconcileAliasesOwnershipGateAccountMismatch(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -150,7 +150,7 @@ func TestReconcileAliasesRoutesWin(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -168,7 +168,7 @@ func TestReconcileAliasesMissingRouteSkips(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -192,7 +192,7 @@ func TestReconcileAliasesRemovesOrphans(t *testing.T) { |
| t.Fatal(err) |
| } |
| // No claim backs orphan.example.com. |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -213,7 +213,7 @@ func TestReconcileAliasesRouteless(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "kass", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| aliases, err := reg.ListAliases() |
| @@ -233,7 +233,7 @@ func TestReconcileAliasesIdempotent(t *testing.T) { |
| putClaim(t, s, Claim{ |
| Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| }) |
| - if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| t.Fatal(err) |
| } |
| first, err := reg.ListAliases() |
| @@ -247,7 +247,7 @@ func TestReconcileAliasesIdempotent(t *testing.T) { |
| // Second pass, later clock: CreatedAt must be preserved, not bumped. |
| later := func() time.Time { return time.Unix(1755000000+3600, 0) } |
| - if err := ReconcileAliases(context.Background(), s, reg, later); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, later, nil); err != nil { |
| t.Fatal(err) |
| } |
| second, err := reg.ListAliases() |
| @@ -284,7 +284,7 @@ func TestReconcileWildcardCreatedAtStable(t *testing.T) { |
| Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", |
| Wildcard: true, Catchall: true}) |
| - if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| first, err := reg.ListWildcardDomains() |
| @@ -300,7 +300,7 @@ func TestReconcileWildcardCreatedAtStable(t *testing.T) { |
| // not bumped — a bump here would move Fingerprint's wildcard_domains |
| // section every poll and force a reload→refresh loop with nothing |
| // actually changed. |
| - if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000+3600, 0) }); err != nil { |
| + if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000+3600, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| second, err := reg.ListWildcardDomains() |
| @@ -335,7 +335,7 @@ func TestReconcileDerivesWildcardState(t *testing.T) { |
| putClaim(t, s, Claim{Host: "bad host.example", Account: "bab", App: "woodstar-relay", |
| Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true, Catchall: true}) |
| - if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil { |
| + if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| @@ -359,7 +359,7 @@ func TestReconcileDerivesWildcardState(t *testing.T) { |
| // Claim removed => derived state removed (pass-owned). |
| deleteClaim(t, s, "woodstar.app") |
| - if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(2000, 0) }); err != nil { |
| + if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(2000, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| wds, _ = reg.ListWildcardDomains() |
| @@ -392,7 +392,7 @@ func TestReconcileWildcardHostCollisionMismatchedTenant(t *testing.T) { |
| Route: "primary.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", |
| Wildcard: true, Catchall: true}) |
| - if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil { |
| + if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| wds, err := reg.ListWildcardDomains() |
| @@ -427,7 +427,7 @@ func TestReconcileWildcardCap(t *testing.T) { |
| Route: "primary.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true}) |
| } |
| - if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil { |
| + if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| wds, err := reg.ListWildcardDomains() |
| @@ -454,7 +454,7 @@ func TestReconcileWildcardEmptyRouteClaim(t *testing.T) { |
| putClaim(t, s, Claim{Host: "story.example", Account: "bab", App: "story", |
| CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true, Catchall: true}) |
| - if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil { |
| + if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil { |
| t.Fatal(err) |
| } |
| wds, _ := reg.ListWildcardDomains() |
| @@ -472,3 +472,142 @@ func TestReconcileWildcardEmptyRouteClaim(t *testing.T) { |
| t.Fatalf("want *.story.example -> story.example, got %+v", aliases) |
| } |
| } |
| + |
| +// A claim naming a hostname that ANOTHER box serves as a real route must not |
| +// materialize. The byHost check above it is per-box (byHost is THIS box's |
| +// routes), and the attach door cannot verify a route exists at all — |
| +// operator-minted rows have no instance record. So without this, an account |
| +// can claim someone else's operator route and, now that aliases publish |
| +// fleet-wide, capture its traffic on every remote edge. |
| +func TestReconcileAliasesRefusesAHostAnotherBoxServesAsARoute(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "go.tito.io", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + |
| + // go.tito.io is a real, non-alias route on some OTHER box in the fleet. |
| + deploymentRoutes := map[string]bool{"go.tito.io": true} |
| + |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range aliases { |
| + if a.Alias == "go.tito.io" { |
| + t.Fatalf("an alias must not materialize for a hostname another box serves as a real route: %+v", aliases) |
| + } |
| + } |
| +} |
| + |
| +// Retro-clean: a claim that materialized before this rule existed is removed |
| +// on the next pass, which is the half neither the fold rule nor a door check |
| +// can deliver — both of those only ever act on NEW traffic and NEW attaches. |
| +func TestReconcileAliasesRemovesAnAliasThatNowCollidesWithADeploymentRoute(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: "go.tito.io", Host: "canary/live", Account: "bab", App: "kass", CreatedAt: 1000, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + putClaim(t, s, Claim{ |
| + Host: "go.tito.io", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + |
| + deploymentRoutes := map[string]bool{"go.tito.io": true} |
| + |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range aliases { |
| + if a.Alias == "go.tito.io" { |
| + t.Fatalf("a pre-existing colliding alias must be removed on the next pass: %+v", aliases) |
| + } |
| + } |
| +} |
| + |
| +// An empty/unknown deployment-route set must not break ordinary attach: the |
| +// fold degrades to "nothing known", and an unrelated custom domain still |
| +// materializes. Fail-OPEN here is the deliberate choice — the fold is |
| +// best-effort evidence read out of a bucket, and a bucket that cannot be |
| +// listed must not take every tenant's custom domain down with it. The local |
| +// byHost check and the remote fold's route preference both still stand. |
| +func TestReconcileAliasesStillMaterializesWhenTheRouteSetIsUnknown(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "shop.customer.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 1 || aliases[0].Alias != "shop.customer.com" { |
| + t.Fatalf("aliases = %+v, want the ordinary custom domain materialized", aliases) |
| + } |
| +} |
| + |
| +// The pin for the one mistake this task is most likely to make: the |
| +// catch-all guard must ask about "*."+host, NEVER host. |
| +// |
| +// A first-route wildcard claim's own host IS a real, non-alias route on the |
| +// claimant's OWN box — that is precisely what "first route" means — so it |
| +// appears in the deployment route set by construction. A guard keyed on |
| +// c.Host would therefore refuse the catch-all for every wildcard first-route |
| +// claim in the fleet, on the very deployment that owns it. That is the |
| +// woodstar.app class, live in production today. |
| +// |
| +// This test fails with deploymentRoutes[c.Host] and passes with |
| +// deploymentRoutes[star]. Nothing else in this file distinguishes them. |
| +func TestReconcileAliasesStillDerivesTheCatchAllForAFirstRouteClaim(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + // The route carries the claim's OWN account and app on purpose: the |
| + // ownership gate above the new guard would short-circuit on a mismatched |
| + // row and the test would pass for the wrong reason. |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "woodstar.app", App: "site", Account: "bab", Kind: "static", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "woodstar.app", Account: "bab", App: "site", |
| + Route: "", Wildcard: true, Catchall: true, // first-route: its own primary |
| + }) |
| + |
| + // The claimant's own box publishes woodstar.app as a REAL route. |
| + deploymentRoutes := map[string]bool{"woodstar.app": true} |
| + |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, a := range aliases { |
| + if a.Alias == "*.woodstar.app" && a.Host == "woodstar.app" { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("catch-all missing: %+v — the guard keyed on the claim host "+ |
| + "instead of \"*.\"+host, refusing every first-route wildcard claim", aliases) |
| + } |
| +} |
diff --git a/internal/edgesfile/fold.go b/internal/edgesfile/fold.go| index dcbb33778..292313379 100644 |
| --- a/internal/edgesfile/fold.go |
| +++ b/internal/edgesfile/fold.go |
| @@ -102,3 +102,80 @@ func EdgeIPs(ctx context.Context, s release.Store, env []string, now time.Time) |
| } |
| return out |
| } |
| + |
| +// DeploymentRoutes is every hostname a LIVE box publishes as a real route — |
| +// a home entry WITHOUT the alias mark. It is the evidence a hostname claim is |
| +// checked against before it may materialize as an alias. |
| +// |
| +// Alias entries are excluded deliberately and the exclusion is the whole |
| +// point: an alias IS a materialized claim, so counting one would make every |
| +// claim reserve its own hostname against itself and no re-attach or |
| +// re-derivation would ever succeed again. Only a NON-alias home entry — an |
| +// operator-minted or reconciler-minted route row — is evidence that some |
| +// other tenant's traffic already belongs to that name. |
| +// |
| +// Live is the same box-snapshot rule EdgeIPs uses, and for the same reason: a |
| +// retired box's lingering file must not go on reserving hostnames forever. |
| +// |
| +// Best-effort by contract: a store that cannot be read returns nil, and a nil |
| +// answer means "nothing known", which callers must treat as permissive. The |
| +// per-box byHost check and the fold's route preference are the other two |
| +// layers; this one is the only one that can act across boxes, but it must not |
| +// be able to break ordinary attach when the bucket is unavailable. |
| +func DeploymentRoutes(ctx context.Context, s release.Store, now time.Time) map[string]bool { |
| + if s == nil { |
| + return nil |
| + } |
| + live := map[string]bool{} |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + log.Printf("edgesfile: could not load box snapshots (%v) — deployment route set unknown this pass", err) |
| + return nil |
| + } |
| + for _, b := range boxes { |
| + if !status.BoxStale(b, now, status.SnapshotPeriod) { |
| + live[b.Label] = true |
| + } |
| + } |
| + keys, err := s.List(ctx, Prefix) |
| + if err != nil { |
| + log.Printf("edgesfile: could not list %s (%v) — deployment route set unknown this pass", Prefix, err) |
| + return nil |
| + } |
| + out := map[string]bool{} |
| + joined := 0 |
| + for _, key := range keys { |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("edgesfile: %s: %v", key, err) |
| + continue |
| + } |
| + f, err := Parse(body) |
| + if err != nil { |
| + log.Printf("edgesfile: %s: %v", key, err) |
| + continue |
| + } |
| + if !live[f.Box] { |
| + continue // no fresh snapshot: not evidence of a live route |
| + } |
| + joined++ |
| + for host, h := range f.Hosts { |
| + if h.Role == RoleHome && !h.Alias { |
| + out[host] = true |
| + } |
| + } |
| + } |
| + // EdgeIPs' tripwire, and it matters MORE here. There a total miss |
| + // degrades to the operator's configured edge list, which is merely the |
| + // pre-fold behaviour; here it degrades to "no hostname is anyone's", |
| + // which silently disarms a SECURITY guard — the answer is an empty map, |
| + // no error, nothing red, and every claim materializes exactly as it did |
| + // before this rule existed. The likely causes are the same three |
| + // (deployment vs status bucket split, every box dark at once, box-name |
| + // drift), and they are all invisible otherwise. Silent on zero files: a |
| + // deployment that has published none is an ordinary state, not a fault. |
| + if len(keys) > 0 && joined == 0 { |
| + log.Printf("edgesfile: %d edges file(s) under %s but no fresh box snapshot matched any of them — the deployment route set is EMPTY this pass, so hostname claims are unchecked against other boxes' routes (are edges and status in different buckets? see CARLOS_STATUS_BUCKET/CARLOS_STATUS_PREFIX)", len(keys), Prefix) |
| + } |
| + return out |
| +} |
diff --git a/internal/edgesfile/fold_test.go b/internal/edgesfile/fold_test.go| index 4635a1d00..4bbaa5995 100644 |
| --- a/internal/edgesfile/fold_test.go |
| +++ b/internal/edgesfile/fold_test.go |
| @@ -212,3 +212,105 @@ func captureLog(t *testing.T) *bytes.Buffer { |
| t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) |
| return &buf |
| } |
| + |
| +// DeploymentRoutes is the evidence a hostname claim is checked against |
| +// before it may materialize as an alias, so what it counts and what it |
| +// ignores is the whole rule: NON-alias home entries on LIVE boxes, and |
| +// nothing else. |
| +// |
| +// The alias exclusion is the load-bearing half. An alias IS a materialized |
| +// claim, so counting one would make every claim reserve its own hostname |
| +// against itself and no re-derivation would ever succeed again — the |
| +// self-poisoning failure this exclusion exists to prevent. |
| +func TestDeploymentRoutesCountsRealRoutesAndIgnoresAliases(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{ |
| + "go.tito.io":{"role":"home"}, |
| + "tickets.example.com":{"role":"home","alias":true}, |
| + "app.bab.oncarlos.com":{"role":"home","platform":true}}}`) |
| + putFreshBox(t, s, "annie", now) |
| + |
| + got := DeploymentRoutes(context.Background(), s, now) |
| + if !got["go.tito.io"] { |
| + t.Errorf("routes = %v, want the operator-minted route counted", got) |
| + } |
| + if got["tickets.example.com"] { |
| + t.Errorf("routes = %v, want the ALIAS entry ignored — counting it would make every claim reserve its own hostname against itself", got) |
| + } |
| + // A platform mint is still a real route: its hostname is genuinely |
| + // served and is not a claim's own output. |
| + if !got["app.bab.oncarlos.com"] { |
| + t.Errorf("routes = %v, want a platform-minted route counted", got) |
| + } |
| +} |
| + |
| +// A retired box's lingering edges file must not go on reserving hostnames |
| +// forever — the same staleness rule EdgeIPs uses, for the same reason, and |
| +// with sharper consequences: here a phantom entry is a REFUSAL, so a dead |
| +// box would permanently block a hostname nobody serves. |
| +func TestDeploymentRoutesDropsABoxWhoseSnapshotIsStale(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"live.example":{"role":"home"}}}`) |
| + putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{"dead.example":{"role":"home"}}}`) |
| + putFreshBox(t, s, "annie", now) |
| + putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| + |
| + got := DeploymentRoutes(context.Background(), s, now) |
| + if got["dead.example"] { |
| + t.Errorf("routes = %v, want the retired box's hostnames dropped", got) |
| + } |
| + if !got["live.example"] { |
| + t.Errorf("routes = %v, want the live box's hostname kept", got) |
| + } |
| +} |
| + |
| +// Best-effort by contract: a store that cannot be read, and no store at all, |
| +// both answer nil — "nothing known", which callers treat as permissive. |
| +// Fail-OPEN is deliberate here even though this is a security guard: a |
| +// bucket that cannot be listed must not take every tenant's custom domain |
| +// down with it, and the per-box route check plus the fold's own route |
| +// preference are the layers underneath. |
| +func TestDeploymentRoutesIsNilWhenNothingCanBeRead(t *testing.T) { |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + if got := DeploymentRoutes(context.Background(), nil, now); got != nil { |
| + t.Errorf("nil store: routes = %v, want nil", got) |
| + } |
| + if got := DeploymentRoutes(context.Background(), brokenStore{}, now); got != nil { |
| + t.Errorf("broken store: routes = %v, want nil", got) |
| + } |
| +} |
| + |
| +// The total-miss tripwire, EdgeIPs' line and then some: there a total miss |
| +// degrades to the operator's configured edge list, which is merely the |
| +// pre-fold behaviour. Here it degrades to "no hostname is anyone's", which |
| +// silently DISARMS the guard — an empty map, no error, nothing red, and |
| +// every claim materializing exactly as it did before the rule existed. The |
| +// same three causes (split buckets, every box dark, box-name drift) and the |
| +// same invisibility, so the log line is all an operator gets. |
| +func TestDeploymentRoutesSaysSoWhenNothingJoined(t *testing.T) { |
| + buf := captureLog(t) |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"go.tito.io":{"role":"home"}}}`) |
| + // No snapshots at all — what a split-bucket deployment looks like here. |
| + if got := DeploymentRoutes(context.Background(), s, now); len(got) != 0 { |
| + t.Fatalf("routes = %v, want empty", got) |
| + } |
| + line := buf.String() |
| + if !strings.Contains(line, "no fresh box snapshot matched") { |
| + t.Errorf("log = %q, want the total-miss tripwire", line) |
| + } |
| + if !strings.Contains(line, "CARLOS_STATUS_BUCKET") { |
| + t.Errorf("log = %q, want it to name where to look", line) |
| + } |
| + |
| + // And silence when there is nothing to join: an empty deployment is an |
| + // ordinary state, not a fault, or the line stops being read. |
| + buf2 := captureLog(t) |
| + DeploymentRoutes(context.Background(), foldTestStore(t), now) |
| + if strings.Contains(buf2.String(), "no fresh box snapshot matched") { |
| + t.Errorf("log = %q, want silence: no edges files is not a fault", buf2.String()) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 35a8a8be6..7c9550ae7 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1318,7 +1318,15 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| if err := reconcileSerialized(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil { |
| log.Printf("agent: fast converge reconcile: %v", err) |
| } |
| - if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil { |
| + // Folded once per pass and handed in, never re-read inside the |
| + // loop: this leg runs on every serial bump (a seconds cadence), |
| + // and the fold is a LoadBoxes plus a List plus one Get per edges |
| + // file. On a fleet of under a dozen boxes that is a handful of |
| + // Gets per bump, which is accepted here rather than cached — a |
| + // cache would need its own staleness rule, and a STALE route set |
| + // is the one thing a security guard must not have. |
| + deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now()) |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes); err != nil { |
| log.Printf("agent: fast converge alias pass: %v", err) |
| } |
| if err := release.Pass(ctx, reg, store, pub, optDir, restart, opts...); err != nil { |
| @@ -1776,7 +1784,8 @@ func instancesLoop(reg *registry.Registry) { |
| if err := reconcileSerialized(ctx, store, reg, cfg); err != nil { |
| log.Printf("agent: instances pass: %v", err) |
| } |
| - if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil { |
| + deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now()) |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes); err != nil { |
| log.Printf("agent: instances alias pass: %v", err) |
| } |
| if !isFleetBox { |