carlos / platform Public

Merge branch 'instance-catchall' into main

Merge instance-catchall: *.<parent> serves each instance at <label>.<parent> (#233)
claude agent, operated by james@keymail.dev 1d1a6f131533becea704bb3de3c3522402c34ef0 parent e8d0328parent f53000e
22 files changed, +2450 −14
  • AGENTS.md +30 −0
  • apimode.go +5 −0
  • docs/site/cli.md +42 −0
  • docs/superpowers/specs/2026-10-05-instance-catchall-design.md +410 −0
  • domains_cli.go +26 −1
  • domains_cli_test.go +46 −0
  • internal/console/apidomains.go +103 −2
  • internal/console/appdomains.go +33 −0
  • internal/console/appsettings.go +8 −0
  • internal/console/domainattach.go +4 −4
  • internal/console/domaincatchall.go +343 −0
  • internal/console/domaincatchall_test.go +374 −0
  • internal/console/server.go +4 −0
  • internal/console/templates/app_settings.html +5 −0
  • internal/console/view_test.go +9 −0
  • internal/domains/aliases.go +165 −1
  • internal/domains/claimcache.go +37 −3
  • internal/domains/claims.go +211 −3
  • internal/domains/instancecatchall_test.go +534 −0
  • internal/edge/edge.go +21 −0
  • internal/edge/homemap.go +15 −0
  • internal/edge/homemap_test.go +25 −0

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 455dd458c..6a925d2c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -448,3 +448,33 @@ wildcard or catchall, publish its paths or detach it. These checks read the
hostname, never the claim, so they reveal nothing about who holds it. The
browser form has no platform door. Its attach refuses platform names to
everyone, and its detach admits the operator.
+
+## Instance catch-alls (James, 2026-10-05)
+
+A claim on `*.<parent>` (`carlos domains attach --app <a> '*.<parent>'`)
+serves each of the app's instances at `<label>.<parent>`. Every box derives
+ordinary `route_aliases` rows from it for its own instance routes
+(`domains.ReconcileAliases`). The design is
+`docs/superpowers/specs/2026-10-05-instance-catchall-design.md`. Four rules
+hold it together; keep all of them:
+
+- **Only a person, in the account that holds `<parent>`'s own claim, may
+ attach one.** The box re-checks the parent on every pass, and a catch-all
+ whose parent has left the account is inert.
+- **An exact claim on a derived name always wins, whoever holds it.** A
+ real route also wins over a derived name.
+- **While a catch-all is live, the attach doors refuse other accounts the
+ names it reserves**, with the neutral 409. Without that, any account
+ could claim a team's name, and its exact claim would win.
+ - It reserves **every** one-label name under the parent once the account
+ proves the parent in DNS: TXT `_carlos-catchall.<parent>` =
+ `carlos-catchall-account=<sqid>`, checked live.
+ - Otherwise it reserves only the labels of the app's existing instances,
+ under any instance domain, so an unproven apex claim cannot block a
+ whole domain.
+ - It never refuses the caller's own existing claim.
+ - The box decides "claimed" from the bucket listing, never from the
+ bodies that decoded.
+ - A label two instances share derives nothing.
+- **Derivation takes only provisioned, non-sidecar, non-replica routes of
+ the claim's own account and app.**
diff --git a/apimode.go b/apimode.go
index ece90110a..7ed847883 100644
--- a/apimode.go
+++ b/apimode.go
@@ -3752,6 +3752,11 @@ type apiDomainRecord struct {
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Records []apiDomainDNSRecord `json:"records,omitempty"`
+ // Instances marks an instance catch-all ("*.<parent>"); Via names the
+ // catch-all a derived row came from. Both absent from an older console.
+ Instances bool `json:"instances,omitempty"`
+ Via string `json:"via,omitempty"`
+ Reserves string `json:"reserves,omitempty"`
}
// apiDomainDNSRecord is one record the member must create (#299 §2b) — the
diff --git a/docs/site/cli.md b/docs/site/cli.md
index b7cd9dd75..f1c082b36 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -826,6 +826,48 @@ zone. A deployment that hosts apps for other people sets
refuses every name under the apps domain and under its own hostname, because
those names belong to the operator and not to a tenant.
+#### One name per instance: `*.<parent>`
+
+For an app with instances, attaching `*.<parent>` gives every instance a
+name under your own domain. An instance at `acme.bab.oncarlos.com` then also
+answers at `acme.example.com`. The label is the first part of the
+instance's own hostname.
+
+```sh
+carlos domains attach --app teams example.com --route www.bab.oncarlos.com
+carlos domains attach --app teams '*.example.com'
+carlos domains list --app teams
+```
+
+- **Create the instance, get the name.** A new instance gets its name with
+ no further call, and a deleted one loses it.
+- **Prerequisites.** `<parent>` itself must already be attached to an app in
+ the same account. Any app will do; a static site is common. A wildcard DNS
+ record (`*`) for `<parent>` must point at this deployment.
+- **Who can attach it.** Only a signed-in person can attach or detach a
+ catch-all, never the app's own token. Quote the star, or your shell
+ expands it.
+- **What wins over it.** A hostname that has its own claim always wins over
+ the catch-all, so `www.example.com` can still go to a different app.
+- **What it reserves.** While the catch-all is attached, no other account
+ can claim the name of one of your instances. Publish the TXT record
+ `_carlos-catchall.<parent>` = `carlos-catchall-account=<your account
+ sqid>` and it reserves **every** `<label>.<parent>`, so a new team's name
+ is safe before the team exists. `carlos domains list` shows which,
+ `reserves:all` or `reserves:instances`, and names the record when it is
+ missing.
+- **In `list`.** The catch-all shows as `per-instance` / `instances`, and
+ each name the fleet is serving follows it, marked `via *.example.com`.
+
+Each name gets its own certificate, as any attached hostname does. Let's
+Encrypt limits how many new certificates one domain gets in a week. An app
+expecting many new instances a week should also enable `--wildcard` on
+`<parent>`'s own claim, so one certificate covers every name.
+
+```sh
+carlos domains detach --app teams '*.example.com'
+```
+
### carlos features
Sets or lists an app's own feature flags — switches the app defines, the
diff --git a/docs/superpowers/specs/2026-10-05-instance-catchall-design.md b/docs/superpowers/specs/2026-10-05-instance-catchall-design.md
new file mode 100644
index 000000000..bc3283432
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-05-instance-catchall-design.md
@@ -0,0 +1,410 @@
+# Instance catch-alls: `<label>.<parent>` serves the app's instance `<label>`
+
+**Date:** 2026-10-05
+**Status:** branch `instance-catchall` on amadan, not landed. Revised after
+the review on [#233](https://amadan.net/carlos/platform/-/discussion/233)
+(§8).
+
+## 1. Problem
+
+Jelly (lazyatom/jam) runs one instance of `dev-managed-jelly` per team, at
+`<team>.bad.carlos.exciting.io`. Teams should also answer at
+`<team>.letsjelly.dev`. Today the manager makes that happen with one exact
+claim per team at signup (`AttachDomain` in `manager/carlos.go`), plus a wait
+for DNS. The existing catch-all (`Wildcard`+`Catchall` on a claim) cannot do
+it, because it sends every subdomain to ONE route
+(`domains.ReconcileAliases`, the `*.<host>` → primary row). It cannot fan
+out to per-team instances, and it belongs to whoever holds the apex claim.
+Here that is the static site `dev-jelly-site`.
+
+## 2. Design
+
+**The claim.** An instance catch-all is a `domains.Claim` at the key
+`domains/*.<parent>.json`, with `Host: "*.<parent>"`, `Account`, `App`,
+`Instances: true` and nothing else: no Route, Wildcard, Catchall or Paths.
+
+- The star in the key means there is one catch-all per parent, held by the
+ same create-only bucket-key gate that every claim uses.
+- No attach door accepts a `*` (`NormalizeAliasHost`), so a catch-all can
+ never collide with an ordinary claim.
+
+**The door.** `carlos domains attach --app <a> '*.<parent>'` (API: the
+existing attach body with `host: "*.<parent>"`). `detach` removes it the
+same way. Writing a catch-all requires all of these:
+
+1. A **person** (`apiPerson`), like `carlos instances enable`. A catch-all
+ is standing policy over every unclaimed name under the parent. A
+ credential the app holds must not be able to set it for itself. Without
+ this, an app token could also put its instances under a sibling app's
+ parent.
+2. No route, kind, channel, wildcard, catchall or platform field in the
+ request. They are refused, not ignored.
+3. The app has an instance manifest. Without one it has nothing to serve.
+ An app without one is also the only kind of app a rename can still
+ move, which would leave the claim naming a dead app.
+4. The parent passes `catchallParentRefused`. It refuses a parent when:
+ - the parent is in a deny tier (reserved or platform, so operators are
+ refused too);
+ - any console, reserved or platform hostname is equal to the parent,
+ under it, or above it. The box cannot see the deny lists, so a
+ derived name could otherwise land on the tunnel host or a console
+ host;
+ - the parent is equal to the apps domain, under it, or above it. That
+ space holds the account-sqid and app-apex names. This is checked
+ whatever `CONSOLE_RESERVE_PLATFORM_DOMAINS` says: on a self-hosted box
+ the apps domain is not reserved, and `edge.<apps-domain>` would
+ otherwise be derivable;
+ - the parent is a public suffix (`co.uk`, `github.io`);
+ - the parent is under the sidecar suffix.
+5. **The parent's own claim is held by the same account, by any of its
+ apps** (`domains.AttachInstanceCatchall`). With wildcard DNS at the edge,
+ a catch-all covers every unclaimed name under the parent at once, so only
+ whoever already holds the parent may make one. If the parent has no claim
+ at all, the request is refused: claim the parent first (an alias to any
+ route of the account will do). Holding a catch-all in the same account
+ but under a different app from the apex is the point of the design
+ (static site at the apex, teams below it).
+
+**On the box.** `ReconcileAliases` gains `deriveInstanceAliases`. For each
+catch-all whose parent is still claimed by the same account, it walks the
+box's local routes and derives one ordinary `route_aliases` row,
+`<rt.Slug>.<parent>` → `rt.Host`, for each route that meets every one of
+these:
+
+- `Provisioned`, not a sidecar, and not a replica;
+- the claim's own app, and the claim's own account (`RouteAccount`);
+- a `Slug` that is a single DNS label and is the first label of the host.
+ The instance create door makes every record this shape.
+
+A derived name loses to, in this order:
+
+1. **any claim on that exact name, whoever holds it**. This covers the
+ manager's existing per-team claims, `www`, `auth`, and another
+ account's claim made before the catch-all existed;
+2. a real route on this box;
+3. a real route on any live box (`deploymentRoutes`).
+
+The desired set is rebuilt on every pass:
+
+- a deleted or moved instance drops its name on the next pass;
+- a detached catch-all drops every name;
+- a released exact claim hands its name back to the catch-all.
+
+Rows are written only when they change, so a steady state causes no edge
+reload. At most 1000 derived names per box (`maxInstanceAliases`), in a
+deterministic order, with the overflow logged (§4.11).
+
+**What the derived rows are.** They are the same rows an exact alias claim
+for each name produces today: `Alias`, `Host`, `Account`, `App`. That is the
+property the rest of this note relies on. Every reader of `route_aliases`
+already handles such rows correctly, and none of them looks for a claim
+behind a row. The only code that deletes alias rows is the pass's own
+desired-set sweep. The edge itself needed only one change, made after
+review: the cross-box precedence fix in §8, item 5.
+
+**The attach guard.** While a catch-all is live, the attach doors refuse
+**other accounts** the one-label names under its parent that it reserves
+(`domains.ForeignInstanceCatchall`, then `catchallInstanceName` in the
+console). A refused attach gets the neutral 409 from the same place
+`deploymentRouteTaken` gives it, in all three attach paths (API alias, form
+alias, first-route). What it reserves (§8, review P1s):
+
+- **Proved parent: every label.** The account proves it controls the parent
+ in DNS with TXT `_carlos-catchall.<parent>` =
+ `carlos-catchall-account=<sqid>`. The value names the account, the same
+ shape as the mail ownership proof (#216).
+ - The guard checks the record live, so it follows the domain's DNS rather
+ than a stamp taken once.
+ - A lookup failure counts as "not proved": the narrower reservation, never
+ a refusal.
+ - `carlos domains list` reports `reserves: all` or `reserves: instances`
+ on the catch-all's row, and names the record when it is missing.
+- **Unproved parent: the labels of the app's existing instances**, under
+ any instance domain the app has used. These are counted from the app's
+ instance record keys, so a manifest-domain change does not orphan them.
+
+- **Why it exists.** Without it, any account could attach
+ `acme.letsjelly.dev`. Its exact claim would beat the derived name and
+ take a live team's address. Attach needs no DNS proof, so nothing else
+ stops that.
+- **Why an unproved parent holds less (first review, finding 3).** Attach
+ needs no DNS proof, so a parent claim proves nothing. Holding every label
+ for an unproved parent would let one squatted apex claim plus a
+ catch-all block the real domain owner from every name under it.
+- **One label only**, because a catch-all serves one label only. Deeper
+ names stay as open as they are today.
+- **The caller's own existing claim is never refused (review finding 1).**
+ A claim made before the catch-all keeps re-attaching and converging.
+ titogo's router re-attaches every name on every pass.
+ `deploymentRouteTaken` makes the same allowance.
+- **"Live"** is the box's rule: the catch-all's parent is still claimed by
+ its account. A catch-all whose parent was released, or has moved to
+ another account, holds nothing. The parent's new holder may replace it
+ (`AttachInstanceCatchall` overwrites a dead one).
+- **Read failures.** A bucket read failure in the guard is a retryable
+ 502. It is never a 409 (which would read as "held") and never a skipped
+ guard.
+
+**Precedence is decided from the listing (review finding 2).** The box
+decides "a claim on this name exists" from the bucket listing's keys, not
+from the claim bodies that decoded this pass. A body whose GET fails is
+still a claim, so a spurious store error cannot hand another account's name
+to a derived alias for a pass.
+
+**Certificates.** Each derived name gets per-host HTTP-01/ALPN certificates
+exactly as exact aliases do: the cert warm orders them once DNS points
+here, and the first handshake orders one otherwise. A delegated wildcard
+certificate is optional on top, by setting `--wildcard` on the parent's
+own claim. It then covers every derived name on every box (§4.10).
+
+## 3. Mixed versions
+
+- **An old box or old console reading a catch-all** decodes a claim with
+ no Route, no Wildcard and no Paths. Every pass already skips that shape.
+ It logs nothing and makes no DNS lookup or ACME order for the literal
+ `*.<parent>`. Certwarm drops `*.` hosts anyway.
+- **An old console** lists the row with an empty route and "checking"
+ state. Its Detach button answers 404, because `NormalizeAliasHost` refuses
+ the star. No old door can rewrite the claim, for the same reason, so the
+ unknown `instances` field cannot be stripped.
+- **Keys:** `*` is safe in S3 keys, in DirStore on Linux, and through the
+ fleet store door. `canonicalKey` accepts it, and `aclRead` and
+ `aclListScope` already grant `domains/**` to fleet boxes. DirStore on
+ Windows would refuse it, but no deployment bucket runs there.
+- **A new console with old boxes:** the catch-all is inert until the boxes
+ upgrade. The attach guard is already live at that point, so other
+ accounts are refused names the boxes do not serve yet. That is harmless,
+ and it is what the owner asked for.
+
+## 4. Deployment types
+
+Each item says whether it is affected and what guards it.
+
+1. **Flagship (console as an app, carloku.com, #209).** Not affected unless
+ a catch-all is made, and none can be made on the deployment's own
+ zones: refused for everyone, operators included. The console's names,
+ steering, tunnel and the apps domain are refused at, under and above the
+ parent. There is no `--platform` form; it can be added if the flagship
+ ever wants one.
+2. **Tito / titogo.** Its router re-attaches hostnames with an instance
+ token, and it cannot create or remove a catch-all: those are
+ person-only. Store-mode custom instance domains (go.tito.io) are
+ unaffected, since nothing derives without a catch-all. The re-attach
+ loop pays one extra GET per attach (`*.<parent>` of the host, usually a
+ 404). A Hetzner spurious 403 there is a retryable 502. Re-attach is
+ idempotent, so the next pass succeeds.
+3. **Pages-hub one-route catch-all, and woodstar.app** (apex plus
+ `Wildcard`+`Catchall`, same app). Unaffected unless an instance
+ catch-all is added on the same parent. If one is, derived exact names
+ beat the route catch-all name by name (`lookupIn`: exact key first, then
+ `*.`). That is same-account by construction, and it is the useful
+ combination: unknown labels fall to the site, known ones to their team.
+ - On woodstar it would take a PDS handle whose label matches an instance
+ label, so do not combine the two there.
+ - The ceecb3a9 self-removal loop cannot recur. Derived rows are exact
+ names published with `Alias: true`, and `DeploymentRoutes` counts only
+ `Role==home && !Alias`.
+4. **Self-hosted single box, and this hand-staged box.** Same as above:
+ nothing changes without a catch-all. The apps domain is refused as a
+ parent even though it is not reserved there. Hand-added routes
+ (`carlos route add`) are not `Provisioned` and derive nothing. That also
+ keeps a legacy account-name column, which reads as ops, out of reach.
+5. **Customer fleets / `carlos apps place`.** Fleet boxes read `domains/**`
+ through the store door, so they derive for their own instances. The store
+ door does not grant `control/edges/`, so `deploymentRoutes` is nil
+ (permissive) there. This gap already exists for plain aliases, and the
+ exact-claim skip still applies.
+6. **Multi-box, multi-region edges, the edges file.** Derived rows publish
+ exactly like exact aliases: only once `StampHealth` has stamped them
+ `pointing`, and backhauled by remote edges to the home box. A new or
+ moved name can take up to about 12 minutes to reach other edges (one
+ stamp period plus one edges-file period). Exact claims have the same
+ delay today; a single box serves at once. Duplicate publication
+ (everywhere-class instances) folds to the first-sorted box, as for exact
+ aliases.
+7. **Edge replicas, steering.**
+ - Replica rows are excluded explicitly (`rt.Replica`). A door-created
+ replica carries the label as its Slug, so the Slug shape alone would
+ not exclude it.
+ - Steering looks at platform names only, and never at aliases or claims.
+8. **Instance moves.** During the cut the source row is `moving`, and the
+ edge and edges file skip it. After the flip the target box derives the
+ name. The name follows the instance, as an exact alias claim would.
+9. **Hibernation and activation.** An alias entry is its primary's upstream
+ copied whole, `route` and `hibernating` included. A request on a derived
+ name therefore wakes the instance itself, and metering, the gate and
+ analytics are keyed on `rt.Host`.
+ - The response cache keys on the requested host, so the app purges a
+ derived name's entries with `Carlos-Purge-Host`. That is pre-existing
+ for exact aliases.
+ - The backend sees `Host: <label>.<parent>`, so the app must accept it.
+ Jelly does today, via the exact claims.
+10. **Isolated exec, path-prefix maps.**
+ - Isolated exec: units and sockets are keyed on `rt.Host`, so it is
+ unaffected.
+ - Path-prefix maps: a derived name inherits its instance's map, as any
+ alias does. A Paths claim on a derived name itself is an exact claim
+ and suppresses the derived row.
+11. **Platform apps-domain wildcard, platform-domain claims (#209),
+ reserved/deny lists, readings sweep, cert sweep, LE limits, alias-table
+ growth, `maxWildcardDomains`.**
+ - `parentsFor` takes only platform/provisioned non-alias hosts, so
+ derived names cannot mint platform parents.
+ - The deny tiers are covered in §2.
+ - The readings sweep (`StampHealth`) does one serial `LookupHost` per
+ alias (3s timeout) every 5–6 minutes. Each alias adds about 140 bytes
+ to the box's status snapshot, and readers skip a snapshot above 1 MiB.
+ A skipped box also drops out of `DeploymentRoutes` and the edge-IP
+ fold. That is why there is a **per-box cap of 1000 derived names**,
+ logged when it is hit.
+ - The cert sweep is expiry-only, and `ownsCertName` checks
+ `deploymentHost`, not claims.
+ - **Let's Encrypt:** without a wildcard, N instances means N new
+ CT-logged certs against the registered domain's limit (50 per 7 days
+ as of writing). About 200 teams take about four weeks of issuance;
+ past the limit, certwarm backs off 12h per host. That is identical to
+ today's per-team exact claims. The remedy is `--wildcard` on the
+ parent claim, which needs `CARLOS_ACME_DELEGATION_ZONE` and a DNS
+ provider. The testbed has neither.
+ - The instance catch-all is not a Wildcard claim, so it takes no slot
+ in `maxWildcardDomains` or `maxDelegatedParents`.
+12. **Trash, purge, rename and move of apps; instance delete with parked
+ data.**
+ - Trash drops the app's routes, so the derived names go with them. The
+ catch-all claim stays, as every claim does, and comes back to life on
+ restore.
+ - Purge never deletes `domains/` (pre-existing for all claims). An
+ orphaned catch-all keeps holding names under its parent for its
+ account, and passes to a later app of the same name in the same
+ account. Detach it before purging.
+ - Rename only applies to never-shipped apps, and the door requires an
+ instance manifest.
+ - Account move leaves claims on the old account, which derive nothing
+ (pre-existing).
+ - Instance delete removes the route but keeps the parked data. The name
+ drops, and comes back if the instance is recreated.
+
+## 5. Security cases
+
+- **(a) A label colliding with another account's exact claim:** the exact
+ claim wins, on every box. A derived name is skipped whenever any claim
+ exists for it. New claims of that kind are refused by the attach guard
+ while the catch-all is live. Claims made before the catch-all keep
+ working, so it cannot take a name away from anyone.
+- **(b) A sibling app's apex label:** cannot happen.
+ - Only the claim's own app's routes derive.
+ - Sibling apex hosts are not under the parent.
+ - The instance create door refuses a label that names a live or trashed
+ sibling app, and `apex.go` refuses the name arriving afterwards.
+- **(c) A team's name taken before the team exists.**
+ - **Proved parent:** impossible while the catch-all is live, for every
+ label, including a deleted team's.
+ - **Unproved parent:** the name is protected once the instance exists. A
+ foreign claim made before that wins, which is the first-come rule every
+ claim has.
+ - A same-account exact claim wins on purpose.
+- **(d) Races between the claim pass and the instance reconcile:** both
+ sides are rebuilt from scratch each pass, and every precedence check is
+ made against the same pass's claim list and route table. A catch-all
+ write racing a foreign attach can let both land; the exact claim then
+ wins, which is the same outcome as (c). A parent released between the
+ door's check and its write is caught by the box's parent re-check.
+- **(e) The parent changes hands:** the old catch-all goes inert on the
+ box, stops holding names at the door, and the new holder can replace it.
+- **(f) A derived alias pointing at another account's route:** prevented by
+ three filters, all of which stay: `rt.App == c.App`,
+ `RouteAccount(rt) == c.Account` and `rt.Provisioned`.
+- **Abuse of the guard as a squat.**
+ - **Unproved parent:** the guard holds only names of instances that
+ exist, each a real, rate-limited instance. That is the same class of
+ squat as claiming those names directly.
+ - **Proved parent:** holding every label takes write access to the
+ parent's DNS, which is what owning the domain means.
+ - Public suffixes are refused as parents either way.
+
+## 7. Review
+
+Reviewed by an Opus agent before landing. Three medium findings were all
+fixed, each with a test that was watched to fail first where practical:
+
+1. The guard refused an account's re-attach of its own older claim.
+2. Precedence was decided from decoded bodies, which fails open on a read
+ error.
+3. The guard held every label, which amplified a squat.
+
+Lows taken:
+
+- the browser form now refuses a channel on a catch-all.
+
+Lows left as they are:
+
+- **Member, not owner, on the API door.** The API door admits any person
+ in the account, the browser door only owners. That is the same split
+ ordinary attach has.
+- **A reassigned name keeps its old `dns_status` until the next stamp.**
+ Pre-existing for exact claims changing hands.
+- **Once-per-process refusal logs** do not repeat if a catch-all recovers
+ and fails again.
+
+## 8. Second review ([#233](https://amadan.net/carlos/platform/-/discussion/233), Paul's agent with Astra)
+
+Two P1s and three P2s. Every one was checked against the code and found
+real, and all five are fixed with tests that were watched to fail first.
+
+1. **P1: a team's name could be taken before the team exists.** Wildcard
+ DNS points every label at the edge, and attach needs no proof.
+ **Fixed:** a proved parent reserves every label (§2, the attach guard).
+2. **P1: the guard checked only the current manifest domain.** **Fixed:**
+ the guard counts the app's instance record keys by first label, across
+ every instance domain (`domains.InstanceLabelCounts`), with one listing
+ and no record bodies.
+3. **P2: a duplicate label routed differently per box.** Two instances
+ sharing a label, possible only after an instance-domain change, were
+ each named by the box that held them. **Fixed:** a label shared by two
+ records derives nothing, on every box alike, and the refusal is logged.
+ - Seen from the same record listing, asked once per catch-all and only
+ when the box has an instance to name.
+ - If the listing fails, the catch-all's existing rows are kept for that
+ pass, never dropped.
+ - The earlier "first in host order wins" text is withdrawn.
+4. **P2: an old owner's detach could delete the new owner's catch-all.**
+ This happened after the parent changed hands and the catch-all was
+ replaced in place. **Fixed:** `DetachClaim` deletes an instance
+ catch-all only at the revision it read (`release.DeleteIfMatch`).
+ - Ordinary claims are unchanged. They cannot be replaced while held, so
+ the race does not exist for them.
+ - Both production stores (S3, DirStore) support the conditional delete.
+5. **P2: a route catch-all on one box beat an instance name on another.**
+ The local `*.<parent>` matched before backhaul was considered.
+ **Fixed in the edge:** a local match made only through a star key now
+ yields to another box's exact host (`homeMap.ExactHome`, then backhaul).
+ - That is the single-box precedence, exact then star, extended across
+ the deployment.
+ - An arrived hop and a door call keep the local answer.
+ - A two-box test covers it.
+
+The review's answers to this note's asks:
+
+- **`--platform` catch-alls:** keep refusing them.
+- **CLI spelling:** agreed.
+- **"Delete now" leftovers:** filed as their own discussion.
+
+## 6. Surfaces
+
+- **CLI.** The catch-all is spelled `carlos domains attach --app <a>
+ '*.<parent>'` and `carlos domains detach --app <a> '*.<parent>'`.
+ - The star is the mode, and no other flag combines with it.
+ - This keeps the meaning of `attach <host>` as "this hostname now
+ serves this app", and needs no new flag grammar.
+ - `--catchall` stays the bool capability it is; `--catchall instances`
+ would have made it a value flag and broken bare `--catchall`.
+ - `carlos domains list` shows the catch-all row with mode `instances`,
+ then each derived name the fleet reports, marked `via *.<parent>`.
+- **API.** Same endpoints. The attach answer and list rows gain
+ `instances: true`, and the list gains `via` on derived rows. Both are
+ additive.
+- **Web UI.** The app's Domains & TLS table shows the catch-all and its
+ derived names. The page does not edit wildcard or catchall today, and
+ the catch-all is person-only, so the page lets an owner attach and detach
+ it the same way as any hostname: typing `*.<parent>` into the attach
+ form.
diff --git a/domains_cli.go b/domains_cli.go
index 0be68b260..748423410 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -27,6 +27,12 @@ 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.
+ 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>'.
detach --app <a> [flags] <hostname>
release a hostname claim
list --app <a> [flags]
@@ -128,6 +134,10 @@ func runDomainsAttach(args []string) {
if err != nil {
log.Fatalf("domains attach: %v", err)
}
+ if strings.HasPrefix(host, "*.") {
+ // An instance catch-all: no route of its own to report.
+ resolvedRoute = "<label>." + strings.TrimPrefix(host, "*.") + " for each instance"
+ }
if created {
fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
} else {
@@ -264,6 +274,21 @@ func runDomainsList(args []string) {
if r.Catchall {
catchall = "yes"
}
+ // An instance catch-all has no single route: each instance serves
+ // at <label>.<parent>. Its derived names follow it, marked with the
+ // catch-all they came from.
+ route := r.Route
+ switch {
+ case r.Instances:
+ // Which labels it holds against other accounts: all of them once
+ // the parent is proved in DNS, else only existing instances'.
+ route, catchall = "per-instance", "instances"
+ if r.Reserves != "" {
+ catchall += "(reserves:" + r.Reserves + ")"
+ }
+ case r.Via != "":
+ catchall = "via " + r.Via
+ }
delegation := r.DelegationStatus
if delegation == "" {
delegation = "-"
@@ -273,7 +298,7 @@ func runDomainsList(args []string) {
guidance = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- r.Host, r.Route, state, dns, cert, wildcard, catchall, delegation, guidance)
+ r.Host, route, state, dns, cert, wildcard, catchall, delegation, guidance)
}
w.Flush()
}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index f3071c28b..9f086f78f 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -342,6 +342,11 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
// answer yet) and never with a "Create:" instruction.
{"host": "flaky.example.com", "route": "default.go.tito.io", "status": "live",
"dns_status": "unresolved"},
+ // An instance catch-all and one name derived from it.
+ {"host": "*.teams.example.com", "route": "", "status": "live", "state": "live",
+ "instances": true},
+ {"host": "acme.teams.example.com", "route": "acme.go.tito.io", "status": "live", "state": "live",
+ "dns_status": "pointing", "via": "*.teams.example.com"},
},
})
default:
@@ -745,3 +750,44 @@ func TestRunDomainsAttachPlatformFlag(t *testing.T) {
t.Fatalf("--platform body = %+v, want platform true", fc.lastAttachBody)
}
}
+
+// TestRunDomainsInstanceCatchall: the star is the whole instruction — the
+// body carries the host and nothing else — and the list marks the
+// catch-all and the names derived from it.
+func TestRunDomainsInstanceCatchall(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.attachCreated = true
+ buf := captureStdout(t)
+ runDomains([]string{"attach", "-app", "tito", "*.teams.example.com"})
+ if b := fc.lastAttachBody; b.Host != "*.teams.example.com" || b.Route != "" || b.Wildcard != nil || b.Catchall != nil || b.Platform {
+ t.Fatalf("console saw body %+v", b)
+ }
+ if out := buf.String(); !strings.Contains(out, "attached *.teams.example.com -> <label>.teams.example.com for each instance") {
+ t.Fatalf("stdout = %q", out)
+ }
+
+ buf = captureStdout(t)
+ runDomains([]string{"list", "-app", "tito"})
+ var head, derived string
+ for _, l := range strings.Split(buf.String(), "\n") {
+ if strings.HasPrefix(l, "*.teams.example.com") {
+ head = l
+ }
+ if strings.HasPrefix(l, "acme.teams.example.com") {
+ derived = l
+ }
+ }
+ if !strings.Contains(head, "per-instance") || !strings.Contains(head, "instances") {
+ t.Fatalf("catch-all row = %q", head)
+ }
+ if !strings.Contains(derived, "acme.go.tito.io") || !strings.Contains(derived, "via *.teams.example.com") {
+ t.Fatalf("derived row = %q", derived)
+ }
+
+ fc.detachRemoved = true
+ buf = captureStdout(t)
+ runDomains([]string{"detach", "-app", "tito", "*.teams.example.com"})
+ if !strings.HasSuffix(fc.lastDetachPath, "/domains/*.teams.example.com") {
+ t.Fatalf("detach path = %s", fc.lastDetachPath)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 2155a225c..c6c95ba6b 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -360,6 +360,37 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, platformOperatorMsg)
return
}
+ // "*.<parent>" is an instance catch-all (domaincatchall.go), not a
+ // hostname: it names no route, carries no capabilities and has no
+ // platform form, so every other field is refused rather than ignored.
+ // A person's act, like `carlos instances enable`: a catch-all is
+ // standing policy over every unclaimed name under parent, which a
+ // credential the app holds must not be able to set for itself.
+ if parent, ok := domains.ParseInstanceCatchallHost(in.Host); ok {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ if in.Route != "" || in.Kind != "" || in.Channel != "" || in.Wildcard != nil || in.Catchall != nil || in.Platform {
+ jsonError(w, http.StatusBadRequest,
+ "an instance catch-all takes no route, kind, channel, wildcard, catchall or platform — it serves each instance at <label>."+parent)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ if ref := s.catchallAppReady(ctx, acct, app); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ created, ref := s.attachInstanceCatchall(ctx, acct, app, parent, principal, message)
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "host": domains.InstanceCatchallHost(parent), "route": "", "created": created, "instances": true,
+ })
+ return
+ }
// Path-traversal defence stated as such: this hostname becomes a bucket
// key segment (domains/<host>.json) and, on the box, a registry alias row
// and a certificate name. Nothing below runs until it is a well-formed,
@@ -464,8 +495,8 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// can refuse first, the two 409s are indistinguishable in status, body
// AND reachability. Anything added to this branch later belongs above
// this line, not below it.
- if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) {
- jsonError(w, http.StatusConflict, hostUnavailableMsg)
+ if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
return
}
created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{
@@ -849,6 +880,24 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
return
}
principal := auditActor(p, acct)
+ // An instance catch-all is released by a person, as it was set by one,
+ // and never through the first-route branch below: a catch-all has an
+ // empty Route too, and that branch deletes a provision record.
+ if parent, ok := domains.ParseInstanceCatchallHost(r.PathValue("host")); ok {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ removed, err := s.detachInstanceCatchall(ctx, acct, app, parent, principal)
+ if err != nil {
+ log.Printf("console: api domains detach %s/%s *.%s: %v", acct.Sqid, app, parent, err)
+ jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"host": domains.InstanceCatchallHost(parent), "removed": removed})
+ return
+ }
// Shape-checked before the key builder, not after: a {host} segment
// arrives percent-decoded, so "..%2f..%2finstances.json" reaches this
// handler as a path with separators in it, and ClaimKey would happily
@@ -951,6 +1000,18 @@ type domainListItem struct {
// so an app need not parse the sentence. Records sharing a "set" are one
// alternative; a client picks one set per name.
Records []dnsRecord `json:"records,omitempty"`
+ // Instances marks an instance catch-all row ("*.<parent>",
+ // domaincatchall.go). Via names the catch-all a row was derived from:
+ // such a row is a name the fleet reports serving for one of the app's
+ // instances, not a claim of its own, so it cannot be detached by name.
+ // Both additive and omitted otherwise.
+ Instances bool `json:"instances,omitempty"`
+ Via string `json:"via,omitempty"`
+ // Reserves, on an instance catch-all's row, is which one-label names
+ // under its parent other accounts are refused: "all" once the account
+ // has proved the parent in DNS (domains.CatchallProofName), otherwise
+ // "instances" — only the names of instances that exist. Additive.
+ Reserves string `json:"reserves,omitempty"`
}
// handleAPIDomainList answers `carlos domains list`: this app's claims, each
@@ -992,7 +1053,9 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
// inside domainListCNAMELookupCap: sorting after the loop would let the
// store's arbitrary iteration order pick a different 8 hosts every call.
var mine []domains.Claim
+ claimed := make(map[string]bool, len(claims))
for _, c := range claims {
+ claimed[c.Host] = true
if c.Account == acct.Sqid && c.App == app {
mine = append(mine, c)
}
@@ -1009,6 +1072,10 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
// same host-sorted order the answer itself uses) simply omit
// delegation_status — the same "nothing to report yet" reading a
// disabled wildcard or an unset zone already gets, never an error.
+ if c.Instances {
+ out = append(out, s.catchallListItems(ctx, acct, app, c, claimed, readings, warm, targets, now)...)
+ continue
+ }
delegation := c.Wildcard && s.delegationZone != "" && lookups < domainListCNAMELookupCap
if delegation {
lookups++
@@ -1089,6 +1156,40 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // An instance catch-all answers with its own row and the names it
+ // derives, the list door's rows for it. Same 404 for a catch-all this
+ // app does not hold as for one nobody does.
+ if parent, ok := domains.ParseInstanceCatchallHost(r.PathValue("host")); ok {
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ claims, err := s.claims.List(ctx, s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: api domain get %s/%s *.%s: %v", acct.Sqid, app, parent, err)
+ jsonError(w, http.StatusBadGateway, "could not read the hostname")
+ return
+ }
+ host := domains.InstanceCatchallHost(parent)
+ claimed := make(map[string]bool, len(claims))
+ var mine *domains.Claim
+ for i, c := range claims {
+ claimed[c.Host] = true
+ if c.Host == host && c.Account == acct.Sqid && c.App == app && c.Instances {
+ mine = &claims[i]
+ }
+ }
+ if mine == nil {
+ jsonError(w, http.StatusNotFound, "no such hostname")
+ return
+ }
+ now := time.Now()
+ items := s.catchallListItems(ctx, acct, app, *mine, claimed, aliasReadings(ctx, s.cfg.Releases, now),
+ warmRecords(ctx, s.cfg.Releases), s.dnsTargets(), now)
+ writeJSON(w, http.StatusOK, map[string]any{
+ "domain": items[0], "derived": items[1:],
+ "deployment": s.domainCapability(ctx, acct.Sqid, app), "paths": map[string]string{},
+ })
+ return
+ }
// Shape-checked BEFORE the key builder, exactly as the detach door does:
// a {host} segment arrives percent-decoded, so "..%2f..%2finstances.json"
// would reach ClaimKey as a path with separators in it. There is no path
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go
index eee8eaa97..4822789b5 100644
--- a/internal/console/appdomains.go
+++ b/internal/console/appdomains.go
@@ -16,9 +16,12 @@ package console
import (
"context"
"crypto/subtle"
+ "log"
"net/http"
"strings"
"time"
+
+ "github.com/carlosframework/platform/internal/domains"
)
// handleAppDomainAttach claims a host for the app and, when the app has no
@@ -44,6 +47,26 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
+ // "*.<parent>" is an instance catch-all (domaincatchall.go). The owner
+ // gate above is the person check: a session is always a person.
+ if parent, ok := domains.ParseInstanceCatchallHost(host); ok {
+ if channel != "" {
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "domains",
+ "an instance catch-all takes no channel — each instance keeps its own", map[string]string{"domain": host})
+ return
+ }
+ ref := s.catchallAppReady(ctx, acct, app)
+ if ref == nil {
+ _, ref = s.attachInstanceCatchall(ctx, acct, app, parent, addr, "")
+ }
+ if ref != nil {
+ s.renderDoorRefusal(w, r, ref)
+ return
+ }
+ s.setFlash(w, "ok", "Attached "+domains.InstanceCatchallHost(parent)+" — each instance now also answers at <label>."+parent+".")
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
+ return
+ }
res, ref := s.attachDomain(ctx, acct, app, host, "", channel, addr)
if ref != nil {
if ref.Status == http.StatusUnprocessableEntity {
@@ -88,6 +111,16 @@ func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
+ if parent, ok := domains.ParseInstanceCatchallHost(host); ok {
+ if _, err := s.detachInstanceCatchall(ctx, acct, app, parent, addr); err != nil {
+ log.Printf("console: detach %s/%s %s: %v", acct.Sqid, app, host, err)
+ s.renderMessage(w, r, http.StatusBadGateway, "Refused", "Could not detach the catch-all — try again.")
+ return
+ }
+ s.setFlash(w, "ok", "Detached "+host+".")
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
+ return
+ }
if ref := s.detachDomain(ctx, acct, app, host, addr); ref != nil {
s.renderDoorRefusal(w, r, ref)
return
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index bed6ff51f..a84ae58e4 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -673,10 +673,18 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string,
reading, seen := readings[host]
return domainState(reading, seen, warm[host], now)
}
+ claimed := make(map[string]bool, len(claims))
+ for _, c := range claims {
+ claimed[c.Host] = true
+ }
for _, c := range claims {
if c.Account != acct.Sqid || c.App != app {
continue
}
+ if c.Instances {
+ out = append(out, s.catchallDomainViews(ctx, acct, app, c, claimed, readings, warm, now)...)
+ continue
+ }
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)
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 4ec6cf467..7cf722b61 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -147,8 +147,8 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
// --- alias attach: the app already has a route to serve from ---------
if route != "" {
- if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) {
- return attachResult{}, claimRefusal(host, domains.ErrHostUnavailable)
+ if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
+ return attachResult{}, ref
}
created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim)
if err != nil {
@@ -177,8 +177,8 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
if ref != nil {
return attachResult{}, ref
}
- if s.deploymentRouteTaken(ctx, host, acct.Sqid, app) {
- return attachResult{}, claimRefusal(host, domains.ErrHostUnavailable)
+ if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
+ return attachResult{}, ref
}
created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim)
if err != nil {
diff --git a/internal/console/domaincatchall.go b/internal/console/domaincatchall.go
new file mode 100644
index 000000000..08d70c16b
--- /dev/null
+++ b/internal/console/domaincatchall.go
@@ -0,0 +1,343 @@
+// Instance catch-alls — a claim on "*.<parent>" that serves each of an
+// app's instances at <label>.<parent> (docs/superpowers/specs/
+// 2026-10-05-instance-catchall-design.md). The claim is written here; the
+// alias rows are derived box-side (domains.ReconcileAliases).
+//
+// Both doors share this core, the way attachDomain is shared: the CLI API
+// door (handleAPIDomainAttach/Detach, which branch here on a "*." host) and
+// the app Domains page.
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "golang.org/x/net/publicsuffix"
+
+ "github.com/carlosframework/platform/internal/certwarm"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// catchallParentMsg is the refusal for a catch-all whose parent the account
+// does not hold. One message whether nobody holds it or someone else does:
+// AttachInstanceCatchall does not tell the two apart, and neither does this.
+func catchallParentMsg(parent string) string {
+ return "attach " + parent + " to an app in this account first — an instance catch-all on *." + parent + " needs " + parent + " itself held by the same account"
+}
+
+// catchallParentRefused reports whether parent may never carry an instance
+// catch-all on this deployment, decided from the name and the config alone.
+//
+// A catch-all claims every one-label name under parent at once, so the
+// refusal looks at the names UNDER parent as well as parent itself:
+//
+// - parent, or anything under it, in the deny tiers (domainHostPolicy):
+// the deployment's own zones are never a tenant's catch-all, operators
+// included;
+// - any reserved, platform or console hostname strictly under parent,
+// which a derived name could otherwise land on (the box cannot see the
+// deny lists, so the door is the only place this can be refused);
+// - the apps domain, or anything under it: the account-sqid and app-apex
+// namespace, where instance hosts already live;
+// - a public suffix (co.uk, github.io): a catch-all reserves the names
+// under its parent to one account at the attach door, and that must
+// never cover a registry's whole namespace;
+// - the reserved sidecar suffix.
+func (s *Server) catchallParentRefused(parent, account string) bool {
+ if s.domainHostPolicy(parent, account) != hostOpen {
+ return true
+ }
+ if strings.HasSuffix(parent, provision.SidecarSuffix) {
+ return true
+ }
+ if suffix, _ := publicsuffix.PublicSuffix(parent); suffix == parent {
+ return true
+ }
+ under := func(entry string) bool {
+ entry = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(entry)), ".")
+ return entry != "" && (entry == parent || strings.HasSuffix(entry, "."+parent) || strings.HasSuffix(parent, "."+entry))
+ }
+ if under(s.cfg.AppsDomain) {
+ return true
+ }
+ entries := slices.Concat(s.consoleHosts(), s.reservedDomains(false), s.platformDomains(false))
+ return slices.ContainsFunc(entries, under)
+}
+
+// attachInstanceCatchall claims "*.<parent>" for acct's app. The caller has
+// established that principal is a person in this account.
+func (s *Server) attachInstanceCatchall(ctx context.Context, acct *Account, app, parent, principal, message string) (created bool, ref *doorRefusal) {
+ if ref := s.bucketRefusal(); ref != nil {
+ return false, ref
+ }
+ if s.catchallParentRefused(parent, acct.Sqid) {
+ return false, &doorRefusal{http.StatusBadRequest, "that hostname is platform-managed"}
+ }
+ created, err := domains.AttachInstanceCatchall(ctx, s.cfg.Releases, parent, acct.Sqid, app,
+ time.Now().UTC().Format(time.RFC3339), principal)
+ switch {
+ case errors.Is(err, domains.ErrParentNotHeld):
+ return false, &doorRefusal{http.StatusConflict, catchallParentMsg(parent)}
+ case err != nil:
+ return false, claimRefusal(domains.InstanceCatchallHost(parent), err)
+ }
+ if created {
+ host := domains.InstanceCatchallHost(parent)
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "domain-attach", TargetType: "app", Target: app, Message: message,
+ Detail: "attached instance catch-all " + host + " -> <label>." + parent + " for each instance",
+ })
+ s.bumpSerial(ctx)
+ }
+ return created, nil
+}
+
+// detachInstanceCatchall releases acct's app's "*.<parent>". Quiet in both
+// directions, like every detach: removed=false whether it was never there or
+// is someone else's.
+func (s *Server) detachInstanceCatchall(ctx context.Context, acct *Account, app, parent, principal string) (removed bool, err error) {
+ if s.cfg.Releases == nil {
+ return false, errors.New("no deployment bucket on this host")
+ }
+ host := domains.InstanceCatchallHost(parent)
+ removed, err = domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app)
+ if err != nil {
+ return false, err
+ }
+ if removed {
+ s.audit(acct, app, principal, "domain-detach", "detached instance catch-all "+host)
+ s.bumpSerial(ctx)
+ }
+ return removed, nil
+}
+
+// heldElsewhere is the attach doors' last check before a claim write: a
+// real route another app serves (deploymentRouteTaken), or a live instance's
+// name under another account's instance catch-all (catchallInstanceName).
+// Both answer with the same neutral 409, from the same place in each door,
+// so neither reads apart from an ordinary "someone holds it".
+//
+// A bucket read the catch-all check could not make is answered as a
+// retryable error rather than a guess: refusing would read as "someone
+// holds it", and admitting would skip the guard.
+func (s *Server) heldElsewhere(ctx context.Context, host, sqid, app string) *doorRefusal {
+ if s.deploymentRouteTaken(ctx, host, sqid, app) {
+ return claimRefusal(host, domains.ErrHostUnavailable)
+ }
+ held, err := s.catchallInstanceName(ctx, host, sqid, app)
+ if err != nil {
+ log.Printf("console: attach %s: instance catch-all check: %v", host, err)
+ return &doorRefusal{http.StatusBadGateway, "could not check the hostname — try again"}
+ }
+ if held {
+ return claimRefusal(host, domains.ErrHostUnavailable)
+ }
+ return nil
+}
+
+// catchallInstanceName reports whether host is a one-label name under
+// another account's live instance catch-all that the catch-all reserves.
+//
+// What it reserves depends on whether the catch-all's account has proved it
+// controls the parent in DNS (catchallProven):
+//
+// - PROVED: every one-label name under the parent. A team's name is then
+// safe before its instance exists, and a deleted team's label is not up
+// for grabs either. With wildcard DNS pointing at the edge, an
+// unreserved label would be one any account could attach and serve,
+// with a real certificate, on somebody else's domain.
+// - NOT PROVED: only the labels the catch-all's app actually has instances
+// at, under any instance domain it has ever used. Attach needs no DNS
+// proof, so a parent claim alone proves nothing, and holding every label
+// would let one unproven apex claim plus a catch-all block the domain's
+// real owner from every name under it.
+//
+// The caller's own existing claim on host is never refused here: a claim
+// made before the catch-all keeps working, re-attaches (titogo's router
+// re-attaches every hostname on each pass) and route converges included.
+// The same allowance deploymentRouteTaken makes, for the same reason.
+//
+// Costs one GET in the common case (no catch-all over the name); the rest
+// is paid only when another account's catch-all is there.
+func (s *Server) catchallInstanceName(ctx context.Context, host, sqid, app string) (bool, error) {
+ c, err := domains.ForeignInstanceCatchall(ctx, s.cfg.Releases, host, sqid)
+ if err != nil || c == nil {
+ return false, err
+ }
+ if own, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && own.Account == sqid && own.App == app {
+ return false, nil
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return false, err
+ }
+ parent, _ := domains.ParseInstanceCatchallHost(c.Host)
+ if s.catchallProven(ctx, parent, c.Account) {
+ return true, nil
+ }
+ counts, err := domains.InstanceLabelCounts(ctx, s.cfg.Releases, c.Account, c.App)
+ if err != nil {
+ return false, err
+ }
+ label, _, _ := strings.Cut(host, ".")
+ return counts[label] > 0, nil
+}
+
+// catchallProven reports whether parent's DNS carries account's catch-all
+// proof (domains.CatchallProofName/Value). Asked live, every time, so the
+// answer follows the domain's DNS rather than a stamp taken once: a domain
+// that changes hands stops proving the old account's catch-all the moment
+// its new owner's zone stops carrying the record.
+//
+// Any lookup failure is "not proved" — the narrower reservation, never a
+// refusal — exactly as the mail ownership proof treats one (#216).
+func (s *Server) catchallProven(ctx context.Context, parent, account string) bool {
+ if s.lookupTXT == nil || parent == "" {
+ return false
+ }
+ vals, err := s.lookupTXT(ctx, domains.CatchallProofName(parent))
+ if err != nil {
+ return false
+ }
+ want := domains.CatchallProofValue(account)
+ for _, v := range vals {
+ if strings.TrimSpace(v) == want {
+ return true
+ }
+ }
+ return false
+}
+
+// catchallAppReady refuses a catch-all for an app with no instance
+// manifest. Such an app has no instances to serve, and it is the one shape a
+// rename can still move (handleAppRename renames only apps that never
+// shipped), which would leave the claim naming an app that no longer exists.
+func (s *Server) catchallAppReady(ctx context.Context, acct *Account, app string) *doorRefusal {
+ if ref := s.bucketRefusal(); ref != nil {
+ return ref
+ }
+ _, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ return &doorRefusal{http.StatusConflict,
+ app + " has no instances — enable them first (carlos instances enable), then attach the catch-all"}
+ case err != nil:
+ log.Printf("console: instance catch-all %s/%s: manifest: %v", acct.Sqid, app, err)
+ return &doorRefusal{http.StatusBadGateway, "could not read this app's instance settings — try again"}
+ }
+ return nil
+}
+
+// derivedName is one name an instance catch-all serves, as the fleet
+// reports it.
+type derivedName struct {
+ Host, Route string
+ Reading status.AliasReading
+}
+
+// catchallDerived lists the names parent's catch-all is serving for acct's
+// app, from the fleet's readings (aliasReadings), host-sorted.
+//
+// The readings carry a hostname and the route it points at, and nothing
+// about whose they are, so a name is only reported as this app's when all
+// of these hold: it is one label under parent; nobody claims it (a derived
+// row exists only where no claim does, and a claimed name is listed as its
+// own claim — or, if it is someone else's, not at all); its route is
+// exactly that label under the app's own instance domain. Another account's
+// instance can never be under that domain, so no other tenant's hostname or
+// route reaches this answer.
+//
+// One manifest read. A failure lists no derived names, never wrong ones.
+func (s *Server) catchallDerived(ctx context.Context, acct *Account, app, parent string, claimed map[string]bool, readings map[string]status.AliasReading) []derivedName {
+ m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ log.Printf("console: instance catch-all %s/%s: manifest: %v", acct.Sqid, app, err)
+ }
+ return nil
+ }
+ var out []derivedName
+ for host, reading := range readings {
+ label, rest, ok := strings.Cut(host, ".")
+ if !ok || rest != parent || claimed[host] {
+ continue
+ }
+ if reading.Route != label+"."+m.Domain {
+ continue
+ }
+ out = append(out, derivedName{Host: host, Route: reading.Route, Reading: reading})
+ }
+ slices.SortFunc(out, func(a, b derivedName) int { return strings.Compare(a.Host, b.Host) })
+ return out
+}
+
+// catchallListItems is the list door's answer for one catch-all: its own
+// row, then a row per derived name marked Via. The catch-all row has no
+// reading of its own (no box resolves "*.<parent>"), so its state speaks
+// for the names under it: live once any of them is, checking until then.
+func (s *Server) catchallListItems(ctx context.Context, acct *Account, app string, c domains.Claim, claimed map[string]bool, readings map[string]status.AliasReading, warm map[string]*certwarm.Record, targets DNSTargets, now time.Time) []domainListItem {
+ parent, _ := domains.ParseInstanceCatchallHost(c.Host)
+ derived := s.catchallDerived(ctx, acct, app, parent, claimed, readings)
+ head := domainListItem{Host: c.Host, Status: "pending", Instances: true,
+ State: StateChecking, Reason: "no-instances-reported",
+ Detail: "Serves each instance at <label>." + parent + ". No edge has reported one yet: an instance needs its route on a box running this build, and checks run every few minutes."}
+ rows := make([]domainListItem, 0, len(derived))
+ pointing := false
+ for _, d := range derived {
+ item := s.domainItem(ctx, domains.Claim{Host: d.Host, Route: d.Route}, readings, warm[d.Host], targets, now, false)
+ item.Via = c.Host
+ rows = append(rows, item)
+ pointing = pointing || d.Reading.DNSStatus == "pointing"
+ }
+ if len(derived) > 0 {
+ head.Status, head.State, head.Reason = "live", StateLive, "instances"
+ head.Detail = "Serves each instance at <label>." + parent + "; the names the fleet reports are listed below it."
+ }
+ if !pointing {
+ // The wildcard record is what makes every label resolve here; the
+ // guidance for "*.<parent>" says exactly that.
+ head.Guidance = domainGuidance(c.Host, targets)
+ head.Records = domainRecords(c.Host, targets, false, "")
+ }
+ // Whether the catch-all reserves every label, or only its instances'
+ // (catchallInstanceName). Said on every listing, with the record to
+ // publish when it is missing, because the difference is exactly whether
+ // another account can take a team's name before the team exists.
+ head.Reserves = "instances"
+ if s.catchallProven(ctx, parent, acct.Sqid) {
+ head.Reserves = "all"
+ head.Detail += " Every <label>." + parent + " is reserved for this account."
+ } else {
+ head.Detail += " Only the names of existing instances are reserved; another account could claim any other <label>." + parent +
+ ". Publish TXT " + domains.CatchallProofName(parent) + " = \"" + domains.CatchallProofValue(acct.Sqid) + "\" to reserve them all."
+ head.Records = append(head.Records, dnsRecord{
+ Type: "TXT", Name: domains.CatchallProofName(parent), Value: domains.CatchallProofValue(acct.Sqid),
+ TTL: domainRecordTTL, Set: "proof",
+ })
+ }
+ return append([]domainListItem{head}, rows...)
+}
+
+// catchallDomainViews is the Settings page's form of catchallListItems: the
+// catch-all's own row (detachable, by typing "*.<parent>") and one row per
+// derived name, which is not.
+func (s *Server) catchallDomainViews(ctx context.Context, acct *Account, app string, c domains.Claim, claimed map[string]bool, readings map[string]status.AliasReading, warm map[string]*certwarm.Record, now time.Time) []DomainView {
+ items := s.catchallListItems(ctx, acct, app, c, claimed, readings, warm, s.dnsTargets(), now)
+ out := make([]DomainView, 0, len(items))
+ for _, it := range items {
+ dv := DomainView{Domain: it.Host, Guidance: it.Guidance, State: it.State, Reason: it.Reason, Detail: it.Detail}
+ if it.Instances {
+ dv.Type, dv.Claimed = "instance catch-all", true
+ } else {
+ dv.Type = "via " + it.Via
+ }
+ out = append(out, dv)
+ }
+ return out
+}
diff --git a/internal/console/domaincatchall_test.go b/internal/console/domaincatchall_test.go
new file mode 100644
index 000000000..418ecb083
--- /dev/null
+++ b/internal/console/domaincatchall_test.go
@@ -0,0 +1,374 @@
+package console
+
+// Instance catch-alls (domaincatchall.go): "*.<parent>" claimed through the
+// domains door, serving each instance at <label>.<parent>. What matters, in
+// the order it would hurt if lost: only a person, in the account that holds
+// the parent, can make one; the deployment's own names can never be a
+// parent; while one is live, no other account can claim a name it serves;
+// detach never takes the first-route branch; and listing it reports only
+// names that are provably this app's.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// catchallFixture is the testbed in miniature: account bab holds
+// teams.example.com through another app, and titogo has instances under
+// go.tito.io.
+func catchallFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx, member := instanceFixture(t, code)
+ noCatchallProof(fx)
+ enableInstances(t, fx, "titogo", titoManifest())
+ if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
+ Host: "teams.example.com", Account: "bab", App: "site",
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return fx, member
+}
+
+// noCatchallProof makes every ownership-proof lookup miss, so no test asks
+// real DNS. proveCatchall publishes one for a parent and account.
+func noCatchallProof(fx *consoleFixture) {
+ fx.srv.lookupTXT = func(context.Context, string) ([]string, error) { return nil, errors.New("no such record") }
+}
+
+func proveCatchall(fx *consoleFixture, parent, account string) {
+ fx.srv.lookupTXT = func(_ context.Context, name string) ([]string, error) {
+ if name == domains.CatchallProofName(parent) {
+ return []string{"something else", domains.CatchallProofValue(account)}, nil
+ }
+ return nil, errors.New("no such record")
+ }
+}
+
+func catchallReq(t *testing.T, fx *consoleFixture, token, body string) (int, string) {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, body)
+ return code, out
+}
+
+func TestInstanceCatchallAttachDetach(t *testing.T) {
+ fx, member := catchallFixture(t, "code-ca-attach")
+ code, out := catchallReq(t, fx, member, `{"host":"*.Teams.example.com"}`)
+ if code != http.StatusOK {
+ t.Fatalf("attach = %d %s", code, out)
+ }
+ var got struct {
+ Host string `json:"host"`
+ Created bool `json:"created"`
+ Instances bool `json:"instances"`
+ }
+ if err := json.Unmarshal([]byte(out), &got); err != nil || got.Host != "*.teams.example.com" || !got.Created || !got.Instances {
+ t.Fatalf("attach answer = %s (%v)", out, err)
+ }
+ c := claimOf(t, fx, "*.teams.example.com")
+ if !c.Instances || c.Account != "bab" || c.App != "titogo" || c.Route != "" || c.CreatedBy != "alice@keymail.dev" {
+ t.Fatalf("claim = %+v", *c)
+ }
+ if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK || !strings.Contains(out, `"created":false`) {
+ t.Fatalf("re-attach = %d %s, want an idempotent 200", code, out)
+ }
+ // Detach by the same name, and never through the first-route branch:
+ // a record at that host (impossible in practice) must survive.
+ code, _, out = rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/*.teams.example.com", member, "")
+ if code != http.StatusOK || !strings.Contains(out, `"removed":true`) {
+ t.Fatalf("detach = %d %s", code, out)
+ }
+ claimAbsent(t, fx, "*.teams.example.com")
+ claimOf(t, fx, "teams.example.com") // the parent is untouched
+}
+
+func TestInstanceCatchallRefusals(t *testing.T) {
+ fx, member := catchallFixture(t, "code-ca-refuse")
+ fx.srv.cfg.DenyDomains = []string{"denied.test"}
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.TunnelHost = "hub.tunnels.example.org"
+ for _, c := range []struct {
+ what, body string
+ code int
+ msg string
+ }{
+ {"a route alongside", `{"host":"*.teams.example.com","route":"go.tito.io"}`, 400, "takes no route"},
+ {"wildcard alongside", `{"host":"*.teams.example.com","wildcard":true}`, 400, "takes no route"},
+ {"unclaimed parent", `{"host":"*.free.example.com"}`, 409, "attach free.example.com to an app in this account first"},
+ {"a deny entry", `{"host":"*.denied.test"}`, 400, "platform-managed"},
+ {"under a deny entry", `{"host":"*.x.denied.test"}`, 400, "platform-managed"},
+ {"the apps domain", `{"host":"*.oncarlos.test"}`, 400, "platform-managed"},
+ {"an account's namespace", `{"host":"*.bab.oncarlos.test"}`, 400, "platform-managed"},
+ {"above the tunnel host", `{"host":"*.tunnels.example.org"}`, 400, "platform-managed"},
+ {"a public suffix", `{"host":"*.co.uk"}`, 400, "platform-managed"},
+ {"the sidecar suffix", `{"host":"*.x.sidecar.internal"}`, 400, "platform-managed"},
+ } {
+ code, out := catchallReq(t, fx, member, c.body)
+ if code != c.code || !strings.Contains(out, c.msg) {
+ t.Errorf("%s: %d %s, want %d containing %q", c.what, code, out, c.code, c.msg)
+ }
+ }
+ claimAbsent(t, fx, "*.teams.example.com")
+
+ // The parent held by another account reads exactly as an unclaimed one.
+ if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
+ Host: "theirs.example.com", Account: "cdef", App: "widgets", CreatedAt: "t",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ _, unclaimed := catchallReq(t, fx, member, `{"host":"*.free.example.com"}`)
+ _, theirs := catchallReq(t, fx, member, `{"host":"*.theirs.example.com"}`)
+ if strings.ReplaceAll(unclaimed, "free.", "") != strings.ReplaceAll(theirs, "theirs.", "") {
+ t.Fatalf("held elsewhere %q reads apart from unclaimed %q", theirs, unclaimed)
+ }
+
+ // An app with no instance manifest has nothing to serve.
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "plain", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "plain"), member, `{"host":"*.teams.example.com"}`)
+ if code != http.StatusConflict || !strings.Contains(out, "has no instances") {
+ t.Fatalf("no manifest: %d %s", code, out)
+ }
+}
+
+// A person's act: the app's own credential cannot set or remove one.
+func TestInstanceCatchallIsAPersonsAct(t *testing.T) {
+ fx, member := catchallFixture(t, "code-ca-person")
+ tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code, out := catchallReq(t, fx, tok, `{"host":"*.teams.example.com"}`); code != http.StatusUnauthorized {
+ t.Fatalf("app token attach = %d %s, want 401", code, out)
+ }
+ claimAbsent(t, fx, "*.teams.example.com")
+ if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK {
+ t.Fatalf("person attach = %d %s", code, out)
+ }
+ code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/*.teams.example.com", tok, "")
+ if code != http.StatusUnauthorized {
+ t.Fatalf("app token detach = %d %s, want 401", code, out)
+ }
+ claimOf(t, fx, "*.teams.example.com")
+ // The app token still attaches ordinary hostnames, including one-label
+ // names under its own account's catch-all.
+ if got := attachDomain(t, fx, tok, "acme.teams.example.com", "acme.go.tito.io"); !got.Created {
+ t.Fatalf("same-account attach under the catch-all = %+v", got)
+ }
+}
+
+// While a catch-all is live, another account cannot claim the name of one
+// of its app's instances, and the refusal is the ordinary neutral 409. Labels
+// with no instance stay open (an unproven parent claim must not block a
+// whole domain), and a claim made before the catch-all keeps re-attaching.
+func TestInstanceCatchallGuardsItsNames(t *testing.T) {
+ fx, member := catchallFixture(t, "code-ca-guard")
+ ctx := context.Background()
+ newInstance := func(label string) {
+ t.Helper()
+ if err := provision.CreateRecord(ctx, fx.rel, provision.Record{Schema: 1, Host: label + ".go.tito.io", Slug: label,
+ App: "titogo", Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ newInstance("acme")
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ bob := cliToken(t, fx, "bob@keymail.dev", "code-ca-guard-bob")
+ attach := func(host string) (int, string) {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "widgets"), bob, attachBody(host, "w.example.net"))
+ return code, out
+ }
+ // Bob's claim from before the catch-all, on a label that later becomes
+ // an instance.
+ if code, out := attach("beta.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("pre-catch-all attach = %d %s", code, out)
+ }
+ if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK {
+ t.Fatalf("catch-all attach = %d %s", code, out)
+ }
+ newInstance("beta")
+
+ if code, out := attach("acme.teams.example.com"); code != http.StatusConflict || strings.TrimSpace(out) != `{"error":"hostname unavailable"}` {
+ t.Fatalf("foreign attach of a live instance's name = %d %s, want the neutral 409", code, out)
+ }
+ claimAbsent(t, fx, "acme.teams.example.com")
+ if code, out := attach("beta.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("re-attach of bob's own older claim = %d %s, want 200", code, out)
+ }
+ // No instance at that label: not the catch-all's to hold.
+ if code, out := attach("free.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("label with no instance = %d %s", code, out)
+ }
+ // Deeper names are not the catch-all's either.
+ if code, out := attach("x.acme.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("deeper name = %d %s", code, out)
+ }
+ // Once the parent is released the catch-all serves nothing and holds
+ // nothing.
+ if _, err := domains.DetachClaim(ctx, fx.rel, "teams.example.com", "bab", "site"); err != nil {
+ t.Fatal(err)
+ }
+ if code, out := attach("acme.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("after the parent went = %d %s", code, out)
+ }
+}
+
+// The list reports the catch-all row and, beneath it, only the names that
+// are provably this app's: one label under the parent, unclaimed, routed to
+// that label under the app's own instance domain.
+func TestInstanceCatchallListsItsDerivedNames(t *testing.T) {
+ fx, member := catchallFixture(t, "code-ca-list")
+ if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK {
+ t.Fatalf("attach = %d %s", code, out)
+ }
+ acct, err := fx.st.AccountBySqid("bab")
+ if err != nil {
+ t.Fatal(err)
+ }
+ claimed := map[string]bool{"teams.example.com": true, "*.teams.example.com": true, "held.teams.example.com": true}
+ readings := map[string]status.AliasReading{
+ "acme.teams.example.com": {Route: "acme.go.tito.io", DNSStatus: "pointing"},
+ "held.teams.example.com": {Route: "held.go.tito.io"}, // a claim of its own
+ "beta.teams.example.com": {Route: "beta.elsewhere.example"}, // not this app's domain
+ "gamma.teams.example.com": {Route: "other.go.tito.io"}, // label does not match
+ "x.acme.teams.example.com": {Route: "x.acme.go.tito.io"}, // two labels deep
+ }
+ got := fx.srv.catchallDerived(context.Background(), acct, "titogo", "teams.example.com", claimed, readings)
+ if len(got) != 1 || got[0].Host != "acme.teams.example.com" || got[0].Route != "acme.go.tito.io" {
+ t.Fatalf("derived = %+v, want only acme", got)
+ }
+
+ code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
+ if code != http.StatusOK {
+ t.Fatalf("list = %d %s", code, out)
+ }
+ var list struct {
+ Domains []struct {
+ Host string `json:"host"`
+ State string `json:"state"`
+ Instances bool `json:"instances"`
+ Guidance string `json:"guidance"`
+ } `json:"domains"`
+ }
+ if err := json.Unmarshal([]byte(out), &list); err != nil {
+ t.Fatal(err)
+ }
+ if len(list.Domains) != 1 || list.Domains[0].Host != "*.teams.example.com" || !list.Domains[0].Instances ||
+ list.Domains[0].State != StateChecking || !strings.Contains(list.Domains[0].Guidance, "*.teams.example.com") {
+ t.Fatalf("list = %+v", list.Domains)
+ }
+ code, _, out = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo")+"/*.teams.example.com", member, "")
+ if code != http.StatusOK || !strings.Contains(out, `"instances":true`) {
+ t.Fatalf("get = %d %s", code, out)
+ }
+}
+
+// The Settings page attaches a catch-all through the ordinary form, shows
+// it as its own row with a detach form, and detaches it by typing the name.
+func TestAppDomainFormInstanceCatchall(t *testing.T) {
+ fx := newConsoleFixture(t)
+ noCatchallProof(fx)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ ctx := context.Background()
+ if err := provision.PutManifest(ctx, fx.rel, fx.sqid, "kass", provision.Manifest{Schema: 1, App: "kass", Domain: "kass.example.net"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{Host: "teams.example.com", Account: fx.sqid, App: "kass", Route: "kass.carlosapp.dev", CreatedAt: "t"}); err != nil {
+ t.Fatal(err)
+ }
+ st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {"*.teams.example.com"}})
+ if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
+ t.Fatalf("attach = %d -> %q:\n%s", st, loc, body)
+ }
+ if cl := claimOf(t, fx, "*.teams.example.com"); !cl.Instances || cl.App != "kass" || cl.CreatedBy == "" {
+ t.Fatalf("claim = %+v", *cl)
+ }
+ _, _, page := get(t, c, fx.u("/apps/kass/settings/domains"))
+ if !strings.Contains(page, "*.teams.example.com") || !strings.Contains(page, "instance catch-all") {
+ t.Fatalf("settings page does not show the catch-all:\n%s", page)
+ }
+ st, _, body = postForm(t, c, fx.u("/apps/kass/domains/detach"), url.Values{
+ "csrf": {csrf}, "host": {"*.teams.example.com"}, "confirm": {"*.teams.example.com"}})
+ if st != http.StatusSeeOther {
+ t.Fatalf("detach = %d:\n%s", st, body)
+ }
+ claimAbsent(t, fx, "*.teams.example.com")
+ claimOf(t, fx, "teams.example.com")
+}
+
+// Two more reservations the guard makes: an instance under an instance
+// domain the app no longer uses still keeps its name, and a parent proved
+// in DNS reserves every label — a team's name is safe before the team
+// exists.
+func TestInstanceCatchallGuardReservations(t *testing.T) {
+ fx, member := catchallFixture(t, "code-ca-reserve")
+ ctx := context.Background()
+ // Created under an older instance domain than the manifest's go.tito.io.
+ if err := provision.CreateRecord(ctx, fx.rel, provision.Record{Schema: 1, Host: "olden.old.example.net", Slug: "olden",
+ App: "titogo", Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil {
+ t.Fatal(err)
+ }
+ if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK {
+ t.Fatalf("catch-all attach = %d %s", code, out)
+ }
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ bob := cliToken(t, fx, "bob@keymail.dev", "code-ca-reserve-bob")
+ attach := func(host string) (int, string) {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "widgets"), bob, attachBody(host, "w.example.net"))
+ return code, out
+ }
+ if code, out := attach("olden.teams.example.com"); code != http.StatusConflict {
+ t.Fatalf("an old-domain instance's name = %d %s, want the neutral 409", code, out)
+ }
+ // Unproved: a label with no instance is open, and the list says so.
+ if code, out := attach("early.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("unproved, no instance = %d %s, want 200", code, out)
+ }
+ _, _, list := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
+ if !strings.Contains(list, `"reserves":"instances"`) || !strings.Contains(list, domains.CatchallProofName("teams.example.com")) {
+ t.Fatalf("list does not say what is reserved, or name the proof record: %s", list)
+ }
+ // Proved: every label is the catch-all account's...
+ proveCatchall(fx, "teams.example.com", "bab")
+ if code, out := attach("later.teams.example.com"); code != http.StatusConflict || strings.TrimSpace(out) != `{"error":"hostname unavailable"}` {
+ t.Fatalf("proved, no instance = %d %s, want the neutral 409", code, out)
+ }
+ claimAbsent(t, fx, "later.teams.example.com")
+ // ...except a claim the caller already holds.
+ if code, out := attach("early.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("re-attach of bob's own earlier claim = %d %s, want 200", code, out)
+ }
+ _, _, list = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
+ if !strings.Contains(list, `"reserves":"all"`) {
+ t.Fatalf("list after proof: %s", list)
+ }
+ // Another account's proof record does not prove this one.
+ proveCatchall(fx, "teams.example.com", "cdef")
+ if code, out := attach("other.teams.example.com"); code != http.StatusOK {
+ t.Fatalf("proof naming another account = %d %s, want 200", code, out)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 3f87a9364..5eaefcbdd 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -881,6 +881,9 @@ 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 func(ctx context.Context, name string) ([]string, error)
// steeringHost is cfg.SteeringHost, normalized once here — see
// dnsTargets, the one reader.
steeringHost string
@@ -1141,6 +1144,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,
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
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 1e7e857c3..7161d8a2b 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -90,6 +90,11 @@ at its platform domain; attaching adds addresses, it never removes one.</p>{{end
attached, the row above says exactly what to create. The platform obtains the
certificate itself and renews it, so nothing has to visit the domain
first.</p>
+<p class="small">For an app with instances, attaching <code>*.example.com</code>
+makes each instance also answer at <code>&lt;label&gt;.example.com</code>, where
+the label is the first part of its own hostname. <code>example.com</code> must
+already be attached to an app in this account, and a wildcard DNS record
+(<code>*</code>) must point here.</p>
{{end}}
<h2>Password gate</h2>
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 3465c9795..c830df918 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -22,6 +22,15 @@ type deletableStore struct {
root string
}
+// DeleteIfMatch passes the conditional delete through to the wrapped store,
+// as every production store offers it (S3Store, DirStore). Embedding the
+// interface alone would hide it, and a caller that must never fall back to
+// an unconditional delete (domains.DetachClaim on an instance catch-all)
+// would then refuse every time.
+func (d *deletableStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ return release.DeleteIfMatch(ctx, d.Store, key, etag)
+}
+
func (d *deletableStore) Delete(_ context.Context, key string) error {
if key == "" || strings.HasPrefix(key, "/") {
return fmt.Errorf("deletablestore: invalid key %q", key)
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index c1b238590..e958d631d 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -15,7 +15,9 @@ import (
"log"
"slices"
"sort"
+ "strconv"
"strings"
+ "sync"
"time"
"github.com/carlosframework/platform/internal/registry"
@@ -43,6 +45,166 @@ const maxWildcardDomains = 32
// primary, not a Kind=="sidecar" row at all.
const sidecarSuffix = ".sidecar.internal"
+// maxInstanceAliases caps the alias rows instance catch-alls derive on one
+// box. Every alias row costs a serial DNS lookup in the box's health stamp
+// and about 140 bytes in its status snapshot, which readers skip whole
+// above 1 MiB — and a skipped box drops out of the deployment's route and
+// edge-address folds. The cap keeps a large app from carrying a box past
+// that. Deterministic (claims in list order, routes in host order), so a
+// rerun keeps the same names; the overflow is logged, never silent.
+const maxInstanceAliases = 1000
+
+// instanceCatchallSaid holds the instance catch-alls whose refusal has
+// already been logged by this process, keyed by claim host and reason. The
+// pass runs every couple of seconds on every box, and a catch-all left
+// without its parent claim is a standing state, not an event.
+var instanceCatchallSaid sync.Map
+
+func sayInstanceCatchallOnce(host, reason string) {
+ if _, said := instanceCatchallSaid.LoadOrStore(host+"\x00"+reason, true); !said {
+ log.Printf("domains: instance catch-all %s: %s", host, reason)
+ }
+}
+
+// deriveInstanceAliases adds the alias rows the instance catch-alls derive
+// (Claim.Instances): for each catch-all "*.<parent>", one row
+// <label>.<parent> -> <instance host> per LOCAL instance route of the
+// claim's account and app, where <label> is the route's Slug — the first
+// label of an instance host, which is what the instance create door makes
+// it. The rows are the same rows an exact alias claim for each name would
+// produce, so every reader of route_aliases (the edge, the edges file, the
+// readings sweep, the issuer) treats them exactly as it treats those.
+//
+// What a derived name never beats, in order:
+//
+// - a claim on that exact name, WHOEVER holds it — judged from the
+// bucket LISTING, so a claim whose body failed to read this pass still
+// wins. An exact claim is a
+// decision somebody made about this one name; a derived name is a
+// default. This is also what lets a same-account exact claim (www,
+// auth) or another app's exact claim sit beside the catch-all without
+// a special case.
+// - a real route on this box, or one any live box in the deployment
+// publishes (deploymentRoutes) — "routes always win", as for every
+// alias.
+//
+// The catch-all itself is honoured only while its parent's own claim is
+// held by the same account (AttachInstanceCatchall's door rule, re-checked
+// here because the parent claim can be released afterwards). Any other
+// field on the claim (Route, Wildcard, Catchall, Paths) means it was not
+// written by the door, and it derives nothing.
+//
+// Instance routes are taken from this box's registry, so each box derives
+// the names of the instances it serves — the same per-box scope the plain
+// alias half has — and a name follows its instance when the instance moves.
+// Only provisioned, non-sidecar, non-replica rows whose Slug is a single
+// label prefixing their host count. That excludes hand-added routes (whose
+// account column may be a legacy name that reads as ops), replica rows (a
+// replica serves its home's account from another box under its own host;
+// the name belongs to the home) and first-route static rows (no Slug).
+//
+// A label two of the app's instances share derives NO name. That can only
+// happen after an operator changed the app's manifest domain between them
+// (every host under one domain has its own label), and the two instances
+// may well be homed on different boxes: a rule each box applied to its own
+// routes would map the one name to two databases depending on which edge a
+// request landed on. The duplicate is seen deployment-wide, from the app's
+// instance records in the bucket (labelHosts), so every box refuses alike,
+// and the refusal is logged.
+//
+// labelHosts is asked once per catch-all, and only when this box has an
+// instance it could name. If it fails, the catch-all's existing rows are
+// kept as they are for this pass rather than dropped: a listing blip must
+// not take every team's name down.
+func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []registry.Route, byHost map[string]registry.Route, existing []registry.RouteAlias, deploymentRoutes map[string]bool, desired map[string]bool, labelHosts func(account, app string) (map[string]int, error), reg *registry.Registry, now func() time.Time) {
+ claimed := make(map[string]Claim, len(claims))
+ for _, c := range claims {
+ claimed[c.Host] = c
+ }
+ derived := 0
+ have := make(map[string]registry.RouteAlias, len(existing))
+ for _, a := range existing {
+ have[a.Alias] = a
+ }
+ for _, c := range claims {
+ if !c.Instances {
+ continue
+ }
+ parent, ok := ParseInstanceCatchallHost(c.Host)
+ if !ok || InstanceCatchallHost(parent) != c.Host {
+ sayInstanceCatchallOnce(c.Host, "refused: not a normalised *.<parent> hostname")
+ continue
+ }
+ if c.Route != "" || c.Wildcard || c.Catchall || len(c.Paths) > 0 {
+ sayInstanceCatchallOnce(c.Host, "refused: the claim carries a route, wildcard, catchall or paths, which no door writes on a catch-all")
+ continue
+ }
+ if strings.HasSuffix(parent, sidecarSuffix) {
+ sayInstanceCatchallOnce(c.Host, "refused: reserved sidecar suffix")
+ continue
+ }
+ if apex, held := claimed[parent]; !held || apex.Account != c.Account {
+ sayInstanceCatchallOnce(c.Host, "inert: its parent "+parent+" is not claimed by account "+c.Account)
+ continue
+ }
+ var hostsOf map[string]int
+ for _, rt := range routes {
+ if !rt.Provisioned || rt.IsSidecar() || rt.Replica || rt.App != c.App || release.RouteAccount(rt) != c.Account {
+ continue
+ }
+ label := rt.Slug
+ if !hostLabelRe.MatchString(label) || !strings.HasPrefix(rt.Host, label+".") {
+ continue
+ }
+ if hostsOf == nil {
+ counts, err := labelHosts(c.Account, c.App)
+ if err != nil {
+ log.Printf("domains: instance catch-all %s: list the app's instances: %v — keeping its names as they are this pass", c.Host, err)
+ for _, a := range existing {
+ if a.Account == c.Account && a.App == c.App && strings.HasSuffix(a.Alias, "."+parent) &&
+ !strings.Contains(strings.TrimSuffix(a.Alias, "."+parent), ".") {
+ desired[a.Alias] = true
+ }
+ }
+ break
+ }
+ hostsOf = counts
+ }
+ if hostsOf[label] > 1 {
+ sayInstanceCatchallOnce(c.Host, "label "+label+" is shared by "+strconv.Itoa(hostsOf[label])+" instances of "+c.App+" (its instance domain was changed); "+label+"."+parent+" serves none of them until one is deleted")
+ continue
+ }
+ name := label + "." + parent
+ if n, ok := NormalizeAliasHost(name); !ok || n != name {
+ continue // too long once joined to parent
+ }
+ // listed, not claimed: a claim whose body could not be read this
+ // pass is still a claim (ClaimCache.listWithHosts).
+ if listed[name] {
+ continue
+ }
+ if _, taken := byHost[name]; taken || deploymentRoutes[name] || desired[name] {
+ continue
+ }
+ if derived >= maxInstanceAliases {
+ sayInstanceCatchallOnce(c.Host, "this box is at its cap of "+strconv.Itoa(maxInstanceAliases)+" derived names; the rest of the app's instances here serve on their own hosts only")
+ break
+ }
+ derived++
+ desired[name] = true
+ if a, ok := have[name]; ok && a.Host == rt.Host && a.Account == c.Account && a.App == c.App {
+ continue // already the row we want: no write, so no reload
+ }
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: name, Host: rt.Host, Account: c.Account, App: c.App,
+ CreatedAt: now().Unix(),
+ }); err != nil {
+ log.Printf("domains: upsert instance alias %s: %v", name, err)
+ }
+ }
+ }
+}
+
// ReconcileAliases derives route_aliases, wildcard_domains and route_paths
// from the global claim records. All three tables are wholly pass-owned:
// every row traces to a claim, so removal needs no ownership flag. The
@@ -66,7 +228,7 @@ const sidecarSuffix = ".sidecar.internal"
// call against one plus a GET per claim in the deployment, every few
// seconds, on every box. nil is legal and means today's uncached fold.
func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool, cache *ClaimCache) error {
- claims, err := cache.List(ctx, s)
+ claims, listed, err := cache.listWithHosts(ctx, s)
if err != nil {
return err
}
@@ -360,6 +522,8 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
}
}
}
+ deriveInstanceAliases(claims, listed, routes, byHost, existing, deploymentRoutes, desired,
+ func(account, app string) (map[string]int, error) { return InstanceLabelCounts(ctx, s, account, app) }, reg, now)
for _, a := range existing {
if !desired[a.Alias] {
if err := reg.DeleteAlias(a.Alias); err != nil {
diff --git a/internal/domains/claimcache.go b/internal/domains/claimcache.go
index b64c5f882..f3091c077 100644
--- a/internal/domains/claimcache.go
+++ b/internal/domains/claimcache.go
@@ -2,6 +2,8 @@ package domains
import (
"context"
+ "fmt"
+ "strings"
"sync"
"time"
@@ -76,13 +78,45 @@ const modSettle = 2 * time.Second
// Claim is a value type with no reference fields, so cached entries cannot
// be mutated through it.
func (c *ClaimCache) List(ctx context.Context, s release.Store) ([]Claim, error) {
+ claims, _, err := c.listWithHosts(ctx, s)
+ return claims, err
+}
+
+// claimHostFromKey is the hostname a claim key names (ClaimKey's inverse).
+func claimHostFromKey(key string) string {
+ return strings.TrimSuffix(strings.TrimPrefix(key, ClaimsPrefix), ".json")
+}
+
+// listWithHosts is List plus the hostname of every key the LISTING showed,
+// whether or not its body could be read this pass. A rule of the form "a
+// claim on this name, whoever holds it, wins" must be decided from the
+// listing: a body whose GET failed (a transient store error) is missing from
+// the claims, and deciding from those would let a default take the name for
+// that pass — failing open on exactly the blip it should ride out.
+func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Claim, map[string]bool, error) {
lister, ok := s.(release.ModLister)
if c == nil || !ok {
- return ListClaims(ctx, s)
+ keys, err := s.List(ctx, ClaimsPrefix)
+ if err != nil {
+ return nil, nil, fmt.Errorf("domains: list claims: %w", err)
+ }
+ hosts := make(map[string]bool, len(keys))
+ var claims []Claim
+ for _, key := range keys {
+ hosts[claimHostFromKey(key)] = true
+ if cl, ok, _ := readClaim(ctx, s, key); ok {
+ claims = append(claims, cl)
+ }
+ }
+ return claims, hosts, nil
}
listed, err := lister.ListMod(ctx, ClaimsPrefix)
if err != nil {
- return nil, err
+ return nil, nil, err
+ }
+ hosts := make(map[string]bool, len(listed))
+ for _, km := range listed {
+ hosts[claimHostFromKey(km.Key)] = true
}
now := time.Now()
c.mu.Lock()
@@ -120,7 +154,7 @@ func (c *ClaimCache) List(ctx context.Context, s release.Store) ([]Claim, error)
// IS the eviction, so a detached claim leaves the cache on the same pass
// it leaves the listing, with no separate invalidation to forget.
c.entries = next
- return claims, nil
+ return claims, hosts, nil
}
// Reset drops everything. Nothing in this package needs it; it exists so a
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index 935a8b062..25a5f3c56 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -71,6 +71,193 @@ type Claim struct {
// lost delta desyncs the two systems silently, whereas a wholesale
// republish is always a full repair.
Paths map[string]string `json:"paths,omitempty"`
+ // Instances marks an instance catch-all: a claim whose Host is
+ // "*.<parent>" (InstanceCatchallHost) and which serves each of its
+ // app's instances at <label>.<parent>, where <label> is the instance
+ // host's own first label. The box derives one ordinary alias row per
+ // local instance (ReconcileAliases); nothing else about such a claim is
+ // used — it never names a Route, a Wildcard, a Catchall or Paths.
+ //
+ // Additive, and inert on a binary that predates it, by construction
+ // rather than by luck: such a binary decodes this claim as one with no
+ // Route (no alias), no Wildcard (no cert parent, no catch-all) and no
+ // Paths — the shape every pass already skips. No door accepts a "*" in
+ // 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"`
+}
+
+// InstanceCatchallHost is the claim host of parent's instance catch-all:
+// "*.<parent>". Using the star as the key is what makes the catch-all
+// globally unique per parent through the same bucket key gate every other
+// claim uses, and no attach door can produce it (NormalizeAliasHost refuses
+// "*"), so it cannot collide with an ordinary claim.
+func InstanceCatchallHost(parent string) string { return "*." + parent }
+
+// ParseInstanceCatchallHost reports whether raw names an instance catch-all
+// ("*.<parent>", case and one trailing dot forgiven) and returns the
+// normalised parent. The parent is held to NormalizeAliasHost's rules, so it
+// has an interior dot: "*.dev" is never a catch-all.
+func ParseInstanceCatchallHost(raw string) (parent string, ok bool) {
+ rest, ok := strings.CutPrefix(strings.ToLower(raw), "*.")
+ if !ok {
+ return "", false
+ }
+ return NormalizeAliasHost(rest)
+}
+
+// instanceRecordsPrefix is where an app's instance records live:
+// provision.RecordsPrefix, spelled here for the import-cycle reason
+// hostLabelRe gives (internal/provision imports this package).
+func instanceRecordsPrefix(account, app string) string {
+ return release.AppPrefix(account, app) + "instances/"
+}
+
+// InstanceLabelCounts counts the app's instance records by the first label
+// of their host, from the record KEYS alone (one listing, no bodies): the
+// deployment-wide view of which labels the app's instances use, whatever
+// box serves them and whatever instance domain each was created under.
+// Replica records are counted too; they have labels of their own, so they
+// never inflate another instance's count.
+func InstanceLabelCounts(ctx context.Context, s release.Store, account, app string) (map[string]int, error) {
+ prefix := instanceRecordsPrefix(account, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ counts := map[string]int{}
+ for _, key := range keys {
+ host, ok := strings.CutSuffix(strings.TrimPrefix(key, prefix), ".json")
+ if !ok || host == "" || strings.Contains(host, "/") {
+ continue
+ }
+ label, _, _ := strings.Cut(host, ".")
+ counts[label]++
+ }
+ return counts, nil
+}
+
+// CatchallProofName and CatchallProofValue are the TXT record that proves an
+// account controls an instance catch-all's parent in DNS:
+// _carlos-catchall.<parent> = "carlos-catchall-account=<sqid>". The value
+// names the account, so one account's proof is no use to another — the same
+// shape as the mail ownership proof (internal/mail, #216). With it, the
+// catch-all reserves every one-label name under the parent at the attach
+// door, not only the names of instances that already exist.
+func CatchallProofName(parent string) string { return "_carlos-catchall." + parent }
+
+func CatchallProofValue(account string) string { return "carlos-catchall-account=" + account }
+
+// ErrParentNotHeld is returned by AttachInstanceCatchall when parent itself
+// is not claimed by the caller's account. One error for both "nobody holds
+// it" and "another account holds it", so this door says nothing the attach
+// door would not.
+var ErrParentNotHeld = errors.New("parent hostname not held by this account")
+
+// AttachInstanceCatchall writes parent's instance catch-all for account's
+// app, create-only by key like AttachClaim. The same app asking again is an
+// idempotent no-op; anyone else (another account, or another app of this
+// one) gets ErrHostUnavailable.
+//
+// The parent must be claimed by the SAME account, by any of its apps. With
+// wildcard DNS pointing at the edge, a catch-all is a claim on every
+// unclaimed name under parent at once, so it may only be made by whoever
+// already holds parent. The box re-checks this on every pass
+// (ReconcileAliases), so a catch-all whose parent claim is later released
+// goes inert rather than outliving it.
+//
+// The check and the write are two objects and not atomic. The race it
+// leaves is the parent claim being released between them, which the box
+// check answers.
+func AttachInstanceCatchall(ctx context.Context, s release.Store, parent, account, app, createdAt, createdBy string) (created bool, err error) {
+ apex, err := GetClaim(ctx, s, parent)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, ErrParentNotHeld
+ } else if err != nil {
+ return false, err
+ }
+ if apex.Account != account {
+ return false, ErrParentNotHeld
+ }
+ host := InstanceCatchallHost(parent)
+ body, err := json.Marshal(Claim{
+ Schema: 1, Host: host, Account: account, App: app,
+ CreatedAt: createdAt, CreatedBy: createdBy, Instances: true,
+ })
+ if err != nil {
+ return false, err
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ etag = ""
+ } else if err != nil {
+ return false, err
+ } else if cur.Account == account && cur.App == app && cur.Instances {
+ return false, nil
+ } else if cur.Instances && cur.Account == apex.Account {
+ // Live: its account still holds the parent. Ours to refuse.
+ return false, ErrHostUnavailable
+ } else if !cur.Instances {
+ return false, ErrHostUnavailable // not a catch-all: no door writes this key otherwise
+ }
+ // Absent, or a catch-all left behind by an account that no longer
+ // holds the parent. The second is inert everywhere already (the box
+ // and ForeignInstanceCatchall both re-check the parent), so the new
+ // holder of the parent replaces it rather than meeting a squat.
+ if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // lost a race — re-read and judge the winner
+ } else {
+ return false, err
+ }
+ }
+}
+
+// ForeignInstanceCatchall returns the live instance catch-all, held by an
+// account OTHER than account, whose parent is one label above host — or nil.
+// The attach doors use it to refuse such a host when the catch-all's app
+// actually runs an instance at that label (the console checks the record;
+// this package cannot import provision). Without that refusal another
+// account could claim a live instance's public name, and its exact claim
+// would beat the derived alias.
+//
+// Only one label under the parent, because that is all a catch-all serves.
+// Deeper names are ordinary names, open to anyone as they always were.
+//
+// "Live" is the box's rule: the catch-all's account still holds the parent.
+// A catch-all whose parent was released, or moved to another account,
+// serves nothing and is reported as nil.
+//
+// One GET (usually a 404), and a second only when another account's
+// catch-all sits over the name. A read failure is returned rather than
+// answered either way: the door turns it into a retryable error, never a
+// refusal that reads as "someone holds this" or a pass that skips the guard.
+func ForeignInstanceCatchall(ctx context.Context, s release.Store, host, account string) (*Claim, error) {
+ _, parent, ok := strings.Cut(host, ".")
+ if !ok || !strings.Contains(parent, ".") {
+ return nil, nil
+ }
+ c, err := GetClaim(ctx, s, InstanceCatchallHost(parent))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ } else if err != nil {
+ return nil, err
+ }
+ if !c.Instances || c.Account == account {
+ return nil, nil
+ }
+ apex, err := GetClaim(ctx, s, parent)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ } else if err != nil {
+ return nil, err
+ }
+ if apex.Account != c.Account {
+ return nil, nil
+ }
+ return c, nil
}
// ErrHostUnavailable is returned by AttachClaim when host is already claimed
@@ -168,8 +355,15 @@ func AttachClaim(ctx context.Context, s release.Store, c Claim) (created, change
// rather than erroring, the same neutrality AttachClaim's ErrHostUnavailable
// observes — a caller must not learn who the real owner is by probing
// detach. An already-absent claim is likewise quiet: detach is idempotent.
+//
+// An instance catch-all is deleted only at the revision this call read
+// (release.DeleteIfMatch). It is the one claim that can change hands in
+// place — AttachInstanceCatchall replaces a dead one for the parent's new
+// holder — so an old owner's detach that read its own claim must not land
+// after that replacement and delete the new owner's. A changed claim
+// answers removed=false, like any claim that is not the caller's.
func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (bool, error) {
- cur, err := GetClaim(ctx, s, host)
+ cur, etag, err := getClaimETag(ctx, s, host)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
@@ -179,6 +373,16 @@ func DetachClaim(ctx context.Context, s release.Store, host, account, app string
if cur.Account != account || cur.App != app {
return false, nil
}
+ if cur.Instances {
+ err := release.DeleteIfMatch(ctx, s, ClaimKey(host), etag)
+ if errors.Is(err, release.ErrCASConflict) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+ }
if err := s.Delete(ctx, ClaimKey(host)); err != nil {
return false, err
}
@@ -310,7 +514,10 @@ func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, a
} else if err != nil {
return false, err
}
- if cur.Account != account || cur.App != app {
+ // An instance catch-all has no capabilities to set: it answers as a
+ // claim the caller does not hold. No door reaches this with a "*"
+ // host; the check keeps it true for any caller that ever does.
+ if cur.Account != account || cur.App != app || cur.Instances {
return false, ErrHostUnavailable
}
if cur.Wildcard == wildcard && cur.Catchall == catchall {
@@ -417,7 +624,8 @@ func SetClaimPaths(ctx context.Context, s release.Store, host, account, app stri
} else if err != nil {
return false, err
}
- if cur.Account != account || cur.App != app {
+ // Nor prefix maps: the same answer, for the same reason.
+ if cur.Account != account || cur.App != app || cur.Instances {
return false, ErrHostUnavailable
}
if samePaths(cur.Paths, paths) {
diff --git a/internal/domains/instancecatchall_test.go b/internal/domains/instancecatchall_test.go
new file mode 100644
index 000000000..b78988c1f
--- /dev/null
+++ b/internal/domains/instancecatchall_test.go
@@ -0,0 +1,534 @@
+package domains
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// addInstance upserts a provisioned instance route the way the provision
+// pass mints one: Slug is the host's first label.
+func addInstance(t *testing.T, reg *registry.Registry, host, slug, account, app string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{
+ Host: host, Slug: slug, App: app, Account: account, Kind: "instance", Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetProvisioned(host, true); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func aliasMap(t *testing.T, reg *registry.Registry) map[string]registry.RouteAlias {
+ t.Helper()
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]registry.RouteAlias{}
+ for _, a := range aliases {
+ out[a.Alias] = a
+ }
+ return out
+}
+
+// jellyStore is the letsjelly.dev testbed in miniature: account bad holds
+// the apex through its static site, and dev-managed-jelly holds the
+// instance catch-all.
+func jellyStore(t *testing.T) release.Store {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"})
+ putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true})
+ return s
+}
+
+func reconcile(t *testing.T, s release.Store, reg *registry.Registry, deploymentRoutes map[string]bool) {
+ t.Helper()
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes, nil); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestInstanceCatchallDerivesOneAliasPerInstance(t *testing.T) {
+ s := jellyStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ addInstance(t, reg, "smoketest.bad.carlos.exciting.io", "smoketest", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ if len(got) != 2 {
+ t.Fatalf("got %d aliases, want 2: %+v", len(got), got)
+ }
+ a := got["acme.letsjelly.dev"]
+ if a.Host != "acme.bad.carlos.exciting.io" || a.Account != "bad" || a.App != "dev-managed-jelly" {
+ t.Fatalf("acme alias = %+v", a)
+ }
+ if got["smoketest.letsjelly.dev"].Host != "smoketest.bad.carlos.exciting.io" {
+ t.Fatalf("smoketest alias = %+v", got["smoketest.letsjelly.dev"])
+ }
+}
+
+func TestInstanceCatchallFollowsTheInstance(t *testing.T) {
+ s := jellyStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ if _, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok {
+ t.Fatal("no alias for a live instance")
+ }
+ // The instance leaves the box (deleted, or moved elsewhere): its name
+ // leaves with it on the next pass.
+ if err := reg.Delete("acme.bad.carlos.exciting.io"); err != nil {
+ t.Fatal(err)
+ }
+ reconcile(t, s, reg, nil)
+ if got := aliasMap(t, reg); len(got) != 0 {
+ t.Fatalf("alias outlived its instance: %+v", got)
+ }
+}
+
+func TestInstanceCatchallDetachDropsEveryName(t *testing.T) {
+ s := jellyStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ deleteClaim(t, s, "*.letsjelly.dev")
+ reconcile(t, s, reg, nil)
+ if got := aliasMap(t, reg); len(got) != 0 {
+ t.Fatalf("aliases outlived the catch-all: %+v", got)
+ }
+}
+
+// An exact claim on a derived name wins, whoever holds it: a same-account
+// one (the Jelly manager's per-team claims, www, auth) and another
+// account's alike.
+func TestInstanceCatchallExactClaimWins(t *testing.T) {
+ s := jellyStore(t)
+ putClaim(t, s, Claim{Host: "acme.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Route: "acme.bad.carlos.exciting.io"})
+ putClaim(t, s, Claim{Host: "beta.letsjelly.dev", Account: "zzz", App: "other", Route: "x.zzz.carlos.exciting.io"})
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ addInstance(t, reg, "beta.bad.carlos.exciting.io", "beta", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ // acme is still served — by its exact claim's row, which says the same.
+ if got["acme.letsjelly.dev"].Host != "acme.bad.carlos.exciting.io" {
+ t.Fatalf("acme = %+v", got["acme.letsjelly.dev"])
+ }
+ // beta's exact claim belongs to another account whose route is not on
+ // this box: no row here at all, and certainly not the derived one.
+ if a, ok := got["beta.letsjelly.dev"]; ok {
+ t.Fatalf("derived alias shadowed another account's exact claim: %+v", a)
+ }
+ // Removing the exact claim hands the name back to the catch-all.
+ deleteClaim(t, s, "acme.letsjelly.dev")
+ reconcile(t, s, reg, nil)
+ if a := aliasMap(t, reg)["acme.letsjelly.dev"]; a.Host != "acme.bad.carlos.exciting.io" {
+ t.Fatalf("after the exact claim went, acme = %+v", a)
+ }
+}
+
+func TestInstanceCatchallRoutesWin(t *testing.T) {
+ s := jellyStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ addInstance(t, reg, "beta.bad.carlos.exciting.io", "beta", "bad", "dev-managed-jelly")
+ addRoute(t, reg, "acme.letsjelly.dev", "bad", "something")
+ reconcile(t, s, reg, map[string]bool{"beta.letsjelly.dev": true})
+ got := aliasMap(t, reg)
+ if len(got) != 0 {
+ t.Fatalf("a derived name shadowed a real route: %+v", got)
+ }
+}
+
+// The catch-all is honoured only while its parent is held by the same
+// account — released, or held by someone else, it derives nothing.
+func TestInstanceCatchallNeedsTheParentInItsAccount(t *testing.T) {
+ for name, apex := range map[string]*Claim{
+ "released": nil,
+ "other account": {Host: "letsjelly.dev", Account: "zzz", App: "squat"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true})
+ if apex != nil {
+ putClaim(t, s, *apex)
+ }
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ if got := aliasMap(t, reg); len(got) != 0 {
+ t.Fatalf("derived without the parent: %+v", got)
+ }
+ })
+ }
+}
+
+// Only the claim's own account and app, and only provisioned instance rows
+// whose Slug is the host's first label.
+func TestInstanceCatchallOnlyItsOwnInstances(t *testing.T) {
+ s := jellyStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ // A sibling app in the same account.
+ addInstance(t, reg, "sib.bad.carlos.exciting.io", "sib", "bad", "dev-jelly-manager")
+ // Another account's app of the same name.
+ addInstance(t, reg, "evil.zzz.carlos.exciting.io", "evil", "zzz", "dev-managed-jelly")
+ // A hand-added route (not provisioned), with a slug.
+ if err := reg.Upsert(registry.Route{Host: "hand.bad.carlos.exciting.io", Slug: "hand", App: "dev-managed-jelly", Account: "bad", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ // A replica row: Slug is the whole host (provision.Replica) ...
+ addInstance(t, reg, "acme-r.bad.carlos.exciting.io", "acme-r.bad.carlos.exciting.io", "bad", "dev-managed-jelly")
+ // ... or a label, when the create door wrote it; the mark excludes it.
+ addInstance(t, reg, "rep.bad.carlos.exciting.io", "rep", "bad", "dev-managed-jelly")
+ if err := reg.SetReplica("rep.bad.carlos.exciting.io", true); err != nil {
+ t.Fatal(err)
+ }
+ // A sidecar of the app.
+ if err := reg.Upsert(registry.Route{Host: "side.sidecar.internal", Slug: "side", App: "dev-managed-jelly", Account: "bad", Kind: "sidecar", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ // A slug that is not the host's first label.
+ addInstance(t, reg, "odd.bad.carlos.exciting.io", "other", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ if len(got) != 1 || got["acme.letsjelly.dev"].Host != "acme.bad.carlos.exciting.io" {
+ t.Fatalf("got %+v, want only acme.letsjelly.dev", got)
+ }
+}
+
+// A catch-all carrying fields no door writes on one is not honoured.
+func TestInstanceCatchallRefusesHandWrittenShapes(t *testing.T) {
+ for name, c := range map[string]Claim{
+ "route": {Route: "acme.bad.carlos.exciting.io"},
+ "wildcard": {Wildcard: true},
+ "paths": {Paths: map[string]string{"x": "acme.bad.carlos.exciting.io"}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"})
+ c.Host, c.Account, c.App, c.Instances = "*.letsjelly.dev", "bad", "dev-managed-jelly", true
+ putClaim(t, s, c)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ for alias := range aliasMap(t, reg) {
+ if alias == "acme.letsjelly.dev" {
+ t.Fatalf("derived from a hand-written catch-all (%s)", name)
+ }
+ }
+ })
+ }
+}
+
+// The catch-all sits beside the apex's own one-route catch-all: the exact
+// derived names win at the edge (an exact key beats "*."), and neither pass
+// removes the other's row.
+func TestInstanceCatchallBesideARouteCatchall(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site", Wildcard: true, Catchall: true})
+ putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true})
+ reg := aliasRegWith(t)
+ if err := reg.Upsert(registry.Route{Host: "letsjelly.dev", App: "dev-jelly-site", Account: "bad", Kind: "static", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ if got["*.letsjelly.dev"].Host != "letsjelly.dev" || got["acme.letsjelly.dev"].Host != "acme.bad.carlos.exciting.io" {
+ t.Fatalf("got %+v", got)
+ }
+}
+
+func TestParseInstanceCatchallHost(t *testing.T) {
+ for raw, want := range map[string]string{
+ "*.letsjelly.dev": "letsjelly.dev",
+ "*.LetsJelly.dev.": "letsjelly.dev",
+ "*.a.b.example.com": "a.b.example.com",
+ "*.dev": "",
+ "letsjelly.dev": "",
+ "*letsjelly.dev": "",
+ "*.*.letsjelly.dev": "",
+ "x.*.letsjelly.dev": "",
+ "*.": "",
+ } {
+ got, ok := ParseInstanceCatchallHost(raw)
+ if ok != (want != "") || got != want {
+ t.Errorf("ParseInstanceCatchallHost(%q) = %q, %v; want %q", raw, got, ok, want)
+ }
+ }
+}
+
+func TestAttachInstanceCatchall(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j"); !errors.Is(err, ErrParentNotHeld) {
+ t.Fatalf("unclaimed parent: err = %v, want ErrParentNotHeld", err)
+ }
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "zzz", App: "squat"})
+ if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j"); !errors.Is(err, ErrParentNotHeld) {
+ t.Fatalf("parent held elsewhere: err = %v, want ErrParentNotHeld", err)
+ }
+ deleteClaim(t, s, "letsjelly.dev")
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"})
+ created, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j")
+ if err != nil || !created {
+ t.Fatalf("first attach: created=%v err=%v", created, err)
+ }
+ c, err := GetClaim(ctx, s, "*.letsjelly.dev")
+ if err != nil || !c.Instances || c.Account != "bad" || c.App != "dev-managed-jelly" || c.Route != "" || c.Wildcard || c.Catchall {
+ t.Fatalf("stored = %+v, %v", c, err)
+ }
+ created, err = AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j")
+ if err != nil || created {
+ t.Fatalf("re-attach: created=%v err=%v, want idempotent", created, err)
+ }
+ // Another app of the same account does not take it over.
+ if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-jelly-site", "t", "j"); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("sibling app: err = %v, want ErrHostUnavailable", err)
+ }
+ // Capability and path writers refuse a catch-all even to its owner.
+ if _, err := SetClaimCapabilities(ctx, s, "*.letsjelly.dev", "bad", "dev-managed-jelly", true, false); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("capabilities on a catch-all: err = %v", err)
+ }
+ if _, err := SetClaimPaths(ctx, s, "*.letsjelly.dev", "bad", "dev-managed-jelly", map[string]string{"x": "a.b.c"}); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("paths on a catch-all: err = %v", err)
+ }
+ // The parent changes hands: the old catch-all is inert, and the new
+ // holder replaces it instead of meeting a squat.
+ deleteClaim(t, s, "letsjelly.dev")
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "zzz", App: "new"})
+ if created, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "zzz", "teams", "t", "j"); err != nil || !created {
+ t.Fatalf("new parent holder: created=%v err=%v", created, err)
+ }
+ if c, _ := GetClaim(ctx, s, "*.letsjelly.dev"); c.Account != "zzz" || c.App != "teams" || !c.Instances {
+ t.Fatalf("after replacement: %+v", c)
+ }
+ deleteClaim(t, s, "*.letsjelly.dev")
+ deleteClaim(t, s, "letsjelly.dev")
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"})
+ if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j"); err != nil {
+ t.Fatal(err)
+ }
+ // Detach is the ordinary owner-gated delete.
+ if removed, err := DetachClaim(ctx, s, "*.letsjelly.dev", "bad", "dev-jelly-site"); err != nil || removed {
+ t.Fatalf("sibling detach: removed=%v err=%v", removed, err)
+ }
+ if removed, err := DetachClaim(ctx, s, "*.letsjelly.dev", "bad", "dev-managed-jelly"); err != nil || !removed {
+ t.Fatalf("owner detach: removed=%v err=%v", removed, err)
+ }
+}
+
+func TestForeignInstanceCatchall(t *testing.T) {
+ ctx := context.Background()
+ s := jellyStore(t)
+ for host, want := range map[string]map[string]bool{
+ "acme.letsjelly.dev": {"bad": false, "zzz": true},
+ "x.acme.letsjelly.dev": {"bad": false, "zzz": false}, // deeper: not the catch-all's
+ "letsjelly.dev": {"bad": false, "zzz": false}, // the parent itself
+ "acme.example.com": {"bad": false, "zzz": false},
+ } {
+ for acct, w := range want {
+ c, err := ForeignInstanceCatchall(ctx, s, host, acct)
+ if got := c != nil; err != nil || got != w {
+ t.Errorf("ForeignInstanceCatchall(%s, %s) = %+v, %v; want %v", host, acct, c, err, w)
+ }
+ }
+ }
+ // Released parent: the catch-all serves nothing, so it holds nothing.
+ deleteClaim(t, s, "letsjelly.dev")
+ if c, err := ForeignInstanceCatchall(ctx, s, "acme.letsjelly.dev", "zzz"); err != nil || c != nil {
+ t.Errorf("after the parent went: %+v, %v; want nil", c, err)
+ }
+}
+
+// What a binary that predates Instances makes of a catch-all claim: the
+// shape it decodes (no Route, no Wildcard, no Paths) is one every pass
+// already skips. Simulated by clearing the field this binary knows.
+func TestInstanceCatchallIsInertWithoutTheField(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"})
+ putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly"})
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ if got := aliasMap(t, reg); len(got) != 0 {
+ t.Fatalf("got %+v", got)
+ }
+ wds, err := reg.ListWildcardDomains()
+ if err != nil || len(wds) != 0 {
+ t.Fatalf("wildcard domains %+v, %v", wds, err)
+ }
+}
+
+// The per-box cap holds, deterministically: the first names in host order
+// are kept, and nothing past the cap is written.
+func TestInstanceCatchallCap(t *testing.T) {
+ s := jellyStore(t)
+ reg := aliasRegWith(t)
+ for i := 0; i <= maxInstanceAliases; i++ {
+ label := "t" + strconv.Itoa(10000+i)
+ addInstance(t, reg, label+".bad.carlos.exciting.io", label, "bad", "dev-managed-jelly")
+ }
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ if len(got) != maxInstanceAliases {
+ t.Fatalf("got %d aliases, want the cap %d", len(got), maxInstanceAliases)
+ }
+ if _, ok := got["t"+strconv.Itoa(10000+maxInstanceAliases)+".letsjelly.dev"]; ok {
+ t.Fatal("the last name in host order was kept past the cap")
+ }
+}
+
+// unreadableKeyStore fails every read of one key, as a spurious store error would,
+// while the key still shows in listings.
+type unreadableKeyStore struct {
+ *release.DirStore
+ failKey string
+}
+
+func (f unreadableKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == f.failKey {
+ return nil, "", errors.New("synthetic 403")
+ }
+ return f.DirStore.Get(ctx, key)
+}
+
+// A claim whose body cannot be read this pass still beats the derived
+// name: precedence is decided from the listing, never from what decoded.
+func TestInstanceCatchallUnreadableClaimStillWins(t *testing.T) {
+ dir := release.NewDirStore(t.TempDir())
+ putClaim(t, dir, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"})
+ putClaim(t, dir, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true})
+ putClaim(t, dir, Claim{Host: "acme.letsjelly.dev", Account: "zzz", App: "other", Route: "x.zzz.carlos.exciting.io"})
+ s := unreadableKeyStore{dir, ClaimKey("acme.letsjelly.dev")}
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ for _, cache := range []*ClaimCache{nil, {}} {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, cache); err != nil {
+ t.Fatal(err)
+ }
+ if a, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; ok {
+ t.Fatalf("derived over an unreadable claim (cache %v): %+v", cache != nil, a)
+ }
+ }
+}
+
+// replacingStore hands back the claim at key as it stood, then lets another
+// writer replace it before the caller acts — the old owner's detach racing
+// the parent's new holder.
+type replacingStore struct {
+ *release.DirStore
+ key string
+ next []byte
+ done bool
+}
+
+func (r *replacingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ body, etag, err := r.DirStore.Get(ctx, key)
+ if key == r.key && !r.done && err == nil {
+ r.done = true
+ if _, perr := r.DirStore.Put(ctx, key, r.next, etag); perr != nil {
+ return nil, "", perr
+ }
+ }
+ return body, etag, err
+}
+
+func TestDetachCatchallNeverDeletesAReplacement(t *testing.T) {
+ ctx := context.Background()
+ dir := release.NewDirStore(t.TempDir())
+ putClaim(t, dir, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "old", Instances: true})
+ next, _ := json.Marshal(Claim{Schema: 1, Host: "*.letsjelly.dev", Account: "zzz", App: "new", Instances: true})
+ s := &replacingStore{DirStore: dir, key: ClaimKey("*.letsjelly.dev"), next: next}
+ removed, err := DetachClaim(ctx, s, "*.letsjelly.dev", "bad", "old")
+ if err != nil || removed {
+ t.Fatalf("detach over a replacement: removed=%v err=%v, want false and no error", removed, err)
+ }
+ c, err := GetClaim(ctx, dir, "*.letsjelly.dev")
+ if err != nil || c.Account != "zzz" || c.App != "new" {
+ t.Fatalf("the replacement was not kept: %+v, %v", c, err)
+ }
+}
+
+// putRecordKey drops an (empty) instance record at its key: the derivation's
+// duplicate check reads record KEYS only.
+func putRecordKey(t *testing.T, s release.Store, account, app, host string) {
+ t.Helper()
+ if _, err := s.Put(context.Background(), instanceRecordsPrefix(account, app)+host+".json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A label two instances share (after an instance-domain change) derives
+// nothing, on every box alike — here, the box holding only one of them.
+func TestInstanceCatchallRefusesASharedLabel(t *testing.T) {
+ s := jellyStore(t)
+ putRecordKey(t, s, "bad", "dev-managed-jelly", "acme.bad.carlos.exciting.io")
+ putRecordKey(t, s, "bad", "dev-managed-jelly", "acme.teams.example.net")
+ putRecordKey(t, s, "bad", "dev-managed-jelly", "beta.bad.carlos.exciting.io")
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ addInstance(t, reg, "beta.bad.carlos.exciting.io", "beta", "bad", "dev-managed-jelly")
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ if _, ok := got["acme.letsjelly.dev"]; ok {
+ t.Fatalf("a shared label derived a name: %+v", got)
+ }
+ if got["beta.letsjelly.dev"].Host != "beta.bad.carlos.exciting.io" {
+ t.Fatalf("an unshared label lost its name: %+v", got)
+ }
+}
+
+// listFailStore fails listings under one prefix, and nothing else.
+type listFailStore struct {
+ *release.DirStore
+ prefix string
+}
+
+func (f listFailStore) List(ctx context.Context, prefix string) ([]string, error) {
+ if strings.HasPrefix(prefix, f.prefix) {
+ return nil, errors.New("synthetic list failure")
+ }
+ return f.DirStore.List(ctx, prefix)
+}
+
+// When the app's instances cannot be listed, the catch-all's names stay as
+// they were for the pass: a blip must not take every team's name down.
+func TestInstanceCatchallKeepsNamesWhenTheListingFails(t *testing.T) {
+ dir := jellyStore(t).(*release.DirStore)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly")
+ reconcile(t, dir, reg, nil)
+ if _, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok {
+ t.Fatal("precondition: the name derives")
+ }
+ failing := listFailStore{dir, instanceRecordsPrefix("bad", "dev-managed-jelly")}
+ if err := ReconcileAliases(context.Background(), failing, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if a, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok || a.Host != "acme.bad.carlos.exciting.io" {
+ t.Fatalf("the name was dropped on a listing failure: %+v", aliasMap(t, reg))
+ }
+}
+
+func TestInstanceLabelCounts(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ putRecordKey(t, s, "bad", "app", "acme.bad.carlos.exciting.io")
+ putRecordKey(t, s, "bad", "app", "acme.old.example.net")
+ putRecordKey(t, s, "bad", "app", "beta.bad.carlos.exciting.io")
+ putRecordKey(t, s, "bad", "other", "gamma.bad.carlos.exciting.io")
+ got, err := InstanceLabelCounts(context.Background(), s, "bad", "app")
+ if err != nil || got["acme"] != 2 || got["beta"] != 1 || got["gamma"] != 0 {
+ t.Fatalf("counts = %v, %v", got, err)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index d612fc368..d57020f91 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -1140,6 +1140,13 @@ func lookupIn(m map[string]upstream, host string) (upstream, bool) {
// routability check in httpFallbackHandler asks only whether this box serves
// the hostname at all, and giving that question a path would make a
// hostname's routability depend on which URL happened to ask.
+// servesExactly reports whether this box's table has host as an exact key,
+// as opposed to answering it only through a "*.<parent>" fallback.
+func (e *Edge) servesExactly(host string) bool {
+ _, ok := e.current()[host]
+ return ok
+}
+
func (e *Edge) lookupUpstream(host string) (upstream, bool) {
return lookupIn(e.current(), host)
}
@@ -1395,6 +1402,20 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r, hopped := e.acceptHop(r, host)
up, ok := e.lookupUpstreamPath(host, r.URL.Path)
+ // A local match through a one-label "*.<parent>" key (a route
+ // catch-all) does not beat ANOTHER box's exact host. That is the same
+ // precedence one box keeps (lookupIn: exact first, then the star),
+ // extended across the deployment: without it, a parent's route
+ // catch-all on this box would swallow a name another box serves
+ // exactly — an instance catch-all's derived name, or any alias homed
+ // elsewhere — for every request that landed here. An arrived hop and a
+ // door call keep the local answer: neither may start a backhaul of its
+ // own here.
+ if ok && !hopped && !isDoor && !e.servesExactly(host) {
+ if eip, box, remote := e.homes.Load().ExactHome(host); remote && e.backhaulTo(w, r, host, eip, box) {
+ return
+ }
+ }
if !ok {
if hopped {
// A PROVEN hop from a sibling edge, for a host that has moved (or
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
index 6558dbd8b..483e81313 100644
--- a/internal/edge/homemap.go
+++ b/internal/edge/homemap.go
@@ -708,6 +708,21 @@ func (m *homeMap) Home(host string) (eip, box string, ok bool) {
return h.eip, h.box, true
}
+// ExactHome is Home without the one-label wildcard fallback: the dial target
+// another box published for exactly host. The edge asks it when its own
+// answer for host came only through a local "*.<parent>" key, so that an
+// exact name served elsewhere still wins (ServeHTTP). Fail-closed without a
+// self label, exactly as Home is.
+func (m *homeMap) ExactHome(host string) (eip, box string, ok bool) {
+ if m == nil || m.self == "" {
+ return "", "", false
+ }
+ if h, found := m.snapshot().hosts[host]; found && h.dial {
+ return h.eip, h.box, true
+ }
+ return "", "", false
+}
+
// lookupHome is Home's lookup, answering the whole winning entry from one
// snapshot read. Home and Owner both go through it, so they can never pick
// different entries for the same host.
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go
index d5fe3676a..2b9c37d38 100644
--- a/internal/edge/homemap_test.go
+++ b/internal/edge/homemap_test.go
@@ -1756,3 +1756,28 @@ func TestHomeMapReportsATieWithThisBoxItself(t *testing.T) {
t.Fatalf("this box is still excluded from its own routing; got %q", hosts["h.test"].box)
}
}
+
+// A route catch-all here does not beat another box's EXACT host: the
+// precedence one box keeps (exact, then "*.<parent>") holds across boxes.
+// A label nobody serves exactly still falls to the local catch-all.
+func TestLocalWildcardYieldsToARemoteExactHost(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "*.example.com")
+ rec, eip := fakeHomeEdge(t, nil)
+ homedOn(t, m, "betty", eip, "acme.example.com")
+
+ resp := hopRequest(t, e, "acme.example.com", nil)
+ if body := readBody(t, resp); resp.StatusCode != http.StatusOK || body != homeEdgeBody {
+ t.Fatalf("acme.example.com = %d %q, want the home edge's answer — another box serves it exactly", resp.StatusCode, body)
+ }
+ if n := rec.get().n; n != 1 {
+ t.Fatalf("home edge saw %d request(s), want 1", n)
+ }
+
+ resp = hopRequest(t, e, "other.example.com", nil)
+ if body := readBody(t, resp); resp.StatusCode != http.StatusOK || !strings.HasPrefix(body, "xff=") {
+ t.Fatalf("other.example.com = %d %q, want the LOCAL catch-all — nobody serves it exactly", resp.StatusCode, body)
+ }
+ if n := rec.get().n; n != 1 {
+ t.Fatalf("home edge saw %d request(s), want still 1", n)
+ }
+}