Merge pull request #328 from carlosframework/domains-per-host-door
console: a per-host domains read, so a refresh is not a deployment-wide scan (#299)
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 |