domains: opt-in proof of control; namespaces are their accounts'; fleet boxes see only their own claims (#215)
1. Proof of control, opt-in per deployment (CONSOLE_DOMAIN_PROOF, off by default; off is exactly the old first-writer-wins behaviour, with no TXT lookups). With it on, a member's claim is pending until a TXT record at _carlos-challenge.<host> carries the account's token (domains.ChallengeToken: derived from deployment, account and host, so another account's record cannot be replayed). Pending claims derive nothing in ReconcileAliases (no alias, paths, wildcard or catch-all, hence no certificate), hold no parent for an instance catch-all, and a first-route attach declares no record until proven. A pending claim yields to a proven attach at any time and to anyone once its reservation lapses, seven days after the first attach; re-attaching never renews it. Repeating the attach (including a plain re-attach of a pending first-route claim) verifies it; `carlos domains verify` does that for you, and attach, list, deploy and the Settings page print the exact record. Claims without a "pending" key read as verified. The account's own <sqid>.<apps-domain> namespace, operator-delegated one-label names and #209 platform names need no record. DNS goes through Config.LookupTXT, the one TXT seam shared with the catch-all proof. 2. Another account's <sqid>.<apps-domain> namespace (live or deleted) is reserved in domainHostPolicy on every deployment, regardless of CONSOLE_RESERVE_PLATFORM_DOMAINS. A first route may not be minted under a sqid-shaped namespace that is not the caller's own, and edge.parentsFor derives a wildcard parent only from sqid-shaped labels, so invented labels cannot order wildcard certs. 3. The fleet store door no longer serves claim bodies across tenants: a box reads only its own account's verified claims, with created_by stripped, and every other claim body is the door's byte-identical 404. The listing still names every claimed hostname, because the alias pass decides "someone else claimed this name" from it (instance catch-alls).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
27 files changed,
+1749
−130
AGENTS.md+19 −0README.md+6 −0apimode.go+45 −8cmd/console/main.go+17 −0deploy.go+49 −4docs/site/cli.md+27 −9domains_cli.go+106 −6internal/console/apidomains.go+50 −8internal/console/appdomains.go+5 −0internal/console/appsettings.go+14 −5internal/console/domainattach.go+52 −7internal/console/domainpolicy.go+159 −0internal/console/domainproof_test.go+425 −0internal/console/domainstate.go+20 −1internal/console/domainstate_test.go+5 −0internal/console/server.go+23 −3internal/console/store.go+7 −5internal/console/view.go+8 −0internal/domains/aliases.go+12 −1internal/domains/claims.go+184 −21internal/domains/claims_proof_test.go+243 −0internal/edge/fleetapi_e2e_test.go+77 −0internal/edge/storedoor.go+111 −32internal/edge/storedoor_test.go+59 −10internal/edge/wildcard.go+11 −1internal/edge/wildcard_parents_test.go+5 −2internal/edge/wildcard_test.go+10 −7
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index 6a925d2c7..cedb4c57c 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -429,6 +429,10 @@ one person owns, and the rule refused ordinary names there. Every deployment |
| still refuses the console's own hostnames by exact name, the steering host, |
| CONSOLE_DENY_DOMAINS, and a hostname another app's route holds. |
| +Two refusals do NOT depend on the setting (#215): every deployment refuses a |
| +name in another live account's `<sqid>.<apps-domain>` namespace, and a first |
| +route under a sqid-shaped namespace that is not the caller's own. |
| + |
| ## The operator's own hostnames (#209, Paul, 2026-10-03) |
| The deny list has two tiers (domainHostPolicy, internal/console/domainpolicy.go). |
| @@ -478,3 +482,18 @@ hold it together; keep all of them: |
| - A label two instances share derives nothing. |
| - **Derivation takes only provisioned, non-sidecar, non-replica routes of |
| the claim's own account and app.** |
| + |
| +## Proof of control for custom domains is opt-in (#215, 2026-10-04) |
| + |
| +With CONSOLE_DOMAIN_PROOF=true, an attached hostname is stored pending and is |
| +neither routed nor given a certificate until a TXT record at |
| +`_carlos-challenge.<host>` carries the account's token (`carlos domains |
| +verify`). A pending claim yields to a proven attach from another account, and |
| +lapses after seven days. Without it, attach is first-writer-wins exactly as |
| +before: an account can take over a name whose DNS already points at the |
| +edge, or hold any name it never controlled. A deployment that hosts apps for |
| +other people must set it. Roll the fleet first: a box from before #215 reads |
| +a pending claim as live and routes it. The default is off because the |
| +2026-08-26/27 scope ruling kept first-writer-wins; turning it on per |
| +deployment is the operator's decision. A pending claim does not hold a |
| +parent for an instance catch-all. |
diff --git a/README.md b/README.md| index 9f4675e57..12b34c020 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -541,6 +541,12 @@ publish an inflated instance count. |
| `CONSOLE_DENY_DOMAINS`. A value that is not a boolean stops the console at |
| boot. |
| - `CONSOLE_DENY_DOMAINS`: comma-separated hostnames in the deployment's own domains (flagship: `carloku.com`). `carlos domains attach` refuses each one, and every name under it, to members. An owner of the ops account, or an address on `CARLOS_SYSTEM_OPERATORS`, can still attach one to an app with `carlos domains attach --platform`, and after that only they can change or detach it. The console's own hostnames, the steering host, the tunnel host and, with `CONSOLE_RESERVE_PLATFORM_DOMAINS`, everything under the apps domain stay refused to everyone. |
| +- `CONSOLE_DOMAIN_PROOF` — set to `true` to require proof of control before |
| + an attached hostname is served: a TXT record at `_carlos-challenge.<host>` |
| + (`carlos domains verify`). A deployment that hosts apps for other people |
| + must set it, after the fleet runs a build that knows pending claims. |
| + **Unset = false**: attach is first-writer-wins, as before. A value that is |
| + not a boolean stops the console at boot. |
| - `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks |
| compare against, read by both the agent (checks) and the console |
| (guidance text), so the two can never name different addresses. Default: |
diff --git a/apimode.go b/apimode.go| index 7ed847883..bb873a5d5 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -3757,8 +3757,18 @@ type apiDomainRecord struct { |
| Instances bool `json:"instances,omitempty"` |
| Via string `json:"via,omitempty"` |
| Reserves string `json:"reserves,omitempty"` |
| + // Verified is false while the claim waits for proof that this account |
| + // controls the hostname (#215); Records then carries the TXT record that |
| + // proves it. nil is a console that predates proof, where every claim |
| + // was live — read it as verified (see domainVerified). |
| + Verified *bool `json:"verified,omitempty"` |
| } |
| +// domainVerified reads a list row's Verified, treating an absent field as |
| +// verified: a console that predates proof of control has no other kind of |
| +// claim. |
| +func domainVerified(d apiDomainRecord) bool { return d.Verified == nil || *d.Verified } |
| + |
| // apiDomainDNSRecord is one record the member must create (#299 §2b) — the |
| // structured form of Guidance. Records sharing a Set are ONE alternative |
| // (with steering the door offers a CNAME or a set of A records); a client |
| @@ -3815,7 +3825,14 @@ func (e *domainListError) Unwrap() error { return e.err } |
| // sees them. route and kind together are the door's 400 — an alias target |
| // and a first-route mint are two different instructions. |
| func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (created bool, resolvedRoute string, err error) { |
| - return s.postDomainAttach(ctx, a, app, domainAttachIn{ |
| + res, err := s.domainAttachFull(ctx, a, app, host, route, kind, channel, wildcard, catchall, messages...) |
| + return res.Created, res.Route, err |
| +} |
| + |
| +// domainAttachFull is domainAttach returning the whole answer, for the |
| +// callers that must tell a member their hostname still needs proving. |
| +func (s *apiSession) domainAttachFull(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (apiDomainAttachResult, error) { |
| + return s.postDomainAttachFull(ctx, a, app, domainAttachIn{ |
| Host: host, Route: route, Kind: kind, Channel: channel, |
| Wildcard: wildcard, Catchall: catchall, Message: firstMutationMessage(messages), |
| }) |
| @@ -3835,23 +3852,43 @@ type domainAttachIn struct { |
| Platform bool `json:"platform,omitempty"` |
| } |
| +// apiDomainAttachResult is the attach door's whole answer. Verified false |
| +// means the claim is stored but not served until the TXT record in Verify |
| +// is published and the attach repeated (#215). A console that predates |
| +// proof sends neither field and every attach it makes is live, so an absent |
| +// verified decodes as true. |
| +type apiDomainAttachResult struct { |
| + Created bool |
| + Route string |
| + Verified bool |
| + Verify *apiDomainDNSRecord |
| +} |
| + |
| func (s *apiSession) postDomainAttach(ctx context.Context, a apiAcct, app string, in domainAttachIn) (created bool, resolvedRoute string, err error) { |
| + res, err := s.postDomainAttachFull(ctx, a, app, in) |
| + return res.Created, res.Route, err |
| +} |
| + |
| +func (s *apiSession) postDomainAttachFull(ctx context.Context, a apiAcct, app string, in domainAttachIn) (apiDomainAttachResult, error) { |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in) |
| if err != nil { |
| - return false, "", err |
| + return apiDomainAttachResult{}, err |
| } |
| if status != http.StatusOK { |
| - return false, "", apiErrorFromBody(status, body) |
| + return apiDomainAttachResult{}, apiErrorFromBody(status, body) |
| } |
| var out struct { |
| - Host string `json:"host"` |
| - Route string `json:"route"` |
| - Created bool `json:"created"` |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Created bool `json:"created"` |
| + Verified *bool `json:"verified"` |
| + Verify *apiDomainDNSRecord `json:"verify"` |
| } |
| if err := json.Unmarshal(body, &out); err != nil { |
| - return false, "", fmt.Errorf("domains attach: unreadable response: %w", err) |
| + return apiDomainAttachResult{}, fmt.Errorf("domains attach: unreadable response: %w", err) |
| } |
| - return out.Created, out.Route, nil |
| + return apiDomainAttachResult{Created: out.Created, Route: out.Route, |
| + Verified: out.Verified == nil || *out.Verified, Verify: out.Verify}, nil |
| } |
| // domainDetach releases a hostname claim. Always 200 on a live console |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 804cfc5ad..1e6ad2364 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -312,6 +312,7 @@ func serve() { |
| DenyDomains: denyDomains(), |
| TunnelHost: tunnelHost(), |
| ReservePlatformDomains: reservePlatformDomains(), |
| + DomainProof: domainProof(), |
| StoreProvisioner: storeProvisioner, |
| StoreScanner: storeScanner, |
| ScanSeedFromDeprecatedEnv: seedAntivirus, |
| @@ -927,6 +928,22 @@ func reservePlatformDomains() bool { |
| return b |
| } |
| +// domainProof parses CONSOLE_DOMAIN_PROOF, the opt-in for proof of control |
| +// on custom hostnames (#215, console.Config.DomainProof). Unset means false; |
| +// a value strconv.ParseBool cannot read stops the boot, for the reason |
| +// reservePlatformDomains gives. |
| +func domainProof() bool { |
| + v := strings.TrimSpace(os.Getenv("CONSOLE_DOMAIN_PROOF")) |
| + if v == "" { |
| + return false |
| + } |
| + b, err := strconv.ParseBool(v) |
| + if err != nil { |
| + log.Fatalf("console: CONSOLE_DOMAIN_PROOF %q is not a boolean (use true or false)", v) |
| + } |
| + return b |
| +} |
| + |
| // denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and |
| // everything under them, that `carlos domains attach` refuses (issue #151). |
| // This is the operator's list of names the console cannot derive on its own: |
diff --git a/deploy.go b/deploy.go| index fb820eb6e..14e133fc7 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -984,10 +984,23 @@ func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, ap |
| // aliased, so the two cases get two different lines, and the alias one |
| // never names a channel — an alias serves the primary's channel, not |
| // one of its own. |
| - _, resolvedRoute, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel, nil, nil, firstMutationMessage(messages)) |
| + res, err := sess.domainAttachFull(ctx, acct, app, host, "", attachKind, channel, nil, nil, firstMutationMessage(messages)) |
| if err != nil { |
| log.Fatalf("deploy: attach %s: %v", host, err) |
| } |
| + resolvedRoute := res.Route |
| + if !res.Verified { |
| + // Stored, not served (#215): the deploy carries on (the release |
| + // still ships to the app's own hosts), and says plainly that this |
| + // hostname waits on a record only the member can create. Re-running |
| + // the deploy repeats the attach, which is what verifies it. |
| + fmt.Fprintf(stdout, "%s %s, waiting for you to prove you control it\n", colorFor(stdout).warn("reserved"), host) |
| + if res.Verify != nil && res.Verify.Name != "" { |
| + fmt.Fprintf(stdout, "Add: TXT %s -> %s\n", res.Verify.Name, res.Verify.Value) |
| + } |
| + fmt.Fprintln(stdout, "Then: re-run this deploy, or carlos domains verify --app "+app+" "+host) |
| + return |
| + } |
| if resolvedRoute == host { |
| fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel) |
| } else { |
| @@ -1024,7 +1037,10 @@ func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomai |
| } |
| for _, d := range domains { |
| if d.Host == host { |
| - return false |
| + // A claim still waiting for proof of control (#215) is attached |
| + // again: repeating the attach is what verifies it once the TXT |
| + // record exists, so a re-run deploy finishes the job. |
| + return !domainVerified(d) |
| } |
| } |
| return true |
| @@ -1108,6 +1124,14 @@ func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcc |
| // something else is broken". Older consoles send no state and fall |
| // through to the DNSStatus arms below, unchanged. |
| switch { |
| + case d.State == stateUnverified: |
| + // Not served until proven (#215), whatever DNS says. The door's |
| + // records carry the TXT that proves it beside the pointing ones. |
| + msg := fmt.Sprintf("%s is not served yet: prove you control it first.", host) |
| + if recs := formatDomainRecords(d.Records); recs != "" { |
| + msg += " " + recs |
| + } |
| + return msg + fmt.Sprintf(" Then re-run this deploy, or 'carlos domains verify --app %s --account %s %s' — the shipped release is already promoted.", app, acct.Seg, host), true |
| case d.State == stateIssuingCert: |
| return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ |
| "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", |
| @@ -1581,6 +1605,7 @@ const ( |
| stateLive = "live" |
| stateChecking = "checking" |
| stateUnknown = "unknown" |
| + stateUnverified = "unverified" |
| ) |
| // formatDomainRecords renders the door's structured records as one short |
| @@ -1593,15 +1618,35 @@ func formatDomainRecords(recs []apiDomainDNSRecord) string { |
| } |
| var order []string |
| groups := map[string][]string{} |
| + // Required records are needed whichever alternative is picked (the |
| + // ACME delegation, the #215 ownership TXT), so they are listed after |
| + // the alternatives rather than offered as one of them. |
| + var required []string |
| for _, r := range recs { |
| + line := fmt.Sprintf("%s %s -> %s", r.Type, r.Name, r.Value) |
| + if r.Required { |
| + required = append(required, line) |
| + continue |
| + } |
| if _, seen := groups[r.Set]; !seen { |
| order = append(order, r.Set) |
| } |
| - groups[r.Set] = append(groups[r.Set], fmt.Sprintf("%s %s -> %s", r.Type, r.Name, r.Value)) |
| + groups[r.Set] = append(groups[r.Set], line) |
| } |
| var alts []string |
| for _, set := range order { |
| alts = append(alts, strings.Join(groups[set], ", ")) |
| } |
| - return "Create: " + strings.Join(alts, " — or — ") + "." |
| + out := "" |
| + if len(alts) > 0 { |
| + out = "Create: " + strings.Join(alts, " — or — ") + "." |
| + } |
| + if len(required) > 0 { |
| + lead := "Also create: " |
| + if out == "" { |
| + lead = "Create: " |
| + } |
| + out = strings.TrimSpace(out + " " + lead + strings.Join(required, ", ") + ".") |
| + } |
| + return out |
| } |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 4656ac7df..a75e46179 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -813,15 +813,33 @@ carlos domains attach --app hello www.example.com |
| ``` |
| `--route` picks which route to point at, and you can leave it off when the app |
| -has exactly one instance record. The hostname comes live on the box owning |
| -that route at its next domains pass, certificate and all. The `list` verb |
| -joins the claims to the fleet's own readings sweep, so DNS state, certificate |
| -expiry and delegation are readable without leaving the terminal. |
| - |
| -The console always refuses its own hostname. By default it accepts every |
| -other name, including names under the console's domain and under the apps |
| -domain. That fits a self-hosted deployment, where one person owns the whole |
| -zone. A deployment that hosts apps for other people sets |
| +has exactly one instance record. The `list` verb joins the claims to the |
| +fleet's own readings sweep, so DNS state, certificate expiry and delegation |
| +are readable without leaving the terminal. The hostname comes live on the |
| +box owning its route at its next domains pass, certificate and all. |
| + |
| +On a deployment that sets `CONSOLE_DOMAIN_PROOF=true`, a hostname is not |
| +served until you prove you control it. `attach` reserves the name and prints |
| +a TXT record to add, at `_carlos-challenge.<hostname>`. Once the record is |
| +there, run: |
| + |
| +```sh |
| +carlos domains verify --app hello www.example.com |
| +``` |
| + |
| +If the record is already in place when you attach, `attach` verifies it |
| +straight away. Until then the name stays in the `unverified` state, and a |
| +reservation nobody proves lapses after seven days. Someone who does prove |
| +control can take it over sooner. Names under your own account's platform |
| +domain need no record. `--channel` on `verify` is only needed when the |
| +hostname will be the app's first route and the app follows more than one |
| +channel. |
| + |
| +The console always refuses its own hostname, and names under another |
| +account's platform domain (`<their-account>.<apps-domain>`). By default it |
| +accepts every other name, including names under the console's domain and |
| +under the apps domain. That fits a self-hosted deployment, where one person |
| +owns the whole zone. A deployment that hosts apps for other people sets |
| `CONSOLE_RESERVE_PLATFORM_DOMAINS=true` on the console. Then the console also |
| refuses every name under the apps domain and under its own hostname, because |
| those names belong to the operator and not to a tenant. |
diff --git a/domains_cli.go b/domains_cli.go| index 748423410..7fa80e256 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -12,12 +12,13 @@ import ( |
| "flag" |
| "fmt" |
| "log" |
| + "os" |
| "sort" |
| "strings" |
| "time" |
| ) |
| -const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags] |
| +const domainsUsage = `usage: carlos domains <attach|verify|detach|list|paths> [flags] |
| attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [flags] <hostname> |
| claim a customer's hostname for the app, pointed at |
| @@ -27,12 +28,19 @@ const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags] |
| one changes it, an unset flag leaves it as it was. |
| --platform attaches a hostname in this deployment's own |
| domains. Platform operators only. |
| + Where the deployment requires proof of control, the |
| + hostname is not served until you add the TXT record |
| + attach prints. |
| attach --app <a> '*.<parent>' |
| an instance catch-all: each of the app's instances also |
| answers at <label>.<parent>, where <label> is its host's |
| first label. <parent> must already be attached to an app |
| in this account. Signed-in people only; takes no other |
| flag. Detach it with: detach --app <a> '*.<parent>'. |
| + verify --app <a> [--channel <c>] [flags] <hostname> |
| + check the TXT record and, once it is there, make the |
| + hostname live. --channel is for a hostname that will be |
| + the app's first route, when the app follows several. |
| detach --app <a> [flags] <hostname> |
| release a hostname claim |
| list --app <a> [flags] |
| @@ -57,6 +65,9 @@ at its next domains pass. |
| const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| ` |
| +const domainsVerifyUsage = `usage: carlos domains verify --app <a> [--channel <c>] [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| +` |
| + |
| const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| ` |
| @@ -75,6 +86,9 @@ func runDomains(args []string) { |
| case "attach": |
| runDomainsAttach(args[1:]) |
| return |
| + case "verify": |
| + runDomainsVerify(args[1:]) |
| + return |
| case "detach": |
| runDomainsDetach(args[1:]) |
| return |
| @@ -127,13 +141,25 @@ func runDomainsAttach(args []string) { |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, domainsAttachUsage) |
| ctx := context.Background() |
| - created, resolvedRoute, err := sess.postDomainAttach(ctx, acct, app2, domainAttachIn{ |
| + res, err := sess.postDomainAttachFull(ctx, acct, app2, domainAttachIn{ |
| Host: host, Route: strings.TrimSpace(*route), |
| Wildcard: wildcardArg, Catchall: catchallArg, Platform: *platform, |
| }) |
| if err != nil { |
| log.Fatalf("domains attach: %v", err) |
| } |
| + created, resolvedRoute := res.Created, res.Route |
| + if !res.Verified { |
| + // Stored, not served (#215). Said first, before any "attached" |
| + // wording a member would read as "it's live". |
| + fmt.Fprintf(stdout, "%s %s -> %s, waiting for you to prove you control it\n", colorFor(stdout).warn("reserved"), host, resolvedRoute) |
| + if !printDomainDNSLine(ctx, sess, acct, app2, host) { |
| + printDomainVerifyHint(res.Verify, app2, host) |
| + return |
| + } |
| + printDomainVerifyHint(nil, app2, host) |
| + return |
| + } |
| if strings.HasPrefix(host, "*.") { |
| // An instance catch-all: no route of its own to report. |
| resolvedRoute = "<label>." + strings.TrimPrefix(host, "*.") + " for each instance" |
| @@ -156,10 +182,13 @@ func runDomainsAttach(args []string) { |
| // not reached yet with no readable answer) — the attach line above already |
| // reported the thing that matters (whether the write happened), so a DNS |
| // line this call cannot back up is skipped rather than guessed at. |
| -func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) { |
| +// |
| +// It reports whether it printed anything, so a caller with something it must |
| +// not leave unsaid (the proof record) can say it itself when this could not. |
| +func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) bool { |
| domains, err := sess.domainList(ctx, acct, app) |
| if err != nil { |
| - return |
| + return false |
| } |
| for _, d := range domains { |
| if d.Host != host { |
| @@ -175,11 +204,11 @@ func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app |
| fmt.Fprintf(stdout, "STATE: %s\n", d.State) |
| } |
| printDomainRecords(d.Records) |
| - return |
| + return true |
| } |
| if d.DNSStatus == "pointing" { |
| fmt.Fprintln(stdout, "DNS: pointing") |
| - return |
| + return true |
| } |
| dns := d.DNSStatus |
| if dns == "" { |
| @@ -190,8 +219,79 @@ func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app |
| } else { |
| fmt.Fprintf(stdout, "DNS: %s\n", dns) |
| } |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// printDomainVerifyHint says exactly which record proves control of host |
| +// and what to run once it exists. A console that sent no record (it cannot |
| +// happen with verified false, but an answer is not a promise) gets the |
| +// command alone. |
| +func printDomainVerifyHint(v *apiDomainDNSRecord, app, host string) { |
| + if v != nil && v.Name != "" { |
| + fmt.Fprintf(stdout, "Add: TXT %s -> %s\n", v.Name, v.Value) |
| + } |
| + fmt.Fprintf(stdout, "Then: carlos domains verify --app %s %s\n", app, host) |
| +} |
| + |
| +// runDomainsVerify proves control of an attached hostname (#215). It is the |
| +// attach door again rather than a door of its own: repeating an attach is |
| +// what verifies a pending claim, because the door looks the TXT record up |
| +// whenever the claim is still pending. This verb only works out WHICH |
| +// attach to repeat — the claim's own route, or a first route for a claim |
| +// that has none — so the member does not have to remember how it was made. |
| +func runDomainsVerify(args []string) { |
| + fs := flag.NewFlagSet("domains verify", flag.ExitOnError) |
| + app := appFlag(fs, "app the hostname belongs to") |
| + channel := fs.String("channel", "", "channel a first-route hostname serves (only needed when the app follows several)") |
| + consoleFlag := consoleOption(fs, "console to verify through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, domainsVerifyUsage) |
| + fs.Parse(args) |
| + host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(fs.Arg(0)), ".")) |
| + if host == "" || fs.NArg() > 1 { |
| + log.Fatal(domainsVerifyUsage) |
| + } |
| + sess := requireAPISession("verify", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, domainsVerifyUsage) |
| + ctx := context.Background() |
| + recs, err := sess.domainList(ctx, acct, app2) |
| + if err != nil { |
| + log.Fatalf("domains verify: %v", err) |
| + } |
| + var cur *apiDomainRecord |
| + for i := range recs { |
| + if recs[i].Host == host { |
| + cur = &recs[i] |
| + } |
| + } |
| + if cur == nil { |
| + log.Fatalf("domains verify: %s is not attached to %s — carlos domains attach it first", host, app2) |
| + } |
| + if domainVerified(*cur) { |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("verified"), host) |
| + printDomainDNSLine(ctx, sess, acct, app2, host) |
| return |
| } |
| + kind, route := "", cur.Route |
| + if route == "" || route == host { |
| + kind, route = "static", "" |
| + } |
| + res, err := sess.domainAttachFull(ctx, acct, app2, host, route, kind, strings.TrimSpace(*channel), nil, nil) |
| + if err != nil { |
| + log.Fatalf("domains verify: %v", err) |
| + } |
| + if !res.Verified { |
| + fmt.Fprintf(stdout, "%s %s: the TXT record is not there yet (DNS changes can take a few minutes to show)\n", colorFor(stdout).warn("not verified"), host) |
| + printDomainVerifyHint(res.Verify, app2, host) |
| + os.Exit(1) |
| + } |
| + fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("verified"), host, res.Route) |
| + printDomainDNSLine(ctx, sess, acct, app2, host) |
| } |
| func runDomainsDetach(args []string) { |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index c6c95ba6b..edf9e7f37 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -416,6 +416,18 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| + // The caller's own PENDING first-route claim (#215): a plain re-attach |
| + // is how it gets verified, and only the first-route core knows how — |
| + // look for the proof, and declare the record once it is there. Without |
| + // this the own-first-route short-circuit below would answer for it |
| + // without ever looking, and the CLI would read that as attached. Own |
| + // claim only, so it reveals nothing the list door does not. |
| + if want := strings.ToLower(strings.TrimSpace(in.Route)); in.Kind == "" && (want == "" || want == host) { |
| + if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && |
| + cur.Account == acct.Sqid && cur.App == app && cur.Route == "" && cur.Pending { |
| + in.Kind, in.Route = "static", "" |
| + } |
| + } |
| if in.Kind == "static" { |
| // No deployment-route check here: this branch delegates to |
| // attachDomain, which carries the guard immediately before each of |
| @@ -436,10 +448,10 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| route := res.Claim.Route |
| - if res.FirstRoute { |
| + if res.FirstRoute || (res.Pending && route == "") { |
| route = host |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": res.Created}) |
| + writeJSON(w, http.StatusOK, attachAnswer(host, route, res.Created, res.Pending, res.Verify)) |
| return |
| } |
| // A first-route claim this app already holds stays first-route. Without |
| @@ -499,7 +511,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, ref.Status, ref.Msg) |
| return |
| } |
| - created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{ |
| + created, changed, pending, err := domains.AttachClaimProven(ctx, s.cfg.Releases, domains.Claim{ |
| Host: host, Account: acct.Sqid, App: app, Route: route, |
| // UTC, like every other timestamp written to the bucket: these claims |
| // are read on boxes in other regions. |
| @@ -512,7 +524,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // 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). |
| - }) |
| + }, time.Now(), s.domainProver(ctx, host, acct.Sqid)) |
| if errors.Is(err, domains.ErrHostUnavailable) { |
| // Exactly these bytes, whoever holds it: naming the holder — or even |
| // distinguishing "held by another app of yours" from "held by a |
| @@ -534,14 +546,17 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if changed { |
| s.auditActivity(acct, app, principal, Activity{ |
| Action: "domain-attach", TargetType: "instance", Target: route, Message: message, |
| - Detail: "attached hostname " + host + " -> " + route + platformNote(in.Platform) + " (cli)", |
| + Detail: "attached hostname " + host + " -> " + route + platformNote(in.Platform) + pendingNote(pending) + " (cli)", |
| }) |
| // The claim is what a box's domains pass mints an alias row from, so |
| // this is a box-consumed write (carlos-deploy §3): announce it, and |
| // the hostname is serving seconds after the call instead of on the |
| // hour. Strictly after the write and the audit — a serial that moved |
| // for a write that failed would send every box looking for nothing. |
| - s.bumpSerial(ctx) |
| + // Not for a pending claim, which every box skips (#215). |
| + if !pending { |
| + 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, |
| @@ -552,7 +567,20 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created}) |
| + writeJSON(w, http.StatusOK, attachAnswer(host, route, created, pending, s.domainChallenge(host, acct.Sqid))) |
| +} |
| + |
| +// attachAnswer is the attach door's 200 body. verified is always present |
| +// (#215): false means the claim is stored but reserved, not served, until |
| +// the TXT record in verify is published and the attach is repeated. verify |
| +// is sent only then, so a client that predates the field sees exactly the |
| +// body it always did for an ordinary attach. |
| +func attachAnswer(host, route string, created, pending bool, verify dnsRecord) map[string]any { |
| + out := map[string]any{"host": host, "route": route, "created": created, "verified": !pending} |
| + if pending { |
| + out["verify"] = verify |
| + } |
| + return out |
| } |
| // wildFailMsg is the ONE 429 body both the peek (wildFailTripped) and the |
| @@ -1012,6 +1040,10 @@ type domainListItem struct { |
| // has proved the parent in DNS (domains.CatchallProofName), otherwise |
| // "instances" — only the names of instances that exist. Additive. |
| Reserves string `json:"reserves,omitempty"` |
| + // Verified is false for a claim whose owner has not yet proved control |
| + // of the hostname (#215); state is then "unverified" and Records carries |
| + // the TXT record that proves it, marked required. Always present. |
| + Verified bool `json:"verified"` |
| } |
| // handleAPIDomainList answers `carlos domains list`: this app's claims, each |
| @@ -1113,7 +1145,8 @@ func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[s |
| item.CertExpiresAt = reading.CertExpiresAt |
| item.CheckedAt = reading.DNSCheckedAt |
| } |
| - item.State, item.Reason, item.Detail = domainState(reading, seen, warm, now) |
| + item.State, item.Reason, item.Detail = claimState(c, reading, seen, warm, now) |
| + item.Verified = !c.Pending |
| // "unresolved" means the lookup FAILED (our resolver, not the customer's |
| // record) — pointing them at "create this record" guidance would blame |
| // them for our outage. Only "" (no answer yet) and "elsewhere" (a real, |
| @@ -1125,6 +1158,15 @@ func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[s |
| // anything for the member to do. |
| item.Records = domainRecords(c.Host, targets, c.Wildcard, s.delegationZone) |
| } |
| + if c.Pending { |
| + // The proof record, in BOTH forms, under the same lockstep rule as |
| + // the pointing advice above: a member reading the sentence and an |
| + // app reading the records must be told to create the same thing. |
| + v := s.domainChallenge(c.Host, c.Account) |
| + item.Guidance = verifyGuidance(v) + " " + item.Guidance |
| + item.Guidance = strings.TrimSpace(item.Guidance) |
| + item.Records = append(item.Records, v) |
| + } |
| if delegation { |
| if s.delegationCNAMEDelegated(ctx, c.Host) { |
| item.DelegationStatus = "delegated" |
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go| index 4822789b5..3baedf822 100644 |
| --- a/internal/console/appdomains.go |
| +++ b/internal/console/appdomains.go |
| @@ -82,6 +82,11 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if res.FirstRoute { |
| msg = "Attached " + host + " as the app's first route, following " + res.Channel + "." |
| } |
| + if res.Pending { |
| + // #215: stored, not served. The flash carries the exact record, so |
| + // the member never has to go looking for what to do next. |
| + msg = "Saved " + host + ", but it is not live yet. " + verifyGuidance(res.Verify) |
| + } |
| s.setFlash(w, "ok", msg) |
| http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther) |
| } |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index a84ae58e4..3f03d1ad1 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -40,6 +40,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -669,9 +670,9 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, |
| now := time.Now() |
| readings := aliasReadings(ctx, s.cfg.Releases, now) |
| warm := warmRecords(ctx, s.cfg.Releases) |
| - state := func(host string) (st, reason, detail string) { |
| - reading, seen := readings[host] |
| - return domainState(reading, seen, warm[host], now) |
| + state := func(c domains.Claim) (st, reason, detail string) { |
| + reading, seen := readings[c.Host] |
| + return claimState(c, reading, seen, warm[c.Host], now) |
| } |
| claimed := make(map[string]bool, len(claims)) |
| for _, c := range claims { |
| @@ -687,11 +688,19 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, |
| } |
| if i, ok := have[strings.ToLower(c.Host)]; ok { |
| out[i].Claimed = true |
| - out[i].State, out[i].Reason, out[i].Detail = state(out[i].Domain) |
| + out[i].State, out[i].Reason, out[i].Detail = state(c) |
| + if c.Pending { |
| + out[i].Guidance = verifyGuidance(s.domainChallenge(c.Host, c.Account)) |
| + } |
| continue |
| } |
| dv := claimDomainView(c.Host, s.dnsTargets()) |
| - dv.State, dv.Reason, dv.Detail = state(c.Host) |
| + dv.State, dv.Reason, dv.Detail = state(c) |
| + if c.Pending { |
| + // The proof comes first: until it is published nothing else on |
| + // the row can take effect (#215). |
| + dv.Guidance = verifyGuidance(s.domainChallenge(c.Host, c.Account)) + " " + dv.Guidance |
| + } |
| out = append(out, dv) |
| } |
| return out |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 7cf722b61..2abbb76d3 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -82,11 +82,18 @@ type doorRefusal struct { |
| // "a new route is being created". Created is domains.AttachClaim's own |
| // answer: this call brought the claim into existence (false on a re-attach, |
| // which the claim writer treats as a converge or a no-op, never a refusal). |
| +// |
| +// Pending is the #215 answer: the claim is stored but its writer has not yet |
| +// proved control of the hostname, so nothing serves it, and on the |
| +// first-route branch no record was declared. Verify is the TXT record that |
| +// proves it; re-attaching once it is published turns the claim verified. |
| type attachResult struct { |
| Claim domains.Claim |
| Created bool |
| FirstRoute bool // a declared record backs this attach |
| Channel string // channel the first-route record follows ("" for alias) |
| + Pending bool |
| + Verify dnsRecord |
| } |
| // attachDomain claims host for acct's app and, when the app has no route to |
| @@ -145,28 +152,43 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| } |
| + // The claim write asks for proof of control (#215) through prove: a TXT |
| + // lookup, made only when the answer can change what is written (see |
| + // domains.AttachClaimProven), so a re-attach of a verified claim costs no |
| + // DNS round trip. |
| + prove := s.domainProver(ctx, host, acct.Sqid) |
| + verify := s.domainChallenge(host, acct.Sqid) |
| + |
| // --- alias attach: the app already has a route to serve from --------- |
| if route != "" { |
| if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { |
| return attachResult{}, ref |
| } |
| - created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim) |
| + created, changed, pending, err := domains.AttachClaimProven(ctx, s.cfg.Releases, claim, time.Now(), prove) |
| if err != nil { |
| return attachResult{}, claimRefusal(host, err) |
| } |
| if changed { |
| s.auditActivity(acct, app, principal, Activity{ |
| Action: "domain-attach", TargetType: "instance", Target: route, Message: message, |
| - Detail: host + " → " + route + platformNote(platform), |
| + Detail: host + " → " + route + platformNote(platform) + pendingNote(pending), |
| }) |
| - s.bumpSerial(ctx) |
| + // A pending claim is invisible to every box, so writing one |
| + // gives the fleet nothing to converge on. |
| + if !pending { |
| + s.bumpSerial(ctx) |
| + } |
| } |
| - return attachResult{Claim: claim, Created: created}, nil |
| + return attachResult{Claim: claim, Created: created, Pending: pending, Verify: verify}, nil |
| } |
| if s.directAppsDomainHost(host) { |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, hostUnavailableMsg} |
| } |
| + if s.firstRouteOutsideNamespace(host, acct.Sqid) { |
| + return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| + "that hostname is platform-managed"} |
| + } |
| // --- first-route attach: this host becomes the app's primary -------- |
| if kindFlag != "" && kindFlag != "static" { |
| @@ -180,10 +202,24 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { |
| return attachResult{}, ref |
| } |
| - created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim) |
| + created, changed, pending, err := domains.AttachClaimProven(ctx, s.cfg.Releases, claim, time.Now(), prove) |
| if err != nil { |
| return attachResult{}, claimRefusal(host, err) |
| } |
| + if pending { |
| + // No record until the claim is proven. The record is what makes the |
| + // host a real route, served and certified on whatever box adopts it, |
| + // so declaring it now would serve an unproven name, which is the |
| + // takeover #215 closes. Re-attaching once the TXT record is |
| + // published lands here verified and declares it then. |
| + if changed { |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "domain-attach", TargetType: "instance", Target: host, Message: message, |
| + Detail: host + " (first route, " + channel + ")" + pendingNote(true), |
| + }) |
| + } |
| + return attachResult{Claim: claim, Created: created, Channel: channel, Pending: true, Verify: verify}, nil |
| + } |
| rec := provision.Record{ |
| Schema: 1, Host: host, App: app, Account: acct.Sqid, |
| Channel: channel, Kind: "static", |
| @@ -213,7 +249,7 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| }) |
| s.bumpSerial(ctx) |
| } |
| - return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel}, nil |
| + return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel, Verify: verify}, nil |
| } |
| // Unreadable (malformed, or a body that lies about its |
| // identity) or written by a door other than this one. Not ours |
| @@ -244,7 +280,7 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| Detail: host + " (first route, " + channel + ")", |
| }) |
| s.bumpSerial(ctx) |
| - return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel}, nil |
| + return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel, Verify: verify}, nil |
| } |
| // detachDomain releases host's claim and, when the claim minted the app's |
| @@ -523,3 +559,12 @@ func platformNote(platform bool) string { |
| } |
| return "" |
| } |
| + |
| +// pendingNote marks an audit row for a claim written pending (#215), so the |
| +// account's activity log does not read as though the hostname went live. |
| +func pendingNote(pending bool) string { |
| + if pending { |
| + return " (waiting for DNS verification)" |
| + } |
| + return "" |
| +} |
diff --git a/internal/console/domainpolicy.go b/internal/console/domainpolicy.go| index 052a5c9c0..5d12255b0 100644 |
| --- a/internal/console/domainpolicy.go |
| +++ b/internal/console/domainpolicy.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "context" |
| "log" |
| "slices" |
| "strings" |
| @@ -74,6 +75,12 @@ func (s *Server) domainHostPolicy(host, account string) hostPolicy { |
| if strings.HasSuffix(host, provision.SidecarSuffix) { |
| return hostReserved |
| } |
| + // Another live account's <sqid>.<apps-domain> namespace (#215): reserved |
| + // to everyone, the operator's door included, and whatever |
| + // ReservePlatformDomains says. |
| + if s.otherAccountsNamespace(host, account) { |
| + return hostReserved |
| + } |
| allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) |
| if allowed { |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| @@ -131,3 +138,155 @@ func (s *Server) principalPlatformOperator(p Principal) bool { |
| // platformOperatorMsg is the refusal for a caller who asks for the platform |
| // door, or writes to a platform hostname's claim, without being an operator. |
| const platformOperatorMsg = "that hostname is platform-managed. only a platform operator (an ops account owner, or an address on CARLOS_SYSTEM_OPERATORS) can attach or change it" |
| + |
| +// appsNamespace returns the label of the account namespace host falls in — |
| +// "bes" for bes.<apps-domain> and for anything under it, such as |
| +// web.bes.<apps-domain> — or "" when host is not under the apps domain or the |
| +// label could not be an account (domains.ValidSqid). Shape only: whether an |
| +// account with that sqid exists is the caller's question. |
| +func (s *Server) appsNamespace(host string) string { |
| + domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| + if domain == "" { |
| + return "" |
| + } |
| + rest, ok := strings.CutSuffix(host, "."+domain) |
| + if !ok || rest == "" { |
| + return "" |
| + } |
| + label := rest[strings.LastIndex(rest, ".")+1:] |
| + if !domains.ValidSqid(label) { |
| + return "" |
| + } |
| + return label |
| +} |
| + |
| +// otherAccountsNamespace reports whether host is in ANOTHER account's |
| +// namespace (#215): <sqid>.<apps-domain> or anything under it, where sqid |
| +// names an account (live or deleted) that is not account. |
| +// |
| +// Refused on every deployment, with or without ReservePlatformDomains. That |
| +// setting decides whether the apps domain is the operator's to hand out, |
| +// which is a real choice on a self-hosted box; it was never meant to decide |
| +// whether one tenant may take another's addresses. Without this, an account |
| +// could attach new.<their-sqid>.<apps-domain>, which serves at once through |
| +// the DNS wildcard and their account wildcard certificate, and then stop |
| +// them creating an app of that name (claimApp's apexHolder check). |
| +// |
| +// Only labels some account has held. A sqid-shaped label nobody has held |
| +// stays an ordinary name, as it always was on a deployment that does not reserve the apps |
| +// domain (www and app are both sqid-shaped). What such a name cannot do is |
| +// become a first route, because a first route under the apps domain would |
| +// make the edge order a wildcard for its label (firstRouteOutsideNamespace). |
| +// |
| +// A lookup failure refuses: the cost of a false refusal is a retry, and the |
| +// cost of a false admission is another tenant's address. |
| +func (s *Server) otherAccountsNamespace(host, account string) bool { |
| + label := s.appsNamespace(host) |
| + if label == "" || label == account { |
| + return false |
| + } |
| + if s.store == nil { |
| + return true // no account table to ask: refuse, as on a lookup failure |
| + } |
| + // AccountAnyBySqid, not AccountBySqid: a deleted account's namespace |
| + // stays reserved too. Its tombstone keeps the sqid from ever being |
| + // reissued, and a name claimed under it would keep serving through the |
| + // apps-domain wildcard DNS while looking like that account's address. |
| + a, _, err := s.store.AccountAnyBySqid(label) |
| + if err != nil { |
| + log.Printf("console: domains: account lookup for %s: %v — refusing", host, err) |
| + return true |
| + } |
| + return a != nil |
| +} |
| + |
| +// firstRouteOutsideNamespace reports whether a FIRST-ROUTE attach of host by |
| +// account would mint a route in a namespace under the apps domain that is not |
| +// the account's own: two or more labels under it, with a sqid-shaped label |
| +// that is not this account's sqid. Such a route is Provisioned, and the edge |
| +// derives an account wildcard parent, <label>.<apps-domain>, from every |
| +// Provisioned route two labels under the apps domain (edge.parentsFor), so |
| +// each invented label would order a wildcard certificate against the |
| +// deployment's shared ACME budget. otherAccountsNamespace already refuses a |
| +// live account's label; this covers the labels no account holds YET. |
| +// |
| +// Labels that could never be a sqid are left alone here: parentsFor derives |
| +// no parent from them, so a first route under one costs one ordinary |
| +// per-host certificate, as any custom domain does. |
| +func (s *Server) firstRouteOutsideNamespace(host, account string) bool { |
| + domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| + if domain == "" { |
| + return false |
| + } |
| + rest, ok := strings.CutSuffix(host, "."+domain) |
| + if !ok || !strings.Contains(rest, ".") { |
| + return false |
| + } |
| + label := s.appsNamespace(host) |
| + return label != "" && label != account |
| +} |
| + |
| +// domainProofExempt reports whether account's claim on host needs no TXT |
| +// proof, because the platform, not a customer zone, answers for the name: |
| +// the account's own namespace under the apps domain, and the one-label names |
| +// an operator delegated to it (CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS). Neither |
| +// has a zone the member could publish a record in, and both were already |
| +// settled as this account's by the deployment's own config. |
| +func (s *Server) domainProofExempt(host, account string) bool { |
| + if s.appsNamespace(host) == account && account != "" { |
| + return true |
| + } |
| + // A platform hostname (#209) is in the deployment's own zones: only a |
| + // platform operator gets past the door to attach one, and its zone is |
| + // the operator's, so the operator's say-so is the proof. |
| + if s.domainHostPolicy(host, account) == hostPlatform { |
| + return true |
| + } |
| + return domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) |
| +} |
| + |
| +// challengeDeployment is the deployment name ownership tokens are bound to |
| +// (domains.ChallengeToken): the console's own hostname. |
| +func (s *Server) challengeDeployment() string { |
| + if hosts := s.consoleHosts(); len(hosts) > 0 { |
| + return hosts[0] |
| + } |
| + return "" |
| +} |
| + |
| +// domainChallenge is the TXT record that proves account controls host. |
| +func (s *Server) domainChallenge(host, account string) dnsRecord { |
| + return dnsRecord{ |
| + Type: "TXT", Name: domains.ChallengeName(host), |
| + Value: domains.ChallengeToken(s.challengeDeployment(), account, host), |
| + TTL: domainRecordTTL, Set: "verify", Required: true, |
| + } |
| +} |
| + |
| +// domainProver returns the proof AttachClaimProven asks for: true when the |
| +// name is proof-exempt, otherwise whether ChallengeName(host) carries this |
| +// account's token right now. Any lookup failure is "not proven" — the claim |
| +// is then written pending and the member retries, which is the safe |
| +// direction for a check whose whole job is to refuse the unproven. |
| +// |
| +// With DomainProof off (the default) every attach is proven by definition, |
| +// so claims are written verified and the resolver is never asked: the |
| +// behaviour this door had before #215. |
| +func (s *Server) domainProver(ctx context.Context, host, account string) func() bool { |
| + return func() bool { |
| + if !s.cfg.DomainProof || s.domainProofExempt(host, account) { |
| + return true |
| + } |
| + want := domains.ChallengeToken(s.challengeDeployment(), account, host) |
| + got, err := s.lookupTXT(ctx, domains.ChallengeName(host)) |
| + if err != nil { |
| + return false |
| + } |
| + for _, v := range got { |
| + if strings.TrimSpace(v) == want { |
| + return true |
| + } |
| + } |
| + return false |
| + } |
| +} |
diff --git a/internal/console/domainproof_test.go b/internal/console/domainproof_test.go| new file mode 100644 |
| index 000000000..1b6ad7993 |
| --- /dev/null |
| +++ b/internal/console/domainproof_test.go |
| @@ -0,0 +1,425 @@ |
| +package console |
| + |
| +// Proof of control at the /domains doors (#215, opt-in: CONSOLE_DOMAIN_PROOF), and the apps-domain |
| +// namespace rules that came with it. Before this, a custom hostname went to |
| +// whichever account attached it first: one whose DNS already pointed at the |
| +// edge was served for the claimant with a platform-issued certificate, and |
| +// any name could be held forever by someone who never controlled it. And |
| +// without CONSOLE_RESERVE_PLATFORM_DOMAINS, one account could attach names |
| +// inside another's <sqid>.<apps-domain> namespace. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// noTXT is a zone with no proof in it. |
| +func noTXT(calls *int) func(context.Context, string) ([]string, error) { |
| + return func(_ context.Context, name string) ([]string, error) { |
| + *calls++ |
| + return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} |
| + } |
| +} |
| + |
| +// txtFor is a zone that carries exactly one account's proof for one host, |
| +// beside unrelated TXT noise. |
| +func txtFor(s *Server, account, host string) func(context.Context, string) ([]string, error) { |
| + return func(_ context.Context, name string) ([]string, error) { |
| + if name != domains.ChallengeName(host) { |
| + return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} |
| + } |
| + return []string{"v=spf1 -all", domains.ChallengeToken(s.challengeDeployment(), account, host)}, nil |
| + } |
| +} |
| + |
| +type proofAttachResp struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Created bool `json:"created"` |
| + Verified *bool `json:"verified"` |
| + Verify dnsRecord `json:"verify"` |
| +} |
| + |
| +type proofListResp struct { |
| + Domains []struct { |
| + Host string `json:"host"` |
| + State string `json:"state"` |
| + Verified bool `json:"verified"` |
| + Guidance string `json:"guidance"` |
| + Records []dnsRecord `json:"records"` |
| + } `json:"domains"` |
| +} |
| + |
| +// The audit's takeover, at the CLI door: a member attaches a hostname whose |
| +// DNS they do not control. On main this was a live claim — served the moment |
| +// a box reconciled it, certificate and all. Now it is stored pending, the |
| +// answer says exactly which record proves it, nothing is announced to the |
| +// fleet, and repeating the attach once the record exists verifies it. |
| +func TestAPIDomainAttachWithoutProofIsPending(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-proof") |
| + fx.srv.cfg.DomainProof = true |
| + lookups := 0 |
| + fx.srv.lookupTXT = noTXT(&lookups) |
| + const host = "victim.example.com" |
| + before := serialValue(t, fx) |
| + |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach = %d %s, want 200", code, out) |
| + } |
| + var got proofAttachResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantToken := domains.ChallengeToken(fx.srv.challengeDeployment(), "bab", host) |
| + if got.Verified == nil || *got.Verified || got.Verify.Type != "TXT" || |
| + got.Verify.Name != "_carlos-challenge."+host || got.Verify.Value != wantToken { |
| + t.Fatalf("attach answer = %s, want verified false and the TXT record %s = %s", out, "_carlos-challenge."+host, wantToken) |
| + } |
| + if lookups != 1 { |
| + t.Errorf("TXT lookups = %d, want 1", lookups) |
| + } |
| + if c := claimOf(t, fx, host); !c.Pending { |
| + t.Fatalf("claim = %+v, want pending", *c) |
| + } |
| + if after := serialValue(t, fx); after != before { |
| + t.Errorf("serial moved %q -> %q for a pending claim no box acts on", before, after) |
| + } |
| + |
| + // The list says the same thing, in both forms. |
| + code, _, out = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s", code, out) |
| + } |
| + var list proofListResp |
| + if err := json.Unmarshal([]byte(out), &list); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list.Domains) != 1 || list.Domains[0].State != StateUnverified || list.Domains[0].Verified { |
| + t.Fatalf("list = %s, want one unverified row", out) |
| + } |
| + row := list.Domains[0] |
| + found := false |
| + for _, r := range row.Records { |
| + if r.Type == "TXT" && r.Name == "_carlos-challenge."+host && r.Value == wantToken && r.Required { |
| + found = true |
| + } |
| + } |
| + if !found || !strings.Contains(row.Guidance, wantToken) { |
| + t.Fatalf("list row = %+v, want the TXT record in records (required) and in guidance", row) |
| + } |
| + |
| + // Publish the record and attach again: verified, announced. |
| + fx.srv.lookupTXT = txtFor(fx.srv, "bab", host) |
| + code, _, out = rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) |
| + if code != http.StatusOK { |
| + t.Fatalf("re-attach = %d %s", code, out) |
| + } |
| + got = proofAttachResp{} |
| + _ = json.Unmarshal([]byte(out), &got) |
| + if got.Verified == nil || !*got.Verified || got.Verify.Name != "" { |
| + t.Fatalf("re-attach answer = %s, want verified true and no verify record", out) |
| + } |
| + if c := claimOf(t, fx, host); c.Pending || c.PendingUntil != "" { |
| + t.Fatalf("claim = %+v, want verified", *c) |
| + } |
| + if after := serialValue(t, fx); after == before { |
| + t.Error("verifying the claim did not bump the serial") |
| + } |
| +} |
| + |
| +// Another account's proof is not this account's proof: the token is |
| +// account-bound, so a record published for one account cannot be replayed. |
| +func TestAPIDomainAttachRefusesAnotherAccountsProof(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-replay") |
| + fx.srv.cfg.DomainProof = true |
| + const host = "shop.example.com" |
| + fx.srv.lookupTXT = txtFor(fx.srv, "cdef", host) |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach = %d %s", code, out) |
| + } |
| + if c := claimOf(t, fx, host); !c.Pending { |
| + t.Fatalf("claim = %+v, want pending: the TXT record proves account cdef, not bab", *c) |
| + } |
| +} |
| + |
| +// Squatting: an unproven claim no longer holds a name against its owner. A |
| +// proven attach supersedes it, an unproven one meets the ordinary 409 — the |
| +// same bytes a verified holder produces, so a refusal still never says how |
| +// firmly a name is held. |
| +func TestAPIDomainProvenOwnerSupersedesSquatter(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-squat") |
| + fx.srv.cfg.DomainProof = true |
| + ctx := context.Background() |
| + const squatted, held = "shop.example.com", "held.example.com" |
| + if _, _, _, err := domains.AttachClaimProven(ctx, fx.rel, domains.Claim{ |
| + Host: squatted, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com", |
| + }, time.Now(), func() bool { return false }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: held, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + lookups := 0 |
| + fx.srv.lookupTXT = noTXT(&lookups) |
| + code1, _, body1 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io")) |
| + code2, _, body2 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")) |
| + if code1 != http.StatusConflict || code1 != code2 || body1 != body2 { |
| + t.Fatalf("unproven attach over a reservation = %d %q, over a verified claim = %d %q; want the same 409", code1, body1, code2, body2) |
| + } |
| + |
| + fx.srv.lookupTXT = txtFor(fx.srv, "bab", squatted) |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io")) |
| + if code != http.StatusOK { |
| + t.Fatalf("proven attach over a reservation = %d %s, want 200", code, out) |
| + } |
| + if c := claimOf(t, fx, squatted); c.Account != "bab" || c.App != "titogo" || c.Pending { |
| + t.Fatalf("claim = %+v, want bab/titogo verified", *c) |
| + } |
| + // A verified claim is still first-writer-wins against proof: proving |
| + // control of a name someone else already proved is a support case, not |
| + // something the door decides. |
| + fx.srv.lookupTXT = txtFor(fx.srv, "bab", held) |
| + if code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")); code != http.StatusConflict { |
| + t.Fatalf("proven attach over a verified claim = %d, want 409", code) |
| + } |
| +} |
| + |
| +// The first-route branch writes NO record for an unproven claim, because the |
| +// record is what turns the host into a real route; the attach that proves it |
| +// declares the record then. |
| +func TestAttachFirstRouteWaitsForProof(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + fx.srv.cfg.DomainProof = true |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + const host = "story.example.com" |
| + lookups := 0 |
| + fx.srv.lookupTXT = noTXT(&lookups) |
| + ops := &opStore{Store: fx.rel} |
| + fx.srv.cfg.Releases = ops |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach refused %+v", ref) |
| + } |
| + if !res.Pending || res.FirstRoute || res.Verify.Name != domains.ChallengeName(host) { |
| + t.Fatalf("result = %+v, want pending, no first route, and the proof record", res) |
| + } |
| + keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(keys) != 0 { |
| + t.Fatalf("records for an unproven first route = %v, want none", keys) |
| + } |
| + if ops.opIndex("put "+release.SerialKey) >= 0 { |
| + t.Errorf("ops %v: serial bumped for a pending claim", ops.ops) |
| + } |
| + |
| + fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host) |
| + res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("re-attach refused %+v", ref) |
| + } |
| + if res.Pending || !res.FirstRoute { |
| + t.Fatalf("result = %+v, want verified first route", res) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { |
| + t.Fatalf("record after proof: %v", err) |
| + } |
| +} |
| + |
| +// Item 2 of #215: another account's <sqid>.<apps-domain> namespace is refused |
| +// whatever CONSOLE_RESERVE_PLATFORM_DOMAINS says, a first route may not be |
| +// minted under a namespace no account holds yet (that would order a |
| +// wildcard certificate for the invented label), and the account's own |
| +// namespace needs no TXT proof — there is no zone the member could publish |
| +// one in. |
| +func TestAppsDomainNamespacesBelongToTheirAccounts(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + fx.srv.cfg.DomainProof = true |
| + if fx.srv.cfg.ReservePlatformDomains { |
| + t.Fatal("fixture reserves platform domains; this test is about deployments that do not") |
| + } |
| + if err := fx.st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + lookups := 0 |
| + fx.srv.lookupTXT = noTXT(&lookups) |
| + |
| + for _, host := range []string{"new.cdef.oncarlos.com", "cdef.oncarlos.com", "a.b.cdef.oncarlos.com"} { |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != http.StatusUnprocessableEntity || ref.Msg != "that hostname is platform-managed" { |
| + t.Errorf("attach %s in another account's namespace = %+v, want the platform-managed refusal", host, ref) |
| + } |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, host); err == nil { |
| + t.Errorf("claim written for %s", host) |
| + } |
| + } |
| + // The CLI door asks the same question. |
| + if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) { |
| + t.Error("domainHostDenied admits another account's namespace") |
| + } |
| + |
| + // An unheld sqid-shaped namespace: no first route. |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "x.zzz.oncarlos.com", "", "", "paul@keymail.dev"); ref == nil { |
| + t.Error("first route under an unheld namespace was accepted") |
| + } |
| + |
| + // The account's own namespace: attached and verified without a lookup. |
| + own := "blog." + fx.sqid + ".oncarlos.com" |
| + res, ref := fx.srv.attachDomain(context.Background(), acct, "story", own, "", "", "paul@keymail.dev") |
| + if ref != nil { |
| + t.Fatalf("attach in own namespace refused %+v", ref) |
| + } |
| + if res.Pending || !res.FirstRoute { |
| + t.Errorf("own-namespace attach = %+v, want verified first route", res) |
| + } |
| + if lookups != 0 { |
| + t.Errorf("TXT lookups = %d, want none: own namespace needs no proof", lookups) |
| + } |
| + |
| + // A name outside every namespace is unchanged on a self-hosted |
| + // deployment: attachable (pending proof like any custom domain). |
| + fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", |
| + Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}) |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "www.oncarlos.com", "", "", "paul@keymail.dev"); ref != nil { |
| + t.Errorf("alias attach of www.oncarlos.com refused %+v; self-hosted behaviour must not change", ref) |
| + } |
| +} |
| + |
| +// serialValue reads control/serial, "" when absent. |
| +func serialValue(t *testing.T, fx *consoleFixture) string { |
| + t.Helper() |
| + b, _, err := fx.rel.Get(context.Background(), release.SerialKey) |
| + if err != nil { |
| + return "" |
| + } |
| + return string(b) |
| +} |
| + |
| +// With CONSOLE_DOMAIN_PROOF unset — the default — nothing about attach |
| +// changes (#215 keeps the 2026-08-26/27 ruling until a deployment opts in): |
| +// no TXT lookup is ever made, every claim is written verified and announced, |
| +// a first route declares its record at once, and the answer says verified. |
| +func TestDomainProofOffBehavesAsBefore(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-proof-off") |
| + if fx.srv.cfg.DomainProof { |
| + t.Fatal("DomainProof is on by default") |
| + } |
| + lookups := 0 |
| + fx.srv.lookupTXT = noTXT(&lookups) |
| + before := serialValue(t, fx) |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody("tickets.example.com", "go.tito.io")) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach = %d %s", code, out) |
| + } |
| + var got proofAttachResp |
| + _ = json.Unmarshal([]byte(out), &got) |
| + if got.Verified == nil || !*got.Verified || got.Verify.Name != "" { |
| + t.Fatalf("attach answer = %s, want verified true and no verify record", out) |
| + } |
| + if c := claimOf(t, fx, "tickets.example.com"); c.Pending || c.PendingUntil != "" { |
| + t.Fatalf("claim = %+v, want verified", *c) |
| + } |
| + if serialValue(t, fx) == before { |
| + t.Error("attach did not bump the serial") |
| + } |
| + |
| + afx, acct := attachFixture(t) |
| + afx.srv.lookupTXT = noTXT(&lookups) |
| + shipVersion(t, afx, "story", "v1") |
| + seedChannel(t, afx, "story", "canary/live", "v1") |
| + res, ref := afx.srv.attachDomain(context.Background(), acct, "story", "story.example.com", "", "", "paul@keymail.dev") |
| + if ref != nil || res.Pending || !res.FirstRoute { |
| + t.Fatalf("first-route attach = %+v / %+v, want a verified first route", res, ref) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), afx.rel, afx.sqid, "story", "story.example.com"); err != nil { |
| + t.Fatalf("record: %v", err) |
| + } |
| + if lookups != 0 { |
| + t.Errorf("TXT lookups = %d with proof off, want none", lookups) |
| + } |
| +} |
| + |
| +// A plain re-attach of one's own PENDING first-route claim (no route, no |
| +// kind — what `carlos domains attach host` sends) must look for the proof. |
| +// The own-first-route short-circuit used to answer it {created:false} with |
| +// no verified field, which the CLI reads as attached (#215 review). |
| +func TestAPIDomainPlainReattachVerifiesPendingFirstRoute(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + fx.srv.cfg.DomainProof = true |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "story", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-dom-plain-reattach") |
| + const host = "story.example.com" |
| + lookups := 0 |
| + fx.srv.lookupTXT = noTXT(&lookups) |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "static", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("seed attach refused %+v", ref) |
| + } |
| + if c := claimOf(t, fx, host); !c.Pending || c.Route != "" { |
| + t.Fatalf("seed claim = %+v, want a pending first-route claim", *c) |
| + } |
| + |
| + post := func() proofAttachResp { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, fx.sqid, "story"), token, attachBody(host, "")) |
| + if code != http.StatusOK { |
| + t.Fatalf("plain re-attach = %d %s", code, out) |
| + } |
| + var got proofAttachResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + return got |
| + } |
| + lookups = 0 |
| + if got := post(); got.Verified == nil || *got.Verified || lookups != 1 { |
| + t.Fatalf("plain re-attach without proof = %+v after %d lookups, want verified false after one", got, lookups) |
| + } |
| + fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host) |
| + if got := post(); got.Verified == nil || !*got.Verified || got.Route != host { |
| + t.Fatalf("plain re-attach with proof = %+v, want verified first route", got) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { |
| + t.Fatalf("record after proof: %v", err) |
| + } |
| +} |
| + |
| +// A deleted account's namespace stays reserved (#215 review): its tombstone |
| +// keeps the sqid from being reissued, and a name under it would still serve |
| +// through the apps-domain wildcard looking like that account's address. |
| +func TestDeletedAccountsNamespaceStaysReserved(t *testing.T) { |
| + fx, _ := attachFixture(t) |
| + if err := fx.st.CreateAccount(42, "cdef", "Gone", "sam@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.DeleteAccount(42, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a, _ := fx.st.AccountBySqid("cdef"); a != nil { |
| + t.Fatal("account not deleted") |
| + } |
| + if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) { |
| + t.Error("a deleted account's namespace is attachable") |
| + } |
| +} |
diff --git a/internal/console/domainstate.go b/internal/console/domainstate.go| index b0920574d..ba77300a9 100644 |
| --- a/internal/console/domainstate.go |
| +++ b/internal/console/domainstate.go |
| @@ -6,6 +6,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/certwarm" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -60,6 +61,13 @@ const ( |
| // Deliberately distinct from pending-dns: "we could not check" must |
| // never be shown to a customer as "you have not done your part". |
| StateUnknown = "unknown" |
| + // StateUnverified — the claim is stored, but whoever attached it has not |
| + // yet proved they control the hostname (#215). Nothing serves it and no |
| + // certificate is ordered for it until the TXT record in its records is |
| + // published and the attach is repeated. It outranks every DNS-derived |
| + // state: a box never reports on a pending claim, so any reading for the |
| + // host belongs to someone else's route, not to this claim. |
| + StateUnverified = "unverified" |
| ) |
| // domainStates is the vocabulary this build can actually produce, in |
| @@ -73,7 +81,7 @@ const ( |
| // listed now because the issuer writes those records and domainState reads |
| // them. |
| var domainStates = []string{ |
| - StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateFailed, StateLive, StateUnknown, |
| + StateUnverified, StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateFailed, StateLive, StateUnknown, |
| } |
| // domainFailedAttempts is how many consecutive failed warm attempts it takes |
| @@ -146,6 +154,17 @@ func domainState(reading status.AliasReading, seen bool, warm *certwarm.Record, |
| "This hostname reported a status this console does not recognise. It may be running a newer build." |
| } |
| +// claimState is domainState for a claim: an unproven claim reads |
| +// "unverified" whatever the readings say (see StateUnverified), and every |
| +// other claim reads what its readings derive. |
| +func claimState(c domains.Claim, reading status.AliasReading, seen bool, warm *certwarm.Record, now time.Time) (state, reason, detail string) { |
| + if c.Pending { |
| + return StateUnverified, "ownership-unproven", |
| + "Prove you control this hostname by adding the TXT record below. Nothing is served for it until then." |
| + } |
| + return domainState(reading, seen, warm, now) |
| +} |
| + |
| // dnsRecord is one record a customer must create. Structured so an app does |
| // not have to parse the English sentence beside it. |
| type dnsRecord struct { |
diff --git a/internal/console/domainstate_test.go b/internal/console/domainstate_test.go| index 32872401e..8329a3e61 100644 |
| --- a/internal/console/domainstate_test.go |
| +++ b/internal/console/domainstate_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/certwarm" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -91,6 +92,10 @@ func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) { |
| s, _, _ := domainState(tc.r, tc.seen, tc.warm, now) |
| produced[s] = true |
| } |
| + // "unverified" comes from the claim, not the readings (#215): a claim |
| + // stored pending is exactly what a member's unproven attach writes. |
| + s, _, _ := claimState(domains.Claim{Pending: true}, status.AliasReading{DNSStatus: "pointing"}, true, nil, now) |
| + produced[s] = true |
| for _, s := range domainStates { |
| if !produced[s] { |
| t.Errorf("state %q is advertised but nothing produces it", s) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 5eaefcbdd..e9bded56f 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -570,6 +570,21 @@ type Config struct { |
| // zone that one person owns, and the reserved rules refused ordinary |
| // names there. See deniedDomains. |
| ReservePlatformDomains bool |
| + // DomainProof (CONSOLE_DOMAIN_PROOF) is the opt-in for proof of control |
| + // on custom hostnames (#215). With it set, a member's attach is stored |
| + // pending until a TXT record at _carlos-challenge.<host> carries the |
| + // account's token, and a pending claim is neither routed nor certified. |
| + // Off by default, and off means exactly the behaviour before #215: |
| + // every claim is written verified and no TXT lookup is ever made. That |
| + // default keeps the 2026-08-26/27 scope ruling (first-writer-wins plus |
| + // DNS-pointing-as-evidence) until a deployment chooses otherwise, and it |
| + // keeps a fleet that still runs pre-#215 boxes safe: such a box would |
| + // read a pending claim as live. Roll the fleet before turning it on. |
| + DomainProof bool |
| + // LookupTXT resolves the ownership-proof records the /domains doors |
| + // check (#215: domains.ChallengeName). nil means net.DefaultResolver; |
| + // tests set it so no attach ever reaches a real resolver. |
| + LookupTXT func(ctx context.Context, name string) ([]string, error) |
| // StoreProvisioner is the backend the object-storage doors provision |
| // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| // ordinary deployment shape, not a broken one: members may still DECLARE |
| @@ -881,8 +896,10 @@ type Server struct { |
| // post-construction. |
| delegationZone string |
| lookupCNAME func(ctx context.Context, host string) (string, error) |
| - // lookupTXT reads an instance catch-all's ownership proof |
| - // (domains.CatchallProofName). Same seam, same default resolver. |
| + // lookupTXT is cfg.LookupTXT, defaulted to net.DefaultResolver: the |
| + // one TXT seam for both ownership proofs, an instance catch-all's |
| + // (domains.CatchallProofName) and a claim's (#215, domainProver). |
| + // Stubbed by tests the same way lookupCNAME is. |
| lookupTXT func(ctx context.Context, name string) ([]string, error) |
| // steeringHost is cfg.SteeringHost, normalized once here — see |
| // dnsTargets, the one reader. |
| @@ -1144,7 +1161,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| delegationZone: normalizeDelegationZone(cfg.DelegationZone), |
| steeringHost: normalizeSteeringHost(cfg.SteeringHost), |
| lookupCNAME: net.DefaultResolver.LookupCNAME, |
| - lookupTXT: net.DefaultResolver.LookupTXT, |
| + lookupTXT: cfg.LookupTXT, |
| storeWalks: newStoreWalkLocks(), |
| // This process's identity in the relay writer lease. Random rather |
| // than derived from the host: two consoles on one box during a |
| @@ -1152,6 +1169,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // each other apart (relayops.go). |
| relayHolder: relayHolderID(), |
| } |
| + if s.lookupTXT == nil { |
| + s.lookupTXT = net.DefaultResolver.LookupTXT |
| + } |
| if s.km != nil { |
| s.sendApp = s.km.SendApp |
| } |
diff --git a/internal/console/store.go b/internal/console/store.go| index ef7fec905..a442ce3fe 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -945,11 +945,13 @@ func (s *Store) AccountByID(id int64) (*Account, error) { |
| // marked row instead of pretending it does not exist. deleted reports which |
| // case this was, so the account struct itself needs no new field for it. |
| // |
| -// The ONLY caller is meant to be materialization: the whole point of keeping |
| -// a tombstoned row (see DeleteAccount) is that the pass which decides whether |
| -// to re-birth an account from its bucket manifest must be able to tell "never |
| -// seen" from "seen, and deleted" — every other door in the console asks the |
| -// filtered question instead. |
| +// The callers are materialization and the /domains doors' namespace check |
| +// (otherAccountsNamespace, which reserves a deleted account's namespace too). |
| +// Materialization is why it exists: the whole point of keeping a tombstoned |
| +// row (see DeleteAccount) is that the pass which decides whether to re-birth |
| +// an account from its bucket manifest must be able to tell "never seen" from |
| +// "seen, and deleted". Every other door in the console asks the filtered |
| +// question instead. |
| func (s *Store) AccountAnyBySqid(sqid string) (*Account, bool, error) { |
| var a Account |
| var created, deletedAt int64 |
diff --git a/internal/console/view.go b/internal/console/view.go| index 999b9b64a..3c27cebc4 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -260,6 +260,14 @@ func isApexDomain(host string) bool { |
| return etld1 == h |
| } |
| +// verifyGuidance is the prove-you-control-it instruction for a pending claim |
| +// (#215) — the sentence form of its TXT record, shared by the domains door |
| +// and the Settings page so the two never word it differently. |
| +func verifyGuidance(v dnsRecord) string { |
| + return "Add a TXT record at " + v.Name + " with the value " + v.Value + |
| + " to prove you control this hostname, then attach it again or run carlos domains verify." |
| +} |
| + |
| // claimDomainView folds a domain claim that has no colocated registry row |
| // yet into its own Domains placeholder row — an alias claim (which never |
| // gets a route row; see domainattach.go) or a first-route claim before the |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index e958d631d..599a705e9 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -143,7 +143,9 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi |
| sayInstanceCatchallOnce(c.Host, "refused: reserved sidecar suffix") |
| continue |
| } |
| - if apex, held := claimed[parent]; !held || apex.Account != c.Account { |
| + // A pending parent (#215) is not held: nobody has proved they |
| + // control it, so it cannot lend a catch-all its names either. |
| + if apex, held := claimed[parent]; !held || apex.Account != c.Account || apex.Pending { |
| sayInstanceCatchallOnce(c.Host, "inert: its parent "+parent+" is not claimed by account "+c.Account) |
| continue |
| } |
| @@ -249,6 +251,15 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix |
| wildCount := 0 |
| for _, c := range claims { |
| + // A pending claim (#215) is a reservation by someone who has not |
| + // proved they control the hostname, so it derives nothing at all: |
| + // no alias, no prefix rows, no wildcard parent, no catch-all. That |
| + // one skip is what keeps an unproven claim from being served and, |
| + // because the edge orders certificates only for names it routes, |
| + // from getting a certificate. |
| + if c.Pending { |
| + continue |
| + } |
| // 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 |
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| index 25a5f3c56..a31cfadb2 100644 |
| --- a/internal/domains/claims.go |
| +++ b/internal/domains/claims.go |
| @@ -9,6 +9,8 @@ package domains |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -16,6 +18,7 @@ import ( |
| "os" |
| "regexp" |
| "strings" |
| + "time" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -85,6 +88,26 @@ type Claim struct { |
| // a hostname (NormalizeAliasHost), so an older console cannot rewrite or |
| // detach it either. Declared here for the round-trip reason above. |
| Instances bool `json:"instances,omitempty"` |
| + // Pending marks a claim whose writer has not yet proved control of the |
| + // hostname (#215): no TXT record carrying ChallengeToken was found at |
| + // ChallengeName(Host) when it was written. A pending claim is a |
| + // reservation and nothing more. ReconcileAliases derives no row from it, |
| + // so it is not routed and no certificate is ever ordered for it, and it |
| + // does not hold the name against anyone who CAN prove control |
| + // (AttachClaimProven supersedes it) or against anyone at all once |
| + // PendingUntil has passed. |
| + // |
| + // The polarity is the additive-wire rule at work: every claim written |
| + // before this field existed carries no "pending" key and therefore reads |
| + // as verified, which is what those claims have been all along. The |
| + // converse matters at roll time: a box running a binary from before this |
| + // field would decode a pending claim as live and route it, so boxes roll |
| + // before the console that starts writing them. |
| + Pending bool `json:"pending,omitempty"` |
| + // PendingUntil is when a pending claim stops reserving its hostname |
| + // (RFC3339, UTC). Meaningless on a verified claim, and cleared when the |
| + // claim is verified. |
| + PendingUntil string `json:"pending_until,omitempty"` |
| } |
| // InstanceCatchallHost is the claim host of parent's instance catch-all: |
| @@ -176,7 +199,8 @@ func AttachInstanceCatchall(ctx context.Context, s release.Store, parent, accoun |
| } else if err != nil { |
| return false, err |
| } |
| - if apex.Account != account { |
| + if apex.Account != account || apex.Pending { |
| + // A pending parent (#215) is a reservation, not a holding. |
| return false, ErrParentNotHeld |
| } |
| host := InstanceCatchallHost(parent) |
| @@ -254,12 +278,58 @@ func ForeignInstanceCatchall(ctx context.Context, s release.Store, host, account |
| } else if err != nil { |
| return nil, err |
| } |
| - if apex.Account != c.Account { |
| - return nil, nil |
| + if apex.Account != c.Account || apex.Pending { |
| + return nil, nil // a pending parent (#215) lends the catch-all nothing |
| } |
| return c, nil |
| } |
| +// ClaimPendingTTL is how long an unproven claim reserves its hostname. Long |
| +// enough for a person to get a TXT record through a slow DNS provider, short |
| +// enough that a name claimed by someone who never could prove it comes free |
| +// on its own. It only ever blocks other UNPROVEN attaches: a writer who can |
| +// prove control supersedes a pending claim at any time. |
| +const ClaimPendingTTL = 7 * 24 * time.Hour |
| + |
| +// pendingLive reports whether c is pending and still reserving its hostname |
| +// at now. A pending claim with no readable expiry is hand-written or damaged, |
| +// and is treated as expired: the conservative direction for a reservation |
| +// nobody proved is to let it go. |
| +func (c Claim) pendingLive(now time.Time) bool { |
| + if !c.Pending { |
| + return false |
| + } |
| + until, err := time.Parse(time.RFC3339, c.PendingUntil) |
| + return err == nil && now.Before(until) |
| +} |
| + |
| +// ChallengeLabel is the DNS label a claimant proves control under: a TXT |
| +// record at ChallengeName(host) carrying ChallengeToken. The name the |
| +// domain-attach spec reserved for this (2026-08-12 §2). |
| +const ChallengeLabel = "_carlos-challenge" |
| + |
| +// ChallengeName is where host's ownership TXT record lives. |
| +func ChallengeName(host string) string { return ChallengeLabel + "." + host } |
| + |
| +// ChallengeToken is the TXT value that proves account controls host on the |
| +// deployment named by deployment (the console's own hostname). |
| +// |
| +// Derived, not stored, and deliberately not secret. What the record proves |
| +// is that whoever controls host's DNS chose THIS account on THIS deployment, |
| +// and that only needs the value to differ per account and per deployment: |
| +// another account's token for the same host is a different string, so a |
| +// record published for one account can never be replayed by another, and a |
| +// record published for an account on one CARLOS deployment proves nothing on |
| +// another that happens to reuse the sqid. Knowing a token helps nobody who |
| +// cannot write the zone. Deriving it means there is nothing to look up |
| +// before a claim exists, which is what lets a claimant who has already |
| +// published the record attach and verify in one call, and lets a proven |
| +// claimant supersede someone else's pending reservation. |
| +func ChallengeToken(deployment, account, host string) string { |
| + sum := sha256.Sum256([]byte("carlos-domain-challenge\x00" + deployment + "\x00" + account + "\x00" + host)) |
| + return "carlos-verify=" + hex.EncodeToString(sum[:16]) |
| +} |
| + |
| // ErrHostUnavailable is returned by AttachClaim when host is already claimed |
| // by a different account or app. Deliberately neutral: it never names the |
| // current holder, so a caller cannot use this door to discover who owns a |
| @@ -296,6 +366,11 @@ func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) |
| // idempotent (converging Route in place); any other owner gets |
| // ErrHostUnavailable — deliberately neutral, never naming the holder. |
| // |
| +// The claim it writes is VERIFIED: AttachClaim is AttachClaimProven with a |
| +// writer that needs no proof. That is right for callers whose authority over |
| +// the name was settled before they got here; a member's door asks for proof |
| +// through AttachClaimProven instead. |
| +// |
| // created reports that this call brought the claim into existence; changed |
| // reports that it WROTE — a create or a route converge, and false for the |
| // pure no-op where the stored claim already said what the caller asked for. |
| @@ -306,46 +381,128 @@ func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) |
| // steady state, and a serial that moved for a call that wrote nothing would |
| // send every box in the fleet looking for a change that isn't there. |
| func AttachClaim(ctx context.Context, s release.Store, c Claim) (created, changed bool, err error) { |
| + created, changed, _, err = AttachClaimProven(ctx, s, c, time.Now(), nil) |
| + return created, changed, err |
| +} |
| + |
| +// AttachClaimProven is AttachClaim for a writer that may or may not control |
| +// host (#215). prove answers "does the caller control this hostname right |
| +// now?" — in practice a TXT lookup at ChallengeName(host) — and nil means |
| +// the caller's authority is already settled (a proof of true). |
| +// |
| +// prove is asked at most once, and only when the answer can change the |
| +// outcome: there is no claim yet, the caller's own claim is still pending, |
| +// or another owner's claim is pending. A re-attach of a verified claim this |
| +// caller already holds never asks, which is what keeps a router that |
| +// re-attaches every hostname on every pass from turning into a DNS lookup |
| +// per hostname per pass. |
| +// |
| +// The rules, in the order they apply: |
| +// |
| +// - no claim: written, pending unless proven; |
| +// - another owner's VERIFIED claim: ErrHostUnavailable, exactly as before; |
| +// - another owner's PENDING claim: superseded by a proven caller at any |
| +// time, and by anyone once it has expired; otherwise ErrHostUnavailable, |
| +// byte-identical to the verified case; |
| +// - the caller's own claim: converged — a pending one becomes verified |
| +// once proven (its expiry is never pushed back) — and Route repointed. |
| +// |
| +// Verification is one-way and sticky: nothing here ever turns a verified |
| +// claim back into a pending one. pending reports the state of the claim as |
| +// stored after the call (false whenever err is non-nil). |
| +func AttachClaimProven(ctx context.Context, s release.Store, c Claim, now time.Time, prove func() bool) (created, changed, pending bool, err error) { |
| c.Schema = 1 |
| - body, err := json.Marshal(c) |
| - if err != nil { |
| - return false, false, err |
| + c.Pending, c.PendingUntil = false, "" |
| + asked, proven := false, true |
| + isProven := func() bool { |
| + if prove == nil { |
| + return true |
| + } |
| + if !asked { |
| + asked, proven = true, prove() |
| + } |
| + return proven |
| + } |
| + // fresh is the caller's claim as a brand-new object: verified when |
| + // proven, otherwise a reservation that runs out ClaimPendingTTL from now. |
| + fresh := func() Claim { |
| + out := c |
| + if !isProven() { |
| + out.Pending = true |
| + out.PendingUntil = now.UTC().Add(ClaimPendingTTL).Format(time.RFC3339) |
| + } |
| + return out |
| } |
| for attempt := 0; ; attempt++ { |
| cur, etag, err := getClaimETag(ctx, s, c.Host) |
| if errors.Is(err, os.ErrNotExist) { |
| + next := fresh() |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return false, false, false, err |
| + } |
| if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil { |
| - return true, true, nil |
| + return true, true, next.Pending, nil |
| } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| continue // lost the create race — re-read and judge the winner |
| } else { |
| - return false, false, err |
| + return false, false, false, err |
| } |
| } else if err != nil { |
| - return false, false, err |
| + return false, false, false, err |
| } |
| if cur.Account != c.Account || cur.App != c.App { |
| - return false, false, ErrHostUnavailable |
| - } |
| - if cur.Route == c.Route { |
| - return false, false, nil // nothing to write: this call is a no-op |
| + // A verified claim is the holder's, full stop. A pending one is |
| + // only a reservation: it yields to a caller who can prove |
| + // control, and to anyone once it has run out. Both refusals are |
| + // the same ErrHostUnavailable, so the answer still never says |
| + // whether the name is held, or how firmly. |
| + if !cur.Pending || (cur.pendingLive(now) && !isProven()) { |
| + return false, false, false, ErrHostUnavailable |
| + } |
| + next := fresh() |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return false, false, false, err |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil { |
| + // From the caller's side this IS a new claim: nothing of the |
| + // previous holder's survives it — not its route, its |
| + // capabilities, or its prefix map. |
| + return true, true, next.Pending, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue |
| + } else { |
| + return false, false, false, err |
| + } |
| } |
| - // Converge repoints Route and nothing else: the stored claim keeps |
| - // its CreatedAt (the claim's age) and Wildcard (TLS opt-in state a |
| - // route repoint has no business clearing). Writing the caller's |
| - // whole claim here would strip both. |
| + // Converge repoints Route and settles the pending state, and nothing |
| + // else: the stored claim keeps its CreatedAt (the claim's age) and |
| + // Wildcard (TLS opt-in state a route repoint has no business |
| + // clearing). Writing the caller's whole claim here would strip both. |
| next := *cur |
| next.Route = c.Route |
| + // A reservation is never renewed: PendingUntil stays where the first |
| + // attach put it, so whoever reserved a name has ClaimPendingTTL from |
| + // then to prove it, however often they re-attach (and `carlos |
| + // deploy` re-attaches on every run). Proof still verifies an expired |
| + // reservation of one's own, as long as nobody has taken it meanwhile. |
| + if cur.Pending && isProven() { |
| + next.Pending, next.PendingUntil = false, "" |
| + } |
| + if next.Route == cur.Route && next.Pending == cur.Pending && next.PendingUntil == cur.PendingUntil { |
| + return false, false, cur.Pending, nil // nothing to write: this call is a no-op |
| + } |
| nbody, err := json.Marshal(next) |
| if err != nil { |
| - return false, false, err |
| + return false, false, false, err |
| } |
| if _, err := s.Put(ctx, ClaimKey(c.Host), nbody, etag); err == nil { |
| - return false, true, nil // not a create, but a real write |
| + return false, true, next.Pending, nil // not a create, but a real write |
| } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| continue // converge race — one retry, then surface |
| } else { |
| - return false, false, err |
| + return false, false, false, err |
| } |
| } |
| } |
| @@ -425,7 +582,13 @@ func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) { |
| func readClaim(ctx context.Context, s release.Store, key string) (c Claim, ok, firm bool) { |
| raw, _, err := s.Get(ctx, key) |
| if err != nil { |
| - log.Printf("domains: claim %s: %v", key, err) |
| + // Not-found is quiet: a claim detached between the listing and this |
| + // read, or, through the fleet store door, another account's claim, |
| + // whose NAME the door lists but whose body it withholds (#215). The |
| + // listing still counts it as claimed (listWithHosts). |
| + if !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("domains: claim %s: %v", key, err) |
| + } |
| return Claim{}, false, false |
| } |
| if err := json.Unmarshal(raw, &c); err != nil { |
diff --git a/internal/domains/claims_proof_test.go b/internal/domains/claims_proof_test.go| new file mode 100644 |
| index 000000000..6a8efc0e8 |
| --- /dev/null |
| +++ b/internal/domains/claims_proof_test.go |
| @@ -0,0 +1,243 @@ |
| +package domains |
| + |
| +// Proof of control for hostname claims (#215). Before this, the first account |
| +// to attach a hostname held it outright: a tenant could take over a name |
| +// whose DNS already pointed at the edge (and be served on it, with a |
| +// platform-issued certificate), or squat any name forever. A claim is now |
| +// pending until its writer proves control, and a pending claim is inert and |
| +// yields. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var proofNow = time.Unix(1_800_000_000, 0).UTC() |
| + |
| +func proofs(answer bool, calls *int) func() bool { |
| + return func() bool { |
| + *calls++ |
| + return answer |
| + } |
| +} |
| + |
| +// An unproven attach writes a reservation, and the reconcile pass derives |
| +// nothing from it — no alias, no prefix rows, no wildcard parent, no |
| +// catch-all — so the name is neither served nor issued a certificate. This |
| +// is the takeover: on main the claim was live the moment it was written. |
| +func TestPendingClaimIsNeitherRoutedNorCertified(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "web.bes.oncarlos.com", App: "web", Account: "bes", Kind: "instance", Status: "ready", |
| + }) |
| + calls := 0 |
| + created, changed, pending, err := AttachClaimProven(context.Background(), s, Claim{ |
| + Host: "victim.example.com", Account: "bes", App: "web", Route: "web.bes.oncarlos.com", |
| + }, proofNow, proofs(false, &calls)) |
| + if err != nil || !created || !changed || !pending { |
| + t.Fatalf("unproven attach = created %v changed %v pending %v err %v, want a new pending claim", created, changed, pending, err) |
| + } |
| + c, err := GetClaim(context.Background(), s, "victim.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !c.Pending || c.PendingUntil != proofNow.Add(ClaimPendingTTL).Format(time.RFC3339) { |
| + t.Fatalf("stored claim = %+v, want pending until now+%s", *c, ClaimPendingTTL) |
| + } |
| + // Capabilities and a prefix map set on a pending claim must stay inert too. |
| + if _, err := SetClaimCapabilities(context.Background(), s, c.Host, "bes", "web", true, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(context.Background(), s, c.Host, "bes", "web", map[string]string{"x": "web.bes.oncarlos.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if as, _ := reg.ListAliases(); len(as) != 0 { |
| + t.Fatalf("a pending claim materialized aliases %+v", as) |
| + } |
| + if ws, _ := reg.ListWildcardDomains(); len(ws) != 0 { |
| + t.Fatalf("a pending claim derived wildcard parents %+v", ws) |
| + } |
| + if ps, _ := reg.ListPaths(); len(ps) != 0 { |
| + t.Fatalf("a pending claim derived path rows %+v", ps) |
| + } |
| + |
| + // Proving it later is a re-attach by the same owner: the claim turns |
| + // verified in place, and the next pass serves it. |
| + created, changed, pending, err = AttachClaimProven(context.Background(), s, Claim{ |
| + Host: "victim.example.com", Account: "bes", App: "web", Route: "web.bes.oncarlos.com", |
| + }, proofNow, proofs(true, &calls)) |
| + if err != nil || created || !changed || pending { |
| + t.Fatalf("proven re-attach = created %v changed %v pending %v err %v, want a verifying write", created, changed, pending, err) |
| + } |
| + if c, _ := GetClaim(context.Background(), s, "victim.example.com"); c.Pending || c.PendingUntil != "" || !c.Wildcard { |
| + t.Fatalf("verified claim = %+v, want pending cleared and capabilities kept", *c) |
| + } |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if as, _ := reg.ListAliases(); len(as) == 0 { |
| + t.Fatal("the verified claim materialized nothing") |
| + } |
| + |
| + // Once verified, a re-attach never asks again: a router that re-attaches |
| + // every hostname on every pass must not cost a DNS lookup per hostname. |
| + calls = 0 |
| + if _, changed, pending, err := AttachClaimProven(context.Background(), s, Claim{ |
| + Host: "victim.example.com", Account: "bes", App: "web", Route: "web.bes.oncarlos.com", |
| + }, proofNow, proofs(false, &calls)); err != nil || changed || pending || calls != 0 { |
| + t.Fatalf("re-attach of a verified claim = changed %v pending %v err %v after %d proofs, want a silent no-op", changed, pending, err, calls) |
| + } |
| +} |
| + |
| +// A pending claim does not block the hostname's real owner: a caller who can |
| +// prove control supersedes it at any time, and an unproven caller can take |
| +// it only once it has run out. A VERIFIED claim yields to nobody, and the |
| +// refusal is the same ErrHostUnavailable either way. |
| +func TestPendingClaimYieldsToProofAndToExpiry(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + calls := 0 |
| + squat := Claim{Host: "shop.example.com", Account: "aaa", App: "squat", Route: "x.aaa.oncarlos.com"} |
| + if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow, proofs(false, &calls)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimCapabilities(ctx, s, squat.Host, "aaa", "squat", true, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + owner := Claim{Host: "shop.example.com", Account: "bbb", App: "shop", Route: "shop.bbb.oncarlos.com"} |
| + if _, _, _, err := AttachClaimProven(ctx, s, owner, proofNow.Add(time.Hour), proofs(false, &calls)); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("unproven attach over a live reservation: err = %v, want ErrHostUnavailable", err) |
| + } |
| + created, changed, pending, err := AttachClaimProven(ctx, s, owner, proofNow.Add(time.Hour), proofs(true, &calls)) |
| + if err != nil || !created || !changed || pending { |
| + t.Fatalf("proven attach over a reservation = created %v changed %v pending %v err %v, want a new verified claim", created, changed, pending, err) |
| + } |
| + c, _ := GetClaim(ctx, s, owner.Host) |
| + if c.Account != "bbb" || c.App != "shop" || c.Pending || c.Wildcard { |
| + t.Fatalf("superseding claim = %+v, want bbb/shop verified with nothing of the squatter's left", *c) |
| + } |
| + |
| + // The squatter, now facing a verified claim, gets the ordinary refusal, |
| + // and no lookup is spent finding that out. |
| + calls = 0 |
| + if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow.Add(2*time.Hour), proofs(true, &calls)); !errors.Is(err, ErrHostUnavailable) || calls != 0 { |
| + t.Fatalf("attach over a verified claim: err = %v after %d proofs, want ErrHostUnavailable after none", err, calls) |
| + } |
| + |
| + // Expiry: an unproven reservation that has run out is free to anyone, |
| + // even another unproven caller, whose own reservation starts fresh. |
| + expired := Claim{Host: "old.example.com", Account: "aaa", App: "squat"} |
| + if _, _, _, err := AttachClaimProven(ctx, s, expired, proofNow, proofs(false, &calls)); err != nil { |
| + t.Fatal(err) |
| + } |
| + later := proofNow.Add(ClaimPendingTTL + time.Minute) |
| + created, _, pending, err = AttachClaimProven(ctx, s, Claim{Host: "old.example.com", Account: "bbb", App: "shop"}, later, proofs(false, &calls)) |
| + if err != nil || !created || !pending { |
| + t.Fatalf("unproven attach over an expired reservation = created %v pending %v err %v, want a fresh reservation", created, pending, err) |
| + } |
| + if c, _ := GetClaim(ctx, s, "old.example.com"); c.Account != "bbb" || c.PendingUntil != later.Add(ClaimPendingTTL).Format(time.RFC3339) { |
| + t.Fatalf("claim after expiry = %+v, want bbb's reservation from now", *c) |
| + } |
| +} |
| + |
| +// Claims written before #215 have no pending key. They are verified — that |
| +// is what they have been all along — so they keep serving with no rewrite. |
| +func TestClaimWithoutPendingKeyIsVerified(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + legacy := `{"schema":1,"host":"tickets.example.com","account":"bab","app":"kass","route":"canary/live","created_at":"2026-08-12T00:00:00Z","created_by":"paul@keymail.dev"}` |
| + if _, err := s.Put(context.Background(), ClaimKey("tickets.example.com"), []byte(legacy), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if as, _ := reg.ListAliases(); len(as) != 1 { |
| + t.Fatalf("legacy claim materialized %+v, want its alias", as) |
| + } |
| + // And a verified claim never grows the keys on the wire. |
| + body, _ := json.Marshal(Claim{Schema: 1, Host: "a.example.com"}) |
| + var m map[string]any |
| + _ = json.Unmarshal(body, &m) |
| + if _, ok := m["pending"]; ok { |
| + t.Fatalf("verified claim marshals a pending key: %s", body) |
| + } |
| + if _, ok := m["pending_until"]; ok { |
| + t.Fatalf("verified claim marshals a pending_until key: %s", body) |
| + } |
| +} |
| + |
| +// The token is account- and deployment-bound: a record published for one |
| +// account proves nothing for another, or for the same sqid on another |
| +// deployment. |
| +func TestChallengeTokenIsBound(t *testing.T) { |
| + base := ChallengeToken("console.carloku.com", "bes", "shop.example.com") |
| + for name, other := range map[string]string{ |
| + "another account": ChallengeToken("console.carloku.com", "bab", "shop.example.com"), |
| + "another deployment": ChallengeToken("carlos.tito.io", "bes", "shop.example.com"), |
| + "another host": ChallengeToken("console.carloku.com", "bes", "www.example.com"), |
| + } { |
| + if other == base { |
| + t.Errorf("%s produces the same token %q", name, base) |
| + } |
| + } |
| + if base != ChallengeToken("console.carloku.com", "bes", "shop.example.com") { |
| + t.Error("token is not deterministic") |
| + } |
| + if got := ChallengeName("shop.example.com"); got != "_carlos-challenge.shop.example.com" { |
| + t.Errorf("ChallengeName = %q", got) |
| + } |
| +} |
| + |
| +// A pending parent is a reservation, not a holding: it cannot lend an |
| +// instance catch-all its one-label names, and a catch-all over it reads as |
| +// not live. |
| +func TestPendingParentHoldsNoInstanceCatchall(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + calls := 0 |
| + if _, _, _, err := AttachClaimProven(ctx, s, Claim{Host: "teams.example.com", Account: "bes", App: "web"}, time.Now(), proofs(false, &calls)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AttachInstanceCatchall(ctx, s, "teams.example.com", "bes", "web", "", ""); !errors.Is(err, ErrParentNotHeld) { |
| + t.Fatalf("catch-all over a pending parent: err = %v, want ErrParentNotHeld", err) |
| + } |
| +} |
| + |
| +// A reservation is never renewed by its own account re-attaching (#215 |
| +// review): `carlos deploy` re-attaches on every run, and renewal would let a |
| +// squatter hold a name forever without proving it. |
| +func TestPendingReservationIsNotRenewed(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + calls := 0 |
| + squat := Claim{Host: "shop.example.com", Account: "aaa", App: "squat"} |
| + if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow, proofs(false, &calls)); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, _ := GetClaim(ctx, s, squat.Host) |
| + for _, at := range []time.Duration{time.Hour, ClaimPendingTTL - time.Minute, ClaimPendingTTL + time.Hour} { |
| + if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow.Add(at), proofs(false, &calls)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c, _ := GetClaim(ctx, s, squat.Host); c.PendingUntil != first.PendingUntil { |
| + t.Fatalf("re-attach at +%s moved pending_until %s -> %s", at, first.PendingUntil, c.PendingUntil) |
| + } |
| + } |
| + // Past it, anyone may take the name. |
| + if created, _, _, err := AttachClaimProven(ctx, s, Claim{Host: squat.Host, Account: "bbb", App: "shop"}, proofNow.Add(ClaimPendingTTL+2*time.Hour), proofs(false, &calls)); err != nil || !created { |
| + t.Fatalf("attach after the reservation lapsed: created %v err %v", created, err) |
| + } |
| +} |
diff --git a/internal/edge/fleetapi_e2e_test.go b/internal/edge/fleetapi_e2e_test.go| index c32c34101..9f6e5e6d6 100644 |
| --- a/internal/edge/fleetapi_e2e_test.go |
| +++ b/internal/edge/fleetapi_e2e_test.go |
| @@ -542,3 +542,80 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| } |
| t.Log("(g) log chunks ship, list and prune through the door; neighbouring keys stay refused") |
| } |
| + |
| +// TestFleetBoxCatchallYieldsToAnotherTenantsClaim (#215 review): the store |
| +// door withholds another account's claim BODY from a fleet box, but must |
| +// still list its NAME. An instance catch-all derives no <label>.<parent> |
| +// alias over a name somebody else has claimed, and ReconcileAliases decides |
| +// "somebody else has claimed it" from the listing; a listing narrowed to the |
| +// box's own account let the box shadow the other tenant's claim. |
| +func TestFleetBoxCatchallYieldsToAnotherTenantsClaim(t *testing.T) { |
| + w := newFleetDoorWorld(t) |
| + ctx := context.Background() |
| + const parent = "teams.example.com" |
| + if _, _, err := domains.AttachClaim(ctx, w.store, domains.Claim{Host: parent, Account: w.sqid, App: "jelly"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := domains.AttachInstanceCatchall(ctx, w.store, parent, w.sqid, "jelly", "", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The other tenant's exact claim on the name the catch-all would derive. |
| + if _, _, err := domains.AttachClaim(ctx, w.store, domains.Claim{ |
| + Host: "beta." + parent, Account: w.other, App: "shop", Route: "shop." + w.other + ".oncarlos.com", |
| + CreatedBy: "sam@keymail.dev", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + instance := "beta." + w.sqid + ".oncarlos.com" |
| + w.put(t, "apps/"+w.sqid+"/jelly/instances/"+instance+".json", `{}`) |
| + // A second instance whose name nobody else claims: the control that |
| + // shows the catch-all does derive through the door. |
| + control := "acme." + w.sqid + ".oncarlos.com" |
| + w.put(t, "apps/"+w.sqid+"/jelly/instances/"+control+".json", `{}`) |
| + |
| + // The box sees the other tenant's NAME but not its claim. |
| + keys, err := w.boxA.List(ctx, domains.ClaimsPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !slices.Contains(keys, domains.ClaimKey("beta."+parent)) { |
| + t.Fatalf("listing %v omits another tenant's claimed name", keys) |
| + } |
| + if _, _, err := w.boxA.Get(ctx, domains.ClaimKey("beta."+parent)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("another tenant's claim body: err = %v, want not-found", err) |
| + } |
| + |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "box.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: instance, Slug: "beta", App: "jelly", Account: w.sqid, Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetProvisioned(instance, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: control, Slug: "acme", App: "jelly", Account: w.sqid, Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetProvisioned(control, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.ReconcileAliases(ctx, w.boxA, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + derived := false |
| + for _, a := range aliases { |
| + if a.Alias == "beta."+parent { |
| + t.Fatalf("the box derived %+v over another tenant's claim", a) |
| + } |
| + derived = derived || (a.Alias == "acme."+parent && a.Host == control) |
| + } |
| + if !derived { |
| + t.Fatalf("aliases = %+v, want acme.%s derived for the unclaimed name", aliases, parent) |
| + } |
| +} |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index ae4e1e9cb..ca7c447d5 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -25,9 +25,11 @@ import ( |
| "log" |
| "net/http" |
| "os" |
| + "sort" |
| "strconv" |
| "strings" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -145,6 +147,10 @@ func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fl |
| ctx := r.Context() |
| switch r.Method { |
| case http.MethodGet: |
| + if strings.HasPrefix(key, domains.ClaimsPrefix) { |
| + d.serveClaim(w, r, sqid, key) |
| + return |
| + } |
| if !aclRead(sqid, fleet, key) { |
| http.NotFound(w, r) |
| return |
| @@ -241,6 +247,10 @@ func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, f |
| http.NotFound(w, r) |
| return |
| } |
| + if scope == domains.ClaimsPrefix { |
| + d.serveClaimListing(w, r, dirs) |
| + return |
| + } |
| out := []string{} // never null on the wire: an empty listing is [] |
| if dirs { |
| got, err := d.store.ListDirs(r.Context(), scope) |
| @@ -370,7 +380,11 @@ func canonicalPrefix(raw string) (string, bool) { |
| // the box (knownAccounts -> ParseRecordKey) |
| // apps/<sqid>/** its owning account's whole app estate |
| // fleets/<fleet>/** its own corner |
| -// domains/** every tenant's hostname claims (see below) |
| +// |
| +// domains/ is NOT here, and is served by its own two paths instead |
| +// (serveClaim, serveClaimListing): a box can list every claimed hostname, |
| +// but reads only its own account's verified claims, with the person who |
| +// made each one stripped out (#215). |
| // |
| // The fleets/<fleet>/** line is not in F3's enumeration and is not a |
| // widening of it: the fleet's own prefix is the only place it may WRITE, and |
| @@ -385,15 +399,18 @@ func canonicalPrefix(raw string) (string, bool) { |
| // nothing more than a mode and a box label, never tenant data. Write stays |
| // console-only: aclWrite never answers true for it. |
| // |
| -// domains/ is different in kind, not degree (#151): a claim record |
| -// (domains.Claim) is a hostname→account/app/route binding, and that binding |
| -// is cross-tenant-readable BY DESIGN — it is exactly the information DNS and |
| -// the serving edge already make public to anyone who resolves the hostname, |
| -// so scoping it to sqid or fleet would hide nothing and would only break |
| -// ReconcileAliases, the alias pass every box runs to reconcile its own |
| -// routes' extra hostnames against the global claim set. Write stays |
| -// console-only: aclWrite never answers true for a domains/ key, so this read |
| -// grant cannot be used to mint or steal a claim. |
| +// domains/ was once readable here whole (#151), on the reasoning that a |
| +// claim says no more than DNS does. It says more: who made it (a person's |
| +// address), the account and app behind it, its path map, and names that do |
| +// not resolve yet or that nobody has proved they control. Anyone can create |
| +// an account and a fleet, so that was every tenant's claims for the asking. |
| +// What a box actually needs is narrower. ReconcileAliases only ever |
| +// materializes a claim whose route is a local route of the claim's own |
| +// account, and a fleet box's routes are all its own account's, so those are |
| +// the only bodies the door serves (serveClaim). It also needs to know which |
| +// NAMES are claimed at all, whoever by, so the listing stays whole |
| +// (serveClaimListing). Write stays console-only: aclWrite never answers true |
| +// for a domains/ key. |
| func aclRead(sqid, fleet, key string) bool { |
| if sqid == "" || fleet == "" { |
| return false |
| @@ -405,9 +422,6 @@ func aclRead(sqid, fleet, key string) bool { |
| if systemReadKey(key) { |
| return true |
| } |
| - if strings.HasPrefix(key, "domains/") { |
| - return true |
| - } |
| return strings.HasPrefix(key, "accounts/"+sqid+"/") || |
| strings.HasPrefix(key, "apps/"+sqid+"/") || |
| strings.HasPrefix(key, "fleets/"+fleet+"/") |
| @@ -450,15 +464,6 @@ func aclDir(sqid, fleet, dir string) bool { |
| if dir == "accounts/"+sqid+"/" { |
| return true |
| } |
| - // domains/ (#151): cross-tenant-readable by the same reasoning as |
| - // aclRead, so every child prefix under it is in scope regardless of |
| - // tenant. ListClaims lists domains/ flat today (List, not ListDirs), so |
| - // this branch is not exercised in production yet — it exists so a future |
| - // nested claim layout does not silently lose read access the way |
| - // accounts/ almost did. |
| - if strings.HasPrefix(dir, "domains/") { |
| - return true |
| - } |
| return strings.HasPrefix(dir, "apps/"+sqid+"/") || |
| strings.HasPrefix(dir, "fleets/"+fleet+"/") |
| } |
| @@ -472,10 +477,10 @@ func aclDir(sqid, fleet, dir string) bool { |
| // It can never write a release artifact, a channel pointer, or an instance |
| // record, which is the tamper surface the whole spec exists to close (F6: |
| // retention does not arm on a fleet box either). It can never write a |
| -// domains/ claim either (#151): aclRead's cross-tenant grant there is |
| -// read-only by design — a claim is a console-mediated hostname reservation, |
| -// and a box that could write one could mint or steal any hostname on the |
| -// platform, not just its own account's. |
| +// domains/ claim either (#151): the door only ever reads claims to a box |
| +// (serveClaim, serveClaimListing). A claim is a console-mediated hostname |
| +// reservation, and a box that could write one could mint or steal any |
| +// hostname on the platform, not just its own account's. |
| // |
| // The family scope is SEGMENT-EXACT on purpose, and the exactness is the |
| // security property: the grant is apps/<sqid>/<ONE app segment>/<ONE family>/**, where |
| @@ -565,12 +570,12 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) { |
| case prefix == "accounts" || prefix == "accounts/": |
| return "accounts/", true |
| case prefix == "domains" || prefix == "domains/": |
| - // #151: the OTHER shared scope, and shared for the same reason as |
| - // accounts/ above but simpler — every claim is cross-tenant-readable |
| - // (aclRead), so unlike apps/ and fleets/ there is no per-tenant |
| - // subtree to narrow into. ReconcileAliases always lists the whole |
| - // prefix; a narrower ask (e.g. "domains/x.example.json") is refused |
| - // rather than widened, matching accounts/'s deliberate refusal of |
| + // #151: the OTHER shared scope. Claims are keyed by hostname, not |
| + // tenant, so there is no subtree to narrow into: serveClaimListing |
| + // names every claimed hostname, and serveClaim withholds every body |
| + // but this account's verified ones (#215). ReconcileAliases always |
| + // lists the whole prefix; a narrower ask (e.g. |
| + // "domains/x.example.json") is refused rather than widened, matching accounts/'s deliberate refusal of |
| // "accounts/<sqid>/" — see that case's comment. |
| return "domains/", true |
| case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet: |
| @@ -581,3 +586,77 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) { |
| return "", false |
| } |
| } |
| + |
| +// serveClaim answers a GET of one hostname claim. A box gets a claim only |
| +// when it is its own account's and verified — the only claims its |
| +// ReconcileAliases could ever act on — and never its created_by, which is a |
| +// person's address the box has no use for. Everything else is the door's |
| +// byte-identical 404, so a fleet credential cannot probe which hostnames |
| +// other tenants hold. |
| +// |
| +// The body is redacted as a generic JSON object rather than through |
| +// domains.Claim, so a field a newer console wrote survives the trip to a |
| +// box that understands it even if this edge does not. |
| +func (d *storeDoor) serveClaim(w http.ResponseWriter, r *http.Request, sqid, key string) { |
| + body, etag, err := d.store.Get(r.Context(), key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + d.storeFailed(w, r, "get", key, err) |
| + return |
| + } |
| + var obj map[string]json.RawMessage |
| + if json.Unmarshal(body, &obj) != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var account string |
| + var pending bool |
| + _ = json.Unmarshal(obj["account"], &account) |
| + _ = json.Unmarshal(obj["pending"], &pending) |
| + if account != sqid || pending { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + delete(obj, "created_by") |
| + out, err := json.Marshal(obj) |
| + if err != nil { |
| + d.storeFailed(w, r, "get", key, err) |
| + return |
| + } |
| + if etag != "" { |
| + w.Header().Set("ETag", etag) |
| + } |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + w.Header().Set("Content-Length", strconv.Itoa(len(out))) |
| + w.Write(out) |
| +} |
| + |
| +// serveClaimListing lists every claim KEY under domains/: hostnames only, |
| +// whoever holds them. The listing has to be whole, because ReconcileAliases |
| +// decides "a claim on this name wins, whoever holds it" from it (an instance |
| +// catch-all derives no <label>.<parent> alias over a name another account |
| +// claimed), and a listing narrowed to this account would let a box shadow |
| +// another tenant's claim. A hostname alone is what DNS publishes anyway; the |
| +// claim behind it (who, which app, its paths, whether it is proven) stays |
| +// behind serveClaim's 404. Child directories: none, claims are flat. |
| +func (d *storeDoor) serveClaimListing(w http.ResponseWriter, r *http.Request, dirs bool) { |
| + out := []string{} |
| + if !dirs { |
| + keys, err := d.store.List(r.Context(), domains.ClaimsPrefix) |
| + if err != nil { |
| + d.storeFailed(w, r, "list", domains.ClaimsPrefix, err) |
| + return |
| + } |
| + for _, key := range keys { |
| + if _, ok := canonicalKey(key); ok && strings.HasSuffix(key, ".json") { |
| + out = append(out, key) |
| + } |
| + } |
| + sort.Strings(out) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(out) |
| +} |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index fab601a69..b1a89f5e2 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -22,8 +22,10 @@ import ( |
| "strings" |
| "testing" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "time" |
| ) |
| // The two identities every row is judged against: fleet "pi" owned by account |
| @@ -194,10 +196,10 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"}, |
| {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"}, |
| - // --- domains/ claims (#151): cross-tenant-readable by design, the |
| - // alias pass (ReconcileAliases) reads them on every box; console-only |
| - // write --- |
| - {"domains/app.example.com.json", opRead, true, "hostname claim, own or any other tenant's"}, |
| + // --- domains/ claims: never through aclRead (#215) — serveClaim |
| + // decides per claim body, own account and verified only (see |
| + // TestStoreDoorClaimsAreOwnAccountAndRedacted); console-only write --- |
| + {"domains/app.example.com.json", opRead, false, "claims are served by serveClaim, not the prefix ACL"}, |
| {"domains/app.example.com.json", opWrite, false, "boxes read claims, only the console writes them"}, |
| {"domains/app.example.com.json", opDelete, false, "nor delete them"}, |
| {"domains", opRead, false, "an object AT the bare name, not the claims prefix"}, |
| @@ -364,12 +366,9 @@ func TestACLDirIsPerTenant(t *testing.T) { |
| "fleets/other/status/": false, |
| "fleets/" + testFleet + "x/y/": false, |
| "control/": false, |
| - // domains/ (#151): cross-tenant by design, so no tenant scoping to |
| - // pin here — every child prefix under it is in scope. ListClaims |
| - // lists domains/ flat (List, not ListDirs) today, so this path isn't |
| - // exercised in production; the row exists so a future nested claim |
| - // layout doesn't silently lose read access. |
| - "domains/anything/": true, |
| + // domains/ (#215): no longer a shared read scope; claims are flat |
| + // and serveClaimListing answers dirs with nothing. |
| + "domains/anything/": false, |
| } |
| for dir, want := range cases { |
| if got := aclDir(testSqid, testFleet, dir); got != want { |
| @@ -917,3 +916,53 @@ func TestStoreDoorOnlyOnReservedHost(t *testing.T) { |
| t.Fatalf("/register on the reserved host = %d, want 400 (still the hub's)", code) |
| } |
| } |
| + |
| +// TestStoreDoorClaimsAreOwnAccountAndRedacted (#215): a fleet box used to be |
| +// able to read every tenant's hostname claims — who made each one (a |
| +// person's address), its account and app, its path map, and whether anyone |
| +// had proved it. It now reads only its own account's verified claims, |
| +// without created_by; every other claim body answers the door's |
| +// byte-identical 404. The listing still names every claimed hostname. |
| +func TestStoreDoorClaimsAreOwnAccountAndRedacted(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + ctx := context.Background() |
| + put := func(c domains.Claim) { |
| + t.Helper() |
| + if _, _, _, err := domains.AttachClaimProven(ctx, store, c, time.Now(), func() bool { return !c.Pending }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put(domains.Claim{Host: "own.example.com", Account: testSqid, App: "web", Route: "web." + testSqid + ".oncarlos.com", CreatedBy: "paul@keymail.dev"}) |
| + put(domains.Claim{Host: "pending.example.com", Account: testSqid, App: "web", Pending: true}) |
| + put(domains.Claim{Host: "theirs.example.com", Account: "zz99", App: "shop", CreatedBy: "sam@keymail.dev"}) |
| + |
| + w := doorDo(d, "GET", "/store/list?prefix=domains/", "", nil) |
| + // Every claimed NAME is listed, whoever holds it: the alias pass decides |
| + // "someone else claimed this" from the listing. Bodies are what is |
| + // withheld (below). |
| + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != |
| + `["domains/own.example.com.json","domains/pending.example.com.json","domains/theirs.example.com.json"]` { |
| + t.Fatalf("listing = %d %s, want every claimed hostname", w.Code, w.Body.String()) |
| + } |
| + w = doorDo(d, "GET", "/store/o/domains/own.example.com.json", "", nil) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("own claim = %d", w.Code) |
| + } |
| + var c domains.Claim |
| + if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Host != "own.example.com" || c.Route == "" || c.CreatedBy != "" || strings.Contains(w.Body.String(), "keymail.dev") { |
| + t.Fatalf("own claim body = %s, want the claim without created_by", w.Body.String()) |
| + } |
| + want := doorDo(d, "GET", "/store/o/domains/absent.example.com.json", "", nil) |
| + for _, key := range []string{"theirs.example.com", "pending.example.com"} { |
| + got := doorDo(d, "GET", "/store/o/domains/"+key+".json", "", nil) |
| + if got.Code != http.StatusNotFound || got.Body.String() != want.Body.String() || fmt.Sprint(got.Header()) != fmt.Sprint(want.Header()) { |
| + t.Errorf("%s = %d %q, want the same 404 an absent claim gets", key, got.Code, got.Body.String()) |
| + } |
| + } |
| + if w := doorDo(d, "GET", "/store/dirs?prefix=domains/", "", nil); strings.TrimSpace(w.Body.String()) != "[]" { |
| + t.Errorf("dirs under domains/ = %s, want []", w.Body.String()) |
| + } |
| +} |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 1c5ae8dff..1cfcc2cc2 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -50,6 +50,7 @@ import ( |
| "golang.org/x/crypto/acme" |
| "golang.org/x/crypto/acme/autocert" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -190,6 +191,15 @@ var ( |
| // appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the |
| // apex, and foreign domains contribute nothing. Sorted and deduped so the |
| // set is deterministic for logs and tests. |
| +// |
| +// <acct> must be shaped like an account sqid (domains.ValidSqid): every |
| +// account parent the platform mints is <sqid>.<appsDomain>, and a label that |
| +// could never be a sqid names no account. Without that bound a route someone |
| +// placed at x.<anything>.<appsDomain> would order a wildcard certificate for |
| +// a parent nobody owns, one per invented label, against the deployment's |
| +// shared ACME budget (#215). The console refuses such first routes for |
| +// sqid-shaped labels that are not the attaching account's own; this is the |
| +// edge's half, for the labels no account could hold. |
| func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) []string { |
| seen := map[string]bool{} |
| var out []string |
| @@ -209,7 +219,7 @@ func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) |
| if !ok { |
| continue |
| } |
| - if labels := strings.Split(rest, "."); len(labels) == 2 { |
| + if labels := strings.Split(rest, "."); len(labels) == 2 && domains.ValidSqid(labels[1]) { |
| add(labels[1] + suffix) |
| } |
| } |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| index ddc3f3c54..9bc2fa747 100644 |
| --- a/internal/edge/wildcard_parents_test.go |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -16,6 +16,7 @@ import ( |
| "golang.org/x/crypto/acme/autocert" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -231,7 +232,9 @@ func TestWildcardRefreshIsSerialized(t *testing.T) { |
| n := gen.Load() |
| // A slow read: the interleaving window the mutex closes. |
| time.Sleep(200 * time.Microsecond) |
| - return []string{fmt.Sprintf("app.acct%d.apps.test", n)} |
| + // Sqid-shaped labels: parentsFor derives a parent only from a label |
| + // that could be an account (#215). |
| + return []string{"app." + domains.Sqid(int64(n)) + ".apps.test"} |
| } |
| w := newWildcardManager(nil, "apps.test", hosts, func() []string { return nil }, "", autocert.DirCache(t.TempDir()), nil) |
| var wg sync.WaitGroup |
| @@ -248,7 +251,7 @@ func TestWildcardRefreshIsSerialized(t *testing.T) { |
| wg.Wait() |
| final := gen.Load() |
| w.refresh() |
| - if got := w.currentParents(); !reflect.DeepEqual(got, []string{fmt.Sprintf("acct%d.apps.test", final)}) { |
| + if got := w.currentParents(); !reflect.DeepEqual(got, []string{domains.Sqid(int64(final)) + ".apps.test"}) { |
| t.Fatalf("parents = %v after generation %d", got, final) |
| } |
| } |
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go| index 8b3f5e182..23a567cf8 100644 |
| --- a/internal/edge/wildcard_test.go |
| +++ b/internal/edge/wildcard_test.go |
| @@ -64,6 +64,9 @@ func TestParentsFor(t *testing.T) { |
| "oncarlos.com", // apex → skipped |
| "console.example.com", // foreign domain → skipped |
| "UPPER.CASE.ONCARLOS.COM", // case-normalized → case.oncarlos.com |
| + "x.my-shop.oncarlos.com", // a label no account could hold → no parent (#215) |
| + "x.acct3.oncarlos.com", // nor one with a digit in it |
| + "x.ops.oncarlos.com", // nor "o": not in the sqid alphabet |
| } |
| got := parentsFor([]string{"go.tito.io"}, "oncarlos.com", hosts) |
| want := []string{"abs.oncarlos.com", "bab.oncarlos.com", "case.oncarlos.com", "go.tito.io"} |
| @@ -423,20 +426,20 @@ func TestOnReloadHookFires(t *testing.T) { |
| func TestCurrentHostsPlatformOnly(t *testing.T) { |
| e := &Edge{} |
| e.upstreams.Store(&map[string]upstream{ |
| - "app.acct.oncarlos.com": {route: registry.Route{Host: "app.acct.oncarlos.com", Platform: true}}, |
| - "review.acct3.oncarlos.com": {route: registry.Route{Host: "review.acct3.oncarlos.com", Provisioned: true}}, |
| - "story.example.com": {route: registry.Route{Host: "story.example.com", Provisioned: true}}, |
| - "custom.example.com": {route: registry.Route{Host: "custom.example.com", Platform: false}}, |
| - "evil.acct2.oncarlos.com": {route: registry.Route{Host: "evil.acct2.oncarlos.com", Platform: false}}, |
| + "app.acct.oncarlos.com": {route: registry.Route{Host: "app.acct.oncarlos.com", Platform: true}}, |
| + "review.bcd.oncarlos.com": {route: registry.Route{Host: "review.bcd.oncarlos.com", Provisioned: true}}, |
| + "story.example.com": {route: registry.Route{Host: "story.example.com", Provisioned: true}}, |
| + "custom.example.com": {route: registry.Route{Host: "custom.example.com", Platform: false}}, |
| + "evil.acct2.oncarlos.com": {route: registry.Route{Host: "evil.acct2.oncarlos.com", Platform: false}}, |
| }) |
| got := e.currentHosts() |
| sort.Strings(got) |
| - want := []string{"app.acct.oncarlos.com", "review.acct3.oncarlos.com", "story.example.com"} |
| + want := []string{"app.acct.oncarlos.com", "review.bcd.oncarlos.com", "story.example.com"} |
| if !reflect.DeepEqual(got, want) { |
| t.Fatalf("currentHosts = %v, want %v", got, want) |
| } |
| parents := parentsFor(nil, "oncarlos.com", got) |
| - wantParents := []string{"acct.oncarlos.com", "acct3.oncarlos.com"} |
| + wantParents := []string{"acct.oncarlos.com", "bcd.oncarlos.com"} |
| if !reflect.DeepEqual(parents, wantParents) { |
| t.Fatalf("parents = %v, want %v (a provisioned custom domain mints no parent; the unowned evil row never reaches here)", parents, wantParents) |
| } |