console: wildcard/catchall capability door with CNAME delegation gate
handleAPIDomainAttach gains optional wildcard/catchall *bool fields
(absent = leave unchanged). Enabling wildcard requires a configured
CARLOS_ACME_DELEGATION_ZONE and a live _acme-challenge CNAME
delegating to it; disabling wildcard skips the CNAME gate and
cascades catchall off in the same call. Capability writes go through
domains.SetClaimCapabilities exclusively — the Claim handed to
AttachClaim's create path has Wildcard/Catchall zeroed, closing the
Task 1 review's authority hole where a smuggled body field could
otherwise bypass the gate. ErrHostUnavailable on the capability path
reuses the door's byte-identical 404 (http.NotFound), never a
distinguishable shape. List rows gain wildcard, catchall and a live
delegation_status ("delegated"/"missing") stamp from the same CNAME
check, omitted when the zone is unset or wildcard is off.
Server gains delegationZone (from Config.DelegationZone, normalized
at construction) and a lookupCNAME seam defaulting to net.LookupCNAME,
independent of PLATFORM_APPS_DOMAIN. cmd/console/main.go reads
CARLOS_ACME_DELEGATION_ZONE, the same variable the edge already reads
for delegated wildcard certs.
OWES: #165's exported domains/index.json writer isn't on main yet, so
a capability flip doesn't reach the fast-path claims index — flagged
at the call site for #165 to wire in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+435
−12
cmd/console/main.go+6 −0internal/console/apidomains.go+140 −1internal/console/apidomains_test.go+269 −10internal/console/server.go+20 −1
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 6d22a8ecb..d3317ac1f 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -251,6 +251,12 @@ func serve() { |
| StoreProvisioner: storeProvisioner, |
| StoreDeployment: storeDeployment, |
| StoreOperators: storeOperators(), |
| + // CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads |
| + // (internal/edge/wildcard.go's newWildcardFromEnv) for the same |
| + // delegated-ACME story; NewServer normalizes it (lowercase, trim). |
| + // Deliberately independent of PLATFORM_APPS_DOMAIN (#167's blind |
| + // spot), so it is read raw here rather than folded into appsDomain(). |
| + DelegationZone: os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index ca4ad69d9..38c6938e7 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -28,6 +28,7 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| "log" |
| "net/http" |
| "net/url" |
| @@ -147,6 +148,15 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| var in struct { |
| Host string `json:"host"` |
| Route string `json:"route"` |
| + // Wildcard and Catchall are the woodstar-carloku capability flips |
| + // (Task 5), decoded as pointers so an ABSENT field means "leave |
| + // unchanged" — the same tri-state rule apps place's -target and |
| + // instances' converge flags already live by. nil never reaches |
| + // SetClaimCapabilities: setDomainCapabilities below reads the |
| + // claim's current values first and only overrides the one(s) the |
| + // caller actually named. |
| + Wildcard *bool `json:"wildcard,omitempty"` |
| + Catchall *bool `json:"catchall,omitempty"` |
| } |
| if !readJSON(w, r, &in) { |
| return |
| @@ -176,6 +186,14 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // UTC, like every other timestamp written to the bucket: these claims |
| // are read on boxes in other regions. |
| CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| + // Wildcard and Catchall are deliberately NOT populated from `in` |
| + // here, even though the request body may carry them: AttachClaim's |
| + // create path marshals this literal verbatim, so any value placed on |
| + // it lands with no CNAME gate and no delegation-zone check. The only |
| + // legal writer of these two fields is SetClaimCapabilities, called |
| + // below, AFTER this write has already established ownership (a |
| + // review finding on Task 1 — an attach body smuggling |
| + // "wildcard":true alongside host/route must not reach this literal). |
| }) |
| if errors.Is(err, domains.ErrHostUnavailable) { |
| // Exactly these bytes, whoever holds it: naming the holder — or even |
| @@ -204,9 +222,110 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // for a write that failed would send every box looking for nothing. |
| s.bumpSerial(ctx) |
| } |
| + // Capability flip: only when the caller actually named one of the two |
| + // fields — a bare attach (both nil) must never touch Wildcard/Catchall, |
| + // the same "absent means unchanged" contract the CLI's fs.Visit-gated |
| + // flags rely on. |
| + if in.Wildcard != nil || in.Catchall != nil { |
| + if !s.setDomainCapabilities(w, r, ctx, acct, app, principal, host, in.Wildcard, in.Catchall) { |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created}) |
| } |
| +// setDomainCapabilities applies an attach body's optional wildcard/catchall |
| +// flip AFTER the underlying claim attach above has already succeeded and |
| +// established ownership. ok=false means a response has already been |
| +// written — the same resolveAliasRoute convention this file already uses. |
| +// |
| +// Absent fields mean "leave unchanged": the door reads the claim's CURRENT |
| +// capabilities as the baseline and overrides only the field(s) the caller |
| +// actually named, so a caller flipping catchall alone cannot accidentally |
| +// clear an already-enabled wildcard, and vice versa. |
| +func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app, principal, host string, wantWildcard, wantCatchall *bool) (ok bool) { |
| + cur, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| + if err != nil { |
| + log.Printf("console: api domains capabilities %s/%s %s: read after attach: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not update the hostname's capabilities — try again") |
| + return false |
| + } |
| + wildcard, catchall := cur.Wildcard, cur.Catchall |
| + if wantWildcard != nil { |
| + wildcard = *wantWildcard |
| + } |
| + if wantCatchall != nil { |
| + catchall = *wantCatchall |
| + } |
| + switch { |
| + case wantWildcard != nil && !*wantWildcard: |
| + // Disabling wildcard cascades catchall off in the SAME call — |
| + // turning off never requires DNS, and a caller disabling wildcard is |
| + // not asking for a 422. The door normalizes here rather than calling |
| + // SetClaimCapabilities with catchall still true and letting its own |
| + // ErrCatchallNeedsWildcard refuse a request that was never asking to |
| + // enable anything. |
| + catchall = false |
| + case wantWildcard != nil && *wantWildcard: |
| + // Enabling wildcard requires a configured delegation zone and a live |
| + // _acme-challenge CNAME delegating to it — the claim-derived- |
| + // wildcards spec's ACME story (§4): this deployment's own zone |
| + // issues the wildcard cert, so it must control the challenge record. |
| + if s.delegationZone == "" { |
| + jsonError(w, http.StatusUnprocessableEntity, "deployment has no delegation zone configured") |
| + return false |
| + } |
| + if !s.delegationCNAMEDelegated(host) { |
| + jsonError(w, http.StatusUnprocessableEntity, |
| + "create CNAME _acme-challenge."+host+" -> _acme-challenge."+host+"."+s.delegationZone+", then retry") |
| + return false |
| + } |
| + } |
| + capChanged, err := domains.SetClaimCapabilities(ctx, s.cfg.Releases, host, acct.Sqid, app, wildcard, catchall) |
| + if errors.Is(err, domains.ErrCatchallNeedsWildcard) { |
| + jsonError(w, http.StatusUnprocessableEntity, err.Error()) |
| + return false |
| + } else if errors.Is(err, domains.ErrHostUnavailable) { |
| + // The family's byte-identical 404 discipline, copied verbatim (not |
| + // jsonError — a distinct shape here would itself be a tell). This |
| + // fires only after the attach above already proved ownership, so in |
| + // practice it means the claim changed hands between that write and |
| + // this one — vanishingly rare, but the response must still leak |
| + // nothing to whoever is now asking. |
| + http.NotFound(w, r) |
| + return false |
| + } else if err != nil { |
| + log.Printf("console: api domains capabilities %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not update the hostname's capabilities — try again") |
| + return false |
| + } |
| + if capChanged { |
| + s.audit(acct, app, principal, "domain-capabilities", |
| + fmt.Sprintf("set %s wildcard=%v catchall=%v (cli)", host, wildcard, catchall)) |
| + s.bumpSerial(ctx) |
| + // #165's exported index-writer is called here once it lands — a |
| + // capability flip must reach the fast-path claims index (see plan: |
| + // claim-derived-wildcards, Task 5 addendum). |
| + } |
| + return true |
| +} |
| + |
| +// delegationCNAMEDelegated reports whether host's _acme-challenge CNAME |
| +// already points at this deployment's delegation zone — the same check the |
| +// attach gate and the list stamp both use, so a claim's delegation_status |
| +// can never disagree with what an attach would decide right now. Both sides |
| +// are trailing-dot-normalized before comparing: net.LookupCNAME answers with |
| +// a fully-qualified name (trailing dot), a hand-typed zone rarely carries |
| +// one, and the comparison must not care which. |
| +func (s *Server) delegationCNAMEDelegated(host string) bool { |
| + got, err := s.lookupCNAME("_acme-challenge." + host) |
| + if err != nil { |
| + return false |
| + } |
| + want := "_acme-challenge." + host + "." + s.delegationZone |
| + return strings.TrimSuffix(got, ".") == strings.TrimSuffix(want, ".") |
| +} |
| + |
| // handleAPIDomainDetach releases a hostname. Always 200, whatever was there: |
| // `removed` reports whether this call is what removed it, and the three ways |
| // of removing nothing — already gone, never claimed, claimed by someone else |
| @@ -256,6 +375,18 @@ type domainListItem struct { |
| Status string `json:"status"` |
| DNSStatus string `json:"dns_status,omitempty"` |
| CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| + // Wildcard and Catchall (Task 5) copy the claim's own capability bits |
| + // verbatim — always present, true or false, the same "known state" |
| + // convention Created/Removed use on the attach/detach answers (never |
| + // omitted the way an unstamped reading is). |
| + Wildcard bool `json:"wildcard"` |
| + Catchall bool `json:"catchall"` |
| + // DelegationStatus stamps "delegated"/"missing" from the SAME CNAME |
| + // check the attach gate uses, live on every list call — never cached |
| + // from whenever wildcard was enabled. Omitted (not "unknown") when the |
| + // 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"` |
| } |
| // handleAPIDomainList answers `carlos domains list`: this app's claims, each |
| @@ -285,12 +416,20 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| if c.Account != acct.Sqid || c.App != app { |
| continue |
| } |
| - item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending"} |
| + item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending", |
| + Wildcard: c.Wildcard, Catchall: c.Catchall} |
| if reading, ok := readings[c.Host]; ok { |
| item.Status = "live" |
| item.DNSStatus = reading.DNSStatus |
| item.CertExpiresAt = reading.CertExpiresAt |
| } |
| + if c.Wildcard && s.delegationZone != "" { |
| + if s.delegationCNAMEDelegated(c.Host) { |
| + item.DelegationStatus = "delegated" |
| + } else { |
| + item.DelegationStatus = "missing" |
| + } |
| + } |
| out = append(out, item) |
| } |
| // Host-sorted, because the Store.List contract does not promise an order |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index b5462cc23..ddf3645dc 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -54,11 +54,14 @@ type domainDetachResp struct { |
| type domainListResp struct { |
| Domains []struct { |
| - Host string `json:"host"` |
| - Route string `json:"route"` |
| - Status string `json:"status"` |
| - DNSStatus string `json:"dns_status"` |
| - CertExpiresAt int64 `json:"cert_expires_at"` |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Status string `json:"status"` |
| + DNSStatus string `json:"dns_status"` |
| + CertExpiresAt int64 `json:"cert_expires_at"` |
| + Wildcard bool `json:"wildcard"` |
| + Catchall bool `json:"catchall"` |
| + DelegationStatus string `json:"delegation_status"` |
| } `json:"domains"` |
| } |
| @@ -378,11 +381,14 @@ func TestAPIDomainList(t *testing.T) { |
| t.Fatalf("list = %+v, want exactly this app's three claims", got.Domains) |
| } |
| byHost := map[string]struct { |
| - Host string `json:"host"` |
| - Route string `json:"route"` |
| - Status string `json:"status"` |
| - DNSStatus string `json:"dns_status"` |
| - CertExpiresAt int64 `json:"cert_expires_at"` |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Status string `json:"status"` |
| + DNSStatus string `json:"dns_status"` |
| + CertExpiresAt int64 `json:"cert_expires_at"` |
| + Wildcard bool `json:"wildcard"` |
| + Catchall bool `json:"catchall"` |
| + DelegationStatus string `json:"delegation_status"` |
| }{} |
| for _, d := range got.Domains { |
| byHost[d.Host] = d |
| @@ -478,3 +484,256 @@ func TestAPIDomainSerialBump(t *testing.T) { |
| t.Fatalf("audit rows after a no-op detach = %d, want it unmoved at 3", got) |
| } |
| } |
| + |
| +// ---- wildcard/catchall capability door (Task 5) ---------------------------- |
| + |
| +// TestAttachWildcardCNAMEGate: the attach body's optional wildcard/catchall |
| +// pointers (Task 5). Enabling wildcard is gated on two things — a configured |
| +// delegation zone, and a live _acme-challenge CNAME delegating to it — and |
| +// catchall is refused without wildcard, the same ErrCatchallNeedsWildcard |
| +// domains.SetClaimCapabilities itself enforces. Every refusal here leaves the |
| +// stored claim exactly as it was. |
| +func TestAttachWildcardCNAMEGate(t *testing.T) { |
| + const zone = "acme.oncarlos.com" |
| + const host = "woodstar.app" |
| + wantCNAME := "_acme-challenge." + host + "." + zone + "." |
| + |
| + t.Run("missing CNAME refuses with the record to create", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-missing") |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("attach with a missing CNAME = %d %s, want 422", code, out) |
| + } |
| + // Decoded, not a raw substring match: json.Encoder HTML-escapes ">" |
| + // to > by default, which json.Unmarshal undoes transparently |
| + // but a literal strings.Contains against the raw wire bytes would not. |
| + var got struct { |
| + Error string `json:"error"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("422 body %s: %v", out, err) |
| + } |
| + want := "create CNAME _acme-challenge." + host + " -> _acme-challenge." + host + "." + zone + ", then retry" |
| + if got.Error != want { |
| + t.Fatalf("422 error = %q, want %q", got.Error, want) |
| + } |
| + if c := claimOf(t, fx, host); c.Wildcard { |
| + t.Fatalf("claim after a refused wildcard enable = %+v, want Wildcard still false", *c) |
| + } |
| + }) |
| + |
| + t.Run("correct CNAME enables wildcard and bumps the serial", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-ok") |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(name string) (string, error) { |
| + if name == "_acme-challenge."+host { |
| + return wantCNAME, nil |
| + } |
| + return "", errors.New("not found") |
| + } |
| + before := serial(t, fx) |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach with a correct CNAME = %d %s, want 200", code, out) |
| + } |
| + if c := claimOf(t, fx, host); !c.Wildcard { |
| + t.Fatalf("claim after an accepted wildcard enable = %+v, want Wildcard true", *c) |
| + } |
| + if got := serial(t, fx); got <= before { |
| + t.Fatalf("serial after a wildcard enable = %d, want it to have moved past %d", got, before) |
| + } |
| + }) |
| + |
| + t.Run("catchall without wildcard refuses and stores nothing", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-catchall") |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","catchall":true}`) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("attach with catchall and no wildcard = %d %s, want 422", code, out) |
| + } |
| + if !strings.Contains(out, "catchall requires wildcard") { |
| + t.Fatalf("422 body = %s, want it to mention catchall requires wildcard", out) |
| + } |
| + if c := claimOf(t, fx, host); c.Wildcard || c.Catchall { |
| + t.Fatalf("claim after a refused catchall = %+v, want both false", *c) |
| + } |
| + }) |
| + |
| + t.Run("wildcard and catchall together with a correct CNAME store both", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-both") |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(name string) (string, error) { |
| + if name == "_acme-challenge."+host { |
| + return wantCNAME, nil |
| + } |
| + return "", errors.New("not found") |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true,"catchall":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach with wildcard+catchall and a correct CNAME = %d %s, want 200", code, out) |
| + } |
| + c := claimOf(t, fx, host) |
| + if !c.Wildcard || !c.Catchall { |
| + t.Fatalf("claim = %+v, want both wildcard and catchall true", *c) |
| + } |
| + }) |
| + |
| + t.Run("non-owner token gets the byte-identical 404", func(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-dom-wc-nonowner") |
| + fx.srv.delegationZone = zone |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := canonicalNotFound(t) |
| + code, h, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), foreign, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + assertNotFound(t, "foreign token attach with wildcard", want, code, h, out) |
| + claimAbsent(t, fx, host) |
| + }) |
| + |
| + // The zone-unset case doubles as the Task 1 review's authority-hole |
| + // regression: the body smuggles BOTH wildcard and catchall alongside |
| + // host/route, in the very attach call that creates the claim. If the |
| + // handler ever passed those fields straight into AttachClaim's create |
| + // path (which marshals its Claim argument verbatim), they would land |
| + // even though the capability step below refuses. They must not: the |
| + // only writer of Wildcard/Catchall is SetClaimCapabilities, called AFTER |
| + // the create, and gated on the zone. |
| + t.Run("zone unset refuses, and smuggled capability fields never reach the claim", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-nozone") |
| + // fx.srv.delegationZone is left at its zero value "" — no |
| + // CARLOS_ACME_DELEGATION_ZONE configured for this fixture. |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true,"catchall":true}`) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("attach with wildcard and no zone = %d %s, want 422", code, out) |
| + } |
| + if !strings.Contains(out, "no delegation zone") { |
| + t.Fatalf("422 body = %s, want it to mention no delegation zone", out) |
| + } |
| + if c := claimOf(t, fx, host); c.Wildcard || c.Catchall { |
| + t.Fatalf("claim after a refused capability request = %+v, want both false — the smuggled fields must not reach AttachClaim's create path", *c) |
| + } |
| + }) |
| +} |
| + |
| +// TestAttachWildcardDisableCascadesCatchallOff: turning wildcard off never |
| +// requires DNS (the CNAME gate is skipped entirely — proven here by breaking |
| +// the stub before the disable call), and catchall — which cannot legally |
| +// outlive wildcard — cascades off in the SAME call rather than making the |
| +// caller send catchall=false too, or the door routing itself into |
| +// ErrCatchallNeedsWildcard on its own converge. |
| +func TestAttachWildcardDisableCascadesCatchallOff(t *testing.T) { |
| + const zone = "acme.oncarlos.com" |
| + const host = "wild.example.com" |
| + fx, member := instanceFixture(t, "code-dom-wc-disable") |
| + fx.srv.delegationZone = zone |
| + wantCNAME := "_acme-challenge." + host + "." + zone + "." |
| + fx.srv.lookupCNAME = func(name string) (string, error) { |
| + if name == "_acme-challenge."+host { |
| + return wantCNAME, nil |
| + } |
| + return "", errors.New("not found") |
| + } |
| + attachDomain(t, fx, member, host, "go.tito.io") |
| + if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, host, "bab", "titogo", true, true); err != nil || !changed { |
| + t.Fatalf("seed SetClaimCapabilities(wildcard,catchall) = changed=%v err=%v", changed, err) |
| + } |
| + // Break the stub: a disable call that still consulted it would now fail |
| + // the CNAME check and 422 — proving the disable path skips it entirely. |
| + fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":false}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("disabling wildcard = %d %s, want 200 (no CNAME check on the way off)", code, out) |
| + } |
| + c := claimOf(t, fx, host) |
| + if c.Wildcard || c.Catchall { |
| + t.Fatalf("claim after disabling wildcard = %+v, want both false (catchall cascades off)", *c) |
| + } |
| +} |
| + |
| +// TestDomainsListDelegationStamp: a claim with Wildcard stamps |
| +// delegation_status from the same CNAME check the attach gate uses — |
| +// "delegated" or "missing", live-read on every list call (not cached from |
| +// whenever wildcard was enabled). A claim without Wildcard never carries the |
| +// field at all. |
| +func TestDomainsListDelegationStamp(t *testing.T) { |
| + const zone = "acme.oncarlos.com" |
| + fx, member := instanceFixture(t, "code-dom-list-deleg") |
| + fx.srv.delegationZone = zone |
| + const wildHost = "wild.example.com" |
| + const plainHost = "plain.example.com" |
| + wantCNAME := "_acme-challenge." + wildHost + "." + zone + "." |
| + fx.srv.lookupCNAME = func(name string) (string, error) { |
| + if name == "_acme-challenge."+wildHost { |
| + return wantCNAME, nil |
| + } |
| + return "", errors.New("not found") |
| + } |
| + attachDomain(t, fx, member, wildHost, "go.tito.io") |
| + if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, wildHost, "bab", "titogo", true, false); err != nil || !changed { |
| + t.Fatalf("seed SetClaimCapabilities(%s, wildcard) = changed=%v err=%v", wildHost, changed, err) |
| + } |
| + attachDomain(t, fx, member, plainHost, "go.tito.io") |
| + |
| + list := func(t *testing.T) domainListResp { |
| + t.Helper() |
| + 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 domainListResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + return got |
| + } |
| + byHost := func(got domainListResp) map[string]struct { |
| + Wildcard bool |
| + Catchall bool |
| + DelegationStatus string |
| + } { |
| + m := map[string]struct { |
| + Wildcard bool |
| + Catchall bool |
| + DelegationStatus string |
| + }{} |
| + for _, d := range got.Domains { |
| + m[d.Host] = struct { |
| + Wildcard bool |
| + Catchall bool |
| + DelegationStatus string |
| + }{d.Wildcard, d.Catchall, d.DelegationStatus} |
| + } |
| + return m |
| + } |
| + |
| + m := byHost(list(t)) |
| + if row := m[wildHost]; !row.Wildcard || row.DelegationStatus != "delegated" { |
| + t.Fatalf("%s row = %+v, want wildcard true and delegated", wildHost, row) |
| + } |
| + if row := m[plainHost]; row.Wildcard || row.DelegationStatus != "" { |
| + t.Fatalf("%s row = %+v, want wildcard false and delegation_status absent", plainHost, row) |
| + } |
| + |
| + // Break the CNAME stub: the same claim now reads "missing" — the stamp is |
| + // a live read, not something cached from when wildcard was enabled. |
| + fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + m = byHost(list(t)) |
| + if row := m[wildHost]; !row.Wildcard || row.DelegationStatus != "missing" { |
| + t.Fatalf("%s row after breaking the CNAME = %+v, want wildcard true and missing", wildHost, row) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index f89334711..0ea9a58bd 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -248,6 +248,16 @@ type Config struct { |
| // behind the typed bucket name and a passkey — it spends no money and |
| // destroys only that account's own data. |
| StoreOperators []string |
| + // DelegationZone is CARLOS_ACME_DELEGATION_ZONE — this deployment's own |
| + // zone that a wildcard-enabled claim's _acme-challenge CNAME must |
| + // delegate to (the edge's newWildcardFromEnv reads the same variable for |
| + // the same reason: claim-derived-wildcards spec §4). Deliberately |
| + // independent of AppsDomain — #167's blind spot was conflating the two. |
| + // "" is a legal deployment: wildcard enable then refuses every request |
| + // with "deployment has no delegation zone configured" rather than |
| + // guessing. Normalized (lowercase, trimmed) into Server.delegationZone at |
| + // construction, same as RedirectHosts is normalized into `redirects`. |
| + DelegationZone string |
| } |
| // Server is the console's http.Handler. |
| @@ -293,6 +303,13 @@ type Server struct { |
| // cliNotified: without this a test racing to fill shipSem would have no |
| // way to know the earlier requests actually hold it yet. |
| shipStarted chan struct{} |
| + // delegationZone and lookupCNAME are the wildcard-capability door's two |
| + // seams (apidomains.go). delegationZone is cfg.DelegationZone, |
| + // normalized once here; lookupCNAME defaults to net.LookupCNAME and |
| + // tests stub it directly on a constructed Server, the same way they |
| + // override fx.srv.cfg.DenyDomains post-construction. |
| + delegationZone string |
| + lookupCNAME func(host string) (string, error) |
| } |
| // NewServer wires a server over an opened Store, loading (or minting) the |
| @@ -345,7 +362,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // human running `carlos ship` by hand, tight enough that a fleet of |
| // slow-loris uploads against a stolen or shared token can't pile up |
| // goroutines and buffers without bound. |
| - shipSem: make(chan struct{}, 2), |
| + shipSem: make(chan struct{}, 2), |
| + delegationZone: strings.ToLower(strings.TrimSpace(cfg.DelegationZone)), |
| + lookupCNAME: net.LookupCNAME, |
| } |
| if s.km != nil { |
| s.sendApp = s.km.SendApp |