Merge pull request #180 from carlosframework/claim-derived-wildcards
Claim-derived wildcard state: delegated parents, catch-all aliases, capability door (Plan C)
19 files changed,
+2084
−153
apimode.go+30 −16cmd/console/main.go+6 −0domains_cli.go+46 −7domains_cli_test.go+139 −14internal/console/apidomains.go+185 −7internal/console/apidomains_test.go+509 −10internal/console/server.go+42 −1internal/domains/aliases.go+128 −22internal/domains/aliases_test.go+229 −0internal/domains/claims.go+46 −0internal/domains/claims_test.go+60 −0internal/domains/reconcile.go+21 −32internal/domains/reconcile_internal_test.go+14 −19internal/edge/alias_test.go+243 −0internal/edge/edge.go+94 −19internal/edge/wildcard.go+38 −1internal/edge/wildcard_test.go+86 −0internal/registry/registry.go+97 −5internal/registry/registry_test.go+71 −0
diff --git a/apimode.go b/apimode.go| index 53fa244c0..5f4b00840 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1690,28 +1690,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 0951bc54f..638fd13a3 100644 |
| --- a/internal/edge/alias_test.go |
| +++ b/internal/edge/alias_test.go |
| @@ -275,3 +275,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 91cdf2ede..d55526719 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -548,6 +548,11 @@ func (e *Edge) currentHosts() []string { |
| // 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 |
| @@ -555,10 +560,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 { |
| @@ -588,7 +631,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 |
| @@ -1024,6 +1067,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 |
| @@ -1398,24 +1488,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 124ce77a6..250ca1721 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -346,6 +346,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 |
| } |
| @@ -1261,11 +1274,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 |
| @@ -1309,6 +1369,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 |
| @@ -1411,5 +1482,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 0f8717b7b..0e0ab87ec 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1991,6 +1991,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. |
| @@ -2020,3 +2062,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) |
| + } |
| +} |