carlos / platform Public

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)
Paul Campbell pushed by paul@keymail.dev c135cccc7783e7a8bcb8c283eaa70222bc276fee parent fd4b8f4parent ee329ad
4 files changed, +246 −29
  • internal/console/apidomains.go +111 −29
  • internal/console/apidomains_test.go +121 −0
  • internal/console/authcli.go +4 −0
  • internal/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