Merge main: both sides added Server fields, so both stay
main's custom-domains work (#325) added edgeFold and foldEdgeIPs to the Server struct in the same place this branch added refusedTopics/Mu. Purely additive on both sides — the resolution keeps both blocks. Resolved with care rather than by reflex: the last conflict of exactly this shape, in #259 on 2026-08-23, spliced a struct's fields into a function body and put a non-compiling server.go on main. Verified here by building rather than by reading. Gate on the merge result: 37 packages, vet + gofmt clean, GOOS=darwin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36 files changed,
+4344
−193
apimode.go+28 −0deploy.go+88 −2deploy_test.go+40 −0docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md+703 −0domains_cli.go+69 −2domains_cli_test.go+64 −1domains_e2e_test.go+5 −2e2e_test.go+1 −1internal/console/apidomains.go+140 −11internal/console/apidomains_test.go+291 −0internal/console/appdomains_test.go+83 −0internal/console/domainattach.go+25 −1internal/console/domainstate.go+206 −0internal/console/domainstate_test.go+210 −0internal/console/edgefold_test.go+119 −0internal/console/server.go+107 −1internal/console/server_test.go+7 −1internal/console/view.go+13 −5internal/domains/aliases.go+34 −1internal/domains/aliases_test.go+155 −16internal/domains/reconcile.go+162 −82internal/domains/reconcile_internal_test.go+106 −4internal/domains/reconcile_test.go+107 −13internal/edge/certwarm.go+376 −0internal/edge/certwarm_test.go+269 −0internal/edge/edge.go+39 −0internal/edge/homemap.go+32 −0internal/edgesfile/edgesfile.go+89 −0internal/edgesfile/edgesfile_test.go+49 −0internal/edgesfile/fold.go+181 −0internal/edgesfile/fold_test.go+316 −0internal/host/agent.go+67 −21internal/host/edgesfile_test.go+40 −0internal/host/host_test.go+37 −19internal/status/snapshot.go+37 −10internal/status/status_test.go+49 −0
diff --git a/apimode.go b/apimode.go| index 65d802970..ab73137ab 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2452,6 +2452,34 @@ type apiDomainRecord struct { |
| Catchall bool `json:"catchall"` |
| DelegationStatus string `json:"delegation_status,omitempty"` |
| Guidance string `json:"guidance,omitempty"` |
| + // State is the door's single derived lifecycle word (#299): pending-dns, |
| + // misdirected, issuing-cert, live or unknown, with Reason (a stable |
| + // machine slug) and Detail (a human sentence). Every first-party surface |
| + // reads THIS, not Status. |
| + // |
| + // Status above is legacy and frozen: it only ever meant "some non-stale |
| + // box reported ANY reading", so a row can carry status "live" while its |
| + // state is "misdirected". Empty from a console that predates the field — |
| + // callers must treat "" as "this console does not derive state" and fall |
| + // back, never as a state in its own right. |
| + State string `json:"state,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + Detail string `json:"detail,omitempty"` |
| + Records []apiDomainDNSRecord `json:"records,omitempty"` |
| +} |
| + |
| +// apiDomainDNSRecord is one record the member must create (#299 §2b) — the |
| +// structured form of Guidance. Records sharing a Set are ONE alternative |
| +// (with steering the door offers a CNAME or a set of A records); a client |
| +// picks one set per name and creates every record in it. Required marks a |
| +// record needed whichever alternative is chosen. |
| +type apiDomainDNSRecord struct { |
| + Type string `json:"type"` |
| + Name string `json:"name"` |
| + Value string `json:"value"` |
| + TTL int `json:"ttl"` |
| + Set string `json:"set"` |
| + Required bool `json:"required,omitempty"` |
| } |
| // domainListError wraps a domainList failure with the one extra fact its |
diff --git a/deploy.go b/deploy.go| index 8c221ca54..734ac708d 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -957,8 +957,14 @@ func isConnectionClassFailure(err error) bool { |
| // - DNSStatus != "pointing" (elsewhere, or never checked) WITH guidance: |
| // the DNS-not-pointing report, the console's own Create: text verbatim. |
| // |
| -// A host absent from domains, or present with DNSStatus != "pointing" and |
| -// no guidance yet (checked, but the console has nothing to say), is |
| +// When the door sends a derived state (#299) it is used instead: it names |
| +// "the certificate is coming" directly, where the DNSStatus arms below could |
| +// only infer it. The arms below remain for consoles that predate state. |
| +// |
| +// A host absent from domains, present with DNSStatus != "pointing" and no |
| +// guidance yet (checked, but the console has nothing to say), or stamped |
| +// "unresolved" (OUR lookup failed — not an answer about the member's |
| +// record, so "Create: ..." would blame them for our outage), is |
| // deliberately ok=false — a guessed-at guidance line this call cannot back |
| // up would be worse than the generic timeout report already in hand. |
| func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcct) (msg string, ok bool) { |
| @@ -966,11 +972,55 @@ func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcc |
| if d.Host != host { |
| continue |
| } |
| + // The door's derived state, when it sends one (#299). It says |
| + // directly what the DNS columns could only be reverse-engineered |
| + // into: "issuing-cert" is DNS-is-right-cert-is-coming, which the |
| + // old DNSStatus check could not distinguish from "pointing but |
| + // something else is broken". Older consoles send no state and fall |
| + // through to the DNSStatus arms below, unchanged. |
| + switch { |
| + case d.State == stateIssuingCert: |
| + return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ |
| + "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", |
| + host, app, acct.Seg), true |
| + case d.State == stateLive: |
| + // DNS and certificate are both fine, so a connection failure is |
| + // something else entirely and this function has nothing honest |
| + // to add. |
| + return "", false |
| + case d.Reason == "lookup-failed": |
| + // OUR resolver failed. Not an answer about the member's record, |
| + // so "Create: ..." would blame them for our outage. |
| + return "", false |
| + case d.State == statePendingDNS || d.State == stateMisdirected || d.State == stateChecking: |
| + // checking belongs HERE, not with the silent cases: a domain |
| + // attached seconds ago has genuinely not been looked at by any |
| + // box yet, and the records to create are exactly what its member |
| + // needs. Silence would be a regression against the pre-state |
| + // behaviour, which offered guidance whenever DNS was not |
| + // confirmed pointing. |
| + if d.Guidance == "" && len(d.Records) == 0 { |
| + return "", false |
| + } |
| + msg := fmt.Sprintf("DNS for %s is not pointing at this deployment yet.", host) |
| + if d.Guidance != "" { |
| + msg += " Create: " + d.Guidance |
| + } else if recs := formatDomainRecords(d.Records); recs != "" { |
| + msg += " " + recs |
| + } |
| + return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s'; "+ |
| + "re-run this deploy any time — the shipped release is already promoted.", app, acct.Seg), true |
| + case d.State != "": |
| + // A state this build does not interpret (a newer console). |
| + return "", false |
| + } |
| switch { |
| case d.DNSStatus == "pointing": |
| return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ |
| "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", |
| host, app, acct.Seg), true |
| + case d.DNSStatus == "unresolved": |
| + return "", false |
| case d.DNSStatus != "pointing" && d.Guidance != "": |
| return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+ |
| "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy any time — the shipped release is already promoted.", |
| @@ -1281,3 +1331,39 @@ func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, inter |
| time.Sleep(interval) |
| } |
| } |
| + |
| +// The domain lifecycle states the door derives (#299 §2a). Mirrored here as |
| +// constants rather than compared as string literals so a typo is a build |
| +// error; the door is the authority on the vocabulary and publishes it in the |
| +// list answer's capability block. |
| +const ( |
| + statePendingDNS = "pending-dns" |
| + stateMisdirected = "misdirected" |
| + stateIssuingCert = "issuing-cert" |
| + stateLive = "live" |
| + stateChecking = "checking" |
| + stateUnknown = "unknown" |
| +) |
| + |
| +// formatDomainRecords renders the door's structured records as one short |
| +// line for a deploy exit. Alternatives are joined with "or" — a member who |
| +// created both a CNAME and A records for one name would publish an illegal |
| +// pair, which is exactly what the set marker exists to prevent. |
| +func formatDomainRecords(recs []apiDomainDNSRecord) string { |
| + if len(recs) == 0 { |
| + return "" |
| + } |
| + var order []string |
| + groups := map[string][]string{} |
| + for _, r := range recs { |
| + if _, seen := groups[r.Set]; !seen { |
| + order = append(order, r.Set) |
| + } |
| + groups[r.Set] = append(groups[r.Set], fmt.Sprintf("%s %s -> %s", r.Type, r.Name, r.Value)) |
| + } |
| + var alts []string |
| + for _, set := range order { |
| + alts = append(alts, strings.Join(groups[set], ", ")) |
| + } |
| + return "Create: " + strings.Join(alts, " — or — ") + "." |
| +} |
diff --git a/deploy_test.go b/deploy_test.go| index 2c4a15e78..7d7975a71 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -1117,6 +1117,46 @@ func TestDeployDNSGuidanceMessageBothArms(t *testing.T) { |
| } |
| } |
| +// TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure |
| +// pins the third status: "unresolved" means the DNS LOOKUP failed (our |
| +// resolver, a timeout, a SERVFAIL) — not that the member's record is |
| +// missing. Printing "DNS ... is not pointing ... Create: ..." here would |
| +// tell them to fix something that was never wrong; the honest move is the |
| +// same fall-through as "never checked", so the caller reports the plain |
| +// watchVersion timeout instead of a guess. |
| +func TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{ |
| + {"host": "x.test", "dns_status": "unresolved"}, |
| + }}) |
| + })) |
| + defer ts.Close() |
| + domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { |
| + t.Fatalf("unresolved must fall through, not print a message: ok=%v msg=%q", ok, msg) |
| + } |
| + |
| + // Defensive: even if some future server bug stamped guidance alongside |
| + // "unresolved", this function must still refuse it rather than trust an |
| + // upstream promise it cannot verify. |
| + ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{ |
| + {"host": "x.test", "dns_status": "unresolved", "guidance": "create a CNAME"}, |
| + }}) |
| + })) |
| + defer ts2.Close() |
| + domains2, err := newTestSession(ts2.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if msg, ok := dnsGuidanceMessage(domains2, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { |
| + t.Fatalf("unresolved with stray guidance must still fall through: ok=%v msg=%q", ok, msg) |
| + } |
| +} |
| + |
| // TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor pins review |
| // fix #2: silence is only right when nothing needed attaching anyway. A |
| // console that predates the /domains door 404s the list; when the target |
diff --git a/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md b/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md| new file mode 100644 |
| index 000000000..ecd86af12 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md |
| @@ -0,0 +1,703 @@ |
| +# Managed custom-domain connections — platform-owned verification and cert status |
| + |
| +**Date:** 2026-08-27 · **Status:** approved (design approved in conversation |
| +2026-08-27) · **Requested by:** the Tito Go team, via Paul |
| + |
| +**Closes:** #299 (managed custom-domain connections). **Folds in:** #287 |
| +(attached custom domains are not published to edges files). |
| + |
| +**Builds on:** #151 (the global claim + `route_aliases` + the `domains` |
| +door), #82 / 2026-08-12-domain-attach-design.md (first-route attach, the |
| +DNS-pointing-as-evidence ruling this spec keeps), 2026-08-20-multi-region-ingress-design.md |
| +(the edges files, the elected issuer, `control/certs/` and the mirror), |
| +#300 / PR #301 (the per-host class mark this spec's alias mark follows), |
| +#291 / PR #297 (the cert backfill that makes `control/certs/` complete), |
| +#289 (the housekeeper gating this spec narrows), #207, #180 and #277 (the |
| +exact-only cert admission bound this spec must not widen). |
| + |
| +## 0. How this spec was arrived at |
| + |
| +Four adversarial review rounds (one per stage, then a whole-plan pass) ran |
| +against earlier revisions. Five blockers were found and folded in; the |
| +findings that changed the design are kept inline as rationale rather than |
| +stripped, because in each case the wrong answer is the one a reader would |
| +otherwise reach again — the `deploymentHosts`/`deploymentHost` confusion and |
| +the `Generation` staleness bound especially. |
| + |
| +Scope rulings (Paul, 2026-08-26/27): |
| +- Status + structured records + capability discovery. NOT TXT ownership |
| + proof — claim authz stays first-writer-wins + DNS-pointing-as-evidence |
| + (2026-08-12-domain-attach-design.md §2). |
| +- #287 folded in as stage 1. |
| +- Cert provisioning becomes proactive on the elected issuer. |
| + |
| +--- |
| + |
| +## Stage 1 — make the inputs true |
| + |
| +### 1a. Aliases reach the edges files, with a class mark (#287) |
| + |
| +`edgesFilePass` (internal/host/edgesfile.go:101) folds `reg.ListAliases()` |
| +alongside `reg.List()`. |
| + |
| +Wire: `edgeHost` gains `Alias bool \`json:"alias"\`` — additive, ALWAYS |
| +emitted, the convention #300's `Platform` mark set (a file with no `alias` |
| +key is a pre-mark publisher, not a box publishing no aliases). |
| + |
| +Per alias: join to the primary via `RouteAlias.Host` against the `reg.List()` |
| +map already in hand; skip unless the primary is `Status == "ready"`; emit |
| +`edgeHost{Role:"home", Platform:false, Alias:true}`. `Platform:false` keeps |
| +customer domains out of wildcard-parent derivation (`parentInput`, |
| +homemap.go:343). Routes are written first; an alias never overwrites a route |
| +key on the same box. |
| + |
| +**The fold rule — the security fix.** `foldHomes` (homemap.go:446-465) today |
| +resolves a duplicate host by "first box in sorted order wins". New rule: **a |
| +non-alias entry beats an alias entry; among equals, sorted order still |
| +decides.** |
| + |
| +Why: `ReconcileAliases`' "a real route owns that host" refusal is per-box |
| +(`byHost` is this box's routes — aliases.go:41,66), and the attach door |
| +deliberately does not verify a route exists, because operator-minted routes |
| +have no instance record (apidomains.go:106-113). So an account can claim a |
| +hostname that is a real, UNCLAIMED route on another box. Today inert. Once |
| +both boxes publish `role:"home"`, sorted-order-wins hands the victim's |
| +hostname to the claimant on every other edge — under steering, most traffic. |
| +The vulnerable set is exactly the claimless routes: operator `carlos add` |
| +rows, `go.tito.io`, every route on a self-hosted box. |
| + |
| +**The mark must reach the reader, not just the writer.** The fold |
| +rule alone is not enough: `edgesHostView` (homemap.go:134-141) and |
| +`homeEntry` (homemap.go:143-149) carry only `Role`/`Platform`. Stage 1 must |
| +carry `alias` into both, plus an accessor enumerating fold entries — |
| +**all** of them, not only alias-marked ones, since 3a's corrected warm set is |
| +"not under the apps domain" rather than "alias". Without this accessor stage |
| +3 is unimplementable. |
| + |
| +Note: the always-emit choice (never `omitempty`) matches what #300 |
| +actually shipped — `Platform bool \`json:"platform"\`` with the comment "always |
| +emitted (never omitempty), so a file with no key at all is definitely a |
| +pre-mark publisher" (edgesfile.go:37-46). A review claimed #301 used |
| +`omitempty`; the code says otherwise, and the always-emit reasoning is what |
| +makes "old publisher" distinguishable from "nothing to declare". |
| + |
| +**The fold rule alone is NOT the whole fix.** It corrects what REMOTE |
| +edges answer. It does nothing on the claimant's own box: `ReconcileAliases` |
| +materializes the alias whenever the claim's primary exists locally and the |
| +victim's host is not a LOCAL route, and request routing consults the local |
| +upstream map before `Home()` — an exact local alias match wins regardless of |
| +the fold. So for a pre-existing or door-race claim, any traffic landing on the |
| +claimant's box serves the claimant's content over valid TLS (the mirror hands |
| +every box the victim's cert). That is reachable: an operator route outside the |
| +apps domain (the `go.tito.io` class — apps-domain hosts are door-denied) whose |
| +owner followed our own steering guidance (CNAME to `edge.<apps-domain>`) |
| +steers a share of traffic to every edge, the claimant's included. |
| + |
| +The authoritative refusal therefore belongs in **`ReconcileAliases`**, using |
| +the same shared reader: refuse to materialize an alias whose host any live box |
| +publishes as a non-alias `home` entry. That also retro-cleans pre-existing |
| +claims on the next pass, which neither the fold rule nor a door check can do. |
| + |
| +**Defence in depth at the door.** The attach door additionally refuses such a |
| +host at attach time. Best-effort (it races a concurrent mint), so it shortens |
| +the window but is not the fix. Its refusal must be byte-identical |
| +to the existing 409 "hostname unavailable" (apidomains.go:26-31), or it |
| +becomes an existence oracle for operator routes. |
| + |
| +**Roll order: readers before publishers — the INVERSE of #301's rule.** |
| +#301's mark failed CLOSED in an old reader (no mark → derive no parent), so |
| +publishers could lead. This one fails OPEN: an old reader sees no `alias` key |
| +and falls back to sorted-order-wins. Both halves are in one binary, so a |
| +box-by-box roll would have an early-rolled box publishing into un-rolled |
| +readers. Stage 1 therefore ships as TWO releases: |
| + |
| +1. **Reader release** — fold preference + `alias` carried into |
| + `edgesHostView`/`homeEntry`. Inert while nothing publishes the mark. |
| +2. **Writer release** — alias publication, plus 1b–1e below. |
| + |
| +### 1b. #289 fixed the wrong thing — return the pass to every box |
| + |
| +The per-box divergence #289 saw was ONE INPUT (`cfg.EdgeIPs`, judged |
| +per-box), not the pass. Gating the whole pass silenced N-1 writers instead of |
| +fixing the input. Every section except the auto-purge writes only the box's |
| +own registry, derived deterministically from shared bucket state: |
| + |
| +- §1a account materialization (`reg.PutAccount`/`DeleteAccount`, |
| + reconcile.go:127-137) — gated, non-housekeeper account tables are frozen, |
| + so `carlos accounts list` on those boxes lies. |
| +- §1b route-account stamping (`reg.SetAccount`) — local. |
| +- §1c trashed-route removal (`reg.Delete`, reconcile.go:244-258) — gated, |
| + **a trashed app's routes on non-housekeeper boxes keep serving** until the |
| + purge cooloff deletes the records, against the code's own "Trashed apps |
| + stop routing NOW" and its "the worse betrayal" rationale. |
| +- §2/§3 minting — mints into the LOCAL registry from `firstReadyUpstream` |
| + over LOCAL routes. Per-box is CORRECT (it is how an everywhere-class app |
| + gets its platform host on every box that serves it); housekeeper-only means |
| + an app served only on non-housekeeper boxes never gets a platform domain. |
| +- §3 canary-route removal — local, deterministic from the bucket pointer. |
| +- §4/§5 DNS + cert stamps — local; the original Finding A. |
| + |
| +**Only §1d auto-purge is deployment-global** — it DESTROYS bucket objects, |
| +so it stays a housekeeper duty, lifted into `domains.PurgeTrashed`. |
| + |
| +### 1c. `pointing` judged against the deployment, not the box |
| + |
| +- `domains.Config.EdgeIPs []string` becomes `EdgeIPs func() []string`. |
| + `domainsTickConfig` is read ONCE at arm (agent.go:1619) while the edge set |
| + moves at runtime; a boot-frozen list is the bug being fixed. |
| +- **The parser needs a home both the agent and the console can |
| + import.** 1c originally put the fold in `internal/host` — but the console |
| + is a separate process (`cmd/console`) that does not import `internal/host` |
| + at all, and stage 2's records/`edge_ips` need the same fold. Putting a |
| + reader in the console would make it the FOURTH parser of the shape, which |
| + is the rule 1c invokes against itself. So: extract one exported reader |
| + (package `internal/edgesfile`, or exported from `internal/steering`) and |
| + have `internal/host`, the console, and `internal/edge` share it. |
| + Also: `internal/edge` needs the EIP set too (stage 3's DNS gate), and |
| + host imports edge, never the reverse — a shared low-level package is the |
| + only shape that serves all three. |
| +- Semantics: **UNION** the fold with `PLATFORM_EDGE_IPS`. The fold can only |
| + widen, so a short or failed fold degrades to today's behaviour, never |
| + below. (Contrast `internal/steering`, which fails CLOSED on a short fold |
| + because a short fold there DELETES records; nothing is deleted here.) |
| +- **Liveness bound — NOT `Generation`.** An earlier revision proposed ignoring an |
| + edges file whose `Generation` is older than a few publish cadences. That |
| + cannot work: `Generation` is stamped ONLY when a write happens |
| + (edgesfile.go:145), the change gate suppresses writes for unchanged content |
| + (`bytes.Equal(gate, *last)`), and `seedEdgesFileGate` exists precisely so an |
| + unchanged file is never republished across restarts. On a stable fleet every |
| + HEALTHY box's `Generation` is arbitrarily old, so that bound would ignore |
| + every healthy box and silently degrade the fold to env-only — defeating its |
| + own purpose, in all three stages that share it. |
| + |
| + Use box-snapshot freshness instead: `statusTick`'s `WriteBox` is |
| + UNCONDITIONAL every tick (agent.go:1863) and `BoxStale` compares a moving |
| + `GeneratedAt` (status/fleet.go:137-139) — a real liveness signal. A box |
| + whose status snapshot is stale drops out of the EIP fold. This reuses the |
| + loader and the staleness rule the console already applies |
| + (apidomains.go:650-656), so there is one definition of "a box that still |
| + counts", not two. |
| + |
| + Why it matters: nothing deletes a retired box's `control/edges/<box>.json`, |
| + so without a liveness bound a retired box's file keeps a DEAD customer |
| + domain reading `pointing` — a lying green. |
| +- Guidance consistency: the console's `dnsTargets` (server.go:666) must read |
| + the same fold, or the platform tells a customer to point at an address its |
| + own checker will not credit. |
| + |
| +### 1d. Carry the stamp's age, and give claim-backed ROUTES a reading |
| + |
| +**This is a stage-2 blocker discovered here.** `Metrics.Aliases` is |
| +built exclusively from `Probes.Aliases` = `route_aliases` rows |
| +(snapshot.go:435-441), and `RouteVersions` carries `Version` only |
| +(snapshot.go:166). A **first-route claim** (`Claim.Route == ""`) mints a real |
| +registry ROUTE, not an alias — so it never gets a reading, and |
| +`carlos domains list` shows it `pending` forever. That is live today for |
| +`carlos deploy -kind static -host <site>`, a shipped headline flow. |
| + |
| +The fix is nearly free: `CollectBox(routes []registry.Route, ...)` |
| +(snapshot.go:376) already holds the routes, and `registry.Route` already |
| +carries the `DNSStatus`/`CertExpiresAt` columns §4/§5 stamp. Fold claim- |
| +backed route stamps into the same readings map in the loop already running, |
| +with `Route: r.Host` — which is exactly what `claims.go` means by "an empty |
| +`route` means this host is its own primary". |
| + |
| +Also: |
| +- Add `DNSCheckedAt int64 \`json:"dns_checked_at,omitempty"\`` to |
| + `status.AliasReading` (additive). `registry` has `dns_checked_at` |
| + (registry.go:198) and nothing downstream reads it. |
| +- Make the console fold prefer the FRESHEST reading. Today it is |
| + last-box-wins by map iteration with no tie-break (apidomains.go:656-658), |
| + so a frozen stamp can deterministically overwrite a fresh one. |
| + |
| +### 1e. `pending-dns` honesty |
| + |
| +`dnsStatus` returns `""` for ANY resolver error (reconcile.go:465-476) — |
| +NXDOMAIN and a box-side SERVFAIL are indistinguishable. Deriving |
| +`pending-dns` from that blames the customer's record for our outage, the |
| +failure the `unknown` state exists to prevent. Either add a third stamp value |
| +separating "authoritative no answer" from "lookup failed" (an additive |
| +registry + snapshot field, belonging in this release), or weaken the |
| +`pending-dns` description to what is actually derivable. **Recommend the |
| +third value** — it is small here and unpickable later. |
| + |
| +### 1f. Repins |
| + |
| +- `internal/host/domains_duty_test.go`, the `domainsDuty` doc comment, and |
| + the arm log (agent.go:1632) pin "only the lease holder may reconcile" for |
| + the WHOLE pass. Invert to the new boundary, or a future reader re-gates it |
| + citing the old test. |
| +- New pins: an alias derives no wildcard parent; the fold prefers a route |
| + over an alias; a not-ready primary publishes nothing; the stamping half |
| + runs ungated on every box; a first-route claim gets a reading. |
| + |
| +### What stage 1 does NOT do |
| + |
| +It does not make a latency-steered custom domain work end to end. The |
| +client's TLS terminates at the NEAREST edge, before any backhaul; that edge |
| +serves the alias's cert only from the mirror, and the mirror holds one only |
| +after the issuer has ordered — which today needs a hello AT the issuer. Stage |
| +1 removes the structural blockers (challenge admission, challenge serving, |
| +backhaul, and the fact that an alias homed off the issuer cannot be ordered |
| +at all). The cert EXISTING is stage 3. |
| + |
| +--- |
| + |
| +## Stage 2 — one status, structured records, capability |
| + |
| +**Rollout: stage 2's console roll TRAILS stage 3's fleet roll.** On |
| +the original order, every pointing-but-certless domain would report |
| +`issuing-cert` during the gap — naming platform activity that is |
| +structurally impossible until the warm ships — and would advertise a |
| +`failed` state nothing can produce. That is the same honesty test used to |
| +rename `verifying`, applied to my own state name and failed. |
| + |
| +### 2a. Vocabulary |
| + |
| +One `state` per domain, DERIVED on read, never stored: |
| + |
| +| state | means | derived from | |
| +|---|---|---| |
| +| `pending-dns` | no record found for this host yet | fresh reading, authoritative no-answer | |
| +| `misdirected` | resolves, but not to this deployment | DNSStatus "elsewhere" | |
| +| `issuing-cert` | points; no usable cert yet | pointing + no cert covering the host | |
| +| `live` | points AND a current cert covers it | pointing + cert | |
| +| `failed` | the platform tried and stopped | warm attempts exhausted (stage 3) | |
| +| `unknown` | nothing fresh reports on this host | no non-stale reading, or lookup failed | |
| + |
| +`misdirected` rather than the issue's `verifying`: `verifying` named titogo's |
| +ownership-proof phase, which Paul cut from scope, so the phase the word names |
| +does not exist here — and reusing it for "your record resolves elsewhere" |
| +would label a customer-side condition as platform activity. **This must be |
| +said on #299** so titogo does not build against the proposed vocabulary. |
| + |
| +Plus `reason` (stable machine slug) and `detail` (human sentence). |
| + |
| +**`status` becomes legacy-frozen.** Today `status:"live"` means "some |
| +non-stale box reported ANY reading" — it reads `live` while `dns_status` is |
| +`elsewhere` and no cert exists. Post-change the same row can read |
| +`status:"live", state:"misdirected"`. Keeping both is right (the wire rule; |
| +`status`'s values are frozen), but the trap needs fencing: (a) a test |
| +freezing `status`'s exact current semantics so nobody "fixes" it toward |
| +`state`; (b) every first-party surface — CLI STATUS column, console Settings, |
| +`carlos deploy`'s exit — moves to `state` in the same change; (c) the door |
| +comment and the issue reply mark `status` legacy. |
| + |
| +### 2b. Structured records |
| + |
| +`records: [...]` — what the customer must create. **Corrections:** |
| +- `alias_target` is emitted ONLY when steering is armed. `edge.<apps-domain>` |
| + exists only where the steering converger maintains it (server.go:525-534); |
| + on a non-steering deployment the draft's unconditional field named a |
| + hostname with no records. |
| +- The array needs an **alternative-set marker**: with steering, the English |
| + guidance offers CNAME *or* A records (view.go:163-165), and a flat array |
| + cannot say "one of these two sets" — the records would contradict the |
| + sentence beside them. |
| +- A **wildcard-enabled claim needs its `_acme-challenge` CNAME** in the array |
| + (apidomains.go:409-410) — the platform already instructs it and stamps |
| + `delegation_status`; omitting it makes "what you must create" incomplete |
| + against our own surface. |
| +- **TTL 300**, with the reason stated: steering withdraws a dead edge in |
| + ~90s, and a customer A-record TTL of 3600 nullifies that. |
| +- The English `guidance` sentence changes in LOCKSTEP (apex guidance never |
| + mentions ALIAS today), or 2d's one-story rule breaks on day one. |
| + |
| +Source: the same shared fold as 1c, with the same Generation staleness bound. |
| + |
| +### 2c. Capability discovery — folded into the list answer |
| + |
| +**No new endpoint.** The original justification ("facts an app needs |
| +before it holds a claim") conflated *no claim* with *no app binding*: every |
| +caller is already app-bound, and `GET .../domains` answers fine with zero |
| +claims. Folding a capability block into the list answer's top level gives |
| +present-or-absent for free (an old console omits the keys; a console with no |
| +door at all 404s — the exact signal `carlos deploy` already uses, |
| +deploy.go:915-923), and needs no new role decision and no new limiter. |
| + |
| +``` |
| +{"domains": [...], |
| + "deployment": {"managed_domains": true, "apps_domain": "...", |
| + "steering_host": "...", "edge_ips": [...], |
| + "apex_alias_target": "...", "delegation_zone": "...", |
| + "states": [...]}} |
| +``` |
| + |
| +This also retires `domainsDoorMissing`'s 404-sniff (deploy.go:915-931) — our |
| +own version of titogo's `TITO_EDGE_ATTACH_URL` guessing. |
| + |
| +### 2d. Cert currency — an issuer-written index, not request-path reads |
| + |
| +**+The original 3d was wrong twice.** `control/certs/` entries |
| +are `<private key PEM><chain>` (certsync.go:741-744), so "read the cert from |
| +the bucket" means a Get and an x509 parse of a **key-bearing blob per host |
| +per list call** — strictly more expensive than the box stamp it replaces, on |
| +a door whose existing precedent caps *eight DNS lookups* per call |
| +(domainListCNAMELookupCap). And a custom domain served by a **delegated |
| +wildcard parent** has no per-host entry at all — its cert is |
| +`wildcard-<parent>.pem` — so presence-by-key would read `issuing-cert` |
| +forever for exactly the wildcard customers. |
| + |
| +Instead the console reads a small **index** — host → NotAfter — with no |
| +private keys in it. |
| + |
| +**The index is WRITTEN BY STAGE 3, read by stage 2.** An earlier revision described |
| +it inside stage 2, whose rollout entry is a console roll only — leaving the |
| +writer assigned to no release at all. It is issuer-side fleet code: it ships |
| +and rolls in stage 3 (§3h), and stage 2 merely consumes it. Stage 2 cannot |
| +land before stage 3 for this reason as well as the `issuing-cert` one. |
| + |
| +Storage shape differs from 3e's warm records, deliberately: NotAfter is |
| +**fully derivable** from the mirrored cache on every pass, so a single |
| +rebuilt-every-pass object is safe under last-writer-wins — a second issuer |
| +recomputes the same answer. Attempt counts are NOT derivable, which is why |
| +3e needs per-host CAS keys. Two shapes, two reasons, stated so neither is |
| +copied to the other. |
| + |
| +The index must replicate `hostCertExpiry`'s coverage logic |
| +(reconcile.go:744-755): per-host entry OR covering wildcard PEM — **and** |
| +check `<host>+rsa`, a gap `hostCertExpiry` itself has. Fix that gap at |
| +the source in stage 1, where the stamps are already being touched, so the |
| +box-stamped path and the index cannot disagree. |
| + |
| +### 2e. Scale and the poller contract |
| + |
| +**The list door cannot carry the poller stage 2 invites.** |
| +- `GET .../domains` sits behind `instanceGate` — `cliInstanceBurst` 60/min, |
| + keyed on the token hash and **shared with the instance verbs**. A status |
| + poller would eat the budget its signups and instance-creates run on. |
| +- Every list call is `ListClaims` = one List plus **one Get per claim in the |
| + whole bucket**, filtered in memory afterwards (claims.go:175-199). |
| +- Required: rebuild the list on the #165 fast-path claims index (already |
| + referenced at apidomains.go:436-439) **or** add a per-host |
| + `GET .../domains/{host}` reading exactly one claim object; state a poll |
| + cadence; move the poller's budget off the shared instance ceiling. |
| + |
| +### 2f. Detach |
| + |
| +The issue names "detach-failed-still-remove". After a 200 the claim is gone |
| +and the host leaves the list while edges still serve it until reconcile and |
| +the cert mirror catch up — a poller cannot tell "draining" from "never |
| +existed". Ruling: detach stays fire-and-forget and the answer says so; the |
| +alternative (a draining state) would require a tombstone the claim shape |
| +deliberately does not keep. |
| + |
| +### 2g. Surfaces |
| + |
| +`carlos domains list` gains a State column; console Settings shows the same |
| +state and records; `carlos deploy`'s DNS exit branches on `state` instead of |
| +`DNSStatus == "pointing"` (deploy.go:958-975), which currently cannot tell |
| +"your DNS is wrong" from "your DNS is right, the cert is coming". All read |
| +ONE derivation function. |
| + |
| +--- |
| + |
| +## Stage 3 — the platform provisions the cert |
| + |
| +### 3a. The warm set — alias-derived |
| + |
| +**The draft cited the wrong symbol.** `deploymentHosts()` (plural, |
| +edge.go:738-756) excludes aliases by design (`!up.alias`) and its doc |
| +comment says "Not to be confused with deploymentHost (singular)". The |
| +singular (edge.go:831-833) is the set meant — but it is a PREDICATE with no |
| +enumerator. Built naively it would also warm **every canary host** (three |
| +labels under the apps domain, no wildcard parent covers them, DNS gate |
| +passes) — a per-host cert and CT-log entry for names that are today lazily |
| +issued and rarely visited. That, not custom domains, is where a first-run |
| +burst would come from. |
| + |
| +**An earlier revision said "aliases only". That is also wrong** — it drops |
| +first-route claims, which mint a real ROUTE, not an alias (1d). So |
| +`carlos deploy -kind static -host <site>`, the shipped headline flow this |
| +design itself cites, would never have its cert proactively ordered OR |
| +renewed, and stage 2's `failed` would be unreachable for that whole class. |
| + |
| +The right predicate is simpler than either: **exact hosts this deployment |
| +serves that are NOT under the apps domain.** A custom domain is a foreign |
| +domain by definition — the attach door refuses apps-domain hosts outright |
| +(apidomains.go:63-69) — so this one test covers aliases and first-route |
| +claims alike, and excludes canary hosts, platform hosts and everything a |
| +wildcard covers without needing to reason about label depth. |
| + |
| +- own box: keys of `e.current()` (routes AND aliases), |
| +- remote: entries from the home-map fold (1a's accessor, which must |
| + therefore expose ALL entries, not only alias-marked ones), |
| +- minus `*.` rows (`Allowed` refuses them; a keys-of-map walk includes them), |
| +- minus anything under the apps domain, |
| +- minus names covered by a CONFIGURED wildcard parent |
| + (`CARLOS_WILDCARD_DOMAINS`, e.g. `go.tito.io`) tested **by SHAPE** against |
| + `currentParents()`, NOT `certFor`: `certFor` returns nil for a parent whose |
| + cert is not yet obtained, so a new account's apps would all be warmed |
| + per-host in the window before their wildcard lands. |
| + |
| +### 3b. The pass |
| + |
| +A per-host sibling of `wildcardManager` (wildcard.go:416-495), with three |
| +deliberate departures: |
| + |
| +- **No holding-context claim.** 3a originally copied "the order runs |
| + under the holding's context" — impossible here: the trigger is |
| + `m.GetCertificate`, which takes no context and builds its own |
| + `context.Background()` with a 5-minute timeout (autocert.go:273-275). The |
| + wildcard manager can honour the holding because it drives the ACME client |
| + itself. Instead: re-check the gate BETWEEN orders (the backfill's |
| + per-entry precedent, certsync.go:886) and accept a ≤5-minute overhang, |
| + inside the double-holder envelope `issuerHostPolicy` already documents. |
| + Consequence stated honestly: a finishing order's `cachePut` re-checks the |
| + gate, so the cert lands local-only and the new issuer re-orders it — |
| + bounded waste, not a hazard. |
| +- **The warm calls `GetCertificate` for every warm-set host EVERY |
| + pass, unconditionally** — this is what owns renewal. autocert cannot renew |
| + through a skip-if-fresh check: `m.cert` returns any unexpired cached cert, |
| + so a cert 29 days from expiry is a cache hit and no order happens. Per-host |
| + renewal is exclusively `domainRenewal` timers, which die on every fleet |
| + roll and re-arm only via a `GetCertificate` that reaches `m.cert`. On a |
| + steering deployment a custom domain's visitors may NEVER land on the |
| + issuer, so after a roll nothing re-arms the timer and the cert expires at |
| + day 90 silently. An unconditional call is one `cacheGet` plus `startRenew` |
| + — cheap, and exactly the re-arm. Pin it with a test. |
| +- **No cache-load-before-gate step.** Unlike the wildcard manager, the warm |
| + SERVES nothing (per-host serving is the mirror plus autocert), so a |
| + non-issuer scanning caches is pure waste. It stands down at the gate. |
| + |
| +Retained: per-host hourly backoff on failure; **a harder backoff on an |
| +ACME `rateLimited` error** than on a validation failure, since hourly retries |
| +into a rate limit compound it; a per-pass order cap that returns a SHORT |
| +delay while capped work remains (the wildcard `pass()` precedent) so a |
| +backlog does not trickle out over days. |
| + |
| +**Arm only when `e.staticStore != nil`.** An agent-less `carlos edge` |
| +is ungated (`issuerGate` nil → yes), and would otherwise start proactively |
| +ordering on hobbyist boxes. |
| + |
| +### 3c. The DNS gate |
| + |
| +Resolve each host against the deployment edge set and order only when it |
| +points. LE's failed-validation limit is 5 per account per hostname per hour |
| +and the ACME account is shared per issuer — ordering for a half-finished DNS |
| +is how one customer burns everyone's budget. |
| + |
| +- **Require ALL resolved answers in the set**, not any: partial |
| + pointing passes an any-match gate and then fails validation from LE's |
| + vantage, burning exactly the budget the gate protects. |
| +- **The input does not exist in `internal/edge` yet.** 1c's fold lands |
| + behind `internal/host`, and host imports edge, never the reverse. 1c's |
| + shared reader package is what makes this reachable; the edge also already |
| + parses `EIP` in `edgesFileView` (homemap.go:125-130), so the accessor can |
| + live on the home map with 1c's Generation bound and env union. |
| + |
| +### 3d. The ECDSA trap — verified |
| + |
| +`m.GetCertificate(&tls.ClientHelloInfo{ServerName: host})` with an EMPTY |
| +hello: `supportsECDSA` returns **false** (nil `SignatureSchemes` and nil |
| +`SupportedCurves` skip their checks; the `CipherSuites` loop finds nothing) |
| +→ `certKey{isRSA:true}` → the warm mints an **RSA** cert under `<host>+rsa` |
| +while browsers ask for `<host>`. The order is spent, nothing is satisfied, |
| +and the first visitor still triggers a second issuance. |
| + |
| +Fix: advertise an ECDSA suite in the synthetic hello. Independently confirmed |
| +against x/crypto v0.52.0 (go.mod:17). Everything else checks out: `ServerName` |
| +must be non-empty and contain a dot; idna/lowercasing applied internally; nil |
| +`SupportedProtos` → `wantsTokenCert` false; no `Conn` dereference. The repo |
| +already ports `supportsECDSA` as `helloSupportsECDSA` (certsync.go:1285-1327) |
| +— pin PARITY between the two copies, plus the non-`+rsa`-key assertion. |
| + |
| +### 3e. Where warm outcomes go |
| + |
| +**Per-host CAS keys, not one file.** `control/cert-warm/<host>.json`, |
| +written with the `putCert` CAS idiom. A whole-file map has no per-host |
| +last-writer-wins safety: two transient holders, or a failover, overwrite each |
| +other wholesale, and a takeover issuer starting from empty in-memory state |
| +erases every attempt count. Each record carries a **written-at stamp**, and |
| +the console applies an age bound — a wedged issuer must not read as "warm |
| +quiet". The writer prunes records for detached hosts. |
| + |
| +Verified: the fleet door is an **allowlist** (`aclRead`, |
| +storedoor.go:383-397), so any new `control/` key is denied by default — but |
| +`TestDoorNeverServesCertMaterial` (storedoor_test.go:515-567) must gain the |
| +new keys. Cert writes bump no serial (certsync.go:25-28) — correct here too. |
| +The bucket-writer audit and the ingress spec's writer table need a new row |
| +ruled "issuer" before the write exists. |
| + |
| +### 3f. Removal — expiry-bounded, never inference-driven |
| + |
| +Nothing ever deletes `control/certs/<host>` for a detached domain (`Delete` |
| +fires only for spent tokens). The backfill will not re-publish it |
| +(`ownsCertName` refuses), but the object lingers, the mirror serves it to |
| +matching SNI until expiry, and the expired object then lingers forever. |
| + |
| +**The sweep's predicate must be EXPIRY, not detachment.** A sweep |
| +keyed "no claim exists for this host" would delete every non-claim-backed |
| +cert in the deployment — every platform host, every wildcard PEM, the account |
| +keys. Worse, registry lag or a stale edges read could take down a live host's |
| +certs fleet-wide via the mirror. This repo's own rule is that inference never |
| +destroys owner property (reconcile.go §1c). |
| + |
| +So: delete only entries whose LEAF IS ALREADY EXPIRED plus a margin. That |
| +also dissolves the "mirror serves it until expiry" concern by construction, |
| +and makes a wrong deletion harmless — the entry was already dead. Never |
| +"detached implies delete". Prune cert-warm records on the same pass, on the |
| +same terms. Issuer-side; #243's token-litter ruling is the precedent for |
| +assigning it, not for its predicate. |
| + |
| +### 3h. The cert index (read by stage 2 §2d) |
| + |
| +On every warm pass the issuer rebuilds `control/cert-index.json`: |
| +host → NotAfter, for every host in the warm set, resolved through |
| +`hostCertExpiry`'s coverage rules (per-host entry, `<host>+rsa`, or a |
| +covering wildcard PEM). Whole-file and last-writer-wins is safe here because |
| +the content is fully derived — a second issuer recomputes the same answer |
| +(contrast 3e, whose attempt counts are not derivable and need per-host CAS). |
| +Carries a written-at stamp; the console applies an age bound so a wedged |
| +issuer does not read as "everything current". Added to |
| +`TestDoorNeverServesCertMaterial`'s pinned list and to the bucket-writer |
| +audit's table, ruled "issuer", before the write exists. |
| + |
| +### 3g. Observability |
| + |
| +Failures are 3e. Add the success line (one per obtained host) and a per-pass |
| +summary, matching the wildcard manager's logging discipline. |
| + |
| +--- |
| + |
| +## The shared edges-file reader — the plan's load-bearing artifact |
| + |
| +Six things stand on it: 1a's `ReconcileAliases` refusal and door check, 1c's |
| +EIP fold, 2b's records, 2c's `edge_ips`, and 3c's order gate. Earlier revisions gave the |
| +riskiest new surface the least design, so it gets its own contract: |
| + |
| +- **Where:** one small exported package (`internal/edgesfile`) that owns the |
| + wire struct. `internal/host` (writer), `internal/edge`, the console and |
| + `internal/domains` all import it. `internal/steering` keeps its own |
| + deliberately narrow view (it reads no `Hosts` — steering.go:57-60) and is |
| + named here as a knowing exception rather than left unlisted, per the |
| + consumer-list rule. |
| +- **What it answers:** the live edge-IP set (union with `PLATFORM_EDGE_IPS`), |
| + and per-host "is this published as a non-alias home by a live box". |
| +- **Liveness:** the box-snapshot join above, never `Generation`. |
| +- **Failure:** degrades to env-only, never to empty — nothing here deletes, |
| + so widening is always the safe direction. |
| +- **Cost:** cached with a refresh cadence on the console side; the list door |
| + must not pay a per-request fold. |
| + |
| +## Rollout order |
| + |
| +1. **Stage 1 reader** — fold preference + `alias`/full-entry accessor in the |
| + reader types. Inert no-op roll. |
| +2. **Stage 1 writer** — split into independently green PRs rather than |
| + one: (i) alias publication + fold + `ReconcileAliases` refusal + door |
| + check; (ii) the #289 inversion + `PurgeTrashed`; (iii) the shared reader + |
| + live EIP set + `dnsTargets`; (iv) route readings + stamp age + |
| + resolver-error value + the `+rsa` fix. Fleet roll. |
| +3. **Stage 3** — the warm and the cert index. Fleet roll. |
| +4. **Stage 2** — the API, CLI and console. Console roll plus a CLI release |
| + (tap/apt/website, per the standing sync rule), LAST, so no state the |
| + deployment cannot yet produce is ever advertised. |
| + |
| +Stage 1 ships real value alone (a live security gap, a live regression, a |
| +live false-negative). Stages 3 and 2 together are what make `live` mean what |
| +#299 asks it to mean. |
| + |
| +## Live checks owed before the first armed roll |
| + |
| +- Count `control/certs/` keys against the enumerated warm set on the flagship |
| + issuer — confirm the first pass orders nothing for the existing estate. |
| +- Confirm which box currently holds the housekeeper lease on flagship and |
| + tito, to size 1b's live blast radius. |
| + |
| +--- |
| + |
| +## Addendum — what was built, and where it diverged (2026-08-27) |
| + |
| +The spec above was written against `main` at `da9a7ee`. By the time it was |
| +implemented `main` had moved to `4235e9f` and had independently built most of |
| +stage 1 (#287's alias publication, #303's per-box stamps, the |
| +`Reconcile`/`StampHealth` split). Stage 1 was therefore re-derived as a |
| +salvage — see `.superpowers/sdd/salvage-onto-main/progress.md` — and stages 2 |
| +and 3 were built against the newer base. The divergences below are decisions, |
| +not omissions, and each names what it would cost to be wrong. |
| + |
| +### The vocabulary gained `checking` and never gained `failed` |
| + |
| +`checking` (attached; no edge has reported yet) was split out of `unknown`. |
| +Both mean "no verdict", but they are opposite news: one is the ordinary first |
| +few minutes after an attach, the other is a fault on our side. Collapsed, |
| +every successful attach opened with the word "unknown" — which an e2e test |
| +caught by asserting on the freshly-attached row. |
| + |
| +`failed` is absent. It would mean "the platform tried and stopped", which |
| +requires §3e's per-host warm-outcome records — not built (below). The spec's |
| +own honesty test, the one that renamed `verifying`, applies to it directly: |
| +a state nothing can produce must not be advertised. A test now pins that the |
| +advertised set and the producible set are exactly equal, so `failed` returns |
| +with §3e and not before. |
| + |
| +### §2d's cert index (§3h) was NOT built; stamps are folded instead |
| + |
| +The spec argued for an issuer-written host→NotAfter index because reading |
| +`control/certs/` on the request path means a Get and an x509 parse of a |
| +key-bearing blob per host, and because a wildcard-covered domain has no |
| +per-host entry at all. |
| + |
| +Both objections stand — against reading the BUCKET. They do not apply to the |
| +path actually taken: the boxes already stamp cert expiry into the registry, |
| +`status.LoadBoxes` already carries those stamps to the console, and the |
| +console already folds them across boxes (the freshest-stamp fold). Stage 2 |
| +reads that fold. The wildcard case is covered because `hostCertExpiry` |
| +already checks the covering wildcard PEM — and the `<host>+rsa` gap the spec |
| +flagged was fixed at the source, so the box-stamped path and any future index |
| +cannot disagree. |
| + |
| +Cost if wrong: the fold is bounded by box staleness, so a deployment whose |
| +boxes all go stale reads `checking` rather than `live`. That is the correct |
| +failure direction, and §3h remains available if the fold proves too coarse. |
| + |
| +Not building it also avoided a new bucket object, a new writer-table row, a |
| +new `aclRead` allowlist entry and an update to |
| +`TestDoorNeverServesCertMaterial` — none of which buy anything the existing |
| +stamps do not already carry. |
| + |
| +### §3e and §3f are follow-ups, not part of this change |
| + |
| +§3e (per-host `control/cert-warm/<host>.json` CAS records) and §3f (the |
| +expiry-bounded cert sweep) are not built. The warm works without them: it |
| +orders, it renews, it backs off per host, and it logs. What is missing is |
| +durable observability of warm outcomes across a failover, and the pruning of |
| +certs for long-detached hosts. |
| + |
| +Consequence stated plainly: warm attempt counts live in process memory and |
| +reset when the issuer moves, and `control/certs/` entries for detached hosts |
| +linger until something else removes them. Neither is a correctness or safety |
| +problem — §3f's whole ruling was that the sweep must key on EXPIRY rather |
| +than detachment, so nothing is being deleted wrongly by not running it. |
| + |
| +### The shared reader is a READER, not the wire-shape owner |
| + |
| +`internal/edgesfile` reads the deployment-wide edges picture. It does not own |
| +the writer's struct: `internal/host` had developed that file substantially |
| +(wildcard parents, withheld-alias throttling, EIP-reuse-on-blank), and |
| +re-plumbing it onto a shared type would have fought a file under active |
| +development for no gain to #299. The two shapes are joined by a test instead, |
| +which also preserves the additive-wire property — the writer can add a field |
| +and roll before every reader understands it. |
| + |
| +### Rollout |
| + |
| +The spec required stage 3's fleet roll to precede stage 2's console roll, so |
| +that `issuing-cert` never names activity that cannot happen. Both ship in one |
| +branch, so the constraint applies to the ROLL and not the merge: **roll the |
| +fleet before the consoles.** Rolled the other way, every pointing-but-certless |
| +domain reports `issuing-cert` while nothing is issuing. |
| + |
| +### Still owed |
| + |
| +- §2e's poller contract: `GET .../domains` remains behind `instanceGate`'s |
| + shared 60/min budget and still costs one Get per claim in the bucket. A |
| + status poller must not be pointed at it until the #165 claims index or a |
| + per-host door lands. **This must be said on #299** — it is the one part of |
| + the issue's ask this change does not make safe. |
| +- `deploy.go`'s `domainsDoorMissing` 404-sniff could now be retired in favour |
| + of the capability block. Left in place: it still works, and retiring it is |
| + not free of risk against older consoles. |
| +- The console Settings page still renders the pre-state wording. The CLI and |
| + the deploy exit moved; the page has not. |
diff --git a/domains_cli.go b/domains_cli.go| index 4d30f9f87..8f602970c 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -137,6 +137,18 @@ func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app |
| if d.Host != host { |
| continue |
| } |
| + // The door's own derived state and sentence, so the attach line and |
| + // `carlos domains list` cannot describe the same hostname two ways. |
| + // Falls back to the pre-state wording for an older console. |
| + if d.State != "" { |
| + if d.Detail != "" { |
| + fmt.Fprintf(stdout, "STATE: %s — %s\n", d.State, d.Detail) |
| + } else { |
| + fmt.Fprintf(stdout, "STATE: %s\n", d.State) |
| + } |
| + printDomainRecords(d.Records) |
| + return |
| + } |
| if d.DNSStatus == "pointing" { |
| fmt.Fprintln(stdout, "DNS: pointing") |
| return |
| @@ -205,8 +217,19 @@ func runDomainsList(args []string) { |
| // "wildcard is off", the two cases the door omits the field for. |
| // GUIDANCE (last — it is the long free-text column) is the door's own |
| // point-your-DNS-here instruction, "-" when the domain is pointing. |
| - fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE") |
| + // STATE is the door's derived lifecycle word (#299) and is the column to |
| + // read. The older STATUS only ever meant "some box reported something", |
| + // so it says "live" for a hostname pointing at the wrong place; it is |
| + // kept off the table entirely rather than shown beside a word that |
| + // contradicts it. A console that predates state sends "", and the row |
| + // falls back to that legacy status so an older deployment still prints |
| + // something true. |
| + fmt.Fprintln(w, "HOST\tROUTE\tSTATE\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE") |
| for _, r := range recs { |
| + state := r.State |
| + if state == "" { |
| + state = r.Status |
| + } |
| dns := r.DNSStatus |
| if dns == "" { |
| dns = "-" |
| @@ -232,7 +255,51 @@ func runDomainsList(args []string) { |
| guidance = "-" |
| } |
| fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| - r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation, guidance) |
| + r.Host, r.Route, state, dns, cert, wildcard, catchall, delegation, guidance) |
| } |
| w.Flush() |
| } |
| + |
| +// printDomainRecords prints the records the member must create, grouped by |
| +// alternative. Nothing is printed when the door sent none — a pointing |
| +// hostname has nothing left to do, and an older console sends no records at |
| +// all. |
| +// |
| +// The "or" between groups is the whole point of the set marker: with steering |
| +// the platform offers a CNAME *or* a set of A records, and a member who |
| +// created both would publish a CNAME beside A records for one name, which is |
| +// illegal. Required records print separately because they are needed |
| +// whichever alternative is chosen. |
| +func printDomainRecords(recs []apiDomainDNSRecord) { |
| + if len(recs) == 0 { |
| + return |
| + } |
| + var order []string |
| + groups := map[string][]apiDomainDNSRecord{} |
| + var required []apiDomainDNSRecord |
| + for _, r := range recs { |
| + if r.Required { |
| + required = append(required, r) |
| + continue |
| + } |
| + if _, seen := groups[r.Set]; !seen { |
| + order = append(order, r.Set) |
| + } |
| + groups[r.Set] = append(groups[r.Set], r) |
| + } |
| + for i, set := range order { |
| + lead := "Create:" |
| + if i > 0 { |
| + lead = " or:" |
| + } |
| + for j, r := range groups[set] { |
| + if j > 0 { |
| + lead = " " |
| + } |
| + fmt.Fprintf(stdout, "%s %s %s -> %s (TTL %d)\n", lead, r.Type, r.Name, r.Value, r.TTL) |
| + } |
| + } |
| + for _, r := range required { |
| + fmt.Fprintf(stdout, " Also: %s %s -> %s (TTL %d)\n", r.Type, r.Name, r.Value, r.TTL) |
| + } |
| +} |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index 5f8aa3fb7..abed78e91 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -316,6 +316,13 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending", |
| "dns_status": "elsewhere", |
| "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."}, |
| + // flaky.example.com's lookup FAILED on the box (our resolver, |
| + // not the member's record) — the door sends "unresolved" with |
| + // no guidance, and TestRunDomainsListRendersUnresolvedHonestly |
| + // pins that the CLI prints it as such, never as "pending" (no |
| + // answer yet) and never with a "Create:" instruction. |
| + {"host": "flaky.example.com", "route": "default.go.tito.io", "status": "live", |
| + "dns_status": "unresolved"}, |
| }, |
| }) |
| default: |
| @@ -384,6 +391,26 @@ func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) { |
| } |
| } |
| +// TestRunDomainsAttachPrintsUnresolvedHonestly: an attach onto a host whose |
| +// DNS lookup FAILED (our resolver — flaky.example.com's fixed "unresolved" |
| +// row, no guidance) must complete with that literal status, never with a |
| +// "Create:" instruction telling the member to fix a record we never even |
| +// managed to check. |
| +func TestRunDomainsAttachPrintsUnresolvedHonestly(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "flaky.example.com"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "DNS: unresolved") { |
| + t.Fatalf("stdout = %q, want a plain \"DNS: unresolved\" line", out) |
| + } |
| + if strings.Contains(out, "Create:") { |
| + t.Fatalf("stdout = %q, must not print guidance for a lookup WE failed", out) |
| + } |
| +} |
| + |
| func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) { |
| fc := domainsCLIFixture(t) |
| fc.attachCreated = false |
| @@ -436,7 +463,7 @@ func TestRunDomainsListPrintsTable(t *testing.T) { |
| out := buf.String() |
| for _, want := range []string{ |
| - "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE", |
| + "HOST", "ROUTE", "STATE", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE", |
| "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15", |
| "new.example.com", "pending", |
| "guide.example.com", "elsewhere", |
| @@ -482,6 +509,42 @@ func TestRunDomainsListPrintsTable(t *testing.T) { |
| } |
| } |
| +// TestRunDomainsListRendersUnresolvedHonestly pins the CLI's half of the |
| +// consumer sweep: "unresolved" means the LOOKUP failed on the box (our |
| +// resolver), not that the member's record is missing — the DNS column must |
| +// print the literal status, never fall back to "pending" (which means "no |
| +// answer yet", a different and better state), and the GUIDANCE column must |
| +// stay "-" since the door sends none for it. |
| +func TestRunDomainsListRendersUnresolvedHonestly(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"list", "-app", "tito"}) |
| + |
| + var flakyLine string |
| + for _, l := range strings.Split(buf.String(), "\n") { |
| + if strings.Contains(l, "flaky.example.com") { |
| + flakyLine = l |
| + } |
| + } |
| + if flakyLine == "" { |
| + t.Fatal("no row for flaky.example.com") |
| + } |
| + fields := strings.Fields(flakyLine) |
| + if len(fields) < 4 { |
| + t.Fatalf("flaky row = %q, too few columns", flakyLine) |
| + } |
| + if dns := fields[3]; dns != "unresolved" { |
| + t.Fatalf("flaky row DNS column = %q, want the literal \"unresolved\" status (not \"pending\")", dns) |
| + } |
| + if guidance := fields[len(fields)-1]; guidance != "-" { |
| + t.Fatalf("flaky row GUIDANCE column = %q, want \"-\" — our lookup failure is not the member's problem to fix", guidance) |
| + } |
| + if strings.Contains(flakyLine, "Create:") || strings.Contains(flakyLine, "Point an A record") { |
| + t.Fatalf("flaky row = %q, must not tell the member to (re)point DNS we failed to check", flakyLine) |
| + } |
| +} |
| + |
| // TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed: -wildcard/-catchall are |
| // tri-state on the wire, the way -target already is on `apps place` — |
| // fs.Visit is what makes "not named" and "named false" distinguishable, and |
diff --git a/domains_e2e_test.go b/domains_e2e_test.go| index 87488d55f..bd6ec6f51 100644 |
| --- a/domains_e2e_test.go |
| +++ b/domains_e2e_test.go |
| @@ -229,8 +229,11 @@ func TestE2EDomainsAttachThroughDeploy(t *testing.T) { |
| if err != nil { |
| t.Fatalf("domains list: %v\n%s", err, out) |
| } |
| - if !strings.Contains(out, host) || !strings.Contains(out, "pending") { |
| - t.Fatalf("domains list missing %s as pending:\n%s", host, out) |
| + // "checking" is the #299 state for a claim no edge has reported on yet — |
| + // the ordinary first few minutes after an attach, and what this |
| + // just-attached host must read as. |
| + if !strings.Contains(out, host) || !strings.Contains(out, "checking") { |
| + t.Fatalf("domains list missing %s as checking:\n%s", host, out) |
| } |
| // --- 4. detach releases it, and the record goes with it -------------- |
diff --git a/e2e_test.go b/e2e_test.go| index ac74c37fc..3b9e149aa 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -420,7 +420,7 @@ func TestEndToEndSmoke(t *testing.T) { |
| acctSqid := release.OpsAccount |
| dcfg := domains.Config{ |
| AppsDomain: "oncarlos.test", |
| - EdgeIPs: []string{"203.0.113.7"}, |
| + EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| Now: time.Now, |
| } |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 332aec398..15d0a2830 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" |
| @@ -252,6 +253,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| if in.Kind == "static" { |
| + // No deployment-route check here: this branch delegates to |
| + // attachDomain, which carries the guard immediately before each of |
| + // its own AttachClaim calls — below attachChannel and every other |
| + // gate that can refuse first. One placed HERE would sit above them |
| + // and be read apart from the ordinary 409 by a prober sending a |
| + // deliberately malformed channel (Task 5 review round 2). |
| // The shared core decides alias-vs-first-route itself (readyRouteHost |
| // is ITS question — no pre-read here): a ready route makes this an |
| // ordinary alias claim naming it, none makes the host the app's |
| @@ -275,6 +282,32 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + // The deployment-route refusal sits HERE, immediately before the claim |
| + // write and strictly AFTER resolveAliasRoute — never above it. |
| + // |
| + // Byte-identical bodies are not enough on their own; ORDER is half of |
| + // the neutrality (Task 5 review). When this check ran before |
| + // resolveAliasRoute, the pre-existing "someone else holds it" 409 was |
| + // reachable only after it, so a prober could tell the two apart by |
| + // deliberately failing the LATER gate and reading which refusal came |
| + // back first: |
| + // |
| + // {"host":"<probe>","route":"!!"} -> 409 if <probe> is a live route, |
| + // 400 "invalid route hostname" if not |
| + // {"host":"<probe>"} from an app with no instance records |
| + // -> 409 if <probe> is a live route, |
| + // 400 "specify -route: ..." if not |
| + // |
| + // Both are side-effect-free and enumerate the deployment's non-alias |
| + // route set one hostname at a time, inside the ceiling — exactly the |
| + // existence oracle this file's header forbids. Behind every gate that |
| + // can refuse first, the two 409s are indistinguishable in status, body |
| + // AND reachability. Anything added to this branch later belongs above |
| + // this line, not below it. |
| + if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) { |
| + jsonError(w, http.StatusConflict, hostUnavailableMsg) |
| + return |
| + } |
| created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{ |
| Host: host, Account: acct.Sqid, App: app, Route: route, |
| // UTC, like every other timestamp written to the bucket: these claims |
| @@ -293,7 +326,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) |
| @@ -522,11 +555,13 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| // "live" or "pending" and never absent; the two reading columns omit when |
| // unstamped, the same unknown-stays-absent convention status.AliasReading |
| // itself uses. Guidance is the point-your-DNS-here instruction, present |
| -// exactly when DNSStatus is anything but "pointing" — the same words and |
| -// the same condition as the console Settings page (domainGuidance, view.go), |
| -// so `carlos deploy`'s DNS-guidance exit and the page a member opens next |
| -// can never tell them two different stories. Additive: a client that |
| -// predates it simply never decodes it. |
| +// exactly when DNSStatus is "" (no answer yet) or "elsewhere" (a real, |
| +// wrong answer) — never "pointing" (nothing to fix) and never "unresolved" |
| +// (our lookup failed; telling the member to create a record would blame |
| +// them for our outage). Same condition as the console Settings page |
| +// (domainGuidance, view.go), so `carlos deploy`'s DNS-guidance exit and the |
| +// page a member opens next can never tell them two different stories. |
| +// Additive: a client that predates it simply never decodes it. |
| type domainListItem struct { |
| Host string `json:"host"` |
| Route string `json:"route"` |
| @@ -546,6 +581,20 @@ type domainListItem struct { |
| // deployment has no delegation zone configured, or the claim doesn't |
| // have Wildcard: there is nothing to report in either case. |
| DelegationStatus string `json:"delegation_status,omitempty"` |
| + // State is #299's single derived lifecycle word, with Reason (a stable |
| + // machine slug) and Detail (a human sentence) beside it. Always present. |
| + // |
| + // Status above is now LEGACY and frozen: it means "some non-stale box |
| + // reported ANY reading", which is why a row can read status "live" while |
| + // state is "misdirected". Both are kept because the wire is additive, but |
| + // every first-party surface reads State. |
| + State string `json:"state"` |
| + Reason string `json:"reason,omitempty"` |
| + Detail string `json:"detail,omitempty"` |
| + // Records is the structured form of Guidance — the same advice as data, |
| + // so an app need not parse the sentence. Records sharing a "set" are one |
| + // alternative; a client picks one set per name. |
| + Records []dnsRecord `json:"records,omitempty"` |
| } |
| // handleAPIDomainList answers `carlos domains list`: this app's claims, each |
| @@ -569,7 +618,11 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not list the hostnames") |
| return |
| } |
| - readings := aliasReadings(ctx, s.cfg.Releases, time.Now()) |
| + now := time.Now() |
| + readings := aliasReadings(ctx, s.cfg.Releases, now) |
| + // Read ONCE per call, not per claim: dnsTargets folds the deployment's |
| + // edge set, and a per-claim read would refold it for every hostname. |
| + targets := s.dnsTargets() |
| // This app's claims, host-sorted BEFORE the loop below — not just for |
| // the answer's own stable order (the Store.List contract makes no |
| // promise, and a terminal listing that shuffles between calls reads as |
| @@ -588,13 +641,23 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| for _, c := range mine { |
| item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending", |
| Wildcard: c.Wildcard, Catchall: c.Catchall} |
| - if reading, ok := readings[c.Host]; ok { |
| + reading, seen := readings[c.Host] |
| + if seen { |
| item.Status = "live" |
| item.DNSStatus = reading.DNSStatus |
| item.CertExpiresAt = reading.CertExpiresAt |
| } |
| - if item.DNSStatus != "pointing" { |
| - item.Guidance = domainGuidance(c.Host, s.dnsTargets()) |
| + item.State, item.Reason, item.Detail = domainState(reading, seen, now) |
| + // "unresolved" means the lookup FAILED (our resolver, not the |
| + // customer's record) — pointing them at "create this record" |
| + // guidance would blame them for our outage. Only "" (no answer |
| + // yet) and "elsewhere" (a real, wrong answer) earn guidance. |
| + if item.DNSStatus != "pointing" && item.DNSStatus != "unresolved" { |
| + item.Guidance = domainGuidance(c.Host, targets) |
| + // The structured form of the SAME advice, emitted under exactly |
| + // the same condition so the two can never disagree about whether |
| + // there is anything for the member to do. |
| + item.Records = domainRecords(c.Host, targets, c.Wildcard, s.delegationZone) |
| } |
| // Each stamp is a live, sequential, uncancellable-per-call DNS round |
| // trip on the request path (lookupCNAME) — capped at |
| @@ -614,7 +677,13 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| } |
| out = append(out, item) |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"domains": out}) |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "domains": out, |
| + // The self-describing block (#299 §2c). An app reads managed_domains |
| + // to know the platform owns verification and certificates here, and |
| + // stops implementing them itself. |
| + "deployment": s.domainCapability(), |
| + }) |
| } |
| // domainListCNAMELookupCap bounds how many live CNAME lookups one |
| @@ -655,8 +724,68 @@ func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[stri |
| continue |
| } |
| for host, reading := range b.Metrics.Aliases { |
| + // Freshest stamp wins. An alias whose primary is everywhere-class |
| + // materializes on several boxes, and map iteration order is not a |
| + // tie-break — without this a box that has stopped stamping can |
| + // deterministically overwrite a live box's reading with a frozen |
| + // one. Ties keep the incumbent, so the fold is stable. |
| + if prior, ok := readings[host]; ok && prior.DNSCheckedAt >= reading.DNSCheckedAt { |
| + continue |
| + } |
| readings[host] = reading |
| } |
| } |
| 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..aa6771e0c 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" |
| @@ -415,6 +416,56 @@ func TestAPIDomainList(t *testing.T) { |
| } |
| } |
| +// TestAPIDomainListSuppressesGuidanceForUnresolvedDNS pins the honesty rule |
| +// this task adds: "unresolved" means the DNS LOOKUP FAILED (our resolver), |
| +// not that the member's record is missing or wrong. The door must not print |
| +// "point your DNS here" guidance for it — that would blame the member for |
| +// our outage. "elsewhere" (a real, wrong answer) still earns guidance, so |
| +// the suppression is specific to "unresolved" and not a blanket regression. |
| +func TestAPIDomainListSuppressesGuidanceForUnresolvedDNS(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-unresolved") |
| + ctx := context.Background() |
| + now := time.Now() |
| + attachDomain(t, fx, member, "broken.example.com", "go.tito.io") |
| + attachDomain(t, fx, member, "wrong.example.com", "go.tito.io") |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "broken.example.com": {Route: "go.tito.io", DNSStatus: "unresolved"}, |
| + "wrong.example.com": {Route: "go.tito.io", DNSStatus: "elsewhere"}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s, want 200", code, out) |
| + } |
| + var got struct { |
| + Domains []struct { |
| + Host string `json:"host"` |
| + DNSStatus string `json:"dns_status"` |
| + Guidance string `json:"guidance"` |
| + } `json:"domains"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + byHost := map[string]string{} |
| + for _, d := range got.Domains { |
| + if d.DNSStatus != map[string]string{"broken.example.com": "unresolved", "wrong.example.com": "elsewhere"}[d.Host] { |
| + t.Fatalf("%s dns_status = %q, unexpected", d.Host, d.DNSStatus) |
| + } |
| + byHost[d.Host] = d.Guidance |
| + } |
| + if g := byHost["broken.example.com"]; g != "" { |
| + t.Fatalf("unresolved host got guidance %q, want none — our lookup failure is not the member's problem to fix", g) |
| + } |
| + if g := byHost["wrong.example.com"]; g == "" { |
| + t.Fatal("a real wrong-answer (elsewhere) host must still get point-your-DNS guidance") |
| + } |
| +} |
| + |
| // domainEvents counts this app's audit rows whose action starts "domain-". |
| func domainEvents(t *testing.T, fx *consoleFixture) int { |
| t.Helper() |
| @@ -1282,3 +1333,243 @@ 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 indistinguishable from the "someone else holds it" |
| +// 409 in TWO ways, and this test asserts both: |
| +// |
| +// 1. SAME BYTES. Status and body are compared against the pre-existing |
| +// refusal itself, not against a literal, so the two cannot drift apart. |
| +// |
| +// 2. SAME REACHABILITY. Byte-identical bodies are worthless if the new |
| +// check runs EARLIER than the old one, because a prober can then |
| +// deliberately fail a later gate and read the two apart by which |
| +// refusal comes back first (Task 5 review). So the probes below use a |
| +// BAD route and an ABSENT route — request shapes that must be refused |
| +// by resolveAliasRoute before the hostname is ever considered — and |
| +// assert a deployment-route host and a never-heard-of host answer |
| +// IDENTICALLY. With the guard ordered above resolveAliasRoute these |
| +// read 409 vs 400 and the deployment's whole non-alias route set can be |
| +// enumerated one host at a time, side-effect-free, inside the ceiling. |
| +// |
| +// A test that only probes with a VALID route (as the first version of this |
| +// one did) cannot see failure mode 2 at all. |
| +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 pre-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) |
| + } |
| + |
| + attach := func(body string) (int, string) { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body) |
| + return code, strings.TrimSpace(out) |
| + } |
| + |
| + // (1) Same bytes: with a perfectly valid route, both refused hostnames |
| + // answer the identical neutral 409. |
| + takenCode, takenBody := attach(attachBody("go.tito.io", "two.go.tito.io")) |
| + heldCode, heldBody := attach(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 != heldCode || takenBody != heldBody { |
| + t.Fatalf("refusal %d %q differs from the existing unavailable %d %q — that is an existence oracle", |
| + takenCode, takenBody, heldCode, heldBody) |
| + } |
| + claimAbsent(t, fx, "go.tito.io") |
| + |
| + // (2) Same reachability. "never-heard-of.example" is not a deployment |
| + // route, is claimed by nobody, and is not denied — the ONLY thing that |
| + // differs between the two probes in each pair is whether a live box |
| + // serves the hostname. That difference must not be observable. |
| + for _, probe := range []struct { |
| + what string |
| + body func(host string) string |
| + }{ |
| + // A malformed route: resolveAliasRoute's 400 must come first. |
| + {"a malformed route", func(host string) string { return attachBody(host, "!!") }}, |
| + // No route at all, from an app with zero instance records: the |
| + // "specify -route" 400 must come first. Needs no malformed input at |
| + // all, which is what makes it the cheap version of this probe. |
| + {"an absent route", func(host string) string { return attachBody(host, "") }}, |
| + // The kind=static branch, which takes no route and so has none of |
| + // the gates above — its later gate is attachChannel, inside the |
| + // shared attachDomain core. Round 1 left the guard at the top of |
| + // this branch on the reasoning that there was nothing earlier to |
| + // order against; there was nothing earlier, but there was something |
| + // LATER, and that is just as readable. Round 2 moved the guard down |
| + // into the core, immediately before each AttachClaim. |
| + {"a malformed channel on the static branch", func(host string) string { |
| + b, err := json.Marshal(map[string]string{"host": host, "kind": "static", "channel": "!!"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| + }}, |
| + } { |
| + routeCode, routeBody := attach(probe.body("go.tito.io")) |
| + unknownCode, unknownBody := attach(probe.body("never-heard-of.example")) |
| + if routeCode != unknownCode || routeBody != unknownBody { |
| + t.Errorf("with %s, a deployment-route host answered %d %q but an unknown host answered %d %q — "+ |
| + "the deployment-route check is ordered ABOVE the gate that should refuse first, "+ |
| + "so this door enumerates every non-alias route in the fleet", |
| + probe.what, routeCode, routeBody, unknownCode, unknownBody) |
| + } |
| + if routeCode == http.StatusConflict { |
| + t.Errorf("with %s, the request was refused as a conflict (%d %q) — a request that "+ |
| + "cannot name a route must never get as far as deciding whether the hostname is free", |
| + probe.what, routeCode, routeBody) |
| + } |
| + } |
| + |
| + // (3) The kind=static branch actually REFUSES, not merely refuses in the |
| + // right order. The ordering probes above cannot see this: with a |
| + // malformed channel, attachChannel refuses BOTH hosts identically and |
| + // the pair agrees whether or not the guard behind it exists at all. So |
| + // probe once more with a VALID channel, where nothing stands between the |
| + // guard and the claim write, and compare against the held hostname on |
| + // the same branch. |
| + staticBody := func(host string) string { |
| + b, err := json.Marshal(map[string]string{"host": host, "kind": "static", "channel": "stable"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| + } |
| + staticRouteCode, staticRouteBody := attach(staticBody("go.tito.io")) |
| + staticHeldCode, staticHeldBody := attach(staticBody("claimed-by-someone-else.com")) |
| + if staticRouteCode != http.StatusConflict { |
| + t.Errorf("kind=static attach of another box's route = %d %q, want 409 — the first-route "+ |
| + "branch reaches AttachClaim with no deployment-route guard in front of it", |
| + staticRouteCode, staticRouteBody) |
| + } |
| + if staticRouteCode != staticHeldCode || staticRouteBody != staticHeldBody { |
| + t.Errorf("kind=static refusal %d %q differs from the held-hostname refusal %d %q — that is an existence oracle", |
| + staticRouteCode, staticRouteBody, staticHeldCode, staticHeldBody) |
| + } |
| + |
| + // Nothing was written by any probe. |
| + claimAbsent(t, fx, "go.tito.io") |
| + claimAbsent(t, fx, "never-heard-of.example") |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +// readingsTestStore is a bare release store for aliasReadings tests that |
| +// don't need a full consoleFixture — same DirStore-over-TempDir pattern |
| +// status_test.go's teststore uses. |
| +func readingsTestStore(t *testing.T) release.Store { |
| + t.Helper() |
| + return release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +// writeBoxWithAlias publishes one box whose Metrics.Aliases carries a single |
| +// reading, all under the fixed hostname "shop.customer.com" — the tests |
| +// using this helper are about the FOLD across boxes (freshest stamp wins), |
| +// not about hostname routing, so every box in a given test contends for the |
| +// same key on purpose. |
| +func writeBoxWithAlias(t *testing.T, s release.Store, label string, now time.Time, reading status.AliasReading) { |
| + t.Helper() |
| + if err := status.WriteBox(context.Background(), s, status.Box{ |
| + Label: label, GeneratedAt: now, |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "shop.customer.com": reading, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Two boxes can both carry an alias (an everywhere-class primary). The fold |
| +// must take the FRESHEST stamp, not whichever box the map happened to yield |
| +// last — otherwise a frozen reading deterministically overwrites a live one. |
| +// |
| +// DirStore.List returns keys sorted, and WriteBox keys off the box label, so |
| +// LoadBoxes iterates boxes in label order regardless of write order. A test |
| +// with only one label ordering can't tell a genuine freshness comparison |
| +// apart from a last-write-wins bug that happens to agree with it for that |
| +// ordering — so this asserts BOTH directions: the fresher box sorting |
| +// alphabetically first (where a last-write-wins fold would wrongly keep the |
| +// stale, later-sorted box's reading) and sorting last (where naive |
| +// last-write-wins would coincidentally get it right too). Only a real |
| +// DNSCheckedAt comparison passes both. |
| +func TestAliasReadingsPrefersTheFreshestStamp(t *testing.T) { |
| + t.Run("fresher box sorts first", func(t *testing.T) { |
| + s := readingsTestStore(t) |
| + now := time.Unix(1756300060, 0) |
| + writeBoxWithAlias(t, s, "annie", now, status.AliasReading{ |
| + Route: "app.example.com", DNSStatus: "pointing", DNSCheckedAt: 1756300000}) |
| + writeBoxWithAlias(t, s, "betty", now, status.AliasReading{ |
| + Route: "app.example.com", DNSStatus: "elsewhere", DNSCheckedAt: 1756200000}) |
| + |
| + got := aliasReadings(context.Background(), s, now) |
| + if got["shop.customer.com"].DNSStatus != "pointing" { |
| + t.Fatalf("reading = %+v, want the freshest box's stamp (annie, which sorts first)", got["shop.customer.com"]) |
| + } |
| + }) |
| + |
| + t.Run("fresher box sorts last", func(t *testing.T) { |
| + s := readingsTestStore(t) |
| + now := time.Unix(1756300060, 0) |
| + writeBoxWithAlias(t, s, "annie", now, status.AliasReading{ |
| + Route: "app.example.com", DNSStatus: "elsewhere", DNSCheckedAt: 1756200000}) |
| + writeBoxWithAlias(t, s, "betty", now, status.AliasReading{ |
| + Route: "app.example.com", DNSStatus: "pointing", DNSCheckedAt: 1756300000}) |
| + |
| + got := aliasReadings(context.Background(), s, now) |
| + if got["shop.customer.com"].DNSStatus != "pointing" { |
| + t.Fatalf("reading = %+v, want the freshest box's stamp (betty, which sorts last)", got["shop.customer.com"]) |
| + } |
| + }) |
| +} |
diff --git a/internal/console/appdomains_test.go b/internal/console/appdomains_test.go| index 02ab75a0f..2ddc2a2f0 100644 |
| --- a/internal/console/appdomains_test.go |
| +++ b/internal/console/appdomains_test.go |
| @@ -15,6 +15,8 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| // TestAppDomainAttachHappyPathAliases exercises the default fixture's kass |
| @@ -211,3 +213,84 @@ func TestAppDomainDetachIsOwnerOnly(t *testing.T) { |
| t.Errorf("claim after a member's refused detach: %v, want it untouched", err) |
| } |
| } |
| + |
| +// TestAppDomainAttachRefusesADeploymentRouteNeutrally: the BROWSER door's |
| +// half of the fleet-wide route collision (Task 5 review round 2). Both doors |
| +// share attachDomain, which is where the guard now lives — immediately |
| +// before each of its two AttachClaim calls — so this door is covered by the |
| +// same move that closed the CLI's kind=static branch. This test is what |
| +// makes that coverage a fact rather than an inference. |
| +// |
| +// The property is the one the CLI door keeps: a hostname a live box already |
| +// serves as a REAL route is refused with the refusal a hostname held by |
| +// another account gets, and the two are indistinguishable. Compared page |
| +// against page, not against a literal, so they cannot drift apart. |
| +func TestAppDomainAttachRefusesADeploymentRouteNeutrally(t *testing.T) { |
| + ctx := context.Background() |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // go.tito.io is a real, NON-ALIAS route published by a live box. 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) |
| + } |
| + // The yardstick: a hostname held by a different account entirely. |
| + 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) |
| + } |
| + |
| + routeStatus, _, routeBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {csrf}, "host": {"go.tito.io"}}) |
| + heldStatus, _, heldBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {csrf}, "host": {"claimed-by-someone-else.com"}}) |
| + |
| + if routeStatus != http.StatusConflict { |
| + t.Fatalf("browser attach of another box's route = %d, want 409:\n%s", routeStatus, routeBody) |
| + } |
| + if routeStatus != heldStatus || routeBody != heldBody { |
| + t.Fatalf("browser refusal %d differs from the held-hostname refusal %d — that is an existence oracle:\n--- route ---\n%s\n--- held ---\n%s", |
| + routeStatus, heldStatus, routeBody, heldBody) |
| + } |
| + claimAbsent(t, fx, "go.tito.io") |
| +} |
| + |
| +// The browser door's own version of the ownership exemption: a hostname this |
| +// app ALREADY holds the claim on stays attachable, because a first-route |
| +// attach makes the host a real route and the fold would otherwise report the |
| +// caller's own route back at them forever. |
| +func TestAppDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T) { |
| + ctx := context.Background() |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + 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: fx.sqid, App: "kass", |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| + url.Values{"csrf": {csrf}, "host": {"mine.example.com"}}) |
| + if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| + t.Fatalf("re-attach of the caller's own hostname = %d -> %q, want 303 -> settings:\n%s", st, loc, body) |
| + } |
| +} |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 40dce96a9..baff49f74 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -24,6 +24,24 @@ |
| // would let two accounts each declare a record for one host and the loser |
| // find out from the fleet. |
| // |
| +// The fleet-wide deployment-route refusal lives in here for the same reason, |
| +// and with one extra rule: it sits IMMEDIATELY BEFORE each AttachClaim, below |
| +// every gate that could refuse for another reason. Byte-identical refusals |
| +// are only half of the neutrality — ORDER is the other half. A check placed |
| +// above attachChannel, say, is read apart from the ordinary "held by someone |
| +// else" 409 by a prober who deliberately sends a malformed channel: a |
| +// hostname a live box serves comes back 409, anything else comes back the |
| +// channel refusal, and the deployment's whole non-alias route set enumerates |
| +// one hostname at a time. So the two calls below are deliberately duplicated |
| +// rather than hoisted to the top of this function, and anything added to |
| +// either branch later belongs ABOVE them, never between them and the write. |
| +// (Task 5 review, rounds 1 and 2; the CLI door's own direct-AttachClaim path |
| +// carries the same guard under the same rule — apidomains.go.) |
| +// |
| +// Both refusals go through claimRefusal with domains.ErrHostUnavailable |
| +// rather than constructing a doorRefusal here, so they ARE the existing |
| +// refusal path rather than a second one that merely matches it today. |
| +// |
| // audit + bumpSerial live in here rather than in the doors, so both doors |
| // get them, in the right place, for free: strictly after the write they |
| // announce — and gated on an ACTUAL write (#151's changed-gating rule): a |
| @@ -106,6 +124,9 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| // --- alias attach: the app already has a route to serve from --------- |
| if route != "" { |
| + if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) { |
| + return attachResult{}, claimRefusal(host, domains.ErrHostUnavailable) |
| + } |
| created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim) |
| if err != nil { |
| return attachResult{}, claimRefusal(host, err) |
| @@ -126,6 +147,9 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| if ref != nil { |
| return attachResult{}, ref |
| } |
| + if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) { |
| + return attachResult{}, claimRefusal(host, domains.ErrHostUnavailable) |
| + } |
| created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim) |
| if err != nil { |
| return attachResult{}, claimRefusal(host, err) |
| @@ -416,7 +440,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/console/domainstate.go b/internal/console/domainstate.go| new file mode 100644 |
| index 000000000..94ede8906 |
| --- /dev/null |
| +++ b/internal/console/domainstate.go |
| @@ -0,0 +1,206 @@ |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// One state per custom domain (#299 §2a), DERIVED on read and never stored. |
| +// |
| +// The issue asked the platform to stop making every app rebuild lifecycle |
| +// states out of raw readings. These are those states: one word per domain, |
| +// with a stable machine reason and a human sentence beside it, computed in |
| +// ONE place so `carlos domains list`, the console Settings page and |
| +// `carlos deploy`'s exit can never tell a member three different stories |
| +// about the same hostname. |
| +const ( |
| + // StatePendingDNS — the resolver answered and there is no record yet. |
| + // The customer has something to do; guidance is right. |
| + StatePendingDNS = "pending-dns" |
| + // StateMisdirected — a real answer, pointing somewhere that is not us. |
| + // |
| + // NOT the issue's proposed "verifying": that word named titogo's |
| + // ownership-proof phase, which is out of scope here, so the phase it |
| + // names does not exist in this design. Reusing it for "your record |
| + // resolves elsewhere" would label a customer-side condition as platform |
| + // activity — the opposite of what the state is for. |
| + StateMisdirected = "misdirected" |
| + // StateIssuingCert — points at us, no usable certificate yet. This is |
| + // platform activity, and naming it is only honest because stage 3's warm |
| + // actually performs it. |
| + StateIssuingCert = "issuing-cert" |
| + // StateLive — points at us AND a current certificate covers it. |
| + StateLive = "live" |
| + // StateChecking — attached, and no edge has reported on it yet. A |
| + // transient state that resolves within one sweep (~5 minutes). |
| + // |
| + // Split out from StateUnknown deliberately. Both mean "we have no |
| + // verdict", but they are opposite news: this one is the ordinary first |
| + // few minutes after an attach, and the other is a fault. Collapsing them |
| + // would make every successful attach open with the word "unknown". |
| + StateChecking = "checking" |
| + // StateUnknown — we have no verdict AND that is not routine: our own |
| + // lookup failed, or a box reported something this build cannot read. |
| + // Deliberately distinct from pending-dns: "we could not check" must |
| + // never be shown to a customer as "you have not done your part". |
| + StateUnknown = "unknown" |
| +) |
| + |
| +// domainStates is the vocabulary this build can actually produce, in |
| +// lifecycle order. Published in the capability block so a client can tell |
| +// which states a deployment might return without guessing from a doc. |
| +// |
| +// "failed" is deliberately ABSENT. The design reserves it for "the platform |
| +// tried to obtain a certificate and stopped", which requires the per-host |
| +// warm-outcome records (§3e) that this build does not write — so nothing can |
| +// produce it, and advertising a state no deployment will ever return is the |
| +// same dishonesty that got "verifying" renamed. It is added by the change |
| +// that makes it reachable, not before. |
| +var domainStates = []string{ |
| + StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateLive, StateUnknown, |
| +} |
| + |
| +// domainState derives one hostname's state, a stable machine reason, and a |
| +// human sentence. |
| +// |
| +// seen reports whether any non-stale box reported on this host at all. The |
| +// distinction between "no box mentioned it" and "a box says there is no DNS |
| +// record" is the difference between our problem and the customer's, so it is |
| +// never collapsed. |
| +func domainState(reading status.AliasReading, seen bool, now time.Time) (state, reason, detail string) { |
| + if !seen { |
| + return StateChecking, "no-reading", |
| + "No edge has reported on this hostname yet. Checks run every few minutes, so this should settle shortly after attaching." |
| + } |
| + switch reading.DNSStatus { |
| + case "unresolved": |
| + // OUR lookup failed. Never phrased as a customer action. |
| + return StateUnknown, "lookup-failed", |
| + "We could not look this hostname up just now. This is a problem on our side, not with your DNS — checks retry every few minutes." |
| + case "": |
| + return StatePendingDNS, "no-record", |
| + "No DNS record found for this hostname yet. Create the records below, then checks pick it up within a few minutes." |
| + case "elsewhere": |
| + return StateMisdirected, "points-elsewhere", |
| + "This hostname resolves, but not to this deployment. Update the records below to point it here." |
| + case "pointing": |
| + if reading.CertExpiresAt > now.Unix() { |
| + return StateLive, "serving", |
| + "This hostname points here and is served over HTTPS with a current certificate." |
| + } |
| + return StateIssuingCert, "awaiting-certificate", |
| + "DNS is correct. A certificate is being obtained for this hostname — usually a minute or two, and no action is needed." |
| + } |
| + // A status this build does not know: a newer box stamped it. Report |
| + // unknown rather than guessing at a verdict we cannot interpret. |
| + return StateUnknown, "unrecognised-status", |
| + "This hostname reported a status this console does not recognise. It may be running a newer build." |
| +} |
| + |
| +// dnsRecord is one record a customer must create. Structured so an app does |
| +// not have to parse the English sentence beside it. |
| +type dnsRecord struct { |
| + Type string `json:"type"` |
| + Name string `json:"name"` |
| + Value string `json:"value"` |
| + TTL int `json:"ttl"` |
| + // Set groups records that must be created TOGETHER, and distinguishes |
| + // alternatives from requirements. With steering armed the platform offers |
| + // a CNAME *or* a set of A records; a flat array cannot say "one of these |
| + // two", and a client that created every row would publish a CNAME |
| + // alongside A records for the same name — illegal, and exactly the |
| + // contradiction the English guidance avoids by saying "works too". |
| + // |
| + // Records sharing a Set are one alternative. A client picks ONE set per |
| + // name and creates every record in it. |
| + Set string `json:"set"` |
| + // Required marks a record that must exist whichever alternative is |
| + // chosen (the ACME challenge CNAME for a wildcard-enabled claim). |
| + Required bool `json:"required,omitempty"` |
| +} |
| + |
| +// domainRecordTTL is the TTL the platform asks for, and the reason is not |
| +// cosmetic: steering withdraws a dead edge from DNS in about 90 seconds, and |
| +// a customer A record cached for an hour nullifies that entirely — the |
| +// failover the platform performs would be invisible to their visitors for |
| +// the rest of the TTL. |
| +const domainRecordTTL = 300 |
| + |
| +// domainRecords is the structured form of domainGuidance — the SAME advice, |
| +// as data. The two are built side by side deliberately: if they ever |
| +// disagreed, an app would create records that contradict the sentence shown |
| +// to the person operating it. |
| +// |
| +// The alias/CNAME alternative is emitted ONLY when steering is armed. |
| +// edge.<apps-domain> exists only where the steering converger maintains its |
| +// records; on a non-steering deployment naming it would hand out a hostname |
| +// with no records behind it. |
| +func domainRecords(host string, t DNSTargets, wildcard bool, delegationZone string) []dnsRecord { |
| + var out []dnsRecord |
| + steering := t.SteeringHost != "" && !isApexDomain(host) |
| + if steering { |
| + out = append(out, dnsRecord{ |
| + Type: "CNAME", Name: host, Value: t.SteeringHost, |
| + TTL: domainRecordTTL, Set: "cname", |
| + }) |
| + } |
| + for _, ip := range t.EdgeIPs { |
| + out = append(out, dnsRecord{ |
| + Type: "A", Name: host, Value: ip, |
| + TTL: domainRecordTTL, Set: "a", |
| + }) |
| + } |
| + // A wildcard-enabled claim also needs its delegation CNAME, and it is |
| + // required alongside EITHER alternative above — the platform already |
| + // instructs this at the attach door and stamps delegation_status from it, |
| + // so omitting it here would make "what you must create" incomplete |
| + // against our own surface. |
| + if wildcard && delegationZone != "" { |
| + out = append(out, dnsRecord{ |
| + Type: "CNAME", |
| + Name: "_acme-challenge." + host, |
| + Value: "_acme-challenge." + host + "." + delegationZone, |
| + TTL: domainRecordTTL, Set: "acme", Required: true, |
| + }) |
| + } |
| + return out |
| +} |
| + |
| +// deploymentCapability is the self-describing block folded into the domains |
| +// list answer (#299 §2c). |
| +// |
| +// Folded into an EXISTING answer rather than given its own endpoint. Every |
| +// caller is already app-bound and the list answers fine with zero claims, so |
| +// a separate "capabilities" door would need a new role decision and a new |
| +// rate-limit budget to tell a client something this answer can carry for |
| +// free. Present-or-absent then comes for free too: an older console omits |
| +// the key, and a console with no domains door at all 404s — the signal |
| +// `carlos deploy` already uses. |
| +type deploymentCapability struct { |
| + // ManagedDomains is the flag an app checks before deciding it must |
| + // implement DNS verification itself. This is what retires titogo's |
| + // TITO_CUSTOM_DOMAINS switch. |
| + ManagedDomains bool `json:"managed_domains"` |
| + AppsDomain string `json:"apps_domain,omitempty"` |
| + SteeringHost string `json:"steering_host,omitempty"` |
| + EdgeIPs []string `json:"edge_ips,omitempty"` |
| + DelegationZone string `json:"delegation_zone,omitempty"` |
| + RecordTTL int `json:"record_ttl"` |
| + // States is the vocabulary THIS deployment can return (see domainStates). |
| + States []string `json:"states"` |
| +} |
| + |
| +func (s *Server) domainCapability() deploymentCapability { |
| + t := s.dnsTargets() |
| + return deploymentCapability{ |
| + ManagedDomains: true, |
| + AppsDomain: strings.TrimSpace(s.cfg.AppsDomain), |
| + SteeringHost: t.SteeringHost, |
| + EdgeIPs: t.EdgeIPs, |
| + DelegationZone: s.delegationZone, |
| + RecordTTL: domainRecordTTL, |
| + States: domainStates, |
| + } |
| +} |
diff --git a/internal/console/domainstate_test.go b/internal/console/domainstate_test.go| new file mode 100644 |
| index 000000000..e74e04600 |
| --- /dev/null |
| +++ b/internal/console/domainstate_test.go |
| @@ -0,0 +1,210 @@ |
| +package console |
| + |
| +import ( |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func TestDomainStateVocabulary(t *testing.T) { |
| + now := time.Unix(1_000_000, 0) |
| + future, past := now.Unix()+86400, now.Unix()-86400 |
| + |
| + for name, tc := range map[string]struct { |
| + reading status.AliasReading |
| + seen bool |
| + wantState string |
| + wantReason string |
| + }{ |
| + "no box has reported": {seen: false, wantState: StateChecking, wantReason: "no-reading"}, |
| + "our lookup failed": {reading: status.AliasReading{DNSStatus: "unresolved"}, seen: true, wantState: StateUnknown, wantReason: "lookup-failed"}, |
| + "no record yet": {reading: status.AliasReading{DNSStatus: ""}, seen: true, wantState: StatePendingDNS, wantReason: "no-record"}, |
| + "points elsewhere": {reading: status.AliasReading{DNSStatus: "elsewhere"}, seen: true, wantState: StateMisdirected, wantReason: "points-elsewhere"}, |
| + "pointing, no cert": {reading: status.AliasReading{DNSStatus: "pointing"}, seen: true, wantState: StateIssuingCert, wantReason: "awaiting-certificate"}, |
| + "pointing, dead cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: past}, seen: true, wantState: StateIssuingCert, wantReason: "awaiting-certificate"}, |
| + "pointing, live cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: future}, seen: true, wantState: StateLive, wantReason: "serving"}, |
| + "a status we don't know": {reading: status.AliasReading{DNSStatus: "quantum"}, seen: true, wantState: StateUnknown, wantReason: "unrecognised-status"}, |
| + } { |
| + state, reason, detail := domainState(tc.reading, tc.seen, now) |
| + if state != tc.wantState || reason != tc.wantReason { |
| + t.Errorf("%s: (%q, %q), want (%q, %q)", name, state, reason, tc.wantState, tc.wantReason) |
| + } |
| + if strings.TrimSpace(detail) == "" { |
| + t.Errorf("%s: no human sentence", name) |
| + } |
| + } |
| +} |
| + |
| +// "we could not check" must never be phrased as "you have not done your |
| +// part". A resolver failure on our side and a genuinely missing record are |
| +// the two cases most likely to be collapsed, and collapsing them blames a |
| +// customer for our outage. |
| +func TestDomainStateNeverBlamesTheCustomerForOurOutage(t *testing.T) { |
| + now := time.Unix(1_000_000, 0) |
| + ourFault, _, ourDetail := domainState(status.AliasReading{DNSStatus: "unresolved"}, true, now) |
| + theirs, _, _ := domainState(status.AliasReading{DNSStatus: ""}, true, now) |
| + if ourFault == theirs { |
| + t.Fatalf("a failed lookup and a missing record both read %q", ourFault) |
| + } |
| + low := strings.ToLower(ourDetail) |
| + if !strings.Contains(low, "our side") { |
| + t.Errorf("the lookup-failure sentence does not own the problem: %q", ourDetail) |
| + } |
| + for _, blame := range []string{"create", "you have not", "your record is missing"} { |
| + if strings.Contains(low, blame) { |
| + t.Errorf("the lookup-failure sentence asks the customer to act (%q): %q", blame, ourDetail) |
| + } |
| + } |
| +} |
| + |
| +// Every state the capability block advertises must be reachable, and every |
| +// state the deriver can return must be advertised. A vocabulary that names a |
| +// state nothing produces is the dishonesty that got "verifying" renamed. |
| +func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) { |
| + now := time.Unix(1_000_000, 0) |
| + produced := map[string]bool{} |
| + for _, tc := range []struct { |
| + r status.AliasReading |
| + seen bool |
| + }{ |
| + {seen: false}, |
| + {r: status.AliasReading{DNSStatus: "unresolved"}, seen: true}, |
| + {r: status.AliasReading{DNSStatus: ""}, seen: true}, |
| + {r: status.AliasReading{DNSStatus: "elsewhere"}, seen: true}, |
| + {r: status.AliasReading{DNSStatus: "pointing"}, seen: true}, |
| + {r: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: now.Unix() + 86400}, seen: true}, |
| + } { |
| + s, _, _ := domainState(tc.r, tc.seen, now) |
| + produced[s] = true |
| + } |
| + for _, s := range domainStates { |
| + if !produced[s] { |
| + t.Errorf("state %q is advertised but nothing produces it", s) |
| + } |
| + } |
| + for s := range produced { |
| + if !slices.Contains(domainStates, s) { |
| + t.Errorf("state %q is produced but not advertised", s) |
| + } |
| + } |
| + // The specific one this build must NOT claim. |
| + if slices.Contains(domainStates, "failed") { |
| + t.Error(`"failed" is advertised, but nothing writes the warm-outcome records that would produce it`) |
| + } |
| +} |
| + |
| +// The structured records and the English sentence are the same advice. If |
| +// they disagree, an app creates records that contradict what the person |
| +// operating it was told. |
| +func TestDomainRecordsMatchTheGuidanceSentence(t *testing.T) { |
| + steering := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}, SteeringHost: "edge.oncarlos.test"} |
| + |
| + recs := domainRecords("www.customer.test", steering, false, "") |
| + sentence := domainGuidance("www.customer.test", steering) |
| + if !strings.Contains(sentence, "CNAME") { |
| + t.Fatal("precondition: the steering sentence should offer a CNAME") |
| + } |
| + var cname, a []dnsRecord |
| + for _, r := range recs { |
| + switch r.Set { |
| + case "cname": |
| + cname = append(cname, r) |
| + case "a": |
| + a = append(a, r) |
| + } |
| + } |
| + if len(cname) != 1 || cname[0].Value != "edge.oncarlos.test" { |
| + t.Errorf("CNAME alternative = %+v", cname) |
| + } |
| + if len(a) != 2 { |
| + t.Errorf("A alternative = %+v, want one per edge IP", a) |
| + } |
| + // The two must be distinguishable AS alternatives: a client creating |
| + // every row would publish a CNAME beside A records for one name, which |
| + // is illegal and is exactly what the sentence's "works too" avoids. |
| + if cname[0].Set == a[0].Set { |
| + t.Error("the CNAME and A records share a set — a client cannot tell they are alternatives") |
| + } |
| + for _, r := range recs { |
| + if r.TTL != domainRecordTTL { |
| + t.Errorf("record %+v: TTL %d, want %d — steering withdraws a dead edge in ~90s and a long TTL nullifies that", r, r.TTL, domainRecordTTL) |
| + } |
| + } |
| +} |
| + |
| +// An apex cannot carry a CNAME (RFC 1034), and the sentence already says so. |
| +// The records must agree. |
| +func TestDomainRecordsOfferNoCNAMEForAnApex(t *testing.T) { |
| + steering := DNSTargets{EdgeIPs: []string{"203.0.113.7"}, SteeringHost: "edge.oncarlos.test"} |
| + for _, r := range domainRecords("customer.test", steering, false, "") { |
| + if r.Type == "CNAME" { |
| + t.Fatalf("offered an apex a CNAME: %+v", r) |
| + } |
| + } |
| +} |
| + |
| +// Without steering, edge.<apps-domain> has no records behind it, so naming |
| +// it would send a customer at a hostname that does not resolve. |
| +func TestDomainRecordsNameNoAliasTargetWithoutSteering(t *testing.T) { |
| + plain := DNSTargets{EdgeIPs: []string{"203.0.113.7"}} |
| + recs := domainRecords("www.customer.test", plain, false, "") |
| + if len(recs) != 1 || recs[0].Type != "A" { |
| + t.Fatalf("records = %+v, want A records only", recs) |
| + } |
| +} |
| + |
| +// A wildcard-enabled claim needs its delegation CNAME, and it is required |
| +// whichever alternative the customer picks — the platform already instructs |
| +// it at the attach door and stamps delegation_status from it. |
| +func TestDomainRecordsIncludeTheACMEChallengeForWildcardClaims(t *testing.T) { |
| + targets := DNSTargets{EdgeIPs: []string{"203.0.113.7"}, SteeringHost: "edge.oncarlos.test"} |
| + recs := domainRecords("www.customer.test", targets, true, "acme.oncarlos.test") |
| + var found *dnsRecord |
| + for i, r := range recs { |
| + if strings.HasPrefix(r.Name, "_acme-challenge.") { |
| + found = &recs[i] |
| + } |
| + } |
| + if found == nil { |
| + t.Fatal("a wildcard claim was given no _acme-challenge record") |
| + } |
| + if !found.Required { |
| + t.Error("the challenge record is not marked required — it is needed whichever alternative is chosen") |
| + } |
| + if found.Value != "_acme-challenge.www.customer.test.acme.oncarlos.test" { |
| + t.Errorf("challenge target = %q", found.Value) |
| + } |
| + // With no delegation zone configured there is nothing to delegate to. |
| + for _, r := range domainRecords("www.customer.test", targets, true, "") { |
| + if strings.HasPrefix(r.Name, "_acme-challenge.") { |
| + t.Error("emitted a challenge record with no delegation zone configured") |
| + } |
| + } |
| +} |
| + |
| +// `status` is LEGACY and its semantics are frozen: it means "some non-stale |
| +// box reported ANY reading", nothing more. That is why a row can carry |
| +// status "live" while its state is "misdirected" — the hostname resolves |
| +// somewhere else entirely, and only one of those two words says so. |
| +// |
| +// This test exists to stop a well-meaning change from "fixing" status toward |
| +// state. Anything that reads status as a health verdict is reading it wrong; |
| +// the field is kept only so clients that predate state keep working. |
| +func TestLegacyStatusStaysDecoupledFromState(t *testing.T) { |
| + now := time.Unix(1_000_000, 0) |
| + // The exact divergence: a box HAS reported (so status is "live"), and |
| + // what it reported is that the hostname points somewhere else. |
| + state, _, _ := domainState(status.AliasReading{DNSStatus: "elsewhere"}, true, now) |
| + if state != StateMisdirected { |
| + t.Fatalf("state = %q, want %q", state, StateMisdirected) |
| + } |
| + // If this ever fails, someone has redefined status. The list handler |
| + // sets it to "live" for any seen reading; that is the frozen meaning. |
| + const legacyStatusForAnySeenReading = "live" |
| + if legacyStatusForAnySeenReading == state { |
| + t.Fatal("status and state have converged — the legacy field's frozen meaning has been changed") |
| + } |
| +} |
diff --git a/internal/console/edgefold_test.go b/internal/console/edgefold_test.go| new file mode 100644 |
| index 000000000..04c153bcb |
| --- /dev/null |
| +++ b/internal/console/edgefold_test.go |
| @@ -0,0 +1,119 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// The guidance a member is shown must name EVERY armed edge, not just the |
| +// ones this console's own PLATFORM_EDGE_IPS happens to list — otherwise a |
| +// multi-region deployment tells people to point at one region and then grades |
| +// them against all of them. Union, configured addresses first, no duplicates. |
| +func TestDNSTargetsUnionsTheConfiguredListWithTheFold(t *testing.T) { |
| + s := &Server{ |
| + cfg: Config{EdgeIPs: []string{"203.0.113.7"}}, |
| + foldEdgeIPs: func(context.Context) []string { return []string{"198.51.100.9", "203.0.113.7"} }, |
| + } |
| + got := s.dnsTargets() |
| + if !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the configured list first, then the fold's extras, deduped", got.EdgeIPs) |
| + } |
| +} |
| + |
| +// dnsTargets is called once PER CLAIM inside the domains-list loop. An |
| +// uncached fold would put a box-snapshot sweep plus one Get per edges file |
| +// behind every row of every listing — the cost discipline |
| +// domainListCNAMELookupCap already established for this door. |
| +func TestEdgeFoldIsCachedForTheTTL(t *testing.T) { |
| + folds := 0 |
| + s := &Server{foldEdgeIPs: func(context.Context) []string { |
| + folds++ |
| + return []string{"198.51.100.9"} |
| + }} |
| + for i := 0; i < 5; i++ { |
| + s.dnsTargets() |
| + } |
| + if folds != 1 { |
| + t.Fatalf("folded %d times inside the TTL, want 1", folds) |
| + } |
| + |
| + // Past the TTL the next reader refolds — an edge that armed a minute ago |
| + // has to become visible without a console restart. |
| + s.edgeFold.mu.Lock() |
| + s.edgeFold.at = time.Now().Add(-edgeFoldTTL - time.Second) |
| + s.edgeFold.mu.Unlock() |
| + if got := s.dnsTargets(); folds != 2 || len(got.EdgeIPs) != 1 { |
| + t.Fatalf("after the TTL: folds = %d, EdgeIPs = %v, want a refold", folds, got.EdgeIPs) |
| + } |
| +} |
| + |
| +// A fold that comes back empty is a bucket that could not be read this |
| +// minute far more often than it is a deployment that lost every edge, so the |
| +// previous answer stands rather than being emptied — narrowing the guidance |
| +// every member is shown on the strength of one failed read is the worse |
| +// error. The timestamp still moves, so a persistent outage costs one bounded |
| +// attempt per TTL, not one per request. |
| +func TestEdgeFoldKeepsTheLastAnswerWhenAFoldFails(t *testing.T) { |
| + folds := 0 |
| + s := &Server{foldEdgeIPs: func(context.Context) []string { |
| + folds++ |
| + if folds == 1 { |
| + return []string{"198.51.100.9"} |
| + } |
| + return nil // the store is down |
| + }} |
| + if got := s.dnsTargets(); len(got.EdgeIPs) != 1 { |
| + t.Fatalf("first fold: EdgeIPs = %v", got.EdgeIPs) |
| + } |
| + s.edgeFold.mu.Lock() |
| + s.edgeFold.at = time.Now().Add(-edgeFoldTTL - time.Second) |
| + s.edgeFold.mu.Unlock() |
| + got := s.dnsTargets() |
| + if folds != 2 { |
| + t.Fatalf("folds = %d, want the stale entry to have been retried", folds) |
| + } |
| + if !reflect.DeepEqual(got.EdgeIPs, []string{"198.51.100.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the previous answer left standing", got.EdgeIPs) |
| + } |
| + // And the failed attempt still moved the clock: a console whose bucket is |
| + // down must not refold on every request of every listing. |
| + before := folds |
| + s.dnsTargets() |
| + if folds != before { |
| + t.Fatalf("folds = %d, want the failed attempt to still hold off for the TTL", folds) |
| + } |
| +} |
| + |
| +// The real fold, end to end over a bucket: a live box's published EIP reaches |
| +// the guidance even though nothing ever set it in this console's env. |
| +func TestRealEdgeFoldReadsTheDeploymentsEdgesFiles(t *testing.T) { |
| + ctx := context.Background() |
| + store := release.NewDirStore(t.TempDir()) |
| + if _, err := store.Put(ctx, edgesfile.Key("annie"), |
| + []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, store, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := &Server{cfg: Config{EdgeIPs: []string{"203.0.113.7"}, Releases: store}} |
| + if got := s.dnsTargets(); !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the configured address plus the folded one", got.EdgeIPs) |
| + } |
| +} |
| + |
| +// A console with no deployment bucket at all (Releases nil) is a legal |
| +// deployment: it degrades to the configured list rather than panicking or |
| +// emptying the guidance. |
| +func TestRealEdgeFoldWithNoStoreDegradesToTheConfiguredList(t *testing.T) { |
| + s := &Server{cfg: Config{EdgeIPs: []string{"203.0.113.7"}}} |
| + if got := s.dnsTargets(); !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7"}) { |
| + t.Fatalf("EdgeIPs = %v, want the configured list", got.EdgeIPs) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 780243d1e..90ea0f831 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -21,6 +21,7 @@ import ( |
| "net/url" |
| "os" |
| "regexp" |
| + "slices" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -32,6 +33,7 @@ import ( |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/economics" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/infraview" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -697,6 +699,45 @@ type Server struct { |
| // value is ready to use. |
| refusedTopicsMu sync.Mutex |
| refusedTopics map[string]time.Time |
| + // edgeFold caches the deployment's folded live edge addresses — every |
| + // box's control/edges/<box>.json joined against its status snapshot — |
| + // so the guidance strings can name every armed edge rather than only |
| + // the ones this console's own PLATFORM_EDGE_IPS lists. Cached because |
| + // dnsTargets is on the request path and is called once PER CLAIM inside |
| + // the domains-list loop: an uncached fold would put a LoadBoxes sweep |
| + // plus one Get per edges file behind every row of every listing. Zero |
| + // value is ready to use. |
| + edgeFold edgeFoldCache |
| + // foldEdgeIPs is edgeFoldCache's test seam: nil means the real fold |
| + // over cfg.Releases (s.realFoldEdgeIPs). Tests inject a counter so the |
| + // TTL can be proven without a bucket, the same way storeSizer and |
| + // ceCosts stand in for AWS above. |
| + foldEdgeIPs func(ctx context.Context) []string |
| +} |
| + |
| +// edgeFoldTTL is how long one fold of the deployment's edges files stands |
| +// for. Edges arm and retire on the scale of a deployment, not a request, and |
| +// every box republishes its own file on a 5min+jitter loop — so a minute is |
| +// far below the rate at which the answer can actually change while still |
| +// keeping the domains-list door off the bucket for all but one request a |
| +// minute. |
| +const edgeFoldTTL = time.Minute |
| + |
| +// edgeFoldTimeout bounds one fold, mirroring the agent's own closure. |
| +// dnsTargets takes no context of its own (it is called from deep inside view |
| +// assembly), so without this a hung store would stall the domains-list door |
| +// indefinitely rather than degrading to the configured list. |
| +const edgeFoldTimeout = 30 * time.Second |
| + |
| +// edgeFoldCache is the folded set plus the instant it was folded. The mutex |
| +// is held ACROSS the refresh deliberately: it is what makes concurrent |
| +// callers share one fold instead of stampeding the bucket, and the fold is |
| +// bounded by edgeFoldTimeout so the worst case is a bounded wait, not a |
| +// wedge. |
| +type edgeFoldCache struct { |
| + mu sync.Mutex |
| + at time.Time |
| + ips []string |
| } |
| // dnsTargets is what every custom-domain guidance string in this console is |
| @@ -705,7 +746,72 @@ type Server struct { |
| // page and the /domains API can never disagree about where a member should |
| // point DNS. |
| func (s *Server) dnsTargets() DNSTargets { |
| - return DNSTargets{EdgeIPs: s.cfg.EdgeIPs, SteeringHost: s.steeringHost} |
| + return DNSTargets{EdgeIPs: s.edgeIPs(), SteeringHost: s.steeringHost} |
| +} |
| + |
| +// edgeIPs is the deployment's edge addresses as this console will tell a |
| +// member to point at: the operator's configured list UNIONED with the live |
| +// fold, configured addresses first. |
| +// |
| +// Union, not replacement, in both directions. A console whose bucket read |
| +// fails still offers PLATFORM_EDGE_IPS rather than none — an empty guidance |
| +// string is worse than a partial one. And a multi-region deployment's other |
| +// edges appear even though this console's env var predates them, which is |
| +// the whole point: the same set the domains reconcile pass now judges |
| +// "pointing" against, so guidance and verdict cannot disagree. |
| +func (s *Server) edgeIPs() []string { |
| + out := append([]string(nil), s.cfg.EdgeIPs...) |
| + for _, ip := range s.foldedEdgeIPs() { |
| + if ip != "" && !slices.Contains(out, ip) { |
| + out = append(out, ip) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// foldedEdgeIPs answers the cached fold, refreshing it when it has aged past |
| +// edgeFoldTTL. |
| +// |
| +// A refresh that comes back with NOTHING leaves the previous answer standing: |
| +// an empty fold is a bucket that could not be read this minute far more often |
| +// than it is a deployment that lost every edge, and emptying the set would |
| +// silently narrow the guidance every member is shown. The timestamp still |
| +// moves, so a persistent outage costs one bounded attempt per TTL rather than |
| +// one per request. |
| +func (s *Server) foldedEdgeIPs() []string { |
| + c := &s.edgeFold |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if !c.at.IsZero() && time.Since(c.at) < edgeFoldTTL { |
| + return c.ips |
| + } |
| + fold := s.foldEdgeIPs |
| + if fold == nil { |
| + fold = s.realFoldEdgeIPs |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), edgeFoldTimeout) |
| + defer cancel() |
| + ips := fold(ctx) |
| + c.at = time.Now() |
| + if len(ips) > 0 { |
| + c.ips = ips |
| + } |
| + return c.ips |
| +} |
| + |
| +// realFoldEdgeIPs folds the deployment bucket's edges files. env is nil here, |
| +// not cfg.EdgeIPs: edgeIPs above does the union, so the configured list is |
| +// added exactly once and the cache holds only what was actually read from the |
| +// bucket — which is what lets an empty fold be recognised as a failed read. |
| +// Sorted so a listing's guidance sentence does not reshuffle between calls on |
| +// the store's arbitrary iteration order. |
| +func (s *Server) realFoldEdgeIPs(ctx context.Context) []string { |
| + if s.cfg.Releases == nil { |
| + return nil |
| + } |
| + ips := edgesfile.EdgeIPs(ctx, s.cfg.Releases, nil, time.Now()) |
| + slices.Sort(ips) |
| + return ips |
| } |
| // normalizeSteeringHost lowercases, trims and shape-validates the steering |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 746eac862..49a968992 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1509,7 +1509,9 @@ func TestAppPageBackfillsAccountManifest(t *testing.T) { |
| // TestDomainViewStates is the pure Domains-row builder over route fixtures: |
| // type from the platform flag, unknown DNS as "" (rendered em-dash), |
| -// guidance ONLY for non-pointing custom domains, canary labeling preserved. |
| +// guidance ONLY for non-pointing custom domains — except "unresolved" (our |
| +// lookup failed; guidance would blame the member for our outage) — canary |
| +// labeling preserved. |
| func TestDomainViewStates(t *testing.T) { |
| edge := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}} |
| renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix() |
| @@ -1539,6 +1541,10 @@ func TestDomainViewStates(t *testing.T) { |
| registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "pointing"}, |
| edge, |
| DomainView{Domain: "kass.training", Type: "custom", DNS: "pointing", TLS: "ready"}}, |
| + {"custom unresolved: our lookup failed, no guidance — not the member's problem", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "unresolved"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "unresolved", TLS: "ready"}}, |
| {"platform elsewhere: never guided (we made this domain; guidance is for THEIR dns)", |
| registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "elsewhere"}, |
| edge, |
diff --git a/internal/console/view.go b/internal/console/view.go| index 214a4fa5a..c0c2a9926 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -142,14 +142,19 @@ type DomainView struct { |
| // only on a deployment that STEERS (multi-region ingress spec, Part 4) — |
| // the steering hostname a non-apex host may CNAME to instead. |
| type DNSTargets struct { |
| - // EdgeIPs is PLATFORM_EDGE_IPS, read at console start. nil is legal: |
| - // guidance then says "this deployment's edge IP" rather than inventing |
| - // an address. |
| + // EdgeIPs is PLATFORM_EDGE_IPS unioned with the deployment's live fold |
| + // of every box's edges file (Server.edgeIPs). nil is legal: guidance |
| + // then says "this deployment's edge IP" rather than inventing an |
| + // address. |
| // |
| // Once a deployment steers, this list must name EVERY armed edge's EIP, |
| // not just the local box's: the DNS check that stamps "pointing" |
| // compares a resolved address against it, and a steered name resolves |
| - // to whichever edge the resolver is nearest. |
| + // to whichever edge the resolver is nearest. The fold is what makes |
| + // that true without the operator re-setting an env var on every box |
| + // each time a region arms — and it is the SAME fold the domains |
| + // reconcile pass judges "pointing" with, so what a member is told to do |
| + // and what the platform then grades them on cannot drift apart. |
| EdgeIPs []string |
| // SteeringHost is the deployment's steering hostname (edge.<apps-domain>, |
| // itself latency-routed with one record per armed edge). "" — every |
| @@ -249,7 +254,10 @@ func domainView(rt registry.Route, dns DNSTargets) DomainView { |
| dv := DomainView{Domain: rt.Host, Type: typ, DNS: rt.DNSStatus, TLS: tls} |
| // Guidance only for custom + non-pointing: platform domains are ours to |
| // fix, and a pointing custom domain needs no pointing instructions. |
| - if !rt.Platform && rt.DNSStatus != "pointing" { |
| + // "unresolved" is excluded too — that means OUR lookup failed, not that |
| + // the member's record is missing or wrong, so telling them to (re)point |
| + // it would blame them for our outage. |
| + if !rt.Platform && rt.DNSStatus != "pointing" && rt.DNSStatus != "unresolved" { |
| dv.Guidance = domainGuidance(rt.Host, dns) |
| } |
| return dv |
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/domains/reconcile.go b/internal/domains/reconcile.go| index dae29e6bc..418ecb905 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -6,8 +6,10 @@ import ( |
| "crypto/x509" |
| "encoding/json" |
| "encoding/pem" |
| + "errors" |
| "fmt" |
| "log" |
| + "net" |
| "os" |
| "path/filepath" |
| "slices" |
| @@ -27,9 +29,14 @@ type Config struct { |
| // manifests read, zero registry writes — the litestream convention: |
| // unconfigured means the feature does not exist. |
| AppsDomain string |
| - // EdgeIPs are this deployment's edge addresses; a host resolving to any |
| - // of them is "pointing". |
| - EdgeIPs []string |
| + // EdgeIPs answers this deployment's current edge addresses; a host |
| + // resolving to any of them is "pointing". A FUNC, not a slice: the set |
| + // moves at runtime as edges arm and retire, while the agent's config is |
| + // built once at loop arm — a boot-frozen list is exactly the bug this |
| + // replaced (a correctly-pointed domain steered to another region's edge |
| + // read as pointing elsewhere). nil, or a func returning nothing, leaves |
| + // DNS stamping standing down for that pass — see section 4. |
| + EdgeIPs func() []string |
| // Resolve is the DNS seam (net resolver in production, a fake in |
| // tests); nil skips DNS stamping entirely. |
| Resolve func(host string) ([]string, error) |
| @@ -87,55 +94,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| // --- 1. account materialization ------------------------------------------ |
| - // The console originates account truth as accounts/<sqid>/account.json in |
| - // the bucket; this is the only place it becomes registry state, and the id |
| - // in the manifest is TAKEN AS GIVEN — never assigned here. That is the |
| - // whole durability property: a rebuilt registry, or a second box, ends up |
| - // with byte-identical account identity, so platform domains never re-home. |
| - // A deployment with no console has no manifests: everything stays under |
| - // the built-in ops account. |
| - known := map[string]bool{OpsSqid: true} // sqid -> materialized |
| - for _, key := range acctKeys { |
| - sqid, ok := AccountManifestSqid(key) |
| - if !ok { |
| - continue |
| - } |
| - body, _, err := s.Get(ctx, key) |
| - if err != nil { |
| - log.Printf("domains: skipping %s: read failed: %v", key, err) |
| - continue |
| - } |
| - var m AccountManifest |
| - if err := json.Unmarshal(body, &m); err != nil || m.ID <= 0 || m.Sqid == "" { |
| - log.Printf("domains: skipping %s: malformed account manifest: %v", key, err) |
| - continue |
| - } |
| - // Three things must agree: the key path, the body's sqid, and the |
| - // frozen encoding of the body's id. A manifest that disagrees with |
| - // itself is a lie about identity — materializing it would put an |
| - // account at a domain that stops resolving the moment anything |
| - // recomputes the sqid — so it is skipped, loudly, never guessed at. |
| - if m.Sqid != sqid || Sqid(m.ID) != sqid { |
| - log.Printf("domains: skipping %s: sqid %q/%q is not Sqid(%d)=%q", key, sqid, m.Sqid, m.ID, Sqid(m.ID)) |
| - continue |
| - } |
| - // A tombstoned manifest is materialized nowhere and, if this registry |
| - // still carries the account from before it was retired, un-materialized |
| - // here. Leaving the row would keep the account listed by `carlos |
| - // accounts list` on every box forever — a deletion that only ever took |
| - // effect in the console that performed it. |
| - if m.Deleted() { |
| - if err := reg.DeleteAccount(m.Sqid); err != nil { |
| - log.Printf("domains: retire account %s: %v", sqid, err) |
| - } |
| - continue |
| - } |
| - if err := reg.PutAccount(m.ID, m.Sqid, m.Name); err != nil { |
| - log.Printf("domains: materialize account %s: %v", sqid, err) |
| - continue |
| - } |
| - known[sqid] = true |
| - } |
| + known := materializeAccounts(ctx, s, acctKeys, reg) |
| // --- 1c. trashed apps ---------------------------------------------------- |
| // A valid trash marker (apps/<sqid>/<app>/deleted.json) is the owner's |
| @@ -458,35 +417,58 @@ func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes |
| } |
| // --- 4. DNS health -------------------------------------------------------- |
| - if cfg.Resolve != nil { |
| + // The edge set is asked for ONCE PER PASS, not once per box start: under |
| + // latency steering a customer's correctly-pointed domain resolves to |
| + // whichever region's edge the resolver is nearest, so the comparison set |
| + // has to be the deployment's live one. |
| + if cfg.Resolve != nil && cfg.EdgeIPs != nil { |
| edge := map[string]bool{} |
| - for _, ip := range cfg.EdgeIPs { |
| + for _, ip := range cfg.EdgeIPs() { |
| edge[ip] = true |
| } |
| - now := cfg.Now().Unix() |
| - // A resolver failure is UNKNOWN (""), and unknown never overwrites a |
| - // verdict: one bad DNS tick must not flip a working domain to |
| - // "never checked" — the edges-file publisher gates on "pointing" |
| - // (#287), so that flip would unpublish every alias fleet-wide for a |
| - // cadence. The stamp time is left alone too, so staleness stays |
| - // visible (dns_checked_at stops advancing) rather than being |
| - // laundered into a fresh unknown. |
| - for _, rt := range routes { |
| - status := dnsStatus(cfg.Resolve, rt.Host, edge) |
| - if status == "" && rt.DNSStatus != "" { |
| - continue |
| - } |
| - if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| - log.Printf("domains: stamp dns on %s: %v", rt.Host, err) |
| - } |
| - } |
| - for _, a := range aliases { |
| - status := dnsStatus(cfg.Resolve, a.Alias, edge) |
| - if status == "" && a.DNSStatus != "" { |
| - continue |
| + // Load-bearing: stamping against an empty set would mark every host |
| + // in the deployment `elsewhere` at once — a fleet-wide lying red |
| + // raised by a bucket read that failed, not by anyone's DNS. Unknown |
| + // is not a verdict this pass is allowed to guess at, so it stands |
| + // down and leaves the previous stamps alone until it knows again. |
| + // |
| + // This is the SET-level case of the same rule the per-host loop |
| + // below applies: what we do not know, we do not stamp. |
| + if len(edge) == 0 { |
| + log.Printf("domains: no edge addresses known this pass — DNS stamping stands down rather than stamping every host elsewhere") |
| + } else { |
| + now := cfg.Now().Unix() |
| + // OUR failure never overwrites a verdict. "unresolved" means the |
| + // lookup itself failed (SERVFAIL, timeout, no resolver) — one bad |
| + // DNS tick must not flip a working domain, because the edges-file |
| + // publisher gates on "pointing" (#287), so that flip would |
| + // unpublish every alias fleet-wide for a cadence. The stamp time |
| + // is left alone too, so staleness stays visible (dns_checked_at |
| + // stops advancing) rather than being laundered into a fresh |
| + // unknown. |
| + // |
| + // An authoritative NXDOMAIN ("") is NOT that case and DOES stamp: |
| + // the resolver answered, and the answer is that the record is |
| + // gone. A customer who deletes their record must stop reading |
| + // "pointing" — refusing that news would be the same lie in the |
| + // opposite direction. |
| + for _, rt := range routes { |
| + status := dnsStatus(cfg.Resolve, rt.Host, edge) |
| + if status == unresolvedDNS && rt.DNSStatus != "" { |
| + continue |
| + } |
| + if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| + log.Printf("domains: stamp dns on %s: %v", rt.Host, err) |
| + } |
| } |
| - if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| - log.Printf("domains: stamp dns on alias %s: %v", a.Alias, err) |
| + for _, a := range aliases { |
| + status := dnsStatus(cfg.Resolve, a.Alias, edge) |
| + if status == unresolvedDNS && a.DNSStatus != "" { |
| + continue |
| + } |
| + if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| + log.Printf("domains: stamp dns on alias %s: %v", a.Alias, err) |
| + } |
| } |
| } |
| } |
| @@ -509,14 +491,32 @@ func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes |
| return nil |
| } |
| -// dnsStatus resolves host through resolve and classifies the answer against |
| -// edge: a resolver failure is UNKNOWN ("") — never a guess — an edge-IP match |
| -// is "pointing", anything else is "elsewhere". Shared by the route and alias |
| -// loops in section 4 so the two verdicts can never drift apart. |
| +// unresolvedDNS is the stamp for "we could not look this up" as distinct |
| +// from "the resolver says there is no such record". The distinction is the |
| +// difference between blaming the customer and owning the outage. |
| +const unresolvedDNS = "unresolved" |
| + |
| +// dnsStatus resolves host and classifies the answer against edge. Three |
| +// distinct unknowns, deliberately: |
| +// |
| +// - "" — the resolver answered authoritatively that the name does not |
| +// exist. The customer has not created the record yet; guidance is right. |
| +// - unresolvedDNS — the lookup FAILED (SERVFAIL, timeout, no resolver). |
| +// That is OUR problem, and telling the customer their record is missing |
| +// would blame them for our outage. Section 4 never lets it overwrite an |
| +// existing verdict. |
| +// - "elsewhere"/"pointing" — a real answer, judged against the set. |
| +// |
| +// Shared by the route and alias loops in section 4 so the two verdicts can |
| +// never drift apart. |
| func dnsStatus(resolve func(host string) ([]string, error), host string, edge map[string]bool) string { |
| ips, err := resolve(host) |
| if err != nil { |
| - return "" |
| + var dnsErr *net.DNSError |
| + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { |
| + return "" |
| + } |
| + return unresolvedDNS |
| } |
| for _, ip := range ips { |
| if edge[ip] { |
| @@ -551,6 +551,82 @@ func AccountManifestSqid(key string) (string, bool) { |
| return sqid, true |
| } |
| +// materializeAccounts turns the deployment's account manifests into this |
| +// box's registry account rows, and returns the set of sqids that exist. |
| +// |
| +// The return value is the point: known is what makes an apps/<sqid>/<app>/… |
| +// key path unambiguous (appKeyOwner), so it drives the trash-marker scan, |
| +// the app->account linkage and the canary-pointer scan — every part of the |
| +// pass that reads a bucket key. It is a function rather than an inline loop |
| +// so that invariant is reachable by a test; there is one vantage, and reg is |
| +// never nil. |
| +func materializeAccounts(ctx context.Context, s release.Store, acctKeys []string, reg *registry.Registry) map[string]bool { |
| + // The console originates account truth as accounts/<sqid>/account.json in |
| + // the bucket; this is the only place it becomes registry state, and the id |
| + // in the manifest is TAKEN AS GIVEN — never assigned here. That is the |
| + // whole durability property: a rebuilt registry, or a second box, ends up |
| + // with byte-identical account identity, so platform domains never re-home. |
| + // A deployment with no console has no manifests: everything stays under |
| + // the built-in ops account. |
| + known := map[string]bool{OpsSqid: true} // sqid -> materialized |
| + for _, key := range acctKeys { |
| + sqid, ok := AccountManifestSqid(key) |
| + if !ok { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("domains: skipping %s: read failed: %v", key, err) |
| + continue |
| + } |
| + var m AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil || m.ID <= 0 || m.Sqid == "" { |
| + log.Printf("domains: skipping %s: malformed account manifest: %v", key, err) |
| + continue |
| + } |
| + // Three things must agree: the key path, the body's sqid, and the |
| + // frozen encoding of the body's id. A manifest that disagrees with |
| + // itself is a lie about identity — materializing it would put an |
| + // account at a domain that stops resolving the moment anything |
| + // recomputes the sqid — so it is skipped, loudly, never guessed at. |
| + if m.Sqid != sqid || Sqid(m.ID) != sqid { |
| + log.Printf("domains: skipping %s: sqid %q/%q is not Sqid(%d)=%q", key, sqid, m.Sqid, m.ID, Sqid(m.ID)) |
| + continue |
| + } |
| + // A tombstoned manifest is materialized nowhere and, if this registry |
| + // still carries the account from before it was retired, un-materialized |
| + // here. Leaving the row would keep the account listed by `carlos |
| + // accounts list` on every box forever — a deletion that only ever took |
| + // effect in the console that performed it. |
| + if m.Deleted() { |
| + if err := reg.DeleteAccount(m.Sqid); err != nil { |
| + log.Printf("domains: retire account %s: %v", sqid, err) |
| + } |
| + continue |
| + } |
| + // KNOWN FIRST, then the write. The manifest is the authority on |
| + // which accounts EXIST; the registry row is only this box's local |
| + // copy of that fact, and the two answer different questions. known |
| + // answers the first — it is what tells a qualified bucket key |
| + // (apps/<sqid>/<app>/…) from a legacy one, and nothing else. |
| + // |
| + // So a failed row-write must not un-know the account. If it did, |
| + // every one of that account's keys would read as legacy for the rest |
| + // of the pass (appKeyOwner returns !ok), and this box would silently |
| + // skip the account's trash markers, route linkage and canary |
| + // pointers — a transient SQLite error turning into "this tenant's |
| + // apps stop reconciling" until some later pass happens to write the |
| + // row. Staying known costs only a stale `carlos accounts list` row on |
| + // this one box, which the next pass fixes. |
| + known[sqid] = true |
| + if err := reg.PutAccount(m.ID, m.Sqid, m.Name); err != nil { |
| + log.Printf("domains: materialize account %s: %v", sqid, err) |
| + continue |
| + } |
| + } |
| + return known |
| +} |
| + |
| // appKeyOwner reads the app→account linkage straight out of a bucket key: |
| // apps/<sqid>/<app>/… belongs to that account. known is the set of |
| // materialized account sqids, which is what makes a key path unambiguous — |
| @@ -794,6 +870,10 @@ func mintedRoute(host, app, account string, src registry.Route) registry.Route { |
| // covering wildcard file, so the apex never matches its own wildcard. |
| func hostCertExpiry(acmeDir, host, appsDomain string) int64 { |
| perHost := certExpiry(filepath.Join(acmeDir, host)) |
| + rsa := certExpiry(filepath.Join(acmeDir, host+"+rsa")) |
| + if rsa > perHost { |
| + perHost = rsa |
| + } |
| wildcard := int64(0) |
| if i := strings.IndexByte(host, '.'); i > 0 { |
| parent := strings.ToLower(host[i+1:]) |
diff --git a/internal/domains/reconcile_internal_test.go b/internal/domains/reconcile_internal_test.go| index 4075fd2a9..a95f3d968 100644 |
| --- a/internal/domains/reconcile_internal_test.go |
| +++ b/internal/domains/reconcile_internal_test.go |
| @@ -1,25 +1,32 @@ |
| package domains |
| -// Internal-package tests for hostCertExpiry: reconcile_test.go |
| -// is package domains_test and cannot reach these unexported helpers, so this |
| -// file follows slugname_test.go's existing convention of a same-package test |
| -// file living alongside it. |
| +// Internal-package tests for reconcile.go's unexported helpers |
| +// (hostCertExpiry, mintedRoute, materializeAccounts): reconcile_test.go |
| +// is package domains_test and cannot reach these, so this file follows |
| +// slugname_test.go's existing convention of a same-package test file living |
| +// alongside it. |
| import ( |
| "bytes" |
| + "context" |
| "crypto/ecdsa" |
| "crypto/elliptic" |
| "crypto/rand" |
| "crypto/x509" |
| "crypto/x509/pkix" |
| "encoding/pem" |
| + "errors" |
| + "io" |
| + "log" |
| "math/big" |
| + "net" |
| "os" |
| "path/filepath" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // writeWildcardPEM writes a self-signed cert for name valid until notAfter, |
| @@ -110,6 +117,53 @@ func TestCertExpiryWildcardFallback(t *testing.T) { |
| } |
| } |
| +// A host whose only cert is the RSA variant (autocert writes <host>+rsa |
| +// alongside <host> when a client can't do ECDSA) has a cert. Reading it as |
| +// certless would hold it at "issuing cert" forever. |
| +func TestHostCertExpiryFindsTheRSAVariant(t *testing.T) { |
| + dir := t.TempDir() |
| + notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second) |
| + writeWildcardPEM(t, filepath.Join(dir, "shop.customer.com+rsa"), "shop.customer.com", notAfter) |
| + if got := hostCertExpiry(dir, "shop.customer.com", "oncarlos.com"); got != notAfter.Unix() { |
| + t.Fatalf("hostCertExpiry = %d, want %d (rsa variant)", got, notAfter.Unix()) |
| + } |
| + |
| + // The ECDSA per-host file, when it also exists and is LATER, still wins. |
| + later := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second) |
| + writeWildcardPEM(t, filepath.Join(dir, "shop.customer.com"), "shop.customer.com", later) |
| + if got := hostCertExpiry(dir, "shop.customer.com", "oncarlos.com"); got != later.Unix() { |
| + t.Fatalf("hostCertExpiry with later ecdsa file = %d, want %d", got, later.Unix()) |
| + } |
| +} |
| + |
| +// An authoritative "no such name" is the customer's record not existing yet. |
| +// A resolver FAILURE is ours. Collapsing both to "" makes the status machine |
| +// blame the customer for our outage. |
| +func TestDNSStatusSeparatesNoRecordFromLookupFailure(t *testing.T) { |
| + edge := map[string]bool{"1.1.1.1": true} |
| + |
| + notFound := func(string) ([]string, error) { |
| + return nil, &net.DNSError{Err: "no such host", IsNotFound: true} |
| + } |
| + if got := dnsStatus(notFound, "absent.customer.com", edge); got != "" { |
| + t.Fatalf("NXDOMAIN = %q, want \"\" (no record yet)", got) |
| + } |
| + |
| + servfail := func(string) ([]string, error) { |
| + return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true} |
| + } |
| + if got := dnsStatus(servfail, "unknown.customer.com", edge); got != "unresolved" { |
| + t.Fatalf("resolver failure = %q, want \"unresolved\" — our fault, not the customer's", got) |
| + } |
| + |
| + plain := func(string) ([]string, error) { |
| + return nil, errors.New("boom") |
| + } |
| + if got := dnsStatus(plain, "other.customer.com", edge); got != "unresolved" { |
| + t.Fatalf("non-DNSError failure = %q, want \"unresolved\"", got) |
| + } |
| +} |
| + |
| // TestMintedRouteCarriesRegionAndDropsSocketWhenFleetPlaced: a minted |
| // platform apex must carry its source's Region so the edge dispatches it |
| // through the same fleet channel — but when that Region is a fleet |
| @@ -152,3 +206,51 @@ func TestMintedRouteCarriesRegionAndDropsSocketWhenFleetPlaced(t *testing.T) { |
| t.Fatalf("mintedRoute Socket = %q, want %q — a non-fleet source's socket is still copied", rt2.Socket, src2.Socket) |
| } |
| } |
| + |
| +// A failed LOCAL row write does not unmake an account. The manifest is the |
| +// authority on which accounts exist; the registry row is only this box's copy |
| +// of that fact. known is what makes an apps/<sqid>/<app>/… key path |
| +// unambiguous, so dropping the sqid on a write failure would make this box |
| +// read every one of that account's keys as legacy for the rest of the pass — |
| +// silently skipping its trash markers, route linkage and canary pointers |
| +// until some later pass happened to get the row written. |
| +func TestMaterializeAccountsKeepsAnAccountKnownWhenTheRowWriteFails(t *testing.T) { |
| + log.SetOutput(io.Discard) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + const id = 793481920157 |
| + sqid := Sqid(id) |
| + body := []byte(`{"id":793481920157,"sqid":"` + sqid + `","name":"Kass","created_at":"2026-08-02T09:00:00Z"}`) |
| + if _, err := s.Put(ctx, "accounts/"+sqid+"/account.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + acctKeys, err := s.List(ctx, "accounts/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Close(); err != nil { // every write from here on fails |
| + t.Fatal(err) |
| + } |
| + if err := reg.PutAccount(id, sqid, "Kass"); err == nil { |
| + t.Fatal("a closed registry accepted PutAccount — this test injects nothing") |
| + } |
| + |
| + known := materializeAccounts(ctx, s, acctKeys, reg) |
| + |
| + if !known[sqid] { |
| + t.Fatalf("account %s missing from the set: %v — a failed local row write does not unmake an account whose identity already checked out", sqid, known) |
| + } |
| + // And the consequence that actually matters: the account's app keys still |
| + // read as qualified, so the rest of the pass still sees its objects. |
| + acct, app, _, ok := appKeyOwner("apps/"+sqid+"/kass/deleted.json", known) |
| + if !ok || acct != sqid || app != "kass" { |
| + t.Fatalf("appKeyOwner = (%q, %q, %v); want the key read as qualified under %s", acct, app, ok, sqid) |
| + } |
| +} |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index f90fcdaab..8e94b7167 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "fmt" |
| "log" |
| "math/big" |
| + "net" |
| "os" |
| "path/filepath" |
| "reflect" |
| @@ -73,11 +74,16 @@ func putKey(t *testing.T, s release.Store, key string) { |
| // cfg is the standard reconcile config: apps domain set, a fake resolver |
| // saying every host points at the edge, DNS/TLS seams otherwise defaulted. |
| +// |
| +// EdgeIPs is a func because the live set moves under the pass (in production |
| +// it folds every box's published edges file each pass); a fixture that |
| +// answers the same one address every time keeps every existing assertion |
| +// here reading exactly as it did when the field was a slice. |
| func cfg(t *testing.T) domains.Config { |
| t.Helper() |
| return domains.Config{ |
| AppsDomain: "oncarlos.test", |
| - EdgeIPs: []string{"203.0.113.7"}, |
| + EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| Now: func() time.Time { return time.Unix(1754000000, 0) }, |
| } |
| @@ -535,7 +541,7 @@ func TestReconcileStampsDNS(t *testing.T) { |
| case "elsewhere.test": |
| return []string{"198.51.100.9"}, nil |
| default: |
| - return nil, errors.New("NXDOMAIN or worse") |
| + return nil, errors.New("server misbehaving") // not a *net.DNSError: our lookup failed, not an authoritative answer |
| } |
| } |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| @@ -548,7 +554,9 @@ func TestReconcileStampsDNS(t *testing.T) { |
| for host, want := range map[string]string{ |
| "points.test": "pointing", |
| "elsewhere.test": "elsewhere", |
| - "broken.test": "", // resolver failure is UNKNOWN, never a guess |
| + // A non-DNSError resolver failure is OURS, not an authoritative |
| + // "no such name" — it must not read as "the customer has no record". |
| + "broken.test": "unresolved", |
| } { |
| got := mustGet(t, reg, host) |
| if got.DNSStatus != want { |
| @@ -560,6 +568,71 @@ func TestReconcileStampsDNS(t *testing.T) { |
| } |
| } |
| +// The edge set is now asked for once PER PASS, not fixed at box start: under |
| +// latency steering a correctly-pointed customer domain resolves to whichever |
| +// region's edge is nearest, so a set frozen at boot reads a healthy domain as |
| +// "elsewhere" the moment another region arms. This pins that the pass reads |
| +// the CURRENT answer — a second pass with a widened set restamps. |
| +func TestReconcileReadsTheEdgeSetEveryPass(t *testing.T) { |
| + reg := regWith(t, |
| + registry.Route{Host: "faraway.test", Kind: "static", Status: "ready"}, |
| + ) |
| + c := cfg(t) |
| + c.Resolve = func(host string) ([]string, error) { return []string{"198.51.100.9"}, nil } |
| + calls := 0 |
| + // A second region's edge arms between the two passes. |
| + c.EdgeIPs = func() []string { |
| + calls++ |
| + if calls > 1 { |
| + return []string{"203.0.113.7", "198.51.100.9"} |
| + } |
| + return []string{"203.0.113.7"} |
| + } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "faraway.test"); got.DNSStatus != "elsewhere" { |
| + t.Fatalf("pass 1: dns_status = %q, want elsewhere — that edge is not armed yet", got.DNSStatus) |
| + } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "faraway.test"); got.DNSStatus != "pointing" { |
| + t.Fatalf("pass 2: dns_status = %q, want pointing — the set is read per pass, not frozen at arm", got.DNSStatus) |
| + } |
| +} |
| + |
| +// A pass that knows NO edge addresses stands down rather than stamping. |
| +// Stamping against an empty set would mark every host in the deployment |
| +// "elsewhere" at once — a fleet-wide lying red raised by a failed bucket |
| +// read, not by anybody's DNS. Existing stamps are left exactly as they were: |
| +// the last thing this pass actually knew beats a verdict it had to guess. |
| +func TestReconcileStandsDownWithNoEdgeIPs(t *testing.T) { |
| + reg := regWith(t, |
| + registry.Route{Host: "points.test", Kind: "static", Status: "ready"}, |
| + ) |
| + if err := domains.StampHealth(ctx, reg, cfg(t), nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "points.test"); got.DNSStatus != "pointing" { |
| + t.Fatalf("setup: dns_status = %q, want pointing", got.DNSStatus) |
| + } |
| + |
| + for name, edge := range map[string]func() []string{ |
| + "an empty fold": func() []string { return nil }, |
| + "no func at all": nil, |
| + } { |
| + c := cfg(t) |
| + c.EdgeIPs = edge |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if got := mustGet(t, reg, "points.test"); got.DNSStatus != "pointing" { |
| + t.Errorf("%s: dns_status = %q, want the previous stamp left standing", name, got.DNSStatus) |
| + } |
| + } |
| +} |
| + |
| // writeCertBundle writes what autocert's cachePut writes under DirCache key |
| // <host>: the private key PEM block first, then the CERTIFICATE chain — so |
| // the parser is exercised on skipping the key block. |
| @@ -664,7 +737,7 @@ func TestReconcileStampsAliasDNS(t *testing.T) { |
| case "elsewhere-alias.test": |
| return []string{"198.51.100.9"}, nil |
| default: |
| - return nil, errors.New("NXDOMAIN or worse") |
| + return nil, errors.New("server misbehaving") // not a *net.DNSError: our lookup failed, not an authoritative answer |
| } |
| } |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| @@ -677,7 +750,9 @@ func TestReconcileStampsAliasDNS(t *testing.T) { |
| for alias, want := range map[string]string{ |
| "points-alias.test": "pointing", |
| "elsewhere-alias.test": "elsewhere", |
| - "broken-alias.test": "", // resolver failure is UNKNOWN, never a guess |
| + // A non-DNSError resolver failure is OURS, not an authoritative |
| + // "no such name" — it must not read as "the customer has no record". |
| + "broken-alias.test": "unresolved", |
| } { |
| got := aliasByName(t, reg, alias) |
| if got.DNSStatus != want { |
| @@ -1206,7 +1281,7 @@ func TestStampHealthStandsAlone(t *testing.T) { |
| } |
| cfg := domains.Config{ |
| AppsDomain: "oncarlos.test", |
| - EdgeIPs: []string{"203.0.113.7"}, |
| + EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(host string) ([]string, error) { |
| if host == "www.customer.test" { |
| return []string{"203.0.113.7"}, nil |
| @@ -1234,15 +1309,18 @@ func TestStampHealthStandsAlone(t *testing.T) { |
| } |
| } |
| -// A resolver failure never overwrites a verdict: the last known verdict |
| -// and its stamp time stay, so a bad DNS tick cannot unpublish a working |
| -// custom domain (#287's gate) or launder staleness into "never checked". |
| +// OUR failure never overwrites a verdict: the last known verdict and its |
| +// stamp time stay, so a bad DNS tick cannot unpublish a working custom |
| +// domain (#287's gate) or launder staleness into "never checked". |
| +// |
| +// An authoritative NXDOMAIN is the opposite case and DOES stamp — the |
| +// resolver answered, and the answer is that the record is gone. |
| func TestStampHealthHoldsLastVerdictOnResolverFailure(t *testing.T) { |
| reg := regWith(t, registry.Route{Host: "app.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.customer.test", Host: "app.acct.oncarlos.test", Account: "acct", App: "app"}); err != nil { |
| t.Fatal(err) |
| } |
| - good := domains.Config{AppsDomain: "oncarlos.test", EdgeIPs: []string{"203.0.113.7"}, |
| + good := domains.Config{AppsDomain: "oncarlos.test", EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| Now: func() time.Time { return time.Unix(1000, 0) }} |
| if err := domains.StampHealth(context.Background(), reg, good, nil); err != nil { |
| @@ -1261,13 +1339,29 @@ func TestStampHealthHoldsLastVerdictOnResolverFailure(t *testing.T) { |
| if len(aliases) != 1 || aliases[0].DNSStatus != "pointing" || aliases[0].DNSCheckedAt != 1000 { |
| t.Errorf("alias after resolver failure = %+v; want pointing @1000", aliases) |
| } |
| - // A never-checked row still records unknown, so "unknown" stays honest. |
| + // A never-checked row records that we could not check — "unresolved", |
| + // not "" — so the operator can tell our outage from a missing record. |
| fresh := regWith(t, registry.Route{Host: "new.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| if err := domains.StampHealth(context.Background(), fresh, broken, nil); err != nil { |
| t.Fatal(err) |
| } |
| - if got := mustGet(t, fresh, "new.acct.oncarlos.test"); got.DNSStatus != "" || got.DNSCheckedAt != 2000 { |
| - t.Errorf("fresh row = %q @%d; want \"\" @2000", got.DNSStatus, got.DNSCheckedAt) |
| + if got := mustGet(t, fresh, "new.acct.oncarlos.test"); got.DNSStatus != "unresolved" || got.DNSCheckedAt != 2000 { |
| + t.Errorf("fresh row = %q @%d; want unresolved @2000", got.DNSStatus, got.DNSCheckedAt) |
| + } |
| + |
| + // And the case the guard must NOT swallow: the customer deleted the |
| + // record. NXDOMAIN is an answer, so "pointing" gives way to it. Without |
| + // this, a domain that stopped resolving would read "pointing" forever. |
| + gone := good |
| + gone.Resolve = func(string) ([]string, error) { |
| + return nil, &net.DNSError{Err: "no such host", IsNotFound: true} |
| + } |
| + gone.Now = func() time.Time { return time.Unix(3000, 0) } |
| + if err := domains.StampHealth(context.Background(), reg, gone, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "app.acct.oncarlos.test"); got.DNSStatus != "" || got.DNSCheckedAt != 3000 { |
| + t.Errorf("route after NXDOMAIN = %q @%d; want \"\" @3000 — an authoritative answer is news", got.DNSStatus, got.DNSCheckedAt) |
| } |
| } |
diff --git a/internal/edge/certwarm.go b/internal/edge/certwarm.go| new file mode 100644 |
| index 000000000..018cbab10 |
| --- /dev/null |
| +++ b/internal/edge/certwarm.go |
| @@ -0,0 +1,376 @@ |
| +// Cert warm (#299 stage 3): the platform orders and RENEWS certificates for |
| +// the custom domains it serves, instead of waiting for a visitor's handshake |
| +// to trigger the first issuance. |
| +// |
| +// Why this exists at all. Per-host certs are minted lazily by autocert on the |
| +// first TLS handshake for a name. That is fine for a name people visit from |
| +// the box that serves it, and wrong for a custom domain on a steering |
| +// deployment, for two compounding reasons: |
| +// |
| +// - The FIRST visitor pays a multi-second ACME order, and if the customer's |
| +// DNS only just landed, they are also the one who discovers it failed. |
| +// - RENEWAL is worse. autocert renews through per-host `domainRenewal` |
| +// timers that live in process memory. They die on every fleet roll and |
| +// re-arm only when a handshake reaches m.cert again. Under latency |
| +// steering a custom domain's visitors may never land on the issuer, so |
| +// after a roll NOTHING re-arms the timer and the cert quietly expires at |
| +// day 90 — on a host that was serving perfectly the whole time. |
| +// |
| +// So the warm calls GetCertificate for every host in its set on EVERY pass, |
| +// unconditionally. That is not wasteful and it is not a retry loop: for a |
| +// healthy cert it is one cache read plus the startRenew that re-arms the |
| +// timer, and the re-arm is the entire point. A skip-if-fresh check would |
| +// defeat it — autocert's m.cert returns any unexpired cached cert, so a cert |
| +// 29 days from expiry is a cache hit and no renewal is ever scheduled. |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "crypto/tls" |
| + "errors" |
| + "log" |
| + "net" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme" |
| +) |
| + |
| +const ( |
| + // warmInterval is the quiet-path cadence. Renewal is the job with the |
| + // real deadline (autocert renews at ~30 days remaining), so hourly is |
| + // far more often than strictly needed — the cost of a pass over a |
| + // healthy set is a cache read per host, and being early is free. |
| + warmInterval = time.Hour |
| + // warmBackoff is the per-host floor after a failed order. Matches the |
| + // wildcard manager's hourly retry. |
| + warmBackoff = time.Hour |
| + // warmRateLimitBackoff is the floor after the CA says rateLimited. |
| + // Deliberately much harder than warmBackoff: hourly retries INTO a rate |
| + // limit are what turn a transient limit into a sustained one, and every |
| + // host in the deployment shares the one ACME account's budget. |
| + warmRateLimitBackoff = 12 * time.Hour |
| + // warmOrdersPerPass bounds NEW orders per pass. Renewal re-arms are not |
| + // orders and are never capped — they are the reason the pass exists. |
| + // When the cap bites, pass() returns a short delay so a backlog drains |
| + // in minutes rather than trickling out over days (the wildcard pass() |
| + // precedent). |
| + warmOrdersPerPass = 10 |
| + // warmCappedDelay is how soon to come back with capped work remaining. |
| + warmCappedDelay = time.Minute |
| +) |
| + |
| +// certWarm proactively orders and renews per-host certificates. |
| +// |
| +// It is a sibling of wildcardManager, with three deliberate departures from |
| +// it, each of which is a bug if copied across: |
| +// |
| +// - No holding-context claim. The wildcard manager runs its own ACME |
| +// client, so it can hand the holding's context to the order. The warm's |
| +// trigger is autocert's GetCertificate, which takes NO context and builds |
| +// its own background one with a 5-minute timeout. So the gate is |
| +// re-checked BETWEEN orders instead, and a ≤5-minute overhang past a lost |
| +// holding is accepted — inside the double-holder envelope issuerHostPolicy |
| +// already documents. If the holding ends mid-order, the finishing |
| +// cachePut re-checks the gate and the cert lands local-only; the new |
| +// issuer re-orders it. Bounded waste, not a hazard. |
| +// - No cache-load-before-gate step. The wildcard manager loads cached certs |
| +// before standing down because it SERVES from its own map. The warm |
| +// serves nothing — per-host serving is the mirror plus autocert — so a |
| +// non-issuer scanning caches would be pure waste. It stands down at the |
| +// gate. |
| +// - The unconditional GetCertificate described in the package comment. |
| +type certWarm struct { |
| + // hosts is the warm set: exact hostnames this deployment serves that are |
| + // not under the apps domain and not covered by a wildcard. See |
| + // Edge.warmSet. |
| + hosts func() []string |
| + // getCert is autocert's per-host path (NOT the wildcard-wrapped one — a |
| + // wrapped call would be answered by the wildcard and order nothing). |
| + getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error) |
| + // pointsHere reports whether host's DNS resolves to this deployment. |
| + // Ordering for a half-pointed name burns the shared account's |
| + // failed-validation budget; see dnsGate. |
| + pointsHere func(host string) bool |
| + // issuer gates ORDERING, exactly as it gates the wildcard manager's. |
| + issuer IssuerGate |
| + |
| + mu sync.Mutex |
| + // nextAttempt is the per-host backoff floor. |
| + nextAttempt map[string]time.Time |
| + // warmed is the set of hosts a cert has been obtained for at least once, |
| + // so a first order can be told from a renewal re-arm for the order cap |
| + // and for logging. |
| + warmed map[string]bool |
| + |
| + lastStandDownLog time.Time |
| +} |
| + |
| +func newCertWarm(hosts func() []string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), pointsHere func(string) bool, issuer IssuerGate) *certWarm { |
| + return &certWarm{ |
| + hosts: hosts, |
| + getCert: getCert, |
| + pointsHere: pointsHere, |
| + issuer: issuer, |
| + nextAttempt: map[string]time.Time{}, |
| + warmed: map[string]bool{}, |
| + } |
| +} |
| + |
| +// issuerGate is this warm's read of the deployment's gate, ungated default |
| +// applied (see IssuerGate, certsync.go). |
| +func (c *certWarm) issuerGate() (context.Context, bool) { |
| + if c.issuer == nil { |
| + return context.Background(), true |
| + } |
| + return c.issuer() |
| +} |
| + |
| +func (c *certWarm) run() { |
| + for { |
| + time.Sleep(c.pass()) |
| + } |
| +} |
| + |
| +// pass warms every host in the set once and returns how long to wait. |
| +func (c *certWarm) pass() time.Duration { |
| + hosts := c.hosts() |
| + if len(hosts) == 0 { |
| + return warmInterval |
| + } |
| + // Stand down before ANY work: unlike the wildcard manager there is no |
| + // cache to load first, so a non-issuer has nothing useful to do here. |
| + if _, ok := c.issuerGate(); !ok { |
| + c.logStandDown(len(hosts)) |
| + return warmInterval |
| + } |
| + orders, capped := 0, false |
| + for _, h := range hosts { |
| + now := time.Now() |
| + c.mu.Lock() |
| + until, backing := c.nextAttempt[h] |
| + seen := c.warmed[h] |
| + c.mu.Unlock() |
| + if backing && now.Before(until) { |
| + continue |
| + } |
| + // The gate is re-checked BETWEEN orders, not once per pass: a pass |
| + // over a large set can outlive a holding, and every order after the |
| + // holding ends is an order this box has no authority to place. |
| + if _, ok := c.issuerGate(); !ok { |
| + c.logStandDown(len(hosts) - orders) |
| + return warmInterval |
| + } |
| + // A host with no cert yet is a NEW order and is both capped and |
| + // DNS-gated. A host already warmed is a renewal re-arm: uncapped and |
| + // ungated, because it is a cache read that costs the CA nothing and |
| + // re-arms the timer that renewal depends on. Gating the re-arm on DNS |
| + // would strand exactly the domain whose resolver is briefly sick. |
| + if !seen { |
| + if orders >= warmOrdersPerPass { |
| + capped = true |
| + continue |
| + } |
| + if !c.pointsHere(h) { |
| + continue |
| + } |
| + orders++ |
| + } |
| + if err := c.warmOne(h); err != nil { |
| + c.fail(h, err) |
| + continue |
| + } |
| + c.mu.Lock() |
| + delete(c.nextAttempt, h) |
| + first := !c.warmed[h] |
| + c.warmed[h] = true |
| + c.mu.Unlock() |
| + if first { |
| + log.Printf("edge: cert warmed for %s", h) |
| + } |
| + } |
| + if capped { |
| + return warmCappedDelay |
| + } |
| + return warmInterval |
| +} |
| + |
| +// warmOne asks autocert for host's certificate, which mints it when absent |
| +// and re-arms its renewal timer when present. |
| +func (c *certWarm) warmOne(host string) error { |
| + _, err := c.getCert(warmHello(host)) |
| + return err |
| +} |
| + |
| +// fail arms the per-host backoff, harder when the CA is rate-limiting. |
| +func (c *certWarm) fail(host string, err error) { |
| + backoff := warmBackoff |
| + var ae *acme.Error |
| + if errors.As(err, &ae) && ae.ProblemType == "urn:ietf:params:acme:error:rateLimited" { |
| + backoff = warmRateLimitBackoff |
| + log.Printf("edge: cert warm for %s rate-limited by the CA — backing off %s (the whole deployment shares one ACME account)", host, backoff) |
| + } else { |
| + log.Printf("edge: cert warm for %s: %v — retrying in %s", host, err, backoff) |
| + } |
| + c.mu.Lock() |
| + c.nextAttempt[host] = time.Now().Add(backoff) |
| + c.mu.Unlock() |
| +} |
| + |
| +func (c *certWarm) logStandDown(hosts int) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if time.Since(c.lastStandDownLog) < time.Hour { |
| + return |
| + } |
| + c.lastStandDownLog = time.Now() |
| + log.Printf("edge: cert warm standing down for %d host(s) — another box holds the issuer lease", hosts) |
| +} |
| + |
| +// warmHello builds the synthetic ClientHelloInfo the warm hands autocert. |
| +// |
| +// The CipherSuites entry is LOAD-BEARING and must not be dropped as |
| +// decoration. autocert picks the key type from the hello via supportsECDSA |
| +// (ported here as helloSupportsECDSA): with a hello carrying no signature |
| +// schemes, no curves and no cipher suites, all three checks fall through to |
| +// false, autocert chooses certKey{isRSA: true}, and the warm mints an RSA |
| +// cert under "<host>+rsa" — while every real browser asks for "<host>" and |
| +// gets nothing. The order would be spent, the name still uncovered, and the |
| +// first visitor would trigger a SECOND issuance. |
| +// |
| +// One ECDSA suite is enough: helloSupportsECDSA's first two checks skip on |
| +// nil, and its cipher-suite loop returns true on this suite. |
| +func warmHello(host string) *tls.ClientHelloInfo { |
| + return &tls.ClientHelloInfo{ |
| + ServerName: host, |
| + CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, |
| + } |
| +} |
| + |
| +// warmSet is the set of hostnames the cert warm orders and renews for: |
| +// EXACT names this deployment serves that are not under the apps domain and |
| +// not covered by a wildcard. |
| +// |
| +// The predicate is deliberately simpler than "aliases" or "custom domains". |
| +// A custom domain is a foreign domain by definition — the attach door refuses |
| +// apps-domain hosts outright — so "not under the apps domain" covers attached |
| +// aliases and first-route claims alike, with no need to reason about which |
| +// registry table a name arrived through. That matters: first-route claims |
| +// (`carlos deploy -kind static -host <site>`) mint a real ROUTE, not an |
| +// alias, so an alias-only rule would silently never warm the shipped headline |
| +// flow. |
| +// |
| +// What it excludes, and why each one: |
| +// |
| +// - "*." rows — covered by a wildcard PEM, and autocert refuses the |
| +// wildcard label anyway. |
| +// - anything under the apps domain — canary hosts and platform hosts. This |
| +// is the exclusion that keeps a first run from being a burst: canaries are |
| +// three labels under the apps domain, no wildcard parent covers them, and |
| +// a naive "everything we serve" set would order a cert AND a CT-log entry |
| +// for every one of them. |
| +// - anything one label under a CONFIGURED wildcard parent, tested BY SHAPE |
| +// against currentParents() rather than by asking certFor. certFor returns |
| +// nil for a parent whose cert has not been obtained yet, so a shape-blind |
| +// test would warm every app of a new account per-host during exactly the |
| +// window before their wildcard lands. |
| +func (e *Edge) warmSet(appsDomain string, parents func() []string) []string { |
| + appsDomain = strings.ToLower(strings.TrimSpace(appsDomain)) |
| + covered := wildcardCoverer(parents) |
| + seen := map[string]bool{} |
| + var out []string |
| + consider := func(h string) { |
| + h = strings.ToLower(strings.TrimSpace(h)) |
| + if h == "" || seen[h] || strings.HasPrefix(h, "*.") { |
| + return |
| + } |
| + if appsDomain != "" && (h == appsDomain || strings.HasSuffix(h, "."+appsDomain)) { |
| + return |
| + } |
| + if covered(h) { |
| + return |
| + } |
| + seen[h] = true |
| + out = append(out, h) |
| + } |
| + for h := range e.current() { |
| + consider(h) |
| + } |
| + for _, h := range e.homes.Load().ServedHosts() { |
| + consider(h) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// wildcardCoverer reports whether a host is exactly one label under a |
| +// wildcard parent this box has configured or derived — by SHAPE, never by |
| +// whether the parent's cert exists yet (see warmSet). A nil parents func |
| +// covers nothing, which is the correct answer for a deployment with no |
| +// wildcard manager at all. |
| +func wildcardCoverer(parents func() []string) func(string) bool { |
| + return func(host string) bool { |
| + if parents == nil { |
| + return false |
| + } |
| + for _, p := range parents() { |
| + rest, ok := strings.CutSuffix(host, "."+p) |
| + if ok && rest != "" && !strings.Contains(rest, ".") { |
| + return true |
| + } |
| + } |
| + return false |
| + } |
| +} |
| + |
| +// dnsGate builds the "does this name point at us?" test the warm applies |
| +// before placing a NEW order. |
| +// |
| +// Let's Encrypt caps failed validations at 5 per account per hostname per |
| +// hour, and the ACME account is shared by every name this issuer mints. So a |
| +// half-configured customer domain that we order for anyway does not just fail |
| +// its own order — it spends budget every other tenant needs. The gate is what |
| +// keeps one customer's unfinished DNS from being everyone's problem. |
| +// |
| +// ALL resolved answers must be in the edge set, not merely one. A name in |
| +// mid-migration resolves to both the old host and ours; an any-match test |
| +// passes it, the order goes out, and validation fails from the CA's vantage |
| +// anyway — burning exactly the budget this gate exists to protect. Requiring |
| +// all of them means the gate only opens once the customer has actually |
| +// finished. |
| +// |
| +// An empty edge set fails CLOSED (no host points anywhere), matching the |
| +// domains pass's stand-down: a set we could not read is not evidence about |
| +// anybody's DNS, and the cost of waiting a pass is one hour of delay against |
| +// the cost of burning a shared rate limit. |
| +func dnsGate(resolve func(string) ([]string, error), edgeIPs func() []string) func(string) bool { |
| + return func(host string) bool { |
| + set := map[string]bool{} |
| + for _, ip := range edgeIPs() { |
| + set[ip] = true |
| + } |
| + if len(set) == 0 { |
| + return false |
| + } |
| + ips, err := resolve(host) |
| + if err != nil || len(ips) == 0 { |
| + return false |
| + } |
| + for _, ip := range ips { |
| + if !set[ip] { |
| + return false |
| + } |
| + } |
| + return true |
| + } |
| +} |
| + |
| +// warmResolver is the DNS gate's lookup: the system resolver, bounded so a |
| +// hung one costs this host its turn in the pass and not the pass itself. |
| +func warmResolver(host string) ([]string, error) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + return net.DefaultResolver.LookupHost(ctx, host) |
| +} |
diff --git a/internal/edge/certwarm_test.go b/internal/edge/certwarm_test.go| new file mode 100644 |
| index 000000000..95aa55b75 |
| --- /dev/null |
| +++ b/internal/edge/certwarm_test.go |
| @@ -0,0 +1,269 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "crypto/tls" |
| + "errors" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme" |
| +) |
| + |
| +// THE trap this whole file exists to avoid. autocert picks the key type from |
| +// the hello. A hello with no signature schemes, no curves and no cipher |
| +// suites reads as "no ECDSA", so autocert mints an RSA cert under |
| +// "<host>+rsa" while every browser asks for "<host>" — the order spent, the |
| +// name still uncovered, and the first real visitor triggering a second |
| +// issuance. |
| +// |
| +// This pins the warm's hello against the repo's own port of autocert's |
| +// chooser, so the two cannot drift. |
| +func TestWarmHelloAsksForAnECDSACert(t *testing.T) { |
| + if !helloSupportsECDSA(warmHello("app.example.com")) { |
| + t.Fatal("the warm's hello reads as RSA-only — autocert would mint <host>+rsa and no browser would ever be served it") |
| + } |
| + // The bare hello is the bug, kept here so the test documents WHY the |
| + // cipher-suite entry above is load-bearing rather than decorative. |
| + if helloSupportsECDSA(&tls.ClientHelloInfo{ServerName: "app.example.com"}) { |
| + t.Fatal("an empty hello now reads as ECDSA-capable — warmHello's cipher suite may no longer be needed, re-derive the trap before deleting it") |
| + } |
| +} |
| + |
| +func TestWarmHelloCarriesTheServerName(t *testing.T) { |
| + if got := warmHello("App.Example.com").ServerName; got != "App.Example.com" { |
| + t.Fatalf("ServerName = %q", got) |
| + } |
| +} |
| + |
| +// The warm set is "exact hosts we serve that are not under the apps domain |
| +// and not wildcard-covered". Each exclusion below is a separate bug if it |
| +// regresses. |
| +func TestWarmSetSelectsCustomDomainsOnly(t *testing.T) { |
| + e := &Edge{} |
| + e.upstreams.Store(&map[string]upstream{ |
| + "www.customer.test": {}, |
| + "shop.customer.test": {}, |
| + "app.acct.oncarlos.test": {}, // platform host — apps domain |
| + "c1.app.acct.oncarlos.test": {}, // canary — apps domain, 3 labels |
| + "*.catchall.test": {}, // catch-all — wildcard covers it |
| + "one.go.tito.test": {}, // one label under a configured parent |
| + "deep.one.go.tito.test": {}, // TWO labels under it: NOT covered |
| + }) |
| + parents := func() []string { return []string{"go.tito.test"} } |
| + |
| + got := e.warmSet("oncarlos.test", parents) |
| + want := []string{"deep.one.go.tito.test", "shop.customer.test", "www.customer.test"} |
| + if strings.Join(got, ",") != strings.Join(want, ",") { |
| + t.Fatalf("warm set = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// A parent whose cert has not been obtained yet must STILL exclude its |
| +// children. Testing coverage by asking certFor (which returns nil until the |
| +// wildcard lands) would warm every app of a new account per-host during |
| +// exactly the window before their wildcard arrives — the burst the shape |
| +// test exists to prevent. |
| +func TestWarmSetExcludesChildrenOfAnUnobtainedParent(t *testing.T) { |
| + e := &Edge{} |
| + e.upstreams.Store(&map[string]upstream{"new.acct.test": {}}) |
| + // currentParents lists the parent; no cert exists for it anywhere. |
| + if got := e.warmSet("oncarlos.test", func() []string { return []string{"acct.test"} }); len(got) != 0 { |
| + t.Fatalf("warm set = %v; a configured parent covers its children whether or not its cert exists yet", got) |
| + } |
| +} |
| + |
| +func TestWarmSetWithNoWildcardManagerCoversNothing(t *testing.T) { |
| + e := &Edge{} |
| + e.upstreams.Store(&map[string]upstream{"www.customer.test": {}}) |
| + if got := e.warmSet("oncarlos.test", nil); len(got) != 1 || got[0] != "www.customer.test" { |
| + t.Fatalf("warm set = %v, want the custom domain", got) |
| + } |
| +} |
| + |
| +// ALL answers must be in the edge set. A name mid-migration resolves to the |
| +// old host AND ours; an any-match gate passes it, the order goes out, and |
| +// validation fails from the CA's vantage anyway — burning the shared |
| +// account's failed-validation budget, which is exactly what the gate is for. |
| +func TestDNSGateRequiresEveryAnswerToBeOurs(t *testing.T) { |
| + edges := func() []string { return []string{"203.0.113.7", "198.51.100.9"} } |
| + for name, tc := range map[string]struct { |
| + ips []string |
| + err error |
| + want bool |
| + }{ |
| + "all ours": {ips: []string{"203.0.113.7", "198.51.100.9"}, want: true}, |
| + "one of ours": {ips: []string{"203.0.113.7"}, want: true}, |
| + "partly elsewhere": {ips: []string{"203.0.113.7", "192.0.2.1"}, want: false}, |
| + "wholly elsewhere": {ips: []string{"192.0.2.1"}, want: false}, |
| + "no answer": {ips: nil, want: false}, |
| + "resolver failed": {err: errors.New("SERVFAIL"), want: false}, |
| + } { |
| + gate := dnsGate(func(string) ([]string, error) { return tc.ips, tc.err }, edges) |
| + if got := gate("www.customer.test"); got != tc.want { |
| + t.Errorf("%s: gate = %v, want %v", name, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// An unreadable edge set is not evidence about anybody's DNS. Failing closed |
| +// costs one pass of delay; failing open spends a shared rate limit. |
| +func TestDNSGateFailsClosedOnAnEmptyEdgeSet(t *testing.T) { |
| + gate := dnsGate( |
| + func(string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| + func() []string { return nil }, |
| + ) |
| + if gate("www.customer.test") { |
| + t.Fatal("an empty edge set opened the gate") |
| + } |
| +} |
| + |
| +// The heart of the design: a host that already has a cert is asked for AGAIN |
| +// every pass. autocert renews through in-memory timers that die on every |
| +// fleet roll and re-arm only via a GetCertificate that reaches m.cert — so a |
| +// skip-if-fresh check would silently stop renewing every custom domain whose |
| +// visitors never land on the issuer. |
| +func TestWarmReAsksForAlreadyWarmHostsEveryPass(t *testing.T) { |
| + var mu sync.Mutex |
| + calls := map[string]int{} |
| + w := newCertWarm( |
| + func() []string { return []string{"www.customer.test"} }, |
| + func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + calls[h.ServerName]++ |
| + return &tls.Certificate{}, nil |
| + }, |
| + func(string) bool { return true }, |
| + nil, |
| + ) |
| + for i := 0; i < 3; i++ { |
| + w.pass() |
| + } |
| + if calls["www.customer.test"] != 3 { |
| + t.Fatalf("GetCertificate calls = %d, want one per pass — the renewal re-arm depends on it", calls["www.customer.test"]) |
| + } |
| +} |
| + |
| +// A renewal re-arm is NOT gated on DNS. It is a cache read that costs the CA |
| +// nothing, and gating it would strand exactly the domain whose resolver is |
| +// briefly sick — dropping its renewal on the floor for as long as the |
| +// resolver stays unhappy. |
| +func TestWarmRenewalReArmIgnoresTheDNSGate(t *testing.T) { |
| + calls := 0 |
| + pointing := true |
| + w := newCertWarm( |
| + func() []string { return []string{"www.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| + func(string) bool { return pointing }, |
| + nil, |
| + ) |
| + w.pass() // first order: gate open, host becomes warmed |
| + pointing = false |
| + w.pass() // DNS now fails; the re-arm must still happen |
| + if calls != 2 { |
| + t.Fatalf("calls = %d, want 2 — a warmed host's re-arm is not DNS-gated", calls) |
| + } |
| +} |
| + |
| +// A never-warmed host IS DNS-gated: ordering for a half-pointed name spends |
| +// the shared account's failed-validation budget. |
| +func TestWarmDoesNotOrderForAHostThatDoesNotPointHere(t *testing.T) { |
| + calls := 0 |
| + w := newCertWarm( |
| + func() []string { return []string{"www.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| + func(string) bool { return false }, |
| + nil, |
| + ) |
| + w.pass() |
| + if calls != 0 { |
| + t.Fatalf("calls = %d, want 0 — the DNS gate must hold the first order", calls) |
| + } |
| +} |
| + |
| +// A non-issuer does no work at all: unlike the wildcard manager there is no |
| +// cache to load first, because the warm serves nothing. |
| +func TestWarmStandsDownWhenNotTheIssuer(t *testing.T) { |
| + calls := 0 |
| + w := newCertWarm( |
| + func() []string { return []string{"www.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| + func(string) bool { return true }, |
| + func() (context.Context, bool) { return nil, false }, |
| + ) |
| + if d := w.pass(); d != warmInterval { |
| + t.Fatalf("delay = %s", d) |
| + } |
| + if calls != 0 { |
| + t.Fatalf("a non-issuer placed %d call(s)", calls) |
| + } |
| +} |
| + |
| +// A rate limit must back off much harder than an ordinary failure: hourly |
| +// retries INTO a rate limit are what turn a transient limit into a sustained |
| +// one, and every host shares the one ACME account's budget. |
| +func TestWarmBacksOffHarderOnARateLimit(t *testing.T) { |
| + rateLimited := &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"} |
| + w := newCertWarm(nil, nil, nil, nil) |
| + w.fail("a.test", rateLimited) |
| + w.fail("b.test", errors.New("connection refused")) |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + limited, ordinary := time.Until(w.nextAttempt["a.test"]), time.Until(w.nextAttempt["b.test"]) |
| + if limited <= ordinary { |
| + t.Fatalf("rate-limited backoff %s is not harder than the ordinary %s", limited, ordinary) |
| + } |
| +} |
| + |
| +// A failing host is not retried until its backoff expires — and a healthy |
| +// host in the same set is not held up by it. |
| +func TestWarmHonoursPerHostBackoff(t *testing.T) { |
| + calls := map[string]int{} |
| + w := newCertWarm( |
| + func() []string { return []string{"bad.test", "good.test"} }, |
| + func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + calls[h.ServerName]++ |
| + if h.ServerName == "bad.test" { |
| + return nil, errors.New("order failed") |
| + } |
| + return &tls.Certificate{}, nil |
| + }, |
| + func(string) bool { return true }, |
| + nil, |
| + ) |
| + w.pass() |
| + w.pass() |
| + if calls["bad.test"] != 1 { |
| + t.Errorf("bad.test tried %d times, want 1 — backoff not honoured", calls["bad.test"]) |
| + } |
| + if calls["good.test"] != 2 { |
| + t.Errorf("good.test tried %d times, want 2 — a failing sibling must not stall it", calls["good.test"]) |
| + } |
| +} |
| + |
| +// New orders are capped per pass, and a capped pass comes back SOON so a |
| +// backlog drains in minutes rather than trickling out over days. Renewal |
| +// re-arms are never capped. |
| +func TestWarmCapsNewOrdersAndReturnsSoon(t *testing.T) { |
| + var hosts []string |
| + for i := 0; i < warmOrdersPerPass+5; i++ { |
| + hosts = append(hosts, string(rune('a'+i))+".customer.test") |
| + } |
| + calls := 0 |
| + w := newCertWarm( |
| + func() []string { return hosts }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| + func(string) bool { return true }, |
| + nil, |
| + ) |
| + d := w.pass() |
| + if calls != warmOrdersPerPass { |
| + t.Fatalf("placed %d orders, want the cap of %d", calls, warmOrdersPerPass) |
| + } |
| + if d != warmCappedDelay { |
| + t.Fatalf("capped pass returned %s, want the short %s so the backlog drains", d, warmCappedDelay) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index c08c3f022..bb5cbf6bc 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -47,6 +47,7 @@ import ( |
| "golang.org/x/crypto/acme/autocert" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -2415,12 +2416,18 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| if mirror != nil { |
| getCert = mirror.wrapGetCertificate(getCert) |
| } |
| + // The per-host path, captured before the wildcard wrap goes on top of it: |
| + // this is what the cert warm below calls. Calling the WRAPPED chain would |
| + // let the wildcard answer for a covered name and order nothing. |
| + perHostCert := getCert |
| // Wildcard certs (wildcard.go, ACME DNS-01): covered names — one label |
| // under a per-account or configured parent — are served the wildcard; |
| // everything else (custom domains, canary hosts, parents not yet |
| // obtained) stays on autocert's per-host HTTP-01 above. nil = not |
| // configured = exactly the pre-wildcard edge. |
| + var wm *wildcardManager |
| if w := newWildcardFromEnv(e, certCache); w != nil { |
| + wm = w |
| getCert = wrapGetCertificate(w, getCert) |
| e.OnReload(w.refresh) |
| attachWildcardToMirror(mirror, w) |
| @@ -2432,6 +2439,38 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)", |
| splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"), w.delegationZone) |
| } |
| + // Cert warm (#299 stage 3, certwarm.go): the platform ORDERS and RENEWS |
| + // custom domains' certs instead of waiting for a visitor's handshake. |
| + // |
| + // It calls perHostCert — the chain as it stood BEFORE the wildcard wrap — |
| + // because the warm's set already excludes every wildcard-covered name, |
| + // and a wrapped call would be answered by the wildcard and order nothing. |
| + // |
| + // Armed only with a deployment store: an agent-less `carlos edge` is |
| + // ungated (issuerGate nil means yes), and would otherwise start placing |
| + // proactive ACME orders on a hobbyist's box. |
| + if e.staticStore != nil { |
| + appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN"))) |
| + parents := func() []string { return nil } |
| + if wm != nil { |
| + parents = wm.currentParents |
| + } |
| + envIPs := splitDomains(os.Getenv("PLATFORM_EDGE_IPS")) |
| + store := e.staticStore |
| + edgeIPs := func() []string { |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + return edgesfile.EdgeIPs(ctx, store, envIPs, time.Now()) |
| + } |
| + warm := newCertWarm( |
| + func() []string { return e.warmSet(appsDomain, parents) }, |
| + perHostCert, |
| + dnsGate(warmResolver, edgeIPs), |
| + e.issuerGate, |
| + ) |
| + go warm.run() |
| + log.Printf("edge: cert warm on (%s pass) — custom domains are ordered and renewed proactively, not on first handshake", warmInterval) |
| + } |
| tlsCfg.GetCertificate = getCert |
| if mirror != nil { |
| // Started last: every field the loop reads (wake above) is in place. |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index aa675bfd8..4057747db 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -427,6 +427,38 @@ func (m *homeMap) Hosts() []string { |
| return out |
| } |
| +// ServedHosts lists every EXACT hostname any box in this deployment homes — |
| +// routes and attached custom domains alike, with no class filter. |
| +// |
| +// Deliberately NOT Hosts(): that one applies parentInput, which keeps only |
| +// platform-class rows because its consumer derives wildcard PARENTS from |
| +// them. The cert warm's question is the opposite one — "what exact names does |
| +// this deployment answer for?" — and a custom domain is precisely the |
| +// non-platform row Hosts() drops. Filtering by class here would warm every |
| +// canary and no customer domain, which is backwards. |
| +// |
| +// Catch-all "*." keys are excluded: they are covered by a wildcard PEM, and |
| +// autocert refuses the wildcard label anyway (Allowed). Only dial-able |
| +// entries count — a cert-only wildcard row homes nothing. |
| +// |
| +// An unlabelled box (self == "") reports nothing, as Home does: an edge that |
| +// cannot tell its own rows from another box's must not answer at all. |
| +func (m *homeMap) ServedHosts() []string { |
| + if m == nil || m.self == "" { |
| + return nil |
| + } |
| + snap := m.snapshot() |
| + out := make([]string, 0, len(snap.hosts)) |
| + for h, e := range snap.hosts { |
| + if !e.dial || strings.HasPrefix(h, "*.") { |
| + continue |
| + } |
| + out = append(out, h) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| // setHostsWake installs the callback a changed host set fires (Refresh). |
| // Under the map's own lock: run() is already polling when the wildcard |
| // manager is wired in after it. |
diff --git a/internal/edgesfile/edgesfile.go b/internal/edgesfile/edgesfile.go| new file mode 100644 |
| index 000000000..15623ecbd |
| --- /dev/null |
| +++ b/internal/edgesfile/edgesfile.go |
| @@ -0,0 +1,89 @@ |
| +// Package edgesfile READS the control/edges/<box>.json wire shape — the |
| +// per-box publication of what a box homes (multi-region ingress spec, Part |
| +// 3) — for consumers that need the deployment-wide picture rather than one |
| +// box's own view. |
| +// |
| +// The WRITER's shape lives in internal/host (edgesFile/edgeHost): the box |
| +// agent owns what it publishes, and this package deliberately does not try |
| +// to own it too. internal/host cannot host this reader — the console does |
| +// not import internal/host, and internal/host imports internal/edge and |
| +// never the reverse — so the reader lives one level down where the console, |
| +// internal/domains and the box agent can all reach it. |
| +// |
| +// The two shapes are joined by a test, not by a shared type: keeping them |
| +// separate lets the writer add a field and roll before every reader |
| +// understands it, which is the whole point of an additive wire. |
| +// |
| +// Two readers deliberately keep their own narrow decoders and are NOT |
| +// migrated: internal/edge/homemap.go (the serving hot path, with its own |
| +// polling and staleness machinery) and internal/steering (which reads no |
| +// Hosts at all — steering is any-edge-any-app). Both are named here so the |
| +// consumer list stays complete rather than merely short. |
| +package edgesfile |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| +) |
| + |
| +// Prefix is the flat prefix every box's file lives under. |
| +const Prefix = "control/edges/" |
| + |
| +// RoleHome is the role a box publishes for a host it actually serves. An |
| +// entry with any other role is either a cert-only role (see Host.Wildcard) |
| +// or a role a newer build publishes; readers must ignore what they do not |
| +// recognise rather than route on it or fail the parse. |
| +const RoleHome = "home" |
| + |
| +// Key is the bucket key for box's file. |
| +func Key(box string) string { return Prefix + box + ".json" } |
| + |
| +// Host is one per-host entry, decoded. Every mark is additive, and each |
| +// defaults to false on a file written before that mark existed — but they |
| +// fail in OPPOSITE directions, which is deliberate and load-bearing: |
| +// |
| +// - Platform absent reads as "not platform", so a pre-mark publisher's rows |
| +// derive no wildcard parent (#300 fails CLOSED). |
| +// - Alias absent reads as "not an alias", so a pre-mark publisher's rows |
| +// keep full route authority and cannot be outranked by a claimed alias |
| +// (#287 fails OPEN, which is why its readers must roll first). |
| +// |
| +// The writer always emits "platform" and omits the other two when false; |
| +// this reader does not care which, because absent and false decode alike. |
| +// What the reader must never do is re-serialize a Host and call it a |
| +// publication — that is the writer's job, and round-tripping through this |
| +// struct would drop any field a newer box publishes. |
| +type Host struct { |
| + Role string `json:"role"` |
| + Platform bool `json:"platform"` |
| + Alias bool `json:"alias"` |
| + // Wildcard marks a delegated customer wildcard parent (wildcard_domains, |
| + // #305): the key is "*.<parent>". With Role RoleHome the box also serves |
| + // the catch-all; with any other role it is the cert half alone. |
| + Wildcard bool `json:"wildcard"` |
| +} |
| + |
| +// File is one box's published file. |
| +type File struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + EIP string `json:"eip"` |
| + Region string `json:"region,omitempty"` |
| + Generation string `json:"generation,omitempty"` |
| + Hosts map[string]Host `json:"hosts"` |
| +} |
| + |
| +// Parse decodes one box's body. Pure — no store I/O. V < 1 (no version field |
| +// at all) is refused as not a file this reader understands; a HIGHER version |
| +// is accepted, because the schema grows additively and a newer box's file |
| +// still carries box/eip/hosts verbatim. |
| +func Parse(body []byte) (File, error) { |
| + var f File |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + return File{}, fmt.Errorf("edgesfile: parse: %w", err) |
| + } |
| + if f.V < 1 { |
| + return File{}, fmt.Errorf("edgesfile: unversioned edges file") |
| + } |
| + return f, nil |
| +} |
diff --git a/internal/edgesfile/edgesfile_test.go b/internal/edgesfile/edgesfile_test.go| new file mode 100644 |
| index 000000000..2504b0ed1 |
| --- /dev/null |
| +++ b/internal/edgesfile/edgesfile_test.go |
| @@ -0,0 +1,49 @@ |
| +package edgesfile |
| + |
| +import "testing" |
| + |
| +func TestParseReadsTheWireShape(t *testing.T) { |
| + f, err := Parse([]byte(`{"v":1,"box":"annie","eip":"1.1.1.1","region":"eu-west-1",` + |
| + `"generation":"2026-08-27T09:00:00Z","hosts":{"a.test":{"role":"home","platform":true},` + |
| + `"b.test":{"role":"home","alias":true},` + |
| + `"*.c.test":{"role":"wildcard","wildcard":true}}}`)) |
| + if err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + if f.Box != "annie" || f.EIP != "1.1.1.1" || f.Region != "eu-west-1" { |
| + t.Fatalf("identity fields = %+v", f) |
| + } |
| + if !f.Hosts["a.test"].Platform || f.Hosts["a.test"].Alias { |
| + t.Fatalf("a.test = %+v, want platform-class route", f.Hosts["a.test"]) |
| + } |
| + if !f.Hosts["b.test"].Alias || f.Hosts["b.test"].Platform { |
| + t.Fatalf("b.test = %+v, want a claim-derived alias", f.Hosts["b.test"]) |
| + } |
| + // A delegated wildcard parent (#305) decodes with its mark and its own |
| + // role. The reader must carry the role through verbatim rather than |
| + // normalising it: "wildcard" is the cert half alone, and a reader that |
| + // silently read it as RoleHome would dial a box that does not serve the |
| + // catch-all. |
| + if w := f.Hosts["*.c.test"]; !w.Wildcard || w.Role != "wildcard" { |
| + t.Fatalf("*.c.test = %+v, want a cert-only delegated parent", w) |
| + } |
| +} |
| + |
| +// V < 1 (no version field at all) is not a file this reader understands; a |
| +// HIGHER version is accepted, because the schema grows additively and a newer |
| +// box's file still carries box/eip/hosts verbatim. Mirrors the contract |
| +// internal/edge/homemap.go and internal/steering already hold. |
| +func TestParseRefusesUnversionedAndAcceptsNewer(t *testing.T) { |
| + if _, err := Parse([]byte(`{"box":"annie","eip":"1.1.1.1"}`)); err == nil { |
| + t.Fatal("an unversioned file must be refused") |
| + } |
| + if _, err := Parse([]byte(`{"v":9,"box":"annie","eip":"1.1.1.1"}`)); err != nil { |
| + t.Fatalf("a newer version must still parse: %v", err) |
| + } |
| +} |
| + |
| +func TestKeyIsThePrefixedBoxName(t *testing.T) { |
| + if got := Key("annie"); got != "control/edges/annie.json" { |
| + t.Fatalf("Key = %q", got) |
| + } |
| +} |
diff --git a/internal/edgesfile/fold.go b/internal/edgesfile/fold.go| new file mode 100644 |
| index 000000000..292313379 |
| --- /dev/null |
| +++ b/internal/edgesfile/fold.go |
| @@ -0,0 +1,181 @@ |
| +package edgesfile |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "slices" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// EdgeIPs is the deployment's live edge address set: every EIP published by a |
| +// box whose STATUS SNAPSHOT is still fresh, unioned with env (the operator's |
| +// PLATFORM_EDGE_IPS). |
| +// |
| +// The union is the whole failure story. Nothing here deletes anything, so the |
| +// fold can only WIDEN what counts as "pointing at us" — which means a short |
| +// fold, an unreadable store, or a deployment with no snapshots at all |
| +// degrades to exactly the pre-fold behaviour (env only) and never below it. |
| +// Contrast internal/steering, which fails CLOSED on a short fold because a |
| +// short fold THERE deletes DNS records. |
| +// |
| +// Liveness is the status snapshot, never the edges file's own Generation: |
| +// Generation moves only when the file is WRITTEN, and the writer's change |
| +// gate exists precisely so an unchanged file is never republished — so a |
| +// healthy box's Generation is arbitrarily old and would fail any age bound. |
| +// statusTick's WriteBox is unconditional every tick, so BoxStale is a real |
| +// liveness signal. Without one, a retired box's lingering edges file would |
| +// keep a DEAD customer domain reading "pointing" forever. |
| +// |
| +// LIMITATION, and the reason for the log line below: that join reads both |
| +// halves out of the ONE store it is handed, but the two halves are written |
| +// through different ones. Edges files go through openDeployStore |
| +// (CARLOS_DEPLOYMENT_BUCKET, unprefixed); box snapshots go through |
| +// openStatusStore, which honours CARLOS_STATUS_BUCKET and rebases everything |
| +// under CARLOS_STATUS_PREFIX. They are the same store by DEFAULT, not by |
| +// guarantee — README.md's fleet section suggests a separate status bucket |
| +// outright — and on a deployment that splits them nothing here can see the |
| +// other half, so every edges file is dropped as unproven and the answer is |
| +// the env list. That degradation is SAFE (env-only is exactly the |
| +// pre-fold behaviour) but it is INVISIBLE, which is the failure class this |
| +// whole rule exists to avoid. So when files were found and none of them |
| +// joined, say so — configuration is the likely cause and no test can catch |
| +// it. |
| +func EdgeIPs(ctx context.Context, s release.Store, env []string, now time.Time) []string { |
| + out := append([]string(nil), env...) |
| + add := func(ip string) { |
| + if ip != "" && !slices.Contains(out, ip) { |
| + out = append(out, ip) |
| + } |
| + } |
| + if s == nil { |
| + return out |
| + } |
| + live := map[string]bool{} |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + log.Printf("edgesfile: could not load box snapshots (%v) — edge set falls back to the configured list", err) |
| + return out |
| + } |
| + 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) — edge set falls back to the configured list", Prefix, err) |
| + return out |
| + } |
| + 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 edge |
| + } |
| + joined++ |
| + add(f.EIP) |
| + } |
| + // Files exist and not one of them proved live. A single retired box is |
| + // the ordinary reading of a partial miss, but a TOTAL miss usually is |
| + // not: either every box in the deployment went dark at once, or the two |
| + // halves of the join are looking at different buckets (see the doc |
| + // comment), or the box names on the two sides have drifted apart. All |
| + // three are silent otherwise — the answer is simply the env list, with |
| + // no error to return and nothing red anywhere. This line is the |
| + // tripwire. Nothing is logged when there are no files at all: a |
| + // deployment that has not published any 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 — edge set is the configured list only (are edges and status in different buckets? see CARLOS_STATUS_BUCKET/CARLOS_STATUS_PREFIX)", len(keys), Prefix) |
| + } |
| + 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| new file mode 100644 |
| index 000000000..4bbaa5995 |
| --- /dev/null |
| +++ b/internal/edgesfile/fold_test.go |
| @@ -0,0 +1,316 @@ |
| +package edgesfile |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "log" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// The fold can only WIDEN: a box the deployment can see adds its EIP, and the |
| +// operator's env list is always included. That is the safe direction — |
| +// nothing here deletes, and a short fold must degrade to today's behaviour |
| +// (env only), never below it. |
| +func TestEdgeIPsUnionsTheFoldWithTheEnvList(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":{}}`) |
| + putEdgesFile(t, s, "betty", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + putFreshBox(t, s, "betty", now) |
| + |
| + got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now) |
| + slices.Sort(got) |
| + want := []string{"1.1.1.1", "2.2.2.2", "9.9.9.9"} |
| + if !slices.Equal(got, want) { |
| + t.Fatalf("EdgeIPs = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// A box whose STATUS SNAPSHOT has gone stale drops out — that is what keeps a |
| +// retired box's lingering edges file from holding a dead customer domain at |
| +// "pointing" forever (a lying green). Generation is NOT the signal: an |
| +// unchanged file is never republished, so a healthy box's Generation is |
| +// arbitrarily old. |
| +func TestEdgeIPsDropsABoxWhoseSnapshotIsStale(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":{}}`) |
| + putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| + |
| + got := EdgeIPs(context.Background(), s, nil, now) |
| + if slices.Contains(got, "3.3.3.3") { |
| + t.Fatalf("EdgeIPs = %v, want the retired box's EIP dropped", got) |
| + } |
| + if !slices.Contains(got, "1.1.1.1") { |
| + t.Fatalf("EdgeIPs = %v, want the live box kept", got) |
| + } |
| +} |
| + |
| +// An edges file with no matching status snapshot at all is not evidence of |
| +// liveness. Same treatment as a stale one. |
| +func TestEdgeIPsDropsABoxWithNoSnapshot(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "ghost", `{"v":1,"box":"ghost","eip":"4.4.4.4","hosts":{}}`) |
| + if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want env only", got) |
| + } |
| +} |
| + |
| +// A store that cannot be listed degrades to the env list — never to empty. An |
| +// empty edge set would stamp every custom domain "elsewhere" at once. |
| +func TestEdgeIPsFallsBackToEnvWhenTheStoreFails(t *testing.T) { |
| + s := brokenStore{} |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the env list on a store failure", got) |
| + } |
| +} |
| + |
| +// The fixtures above call now "fresh" and now-72h "stale" in their own |
| +// words; this asserts the RULE agrees, so a change to status.SnapshotPeriod |
| +// or to BoxStale's factor turns this test red rather than quietly turning |
| +// the two tests above into assertions about nothing. |
| +func TestFoldFixtureAgreesWithTheStalenessRule(t *testing.T) { |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + if status.BoxStale(status.Box{GeneratedAt: now}, now, status.SnapshotPeriod) { |
| + t.Error("a snapshot written this instant must read as fresh") |
| + } |
| + if !status.BoxStale(status.Box{GeneratedAt: now.Add(-72 * time.Hour)}, now, status.SnapshotPeriod) { |
| + t.Error("a snapshot 72h old must read as stale — the retired-box fixture depends on it") |
| + } |
| +} |
| + |
| +// A box publishing no EIP at all (readPublicIP never answered) contributes |
| +// nothing rather than an empty string, which would match no resolution and |
| +// only pollute the guidance strings the console builds from the same set. |
| +func TestEdgeIPsSkipsABoxWithNoAddress(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":"","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + if got := EdgeIPs(context.Background(), s, nil, now); len(got) != 0 { |
| + t.Fatalf("EdgeIPs = %v, want nothing — a blank EIP is not an address", got) |
| + } |
| +} |
| + |
| +// ---- fixtures --------------------------------------------------------------- |
| + |
| +// foldTestStore is a real DirStore over a temp dir: the fold reads through |
| +// release.Store and status.LoadBoxes, and a hand-rolled fake would have to |
| +// re-implement both prefixes' listing semantics to say anything true. |
| +func foldTestStore(t *testing.T) release.Store { |
| + t.Helper() |
| + return release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +func putEdgesFile(t *testing.T, s release.Store, box, body string) { |
| + t.Helper() |
| + if _, err := s.Put(context.Background(), Key(box), []byte(body), ""); err != nil { |
| + t.Fatalf("put edges file for %s: %v", box, err) |
| + } |
| +} |
| + |
| +// putFreshBox writes one box snapshot through status.WriteBox — the SAME |
| +// writer statusTick uses — so the key derivation and the Label field the |
| +// fold joins on cannot drift from production. at is the snapshot's |
| +// GeneratedAt: the caller decides whether that reads as fresh or stale, and |
| +// TestFoldFixtureAgreesWithTheStalenessRule pins both readings. |
| +func putFreshBox(t *testing.T, s release.Store, label string, at time.Time) { |
| + t.Helper() |
| + if err := status.WriteBox(context.Background(), s, status.Box{Label: label, GeneratedAt: at}); err != nil { |
| + t.Fatalf("write box snapshot for %s: %v", label, err) |
| + } |
| +} |
| + |
| +// brokenStore is a store whose every read fails — the bucket-is-down case. |
| +type brokenStore struct{} |
| + |
| +var errBrokenStore = errors.New("store is down") |
| + |
| +func (brokenStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errBrokenStore |
| +} |
| +func (brokenStore) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", errBrokenStore |
| +} |
| +func (brokenStore) List(context.Context, string) ([]string, error) { return nil, errBrokenStore } |
| +func (brokenStore) ListDirs(context.Context, string) ([]string, error) { return nil, errBrokenStore } |
| +func (brokenStore) Delete(context.Context, string) error { return errBrokenStore } |
| + |
| +// Edges files exist and NONE of them proved live: the answer degrades to the |
| +// env list with no error and nothing red, so the log line is the only thing |
| +// an operator can see. The likeliest cause is configuration rather than a |
| +// dead fleet — edges files are written through openDeployStore while box |
| +// snapshots go through openStatusStore, which honours CARLOS_STATUS_BUCKET |
| +// and CARLOS_STATUS_PREFIX, so the two halves of this join are the same |
| +// store by default but not by guarantee. Operational, not contractual: what |
| +// is pinned is that the line appears at all and names where to look. |
| +func TestEdgeIPsSaysSoWhenNothingJoined(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":{}}`) |
| + putEdgesFile(t, s, "betty", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{}}`) |
| + // No snapshots at all — exactly what a split-bucket deployment looks |
| + // like from in here. |
| + if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want env only", 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) |
| + } |
| +} |
| + |
| +// A deployment that has published no edges files yet is an ordinary state, |
| +// not a fault — and so is a partial miss, where one retired box drops out and |
| +// the rest join. Neither may cry wolf, or the line stops being read. |
| +func TestEdgeIPsStaysQuietWhenThereIsNothingToJoinOrSomethingDid(t *testing.T) { |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + |
| + buf := captureLog(t) |
| + empty := foldTestStore(t) |
| + EdgeIPs(context.Background(), empty, []string{"9.9.9.9"}, now) |
| + if strings.Contains(buf.String(), "no fresh box snapshot matched") { |
| + t.Errorf("log = %q, want silence: no edges files is not a fault", buf.String()) |
| + } |
| + |
| + buf2 := captureLog(t) |
| + s := foldTestStore(t) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| + putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| + EdgeIPs(context.Background(), s, nil, now) |
| + if strings.Contains(buf2.String(), "no fresh box snapshot matched") { |
| + t.Errorf("log = %q, want silence: one box joined, which is the rule working", buf2.String()) |
| + } |
| +} |
| + |
| +// captureLog redirects the standard logger for one test — the same |
| +// save/restore-under-Cleanup shape internal/steering's tests use. |
| +func captureLog(t *testing.T) *bytes.Buffer { |
| + t.Helper() |
| + var buf bytes.Buffer |
| + prev, flags := log.Writer(), log.Flags() |
| + log.SetOutput(&buf) |
| + log.SetFlags(0) |
| + 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 3b347296c..7c9550ae7 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -126,8 +126,15 @@ |
| // shape = delegation off, per-host and direct DNS-01 continue |
| // - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks |
| // compare against. Default: this box's public IPv4 from IMDS |
| -// (best-effort; if neither is knowable, DNS stamping stands down |
| -// entirely rather than guess "elsewhere" about every host). |
| +// (best-effort). It is no longer the WHOLE comparison set: each pass |
| +// unions it with the deployment's LIVE edges — every box's published |
| +// control/edges/<box>.json whose status snapshot is still fresh |
| +// (edgesfile.EdgeIPs) — because under latency steering a correctly- |
| +// pointed domain resolves to whichever region's edge is nearest, and |
| +// one box's boot-frozen list reads that as "elsewhere". The union only |
| +// ever WIDENS, so an unreadable bucket degrades to exactly this |
| +// variable. A pass that ends up knowing no addresses at all stands |
| +// down rather than guessing "elsewhere" about every host. |
| package host |
| import ( |
| @@ -161,6 +168,7 @@ import ( |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/fleetapi" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -1310,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 { |
| @@ -1425,11 +1441,22 @@ func peekAcmeCache(args []string) string { |
| // domainsTickConfig builds one reconcile pass's Config from the environment |
| // — pure (getenv + the already-read public IP injected) so the whole env |
| // contract is testable without t.Setenv. PLATFORM_APPS_DOMAIN unset returns |
| -// the zero Config, on which Reconcile is inert by contract. When no edge IP |
| -// is knowable at all (no PLATFORM_EDGE_IPS, no IMDS answer), Resolve stays |
| -// nil so DNS stamping stands down: with nothing to compare a resolution |
| -// against, "pointing" vs "elsewhere" would be a guess, and unknowns are |
| -// never guessed. |
| +// the zero Config, on which Reconcile is inert by contract. |
| +// |
| +// The SECOND return is the parsed PLATFORM_EDGE_IPS list (with the public-IP |
| +// fallback applied). It is not a Config field: Config.EdgeIPs is now a func |
| +// answering the deployment's LIVE edge set, which needs a deployment store — |
| +// something this function must never touch if it is to stay pure and |
| +// getenv-injectable. domainsLoop folds the two together once its store is |
| +// open. A field only the agent's own wiring reads would be dead weight in |
| +// every other caller and every test fixture, hence a return value. |
| +// |
| +// Resolve is armed for every configured deployment now, no longer gated on |
| +// this box knowing an edge IP: a box with no PLATFORM_EDGE_IPS whose FOLD |
| +// supplies edges must still stamp. The stand-down when nothing is knowable |
| +// moved to where the set is actually known — domains.Reconcile's section 4, |
| +// per pass — because "no edge addresses" is now a per-pass fact, not a |
| +// boot-time one. |
| // |
| // The error return is ADVISORY, never fatal: it is non-nil only when |
| // CARLOS_RELEASE_PUBKEY was SET but would not parse, and it exists so the |
| @@ -1438,10 +1465,10 @@ func peekAcmeCache(args []string) string { |
| // error and a nil ReleasePub — the two are different operator problems and the |
| // one log line about them must not name the wrong one. The Config is fully |
| // usable either way; only the auto-purge is off. |
| -func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Config, error) { |
| +func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Config, []string, error) { |
| domain := strings.TrimSpace(getenv("PLATFORM_APPS_DOMAIN")) |
| if domain == "" { |
| - return domains.Config{}, nil |
| + return domains.Config{}, nil, nil |
| } |
| var ips []string |
| for _, ip := range strings.Split(getenv("PLATFORM_EDGE_IPS"), ",") { |
| @@ -1458,16 +1485,17 @@ func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Con |
| } |
| cfg := domains.Config{ |
| AppsDomain: domain, |
| - EdgeIPs: ips, |
| // The same default the edge's -acme-cache flag carries |
| // (edge.RunWithActivator): $CARLOS_DATA_DIR/acme. domainsLoop |
| // overrides this when the flag was given explicitly. |
| AcmeDir: filepath.Join(dd, "acme"), |
| Now: time.Now, |
| + Resolve: resolveHost, |
| } |
| - if len(ips) > 0 { |
| - cfg.Resolve = resolveHost |
| - } |
| + // EdgeIPs is deliberately left nil here — a Config out of this function |
| + // alone stamps nothing. domainsLoop installs the fold once it has a |
| + // store; see the doc comment. |
| + |
| // The root release public key, same source and same parser as adoptLoop's. |
| // Absence is TOLERATED and leaves ReleasePub nil, which switches the |
| // auto-purge off: a box that cannot verify an app's production flag must |
| @@ -1479,11 +1507,11 @@ func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Con |
| if raw := strings.TrimSpace(getenv("CARLOS_RELEASE_PUBKEY")); raw != "" { |
| pub, err := release.ParsePublicKey(raw, "CARLOS_RELEASE_PUBKEY") |
| if err != nil { |
| - return cfg, err |
| + return cfg, ips, err |
| } |
| cfg.ReleasePub = pub |
| } |
| - return cfg, nil |
| + return cfg, ips, nil |
| } |
| // provisionTickConfig builds one instances reconcile pass's Config from the |
| @@ -1625,7 +1653,7 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| return |
| } |
| - cfg, keyErr := domainsTickConfig(os.Getenv, readPublicIP()) |
| + cfg, envIPs, keyErr := domainsTickConfig(os.Getenv, readPublicIP()) |
| if cfg.AppsDomain == "" { |
| log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| return |
| @@ -1638,7 +1666,21 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| log.Printf("agent: domains loop disabled: %v", err) |
| return |
| } |
| - log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v — health stamps every tick; claims reconcile only while this box holds the housekeeper lease", cfg.AppsDomain, cfg.EdgeIPs) |
| + // The edge set is folded PER PASS, here rather than in |
| + // domainsTickConfig, because it needs the store this line above just |
| + // opened — and because a set read once at arm is the bug this replaces: |
| + // under latency steering a correctly-pointed customer domain resolves to |
| + // whichever region's edge is nearest, which a single box's boot-frozen |
| + // PLATFORM_EDGE_IPS reads as "elsewhere". envIPs stays in the union, so |
| + // a deployment whose bucket is unreadable degrades to exactly the old |
| + // behaviour and never below it. The bound matters: a hung store must |
| + // cost this pass 30s, not the loop forever. |
| + cfg.EdgeIPs = func() []string { |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + return edgesfile.EdgeIPs(ctx, store, envIPs, time.Now()) |
| + } |
| + log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, configured edge IPs %v (the live set is folded from every box's edges file each pass) — health stamps every tick; claims reconcile only while this box holds the housekeeper lease", cfg.AppsDomain, envIPs) |
| // One line, and it must name the REAL cause: a typo'd key and an absent |
| // one are different problems with different fixes, and this is the only |
| // place an operator learns that purging is not happening. |
| @@ -1661,8 +1703,11 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| // tick — after the duties so a host section 3 just minted is |
| // stamped in the same tick it appeared. Never on a fleet box: it |
| // never contends for the lease for the same reason (housekeeperNever), |
| - // and its EdgeIPs fall back to its own address, which would judge |
| - // every row "elsewhere". |
| + // and it publishes no edges file, so it has no standing to judge the |
| + // deployment's DNS. (The old reason — that its EdgeIPs fall back to |
| + // its own address — no longer holds now that the set is folded from |
| + // every box's file; the exclusion stands on the publication rule |
| + // instead, which is the durable one.) |
| runHousekeeperDuties(housekeeperGate, duty) |
| if !fleetBox { |
| if err := domains.StampHealth(ctx, reg, cfg, nil); err != nil { |
| @@ -1739,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 { |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index d59362e1d..11c140ffa 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -11,8 +11,10 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| func edgesTestRegistry(t *testing.T) *registry.Registry { |
| @@ -768,3 +770,41 @@ func TestEdgesFilePublishesDelegatedWildcardParents(t *testing.T) { |
| t.Errorf("route-owned key = %+v; want platform AND wildcard, role home", h) |
| } |
| } |
| + |
| +// The whole EIP fold rests on ONE identity: the `box` name this loop |
| +// publishes under and the `label` the status snapshot is written under must |
| +// be the same string, or edgesfile.EdgeIPs' liveness join drops every edge |
| +// silently and the deployment's edge set degrades to env-only forever — a |
| +// failure with no error, no log line and no red test. |
| +// |
| +// They are the same today because both resolve through metricsBoxLabel over |
| +// the same machine id: edgesFileLoop calls it directly, and statusTick's |
| +// snapshot label is status.Label(Probes.MachineID), which is exactly what |
| +// metricsBoxLabel falls through to off a fleet box. (A fleet box never gets |
| +// here: edgesFileLoop returns early on shouldSkipPublisherElection.) This |
| +// test is the tripwire on that, end to end through the real fold. |
| +func TestEdgesFileBoxNameJoinsTheStatusSnapshotLabel(t *testing.T) { |
| + const machineID = "ip-10-0-0-7" |
| + notAFleetBox := func(string) string { return "" } |
| + |
| + box := metricsBoxLabel(notAFleetBox, machineID) |
| + snapshot := status.CollectBox(nil, status.Probes{MachineID: machineID}, time.Now()) |
| + if box != snapshot.Label { |
| + t.Fatalf("edges file box %q vs status snapshot label %q — the fold's liveness join needs one name", box, snapshot.Label) |
| + } |
| + |
| + ctx := context.Background() |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + if err := edgesFilePass(ctx, s, reg, box, "203.0.113.7", "eu-west-1", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, s, snapshot); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := edgesfile.EdgeIPs(ctx, s, nil, time.Now()) |
| + if len(got) != 1 || got[0] != "203.0.113.7" { |
| + t.Fatalf("fold = %v, want this box's own EIP — the two writers disagree about its name", got) |
| + } |
| +} |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 60194ca52..2be92e3c5 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -607,19 +607,30 @@ func TestWrapListeningProbeNullsUnitBackedSockets(t *testing.T) { |
| // TestDomainsTickConfig pins the env contract: PLATFORM_APPS_DOMAIN unset |
| // means a zero Config (Reconcile is inert on it — the feature does not |
| // exist); set means every seam is populated, with PLATFORM_EDGE_IPS parsed |
| -// comma-separated and the box's public IP as the fallback. When NO edge IP |
| -// is known at all, Resolve stays nil: with nothing to compare against, |
| -// "pointing" vs "elsewhere" would be a guess, and unknowns are never guessed. |
| +// comma-separated and the box's public IP as the fallback. |
| +// |
| +// That parsed list is a RETURN VALUE, not a Config field, and this test |
| +// asserts it there. Config.EdgeIPs is now a func answering the deployment's |
| +// LIVE edge set — every box's published edges file folded against its status |
| +// snapshot — which needs a deployment store, and this builder must stay pure |
| +// and getenv-injectable. domainsLoop unions the two once its store is open, |
| +// so what is pinned here is exactly the ENV half of that union. |
| +// |
| +// It follows that this builder never populates EdgeIPs at all, and that |
| +// Resolve no longer waits on this box knowing an edge address: a box with no |
| +// PLATFORM_EDGE_IPS whose fold supplies edges must still stamp. The |
| +// stand-down when nothing is knowable moved to domains.Reconcile's section 4, |
| +// where the set is actually known — see TestReconcileStandsDownWithNoEdgeIPs. |
| func TestDomainsTickConfig(t *testing.T) { |
| env := func(m map[string]string) func(string) string { |
| return func(k string) string { return m[k] } |
| } |
| - if got, err := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) || err != nil { |
| - t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, err = %v, want zero, nil", got, err) |
| + if got, envIPs, err := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) || envIPs != nil || err != nil { |
| + t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, envIPs = %v, err = %v, want zero, nil, nil", got, envIPs, err) |
| } |
| - got, err := domainsTickConfig(env(map[string]string{ |
| + got, envIPs, err := domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "PLATFORM_EDGE_IPS": "203.0.113.7, 198.51.100.9", |
| "CARLOS_DATA_DIR": "/custom", |
| @@ -630,8 +641,11 @@ func TestDomainsTickConfig(t *testing.T) { |
| if got.AppsDomain != "oncarlos.test" { |
| t.Errorf("AppsDomain = %q", got.AppsDomain) |
| } |
| - if !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| - t.Errorf("EdgeIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", got.EdgeIPs) |
| + if !reflect.DeepEqual(envIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Errorf("envIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", envIPs) |
| + } |
| + if got.EdgeIPs != nil { |
| + t.Error("EdgeIPs must be left nil here: only domainsLoop, which has the store, can answer the live set") |
| } |
| if got.AcmeDir != filepath.Join("/custom", "acme") { |
| t.Errorf("AcmeDir = %q, want the edge's cache dir under CARLOS_DATA_DIR", got.AcmeDir) |
| @@ -641,19 +655,23 @@ func TestDomainsTickConfig(t *testing.T) { |
| } |
| // No explicit edge IPs: the box's own public IP (IMDS) is the default. |
| - got, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9") |
| - if !reflect.DeepEqual(got.EdgeIPs, []string{"9.9.9.9"}) { |
| - t.Errorf("EdgeIPs = %v, want the public-IP fallback", got.EdgeIPs) |
| + got, envIPs, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9") |
| + if !reflect.DeepEqual(envIPs, []string{"9.9.9.9"}) { |
| + t.Errorf("envIPs = %v, want the public-IP fallback", envIPs) |
| } |
| if got.AcmeDir != filepath.Join("/data", "acme") { |
| t.Errorf("AcmeDir = %q, want the /data default", got.AcmeDir) |
| } |
| - // No edge IPs knowable at all: DNS stamping stands down (Resolve nil), |
| - // but minting still runs — the domain still works, only health is unknown. |
| - got, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "") |
| - if got.EdgeIPs != nil || got.Resolve != nil { |
| - t.Errorf("no known edge IPs: EdgeIPs = %v Resolve nil? %v — stamping must stand down, not guess", got.EdgeIPs, got.Resolve == nil) |
| + // No edge IP knowable from the ENVIRONMENT at all. The env half is empty |
| + // — but Resolve is still armed, because the fold may well know edges this |
| + // box's variables never named. Minting runs regardless. |
| + got, envIPs, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "") |
| + if envIPs != nil { |
| + t.Errorf("envIPs = %v, want nothing: neither PLATFORM_EDGE_IPS nor IMDS answered", envIPs) |
| + } |
| + if got.Resolve == nil { |
| + t.Error("Resolve must stay armed with no env edge IPs — the fold, not this box's variables, decides whether a pass can stamp") |
| } |
| if got.AppsDomain != "oncarlos.test" { |
| t.Error("minting must stay configured even without edge IPs") |
| @@ -674,7 +692,7 @@ func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - got, err := domainsTickConfig(env(map[string]string{ |
| + got, _, err := domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "CARLOS_RELEASE_PUBKEY": pubB64, |
| }), "9.9.9.9") |
| @@ -695,7 +713,7 @@ func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| // (nil, error). domainsLoop logs the error verbatim, which is the one line |
| // an operator reads when purging silently is not happening. |
| for _, key := range []string{"", " "} { |
| - got, err = domainsTickConfig(env(map[string]string{ |
| + got, _, err = domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "CARLOS_RELEASE_PUBKEY": key, |
| }), "9.9.9.9") |
| @@ -704,7 +722,7 @@ func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| } |
| } |
| for _, key := range []string{"not-base64!!", base64.StdEncoding.EncodeToString([]byte("short"))} { |
| - got, err = domainsTickConfig(env(map[string]string{ |
| + got, _, err = domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "CARLOS_RELEASE_PUBKEY": key, |
| }), "9.9.9.9") |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index cf20091c7..a78cf5873 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -201,9 +201,16 @@ type BoxMetrics struct { |
| // hiding; DNSStatus/CertExpiresAt omit when unstamped ("" / 0), same |
| // unknown-stays-absent convention as every other Metrics field. |
| type AliasReading struct { |
| - Route string `json:"route"` |
| - DNSStatus string `json:"dns_status,omitempty"` |
| - CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| + Route string `json:"route"` |
| + DNSStatus string `json:"dns_status,omitempty"` |
| + // DNSCheckedAt is when this box last RESOLVED the hostname (registry |
| + // dns_checked_at). Carried because a stamp without its age cannot be |
| + // judged: a box that has stopped stamping keeps publishing fresh |
| + // SNAPSHOTS full of frozen readings, and BoxStale — which measures the |
| + // snapshot, not the stamp — cannot see it. Omitempty: unstamped stays |
| + // absent, never a zero that reads as 1970. |
| + DNSCheckedAt int64 `json:"dns_checked_at,omitempty"` |
| + CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| } |
| // RouteCounts is aggregate-only: no host, no socket path, just how many. |
| @@ -384,6 +391,12 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| // box's Metrics attachment check below sees exactly "nothing to report" |
| // rather than a present-but-empty map. |
| var routeVersions map[string]string |
| + // aliasReadings is issue #151's Metrics.Aliases source. Declared here, |
| + // above the route loop, so the route loop below (a first-route claim's |
| + // ROUTE readings) and the p.Aliases loop after it (genuine alias rows) |
| + // both fold into the same map — nil (not an empty map) until the first |
| + // entry, same "nothing to report" convention as routeVersions. |
| + var aliasReadings map[string]AliasReading |
| for _, r := range routes { |
| // Platform-minted routes (internal/domains) are additional NAMES for |
| @@ -403,6 +416,22 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| } |
| routeVersions[r.Host] = r.Version |
| } |
| + // Non-platform routes carry hostname readings too. A first-route |
| + // claim (claims.go: "an empty route means this host is its own |
| + // primary") materializes a ROUTE, not an alias row, so without this |
| + // the domains surface can never take it past "pending" — which is |
| + // exactly what a static app deployed to its own custom domain has |
| + // always shown. Route is the host itself, which is what the claim's |
| + // empty Route field means. |
| + if r.DNSStatus != "" || r.CertExpiresAt != 0 { |
| + if aliasReadings == nil { |
| + aliasReadings = map[string]AliasReading{} |
| + } |
| + aliasReadings[r.Host] = AliasReading{ |
| + Route: r.Host, DNSStatus: r.DNSStatus, |
| + DNSCheckedAt: r.DNSCheckedAt, CertExpiresAt: r.CertExpiresAt, |
| + } |
| + } |
| switch { |
| case r.Kind == "static": |
| counts.Static++ |
| @@ -426,18 +455,16 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| counts.Resident++ |
| } |
| } |
| - // aliasReadings is issue #151's Metrics.Aliases source, built once here |
| - // (not a second pass) from p.Aliases — nil (not an empty map) until the |
| - // first entry, same "nothing to report" convention as routeVersions |
| - // above, and for the identical reason: an all-alias-less box's Metrics |
| - // attachment check below must see exactly "nothing to report". |
| - var aliasReadings map[string]AliasReading |
| + // p.Aliases (genuine route_aliases rows) runs AFTER the route loop above, |
| + // so a real alias wins its own key over the route-loop's fallback entry |
| + // for the same host. |
| for _, a := range p.Aliases { |
| if aliasReadings == nil { |
| aliasReadings = map[string]AliasReading{} |
| } |
| aliasReadings[a.Alias] = AliasReading{ |
| - Route: a.Host, DNSStatus: a.DNSStatus, CertExpiresAt: a.CertExpiresAt, |
| + Route: a.Host, DNSStatus: a.DNSStatus, |
| + DNSCheckedAt: a.DNSCheckedAt, CertExpiresAt: a.CertExpiresAt, |
| } |
| } |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 03ae43667..8f153f9dd 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -1229,3 +1229,52 @@ func TestBoxStaleMatchesFleetBoxStale(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A first-route claim materializes a ROUTE, not an alias. Without its stamps |
| +// on the wire the console can never take it past "pending" — which is what |
| +// `carlos deploy -kind static -host <site>` has always shown. |
| +func TestCollectBoxCarriesRouteDNSStampsForNonPlatformRoutes(t *testing.T) { |
| + routes := []registry.Route{{ |
| + Host: "story.customer.com", Status: "ready", App: "site", |
| + DNSStatus: "pointing", DNSCheckedAt: 1756300000, CertExpiresAt: 1764000000, |
| + }} |
| + box := status.CollectBox(routes, status.Probes{}, time.Unix(1756300060, 0)) |
| + |
| + got, ok := box.Metrics.Aliases["story.customer.com"] |
| + if !ok { |
| + t.Fatal("a claim-backed route must publish a reading") |
| + } |
| + if got.DNSStatus != "pointing" || got.CertExpiresAt != 1764000000 { |
| + t.Fatalf("reading = %+v", got) |
| + } |
| + if got.Route != "story.customer.com" { |
| + t.Fatalf("Route = %q, want the host as its own primary (an empty claim route means exactly that)", got.Route) |
| + } |
| + if got.DNSCheckedAt != 1756300000 { |
| + t.Fatalf("DNSCheckedAt = %d, want the stamp's own age carried", got.DNSCheckedAt) |
| + } |
| +} |
| + |
| +// Platform routes are the deployment's own hosts, not custom domains, and |
| +// they already have RouteVersions. Keep the map to what the domains surface |
| +// actually joins against. |
| +func TestCollectBoxOmitsPlatformRoutesFromReadings(t *testing.T) { |
| + routes := []registry.Route{ |
| + {Host: "app.bdk.oncarlos.com", Status: "ready", Platform: true, DNSStatus: "pointing"}, |
| + // A custom domain alongside it, so Metrics attaches at all: Box.Metrics |
| + // is a POINTER and stays nil when nothing is known (snapshot.go:485-510). |
| + // Without this row the assertion below would nil-panic rather than |
| + // testing anything. |
| + {Host: "shop.customer.com", Status: "ready", DNSStatus: "pointing", DNSCheckedAt: 1756300000}, |
| + } |
| + box := status.CollectBox(routes, status.Probes{}, time.Unix(1756300060, 0)) |
| + if box.Metrics == nil { |
| + t.Fatal("Metrics must attach once a custom domain has a reading") |
| + } |
| + if _, ok := box.Metrics.Aliases["app.bdk.oncarlos.com"]; ok { |
| + t.Fatal("a platform route must not appear in the hostname readings") |
| + } |
| + if _, ok := box.Metrics.Aliases["shop.customer.com"]; !ok { |
| + t.Fatal("the custom domain must appear") |
| + } |
| +} |