carlos / platform Public

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>
Paul Campbell 2472b543a7b3bd4ba9d95fb8396a713a14a68d49 parent 5909ce8
4 files changed, +435 −12
  • cmd/console/main.go +6 −0
  • internal/console/apidomains.go +140 −1
  • internal/console/apidomains_test.go +269 −10
  • internal/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