Merge branch 'instance-catchall' into main
Merge instance-catchall: *.<parent> serves each instance at <label>.<parent> (#233)
22 files changed,
+2450
−14
AGENTS.md+30 −0apimode.go+5 −0docs/site/cli.md+42 −0docs/superpowers/specs/2026-10-05-instance-catchall-design.md+410 −0domains_cli.go+26 −1domains_cli_test.go+46 −0internal/console/apidomains.go+103 −2internal/console/appdomains.go+33 −0internal/console/appsettings.go+8 −0internal/console/domainattach.go+4 −4internal/console/domaincatchall.go+343 −0internal/console/domaincatchall_test.go+374 −0internal/console/server.go+4 −0internal/console/templates/app_settings.html+5 −0internal/console/view_test.go+9 −0internal/domains/aliases.go+165 −1internal/domains/claimcache.go+37 −3internal/domains/claims.go+211 −3internal/domains/instancecatchall_test.go+534 −0internal/edge/edge.go+21 −0internal/edge/homemap.go+15 −0internal/edge/homemap_test.go+25 −0
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><label>.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) |
| + } |
| +} |