carlos / platform Public

console: fix review findings on wildcard capability door

Four Importants from Task 5 review:

- CNAME comparison was case-sensitive; DNS names compare
  case-insensitively (RFC 4343) and a resolver answers in the record's
  own authored case, so a customer's mixed-case CNAME target read as a
  permanently broken delegation. Fixed with strings.EqualFold.

- lookupCNAME gains a context.Context parameter (net.DefaultResolver.
  LookupCNAME, the ctx-first idiom internal/edge/wildcard.go already
  uses), and the attach-path gate now fires only on the false->true
  TRANSITION — re-sending wildcard:true for an already-wildcard claim
  (titogo's router re-attaches every hostname on each reload pass) no
  longer re-runs the lookup, so a resolver blip can't 422 an idempotent
  re-attach. The list path caps live CNAME lookups at 8 per call
  (lexicographic host order, claimed rows filtered and sorted before
  the loop so the cap picks the same 8 hosts every time); rows beyond
  the cap omit delegation_status, same as an unset zone.

- setDomainCapabilities's ErrHostUnavailable->404 branch is now
  exercised directly: a lookupCNAME stub that DetachClaims the claim as
  a side effect, then returns the correct CNAME, drives
  SetClaimCapabilities into os.ErrNotExist -> ErrHostUnavailable,
  asserted against the fixture's canonicalNotFound byte-identity check.

- CARLOS_ACME_DELEGATION_ZONE is now shape-validated at Server
  construction (provision.ValidHostShape, mirroring the edge's own
  validation of the same variable) via a new normalizeDelegationZone
  helper; an invalid zone degrades to "" (fails closed into the
  existing "no delegation zone configured" 422) rather than being
  handed to lookupCNAME verbatim.

Covering tests: internal/console -run TestAttachWildcard*|TestDomainsList*
plus the new TestNormalizeDelegationZone and
TestDelegationZoneInvalidAtConstruction, all green; full gate
(go build/vet/test ./... all 22 packages, gofmt, GOOS=darwin) clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3e42910b51b4ae70f50966b2c59b37705b458473 parent 6e06314
3 files changed, +332 −31
  • internal/console/apidomains.go +55 −16
  • internal/console/apidomains_test.go +248 −8
  • internal/console/server.go +29 −7
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 38c6938e7..4e82f297e 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -257,6 +257,14 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
if wantCatchall != nil {
catchall = *wantCatchall
}
+ // enabling is the false->true TRANSITION only — re-sending wildcard:true
+ // for a claim that already has it must not re-run the gate. Two reasons:
+ // the CNAME lookup is a live, sequential, request-path DNS round trip
+ // (IMPORTANT-2, Task 5 review), and titogo's router re-attaches every
+ // hostname (with whatever capability flags it was minted with) on every
+ // reload pass — gating on the transition is what keeps a resolver blip
+ // from 422ing an otherwise idempotent, already-successful re-attach.
+ enabling := wantWildcard != nil && *wantWildcard && !cur.Wildcard
switch {
case wantWildcard != nil && !*wantWildcard:
// Disabling wildcard cascades catchall off in the SAME call —
@@ -266,7 +274,7 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
// ErrCatchallNeedsWildcard refuse a request that was never asking to
// enable anything.
catchall = false
- case wantWildcard != nil && *wantWildcard:
+ case enabling:
// 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
@@ -275,7 +283,7 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
jsonError(w, http.StatusUnprocessableEntity, "deployment has no delegation zone configured")
return false
}
- if !s.delegationCNAMEDelegated(host) {
+ if !s.delegationCNAMEDelegated(ctx, host) {
jsonError(w, http.StatusUnprocessableEntity,
"create CNAME _acme-challenge."+host+" -> _acme-challenge."+host+"."+s.delegationZone+", then retry")
return false
@@ -314,16 +322,21 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
// 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)
+// are trailing-dot-normalized before comparing: net.DefaultResolver's
+// LookupCNAME answers with a fully-qualified name (trailing dot), a hand-
+// typed zone rarely carries one, and the comparison must not care which.
+// The comparison is also case-INsensitive (strings.EqualFold): DNS names
+// compare case-insensitively by definition (RFC 4343), and a resolver
+// answers in the record's own authored case — a customer who typed their
+// CNAME target in mixed case must not read as a permanently broken
+// delegation.
+func (s *Server) delegationCNAMEDelegated(ctx context.Context, host string) bool {
+ got, err := s.lookupCNAME(ctx, "_acme-challenge."+host)
if err != nil {
return false
}
want := "_acme-challenge." + host + "." + s.delegationZone
- return strings.TrimSuffix(got, ".") == strings.TrimSuffix(want, ".")
+ return strings.EqualFold(strings.TrimSuffix(got, "."), strings.TrimSuffix(want, "."))
}
// handleAPIDomainDetach releases a hostname. Always 200, whatever was there:
@@ -411,11 +424,22 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
return
}
readings := aliasReadings(ctx, s.cfg.Releases, time.Now())
- out := make([]domainListItem, 0, len(claims))
+ // This app's claims, host-sorted BEFORE the loop below — not just for
+ // the answer's own stable order (the Store.List contract makes no
+ // promise, and a terminal listing that shuffles between calls reads as
+ // churn), but because the sort order also decides which claims fall
+ // inside domainListCNAMELookupCap: sorting after the loop would let the
+ // store's arbitrary iteration order pick a different 8 hosts every call.
+ var mine []domains.Claim
for _, c := range claims {
- if c.Account != acct.Sqid || c.App != app {
- continue
+ if c.Account == acct.Sqid && c.App == app {
+ mine = append(mine, c)
}
+ }
+ sort.Slice(mine, func(i, j int) bool { return mine[i].Host < mine[j].Host })
+ 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}
if reading, ok := readings[c.Host]; ok {
@@ -423,8 +447,17 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
item.DNSStatus = reading.DNSStatus
item.CertExpiresAt = reading.CertExpiresAt
}
- if c.Wildcard && s.delegationZone != "" {
- if s.delegationCNAMEDelegated(c.Host) {
+ // Each 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 {
+ lookups++
+ if s.delegationCNAMEDelegated(ctx, c.Host) {
item.DelegationStatus = "delegated"
} else {
item.DelegationStatus = "missing"
@@ -432,12 +465,18 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
}
out = append(out, item)
}
- // Host-sorted, because the Store.List contract does not promise an order
- // and a terminal listing that shuffles between calls reads as churn.
- sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
writeJSON(w, http.StatusOK, map[string]any{"domains": out})
}
+// 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
+// and on the request path, so an unbounded loop turns a listing into a
+// resolver-latency multiplier. Claims beyond the cap (in host-sorted order)
+// list with delegation_status simply omitted, exactly like an unset zone —
+// never an error, never a partial/misleading stamp.
+const domainListCNAMELookupCap = 8
+
// aliasReadings builds a hostname -> reading map from every NON-STALE box's
// own private Metrics.Aliases (status/boxes/*.json), through status.LoadBoxes
// — the same loader and the same staleness rule stagedVersions uses
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index ddf3645dc..29173035a 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -14,15 +14,20 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"net/http"
+ "net/http/httptest"
"net/url"
"os"
+ "path/filepath"
+ "sort"
"strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
)
@@ -501,7 +506,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) {
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") }
+ fx.srv.lookupCNAME = func(context.Context, 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 {
@@ -528,7 +533,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) {
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) {
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
if name == "_acme-challenge."+host {
return wantCNAME, nil
}
@@ -548,10 +553,97 @@ func TestAttachWildcardCNAMEGate(t *testing.T) {
}
})
+ // IMPORTANT-1 (review): DNS names compare case-insensitively (RFC 4343)
+ // and a resolver answers in the record's own authored case, not the
+ // zone's. A customer who typed their CNAME target in mixed case must
+ // still delegate, not read as a permanently broken/missing record.
+ t.Run("mixed-case CNAME target still delegates", func(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-wc-mixedcase")
+ fx.srv.delegationZone = zone
+ mixedCase := "_ACME-Challenge." + host + "." + strings.ToUpper(zone) + "."
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
+ if name == "_acme-challenge."+host {
+ return mixedCase, 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}`)
+ if code != http.StatusOK {
+ t.Fatalf("attach with a mixed-case CNAME target = %d %s, want 200", code, out)
+ }
+ if c := claimOf(t, fx, host); !c.Wildcard {
+ t.Fatalf("claim after a mixed-case CNAME enable = %+v, want Wildcard true", *c)
+ }
+ })
+
+ // IMPORTANT-2 (review): re-sending wildcard:true for a claim that
+ // already has it must not re-run the CNAME lookup at all — proven here
+ // by a stub that always errors (simulating a resolver blip) plus a call
+ // counter: if the gate wrongly re-checked on every idempotent re-attach,
+ // this would 422 instead of succeeding, and the counter would be
+ // nonzero.
+ t.Run("re-sending wildcard true on an already-wildcard claim skips the lookup", func(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-wc-idempotent")
+ fx.srv.delegationZone = zone
+ calls := 0
+ fx.srv.lookupCNAME = func(_ context.Context, 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}`)
+ if code != http.StatusOK {
+ t.Fatalf("seed wildcard enable = %d %s, want 200", code, out)
+ }
+ // Now break the resolver entirely (a blip) and count every call.
+ fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
+ calls++
+ return "", errors.New("resolver unavailable")
+ }
+ 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("re-sending wildcard:true on an already-wildcard claim = %d %s, want 200 (no re-check)", code, out)
+ }
+ if calls != 0 {
+ t.Fatalf("lookupCNAME was called %d times on an idempotent re-attach, want 0", calls)
+ }
+ if c := claimOf(t, fx, host); !c.Wildcard {
+ t.Fatalf("claim after the idempotent re-attach = %+v, want Wildcard still true", *c)
+ }
+ })
+
+ // IMPORTANT-3 (review): the capability write's own ErrHostUnavailable
+ // branch (setDomainCapabilities, apidomains.go), forced without a new
+ // test seam: the lookupCNAME stub DETACHES the claim as a side effect
+ // (simulating the claim changing hands between the attach write above
+ // and SetClaimCapabilities's own read) and THEN returns the correct
+ // CNAME, so the gate passes but SetClaimCapabilities's read finds
+ // os.ErrNotExist and returns ErrHostUnavailable — which must answer the
+ // SAME byte-identical 404 every other non-owner refusal does.
+ t.Run("claim vanishing between attach and capability write answers the byte-identical 404", func(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-wc-vanish")
+ fx.srv.delegationZone = zone
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
+ if _, err := domains.DetachClaim(context.Background(), fx.rel, host, "bab", "titogo"); err != nil {
+ t.Fatal(err)
+ }
+ return wantCNAME, nil
+ }
+ want := canonicalNotFound(t)
+ code, h, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
+ assertNotFound(t, "capability write after the claim vanished mid-request", want, code, h, out)
+ claimAbsent(t, fx, host)
+ })
+
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") }
+ fx.srv.lookupCNAME = func(context.Context, 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 {
@@ -568,7 +660,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) {
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) {
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
if name == "_acme-challenge."+host {
return wantCNAME, nil
}
@@ -641,7 +733,7 @@ func TestAttachWildcardDisableCascadesCatchallOff(t *testing.T) {
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) {
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
if name == "_acme-challenge."+host {
return wantCNAME, nil
}
@@ -653,7 +745,7 @@ func TestAttachWildcardDisableCascadesCatchallOff(t *testing.T) {
}
// 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") }
+ fx.srv.lookupCNAME = func(context.Context, 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 {
@@ -677,7 +769,7 @@ func TestDomainsListDelegationStamp(t *testing.T) {
const wildHost = "wild.example.com"
const plainHost = "plain.example.com"
wantCNAME := "_acme-challenge." + wildHost + "." + zone + "."
- fx.srv.lookupCNAME = func(name string) (string, error) {
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
if name == "_acme-challenge."+wildHost {
return wantCNAME, nil
}
@@ -731,9 +823,157 @@ func TestDomainsListDelegationStamp(t *testing.T) {
// 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") }
+ fx.srv.lookupCNAME = func(context.Context, 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)
}
}
+
+// TestDomainsListDelegationStampCapsLookups: IMPORTANT-2 (review) bounds the
+// list door's live CNAME work at domainListCNAMELookupCap per call. Nine
+// wildcard-enabled claims, host-sorted "h0".."h8": only the lexicographically
+// first 8 get a delegation_status stamp (and only 8 lookupCNAME calls are
+// made at all); the 9th lists with the field simply omitted, same as an
+// unset zone or wildcard off — never an error.
+func TestDomainsListDelegationStampCapsLookups(t *testing.T) {
+ const zone = "acme.oncarlos.com"
+ fx, member := instanceFixture(t, "code-dom-list-cap")
+ fx.srv.delegationZone = zone
+ var calls int
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
+ calls++
+ host := strings.TrimPrefix(name, "_acme-challenge.")
+ return "_acme-challenge." + host + "." + zone + ".", nil
+ }
+ hosts := make([]string, 9)
+ for i := range hosts {
+ h := fmt.Sprintf("h%d.example.com", i)
+ hosts[i] = h
+ attachDomain(t, fx, member, h, "go.tito.io")
+ if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, h, "bab", "titogo", true, false); err != nil || !changed {
+ t.Fatalf("seed SetClaimCapabilities(%s, wildcard) = changed=%v err=%v", h, changed, err)
+ }
+ }
+ sort.Strings(hosts)
+
+ 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)
+ }
+ if len(got.Domains) != 9 {
+ t.Fatalf("list = %d rows, want 9", len(got.Domains))
+ }
+ byHost := map[string]string{}
+ for _, d := range got.Domains {
+ byHost[d.Host] = d.DelegationStatus
+ }
+ for i, h := range hosts {
+ if i < 8 {
+ if byHost[h] != "delegated" {
+ t.Errorf("host %d (%s) delegation_status = %q, want delegated (within the cap)", i, h, byHost[h])
+ }
+ } else {
+ if byHost[h] != "" {
+ t.Errorf("host %d (%s) delegation_status = %q, want omitted (beyond the cap)", i, h, byHost[h])
+ }
+ }
+ }
+ if calls != 8 {
+ t.Fatalf("lookupCNAME was called %d times, want exactly 8 (the cap)", calls)
+ }
+}
+
+// ---- zone validation at construction (Task 5 review, IMPORTANT-4) ---------
+
+// TestNormalizeDelegationZone: CARLOS_ACME_DELEGATION_ZONE is lowercased,
+// trimmed, and shape-checked the same way the edge validates the identical
+// variable (internal/edge/wildcard.go) — an invalid value degrades to "",
+// never a panic or a value handed to lookupCNAME verbatim.
+func TestNormalizeDelegationZone(t *testing.T) {
+ for _, tc := range []struct{ raw, want string }{
+ {"", ""},
+ {" ", ""},
+ {"ACME.OnCarlos.Com", "acme.oncarlos.com"},
+ {" acme.oncarlos.com ", "acme.oncarlos.com"},
+ {"*.bad zone", ""},
+ {"-leading-hyphen.example.com", ""},
+ {"has a space.example.com", ""},
+ } {
+ if got := normalizeDelegationZone(tc.raw); got != tc.want {
+ t.Errorf("normalizeDelegationZone(%q) = %q, want %q", tc.raw, got, tc.want)
+ }
+ }
+}
+
+// zoneFixtureWithDelegation builds a full console fixture the same shape
+// instanceFixture does, but with Config.DelegationZone set AT CONSTRUCTION —
+// the only way to exercise NewServer's own zone validation (IMPORTANT-4):
+// every other test in this file stubs delegationZone directly on an
+// already-constructed Server, which bypasses normalizeDelegationZone
+// entirely.
+func zoneFixtureWithDelegation(t *testing.T, zone, code string) (*consoleFixture, string) {
+ t.Helper()
+ regPath, relStore, priv, pub := buildFixtures(t)
+ st, err := Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ var srv *Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = NewServer(Config{
+ Origin: ts.URL, Keymail: fk.srv.URL, Registry: regPath, Releases: relStore,
+ ReleasePub: pub, Key: priv, AppScheme: "http", DelegationZone: zone,
+ }, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv.instances.timeout = 100 * time.Millisecond
+ if err := st.AddMember(OpsAccountID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx := &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath,
+ priv: priv, pub: pub, sqid: release.OpsAccount}
+ now := time.Now()
+ if _, err := st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// TestDelegationZoneInvalidAtConstruction: a garbage
+// CARLOS_ACME_DELEGATION_ZONE degrades to delegation-off at Server
+// construction (IMPORTANT-4) — a wildcard-enable request against it answers
+// the ordinary "no delegation zone configured" 422, the exact same refusal
+// an unset zone gets, never a distinguishable error and never a lookupCNAME
+// call built from an invalid zone.
+func TestDelegationZoneInvalidAtConstruction(t *testing.T) {
+ fx, member := zoneFixtureWithDelegation(t, "*.bad zone", "code-dom-wc-badzone")
+ fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
+ t.Fatal("lookupCNAME must not be called when the configured zone is invalid")
+ return "", nil
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ `{"host":"woodstar.app","route":"go.tito.io","wildcard":true}`)
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("attach with wildcard under an invalid configured 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)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 0ea9a58bd..f42746189 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -304,12 +304,34 @@ type Server struct {
// 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.
+ // seams (apidomains.go). delegationZone is cfg.DelegationZone, shape-
+ // validated and normalized once here (normalizeDelegationZone);
+ // lookupCNAME takes the request's own ctx — the same ctx-first idiom
+ // internal/edge/wildcard.go already uses for its DNS lookups — and
+ // defaults to net.DefaultResolver.LookupCNAME. 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)
+ lookupCNAME func(ctx context.Context, host string) (string, error)
+}
+
+// normalizeDelegationZone lowercases, trims, and shape-validates raw (from
+// CARLOS_ACME_DELEGATION_ZONE): a value that isn't a legal hostname degrades
+// to "" — delegation off — rather than being handed to lookupCNAME verbatim
+// or failing boot. Mirrors the edge's own validation of the same variable
+// (internal/edge/wildcard.go's newWildcardFromEnv), so a zone the edge would
+// refuse never gets ACCEPTED here only to make every wildcard-enable request
+// dial a garbage DNS query. "" already has a well-defined, honest answer —
+// every enable request refuses with "deployment has no delegation zone
+// configured" — so a badly-typed zone gets exactly that, not a crash or a
+// distinguishable error.
+func normalizeDelegationZone(raw string) string {
+ zone := strings.ToLower(strings.TrimSpace(raw))
+ if zone != "" && !provision.ValidHostShape(zone) {
+ log.Printf("console: CARLOS_ACME_DELEGATION_ZONE %q is not a valid domain — delegation off, wildcard-enable requests refuse with \"no delegation zone configured\" until it's fixed", raw)
+ return ""
+ }
+ return zone
}
// NewServer wires a server over an opened Store, loading (or minting) the
@@ -363,8 +385,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// slow-loris uploads against a stolen or shared token can't pile up
// goroutines and buffers without bound.
shipSem: make(chan struct{}, 2),
- delegationZone: strings.ToLower(strings.TrimSpace(cfg.DelegationZone)),
- lookupCNAME: net.LookupCNAME,
+ delegationZone: normalizeDelegationZone(cfg.DelegationZone),
+ lookupCNAME: net.DefaultResolver.LookupCNAME,
}
if s.km != nil {
s.sendApp = s.km.SendApp