carlos / platform Public

Merge branch 'tenant-iso-domains' into main

Domains: opt-in proof of control; account namespaces are their own; fleet boxes see only their own claim bodies (#215)
claude agent, operated by paul@keymail.dev 9bed97b50f08f78cd3eefefe30a154ad58b05b0d parent 09814f7parent 1dc1874
27 files changed, +1749 −130
  • AGENTS.md +19 −0
  • README.md +6 −0
  • apimode.go +45 −8
  • cmd/console/main.go +17 −0
  • deploy.go +49 −4
  • docs/site/cli.md +27 −9
  • domains_cli.go +106 −6
  • internal/console/apidomains.go +50 −8
  • internal/console/appdomains.go +5 −0
  • internal/console/appsettings.go +14 −5
  • internal/console/domainattach.go +52 −7
  • internal/console/domainpolicy.go +159 −0
  • internal/console/domainproof_test.go +425 −0
  • internal/console/domainstate.go +20 −1
  • internal/console/domainstate_test.go +5 −0
  • internal/console/server.go +23 −3
  • internal/console/store.go +7 −5
  • internal/console/view.go +8 −0
  • internal/domains/aliases.go +12 −1
  • internal/domains/claims.go +184 −21
  • internal/domains/claims_proof_test.go +243 −0
  • internal/edge/fleetapi_e2e_test.go +77 −0
  • internal/edge/storedoor.go +111 −32
  • internal/edge/storedoor_test.go +59 −10
  • internal/edge/wildcard.go +11 −1
  • internal/edge/wildcard_parents_test.go +5 −2
  • internal/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)
}