carlos / platform Public

Merge pull request #325 from carlosframework/custom-domains-managed

domains: managed custom-domain connections — one state, structured records, capability discovery, and platform-provisioned certs (#299)
Paul Campbell pushed by paul@keymail.dev 039c0645484de24823ec1f794f1f0aa9bd19514f parent 72ed99eparent 10187f0
36 files changed, +4344 −193
  • apimode.go +28 −0
  • deploy.go +88 −2
  • deploy_test.go +40 −0
  • docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md +703 −0
  • domains_cli.go +69 −2
  • domains_cli_test.go +64 −1
  • domains_e2e_test.go +5 −2
  • e2e_test.go +1 −1
  • internal/console/apidomains.go +140 −11
  • internal/console/apidomains_test.go +291 −0
  • internal/console/appdomains_test.go +83 −0
  • internal/console/domainattach.go +25 −1
  • internal/console/domainstate.go +206 −0
  • internal/console/domainstate_test.go +210 −0
  • internal/console/edgefold_test.go +119 −0
  • internal/console/server.go +107 −1
  • internal/console/server_test.go +7 −1
  • internal/console/view.go +13 −5
  • internal/domains/aliases.go +34 −1
  • internal/domains/aliases_test.go +155 −16
  • internal/domains/reconcile.go +162 −82
  • internal/domains/reconcile_internal_test.go +106 −4
  • internal/domains/reconcile_test.go +107 −13
  • internal/edge/certwarm.go +376 −0
  • internal/edge/certwarm_test.go +269 −0
  • internal/edge/edge.go +39 −0
  • internal/edge/homemap.go +32 −0
  • internal/edgesfile/edgesfile.go +89 −0
  • internal/edgesfile/edgesfile_test.go +49 −0
  • internal/edgesfile/fold.go +181 −0
  • internal/edgesfile/fold_test.go +316 −0
  • internal/host/agent.go +67 −21
  • internal/host/edgesfile_test.go +40 −0
  • internal/host/host_test.go +37 −19
  • internal/status/snapshot.go +37 −10
  • internal/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 424b8792d..781b9cb81 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"
@@ -691,6 +693,45 @@ type Server struct {
// TopicArn, which is not a secret, triggers a fresh outbound HTTPS fetch
// and full chain verification. Zero value is ready to use.
snsCerts snsCertCache
+ // 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
@@ -699,7 +740,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")
+ }
+}