carlos / platform Public

Merge remote-tracking branch 'origin/main' into multi-upstream-routes

Paul Campbell a255eca565e4149fd3b2e492fa27e3bec3d3f8f4 parent a0fc0cdparent d754d04
19 files changed, +2084 −153
  • apimode.go +30 −16
  • cmd/console/main.go +6 −0
  • domains_cli.go +46 −7
  • domains_cli_test.go +139 −14
  • internal/console/apidomains.go +185 −7
  • internal/console/apidomains_test.go +509 −10
  • internal/console/server.go +42 −1
  • internal/domains/aliases.go +128 −22
  • internal/domains/aliases_test.go +229 −0
  • internal/domains/claims.go +46 −0
  • internal/domains/claims_test.go +60 −0
  • internal/domains/reconcile.go +21 −32
  • internal/domains/reconcile_internal_test.go +14 −19
  • internal/edge/alias_test.go +243 −0
  • internal/edge/edge.go +94 −19
  • internal/edge/wildcard.go +38 −1
  • internal/edge/wildcard_test.go +86 −0
  • internal/registry/registry.go +97 −5
  • internal/registry/registry_test.go +71 −0
diff --git a/apimode.go b/apimode.go
index d6752d422..259a25cb3 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1756,28 +1756,42 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
// CertExpiresAt are the box-stamped reading columns and stay their zero
// value ("" / 0) for a claim no live box has reported on yet ("pending"),
// the same absent-stays-absent convention status.AliasReading uses.
+// Wildcard/Catchall are always present (true or false, never omitted — the
+// door knows both for every claim); DelegationStatus is "delegated"/
+// "missing", empty when the deployment has no delegation zone configured or
+// the claim doesn't have Wildcard.
type apiDomainRecord struct {
- Host string `json:"host"`
- Route string `json:"route"`
- Status string `json:"status"`
- DNSStatus string `json:"dns_status,omitempty"`
- CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Status string `json:"status"`
+ DNSStatus string `json:"dns_status,omitempty"`
+ CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
+ Wildcard bool `json:"wildcard"`
+ Catchall bool `json:"catchall"`
+ DelegationStatus string `json:"delegation_status,omitempty"`
}
// domainAttach asks the console to claim host for app, pointed at route (a
// route hostname, or "" to let the console default it to the app's single
-// instance record). created is the caller's own answer — false on a re-
-// attach that changed nothing or only repointed the route — and
-// resolvedRoute is always the route the claim actually carries, whatever
-// this call sent: a caller that omitted -route learns what the server
-// defaulted it to. A 409 (another account or app already holds the
-// hostname) surfaces as "hostname unavailable" verbatim, apiErrorFromBody's
-// json-first decode doing the work — the door never says who holds it.
-func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) {
+// instance record). wildcard/catchall are the Task 5 capability flips — nil
+// means "leave unchanged" and is never sent on the wire (omitempty), so a
+// bare attach cannot clear a capability the caller never mentioned; the
+// caller (runDomainsAttach) is responsible for turning a bare, unset CLI
+// flag into nil rather than a pointer to false. created is the caller's own
+// answer — false on a re-attach that changed nothing or only repointed the
+// route — and resolvedRoute is always the route the claim actually carries,
+// whatever this call sent: a caller that omitted -route learns what the
+// server defaulted it to. A 409 (another account or app already holds the
+// hostname) surfaces as "hostname unavailable" verbatim, and a 422 (bad
+// wildcard/catchall request) surfaces its own message verbatim too —
+// apiErrorFromBody's json-first decode doing the work either way.
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string, wildcard, catchall *bool) (created bool, resolvedRoute string, err error) {
in := struct {
- Host string `json:"host"`
- Route string `json:"route,omitempty"`
- }{host, route}
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ Wildcard *bool `json:"wildcard,omitempty"`
+ Catchall *bool `json:"catchall,omitempty"`
+ }{host, route, wildcard, catchall}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in)
if err != nil {
return false, "", err
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 0b94019e1..e10202bca 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/domains_cli.go b/domains_cli.go
index 4e5330923..b1841e93d 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -18,15 +18,17 @@ import (
const domainsUsage = `usage: carlos domains <attach|detach|list> [flags]
- attach -app <a> [-route <r>] [flags] <hostname>
+ attach -app <a> [-route <r>] [-wildcard] [-catchall] [flags] <hostname>
claim a customer's hostname for the app, pointed at
-route (default: the app's single instance record, if it
- has exactly one — otherwise -route is required)
+ has exactly one — otherwise -route is required).
+ -wildcard/-catchall are capability flips: naming either
+ one changes it, an unset flag leaves it as it was.
detach -app <a> [flags] <hostname>
release a hostname claim
list -app <a> [flags]
this app's hostname claims, joined to the fleet's own
- readings sweep (DNS/CERT-EXPIRES)
+ readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION)
-account <a> account (sqid or name; default "ops")
-account-id <id> account sqid, matched as a sqid ONLY
@@ -38,7 +40,7 @@ route, and writes it. The hostname comes live on the box that owns the route
at its next domains pass.
`
-const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
+const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-wildcard] [-catchall] [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
`
const domainsDetachUsage = `usage: carlos domains detach -app <a> [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
@@ -71,6 +73,8 @@ func runDomainsAttach(args []string) {
fs := flag.NewFlagSet("domains attach", flag.ExitOnError)
app := fs.String("app", "", "app the hostname belongs to")
route := fs.String("route", "", "route hostname to attach to")
+ wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)")
+ catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs -wildcard)")
consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)")
acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
@@ -79,10 +83,27 @@ func runDomainsAttach(args []string) {
if *app == "" || host == "" || fs.NArg() > 1 {
log.Fatal(domainsAttachUsage)
}
+ // -wildcard/-catchall are tri-state, the same reason apps place's
+ // -target is: an omitted flag must leave the claim's current capability
+ // untouched, and the bool value alone cannot distinguish "not named"
+ // from "named, false" (an explicit -catchall=false is a real request to
+ // turn it off). fs.Visit reports only flags actually present on the
+ // command line.
+ var wildcardArg, catchallArg *bool
+ fs.Visit(func(f *flag.Flag) {
+ switch f.Name {
+ case "wildcard":
+ v := *wildcard
+ wildcardArg = &v
+ case "catchall":
+ v := *catchall
+ catchallArg = &v
+ }
+ })
sess := requireAPISession("attach", *consoleFlag)
printUsing(stdout, sess)
created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw),
- *app, host, strings.TrimSpace(*route))
+ *app, host, strings.TrimSpace(*route), wildcardArg, catchallArg)
if err != nil {
log.Fatalf("domains attach: %v", err)
}
@@ -134,7 +155,12 @@ func runDomainsList(args []string) {
log.Fatalf("domains list: %v", err)
}
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES")
+ // WILDCARD/CATCHALL print "yes" or "" (the route table's -hibernate/HIB
+ // convention, cli.go), never "no" — a blank cell reads as "not set"
+ // without shouting it. DELEGATION is a tri-state the door itself never
+ // fabricates: "-" covers both "no delegation zone configured" and
+ // "wildcard is off", the two cases the door omits the field for.
+ fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION")
for _, r := range recs {
dns := r.DNSStatus
if dns == "" {
@@ -144,7 +170,20 @@ func runDomainsList(args []string) {
if r.CertExpiresAt > 0 {
cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02")
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert)
+ wildcard := ""
+ if r.Wildcard {
+ wildcard = "yes"
+ }
+ catchall := ""
+ if r.Catchall {
+ catchall = "yes"
+ }
+ delegation := r.DelegationStatus
+ if delegation == "" {
+ delegation = "-"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation)
}
w.Flush()
}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index 485b06046..bc3a318cc 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -38,7 +38,7 @@ func TestAPISessionDomainAttach(t *testing.T) {
s := newTestSession(ts.URL)
created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
- "tickets.example.com", "slug.go.tito.io")
+ "tickets.example.com", "slug.go.tito.io", nil, nil)
if err != nil {
t.Fatalf("domainAttach: %v", err)
}
@@ -66,7 +66,7 @@ func TestAPISessionDomainAttachNoOp(t *testing.T) {
s := newTestSession(ts.URL)
created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
- "tickets.example.com", "")
+ "tickets.example.com", "", nil, nil)
if err != nil {
t.Fatalf("domainAttach: %v", err)
}
@@ -86,12 +86,73 @@ func TestAPISessionDomainAttachConflict(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "")
+ _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "", nil, nil)
if err == nil || err.Error() != "hostname unavailable" {
t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err)
}
}
+// TestAPISessionDomainAttachWildcardRefusal: a 422 (the wildcard/catchall
+// gate) surfaces its own message verbatim too — the CNAME record the caller
+// needs to create, exactly as the door wrote it, not summarized.
+func TestAPISessionDomainAttachWildcardRefusal(t *testing.T) {
+ const msg = "create CNAME _acme-challenge.tickets.example.com -> _acme-challenge.tickets.example.com.acme.oncarlos.com, then retry"
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ json.NewEncoder(w).Encode(map[string]any{"error": msg})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ yes := true
+ _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", &yes, nil)
+ if err == nil || err.Error() != msg {
+ t.Fatalf("domainAttach against a 422 = %v, want exactly %q", err, msg)
+ }
+}
+
+// TestAPISessionDomainAttachWildcardCatchallWireShape: wildcard/catchall
+// pointers marshal only when non-nil — nil never reaches the wire at all
+// (omitempty on a nil *bool), so a caller that names neither sends a body
+// indistinguishable from the pre-Task-5 shape.
+func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) {
+ var gotBody map[string]json.RawMessage
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "route": "go.tito.io", "created": true})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ // Neither named: the wire body must carry no "wildcard"/"catchall" keys
+ // at all, not false-valued ones — an absent key is what "leave
+ // unchanged" means to the door.
+ if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", nil, nil); err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if _, ok := gotBody["wildcard"]; ok {
+ t.Fatalf("body = %v, want no wildcard key when nil", gotBody)
+ }
+ if _, ok := gotBody["catchall"]; ok {
+ t.Fatalf("body = %v, want no catchall key when nil", gotBody)
+ }
+
+ // wildcard named true, catchall named false: both keys present, with
+ // their own values — false is a real request ("turn it off"), not an
+ // absence.
+ yes, no := true, false
+ if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", &yes, &no); err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ var w, c bool
+ if err := json.Unmarshal(gotBody["wildcard"], &w); err != nil || !w {
+ t.Fatalf("wildcard = %v (err %v), want true", gotBody["wildcard"], err)
+ }
+ if err := json.Unmarshal(gotBody["catchall"], &c); err != nil || c {
+ t.Fatalf("catchall = %v (err %v), want false", gotBody["catchall"], err)
+ }
+}
+
// TestAPISessionDomainDetach: domainDetach DELETEs with the host
// path-escaped and decodes removed.
func TestAPISessionDomainDetach(t *testing.T) {
@@ -185,7 +246,7 @@ func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) {
s := newTestSession(ts.URL)
acct := apiAcct{Seg: "bab"}
- if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", ""); err == nil {
+ if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", "", nil, nil); err == nil {
t.Fatal("domainAttach against a 404 must error")
}
if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil {
@@ -203,7 +264,10 @@ func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) {
type fakeDomainsConsole struct {
ts *httptest.Server
lastAttachPath string
- lastAttachBody struct{ Host, Route string }
+ lastAttachBody struct {
+ Host, Route string
+ Wildcard, Catchall *bool
+ }
attachCreated bool
lastDetachPath string
detachRemoved bool
@@ -231,8 +295,10 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
json.NewEncoder(w).Encode(map[string]any{
"domains": []map[string]any{
{"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
- "dns_status": "pointing", "cert_expires_at": 1799999999},
- {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ "dns_status": "pointing", "cert_expires_at": 1799999999,
+ "wildcard": true, "catchall": true, "delegation_status": "delegated"},
+ {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending",
+ "wildcard": false, "catchall": false},
},
})
default:
@@ -324,7 +390,7 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
out := buf.String()
for _, want := range []string{
- "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES",
+ "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION",
"tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
"new.example.com", "pending",
} {
@@ -332,17 +398,76 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
t.Fatalf("stdout missing %q:\n%s", want, out)
}
}
- // The pending row's dns/cert columns must read "-", not a stray zero or
- // blank cell that would misalign the table.
lines := strings.Split(out, "\n")
- var pendingLine string
+ var wildcardLine, plainLine string
for _, l := range lines {
+ if strings.Contains(l, "tickets.example.com") {
+ wildcardLine = l
+ }
if strings.Contains(l, "new.example.com") {
- pendingLine = l
+ plainLine = l
}
}
- if pendingLine == "" || !strings.Contains(pendingLine, "-") {
- t.Fatalf("pending row = %q, want a \"-\" placeholder for the absent columns", pendingLine)
+ // tickets.example.com carries wildcard+catchall+delegated: "yes" twice
+ // and "delegated" once, in the last three (tabwriter-padded) columns.
+ if wildcardLine == "" {
+ t.Fatal("no row for tickets.example.com")
+ }
+ if fields := strings.Fields(wildcardLine); len(fields) < 3 ||
+ fields[len(fields)-3] != "yes" || fields[len(fields)-2] != "yes" || fields[len(fields)-1] != "delegated" {
+ t.Fatalf("wildcard row = %q, want yes/yes/delegated in the last three columns", wildcardLine)
+ }
+ // The pending row's dns/cert columns read "-", and wildcard/catchall are
+ // blank (not "yes") with delegation "-" — the door omits the field.
+ if plainLine == "" {
+ t.Fatal("no row for new.example.com")
+ }
+ if fields := strings.Fields(plainLine); len(fields) < 1 || fields[len(fields)-1] != "-" {
+ t.Fatalf("pending row = %q, want delegation \"-\"", plainLine)
+ }
+ if strings.Contains(plainLine, "yes") {
+ t.Fatalf("pending row = %q, want no wildcard/catchall yes when both are false", plainLine)
+ }
+}
+
+// TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed: -wildcard/-catchall are
+// tri-state on the wire, the way -target already is on `apps place` —
+// fs.Visit is what makes "not named" and "named false" distinguishable, and
+// this is the regression that would silently break if runDomainsAttach ever
+// switched to reading *wildcard/*catchall unconditionally.
+func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) {
+ fc := domainsCLIFixture(t)
+
+ // Bare attach: neither flag named, neither pointer sent.
+ runDomains([]string{"attach", "-app", "tito", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard != nil || fc.lastAttachBody.Catchall != nil {
+ t.Fatalf("bare attach body = %+v, want both wildcard and catchall unset", fc.lastAttachBody)
+ }
+
+ // -wildcard named (implicitly true): wildcard sent true, catchall still
+ // unset — naming one flag must not touch the other.
+ runDomains([]string{"attach", "-app", "tito", "-wildcard", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard {
+ t.Fatalf("wildcard body = %+v, want wildcard=true", fc.lastAttachBody)
+ }
+ if fc.lastAttachBody.Catchall != nil {
+ t.Fatalf("wildcard body = %+v, want catchall still unset", fc.lastAttachBody)
+ }
+
+ // -wildcard=false named explicitly: a real request to turn it off, sent
+ // as a present false, not treated as "not named".
+ runDomains([]string{"attach", "-app", "tito", "-wildcard=false", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard == nil || *fc.lastAttachBody.Wildcard {
+ t.Fatalf("wildcard=false body = %+v, want wildcard present and false", fc.lastAttachBody)
+ }
+
+ // Both named: both sent.
+ runDomains([]string{"attach", "-app", "tito", "-wildcard", "-catchall", "tickets.example.com"})
+ if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard {
+ t.Fatalf("both body = %+v, want wildcard=true", fc.lastAttachBody)
+ }
+ if fc.lastAttachBody.Catchall == nil || !*fc.lastAttachBody.Catchall {
+ t.Fatalf("both body = %+v, want catchall=true", fc.lastAttachBody)
}
}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index ca4ad69d9..4e82f297e 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,123 @@ 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
+ }
+ // 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 —
+ // 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 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
+ // 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(ctx, 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.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.EqualFold(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 +388,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
@@ -280,25 +424,59 @@ 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)
}
- item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending"}
+ }
+ 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 {
item.Status = "live"
item.DNSStatus = reading.DNSStatus
item.CertExpiresAt = reading.CertExpiresAt
}
+ // 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"
+ }
+ }
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 b5462cc23..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"
)
@@ -54,11 +59,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 +386,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 +489,491 @@ 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(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 {
+ 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(_ context.Context, 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)
+ }
+ })
+
+ // 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(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 {
+ 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(_ 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,"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(_ context.Context, 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(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 {
+ 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(_ context.Context, 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(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 f7dbdfa57..89f2898a3 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -466,6 +466,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.
@@ -533,6 +543,35 @@ 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, 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(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
@@ -585,7 +624,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: normalizeDelegationZone(cfg.DelegationZone),
+ lookupCNAME: net.DefaultResolver.LookupCNAME,
}
if s.km != nil {
s.sendApp = s.km.SendApp
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index 46a90b812..27e0e7c61 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -1,6 +1,11 @@
-// Alias reconcile pass — derives the registry's route_aliases table from
-// the global claims recorded in the bucket (#151). See claims.go for the
-// claim shape and registry.go's RouteAlias for the storage shape; this file
+// Alias reconcile pass — derives the registry's route_aliases table AND its
+// wildcard_domains table from the global claims recorded in the bucket
+// (#151). A claim contributes an ordinary alias row when it names a Route
+// (see the loop below); a Wildcard claim additionally contributes a
+// wildcard_domains row (a delegated-ACME wildcard-cert parent), and a
+// Wildcard+Catchall claim also contributes a "*.<host>" route_aliases row
+// pointing at the same primary. See claims.go for the claim shape and
+// registry.go's RouteAlias/WildcardDomain for the storage shapes; this file
// is the seam that turns one into the other.
package domains
@@ -13,13 +18,19 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// ReconcileAliases derives route_aliases from the global claim records.
-// The table is wholly pass-owned: every row traces to a claim, so removal
-// needs no ownership flag. The authoritative tenancy gate is HERE — the
-// door's route check is best-effort (it cannot see the registry); a claim
-// only materializes when the named route exists locally AND its
-// account/app match the claim. Routes always win a host collision: an
-// alias must never shadow a real route.
+// maxWildcardDomains caps wildcard_domains per box: every delegated parent
+// shares one ACME account's new-order budget, so an unbounded claim set
+// could starve platform issuance. Deterministic (claims come in list
+// order); drops are logged, never silent.
+const maxWildcardDomains = 32
+
+// ReconcileAliases derives route_aliases and wildcard_domains from the
+// global claim records. Both tables are wholly pass-owned: every row traces
+// to a claim, so removal needs no ownership flag. The authoritative tenancy
+// gate is HERE — the door's route check is best-effort (it cannot see the
+// registry); a claim only materializes when its primary route exists
+// locally AND its account/app match the claim. Routes always win a host
+// collision: an alias (plain or catch-all) must never shadow a real route.
func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error {
claims, err := ListClaims(ctx, s)
if err != nil {
@@ -38,29 +49,97 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
return err
}
desired := map[string]bool{}
+ desiredWild := map[string]registry.WildcardDomain{}
+ wildCount := 0
for _, c := range claims {
- if c.Route == "" { // first-route claims (#82's case) are not aliases
+ // Plain alias derivation (#164): a routeless (first-route) claim is
+ // not an alias — that skip is scoped to THIS half only, so a
+ // routeless Wildcard claim still falls through to the wildcard
+ // derivation below.
+ if c.Route != "" {
+ rt, ok := byHost[c.Route]
+ if !ok {
+ // primary not on this box — another box's claim
+ } else if rt.App != c.App || release.RouteAccount(rt) != c.Account {
+ log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s",
+ c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App)
+ } else if _, taken := byHost[c.Host]; taken {
+ log.Printf("domains: alias %s skipped: a real route owns that host", c.Host)
+ } else {
+ desired[c.Host] = true
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App,
+ CreatedAt: now().Unix(),
+ }); err != nil {
+ log.Printf("domains: upsert alias %s: %v", c.Host, err)
+ }
+ }
+ }
+
+ // Wildcard/catch-all derivation. A claim with Catchall but not
+ // Wildcard cannot come from the write path (SetClaimCapabilities
+ // refuses it), but a hand-written or pre-migration claim could carry
+ // it — tolerated, logged, nothing derived.
+ if c.Catchall && !c.Wildcard {
+ log.Printf("domains: catch-all %s skipped: claim has catchall without wildcard", c.Host)
+ }
+ if !c.Wildcard {
+ continue
+ }
+ if !validHostShape(c.Host) {
+ log.Printf("domains: wildcard %s refused: invalid host shape", c.Host)
continue
}
- rt, ok := byHost[c.Route]
+ primaryHost := c.Route
+ if primaryHost == "" {
+ primaryHost = c.Host // first-route claim: its own materialized primary
+ }
+ // A named-Route claim's own Host can itself be a local route (e.g. a
+ // domain already routed AND also carrying a wildcard cert opt-in for
+ // its subdomains). That's fine when the tenant matches; a mismatched
+ // tenant means a different app/account already owns this hostname
+ // directly, and the wildcard claim must not shadow it. The
+ // empty-Route (first-route) case is safe by construction: there
+ // primaryHost == c.Host, so this is the same route checked below.
+ if c.Route != "" {
+ if hostRt, taken := byHost[c.Host]; taken {
+ if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account {
+ log.Printf("domains: wildcard %s refused: a real route owns that host under %s/%s",
+ c.Host, release.RouteAccount(hostRt), hostRt.App)
+ continue
+ }
+ }
+ }
+ rt, ok := byHost[primaryHost]
if !ok {
continue // primary not on this box — another box's claim
}
if rt.App != c.App || release.RouteAccount(rt) != c.Account {
- log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s",
- c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App)
+ log.Printf("domains: wildcard %s refused: route %s belongs to %s/%s, claim says %s/%s",
+ c.Host, primaryHost, release.RouteAccount(rt), rt.App, c.Account, c.App)
continue
}
- if _, taken := byHost[c.Host]; taken {
- log.Printf("domains: alias %s skipped: a real route owns that host", c.Host)
+ if wildCount >= maxWildcardDomains {
+ log.Printf("domains: wildcard %s dropped: per-box cap %d reached", c.Host, maxWildcardDomains)
continue
}
- desired[c.Host] = true
- if err := reg.UpsertAlias(registry.RouteAlias{
- Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App,
- CreatedAt: now().Unix(),
- }); err != nil {
- log.Printf("domains: upsert alias %s: %v", c.Host, err)
+ wildCount++
+ desiredWild[c.Host] = registry.WildcardDomain{
+ Host: c.Host, Account: c.Account, App: c.App, CreatedAt: now().Unix(),
+ }
+ if c.Catchall {
+ star := "*." + c.Host
+ if _, taken := byHost[star]; taken {
+ log.Printf("domains: catch-all %s skipped: a real route owns that host", star)
+ } else {
+ desired[star] = true
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: star, Host: primaryHost, Account: c.Account, App: c.App,
+ CreatedAt: now().Unix(),
+ }); err != nil {
+ log.Printf("domains: upsert catch-all %s: %v", star, err)
+ }
+ }
}
}
for _, a := range existing {
@@ -70,5 +149,32 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
}
}
}
+
+ // wildcard_domains is wholly pass-owned, like route_aliases.
+ existingWild, err := reg.ListWildcardDomains()
+ if err != nil {
+ return err
+ }
+ for h, w := range desiredWild {
+ cur := false
+ for _, e := range existingWild {
+ if e.Host == h && e.Account == w.Account && e.App == w.App {
+ cur = true
+ break
+ }
+ }
+ if !cur {
+ if err := reg.UpsertWildcardDomain(w); err != nil {
+ log.Printf("domains: upsert wildcard %s: %v", h, err)
+ }
+ }
+ }
+ for _, e := range existingWild {
+ if _, ok := desiredWild[e.Host]; !ok {
+ if err := reg.DeleteWildcardDomain(e.Host); err != nil {
+ log.Printf("domains: remove wildcard %s: %v", e.Host, err)
+ }
+ }
+ }
return nil
}
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go
index 3e202c599..58f39f12d 100644
--- a/internal/domains/aliases_test.go
+++ b/internal/domains/aliases_test.go
@@ -3,7 +3,9 @@ package domains
import (
"context"
"encoding/json"
+ "fmt"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -43,6 +45,26 @@ func putClaim(t *testing.T, s release.Store, c Claim) {
}
}
+// deleteClaim removes host's claim from the bucket, so a re-run of
+// ReconcileAliases sees it gone (pass-owned removal tests).
+func deleteClaim(t *testing.T, s release.Store, host string) {
+ t.Helper()
+ if err := s.Delete(context.Background(), ClaimKey(host)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// addRoute upserts one route into reg, same shape the other tests in this
+// file inline into aliasRegWith's constructor call.
+func addRoute(t *testing.T, reg *registry.Registry, host, account, app string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{
+ Host: host, App: app, Account: account, Kind: "instance", Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
func aliasNow() func() time.Time {
return func() time.Time { return time.Unix(1755000000, 0) }
}
@@ -243,3 +265,210 @@ func TestReconcileAliasesIdempotent(t *testing.T) {
t.Fatalf("alias row changed on re-run: first=%+v second=%+v", first[0], second[0])
}
}
+
+// TestReconcileWildcardCreatedAtStable is the final-review pin: unlike
+// route_aliases (UpsertAlias's ON CONFLICT deliberately leaves created_at out
+// of its SET list, so the DB itself preserves it), UpsertWildcardDomain DOES
+// overwrite created_at on conflict — wildcard_domains.created_at is only kept
+// stable by the reconcile pass's own skip-upsert-when-unchanged guard (an
+// unchanged host/account/app row is never re-upserted). That guard matters
+// because Fingerprint hashes wildcard_domains.created_at (unlike
+// route_aliases, which excludes it): a created_at that bumped on every pass
+// would move the fingerprint on every 2s poll and force a needless edge
+// reload forever, even with a completely unchanged claim set.
+func TestReconcileWildcardCreatedAtStable(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "woodstar-relay.bab.oncarlos.com", "bab", "woodstar-relay")
+ putClaim(t, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay",
+ Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z",
+ Wildcard: true, Catchall: true})
+
+ if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil {
+ t.Fatal(err)
+ }
+ first, err := reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first) != 1 || first[0].Host != "woodstar.app" {
+ t.Fatalf("wildcard_domains after first pass = %+v, want one row for woodstar.app", first)
+ }
+ firstCreatedAt := first[0].CreatedAt
+
+ // Second pass, later clock, same claim set: CreatedAt must be preserved,
+ // not bumped — a bump here would move Fingerprint's wildcard_domains
+ // section every poll and force a reload→refresh loop with nothing
+ // actually changed.
+ if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000+3600, 0) }); err != nil {
+ t.Fatal(err)
+ }
+ second, err := reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(second) != 1 || second[0].Host != "woodstar.app" {
+ t.Fatalf("wildcard_domains after second pass = %+v, want one row for woodstar.app", second)
+ }
+ if second[0].CreatedAt != firstCreatedAt {
+ t.Fatalf("wildcard_domains CreatedAt changed on re-run: first=%d second=%d", firstCreatedAt, second[0].CreatedAt)
+ }
+}
+
+func TestReconcileDerivesWildcardState(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ // Local primary route, tenancy bab/woodstar-relay.
+ addRoute(t, reg, "woodstar-relay.bab.oncarlos.com", "bab", "woodstar-relay")
+
+ putClaim(t, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay",
+ Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z",
+ Wildcard: true, Catchall: true})
+ // Wildcard-only (no catchall): row yes, *. alias no.
+ putClaim(t, s, Claim{Host: "certonly.example", Account: "bab", App: "woodstar-relay",
+ Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true})
+ // Wildcard claim whose primary is NOT on this box: no row.
+ putClaim(t, s, Claim{Host: "elsewhere.example", Account: "bab", App: "woodstar-relay",
+ Route: "not-here.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true})
+ // Invalid host shape: no row, no alias.
+ putClaim(t, s, Claim{Host: "bad host.example", Account: "bab", App: "woodstar-relay",
+ Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true, Catchall: true})
+
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil {
+ t.Fatal(err)
+ }
+
+ wds, err := reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(wds) != 2 || wds[0].Host != "certonly.example" || wds[1].Host != "woodstar.app" {
+ t.Fatalf("wildcard_domains = %+v", wds)
+ }
+ aliases, _ := reg.ListAliases()
+ var stars []string
+ for _, a := range aliases {
+ if strings.HasPrefix(a.Alias, "*.") {
+ stars = append(stars, a.Alias+"->"+a.Host)
+ }
+ }
+ if len(stars) != 1 || stars[0] != "*.woodstar.app->woodstar-relay.bab.oncarlos.com" {
+ t.Fatalf("catch-all aliases = %v", stars)
+ }
+
+ // Claim removed => derived state removed (pass-owned).
+ deleteClaim(t, s, "woodstar.app")
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(2000, 0) }); err != nil {
+ t.Fatal(err)
+ }
+ wds, _ = reg.ListWildcardDomains()
+ if len(wds) != 1 || wds[0].Host != "certonly.example" {
+ t.Fatalf("after claim delete: %+v", wds)
+ }
+ aliases, _ = reg.ListAliases()
+ for _, a := range aliases {
+ if strings.HasPrefix(a.Alias, "*.") {
+ t.Fatalf("catch-all alias must be removed with its claim: %+v", a)
+ }
+ }
+}
+
+func TestReconcileWildcardHostCollisionMismatchedTenant(t *testing.T) {
+ // A named-Route wildcard claim whose own Host is itself a REAL local
+ // route owned by a DIFFERENT tenant: the wildcard row (and any
+ // catch-all) must be refused, same spirit as the plain-alias half
+ // already refusing to shadow a real route. The claim's Route names a
+ // primary that DOES belong to the claim's own tenant, so the
+ // primary-tenancy check alone would pass — the collision on c.Host
+ // itself is the only gate that can catch this case.
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "primary.bab.oncarlos.com", "bab", "appA")
+ addRoute(t, reg, "hostx.example", "bad", "appB") // real route, different tenant
+
+ putClaim(t, s, Claim{Host: "hostx.example", Account: "bab", App: "appA",
+ Route: "primary.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z",
+ Wildcard: true, Catchall: true})
+
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil {
+ t.Fatal(err)
+ }
+ wds, err := reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(wds) != 0 {
+ t.Fatalf("wildcard_domains = %+v, want none (real route under a different tenant owns the host)", wds)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, a := range aliases {
+ if strings.HasPrefix(a.Alias, "*.") {
+ t.Fatalf("no catch-all should derive from a refused wildcard claim: %+v", a)
+ }
+ }
+}
+
+func TestReconcileWildcardCap(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "primary.bab.oncarlos.com", "bab", "appA")
+
+ // 33 valid wildcard claims sharing one local primary, with deterministic
+ // zero-padded hosts so bucket-list (lexicographic) order matches numeric
+ // order: w00.example .. w32.example.
+ for i := 0; i < 33; i++ {
+ putClaim(t, s, Claim{Host: fmt.Sprintf("w%02d.example", i), Account: "bab", App: "appA",
+ Route: "primary.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true})
+ }
+
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil {
+ t.Fatal(err)
+ }
+ wds, err := reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(wds) != 32 {
+ t.Fatalf("got %d wildcard_domains, want 32 (per-box cap): %+v", len(wds), wds)
+ }
+ for _, w := range wds {
+ if w.Host == "w32.example" {
+ t.Fatalf("w32.example (33rd claim, lexicographically last) should have been dropped over the cap: %+v", wds)
+ }
+ }
+}
+
+func TestReconcileWildcardEmptyRouteClaim(t *testing.T) {
+ // First-route claim (#82's shape): empty Route, the claim's own host IS
+ // the primary once materialized. Catchall aliases that primary.
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t)
+ addRoute(t, reg, "story.example", "bab", "story")
+ putClaim(t, s, Claim{Host: "story.example", Account: "bab", App: "story",
+ CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true, Catchall: true})
+
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }); err != nil {
+ t.Fatal(err)
+ }
+ wds, _ := reg.ListWildcardDomains()
+ if len(wds) != 1 || wds[0].Host != "story.example" {
+ t.Fatalf("wildcard_domains = %+v", wds)
+ }
+ aliases, _ := reg.ListAliases()
+ found := false
+ for _, a := range aliases {
+ if a.Alias == "*.story.example" && a.Host == "story.example" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("want *.story.example -> story.example, got %+v", aliases)
+ }
+}
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index 7b00329c4..7cdb7dacc 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -249,3 +249,49 @@ func HostDenied(host string, deny []string) bool {
}
return false
}
+
+// ErrCatchallNeedsWildcard is returned by SetClaimCapabilities for a
+// catchall request without wildcard: a catch-all nobody can serve TLS for
+// is a config error, not a state — refused at the write path, never stored.
+var ErrCatchallNeedsWildcard = errors.New("catchall requires wildcard (no cert could serve the catch-all)")
+
+// SetClaimCapabilities is the ONLY writer of a claim's Wildcard/Catchall
+// fields. AttachClaim's converge deliberately repoints Route and nothing
+// else — it can preserve these fields but never set them (#151's ruling) —
+// so capability changes go through this CAS read-modify-write, which
+// preserves every other field. Owner-gated: a caller who does not hold the
+// claim gets the same neutral ErrHostUnavailable the attach door uses, and
+// a missing claim answers identically (no existence oracle).
+func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, app string, wildcard, catchall bool) (changed bool, err error) {
+ if catchall && !wildcard {
+ return false, ErrCatchallNeedsWildcard
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, ErrHostUnavailable
+ } else if err != nil {
+ return false, err
+ }
+ if cur.Account != account || cur.App != app {
+ return false, ErrHostUnavailable
+ }
+ if cur.Wildcard == wildcard && cur.Catchall == catchall {
+ return false, nil
+ }
+ next := *cur
+ next.Wildcard = wildcard
+ next.Catchall = catchall
+ body, err := json.Marshal(next)
+ if err != nil {
+ return false, err
+ }
+ if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
+ continue
+ } else {
+ return false, err
+ }
+ }
+}
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go
index 078f10ed4..72513ef18 100644
--- a/internal/domains/claims_test.go
+++ b/internal/domains/claims_test.go
@@ -235,6 +235,66 @@ func TestNormalizeAliasHost(t *testing.T) {
}
}
+func TestSetClaimCapabilities(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // Set both capabilities.
+ changed, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", true, true)
+ if err != nil || !changed {
+ t.Fatalf("set: changed=%v err=%v", changed, err)
+ }
+ got, err := GetClaim(ctx, s, "woodstar.app")
+ if err != nil || !got.Wildcard || !got.Catchall {
+ t.Fatalf("stored claim: %+v err=%v", got, err)
+ }
+ // Route, CreatedAt survive the capability write.
+ if got.Route != "woodstar-relay.bab.oncarlos.com" || got.CreatedAt != "2026-08-13T00:00:00Z" {
+ t.Fatalf("capability write must preserve Route+CreatedAt: %+v", got)
+ }
+
+ // Idempotent: same values = no write.
+ changed, err = SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", true, true)
+ if err != nil || changed {
+ t.Fatalf("no-op set: changed=%v err=%v", changed, err)
+ }
+
+ // Turning both off works and reports changed.
+ changed, err = SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", false, false)
+ if err != nil || !changed {
+ t.Fatalf("unset: changed=%v err=%v", changed, err)
+ }
+}
+
+func TestSetClaimCapabilitiesRefusals(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, _, err := AttachClaim(ctx, s, Claim{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", CreatedAt: "2026-08-13T00:00:00Z"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // Catchall without wildcard: refused, never stored.
+ if _, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bab", "woodstar-relay", false, true); !errors.Is(err, ErrCatchallNeedsWildcard) {
+ t.Fatalf("want ErrCatchallNeedsWildcard, got %v", err)
+ }
+ if got, _ := GetClaim(ctx, s, "woodstar.app"); got.Catchall {
+ t.Fatal("refused catchall must not be stored")
+ }
+
+ // Wrong owner: neutral refusal, same error the attach door uses.
+ if _, err := SetClaimCapabilities(ctx, s, "woodstar.app", "bad", "other-app", true, false); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("want ErrHostUnavailable, got %v", err)
+ }
+
+ // No claim at all: neutral, not a bare not-found (no existence oracle).
+ if _, err := SetClaimCapabilities(ctx, s, "unclaimed.example", "bab", "woodstar-relay", true, false); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("want ErrHostUnavailable for missing claim, got %v", err)
+ }
+}
+
func TestHostDenied(t *testing.T) {
deny := []string{"oncarlos.com"}
cases := []struct {
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index c5d4f72f5..235b09a5e 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -721,42 +721,31 @@ func mintedRoute(host, app, account string, src registry.Route) registry.Route {
return rt
}
-// wildcardParent returns the parent domain whose per-account wildcard cert
-// would cover host — host exactly two labels under appsDomain
-// (<app>.<acct>.<appsDomain>) → <acct>.<appsDomain> — or "" when no wildcard
-// could cover it (canary depth, a parent apex, a foreign domain, no apps
-// domain configured).
-func wildcardParent(host, appsDomain string) string {
- if appsDomain == "" {
- return ""
- }
- suffix := "." + strings.ToLower(appsDomain)
- rest, ok := strings.CutSuffix(strings.ToLower(host), suffix)
- if !ok {
- return ""
- }
- if labels := strings.Split(rest, "."); len(labels) == 2 {
- return labels[1] + suffix
- }
- return ""
-}
-
// hostCertExpiry resolves host's cert expiry from the acme cache dir as the
-// LATER of the per-host autocert entry and the covering per-account
-// wildcard's PEM (wildcard-<parent>.pem). Two reasons a bare per-host lookup
-// isn't enough: a wildcard-covered host never gets a per-host entry at all
-// (without the wildcard fallback status would call it certless forever), and
-// a host GRANDFATHERED onto a per-host cert from before its wildcard existed
-// keeps that file on disk forever — autocert stops renewing it once the
-// wildcard starts serving the host, so the stale file's expiry would
-// shadow the wildcard's and status would cry "expiring" over a cert the edge
-// no longer serves. The edge always serves whichever cert is actually valid,
-// so the max of the two is the only truthful answer. 0 = unknown, never a
-// guess.
+// LATER of the per-host autocert entry and the covering wildcard's PEM
+// (wildcard-<parent>.pem). Two reasons a bare per-host lookup isn't enough: a
+// wildcard-covered host never gets a per-host entry at all (without the
+// wildcard fallback status would call it certless forever), and a host
+// GRANDFATHERED onto a per-host cert from before its wildcard existed keeps
+// that file on disk forever — autocert stops renewing it once the wildcard
+// starts serving the host, so the stale file's expiry would shadow the
+// wildcard's and status would cry "expiring" over a cert the edge no longer
+// serves. The edge always serves whichever cert is actually valid, so the max
+// of the two is the only truthful answer. 0 = unknown, never a guess.
+//
+// The wildcard parent is host's generic one-label strip, not an
+// appsDomain-shaped derivation (appsDomain is otherwise unused here): one
+// label folds into "*.", so any host's covering wildcard is its one-label
+// parent's, whether that parent is an <acct>.<apps-domain> derivation, a
+// static domain (go.tito.io), or a delegated customer domain (woodstar.app).
+// Trying the read is cheaper than knowing which — a miss is a miss. An apex
+// host's own strip lands one label short of the parent that names ITS
+// covering wildcard file, so the apex never matches its own wildcard.
func hostCertExpiry(acmeDir, host, appsDomain string) int64 {
perHost := certExpiry(filepath.Join(acmeDir, host))
wildcard := int64(0)
- if parent := wildcardParent(host, appsDomain); parent != "" {
+ if i := strings.IndexByte(host, '.'); i > 0 {
+ parent := strings.ToLower(host[i+1:])
wildcard = certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem"))
}
if wildcard > perHost {
diff --git a/internal/domains/reconcile_internal_test.go b/internal/domains/reconcile_internal_test.go
index a354f3d1c..4075fd2a9 100644
--- a/internal/domains/reconcile_internal_test.go
+++ b/internal/domains/reconcile_internal_test.go
@@ -1,6 +1,6 @@
package domains
-// Internal-package tests for wildcardParent/hostCertExpiry: reconcile_test.go
+// Internal-package tests for hostCertExpiry: reconcile_test.go
// is package domains_test and cannot reach these unexported helpers, so this
// file follows slugname_test.go's existing convention of a same-package test
// file living alongside it.
@@ -58,24 +58,6 @@ func writeWildcardPEM(t *testing.T, path, name string, notAfter time.Time) {
}
}
-func TestWildcardParent(t *testing.T) {
- cases := []struct {
- host, appsDomain, want string
- }{
- {"kass.abs.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
- {"canary.kass.abs.oncarlos.com", "oncarlos.com", ""}, // canary depth: not covered
- {"abs.oncarlos.com", "oncarlos.com", ""}, // apex of a parent
- {"custom.example.com", "oncarlos.com", ""}, // foreign domain
- {"kass.abs.oncarlos.com", "", ""}, // no apps domain configured
- {"Kass.ABS.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
- }
- for _, c := range cases {
- if got := wildcardParent(c.host, c.appsDomain); got != c.want {
- t.Errorf("wildcardParent(%q, %q) = %q, want %q", c.host, c.appsDomain, got, c.want)
- }
- }
-}
-
// A wildcard-covered host has no per-host acme cache entry — its expiry must
// come from the covering wildcard-<parent>.pem instead of reporting 0
// (certless) forever.
@@ -113,6 +95,19 @@ func TestCertExpiryWildcardFallback(t *testing.T) {
if got := hostCertExpiry(dir, "kass.zzz.oncarlos.com", "oncarlos.com"); got != 0 {
t.Fatalf("hostCertExpiry with no files = %d, want 0", got)
}
+
+ // A host under a NON-apps-domain wildcard parent (static go.tito.io /
+ // delegated woodstar.app): the fallback must strip one label and try
+ // the parent's wildcard PEM, not require the apps-domain shape.
+ delegatedNotAfter := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, "wildcard-woodstar.app.pem"), "*.woodstar.app", delegatedNotAfter)
+ if got := hostCertExpiry(dir, "paul.woodstar.app", "oncarlos.com"); got != delegatedNotAfter.Unix() {
+ t.Fatalf("delegated-parent fallback = %d, want %d", got, delegatedNotAfter.Unix())
+ }
+ // The apex itself must NOT match its own wildcard.
+ if got := hostCertExpiry(dir, "woodstar.app", "oncarlos.com"); got != 0 {
+ t.Fatalf("apex must not claim the wildcard: got %d, want 0", got)
+ }
}
// TestMintedRouteCarriesRegionAndDropsSocketWhenFleetPlaced: a minted
diff --git a/internal/edge/alias_test.go b/internal/edge/alias_test.go
index c74d31ac5..2627c867b 100644
--- a/internal/edge/alias_test.go
+++ b/internal/edge/alias_test.go
@@ -347,3 +347,246 @@ func TestCurrentHostsSkipsAliases(t *testing.T) {
t.Fatalf("currentHosts = %v, must NOT contain an attached hostname", got)
}
}
+
+// TestWildcardAliasRequestRouting is Task 4's sub-change 3: a catch-all
+// alias row ("*.<host>", the shape Task 3's reconcile pass derives from a
+// claim's Catchall+Wildcard capability) must resolve one-label subdomains at
+// request time, folding into the SAME upstream map entry the exact primary
+// host resolves to (#151/#152's "identical map value" rule — activator
+// keying, version header and purge tenancy all read off that one entry).
+// Exact hosts always win a collision with the wildcard fallback, a wildcard
+// covers exactly one label (mirroring the TLS cert), and the apex must never
+// match its own wildcard.
+func TestWildcardAliasRequestRouting(t *testing.T) {
+ backend, seenHost := hostEchoBackend(t, "hello from the primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "woodstar-relay.bab.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ // Entry identity: paul.woodstar.app must resolve to the SAME upstream
+ // map value as the primary host — same *httputil.ReverseProxy pointer,
+ // same route row — not a value that merely behaves the same.
+ m := e.current()
+ primary, ok := m["woodstar-relay.bab.oncarlos.com"]
+ if !ok {
+ t.Fatalf("primary route missing from the upstream table")
+ }
+ star, ok := m["*.woodstar.app"]
+ if !ok {
+ t.Fatalf("catch-all alias missing from the upstream table")
+ }
+ if star.proxy != primary.proxy {
+ t.Fatalf("*.woodstar.app proxy %p is not the primary's proxy %p", star.proxy, primary.proxy)
+ }
+ if star.route.Host != primary.route.Host {
+ t.Fatalf("*.woodstar.app route.Host = %q, want the primary's %q", star.route.Host, primary.route.Host)
+ }
+
+ up, ok := e.lookupUpstream("paul.woodstar.app")
+ if !ok {
+ t.Fatalf("lookupUpstream(paul.woodstar.app) missed")
+ }
+ if up.proxy != primary.proxy {
+ t.Fatalf("lookupUpstream(paul.woodstar.app) resolved to a different entry than the primary (proxy %p vs %p)", up.proxy, primary.proxy)
+ }
+
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+ code, body := getHost(t, srv.URL, "paul.woodstar.app")
+ if code != http.StatusOK || body != "hello from the primary" {
+ t.Fatalf("got %d %q, want 200 from the primary's backend", code, body)
+ }
+ if *seenHost != "paul.woodstar.app" {
+ t.Fatalf("backend saw Host %q, want paul.woodstar.app (inbound Host preserved)", *seenHost)
+ }
+
+ // Two labels deep: a wildcard covers exactly one label, mirroring the
+ // TLS cert — a.b.woodstar.app must miss.
+ if _, ok := e.lookupUpstream("a.b.woodstar.app"); ok {
+ t.Fatalf("lookupUpstream(a.b.woodstar.app) hit, want a miss (two labels — wildcard covers one)")
+ }
+
+ // The apex itself must never match its own wildcard: "woodstar.app" has
+ // no dot before "app", so the fallback would (wrongly) probe "*.app",
+ // never "*.woodstar.app" — this pins that the apex is never routed by
+ // its own catch-all.
+ if _, ok := e.lookupUpstream("woodstar.app"); ok {
+ t.Fatalf("lookupUpstream(woodstar.app) hit, want a miss (apex must not match its own wildcard)")
+ }
+
+ // An exact alias row wins over the wildcard fallback.
+ exactBackend, _ := hostEchoBackend(t, "exact wins")
+ if err := reg.Upsert(registry.Route{
+ Host: "other.bab.oncarlos.com", Kind: "service",
+ Addr: exactBackend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert other: %v", err)
+ }
+ upsertAlias(t, reg, "paul.woodstar.app", "other.bab.oncarlos.com")
+ if err := e.Reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ up, ok = e.lookupUpstream("paul.woodstar.app")
+ if !ok {
+ t.Fatalf("lookupUpstream(paul.woodstar.app) missed after exact alias added")
+ }
+ if up.route.Host != "other.bab.oncarlos.com" {
+ t.Fatalf("lookupUpstream(paul.woodstar.app) route.Host = %q, want the EXACT alias's primary other.bab.oncarlos.com", up.route.Host)
+ }
+}
+
+// TestHostPolicyExcludesWildcardFallback is the CRITICAL-1 review fix: the
+// autocert HostPolicy (Allowed) must stay on the EXACT map only, never
+// lookupUpstream's wildcard fallback. A catch-all alias ("*.<domain>") gets
+// its TLS coverage from the WILDCARD cert (wrapGetCertificate checks certFor
+// before ever reaching autocert) — Allowed only gates a NEW per-host ACME
+// order, which is unbounded (attacker-chosen labels) and shared across every
+// tenant on the box (one ACME account). Before this fix, a stranger's SNI
+// random.woodstar.app would pass Allowed and drive a fresh per-host order
+// for as long as the wildcard cert wasn't yet obtained — the normal state
+// right after an attach, before the customer's CNAME exists.
+//
+// Request-time routing (lookupUpstream, unaffected by this fix) must still
+// resolve the same one-label wildcard host — the policy split is HostPolicy
+// vs. serving, not a routing regression.
+func TestHostPolicyExcludesWildcardFallback(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "woodstar-relay.bab.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ // (a) Request routing is unchanged: the wildcard-alias host still
+ // resolves at request time.
+ if _, ok := e.lookupUpstream("paul.woodstar.app"); !ok {
+ t.Fatalf("lookupUpstream(paul.woodstar.app) missed — routing must still resolve via the wildcard alias")
+ }
+
+ // (b) HostPolicy refuses ANY one-label host under the catch-all that
+ // isn't an exact route/alias row of its own — only exact hosts are
+ // certable via the per-host path.
+ if e.Allowed("random.woodstar.app") {
+ t.Fatalf("Allowed(random.woodstar.app) = true, want false — HostPolicy must not follow the wildcard fallback (unbounded per-host ACME orders from attacker-chosen SNI)")
+ }
+ if e.Allowed("paul.woodstar.app") {
+ t.Fatalf("Allowed(paul.woodstar.app) = true, want false — same rule, no exact row for this host either")
+ }
+
+ // An EXACT alias row is still certable, same as any other routed host.
+ upsertAlias(t, reg, "paul.woodstar.app", "woodstar-relay.bab.oncarlos.com")
+ if err := e.Reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ if !e.Allowed("paul.woodstar.app") {
+ t.Fatalf("Allowed(paul.woodstar.app) = false, want true once it has its OWN exact alias row")
+ }
+ // The still-wildcard-only host stays refused.
+ if e.Allowed("random.woodstar.app") {
+ t.Fatalf("Allowed(random.woodstar.app) = true, want false — still no exact row for it")
+ }
+ // The wildcard label itself must never be certable — even though it is
+ // the exact map key backing the catch-all fallback, refused locally
+ // rather than resting on autocert's own IDN rejection of "*".
+ if e.Allowed("*.woodstar.app") {
+ t.Fatalf("Allowed(*.woodstar.app) = true, want false — the wildcard label itself must never be certable")
+ }
+}
+
+// TestHTTPFallbackRedirectsWildcardAlias is the round-2 review fix: the :80
+// handler (httpFallbackHandler) is a THIRD Allowed() consumer the first fix
+// pass missed. It answers a routing question ("does something serve this
+// host on 443?"), not an issuance question, so it must use lookupUpstream —
+// any host that routes via the *.woodstar.app catch-all (paul.woodstar.app,
+// random.woodstar.app, any label) gets the classic 302 to https, even though
+// none of them has its own exact row and Allowed() (HostPolicy) refuses all
+// of them. A host that doesn't route at all still gets unknownHost (404 with
+// no fallback URL configured).
+func TestHTTPFallbackRedirectsWildcardAlias(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "woodstar-relay.bab.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ h := e.httpFallbackHandler()
+
+ for _, host := range []string{"paul.woodstar.app", "random.woodstar.app"} {
+ req := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound {
+ t.Fatalf("%s: status = %d, want 302 — it routes via the *.woodstar.app catch-all even with no exact row", host, rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "https://"+host+"/" {
+ t.Fatalf("%s: Location = %q, want https://%s/", host, loc, host)
+ }
+ }
+
+ // A host that doesn't route at all (no exact route, no matching
+ // wildcard alias) still hits unknownHost — no fallback URL configured
+ // here, so a plain 404.
+ req := httptest.NewRequest(http.MethodGet, "http://nowhere.example.com/", nil)
+ req.Host = "nowhere.example.com"
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("nowhere.example.com: status = %d, want 404 (unknownHost, no fallback URL)", rec.Code)
+ }
+}
+
+// TestHTTPFallbackRedirectsTunnelHost is the final-review fix: the old inline
+// :80 closure gated on Allowed(host), which special-cases the reserved tunnel
+// hostname (see Allowed's own e.tunnels check); the extracted handler gates
+// on lookupUpstream alone, which has no tunnel branch at all, so plain
+// http://<tunnel-host>/ regressed to unknownHost instead of the classic
+// redirect into the hub's own TLS handshake on 443.
+func TestHTTPFallbackRedirectsTunnelHost(t *testing.T) {
+ e, err := New(openReg(t), nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableTunnels(NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"}))
+ if err := e.Reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ h := e.httpFallbackHandler()
+
+ req := httptest.NewRequest(http.MethodGet, "http://tunnel.test/register", nil)
+ req.Host = "tunnel.test"
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound {
+ t.Fatalf("tunnel.test: status = %d, want 302 — the reserved tunnel hostname answers on 443 and must redirect like any other routable host", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "https://tunnel.test/register" {
+ t.Fatalf("tunnel.test: Location = %q, want https://tunnel.test/register", loc)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 5175a40fe..151be4f35 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -677,6 +677,11 @@ func (e *Edge) UpstreamStates() map[string][]UpstreamState {
// Allowed reports whether host is configured — used as the autocert
// HostPolicy, so a certificate is only ever requested for a host we serve.
func (e *Edge) Allowed(host string) bool {
+ // Never certable directly: refuse the wildcard label itself rather than
+ // resting on autocert's own IDN rejection of "*" to keep the bounded set.
+ if strings.HasPrefix(host, "*.") {
+ return false
+ }
// The reserved tunnel hostname is not a registry route, but remote hosts
// dial it over TLS on this same 443 listener — so it must be Allowed, and
// autocert will mint for it. That is intended: it is a hostname the
@@ -684,10 +689,48 @@ func (e *Edge) Allowed(host string) bool {
if e.tunnels != nil && host == e.tunnels.Host() {
return true
}
+ // Deliberately the EXACT map only — not lookupUpstream's wildcard
+ // fallback. A catch-all alias ("*.<domain>") is a bounded, registry-backed
+ // name; autocert's per-host HostPolicy gates a NEW per-host ACME order,
+ // which is unbounded (labels are attacker-chosen) and shared across every
+ // tenant on the box (one ACME account, one rate-limit bucket). Coverage
+ // for a one-label host under a catch-all comes from the WILDCARD cert
+ // (wrapGetCertificate calls certFor before ever reaching autocert); this
+ // exact check only decides per-host HTTP-01 fallback for ROUTED hosts —
+ // widening it to the wildcard fallback would let a stranger's SNI
+ // <anything>.<domain> drive a fresh order and exhaust the box's shared
+ // ACME quota for every tenant on it.
_, ok := e.current()[host]
return ok
}
+// lookupUpstream resolves host to its upstream entry — the request-routing
+// lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy — see the comment
+// there). An exact match (a real route, or a plain #151 alias landed in the
+// same map) always wins; on a miss, a one-label wildcard key folds in:
+// "*.<parent>" is the shape Task 3's reconcile pass derives from a claim's
+// catch-all row, and it lands in the SAME map as every other alias
+// (up.alias = true, primary's route + proxy verbatim) so this fallback
+// resolves to the primary's own entry — the identical map value the
+// #151/#152 agreed rule requires, not a value that merely behaves the same.
+// A wildcard matches exactly one label, mirroring the TLS cert: host[i:]
+// keeps the LEADING dot of the FIRST label only, so a two-label host
+// (a.b.woodstar.app) composes "*.b.woodstar.app" and misses, and the apex
+// itself (woodstar.app) composes "*.app" — never its own "*.woodstar.app" —
+// so it can never match its own wildcard.
+func (e *Edge) lookupUpstream(host string) (upstream, bool) {
+ m := e.current()
+ if up, ok := m[host]; ok {
+ return up, true
+ }
+ if i := strings.IndexByte(host, '.'); i > 0 {
+ if up, ok := m["*"+host[i:]]; ok {
+ return up, true
+ }
+ }
+ return upstream{}, false
+}
+
func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
@@ -717,7 +760,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
e.tunnels.ServeHTTP(w, r)
return
}
- up, ok := e.current()[host]
+ up, ok := e.lookupUpstream(host)
if !ok {
e.unknownHost(w, r)
return
@@ -1153,6 +1196,53 @@ func (e *Edge) unknownHost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown host", http.StatusNotFound)
}
+// httpFallbackHandler is the non-ACME :80 handler passed to
+// autocert.Manager.HTTPHandler (RunWithActivatorReady): known hosts get the
+// classic redirect to https (GET/HEAD only, 302); unknown hosts go to the
+// fallback URL instead of a redirect into a doomed TLS handshake (no cert
+// will ever exist for them).
+//
+// This gate uses lookupUpstream, NOT Allowed: "does something answer this
+// host on 443?" is a ROUTING question (lookupUpstream's job, including its
+// one-label wildcard-alias fallback), not an issuance question (Allowed's
+// job, deliberately exact-only — see its own doc comment). A wildcard-alias
+// host like paul.woodstar.app resolves fine on 443 via the wildcard cert, so
+// plain http:// must redirect it rather than 404. This is safe to widen past
+// Allowed's exact match because a redirect grants nothing: it's a 302 to the
+// SAME host on 443, never a new ACME order — the attacker-supplied-label
+// hazard that keeps Allowed narrow does not apply here.
+//
+// The ACME HTTP-01 challenge path is untouched by this: m.HTTPHandler (the
+// caller) checks the /.well-known/acme-challenge/ prefix — and runs its OWN
+// hostPolicy, which IS Allowed — before ever falling through to this
+// handler, so a challenge request never reaches lookupUpstream at all.
+func (e *Edge) httpFallbackHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := r.Host
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ // The reserved tunnel hostname is answered by the hub itself on 443
+ // (ServeHTTP, and Allowed's same check for issuance) but is not a
+ // registry route, so lookupUpstream alone never sees it — without this,
+ // plain http://<tunnel-host>/ fell through to unknownHost instead of
+ // redirecting like every other routable host.
+ routable := e.tunnels != nil && host == e.tunnels.Host()
+ if !routable {
+ _, routable = e.lookupUpstream(host)
+ }
+ if !routable {
+ e.unknownHost(w, r)
+ return
+ }
+ if r.Method != "GET" && r.Method != "HEAD" {
+ http.Error(w, "Use HTTPS", http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusFound)
+ })
+}
+
// Watch polls the registry for writes from other processes (`carlos add`
// etc., without needing rights to signal us) every 2s and reloads on change.
// It polls a cheap fingerprint of the routes table rather than PRAGMA
@@ -1675,24 +1765,9 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"), w.delegationZone)
}
- // The non-ACME :80 handler. Known hosts get the classic redirect to https
- // (GET/HEAD only, 302); unknown hosts go to the fallback URL instead of a
- // redirect into a doomed TLS handshake (no cert will ever exist for them).
- httpFallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- host := r.Host
- if h, _, err := net.SplitHostPort(host); err == nil {
- host = h
- }
- if !e.Allowed(host) {
- e.unknownHost(w, r)
- return
- }
- if r.Method != "GET" && r.Method != "HEAD" {
- http.Error(w, "Use HTTPS", http.StatusBadRequest)
- return
- }
- http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusFound)
- })
+ // The non-ACME :80 handler. See httpFallbackHandler's doc comment for the
+ // Allowed-vs-lookupUpstream split.
+ httpFallback := e.httpFallbackHandler()
// ReadHeaderTimeout guards the public front door against Slowloris-style
// slow-header connection exhaustion. No ReadTimeout/WriteTimeout — those
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
index 32ce6e5e1..41d65a9f9 100644
--- a/internal/edge/wildcard.go
+++ b/internal/edge/wildcard.go
@@ -182,6 +182,10 @@ func (w *wildcardManager) refresh() {
if static[d] || (appsSuffix != "" && strings.HasSuffix(d, appsSuffix)) {
continue
}
+ if w.delegationZone != "" && len("_acme-challenge."+d+"."+w.delegationZone) > 253 {
+ log.Printf("edge: delegated wildcard %s dropped: composed challenge name exceeds 253 bytes", d)
+ continue
+ }
delegated = append(delegated, d)
}
}
@@ -619,7 +623,40 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
log.Printf("edge: CARLOS_ACME_DELEGATION_ZONE %q invalid — delegation off, per-host and direct DNS-01 continue", rawZone)
zone = ""
}
- return newWildcardManager(static, appsDomain, e.currentHosts, nil, zone, cache, dns)
+ // Delegated source: the claim-derivation pass (Task 3, internal/domains)
+ // writes wildcard_domains; refresh() reads it fresh on every call, same
+ // as e.currentHosts above. A read error returns the LAST successful
+ // list, not nil/empty: refresh() only runs on startup and on OnReload
+ // (run()'s 12h ticker never re-invokes the delegated source itself), so
+ // on a quiet box a transient error would otherwise empty the delegated
+ // set until some unrelated registry write next moves the fingerprint —
+ // unbounded, and it would also stop parentsFor from including a parent
+ // whose cert is already cached and valid (certFor iterates w.parents).
+ // Caching here is what makes the log line's "unchanged this pass"
+ // literally true.
+ var (
+ mu sync.Mutex
+ lastGood []string
+ )
+ delegated := func() []string {
+ wds, err := e.reg.ListWildcardDomains()
+ if err != nil {
+ mu.Lock()
+ out := append([]string(nil), lastGood...)
+ mu.Unlock()
+ log.Printf("edge: wildcard domains read failed: %v — delegated set unchanged this pass", err)
+ return out
+ }
+ out := make([]string, 0, len(wds))
+ for _, w := range wds {
+ out = append(out, w.Host)
+ }
+ mu.Lock()
+ lastGood = out
+ mu.Unlock()
+ return out
+ }
+ return newWildcardManager(static, appsDomain, e.currentHosts, delegated, zone, cache, dns)
}
// wrapGetCertificate serves the wildcard for covered names and hands
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 8ad08d544..eead55f42 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -11,6 +11,8 @@ import (
"errors"
"math/big"
"reflect"
+ "slices"
+ "strings"
"testing"
"time"
@@ -541,3 +543,87 @@ func TestNewWildcardFromEnvInvalidDelegationZoneDegrades(t *testing.T) {
t.Errorf("delegationZone = %q, want empty (delegation off)", w.delegationZone)
}
}
+
+// TestDelegatedSourceReadsRegistry pins Task 4's sub-change 1: newWildcardFromEnv
+// must wire its delegated source to a LIVE registry read (Task 2's
+// ListWildcardDomains), not the nil #168 left as a placeholder. Two
+// wildcard_domains rows, written the way the reconcile pass (Task 3) writes
+// them, must both surface as delegated parents after refresh().
+func TestDelegatedSourceReadsRegistry(t *testing.T) {
+ reg := openReg(t) // edge_test.go's registry fixture
+ for _, wd := range []registry.WildcardDomain{
+ {Host: "woodstar.app", Account: "acct", App: "app", CreatedAt: 1},
+ {Host: "example.net", Account: "acct2", App: "app2", CreatedAt: 2},
+ } {
+ if err := reg.UpsertWildcardDomain(wd); err != nil {
+ t.Fatalf("UpsertWildcardDomain(%s): %v", wd.Host, err)
+ }
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "acme.oncarlos.com")
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("manager should be on")
+ }
+ w.refresh()
+
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ for _, host := range []string{"woodstar.app", "example.net"} {
+ if !slices.Contains(parents, host) {
+ t.Errorf("parents = %v, want %s (from a wildcard_domains registry row)", parents, host)
+ }
+ if !w.isDelegated(host) {
+ t.Errorf("isDelegated(%s) = false, want true", host)
+ }
+ }
+
+ // IMPORTANT-1 (review fix): a read error after a successful read must
+ // return the LAST-GOOD list, not empty it out — refresh() only runs at
+ // startup and on OnReload (run()'s 12h ticker never re-invokes the
+ // delegated source), so on a quiet box an emptied set would stay empty
+ // until some unrelated write next moved the registry fingerprint, and
+ // certFor would stop serving an already-cached, still-valid wildcard for
+ // every delegated parent in the meantime.
+ if err := reg.Close(); err != nil {
+ t.Fatalf("close registry: %v", err)
+ }
+ got := w.delegated()
+ want := []string{"example.net", "woodstar.app"}
+ slices.Sort(got)
+ if !slices.Equal(got, want) {
+ t.Fatalf("delegated() after a read error = %v, want the last-good list %v", got, want)
+ }
+}
+
+// TestRefreshDropsOverlongChallengeNames pins sub-change 2: a delegated host
+// whose composed DNS-01 challenge name ("_acme-challenge."+host+"."+zone)
+// would exceed the DNS 253-byte FQDN limit must never reach parents — an
+// AuthorizeOrder for it would fail at the CA anyway, and dropping it here
+// (rather than surfacing that failure through obtain()'s hourly retry loop)
+// keeps a single pathological claim from producing an endless retry log line
+// for a name that can never succeed.
+func TestRefreshDropsOverlongChallengeNames(t *testing.T) {
+ zone := "acme.oncarlos.com"
+ // "_acme-challenge." (16) + longHost (262) + "." + zone (17) > 253.
+ longHost := strings.Repeat("a", 250) + ".example.com"
+ w := newWildcardManager(nil, "", func() []string { return nil },
+ func() []string { return []string{longHost} },
+ zone, autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ if slices.Contains(parents, longHost) {
+ t.Fatalf("parents = %v, want %s dropped (composed challenge name exceeds 253 bytes)", parents, longHost)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index d67dfb6bd..d051d5195 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -379,6 +379,19 @@ CREATE TABLE IF NOT EXISTS route_aliases (
dns_checked_at INTEGER NOT NULL DEFAULT 0,
cert_expires_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
+);`); err != nil {
+ return err
+ }
+ // wildcard_domains is a derived table (a later reconcile pass owns every
+ // row, materializing domains.Claim records whose Wildcard capability is
+ // set into it) — a plain CREATE TABLE IF NOT EXISTS, same shape as
+ // route_aliases above: additive, so existing DBs migrate on open.
+ if _, err := r.sql.Exec(`
+CREATE TABLE IF NOT EXISTS wildcard_domains (
+ host TEXT PRIMARY KEY,
+ account TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL DEFAULT 0
);`); err != nil {
return err
}
@@ -1420,11 +1433,58 @@ func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error {
return err
}
-// Fingerprint summarizes the routes, fleets, fleet_boxes and route_aliases
-// tables over a fresh connection, for the edge's cheap change poll. It is
-// content-accurate: it hashes the actual VALUES of the reload-relevant
-// columns, not a
-// length-weighted aggregate, so two DIFFERENT values of the SAME length
+// WildcardDomain is one derived row of the wildcard_domains table: a
+// customer domain whose claim carries wildcard:true and whose primary
+// route lives on this box — i.e. a delegated wildcard-cert parent the
+// edge should obtain *.host for. Wholly pass-owned, like route_aliases:
+// every row traces to a claim, so removal needs no ownership flag.
+type WildcardDomain struct {
+ Host string
+ Account string
+ App string
+ CreatedAt int64
+}
+
+// ListWildcardDomains returns every wildcard domain, ordered by host.
+func (r *Registry) ListWildcardDomains() ([]WildcardDomain, error) {
+ rows, err := r.sql.Query(`SELECT host, account, app, created_at FROM wildcard_domains ORDER BY host`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []WildcardDomain
+ for rows.Next() {
+ var w WildcardDomain
+ if err := rows.Scan(&w.Host, &w.Account, &w.App, &w.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, w)
+ }
+ return out, rows.Err()
+}
+
+// UpsertWildcardDomain inserts or updates a row by host.
+func (r *Registry) UpsertWildcardDomain(w WildcardDomain) error {
+ _, err := r.sql.Exec(`
+INSERT INTO wildcard_domains (host, account, app, created_at)
+VALUES (?, ?, ?, ?)
+ON CONFLICT(host) DO UPDATE SET account=excluded.account, app=excluded.app, created_at=excluded.created_at`,
+ w.Host, w.Account, w.App, w.CreatedAt)
+ return err
+}
+
+// DeleteWildcardDomain removes host's row. Idempotent: a host already gone
+// is a success, same contract as DeleteAlias.
+func (r *Registry) DeleteWildcardDomain(host string) error {
+ _, err := r.sql.Exec(`DELETE FROM wildcard_domains WHERE host = ?`, host)
+ return err
+}
+
+// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases and
+// wildcard_domains tables over a fresh connection, for the edge's cheap
+// change poll. It is content-accurate: it hashes the actual VALUES of the
+// reload-relevant columns, not a length-weighted aggregate, so two
+// DIFFERENT values of the SAME length
// (channel canary/1 -> canary/2, a git short-sha version bump, a rotated
// sha256 token hash, a failover restamp box-a -> box-b) change the result
// exactly like any other write. `grants` stays deliberately excluded
@@ -1474,6 +1534,17 @@ func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error {
// the guarantee true for any future writer that mutates a row into a
// byte-identical shape.
//
+// wildcard_domains (Task 4 carry from Task 2's review) is hashed for the
+// same reload-latency reason as fleets/fleet_boxes above: the claim-derived
+// delegated wildcard parents (internal/domains' ReconcileAliases, Task 3)
+// land here, and wildcard.go's refresh() runs off Edge.OnReload — which
+// only fires when Watch's 2s Fingerprint poll actually moves. Without this
+// section a new delegated parent (or one going away) would be invisible to
+// that poll and wait for the wildcard manager's own 12h tick instead of the
+// usual few-second bound every other route/alias mutation gets. All four
+// columns are hashed — there is no cadence-stamped column on this table to
+// exclude, unlike route_aliases' dns_status/cert_expires_at.
+//
// The ORDER BY clauses are what make the digest deterministic across polls;
// a hypothetical reordering would only cost a redundant reload (idempotent),
// never a missed change — the failure mode worth engineering against is the
@@ -1576,5 +1647,26 @@ FROM fleet_boxes ORDER BY fleet, label`)
return "", err
}
+ // wildcard_domains: see the doc comment above — this is what lets a
+ // claim-derived delegated wildcard parent reach wildcard.go's refresh()
+ // on the edge's normal 2s poll instead of waiting on its own 12h tick.
+ fmt.Fprint(h, "\x1dwildcard_domains\x1e")
+ wrows, err := db.Query(`SELECT host, account, app, created_at FROM wildcard_domains ORDER BY host`)
+ if err != nil {
+ return "", err
+ }
+ defer wrows.Close()
+ for wrows.Next() {
+ var host, account, app string
+ var createdAt int64
+ if err := wrows.Scan(&host, &account, &app, &createdAt); err != nil {
+ return "", err
+ }
+ fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%d\x1e", host, account, app, createdAt)
+ }
+ if err := wrows.Err(); err != nil {
+ return "", err
+ }
+
return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
}
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index f5e2b0b83..b2b9fe8a6 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -2155,6 +2155,48 @@ func TestFingerprintCoversAliases(t *testing.T) {
}
}
+// TestFingerprintCoversWildcardDomains is Task 4's carry-over from Task 2's
+// review: wildcard_domains rows (Task 3's reconcile-derived delegated
+// wildcard parents) were entirely absent from Fingerprint, so a new
+// delegated parent landing (or leaving) the registry would not move the
+// digest the edge's 2s Watch poll keys off — it would sit unrouted until the
+// wildcard manager's own 12h tick, rather than reaching wildcard.go's
+// refresh() (registered via Edge.OnReload) within the usual 2s bound. This
+// pins the fix the same way TestFingerprintCoversAliases pins route_aliases:
+// an Upsert and a Delete of a wildcard_domains row must both move the
+// fingerprint.
+func TestFingerprintCoversWildcardDomains(t *testing.T) {
+ reg := openTestRegistry(t)
+ fp1, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "myapp", CreatedAt: 1}); err != nil {
+ t.Fatalf("UpsertWildcardDomain: %v", err)
+ }
+ fp2, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp2 == fp1 {
+ t.Fatal("Fingerprint blind to a wildcard_domains UpsertWildcardDomain")
+ }
+
+ if err := reg.DeleteWildcardDomain("woodstar.app"); err != nil {
+ t.Fatalf("DeleteWildcardDomain: %v", err)
+ }
+ fp3, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp3 == fp2 {
+ t.Fatal("Fingerprint blind to a DeleteWildcardDomain")
+ }
+ if fp3 != fp1 {
+ t.Fatalf("Fingerprint after add+remove = %s, want back to the original %s", fp3, fp1)
+ }
+}
+
// TestMigrateIdempotentWithAliases pins migrate()'s additive contract for the
// new table: opening an already-migrated registry a second time (CREATE
// TABLE IF NOT EXISTS route_aliases) must not error.
@@ -2184,3 +2226,32 @@ func TestMigrateIdempotentWithAliases(t *testing.T) {
t.Fatalf("route_aliases row lost across a second Open: %+v", got)
}
}
+
+func TestWildcardDomains(t *testing.T) {
+ r := openTestRegistry(t)
+
+ if err := r.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", CreatedAt: 100}); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.UpsertWildcardDomain(WildcardDomain{Host: "example.org", Account: "bad", App: "other", CreatedAt: 200}); err != nil {
+ t.Fatal(err)
+ }
+ // Upsert by host: same host, new owner fields win.
+ if err := r.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "woodstar-relay2", CreatedAt: 300}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := r.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[0].Host != "example.org" || got[1].Host != "woodstar.app" || got[1].App != "woodstar-relay2" {
+ t.Fatalf("list = %+v", got)
+ }
+ if err := r.DeleteWildcardDomain("example.org"); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = r.ListWildcardDomains()
+ if len(got) != 1 || got[0].Host != "woodstar.app" {
+ t.Fatalf("after delete: %+v", got)
+ }
+}