console: a per-host domains read, so a refresh is not a deployment-wide scan
The list door cannot carry a refresh loop. ListClaims is one List plus one
Get per claim in the WHOLE deployment, filtered by account and app
afterwards in memory — so a caller refreshing one hostname pays for every
custom domain anyone has attached. That is fine today and degrades for
everyone as a deployment fills up.
GET /api/cli/apps/{account}/{app}/domains/{host} reads exactly one object
by key and returns the same derived row the list returns. That is what
makes an on-demand refresh, or a light per-host re-check, defensible
rather than something to warn callers off.
Both doors now build the row through one function. They answer the same
question about the same hostname and must not derive it two ways: a
consumer that refreshes one host and compares it against a listed row
would otherwise see a mirrored status flap for reasons that have nothing
to do with the domain.
Also adds checked_at to the wire. The box has always stamped when it last
resolved a hostname, but that stamp stopped at the status snapshot and
never reached the domains answer. It is what makes a mirrored state
ageable: "live" is not monotonic — a customer can change their DNS after
the fact — so a consumer holding a mirror needs to know how old the
verdict is, not just what it was.
A host this app does not own answers 404, byte-identical to a host nobody
has claimed. The claims keyspace is GLOBAL (the key IS the cross-account
uniqueness gate), so distinguishing "somebody else holds this" from
"unclaimed" would turn this door into an existence oracle over every
hostname in the deployment — leaking exactly what AttachClaim's neutral
refusal protects. The first cut got this wrong: absent claims come back
os.ErrNotExist-wrapped and were answering 502 where a wrong-owner host
answered 404. The test caught it on its first run and now reproduces it
when the fix is reverted.
The instance role reaches this door deliberately, and it is recorded as
NEW access rather than preserved legacy reach: no app token could reach
it before service principals, so listing it in the legacy pin would make
that map lie about what compatibility means.
Refs #299.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed,
+246
−29
internal/console/apidomains.go+111 −29internal/console/apidomains_test.go+121 −0internal/console/authcli.go+4 −0internal/console/svcroutes_test.go+10 −0
CI passed — run details
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 15d0a2830..071846791 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -40,6 +40,7 @@ import ( |
| "log" |
| "net/http" |
| "net/url" |
| + "os" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -591,6 +592,13 @@ type domainListItem struct { |
| State string `json:"state"` |
| Reason string `json:"reason,omitempty"` |
| Detail string `json:"detail,omitempty"` |
| + // CheckedAt is when the reporting box last RESOLVED this hostname (unix |
| + // seconds), carried through from the box stamp. It is what makes a |
| + // mirrored state ageable: without it a consumer cannot tell a state |
| + // confirmed a minute ago from one confirmed before an outage began, and |
| + // "live" is not monotonic — a customer can change their DNS after the |
| + // fact. Omitted when no box has stamped a check yet. |
| + CheckedAt int64 `json:"checked_at,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. |
| @@ -639,43 +647,19 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| out := make([]domainListItem, 0, len(mine)) |
| lookups := 0 |
| for _, c := range mine { |
| - item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending", |
| - Wildcard: c.Wildcard, Catchall: c.Catchall} |
| - reading, seen := readings[c.Host] |
| - if seen { |
| - item.Status = "live" |
| - item.DNSStatus = reading.DNSStatus |
| - item.CertExpiresAt = reading.CertExpiresAt |
| - } |
| - 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 |
| + // Each delegation stamp is a live, sequential, uncancellable-per-call |
| + // DNS round trip on the request path (lookupCNAME) — capped at |
| // domainListCNAMELookupCap per list call so an app with dozens of |
| // wildcard-enabled hostnames cannot turn one `carlos domains list` |
| // into dozens of resolver round trips. Hosts beyond the cap (in the |
| // same host-sorted order the answer itself uses) simply omit |
| // delegation_status — the same "nothing to report yet" reading a |
| // disabled wildcard or an unset zone already gets, never an error. |
| - if c.Wildcard && s.delegationZone != "" && lookups < domainListCNAMELookupCap { |
| + delegation := c.Wildcard && s.delegationZone != "" && lookups < domainListCNAMELookupCap |
| + if delegation { |
| lookups++ |
| - if s.delegationCNAMEDelegated(ctx, c.Host) { |
| - item.DelegationStatus = "delegated" |
| - } else { |
| - item.DelegationStatus = "missing" |
| - } |
| } |
| - out = append(out, item) |
| + out = append(out, s.domainItem(ctx, c, readings, targets, now, delegation)) |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "domains": out, |
| @@ -686,6 +670,104 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| }) |
| } |
| +// domainItem builds ONE claim's answer row: the claim joined to what the |
| +// fleet reports for it, plus the derived state, guidance and records. |
| +// |
| +// Shared by the list door and the per-host door deliberately. The two answer |
| +// the same question about the same hostname and must never derive it two |
| +// ways — a consumer that refreshes one host and compares it against a listed |
| +// row has to see the same verdict, or a mirrored status flaps for reasons |
| +// that have nothing to do with the domain. |
| +// |
| +// delegation says whether to spend a live CNAME lookup on this row. The list |
| +// door caps how many it will spend per call; the per-host door always can, |
| +// because it is answering about exactly one host. |
| +func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[string]status.AliasReading, targets DNSTargets, now time.Time, delegation bool) domainListItem { |
| + item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending", |
| + Wildcard: c.Wildcard, Catchall: c.Catchall} |
| + reading, seen := readings[c.Host] |
| + if seen { |
| + item.Status = "live" |
| + item.DNSStatus = reading.DNSStatus |
| + item.CertExpiresAt = reading.CertExpiresAt |
| + item.CheckedAt = reading.DNSCheckedAt |
| + } |
| + 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) |
| + } |
| + if delegation { |
| + if s.delegationCNAMEDelegated(ctx, c.Host) { |
| + item.DelegationStatus = "delegated" |
| + } else { |
| + item.DelegationStatus = "missing" |
| + } |
| + } |
| + return item |
| +} |
| + |
| +// handleAPIDomainGet answers ONE hostname — the per-host read (#299 §2e). |
| +// |
| +// It exists because the list door cannot carry a refresh loop: ListClaims is |
| +// one List plus one Get per claim in the WHOLE deployment, filtered by |
| +// account and app afterwards, so a caller refreshing one hostname pays for |
| +// every custom domain anyone has attached. This reads exactly one object by |
| +// key, which is what makes an on-demand refresh — or a light per-host |
| +// re-check — defensible rather than something to warn callers off. |
| +// |
| +// A host this app does not own answers 404, byte-identical to a host nobody |
| +// has claimed. That is deliberate: the claims keyspace is GLOBAL (the key is |
| +// the cross-account uniqueness gate), so distinguishing "someone else holds |
| +// this" from "unclaimed" would turn this door into an existence oracle over |
| +// every hostname in the deployment. AttachClaim already refuses in the same |
| +// neutral way, and this must not be the door that leaks what that one |
| +// protects. |
| +func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + // Shape-checked BEFORE the key builder, exactly as the detach door does: |
| + // a {host} segment arrives percent-decoded, so "..%2f..%2finstances.json" |
| + // would reach ClaimKey as a path with separators in it. There is no path |
| + // through this handler on which an unnormalized host reaches the store. |
| + host, ok := domains.NormalizeAliasHost(r.PathValue("host")) |
| + if !ok { |
| + jsonError(w, http.StatusBadRequest, "invalid hostname") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + c, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| + // An ABSENT claim is not an error here, and collapsing it into the same |
| + // 404 the wrong-owner case gets is the whole isolation property. Left as |
| + // a 502, "nobody has claimed this" would be distinguishable from |
| + // "somebody else holds it" — which is precisely the existence oracle the |
| + // neutral 404 exists to prevent. Only a real read failure is a 502. |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: api domain get %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the hostname") |
| + return |
| + } |
| + if err != nil || c == nil || c.Account != acct.Sqid || c.App != app { |
| + jsonError(w, http.StatusNotFound, "no such hostname") |
| + return |
| + } |
| + now := time.Now() |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "domain": s.domainItem(ctx, *c, aliasReadings(ctx, s.cfg.Releases, now), s.dnsTargets(), now, c.Wildcard && s.delegationZone != ""), |
| + "deployment": s.domainCapability(), |
| + }) |
| +} |
| + |
| // domainListCNAMELookupCap bounds how many live CNAME lookups one |
| // `carlos domains list` call may perform (IMPORTANT-2, Task 5 review): the |
| // stamp is a real DNS round trip per wildcard-enabled hostname, sequential |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index aa6771e0c..f3cffd366 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -1573,3 +1573,124 @@ func TestAliasReadingsPrefersTheFreshestStamp(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// domainGetURL is the per-host read's URL (#299 §2e). |
| +func domainGetURL(fx *consoleFixture, acct, app, host string) string { |
| + return domainsURL(fx, acct, app) + "/" + host |
| +} |
| + |
| +// The per-host door answers the SAME verdict as the list door for the same |
| +// hostname. It exists to be cheap, not to be different — a consumer that |
| +// refreshes one host and compares it against a listed row must not see a |
| +// mirrored status flap for reasons that have nothing to do with the domain. |
| +func TestAPIDomainGetAgreesWithTheListRow(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-get") |
| + ctx := context.Background() |
| + now := time.Now() |
| + attachDomain(t, fx, member, "live.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{ |
| + "live.example.com": {Route: "go.tito.io", DNSStatus: "pointing", |
| + CertExpiresAt: now.Add(24 * time.Hour).Unix(), DNSCheckedAt: 1786000123}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "live.example.com"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("get = %d %s, want 200", code, out) |
| + } |
| + var got struct { |
| + Domain struct { |
| + Host string `json:"host"` |
| + State string `json:"state"` |
| + Reason string `json:"reason"` |
| + CheckedAt int64 `json:"checked_at"` |
| + } `json:"domain"` |
| + Deployment struct { |
| + ManagedDomains bool `json:"managed_domains"` |
| + } `json:"deployment"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("get body %s: %v", out, err) |
| + } |
| + if got.Domain.Host != "live.example.com" || got.Domain.State != StateLive { |
| + t.Fatalf("domain = %+v, want live.example.com in state %q", got.Domain, StateLive) |
| + } |
| + // checked_at is what makes a mirrored state ageable. Without it a |
| + // consumer cannot tell a verdict confirmed a minute ago from one |
| + // confirmed before an outage began, and "live" is not monotonic. |
| + if got.Domain.CheckedAt != 1786000123 { |
| + t.Errorf("checked_at = %d, want the box's own stamp", got.Domain.CheckedAt) |
| + } |
| + // The capability block rides on this answer too, so a client refreshing |
| + // one host never has to call the list door just to learn the deployment |
| + // still manages domains. |
| + if !got.Deployment.ManagedDomains { |
| + t.Error("the per-host answer carries no capability block") |
| + } |
| + |
| + // And the same host, listed, agrees. |
| + _, _, listOut := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + var list struct { |
| + Domains []struct { |
| + Host string `json:"host"` |
| + State string `json:"state"` |
| + CheckedAt int64 `json:"checked_at"` |
| + } `json:"domains"` |
| + } |
| + if err := json.Unmarshal([]byte(listOut), &list); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list.Domains) != 1 { |
| + t.Fatalf("list = %+v", list.Domains) |
| + } |
| + if list.Domains[0].State != got.Domain.State || list.Domains[0].CheckedAt != got.Domain.CheckedAt { |
| + t.Fatalf("list row %+v disagrees with the per-host answer %+v", list.Domains[0], got.Domain) |
| + } |
| +} |
| + |
| +// THE tenant-isolation property. The claims keyspace is GLOBAL — the key is |
| +// the cross-account uniqueness gate — so a host another account holds must be |
| +// indistinguishable from a host nobody has claimed. Otherwise this door |
| +// becomes an existence oracle over every hostname in the deployment, leaking |
| +// exactly what AttachClaim's neutral refusal protects. |
| +func TestAPIDomainGetCannotDistinguishAnotherOwnerFromUnclaimed(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-get-iso") |
| + ctx := context.Background() |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: "taken.example.com", Account: "bab", App: "other", |
| + Route: "other.go.tito.io", CreatedAt: now.UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + takenCode, _, takenBody := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "taken.example.com"), member, "") |
| + freeCode, _, freeBody := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "nobody.example.com"), member, "") |
| + |
| + if takenCode != http.StatusNotFound || freeCode != http.StatusNotFound { |
| + t.Fatalf("taken = %d, unclaimed = %d; both must be 404", takenCode, freeCode) |
| + } |
| + if takenBody != freeBody { |
| + t.Fatalf("a host another account holds answers %q but an unclaimed one answers %q — that difference IS the oracle", takenBody, freeBody) |
| + } |
| +} |
| + |
| +// A {host} segment arrives percent-decoded, so a traversal attempt reaches |
| +// the handler as a path with separators in it. It must be refused on SHAPE, |
| +// before anything builds a bucket key from it. |
| +func TestAPIDomainGetRefusesATraversalHost(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-get-trav") |
| + for _, bad := range []string{"..%2f..%2finstances.json", "not a host", "%2e%2e%2fclaims"} { |
| + code, _, _ := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo")+"/"+bad, member, "") |
| + if code == http.StatusOK { |
| + t.Errorf("%q answered 200", bad) |
| + } |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 026f3f9ea..4c66718d2 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -604,6 +604,10 @@ func (s *Server) registerCLIRoutes() { |
| // Same for logs, above. Pinned by |
| // TestInstanceRoleMatchesTodaysAppTokenReach; spec §5. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainList) |
| + // The per-host read (#299): same roles as the list it narrows — it |
| + // discloses strictly less (one hostname the caller already owns), so it |
| + // cannot need a role the list does not. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach) |
| // The config API (apiconfig.go) — `carlos env` and `carlos secrets` |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index b8b548fbf..c7ab9d85a 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -205,8 +205,18 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) { |
| // map lie about what LEGACY reach is — an app token could not reach this |
| // door before service principals existed, so it is new access granted |
| // deliberately, not compatibility being preserved. |
| +// |
| +// GET .../domains/{host} is the second, and for the same reason. It is the |
| +// per-host read (#299 §2e) and it did not exist before service principals, |
| +// so no app token has ever reached it and listing it in `want` would make |
| +// that map lie about what LEGACY reach is. The instance role reaches it |
| +// DELIBERATELY: an app's router refreshes one hostname's managed state with |
| +// its CARLOS_INSTANCE_TOKEN, which is the entire reason the door was added. |
| +// It discloses strictly less than the list door beside it — one hostname the |
| +// caller already owns — so it cannot need a role that list does not have. |
| var exemptFromLegacyPin = map[string]bool{ |
| "GET /api/cli/apps": true, |
| + "GET /api/cli/apps/{account}/{app}/domains/{host}": true, |
| } |
| // TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec |