Merge pull request #164 from carlosframework/worktree-route-hostnames
route hostnames: attach/detach an extra public hostname on an existing route (#151)
26 files changed,
+4068
−32
apimode.go+86 −0cmd/console/main.go+20 −0docs/superpowers/plans/2026-08-12-route-extra-hostnames.md+548 −0docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md+288 −0domains_cli.go+150 −0domains_cli_test.go+393 −0internal/console/apidomains.go+335 −0internal/console/apidomains_test.go+480 −0internal/console/authcli.go+9 −2internal/console/server.go+9 −0internal/domains/aliases.go+74 −0internal/domains/aliases_test.go+245 −0internal/domains/claims.go+251 −0internal/domains/claims_test.go+256 −0internal/domains/reconcile.go+43 −10internal/domains/reconcile_test.go+77 −0internal/edge/alias_test.go+277 −0internal/edge/edge.go+35 −1internal/edge/storedoor.go+43 −7internal/edge/storedoor_test.go+20 −0internal/host/agent.go+21 −3internal/registry/registry.go+121 −3internal/registry/registry_test.go+186 −0internal/status/snapshot.go+59 −6internal/status/status_test.go+38 −0main.go+4 −0
diff --git a/apimode.go b/apimode.go| index 3dffd2b6e..53fa244c0 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1685,6 +1685,92 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st |
| return nil |
| } |
| +// apiDomainRecord mirrors the domains door's per-row wire shape |
| +// (domainListItem, internal/console/apidomains.go) — DNSStatus and |
| +// CertExpiresAt are the box-stamped reading columns and stay their zero |
| +// value ("" / 0) for a claim no live box has reported on yet ("pending"), |
| +// the same absent-stays-absent convention status.AliasReading uses. |
| +type apiDomainRecord struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Status string `json:"status"` |
| + DNSStatus string `json:"dns_status,omitempty"` |
| + CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| +} |
| + |
| +// domainAttach asks the console to claim host for app, pointed at route (a |
| +// route hostname, or "" to let the console default it to the app's single |
| +// instance record). created is the caller's own answer — false on a re- |
| +// attach that changed nothing or only repointed the route — and |
| +// resolvedRoute is always the route the claim actually carries, whatever |
| +// this call sent: a caller that omitted -route learns what the server |
| +// defaulted it to. A 409 (another account or app already holds the |
| +// hostname) surfaces as "hostname unavailable" verbatim, apiErrorFromBody's |
| +// json-first decode doing the work — the door never says who holds it. |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) { |
| + in := struct { |
| + Host string `json:"host"` |
| + Route string `json:"route,omitempty"` |
| + }{host, route} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in) |
| + if err != nil { |
| + return false, "", err |
| + } |
| + if status != http.StatusOK { |
| + return false, "", apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return false, "", fmt.Errorf("domains attach: unreadable response: %w", err) |
| + } |
| + return out.Created, out.Route, nil |
| +} |
| + |
| +// domainDetach releases a hostname claim. Always 200 on a live console |
| +// (handleAPIDomainDetach's own contract): removed is false for a hostname |
| +// this app never held, one it no longer holds, or one another account |
| +// holds — the door's own quiet-in-both-directions neutrality (spec §5), not |
| +// distinguished here either. |
| +func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) { |
| + status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host))) |
| + if err != nil { |
| + return false, err |
| + } |
| + if status != http.StatusOK { |
| + return false, apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Removed bool `json:"removed"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return false, fmt.Errorf("domains detach: unreadable response: %w", err) |
| + } |
| + return out.Removed, nil |
| +} |
| + |
| +// domainList reads the app's hostname claims, each joined to the fleet's |
| +// readings sweep for status/dns_status/cert_expires_at. |
| +func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Domains []apiDomainRecord `json:"domains"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("domains list: unreadable response: %w", err) |
| + } |
| + return res.Domains, nil |
| +} |
| + |
| // shipTimeout bounds ship's own context — generous on purpose. The console |
| // gives itself up to 10 minutes to read the request body and 2 minutes to |
| // store it (internal/console/apicli.go), well past the 30s absolute Timeout |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 0de48d4a1..6d22a8ecb 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -247,6 +247,7 @@ func serve() { |
| SecretsPub: secretsPub(), |
| AppsDomain: appsDomain(), |
| RedirectHosts: redirectHosts(), |
| + DenyDomains: denyDomains(), |
| StoreProvisioner: storeProvisioner, |
| StoreDeployment: storeDeployment, |
| StoreOperators: storeOperators(), |
| @@ -449,6 +450,25 @@ func redirectHosts() []string { |
| return out |
| } |
| +// denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and |
| +// everything under them, that `carlos domains attach` refuses (issue #151). |
| +// This is the operator's list of names the console cannot derive on its own: |
| +// the edge's reserved tunnel hostname, retired console origins, any other |
| +// platform-managed domain. The console's own origin host and |
| +// PLATFORM_APPS_DOMAIN are added by the door itself (deniedDomains, |
| +// internal/console/apidomains.go), so an unset variable is a legal — and |
| +// still safe — deployment. Blank entries are dropped: an empty string in the |
| +// list would be a deny entry no hostname could avoid matching. |
| +func denyDomains() []string { |
| + var out []string |
| + for _, d := range strings.Split(os.Getenv("CONSOLE_DENY_DOMAINS"), ",") { |
| + if d = strings.ToLower(strings.TrimSpace(d)); d != "" { |
| + out = append(out, d) |
| + } |
| + } |
| + return out |
| +} |
| + |
| func envOr(k, def string) string { |
| if v := os.Getenv(k); v != "" { |
| return v |
diff --git a/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md| new file mode 100644 |
| index 000000000..8cada5ce5 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md |
| @@ -0,0 +1,548 @@ |
| +# Route Extra Hostnames (#151) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Attach/detach an extra public hostname on an existing route: global claim record in the bucket → derived `route_aliases` registry table → edge serves the alias off the primary route's own upstream entry, with HTTP-01 certs, identical hibernation wake, a console API door (app-token capable), and a `carlos domains` CLI verb. |
| + |
| +**Architecture:** Desired state is one global create-only object per hostname (`domains/<host>.json`) — the bucket key is the cross-account uniqueness gate. A box reconcile pass derives `route_aliases` rows (wholly pass-owned, no ownership flag). The edge maps each alias to the primary's identical `upstream` value at Reload. Console door writes claims + bumps `control/serial`; boxes converge in ~2s. |
| + |
| +**Tech Stack:** Go stdlib, modernc.org/sqlite (registry), existing `release.Store` bucket abstraction. No new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md` — read it first. |
| + |
| +## Global Constraints |
| + |
| +- Worktree: `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/route-hostnames`, branch `worktree-route-hostnames`. NEVER touch the primary checkout (another session owns it). |
| +- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, and may need the sandbox disabled ("read-only file system" is the tell). |
| +- Before any commit: the touched packages' tests pass; before the final commit: `go build ./... && go vet ./... && go test ./... -count=1` all green, `gofmt -l` clean for touched files, and `GOOS=darwin go build ./...` compiles (the CLI runs on Macs). |
| +- Wire shapes are additive: claim schema is 1; refuse `Schema > 1` on read (the `GetManifestETag` precedent); never repurpose a field. |
| +- `Fingerprint()` additions go in a SEPARATE second query/hash section — do NOT edit the existing routes SELECT (agreed with the HA session; their #152 columns land on that line). |
| +- Do NOT hash `dns_status` / `dns_checked_at` / `cert_expires_at` into the fingerprint — stamping runs every 5 minutes and would otherwise force a pointless edge reload each pass (the routes section deliberately omits the same columns). |
| +- The alias's `upstream` entry must be the primary's identical value (shared `proxy` handler), never a rebuilt proxy or copied Addr — #152 composition depends on it. |
| +- Neutral errors only for cross-tenant probes: "hostname unavailable" (409), never who owns it; detach of unknown/other-owned is a quiet 200. |
| +- Commit after every task, message style: `feat: <what> (#151)` / `test: ...`; end commit messages with the Co-Authored-By trailer from CLAUDE.md. |
| + |
| +--- |
| + |
| +### Task 1: Claim records (`internal/domains/claims.go`) |
| + |
| +**Files:** |
| +- Create: `internal/domains/claims.go` |
| +- Test: `internal/domains/claims_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `release.Store` (`internal/release/store.go:42` — `Get/Put/List/Delete`), `release.ErrCASConflict`, `provision.ValidHostShape` (`internal/provision/provision.go:131`). |
| +- Produces (later tasks rely on these exact names): |
| + - `const ClaimsPrefix = "domains/"` |
| + - `func ClaimKey(host string) string` → `"domains/" + host + ".json"` |
| + - `type Claim struct { Schema int; Host, Account, App, Route, CreatedAt, CreatedBy string }` (JSON tags: `schema`, `host`, `account`, `app`, `route,omitempty`, `created_at`, `created_by,omitempty`) |
| + - `var ErrHostUnavailable = errors.New("hostname unavailable")` |
| + - `func AttachClaim(ctx context.Context, s release.Store, c Claim) (created bool, err error)` |
| + - `func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (removed bool, err error)` |
| + - `func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error)` |
| + - `func ListClaims(ctx context.Context, s release.Store) ([]Claim, error)` |
| + - `func NormalizeAliasHost(raw string) (string, bool)` — lowercase, strip one trailing dot, then require `provision.ValidHostShape` AND at least one interior dot (a bare label is never a public hostname) |
| + - `func HostDenied(host string, deny []string) bool` — true when host equals any deny entry or ends in `"."+entry` (empty entries skipped) |
| + |
| +- [ ] **Step 1: Write the failing tests** — `internal/domains/claims_test.go`, using `release.NewDirStore(t.TempDir())` (the `serial_test.go` precedent). Cases: |
| + |
| +```go |
| +func TestAttachClaimCreates(t *testing.T) // fresh attach: created=true, GetClaim round-trips all fields |
| +func TestAttachClaimIdempotent(t *testing.T) // same owner, same route: created=false, nil error, object unchanged |
| +func TestAttachClaimConvergesRoute(t *testing.T) // same owner, new route: created=false, stored Route updated |
| +func TestAttachClaimOtherOwnerRefused(t *testing.T) // different account or app: errors.Is(err, ErrHostUnavailable), object unchanged |
| +func TestAttachClaimNewerSchemaRefused(t *testing.T) // stored {"schema":2}: error mentioning schema, object unchanged |
| +func TestDetachClaimRemoves(t *testing.T) // owner detach: removed=true, GetClaim → os.ErrNotExist |
| +func TestDetachClaimUnknownQuiet(t *testing.T) // no claim: removed=false, nil error |
| +func TestDetachClaimOtherOwnerQuiet(t *testing.T) // other owner: removed=false, nil error, object still there |
| +func TestListClaimsSkipsGarbage(t *testing.T) // a domains/ key with invalid JSON is skipped, valid ones returned |
| +func TestNormalizeAliasHost(t *testing.T) // "Tickets.Example.COM." → "tickets.example.com", ok; "localhost" (no dot) → !ok; "a..b" → !ok; "" → !ok; 254 chars → !ok |
| +func TestHostDenied(t *testing.T) // ("x.oncarlos.com", ["oncarlos.com"]) true; ("oncarlos.com", same) true; ("xoncarlos.com", same) false; empty deny entry never matches |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify failure** — `go test ./internal/domains/ -run 'Claim|AliasHost|HostDenied' -count=1`. Expected: compile error (`claims.go` doesn't exist). |
| + |
| +- [ ] **Step 3: Implement `claims.go`.** AttachClaim semantics (comment them in the file): |
| + |
| +```go |
| +// AttachClaim writes host's global claim, create-only by key: the bucket |
| +// key IS the cross-account uniqueness gate. Same owner re-attaching is |
| +// idempotent (converging Route in place); any other owner gets |
| +// ErrHostUnavailable — deliberately neutral, never naming the holder. |
| +func AttachClaim(ctx context.Context, s release.Store, c Claim) (bool, error) { |
| + c.Schema = 1 |
| + body, err := json.Marshal(c) |
| + if err != nil { |
| + return false, err |
| + } |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, c.Host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil { |
| + return true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // lost the create race — re-read and judge the winner |
| + } else { |
| + return false, err |
| + } |
| + } else if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != c.Account || cur.App != c.App { |
| + return false, ErrHostUnavailable |
| + } |
| + if cur.Route == c.Route { |
| + return false, nil |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil { |
| + return false, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // converge race — one retry, then surface |
| + } else { |
| + return false, err |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`getClaimETag` is the unexported Get+parse (refusing `Schema > 1` with the `GetManifestETag`-style error); `GetClaim` wraps it. `DetachClaim`: get; `os.ErrNotExist` → `(false, nil)`; owner mismatch → `(false, nil)`; else `s.Delete` → `(true, nil)`. `ListClaims`: `s.List(ctx, ClaimsPrefix)`, parse each, skip unparseable/newer-schema with a `log.Printf`. `NormalizeAliasHost`: `strings.ToLower`, `strings.TrimSuffix(h, ".")` once, then `provision.ValidHostShape(h) && strings.Contains(h, ".")`. |
| + |
| +- [ ] **Step 4: Run to verify pass** — same command. Expected: PASS. |
| +- [ ] **Step 5: Commit** — `git add internal/domains/claims.go internal/domains/claims_test.go && git commit -m "feat: global hostname claim records (#151)"`. |
| + |
| +--- |
| + |
| +### Task 2: Registry `route_aliases` table |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` — `migrate()` (after the routes ALTER block, `registry.go:291-360`), `Fingerprint()` (append a section AFTER the `fleet_boxes` section, `registry.go:1277-1295`) |
| +- Test: `internal/registry/registry_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `type RouteAlias struct { Alias, Host, Account, App, DNSStatus string; DNSCheckedAt, CertExpiresAt, CreatedAt int64 }` |
| + - `func (r *Registry) ListAliases() ([]RouteAlias, error)` (ordered by alias) |
| + - `func (r *Registry) UpsertAlias(a RouteAlias) error` — `ON CONFLICT(alias) DO UPDATE SET host,account,app` only (stamps survive re-upsert; CreatedAt kept from first insert) |
| + - `func (r *Registry) DeleteAlias(alias string) error` (idempotent) |
| + - `func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error` |
| + - `func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error` |
| + |
| +- [ ] **Step 1: Write the failing tests** (follow the file's existing open-registry helper): |
| + |
| +```go |
| +func TestRouteAliasCRUD(t *testing.T) // upsert, list round-trip, re-upsert repoints host but keeps stamps+created_at, delete idempotent |
| +func TestRouteAliasStamps(t *testing.T) // SetAliasDNSStatus / SetAliasCertExpiresAt persist and survive UpsertAlias |
| +func TestFingerprintCoversAliases(t *testing.T) // fp1; UpsertAlias → fp2 != fp1; SetAliasDNSStatus → fp3 == fp2 (stamps NEVER move the fingerprint); DeleteAlias → fp4 != fp2 |
| +func TestMigrateIdempotentWithAliases(t *testing.T) // open the same db path twice; second open succeeds |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure** — `go test ./internal/registry/ -run 'Alias|FingerprintCovers' -count=1`. Expected: compile error. |
| +- [ ] **Step 3: Implement.** DDL in `migrate()`: |
| + |
| +```go |
| +`CREATE TABLE IF NOT EXISTS route_aliases ( |
| + alias TEXT PRIMARY KEY, |
| + host TEXT NOT NULL, |
| + account TEXT NOT NULL DEFAULT '', |
| + app TEXT NOT NULL DEFAULT '', |
| + dns_status TEXT NOT NULL DEFAULT '', |
| + dns_checked_at INTEGER NOT NULL DEFAULT 0, |
| + cert_expires_at INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL DEFAULT 0 |
| +)` |
| +``` |
| + |
| +Fingerprint addition — a new section appended after `fleet_boxes`, same shape (comment why stamps are excluded, mirroring the routes section's own omission): |
| + |
| +```go |
| +fmt.Fprint(h, "\x1droute_aliases\x1e") |
| +arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`) |
| +// scan alias, host, account, app; Fprintf "%s\x1f%s\x1f%s\x1f%s\x1e" |
| +``` |
| + |
| +- [ ] **Step 4: Verify pass** — same command, then `go test ./internal/registry/ -count=1` (whole package: the fingerprint golden tests must still pass — if an existing test pins an exact digest for an empty registry, update it once, stating why in the test comment). |
| +- [ ] **Step 5: Commit** — `feat: derived route_aliases registry table (#151)`. |
| + |
| +--- |
| + |
| +### Task 3: Alias reconcile pass (`internal/domains/aliases.go`) |
| + |
| +**Files:** |
| +- Create: `internal/domains/aliases.go` |
| +- Test: `internal/domains/aliases_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1 (`ListClaims`, `Claim`), Task 2 (`ListAliases/UpsertAlias/DeleteAlias`, `RouteAlias`), `release.RouteAccount(rt)` (the resolver `domains/reconcile.go:396` already uses), `registry.Registry.List()`. |
| +- Produces: `func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error` |
| + |
| +- [ ] **Step 1: Failing tests** — `release.NewDirStore` + a temp registry with seeded routes: |
| + |
| +```go |
| +func TestReconcileAliasesMaterializes(t *testing.T) // claim {route: R owned by acct/app matching R's row} → alias row appears with account/app/created_at |
| +func TestReconcileAliasesOwnershipGate(t *testing.T) // claim whose account or app differs from the route row's → NO row, pass returns nil |
| +func TestReconcileAliasesRoutesWin(t *testing.T) // claim whose host equals an existing routes.host → NO row |
| +func TestReconcileAliasesMissingRouteSkips(t *testing.T) // claim naming an absent route → NO row, no error |
| +func TestReconcileAliasesRemovesOrphans(t *testing.T) // pre-seeded alias row with no claim → deleted |
| +func TestReconcileAliasesRouteless(t *testing.T) // claim with Route:"" (the #82 first-route case) → ignored entirely |
| +func TestReconcileAliasesIdempotent(t *testing.T) // second run: same rows, CreatedAt unchanged |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/domains/ -run ReconcileAliases -count=1` |
| +- [ ] **Step 3: Implement:** |
| + |
| +```go |
| +// ReconcileAliases derives route_aliases from the global claim records. |
| +// The table is wholly pass-owned: every row traces to a claim, so removal |
| +// needs no ownership flag. The authoritative tenancy gate is HERE — the |
| +// door's route check is best-effort (it cannot see the registry); a claim |
| +// only materializes when the named route exists locally AND its |
| +// account/app match the claim. Routes always win a host collision: an |
| +// alias must never shadow a real route. |
| +func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error { |
| + claims, err := ListClaims(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + byHost := make(map[string]registry.Route, len(routes)) |
| + for _, rt := range routes { |
| + byHost[rt.Host] = rt |
| + } |
| + existing, err := reg.ListAliases() |
| + if err != nil { |
| + return err |
| + } |
| + desired := map[string]bool{} |
| + for _, c := range claims { |
| + if c.Route == "" { // first-route claims (#82's case) are not aliases |
| + continue |
| + } |
| + rt, ok := byHost[c.Route] |
| + if !ok { |
| + continue // primary not on this box — another box's claim |
| + } |
| + if rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| + log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s", |
| + c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App) |
| + continue |
| + } |
| + if _, taken := byHost[c.Host]; taken { |
| + log.Printf("domains: alias %s skipped: a real route owns that host", c.Host) |
| + continue |
| + } |
| + desired[c.Host] = true |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App, |
| + CreatedAt: now().Unix(), |
| + }); err != nil { |
| + log.Printf("domains: upsert alias %s: %v", c.Host, err) |
| + } |
| + } |
| + for _, a := range existing { |
| + if !desired[a.Alias] { |
| + if err := reg.DeleteAlias(a.Alias); err != nil { |
| + log.Printf("domains: remove alias %s: %v", a.Alias, err) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +(CreatedAt-preservation on re-upsert comes from Task 2's ON CONFLICT clause — assert it in the idempotency test.) |
| + |
| +- [ ] **Step 4: Verify pass**, then `go test ./internal/domains/ -count=1`. |
| +- [ ] **Step 5: Commit** — `feat: alias reconcile pass derives route_aliases from claims (#151)`. |
| + |
| +--- |
| + |
| +### Task 4: Edge serves aliases off the primary's entry |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` — `upstream` struct (`:146`), `Reload()` (insert after the routes loop ends at `:420`, before the fleet-auth block at `:421`), `currentHosts()` (`:491`) |
| +- Test: create `internal/edge/alias_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 2 `ListAliases`. |
| +- Produces: behavior only — no new exports. `upstream` gains `alias bool`. |
| + |
| +- [ ] **Step 1: Failing tests** (package `edge`, follow existing `edge_test.go` harness style — registry in a temp dir, `New(reg, act)`): |
| + |
| +```go |
| +func TestAliasServesPrimaryBackend(t *testing.T) // route ready + alias row; GET with Host=alias returns the primary's backend response |
| +func TestAliasSharesUpstreamEntry(t *testing.T) // e.current()[alias].proxy is the IDENTICAL handler value as [primary].proxy (the #152 composition pin: never a rebuilt proxy) |
| +func TestAliasAllowed(t *testing.T) // e.Allowed(alias) true; e.Allowed("stranger.example") false |
| +func TestAliasNeverShadowsRoute(t *testing.T) // alias row whose Alias equals another route's host: the ROUTE's backend answers |
| +func TestAliasSkipsMissingPrimary(t *testing.T) // alias whose Host has no ready route: unknownHost answer, Allowed false |
| +func TestAliasWakesPrimaryOnce(t *testing.T) // hibernating primary + fake Activator: request with Host=alias reaches act.ServeHTTP with up.route.Host == primary (never the alias) — the #129-class regression pin |
| +func TestCurrentHostsSkipsAliases(t *testing.T) // platform-minted primary + alias: currentHosts() contains primary, NOT the alias (wildcard derivation must never see customer domains) |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/edge/ -run Alias -count=1` |
| +- [ ] **Step 3: Implement.** `upstream` gains a field: |
| + |
| +```go |
| + // alias marks an entry mapped in from route_aliases: it shares the |
| + // primary's proxy/route/hibernating verbatim (so hibernation wakes the |
| + // primary's actEntry and #152's per-primary prober state applies), and |
| + // the flag exists so wildcard parent derivation (currentHosts) never |
| + // treats a customer hostname as a platform host. |
| + alias bool |
| +``` |
| + |
| +In `Reload()`, after the routes loop and before the fleet-auth block: |
| + |
| +```go |
| + // Attached hostnames (#151): each alias resolves to the primary's OWN |
| + // entry — same proxy value, same route (so activator keying, version |
| + // header, and purge tenancy are all the primary's). Routes always win |
| + // a collision; an alias whose primary isn't in m (not ready, not |
| + // local) is skipped — never a 502 route to nowhere. Errors abort the |
| + // reload like every other registry read here: a transient failure must |
| + // leave the old table serving. |
| + aliases, err := e.reg.ListAliases() |
| + if err != nil { |
| + return err |
| + } |
| + for _, a := range aliases { |
| + if _, taken := m[a.Alias]; taken { |
| + log.Printf("edge: alias %s shadowed by a real route — skipped", a.Alias) |
| + continue |
| + } |
| + up, ok := m[a.Host] |
| + if !ok { |
| + continue |
| + } |
| + up.alias = true |
| + m[a.Alias] = up |
| + } |
| +``` |
| + |
| +`currentHosts()`: the filter line becomes `if up.route.Platform && !up.alias {`. |
| +`ServeHTTP` needs NO change (map lookup covers aliases; `up.route` is the primary's row, so the activator wake, version header, and cache `Host` keying all behave per spec §4). `managed` is untouched — the primary's own loop iteration already registered it. |
| + |
| +- [ ] **Step 4: Verify pass**, then `go test ./internal/edge/ -count=1` (the package is 16s — run it whole; hibernation and wildcard suites must stay green). |
| +- [ ] **Step 5: Commit** — `feat: edge serves attached hostnames off the primary route's entry (#151)`. |
| + |
| +--- |
| + |
| +### Task 5: DNS + cert stamping for aliases |
| + |
| +**Files:** |
| +- Modify: `internal/domains/reconcile.go` — extend sections 4 (`:409-431`) and 5 (`:433-441`) |
| +- Test: `internal/domains/reconcile_test.go` (append; follow the existing fake-resolver pattern in this file) |
| + |
| +**Interfaces:** consumes Task 2's `SetAliasDNSStatus`/`SetAliasCertExpiresAt`; produces behavior only. |
| + |
| +- [ ] **Step 1: Failing tests:** |
| + |
| +```go |
| +func TestReconcileStampsAliasDNS(t *testing.T) // alias row + cfg.Resolve returning the edge IP → dns_status "pointing"; returning another IP → "elsewhere"; resolver error → "" (unknown, never a guess) |
| +func TestReconcileStampsAliasCert(t *testing.T) // acme dir with a cert file named exactly the alias → cert_expires_at stamped (reuse the cert fixture helper the existing TLS-health tests use) |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** In section 4, after the routes loop, iterate `reg.ListAliases()` with the same resolve-then-stamp shape (`SetAliasDNSStatus(a.Alias, status, now)`); in section 5 likewise with `hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain)` → `SetAliasCertExpiresAt`. One `ListAliases` call shared by both sections (list once above section 4, guarded the same way — only when `cfg.Resolve != nil || cfg.AcmeDir != ""`). Log-and-continue per item, matching the route stamping. |
| +- [ ] **Step 4: Verify pass**, then whole package. |
| +- [ ] **Step 5: Commit** — `feat: DNS and cert health stamps for route aliases (#151)`. |
| + |
| +--- |
| + |
| +### Task 6: Alias readings in the box snapshot |
| + |
| +**Files:** |
| +- Modify: `internal/status/snapshot.go` — `Probes` (`:41`), `BoxMetrics` (`:103`), `CollectBox` (`:290`) |
| +- Modify: `internal/host/agent.go` — the `CollectBox` call site (grep `status.CollectBox(`; populate the new field from `reg.ListAliases()`, log-and-continue on error) |
| +- Test: `internal/status/snapshot_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `Probes.Aliases []registry.RouteAlias` (additive field) |
| + - `BoxMetrics.Aliases map[string]AliasReading` — JSON `aliases,omitempty` |
| + - `type AliasReading struct { Route string `json:"route"`; DNSStatus string `json:"dns_status,omitempty"`; CertExpiresAt int64 `json:"cert_expires_at,omitempty"` }` |
| + |
| +- [ ] **Step 1: Failing test:** |
| + |
| +```go |
| +func TestCollectBoxAliases(t *testing.T) // Probes.Aliases with one stamped alias → Box.Metrics.Aliases[alias] carries route/dns/cert; empty Probes.Aliases → field omitted from JSON (assert `"aliases"` absent from marshaled bytes) |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/status/ -run Aliases -count=1` |
| +- [ ] **Step 3: Implement.** In `CollectBox`, beside where `RouteVersions` is built (`:396`): map `p.Aliases` → `AliasReading`. Wire the agent call site. `status/boxes/*.json` is bucket-private (the dry-run leak scan guards the PUBLIC feed only), and nothing here feeds `BuildFleet`'s public aggregation — state that in the field's doc comment. |
| +- [ ] **Step 4: Verify pass** — `go test ./internal/status/ ./internal/host/ -count=1` (host package: the dry-run self-check must stay green). |
| +- [ ] **Step 5: Commit** — `feat: alias readings in box status snapshot (#151)`. |
| + |
| +--- |
| + |
| +### Task 7: Store-door read access to `domains/` |
| + |
| +**Files:** |
| +- Modify: `internal/edge/storedoor.go` — `aclRead` (`:372`), `aclDir` (`:396`), and the listing-prefix switch (`:481-501`) |
| +- Test: `internal/edge/storedoor_test.go` (append to the existing acl table tests) |
| + |
| +- [ ] **Step 1: Failing tests** — extend the existing acl test tables: `aclRead(sqid, fleet, "domains/x.example.json")` → true; `aclRead(..., "domains")` (no slash, an object AT the name) → false; listing prefix `"domains/"` allowed; write `aclWrite(..., "domains/x.json")` → false (boxes read claims, only the console writes them). |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/edge/ -run Acl -count=1` (match the existing test names — grep first). |
| +- [ ] **Step 3: Implement** — one case each in `aclRead` (`strings.HasPrefix(key, "domains/")`), `aclDir`, and the listing switch (`prefix == "domains" || prefix == "domains/"` plus `strings.HasPrefix(prefix, "domains/")`), commented as the #151 claim records fleet boxes' alias pass reads. |
| +- [ ] **Step 4: Verify pass.** |
| +- [ ] **Step 5: Commit** — `feat: store door reads domains/ claims (#151)`. |
| + |
| +--- |
| + |
| +### Task 8: Agent wiring — fast leg + 5-minute floor |
| + |
| +**Files:** |
| +- Modify: `internal/host/agent.go` — `fullPass` (`:1118-1130`) and `instancesLoop` (`:1421+`, the `provision.Reconcile` tick) |
| +- Test: none new (the legs are thin-by-convention like their siblings; behavior is Task 3's) |
| + |
| +- [ ] **Step 1: Implement.** In `fullPass`, after the `reconcileSerialized` call: |
| + |
| +```go |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil { |
| + log.Printf("agent: fast converge alias pass: %v", err) |
| + } |
| +``` |
| + |
| +In `instancesLoop`'s tick, after its `provision.Reconcile`/`reconcileSerialized` call, the same call with the same log shape — this is the 5-minute floor; it must NOT live in `domainsLoop`, whose `PLATFORM_APPS_DOMAIN` inert gate would silently disable aliases on boxes that never set it. Add one line to the `fullPass` doc comment naming the new leg. |
| +- [ ] **Step 2: Verify** — `go build ./... && go test ./internal/host/ -count=1`. |
| +- [ ] **Step 3: Commit** — `feat: alias reconcile rides the fast loop and the instances floor (#151)`. |
| + |
| +--- |
| + |
| +### Task 9: Console door — the `/domains` family |
| + |
| +**Files:** |
| +- Create: `internal/console/apidomains.go` |
| +- Modify: `internal/console/authcli.go` — register three routes beside the instances block (`:273-281`) and reword the "only door onto instances/" comment (`:263-266`) to "instances/ and domains/" |
| +- Modify: `cmd/console/main.go` — parse `CONSOLE_DENY_DOMAINS` (comma-separated) into a new `console.Config.DenyDomains []string` field (define the field in `internal/console/server.go` beside `Origin`, `:166` area) |
| +- Test: `internal/console/apidomains_test.go` (follow `apiinstances_test.go` harness patterns exactly, including the app-token fixtures) |
| + |
| +**Interfaces:** |
| +- Consumes: `instanceGate` (`apiinstances.go:103` — reused verbatim: app token bound to acct/app OR member user token, token-hash rate limit, Releases-nil 503), Task 1 (`AttachClaim/DetachClaim/ListClaims/NormalizeAliasHost/HostDenied/ErrHostUnavailable`), `provision.ValidHostShape`, `provision.ListRecords`, `status.LoadBoxes` (`internal/status/fleet.go:150`) + Task 6's `AliasReading`, `s.audit` (`server.go:1838` — `audit(acct, app, addr, action, detail)`), `s.bumpSerial`, `jsonError`, `readJSON`, `writeJSON`. |
| +- Produces (the CLI and titogo consume this wire shape): |
| + - `POST /api/cli/apps/{account}/{app}/domains` body `{"host": "...", "route": "..."}` (route optional) → 200 `{"host","route","created":bool}`; 400 invalid host/ambiguous route; 409 `{"error":"hostname unavailable"}`; 201 is NOT used (attach is idempotent — 200 either way) |
| + - `DELETE /api/cli/apps/{account}/{app}/domains/{host}` → 200 `{"host","removed":bool}` always (quiet idempotency) |
| + - `GET /api/cli/apps/{account}/{app}/domains` → 200 `{"domains":[{"host","route","status","dns_status","cert_expires_at"}]}` — `status` is `"live"` when any box reading carries the alias, else `"pending"` |
| + |
| +- [ ] **Step 1: Failing tests:** |
| + |
| +```go |
| +func TestAPIDomainAttachCreates(t *testing.T) // member token: 200, created true; claim object exists in the test store |
| +func TestAPIDomainAttachAppToken(t *testing.T) // app token bound to the acct/app: 200; token bound to ANOTHER app: byte-identical 404 (the apiInstancePrincipal contract) |
| +func TestAPIDomainAttachIdempotent(t *testing.T) // second identical attach: 200, created false |
| +func TestAPIDomainAttachConflictNeutral(t *testing.T) // claim held by another account: 409, body exactly {"error":"hostname unavailable"} |
| +func TestAPIDomainAttachValidation(t *testing.T) // "Bad_Host!", "nodots", host under a DenyDomains entry, host == console origin host: all 400 |
| +func TestAPIDomainAttachRouteDefault(t *testing.T) // app with exactly one instance record + no route in body: claim's Route is that record's host; two records + no route: 400 naming the flag |
| +func TestAPIDomainDetachQuiet(t *testing.T) // detach existing: 200 removed true (+ claim gone); detach unknown: 200 removed false; detach other-owner's: 200 removed false, claim intact |
| +func TestAPIDomainList(t *testing.T) // two claims for the app + a box snapshot carrying one as an alias reading: that one status "live" with dns_status, the other "pending"; another app's claim absent |
| +func TestAPIDomainSerialBump(t *testing.T) // attach and effective detach move control/serial; no-op detach does NOT |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/console/ -run APIDomain -count=1` |
| +- [ ] **Step 3: Implement `apidomains.go`.** Attach handler skeleton (the others follow the delete/list patterns already quoted in `apiinstances.go:560-640`): |
| + |
| +```go |
| +func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var in struct{ Host, Route string } |
| + if !readJSON(w, r, &in) { |
| + return |
| + } |
| + host, ok := domains.NormalizeAliasHost(in.Host) |
| + if !ok { |
| + jsonError(w, http.StatusBadRequest, "invalid hostname") |
| + return |
| + } |
| + if domains.HostDenied(host, s.deniedDomains()) { |
| + jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route) |
| + if !ok { |
| + return |
| + } |
| + created, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{ |
| + Host: host, Account: acct.Sqid, App: app, Route: route, |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| + }) |
| + if errors.Is(err, domains.ErrHostUnavailable) { |
| + jsonError(w, http.StatusConflict, "hostname unavailable") |
| + return |
| + } else if err != nil { |
| + log.Printf("console: api domains attach %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not attach the hostname — try again") |
| + return |
| + } |
| + s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)") |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created}) |
| +} |
| +``` |
| + |
| +Helpers in the same file: `s.deniedDomains()` = `cfg.DenyDomains` + the origin's hostname (parse `s.cfg.Origin` once); `s.resolveAliasRoute` = explicit route → `provision.ValidHostShape` gate then accept (records are best-effort; operator routes have none — the box gate is authoritative, say so in the comment); empty route → `provision.ListRecords`, exactly one → its host, zero or many → 400 `"specify -route: the app has N instances"`. Detach: `NormalizeAliasHost` (400 on garbage — traversal defence), `DetachClaim`, audit + `bumpSerial` only when `removed`, always 200. List: `ListClaims` filtered to `acct.Sqid`/`app`, merge `status.LoadBoxes` aliases sections. Registrations in `registerCLIRoutes`: |
| + |
| +```go |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainList) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainAttach) |
| + s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/domains/{host}", s.handleAPIDomainDetach) |
| +``` |
| + |
| +- [ ] **Step 4: Verify pass**, then `go test ./internal/console/ -count=1` (13s — run whole). |
| +- [ ] **Step 5: Commit** — `feat: console domains door — attach/detach/list hostnames (#151)`. |
| + |
| +--- |
| + |
| +### Task 10: `carlos domains` CLI verb |
| + |
| +**Files:** |
| +- Modify: `apimode.go` — wire types + three `apiSession` methods (follow `instanceCreate` at `:1273-1296`) |
| +- Create: `domains_cli.go` (follow `instances_cli.go:237-259` shape: `flag.NewFlagSet`, `requireAPISession`, print) |
| +- Modify: `cli.go` — top-level dispatch `case "domains": runDomains(args[1:])` beside `case "instances"` (`:227` area is the run func; find the dispatch switch by grepping `runInstances(`), plus one usage line in the help text |
| +- Test: `domains_cli_test.go` (httptest console fake, the pattern of the existing `instances_cli` tests; env via `carlosTestEnv`, `cmd.Dir` in scratch) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 9's wire shapes. |
| +- Produces: |
| + |
| +```go |
| +type apiDomainRecord struct { |
| + Host, Route, Status, DNSStatus string // json: host, route, status, dns_status |
| + CertExpiresAt int64 // json: cert_expires_at |
| +} |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) |
| +func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) |
| +func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** — fake console asserting method+path+body and returning canned JSON; verbs under test: |
| + - `carlos domains attach -app tito -route slug.go.tito.io tickets.example.com` → POSTs the pair, prints `attached tickets.example.com -> slug.go.tito.io` (or `already attached` when created=false) |
| + - `attach` with no positional host → usage error, exit 2 |
| + - `carlos domains detach -app tito tickets.example.com` → DELETE with the host path-escaped, prints `detached`/`was not attached` |
| + - `carlos domains list -app tito` → table: HOST ROUTE STATUS DNS CERT-EXPIRES (cert as date or `-` when 0) |
| +- [ ] **Step 2: Verify failure.** `go test . -run Domains -count=1` |
| +- [ ] **Step 3: Implement.** `domainDetach` decodes the 200 body's `removed`. 409 from attach surfaces the server's `hostname unavailable` verbatim (`apiErrorFromBody`, `apimode.go:249`). `runDomains` dispatches attach/detach/list, default prints usage. |
| +- [ ] **Step 4: Verify pass** — `go test . -count=1` (root package is 97s; run it whole once here). |
| +- [ ] **Step 5: Commit** — `feat: carlos domains attach/detach/list (#151)`. |
| + |
| +--- |
| + |
| +### Task 11: Full gate, docs, PR |
| + |
| +- [ ] **Step 1:** `gofmt -l` on every touched file (must print nothing); fix any. |
| +- [ ] **Step 2:** `go build ./... && go vet ./... && go test ./... -count=1` — all green; `GOOS=darwin go build ./...` compiles. |
| +- [ ] **Step 3:** README/CLAUDE.md: no changes needed (deploy reality unchanged until rollout); add one line to CLAUDE.md's Deploy reality section ONLY if asked. |
| +- [ ] **Step 4:** Push branch, open PR: title `route hostnames: attach/detach an extra public hostname on an existing route (#151)`, body = spec summary + `Closes #151` + the cross-session coordination note (unit-economics + HA + #82 merge agreements) + rollout order from spec §Rollout, ending with the Generated-with line from CLAUDE.md. |
| +- [ ] **Step 5:** Ping the #82 session with: package path `internal/domains`, exports `AttachClaim/DetachClaim/GetClaim/ListClaims/NormalizeAliasHost/HostDenied/ErrHostUnavailable/ClaimKey/ClaimsPrefix`, door file `internal/console/apidomains.go`, CLI file `domains_cli.go`, branch `worktree-route-hostnames`. |
| + |
| +## Self-review notes (done at write time) |
| + |
| +- Spec §1-§8 → Tasks 1-10 cover record/table/reconcile/edge/door/CLI/stamping/readings/acl/wiring; §Rollout → Task 11. |
| +- Type names cross-checked: `RouteAlias` (T2) consumed by T3/T4/T5/T6; `Claim` (T1) by T3/T9; `AliasReading` (T6) by T9's list merge. |
| +- Deliberately absent: `CONSOLE_DENY_DOMAINS` values themselves (deploy-time, per deployment: flagship `oncarlos.com`, tito `go.tito.io,platform.tito.io`) — recorded in the PR body's rollout section, not in code. |
diff --git a/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md b/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md| new file mode 100644 |
| index 000000000..e6cb277d8 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md |
| @@ -0,0 +1,288 @@ |
| +# Route extra hostnames (attach/detach) — design record |
| + |
| +Date: 2026-08-12. Issue: #151 (titogo custom domains, the CARLOS edge half — |
| +titogo repo `docs/superpowers/specs/2026-08-11-custom-domains-design.md` §4). |
| +Approved by Paul in the brainstorming session that produced this document. |
| + |
| +Companion division of labor (agreed cross-session, recorded in §10): #151 |
| +lands the whole alias mechanism, API door, and CLI; #82 later rides the same |
| +door for console UI, deploy auto-attach, and the first-route case. |
| + |
| +## What this is |
| + |
| +Attach an extra public hostname to an existing route: the edge obtains and |
| +renews a certificate for it (HTTP-01, each customer domain its own Let's |
| +Encrypt registered domain, so rate limits are per-customer), proxies it to |
| +the same backend, and wakes hibernated instances identically to the route's |
| +primary hostname. Detach removes it. Both verbs are idempotent — the titogo |
| +router retries them on its reload passes. |
| + |
| +Surface: a first-class member feature — console API door + `carlos domains` |
| +CLI verb — which the titogo router also calls with its app token. Not a |
| +titogo-only side door. |
| + |
| +## Decisions made in the session |
| + |
| +- **Member feature + CLI**, not a minimal titogo-shaped door. |
| +- **Ownership proof = DNS pointing at the edge.** Attach validates shape, |
| + uniqueness, and the platform-domain denylist; the hostname only certs and |
| + serves when its DNS actually resolves to us (HTTP-01 succeeding is the |
| + gate). No platform TXT challenge in v1 — titogo's richer claim |
| + verification stays titogo's. |
| +- **Alias table + edge map, not a second routes row.** The #82 sketch |
| + (mint a full copied route row) reproduces the #129 socket-race class for |
| + hibernating instances, the #148 bare-alias breakage for unit-backed |
| + routes, and copy-drift when sockets move. Titogo's registry made the same |
| + call for the same reasons. The #82 session has conceded its second-row |
| + materialization to this mechanism (§10). |
| +- **Build on main, not the #137 branch.** The alias record is a sibling of |
| + `instances/<host>.json`, not a field inside it — #137's spec (§9) |
| + explicitly scoped this territory out. #137 is a design precedent here |
| + (record → reconcile → derived state), not a code dependency. |
| + |
| +## Architecture |
| + |
| +### 1. Desired state: one global claim record per hostname |
| + |
| +Bucket key `domains/<host>.json`: |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "host": "tickets.example.com", |
| + "account": "<sqid>", |
| + "app": "tito", |
| + "route": "<primary-route-host>", |
| + "created_at": "...", |
| + "created_by": "<address or app-token label>" |
| +} |
| +``` |
| + |
| +The key is global, so create-only CAS **is** the cross-account uniqueness |
| +check — no racy list-then-verify. `route` names the primary route this |
| +hostname aliases. An empty/omitted `route` is reserved (additively) for |
| +#82's first-route case: "this host is, or will be, its own primary route." |
| +The #151 reconcile pass ignores routeless records entirely. |
| + |
| +Wire-shape rules apply from day one: additive only; anything written before |
| +a change must still parse. |
| + |
| +Attach = CAS create (or converge `route` if the same owner re-attaches); |
| +detach = owner-verified delete. The claim-writing helper is exported so |
| +#82's first-route path writes claims through the same code. |
| + |
| +### 2. Registry: a wholly derived `route_aliases` table |
| + |
| +``` |
| +route_aliases( |
| + alias TEXT PRIMARY KEY, |
| + host TEXT NOT NULL, -- the primary route |
| + account TEXT NOT NULL, |
| + app TEXT NOT NULL, |
| + dns_status TEXT, |
| + dns_checked_at TEXT, |
| + cert_expires_at TEXT, |
| + created_at TEXT |
| +) |
| +``` |
| + |
| +Idempotent CREATE in `Registry.migrate()`. Every row is derived from a claim |
| +record by the reconcile pass — nothing else writes the table, so no |
| +`Provisioned`-style ownership flag is needed: the pass owns all rows. |
| + |
| +`Fingerprint()` grows a **separate second query/hash section** for |
| +`route_aliases` — the existing routes SELECT line is not edited. (Agreed |
| +with the HA session so #152's `addrs`/`health` column additions to that |
| +line cannot collide.) |
| + |
| +### 3. Box reconcile: a small pass in the fast loop |
| + |
| +New pass (beside `internal/domains`): list `domains/` records; for each |
| +record with a non-empty `route`: |
| + |
| +- if the local registry has that route **and its `account`/`app` columns |
| + match the record** — this is the authoritative ownership gate — upsert |
| + the alias row; |
| +- if the alias equals an existing `routes.host`, skip and log: real routes |
| + always win over aliases; |
| +- boxes not carrying the primary route skip the record. |
| + |
| +Alias rows with no backing record are deleted. The pass rides the agent's |
| +serial-triggered `fullPass` (2s convergence) with the hourly adopt-timer |
| +floor beneath it, like every other pass. |
| + |
| +DNS and cert-expiry stamping for aliases rides the existing periodic |
| +`internal/domains` reconcile (same `SetDNSStatus` / acme-cache-file |
| +helpers, applied to `route_aliases` rows). |
| + |
| +Store-door boxes: `internal/edge/storedoor.go` gains an `aclRead` case |
| +granting fleet boxes read-only access to the `domains/` prefix, so the pass |
| +works there too (direct-S3 platform boxes are unaffected). |
| + |
| +### 4. Edge: an alias resolves to the primary's upstream entry |
| + |
| +In `Reload()`, after the host map is built from routes: for each alias |
| +whose primary host has an entry, the alias key maps to **the identical |
| +upstream entry** the primary uses — the same value, never a copied `Addr`, |
| +never a rebuilt proxy — marked as an alias. Consequences, all by |
| +construction: |
| + |
| +- **Hibernation wake is identical**: the activator is keyed on the primary |
| + `Route.Host` at every layer (actEntry, lease, live-symlink, cgroup), so a |
| + request arriving on the alias wakes the same single instance — no |
| + #129-class second wake racing on one socket. |
| +- **TLS needs nothing new**: `Allowed()` walks the live map, so the alias |
| + is cert-eligible; per-hostname HTTP-01 mints on first handshake. |
| +- **Wildcards are untouched**: alias entries are explicitly skipped in |
| + wildcard parent derivation (`currentHosts`) — customer domains wouldn't |
| + match the platform shape anyway, but the exclusion is stated, not |
| + incidental. |
| +- **Static primaries work**: all content/backing derivation uses |
| + `route.Host` (the primary); the request Host is used only for TLS, |
| + cache keying, and usage metering. An alias on a static route serves the |
| + primary's channel-pointed content and carries `X-Carlos-Version`. (Rule |
| + pinned by test — it is what makes #82's `deploy -kind static -host` on |
| + an already-routed app a pure alias attach.) |
| +- **HA composes** (#152): when multi-upstream routes land, the primary's |
| + entry carries the addr set and prober state; aliases inherit failover |
| + with zero alias-side changes because they share the entry. Pinned by a |
| + test asserting alias and primary resolve to the same entry. |
| +- **Usage metering** (unit-economics branch): the edge meters per request |
| + Host, so an attached domain counts as its own row, attributed to the |
| + same route/app. Intended. |
| + |
| +### 5. Console door: the `/domains` family |
| + |
| +``` |
| +GET /api/cli/apps/{account}/{app}/domains → list |
| +POST /api/cli/apps/{account}/{app}/domains → attach {host, route?} |
| +DELETE /api/cli/apps/{account}/{app}/domains/{host} → detach |
| +``` |
| + |
| +Auth follows `apiInstancePrincipal` exactly: an app token bound to that |
| +account+app, or a member's user token. The app-token allowlist grows from |
| +"instances" to "instances + domains" (`authcli.go`'s registered family). |
| +Rate limiting keys by token hash, the instances precedent. |
| + |
| +Validation, in one exported spot (so #82 adds its rulings there): |
| + |
| +- well-formed lowercase ASCII FQDN — no port, no wildcard label, trailing |
| + dot stripped; IDN/punycode out of scope (titogo's v1 rule, adopted); |
| +- denylist: the apps domain and any host under it, the console's own |
| + origin host, and the edge's reserved tunnel hostname — all derived from |
| + console/deployment config, not hardcoded; |
| +- `route` must name a route of this app — best-effort at the door |
| + (instance records; if the app has exactly one instance, `route` may be |
| + omitted and defaults to it), authoritative at reconcile (§3). Operator |
| + routes with no instance record (tito's) pass the door and are gated on |
| + box. |
| + |
| +Idempotency (the titogo contract): |
| + |
| +- attach, already attached by the same owner → 200, converging `route` if |
| + it changed; |
| +- attach, owned by someone else → 409 with a neutral "hostname |
| + unavailable" — never who owns it; |
| +- detach, unknown hostname → quiet 200; |
| +- detach, owned by someone else → quiet 200, no action taken. |
| + |
| +Every successful write audits and bumps `control/serial` (strictly after |
| +the write), so boxes converge in ~2s. |
| + |
| +### 6. CLI: `carlos domains` |
| + |
| +``` |
| +carlos domains attach -app tito [-route <primary-host>] tickets.example.com |
| +carlos domains detach -app tito tickets.example.com |
| +carlos domains list -app tito |
| +``` |
| + |
| +`list` shows each hostname's primary route, DNS status, and cert expiry — |
| +the command alone verifies the result, per the no-infra rule. Status |
| +source: the box readings sweep (the #144 machinery) grows an additive |
| +aliases section carrying each alias row's stamped `dns_status` / |
| +`cert_expires_at`; the console door merges it into the claim listing. A |
| +claim no rolled box has materialized yet lists with status `pending`. |
| +Standard `apiSession` plumbing (`apimode.go` wire methods + a |
| +`domains_cli.go` verb file). |
| + |
| +### 7. Titogo wiring |
| + |
| +Nothing new to build platform-side for delivery. The router reads |
| +`TITO_EDGE_ATTACH_URL` (set to this door's collection URL, |
| +`https://<console>/api/cli/apps/<sqid>/tito/domains`) and reuses |
| +`CARLOS_INSTANCE_TOKEN` as its bearer. Both deliver through the existing |
| +`carlos env set -app tito [-secret]` → `SyncExec` → `<host>.config.env` |
| +chain the router already consumes (`ExecDelivery: "file"`). |
| + |
| +Note for the record: `CARLOS_INSTANCE_URL` is a titogo-side env name; the |
| +platform's contribution is the door URL shape, documented when closing |
| +the issue. |
| + |
| +## Error handling |
| + |
| +- Primary route missing or not ready → alias skipped at reload with a log |
| + line; never a 502 route to nowhere. |
| +- Record whose route never appears → sits inert. This is also the rollout |
| + property: old box binaries ignore `domains/` entirely, so the console |
| + door can open before boxes roll; records materialize when boxes catch up. |
| +- DNS not yet pointed → HTTP-01 fails, autocert retries on later |
| + handshakes; `carlos domains list` shows `dns_status` so the caller sees |
| + why. |
| +- CAS race on simultaneous attach → one winner; the loser gets the neutral |
| + 409. |
| + |
| +## Testing |
| + |
| +- **Registry**: alias CRUD, migrate idempotence, fingerprint's second |
| + section changes when aliases change and is byte-stable when they don't. |
| +- **Reconcile**: materialize on match; account/app mismatch refused; |
| + routes-win collision skip; record-gone removal; routeless (`route: ""`) |
| + records ignored; multi-record convergence. |
| +- **Edge**: alias serves the primary's backend; alias and primary share |
| + the same upstream entry (the #152 composition pin); `Allowed()` admits |
| + the alias; hibernating primary wakes exactly once via the alias (the |
| + #129-class regression test); wildcard parent derivation skips aliases; |
| + static primary's alias serves content + `X-Carlos-Version`. |
| +- **Console**: auth matrix (app token right/wrong app, member, non-member |
| + byte-identical 404); the four idempotency cases; denylist; CAS race; |
| + serial bump after write. |
| +- **CLI**: attach/detach/list round-trip against a fake console; env built |
| + through `carlosTestEnv` with `cmd.Dir` in scratch (the minted-accounts |
| + rule). |
| + |
| +## Rollout |
| + |
| +One PR off main (worktree `route-hostnames`; the primary checkout's |
| +working tree belongs to the unit-economics session and is untouched). |
| +Deploy in the usual roll: box binaries + consoles; ordering is safe either |
| +way (additive records). After deploy: confirm/mint the tito app token, |
| +`carlos env set` the URL + token, close #151, and notify the titogo agent |
| +so its PR 5 (the attach/detach client) can proceed. |
| + |
| +## Cross-session coordination (recorded 2026-08-12, evening) |
| + |
| +- **unit-economics** (same checkout, branch `unit-economics`): touching |
| + `internal/edge/edge.go` (metering wrap in ServeHTTP) and console route |
| + registrations. Different hunks; whoever lands second rebases. Alias |
| + hosts metering as their own rows is intended (§4). |
| +- **HA / #152 #153** (PR #154, base includes #137): three agreements — |
| + alias shares the primary's upstream entry identity; `route_aliases` |
| + fingerprints as a separate section; store-door `aclRead` for `domains/`. |
| +- **#82 session** (spec being written against this shape): concedes |
| + second-row materialization; #82 rides this door for console UI + |
| + `deploy` auto-attach; owns the first-route case (routeless claim + |
| + #137-vocabulary instance record) on the worktree-route-records base. |
| + Claim record is the single uniqueness gate for both cases, written |
| + through the same helper. #82's spec names #151 as its dependency. |
| +- **#137** caught up to origin/main by the #82 session; merging is Paul's |
| + action. #151 does not consume it. |
| + |
| +## Deliberately not in v1 (recorded) |
| + |
| +- IDN/punycode hostnames. |
| +- A platform-side TXT ownership challenge. |
| +- Migrating `domains.Reconcile`'s minted platform aliases (#148's subject) |
| + onto `route_aliases` — a natural follow-up, not this change. |
| +- Console web UI and `carlos deploy` auto-attach — #82's territory. |
| +- Wildcard customer domains (`*.example.com` attach). |
diff --git a/domains_cli.go b/domains_cli.go| new file mode 100644 |
| index 000000000..4e5330923 |
| --- /dev/null |
| +++ b/domains_cli.go |
| @@ -0,0 +1,150 @@ |
| +// The `carlos domains` verb family (Task 10, issue #151): attach a |
| +// customer's own hostname to an app's route, detach it, or list what an app |
| +// currently holds — the CLI front over internal/console/apidomains.go, the |
| +// same instance-gated console door titogo's router speaks with its |
| +// CARLOS_INSTANCE_TOKEN. API mode only, the same as `carlos instances |
| +// create/list/delete`: a claim is a console-authorized write, and there is |
| +// no store-mode equivalent. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const domainsUsage = `usage: carlos domains <attach|detach|list> [flags] |
| + |
| + attach -app <a> [-route <r>] [flags] <hostname> |
| + claim a customer's hostname for the app, pointed at |
| + -route (default: the app's single instance record, if it |
| + has exactly one — otherwise -route is required) |
| + detach -app <a> [flags] <hostname> |
| + release a hostname claim |
| + list -app <a> [flags] |
| + this app's hostname claims, joined to the fleet's own |
| + readings sweep (DNS/CERT-EXPIRES) |
| + |
| + -account <a> account (sqid or name; default "ops") |
| + -account-id <id> account sqid, matched as a sqid ONLY |
| + -console <origin> console to act through (default: the only logged-in one) |
| + |
| +Signed-in only (carlos auth login): a claim is a console-authorized write — |
| +the console checks the hostname against the deploy's deny list, resolves the |
| +route, and writes it. The hostname comes live on the box that owns the route |
| +at its next domains pass. |
| +` |
| + |
| +const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-account <a>] [-account-id <id>] [-console <origin>] <hostname> |
| +` |
| + |
| +const domainsDetachUsage = `usage: carlos domains detach -app <a> [-account <a>] [-account-id <id>] [-console <origin>] <hostname> |
| +` |
| + |
| +const domainsListUsage = `usage: carlos domains list -app <a> [-account <a>] [-account-id <id>] [-console <origin>] |
| +` |
| + |
| +// runDomains dispatches the domains sub-verbs. attach/detach/list are |
| +// positional words, not flags, and must never reach fs.Parse — the same |
| +// shape runInstances uses for its own sub-verbs. |
| +func runDomains(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "attach": |
| + runDomainsAttach(args[1:]) |
| + return |
| + case "detach": |
| + runDomainsDetach(args[1:]) |
| + return |
| + case "list": |
| + runDomainsList(args[1:]) |
| + return |
| + } |
| + } |
| + log.Fatal(domainsUsage) |
| +} |
| + |
| +func runDomainsAttach(args []string) { |
| + fs := flag.NewFlagSet("domains attach", flag.ExitOnError) |
| + app := fs.String("app", "", "app the hostname belongs to") |
| + route := fs.String("route", "", "route hostname to attach to") |
| + consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + host := strings.TrimSpace(fs.Arg(0)) |
| + if *app == "" || host == "" || fs.NArg() > 1 { |
| + log.Fatal(domainsAttachUsage) |
| + } |
| + sess := requireAPISession("attach", *consoleFlag) |
| + printUsing(stdout, sess) |
| + created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw), |
| + *app, host, strings.TrimSpace(*route)) |
| + if err != nil { |
| + log.Fatalf("domains attach: %v", err) |
| + } |
| + if created { |
| + fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("attached"), host, resolvedRoute) |
| + } else { |
| + fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute) |
| + } |
| +} |
| + |
| +func runDomainsDetach(args []string) { |
| + fs := flag.NewFlagSet("domains detach", flag.ExitOnError) |
| + app := fs.String("app", "", "app the hostname belongs to") |
| + consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + host := strings.TrimSpace(fs.Arg(0)) |
| + if *app == "" || host == "" || fs.NArg() > 1 { |
| + log.Fatal(domainsDetachUsage) |
| + } |
| + sess := requireAPISession("detach", *consoleFlag) |
| + printUsing(stdout, sess) |
| + removed, err := sess.domainDetach(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, host) |
| + if err != nil { |
| + log.Fatalf("domains detach: %v", err) |
| + } |
| + if removed { |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("detached"), host) |
| + } else { |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("was not attached"), host) |
| + } |
| +} |
| + |
| +func runDomainsList(args []string) { |
| + fs := flag.NewFlagSet("domains list", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose hostname claims to list") |
| + consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal(domainsListUsage) |
| + } |
| + sess := requireAPISession("list", *consoleFlag) |
| + printUsing(stdout, sess) |
| + recs, err := sess.domainList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app) |
| + if err != nil { |
| + log.Fatalf("domains list: %v", err) |
| + } |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES") |
| + for _, r := range recs { |
| + dns := r.DNSStatus |
| + if dns == "" { |
| + dns = "-" |
| + } |
| + cert := "-" |
| + if r.CertExpiresAt > 0 { |
| + cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02") |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert) |
| + } |
| + w.Flush() |
| +} |
diff --git a/domains_cli_test.go b/domains_cli_test.go| new file mode 100644 |
| index 000000000..485b06046 |
| --- /dev/null |
| +++ b/domains_cli_test.go |
| @@ -0,0 +1,393 @@ |
| +// Tests for the `carlos domains` verb family — the CLI door onto |
| +// internal/console/apidomains.go (Task 10, issue #151). Same two-layer shape |
| +// fleets_cli_test.go established: apiSession-level tests drive the three new |
| +// client methods (apimode.go) directly against an httptest fake console |
| +// (path, body, wire-shape round trips), and RunDomains-level tests drive the |
| +// full verb (flag parsing, printing) against the same kind of fake console, |
| +// credentials written straight to disk. Usage-error paths (log.Fatal, would |
| +// kill an in-process test binary) run as a real subprocess, carlosTestEnv + |
| +// scratch cmd.Dir per CLAUDE.md's rule. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// ---- apiSession-level: path + body + wire-shape round trips --------------- |
| + |
| +// TestAPISessionDomainAttach: domainAttach POSTs {host,route} to the app's |
| +// domains door and decodes created + the route the server resolved. |
| +func TestAPISessionDomainAttach(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody struct{ Host, Route string } |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": gotBody.Host, "route": gotBody.Route, "created": true, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| + "tickets.example.com", "slug.go.tito.io") |
| + if err != nil { |
| + t.Fatalf("domainAttach: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodPost { |
| + t.Fatalf("request = %s %s, want POST .../tito/domains", gotMethod, gotPath) |
| + } |
| + if gotBody.Host != "tickets.example.com" || gotBody.Route != "slug.go.tito.io" { |
| + t.Fatalf("body = %+v", gotBody) |
| + } |
| + if !created || route != "slug.go.tito.io" { |
| + t.Fatalf("domainAttach = created=%v route=%q", created, route) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainAttachNoOp: created=false round-trips (the re-attach, |
| +// nothing-changed case), and the server's resolved route still comes back |
| +// even when the caller sent none. |
| +func TestAPISessionDomainAttachNoOp(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": "tickets.example.com", "route": "default.go.tito.io", "created": false, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| + "tickets.example.com", "") |
| + if err != nil { |
| + t.Fatalf("domainAttach: %v", err) |
| + } |
| + if created || route != "default.go.tito.io" { |
| + t.Fatalf("domainAttach = created=%v route=%q, want created=false route=default.go.tito.io", created, route) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainAttachConflict: a 409 surfaces the server's |
| +// "hostname unavailable" message verbatim, apiErrorFromBody's json-first |
| +// decode doing the work — no special-casing needed on this side. |
| +func TestAPISessionDomainAttachConflict(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + json.NewEncoder(w).Encode(map[string]any{"error": "hostname unavailable"}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "") |
| + if err == nil || err.Error() != "hostname unavailable" { |
| + t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainDetach: domainDetach DELETEs with the host |
| +// path-escaped and decodes removed. |
| +func TestAPISessionDomainDetach(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.EscapedPath(), r.Method |
| + json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "removed": true}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com") |
| + if err != nil { |
| + t.Fatalf("domainDetach: %v", err) |
| + } |
| + if gotMethod != http.MethodDelete { |
| + t.Fatalf("method = %s, want DELETE", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bab/tito/domains/tickets.example.com" { |
| + t.Fatalf("path = %s, want the app's domains door with the host appended", gotPath) |
| + } |
| + if !removed { |
| + t.Fatal("removed = false, want true") |
| + } |
| +} |
| + |
| +// TestAPISessionDomainDetachWasNotAttached: removed=false round-trips |
| +// unchanged — the door's own three-ways-the-same neutrality (spec §5). |
| +func TestAPISessionDomainDetachWasNotAttached(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"host": "ghost.example.com", "removed": false}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "ghost.example.com") |
| + if err != nil { |
| + t.Fatalf("domainDetach: %v", err) |
| + } |
| + if removed { |
| + t.Fatal("removed = true, want false") |
| + } |
| +} |
| + |
| +// TestAPISessionDomainList: domainList GETs the domains door and decodes the |
| +// "domains" wrapper, tolerating rows that omit dns_status/cert_expires_at |
| +// (a claim no live box has materialized yet — apidomains.go's "pending"). |
| +func TestAPISessionDomainList(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "domains": []map[string]any{ |
| + {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live", |
| + "dns_status": "pointing", "cert_expires_at": 1799999999}, |
| + {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + recs, err := s.domainList(context.Background(), apiAcct{Seg: "bab"}, "tito") |
| + if err != nil { |
| + t.Fatalf("domainList: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodGet { |
| + t.Fatalf("request = %s %s, want GET .../tito/domains", gotMethod, gotPath) |
| + } |
| + if len(recs) != 2 { |
| + t.Fatalf("domainList returned %d records, want 2", len(recs)) |
| + } |
| + if recs[0].Host != "tickets.example.com" || recs[0].Route != "slug.go.tito.io" || |
| + recs[0].Status != "live" || recs[0].DNSStatus != "pointing" || recs[0].CertExpiresAt != 1799999999 { |
| + t.Fatalf("recs[0] = %+v", recs[0]) |
| + } |
| + if recs[1].Host != "new.example.com" || recs[1].Status != "pending" || |
| + recs[1].DNSStatus != "" || recs[1].CertExpiresAt != 0 { |
| + t.Fatalf("recs[1] = %+v, want empty dns_status and zero cert_expires_at", recs[1]) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainDoorsSurfaceServerErrors: a non-2xx from any of the |
| +// three domain doors surfaces an error rather than a swallowed one. |
| +func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + w.Write([]byte("404 page not found\n")) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", ""); err == nil { |
| + t.Fatal("domainAttach against a 404 must error") |
| + } |
| + if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil { |
| + t.Fatal("domainDetach against a 404 must error") |
| + } |
| + if _, err := s.domainList(context.Background(), acct, "ghost"); err == nil { |
| + t.Fatal("domainList against a 404 must error") |
| + } |
| +} |
| + |
| +// ---- RunDomains-level: full verb, flag parsing + printing ----------------- |
| + |
| +// fakeDomainsConsole is a minimal in-process console answering every |
| +// domains door with fixed, easily-asserted bodies. |
| +type fakeDomainsConsole struct { |
| + ts *httptest.Server |
| + lastAttachPath string |
| + lastAttachBody struct{ Host, Route string } |
| + attachCreated bool |
| + lastDetachPath string |
| + detachRemoved bool |
| +} |
| + |
| +func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| + t.Helper() |
| + fc := &fakeDomainsConsole{attachCreated: true, detachRemoved: true} |
| + fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"): |
| + fc.lastAttachPath = r.URL.Path |
| + json.NewDecoder(r.Body).Decode(&fc.lastAttachBody) |
| + route := fc.lastAttachBody.Route |
| + if route == "" { |
| + route = "default.go.tito.io" |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": fc.lastAttachBody.Host, "route": route, "created": fc.attachCreated, |
| + }) |
| + case r.Method == http.MethodDelete: |
| + fc.lastDetachPath = r.URL.Path |
| + json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved}) |
| + case r.Method == http.MethodGet: |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "domains": []map[string]any{ |
| + {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live", |
| + "dns_status": "pointing", "cert_expires_at": 1799999999}, |
| + {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"}, |
| + }, |
| + }) |
| + default: |
| + w.WriteHeader(http.StatusNotFound) |
| + } |
| + })) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +// domainsCLIFixture wires up a fake console plus a credential pointed at it, |
| +// entirely in-process — the same shape fleetsCLIFixture uses. |
| +func domainsCLIFixture(t *testing.T) *fakeDomainsConsole { |
| + t.Helper() |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + t.Chdir(t.TempDir()) |
| + creds := filepath.Join(home, "carlos-credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", creds) |
| + fc := newFakeDomainsConsole(t) |
| + writeTestCredential(t, creds, fc.ts.URL) |
| + return fc |
| +} |
| + |
| +func TestRunDomainsAttachPrintsAttached(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.attachCreated = true |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "-route", "slug.go.tito.io", "tickets.example.com"}) |
| + |
| + if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" { |
| + t.Fatalf("console saw body %+v", fc.lastAttachBody) |
| + } |
| + if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") { |
| + t.Fatalf("stdout = %q, want the attached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.attachCreated = false |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) |
| + |
| + if !strings.Contains(buf.String(), "already attached") { |
| + t.Fatalf("stdout = %q, want the already-attached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +func TestRunDomainsDetachPrintsDetached(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.detachRemoved = true |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"detach", "-app", "tito", "tickets.example.com"}) |
| + |
| + if !strings.HasSuffix(fc.lastDetachPath, "/domains/tickets.example.com") { |
| + t.Fatalf("detach path = %s, want it to end in the escaped host", fc.lastDetachPath) |
| + } |
| + if !strings.Contains(buf.String(), "detached tickets.example.com") { |
| + t.Fatalf("stdout = %q, want the detached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.detachRemoved = false |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"detach", "-app", "tito", "ghost.example.com"}) |
| + |
| + if !strings.Contains(buf.String(), "was not attached") { |
| + t.Fatalf("stdout = %q, want the was-not-attached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +// TestRunDomainsListPrintsTable: the list table carries every column and |
| +// falls back to "-" for the row with no dns_status/cert_expires_at. |
| +func TestRunDomainsListPrintsTable(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"list", "-app", "tito"}) |
| + |
| + out := buf.String() |
| + for _, want := range []string{ |
| + "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", |
| + "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15", |
| + "new.example.com", "pending", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing %q:\n%s", want, out) |
| + } |
| + } |
| + // The pending row's dns/cert columns must read "-", not a stray zero or |
| + // blank cell that would misalign the table. |
| + lines := strings.Split(out, "\n") |
| + var pendingLine string |
| + for _, l := range lines { |
| + if strings.Contains(l, "new.example.com") { |
| + pendingLine = l |
| + } |
| + } |
| + if pendingLine == "" || !strings.Contains(pendingLine, "-") { |
| + t.Fatalf("pending row = %q, want a \"-\" placeholder for the absent columns", pendingLine) |
| + } |
| +} |
| + |
| +// ---- subprocess: the usage-error / bad-argument paths --------------------- |
| + |
| +// TestDomainsCLIUsageErrors drives every domains verb with missing or |
| +// malshaped arguments as a real subprocess (buildCarlos) — the only way to |
| +// observe a log.Fatal/os.Exit refusal without killing the test binary. |
| +// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is |
| +// a scratch dir (CLAUDE.md's rule), so a case that DID try to dial out would |
| +// fail on "not logged in", not silently succeed. |
| +func TestDomainsCLIUsageErrors(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + env := carlosTestEnv(t) |
| + |
| + run := func(args ...string) (string, error) { |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = env |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + for _, tc := range []struct { |
| + name string |
| + args []string |
| + want string |
| + }{ |
| + {"domains no args", []string{"domains"}, "usage: carlos domains"}, |
| + {"domains bogus verb", []string{"domains", "bogus"}, "usage: carlos domains"}, |
| + {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "usage: carlos domains attach"}, |
| + {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"}, |
| + {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "usage: carlos domains detach"}, |
| + {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, |
| + {"list no app", []string{"domains", "list"}, "usage: carlos domains list"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + out, err := run(tc.args...) |
| + if err == nil { |
| + t.Fatalf("carlos %s exited 0, want a nonzero (usage) exit:\n%s", strings.Join(tc.args, " "), out) |
| + } |
| + if !strings.Contains(out, tc.want) { |
| + t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| new file mode 100644 |
| index 000000000..ca4ad69d9 |
| --- /dev/null |
| +++ b/internal/console/apidomains.go |
| @@ -0,0 +1,335 @@ |
| +// The CLI's extra-hostname API (#151): the verbs that attach a customer's |
| +// own hostname to an app's route, as a data write in the deployment bucket, |
| +// so an app holding no AWS credentials and no box access can offer |
| +// custom-domain hosting to ITS customers in one call. The claim it writes is |
| +// the whole authorization; the agent's domains pass turns it into an alias |
| +// row, on the box that actually owns the route. |
| +// |
| +// The gate is the instance family's, verbatim (instanceGate, apiinstances.go): |
| +// the SAME two bearer shapes reach it — a member's user token, or an APP |
| +// token bound to one account and one app at mint time — and every refusal |
| +// before that proof is the same byte-identical 404. That reuse is deliberate: |
| +// titogo's router calls this door with its CARLOS_INSTANCE_TOKEN, the very |
| +// credential it already holds for instances, and a second hand-rolled gate |
| +// here would be a second place for the ordering (principal, budget, |
| +// readiness) to drift. |
| +// |
| +// Two neutralities this file exists to keep: |
| +// - a hostname another account holds answers 409 "hostname unavailable" and |
| +// NOTHING else — not who holds it, not which account, not whether the |
| +// holder is in this deployment at all; |
| +// - detach is quiet in both directions — an unknown hostname and another |
| +// owner's hostname answer with the identical 200 body, so detach is not a |
| +// way to ask "does anyone own this?". |
| +// |
| +// Spec: docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md §5. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// deniedDomains is the deny list every attach is checked against: the |
| +// operator's own CONSOLE_DENY_DOMAINS entries, plus the three names this |
| +// console can derive without being told (spec §5: "all derived from |
| +// console/deployment config, not hardcoded"). |
| +// |
| +// The derived three are not optional and not the operator's to forget: |
| +// |
| +// - the ORIGIN host is the console itself — an alias there would point the |
| +// door's own hostname at a tenant's route; |
| +// - the APPS DOMAIN is the whole platform apex namespace |
| +// (<app>.<sqid>.<apps-domain>): every app's public URL lives under it, |
| +// and a claim there would be one tenant claiming another tenant's |
| +// address — the sibling-apex hazard handleAPIInstanceCreate refuses |
| +// within an account, refused here across the deployment. Hosts under the |
| +// apps domain are what `carlos instances` exists for; this door is for |
| +// hostnames the caller's own DNS controls; |
| +// - the REDIRECT HOSTS are retired console origins (CONSOLE_REDIRECT_HOSTS |
| +// — tito's carlos.tito.io), which this console still answers on and |
| +// 301s to Origin. They are guaranteed distinct from the origin host |
| +// (NewServer refuses a self-redirect), so nothing else in this list |
| +// covers them, and a tenant claiming one would be claiming a console |
| +// address that operators, bookmarks and old CLI credentials still reach. |
| +// Route-wins ordering on a box is not the answer: it is per-box, and |
| +// stops being protection the moment a second box materializes the alias. |
| +// |
| +// Derived per call rather than cached at boot — one url.Parse and a short |
| +// copy on a door that then does bucket I/O — so it stays honest for an |
| +// embedder (or a test) that sets any of these after NewServer. The redirect |
| +// entries go through the same normalizeHost ServeHTTP's retired-host check |
| +// uses, so the two readings of CONSOLE_REDIRECT_HOSTS cannot disagree. |
| +func (s *Server) deniedDomains() []string { |
| + deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2) |
| + deny = append(deny, s.cfg.DenyDomains...) |
| + if u, err := url.Parse(s.cfg.Origin); err == nil { |
| + if h := strings.ToLower(u.Hostname()); h != "" { |
| + deny = append(deny, h) |
| + } |
| + } |
| + if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" { |
| + deny = append(deny, d) |
| + } |
| + for _, h := range s.cfg.RedirectHosts { |
| + if h = normalizeHost(h); h != "" { |
| + deny = append(deny, h) |
| + } |
| + } |
| + return deny |
| +} |
| + |
| +// resolveAliasRoute decides which of the app's routes a claim points at. |
| +// ok=false means a response has already been written. |
| +// |
| +// An EXPLICIT route is shape-checked and then accepted as given. The door |
| +// deliberately does not verify that the route exists: instance records are |
| +// only one way a route comes to be, and an operator-minted route (tito's |
| +// go.tito.io, every route on a self-hosted box) has no record at all — |
| +// refusing those here would make the feature unusable for exactly the |
| +// deployment that asked for it. The authoritative check is on the box, at |
| +// reconcile: a claim naming a route that box does not own materializes |
| +// nothing (spec §3), so a wrong route is inert rather than dangerous. |
| +// |
| +// An OMITTED route is defaulted only when there is exactly one thing it could |
| +// mean — the app's single instance record. Zero or several is refused, naming |
| +// the flag that resolves it: guessing an app's primary route out of a set |
| +// would attach a customer's hostname to whichever one sorted first. |
| +func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, acct *Account, app, want string) (string, bool) { |
| + if route := strings.ToLower(strings.TrimSpace(want)); route != "" { |
| + if !provision.ValidHostShape(route) { |
| + jsonError(w, http.StatusBadRequest, "invalid route hostname") |
| + return "", false |
| + } |
| + return route, true |
| + } |
| + recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) |
| + if err != nil { |
| + log.Printf("console: api domains route default %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read this app's instances") |
| + return "", false |
| + } |
| + if len(recs) == 1 { |
| + return recs[0].Host, true |
| + } |
| + if len(recs) == 0 { |
| + jsonError(w, http.StatusBadRequest, |
| + "specify -route: the app has no instance records to default to — name the route hostname to attach to") |
| + return "", false |
| + } |
| + jsonError(w, http.StatusBadRequest, |
| + "specify -route: the app has "+strconv.Itoa(len(recs))+" instances") |
| + return "", false |
| +} |
| + |
| +// handleAPIDomainAttach is the point of the feature: one claim written to the |
| +// bucket, in the spec's refusal order — gate, body, hostname, deny list, |
| +// route, write. Idempotent by design (the titogo contract): re-attaching a |
| +// hostname this app already holds is 200 with created false, converging the |
| +// route if it moved, and a hostname anyone ELSE holds is the neutral 409. |
| +func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var in struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + } |
| + if !readJSON(w, r, &in) { |
| + 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, |
| + // lowercased, dot-bearing hostname. |
| + host, ok := domains.NormalizeAliasHost(in.Host) |
| + if !ok { |
| + jsonError(w, http.StatusBadRequest, |
| + "invalid hostname — attach a full hostname like tickets.example.com") |
| + return |
| + } |
| + if domains.HostDenied(host, s.deniedDomains()) { |
| + jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route) |
| + if !ok { |
| + return |
| + } |
| + created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{ |
| + Host: host, Account: acct.Sqid, App: app, Route: route, |
| + // UTC, like every other timestamp written to the bucket: these claims |
| + // are read on boxes in other regions. |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| + }) |
| + if errors.Is(err, domains.ErrHostUnavailable) { |
| + // Exactly these bytes, whoever holds it: naming the holder — or even |
| + // distinguishing "held by another app of yours" from "held by a |
| + // stranger" — would make this door a hostname-ownership oracle. |
| + jsonError(w, http.StatusConflict, "hostname unavailable") |
| + return |
| + } else if err != nil { |
| + log.Printf("console: api domains attach %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not attach the hostname — try again") |
| + return |
| + } |
| + // Only an actual WRITE audits and announces (spec §5), the same rule |
| + // detach observes below. `created` is the caller's answer; `changed` is |
| + // this one, and they differ on the converge — a re-attach that repointed |
| + // the route created nothing but must reach the fleet. The no-op case is |
| + // not hypothetical: titogo's router re-attaches every hostname on each |
| + // reload pass, so a bump per call would keep every box converging onto |
| + // nothing and an audit row per call would bury the tenant's log. |
| + if changed { |
| + s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)") |
| + // The claim is what a box's domains pass mints an alias row from, so |
| + // this is a box-consumed write (carlos-deploy §3): announce it, and |
| + // the hostname is serving seconds after the call instead of on the |
| + // hour. Strictly after the write and the audit — a serial that moved |
| + // for a write that failed would send every box looking for nothing. |
| + s.bumpSerial(ctx) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created}) |
| +} |
| + |
| +// handleAPIDomainDetach releases a hostname. Always 200, whatever was there: |
| +// `removed` reports whether this call is what removed it, and the three ways |
| +// of removing nothing — already gone, never claimed, claimed by someone else |
| +// — are indistinguishable on the wire (DetachClaim's own neutrality, spec |
| +// §5). Only an EFFECTIVE removal audits and bumps the serial: a no-op changed |
| +// nothing for a box to converge onto, and an audit row per probe would let a |
| +// prober write to someone else's account log. |
| +func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + 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 |
| + // join it. There is no path through this handler on which an |
| + // unnormalized host reaches the store. |
| + host, ok := domains.NormalizeAliasHost(r.PathValue("host")) |
| + if !ok { |
| + jsonError(w, http.StatusBadRequest, "invalid hostname") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api domains detach %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again") |
| + return |
| + } |
| + if removed { |
| + s.audit(acct, app, principal, "domain-detach", "detached hostname "+host+" (cli)") |
| + // Taking a hostname down is the direction people want FAST. |
| + s.bumpSerial(ctx) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"host": host, "removed": removed}) |
| +} |
| + |
| +// domainListItem is one row of the list answer — the claim's desired state |
| +// (host, route) joined with what the fleet actually reports for it. Status is |
| +// "live" or "pending" and never absent; the two reading columns omit when |
| +// unstamped, the same unknown-stays-absent convention status.AliasReading |
| +// itself uses. |
| +type domainListItem struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Status string `json:"status"` |
| + DNSStatus string `json:"dns_status,omitempty"` |
| + CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| +} |
| + |
| +// handleAPIDomainList answers `carlos domains list`: this app's claims, each |
| +// joined to the box readings sweep so the command alone verifies its own |
| +// result (the no-infra rule) — DNS status and cert expiry as the box stamped |
| +// them, or "pending" for a claim no live box has materialized yet. |
| +func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + // Claims are keyed globally (domains/<host>.json — the key IS the |
| + // cross-account uniqueness gate), so the tenant boundary is this filter |
| + // and nothing else: every row that reaches the answer matched BOTH this |
| + // account's sqid and this app. |
| + claims, err := domains.ListClaims(ctx, s.cfg.Releases) |
| + if err != nil { |
| + log.Printf("console: api domains list %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not list the hostnames") |
| + return |
| + } |
| + readings := aliasReadings(ctx, s.cfg.Releases, time.Now()) |
| + out := make([]domainListItem, 0, len(claims)) |
| + for _, c := range claims { |
| + if c.Account != acct.Sqid || c.App != app { |
| + continue |
| + } |
| + item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending"} |
| + if reading, ok := readings[c.Host]; ok { |
| + item.Status = "live" |
| + item.DNSStatus = reading.DNSStatus |
| + item.CertExpiresAt = reading.CertExpiresAt |
| + } |
| + out = append(out, item) |
| + } |
| + // Host-sorted, because the Store.List contract does not promise an order |
| + // and a terminal listing that shuffles between calls reads as churn. |
| + sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| + writeJSON(w, http.StatusOK, map[string]any{"domains": out}) |
| +} |
| + |
| +// aliasReadings builds a hostname -> reading map from every NON-STALE box's |
| +// own private Metrics.Aliases (status/boxes/*.json), through status.LoadBoxes |
| +// — the same loader and the same staleness rule stagedVersions uses |
| +// (apiinstances.go), for the same reason: a box that stopped reporting is |
| +// EXCLUDED entirely rather than contributing its last-known reading, so a |
| +// hostname whose only box went dark reads "pending" instead of going on |
| +// claiming a cert expiry nobody has checked in a week. now is passed in, not |
| +// read internally, so one request votes on staleness with one clock. |
| +// |
| +// Best-effort throughout: a store that cannot be listed degrades to "nothing |
| +// known" — every claim then lists as pending — rather than failing the call. |
| +// This join is observability on top of the listing, never load-bearing for |
| +// it. A nil store is not reachable here (instanceGate's 503 comes first), but |
| +// the check is kept for any future caller. |
| +func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[string]status.AliasReading { |
| + if s == nil { |
| + return nil |
| + } |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + log.Printf("console: api domains: could not load status boxes for hostname readings: %v", err) |
| + return nil |
| + } |
| + readings := map[string]status.AliasReading{} |
| + for _, b := range boxes { |
| + if b.Metrics == nil || status.BoxStale(b, now, status.SnapshotPeriod) { |
| + continue |
| + } |
| + for host, reading := range b.Metrics.Aliases { |
| + readings[host] = reading |
| + } |
| + } |
| + return readings |
| +} |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| new file mode 100644 |
| index 000000000..b5462cc23 |
| --- /dev/null |
| +++ b/internal/console/apidomains_test.go |
| @@ -0,0 +1,480 @@ |
| +package console |
| + |
| +// The domains door (apidomains.go): attach, detach and list of the extra |
| +// hostnames an app's routes answer on (#151). The properties that matter, in |
| +// the order they'd hurt if lost: the gate is the instance family's, so an app |
| +// token bound elsewhere gets the byte-identical 404 and learns nothing; a |
| +// hostname someone else holds answers a NEUTRAL 409 that never names the |
| +// holder; detach is quiet in both directions (unknown host and another |
| +// owner's host read alike, and neither touches the claim or the serial); and |
| +// every effective write — and only an effective write — bumps control/serial |
| +// so boxes converge in seconds. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func domainsURL(fx *consoleFixture, acct, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/domains" |
| +} |
| + |
| +// attachBody is the door's request shape. route is always sent (empty means |
| +// "default it"), because an omitted field and an empty one must behave the |
| +// same and the tests exercise the empty one. |
| +func attachBody(host, route string) string { |
| + b, err := json.Marshal(map[string]string{"host": host, "route": route}) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +type domainAttachResp struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Created bool `json:"created"` |
| +} |
| + |
| +type domainDetachResp struct { |
| + Host string `json:"host"` |
| + Removed bool `json:"removed"` |
| +} |
| + |
| +type domainListResp struct { |
| + Domains []struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Status string `json:"status"` |
| + DNSStatus string `json:"dns_status"` |
| + CertExpiresAt int64 `json:"cert_expires_at"` |
| + } `json:"domains"` |
| +} |
| + |
| +// attachDomain posts one attach and returns the decoded answer, failing the |
| +// test on any status but the one asked for. |
| +func attachDomain(t *testing.T, fx *consoleFixture, token, host, route string) domainAttachResp { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, attachBody(host, route)) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach %s = %d %s, want 200", host, code, out) |
| + } |
| + var got domainAttachResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("attach body %s: %v", out, err) |
| + } |
| + return got |
| +} |
| + |
| +// claimOf reads a claim straight from the bucket — the store side of every |
| +// assertion here, so a handler that answers 200 without writing is caught. |
| +func claimOf(t *testing.T, fx *consoleFixture, host string) *domains.Claim { |
| + t.Helper() |
| + c, err := domains.GetClaim(context.Background(), fx.rel, host) |
| + if err != nil { |
| + t.Fatalf("claim %s: %v", host, err) |
| + } |
| + return c |
| +} |
| + |
| +func claimAbsent(t *testing.T, fx *consoleFixture, host string) { |
| + t.Helper() |
| + if _, err := domains.GetClaim(context.Background(), fx.rel, host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("claim %s exists (err=%v), want none", host, err) |
| + } |
| +} |
| + |
| +// TestAPIDomainAttachCreates: a member's token attaches a hostname to a |
| +// named route — 200, created true, and a claim object in the bucket carrying |
| +// the member as its author. |
| +func TestAPIDomainAttachCreates(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-create") |
| + got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io") |
| + if got != (domainAttachResp{Host: "tickets.example.com", Route: "go.tito.io", Created: true}) { |
| + t.Fatalf("attach answer = %+v, want the host/route echoed with created true", got) |
| + } |
| + c := claimOf(t, fx, "tickets.example.com") |
| + if c.Schema != 1 || c.Account != "bab" || c.App != "titogo" || c.Route != "go.tito.io" { |
| + t.Fatalf("claim = %+v, want schema 1 bab/titogo -> go.tito.io", *c) |
| + } |
| + if c.CreatedBy != "alice@keymail.dev" { |
| + t.Fatalf("created_by = %q, want the member's address", c.CreatedBy) |
| + } |
| + if c.CreatedAt == "" { |
| + t.Fatalf("claim has no created_at: %+v", *c) |
| + } |
| + // The hostname is normalized before it is stored: a caller shouting the |
| + // host with a trailing dot claims the same object, not a second one. |
| + if got := attachDomain(t, fx, member, "TICKETS.example.com.", "go.tito.io"); got.Created || got.Host != "tickets.example.com" { |
| + t.Fatalf("re-attach of the same host in another case = %+v, want the normalized host, created false", got) |
| + } |
| +} |
| + |
| +// TestAPIDomainAttachAppToken: the app-held credential works on its own app |
| +// — and a token bound to a NEIGHBOURING app in the same account meets the |
| +// family's byte-identical 404 (apiInstancePrincipal's contract), never a |
| +// hint that the path exists. |
| +func TestAPIDomainAttachAppToken(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-dom-apptoken") |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := attachDomain(t, fx, mine, "tickets.example.com", "go.tito.io") |
| + if !got.Created { |
| + t.Fatalf("app token attach = %+v, want created true", got) |
| + } |
| + if c := claimOf(t, fx, "tickets.example.com"); c.CreatedBy != "app:bab/titogo" { |
| + t.Fatalf("created_by = %q, want the app principal app:bab/titogo", c.CreatedBy) |
| + } |
| + want := canonicalNotFound(t) |
| + for _, c := range []struct{ what, method, url, body string }{ |
| + {"foreign app token attach", http.MethodPost, domainsURL(fx, "bab", "titogo"), attachBody("other.example.com", "go.tito.io")}, |
| + {"foreign app token list", http.MethodGet, domainsURL(fx, "bab", "titogo"), ""}, |
| + {"foreign app token detach", http.MethodDelete, domainsURL(fx, "bab", "titogo") + "/tickets.example.com", ""}, |
| + } { |
| + code, h, out := rawReq(t, c.method, c.url, foreign, c.body) |
| + assertNotFound(t, c.what, want, code, h, out) |
| + } |
| + // The refused calls changed nothing: no new claim, and the one that |
| + // existed is still there. |
| + claimAbsent(t, fx, "other.example.com") |
| + claimOf(t, fx, "tickets.example.com") |
| +} |
| + |
| +// TestAPIDomainAttachIdempotent: attach is the caller's GOAL, not an event — |
| +// a second identical attach is 200 with created false, and an attach naming a |
| +// different route converges the claim in place rather than refusing it. |
| +func TestAPIDomainAttachIdempotent(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-idem") |
| + if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); !got.Created { |
| + t.Fatalf("first attach = %+v, want created true", got) |
| + } |
| + if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); got.Created { |
| + t.Fatalf("second identical attach = %+v, want created false", got) |
| + } |
| + if got := attachDomain(t, fx, member, "tickets.example.com", "two.go.tito.io"); got.Created || got.Route != "two.go.tito.io" { |
| + t.Fatalf("re-attach to another route = %+v, want created false on the new route", got) |
| + } |
| + if c := claimOf(t, fx, "tickets.example.com"); c.Route != "two.go.tito.io" { |
| + t.Fatalf("claim route = %q, want the converged two.go.tito.io", c.Route) |
| + } |
| +} |
| + |
| +// TestAPIDomainAttachConflictNeutral: a hostname another account holds is a |
| +// 409 whose body says exactly one thing — never who holds it, never whether |
| +// the holder is in this deployment at all. |
| +func TestAPIDomainAttachConflictNeutral(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-conflict") |
| + if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{ |
| + Host: "tickets.example.com", Account: "cdef", App: "widgets", |
| + Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody("tickets.example.com", "go.tito.io")) |
| + if code != http.StatusConflict { |
| + t.Fatalf("attach of a held hostname = %d %s, want 409", code, out) |
| + } |
| + if got := strings.TrimSpace(out); got != `{"error":"hostname unavailable"}` { |
| + t.Fatalf("409 body = %s, want the neutral {\"error\":\"hostname unavailable\"}", got) |
| + } |
| + // The holder's claim is untouched by the refusal. |
| + if c := claimOf(t, fx, "tickets.example.com"); c.Account != "cdef" || c.App != "widgets" { |
| + t.Fatalf("claim after a refused attach = %+v, want the holder's own", *c) |
| + } |
| +} |
| + |
| +// TestAPIDomainAttachValidation: every hostname this door refuses to take, |
| +// each with nothing written behind it — malformed, bare-label, under a |
| +// configured deny domain, under the deployment's apps domain, the console's |
| +// own origin host, and a RETIRED console origin (CONSOLE_REDIRECT_HOSTS, |
| +// which this console still answers on and 301s away — tito's carlos.tito.io). |
| +func TestAPIDomainAttachValidation(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-validate") |
| + fx.srv.cfg.DenyDomains = []string{"", "denied.test"} |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.RedirectHosts = []string{"", "Carlos.Tito.Test:8443"} |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, host := range []string{ |
| + "Bad_Host!", // not a hostname at all |
| + "nodots", // a bare label is never a public hostname |
| + "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| + "denied.test", // the deny entry itself |
| + "one.bab.oncarlos.test", // platform apex space (spec §5) |
| + origin.Hostname(), // the console's own origin host |
| + "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it |
| + "old.carlos.tito.test", // and anything under it |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody(host, "go.tito.io")) |
| + if code != http.StatusBadRequest { |
| + t.Errorf("attach %q = %d %s, want 400", host, code, out) |
| + } |
| + claimAbsent(t, fx, strings.ToLower(host)) |
| + } |
| + // An empty deny entry matches nothing: the ordinary hostname still goes |
| + // through with "" in the list. |
| + if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); !got.Created { |
| + t.Fatalf("ordinary attach with an empty deny entry present = %+v, want created true", got) |
| + } |
| +} |
| + |
| +// TestAPIDomainAttachRouteDefault: the route may be omitted when there is |
| +// exactly one thing it could mean. Two instance records make it ambiguous, |
| +// and ambiguity names the flag that resolves it rather than guessing. |
| +func TestAPIDomainAttachRouteDefault(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-route") |
| + ctx := context.Background() |
| + rec := provision.Record{Schema: 1, Host: "one.go.tito.io", Slug: "one", App: "titogo", |
| + Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := attachDomain(t, fx, member, "tickets.example.com", ""); got.Route != "one.go.tito.io" { |
| + t.Fatalf("attach with no route = %+v, want the app's one instance as the route", got) |
| + } |
| + if c := claimOf(t, fx, "tickets.example.com"); c.Route != "one.go.tito.io" { |
| + t.Fatalf("claim route = %q, want one.go.tito.io", c.Route) |
| + } |
| + rec.Host, rec.Slug = "two.go.tito.io", "two" |
| + if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody("other.example.com", "")) |
| + if code != http.StatusBadRequest { |
| + t.Fatalf("ambiguous attach = %d %s, want 400", code, out) |
| + } |
| + if !strings.Contains(out, "-route") { |
| + t.Fatalf("ambiguous attach body = %s, want it to name the -route flag", out) |
| + } |
| + claimAbsent(t, fx, "other.example.com") |
| +} |
| + |
| +// TestAPIDomainDetachQuiet: detach answers the same 200 shape three ways — |
| +// the claim it removed, a hostname nobody claimed, and one another account |
| +// holds — so a caller cannot use detach to discover who owns a hostname. |
| +// Only the first of the three actually removes anything. |
| +func TestAPIDomainDetachQuiet(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-detach") |
| + attachDomain(t, fx, member, "tickets.example.com", "go.tito.io") |
| + if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{ |
| + Host: "theirs.example.com", Account: "cdef", App: "widgets", |
| + Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + detach := func(host string) domainDetachResp { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/"+host, member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("detach %s = %d %s, want 200", host, code, out) |
| + } |
| + var got domainDetachResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("detach body %s: %v", out, err) |
| + } |
| + return got |
| + } |
| + if got := detach("tickets.example.com"); got != (domainDetachResp{Host: "tickets.example.com", Removed: true}) { |
| + t.Fatalf("detach of an own claim = %+v, want removed true", got) |
| + } |
| + claimAbsent(t, fx, "tickets.example.com") |
| + if got := detach("tickets.example.com"); got.Removed { |
| + t.Fatalf("second detach = %+v, want removed false", got) |
| + } |
| + if got := detach("never.example.com"); got != (domainDetachResp{Host: "never.example.com", Removed: false}) { |
| + t.Fatalf("detach of an unknown hostname = %+v, want the same shape, removed false", got) |
| + } |
| + if got := detach("theirs.example.com"); got != (domainDetachResp{Host: "theirs.example.com", Removed: false}) { |
| + t.Fatalf("detach of another account's claim = %+v, want the same quiet answer", got) |
| + } |
| + if c := claimOf(t, fx, "theirs.example.com"); c.Account != "cdef" || c.App != "widgets" { |
| + t.Fatalf("another account's claim after a detach = %+v, want it intact", *c) |
| + } |
| + // Traversal defence: a {host} arrives percent-decoded, so a path with |
| + // separators in it reaches the handler. It is refused on shape, before |
| + // anything builds a bucket key from it. |
| + for _, bad := range []string{"bad_host", "..%2F..%2Finstances.json"} { |
| + if code, _, out := rawReq(t, http.MethodDelete, |
| + domainsURL(fx, "bab", "titogo")+"/"+bad, member, ""); code != http.StatusBadRequest { |
| + t.Errorf("detach %q = %d %s, want 400", bad, code, out) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIDomainList: this app's claims only, each joined to the box readings |
| +// sweep — a hostname a live box reports carries its DNS/cert columns and |
| +// reads "live"; one no live box reports reads "pending", including one only a |
| +// STALE box still remembers. |
| +func TestAPIDomainList(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-list") |
| + ctx := context.Background() |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + attachDomain(t, fx, member, "live.example.com", "go.tito.io") |
| + attachDomain(t, fx, member, "pending.example.com", "go.tito.io") |
| + attachDomain(t, fx, member, "stale.example.com", "go.tito.io") |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: "elsewhere.example.com", Account: "bab", App: "other", |
| + Route: "other.go.tito.io", CreatedAt: now.UTC().Format(time.RFC3339), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "live.example.com": {Route: "go.tito.io", DNSStatus: "ok", CertExpiresAt: 1786000000}, |
| + "elsewhere.example.com": {Route: "other.go.tito.io", DNSStatus: "ok"}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A box that stopped reporting drops OUT of the join (the stagedVersions |
| + // rule): its last-known reading must not go on answering as if current. |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: now.Add(-2 * time.Hour), Label: "bob-silent", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "stale.example.com": {Route: "go.tito.io", DNSStatus: "ok", CertExpiresAt: 1786000000}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s, want 200", code, out) |
| + } |
| + var got domainListResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + if len(got.Domains) != 3 { |
| + t.Fatalf("list = %+v, want exactly this app's three claims", got.Domains) |
| + } |
| + byHost := map[string]struct { |
| + Host string `json:"host"` |
| + Route string `json:"route"` |
| + Status string `json:"status"` |
| + DNSStatus string `json:"dns_status"` |
| + CertExpiresAt int64 `json:"cert_expires_at"` |
| + }{} |
| + for _, d := range got.Domains { |
| + byHost[d.Host] = d |
| + } |
| + if _, ok := byHost["elsewhere.example.com"]; ok { |
| + t.Fatalf("another app's claim is in this app's listing: %+v", got.Domains) |
| + } |
| + live := byHost["live.example.com"] |
| + if live.Status != "live" || live.DNSStatus != "ok" || live.CertExpiresAt != 1786000000 || live.Route != "go.tito.io" { |
| + t.Fatalf("reported hostname = %+v, want live with its DNS/cert columns", live) |
| + } |
| + if p := byHost["pending.example.com"]; p.Status != "pending" || p.DNSStatus != "" || p.CertExpiresAt != 0 { |
| + t.Fatalf("unreported hostname = %+v, want pending with no readings", p) |
| + } |
| + if s := byHost["stale.example.com"]; s.Status != "pending" || s.DNSStatus != "" { |
| + t.Fatalf("hostname only a stale box reports = %+v, want pending", s) |
| + } |
| +} |
| + |
| +// domainEvents counts this app's audit rows whose action starts "domain-". |
| +func domainEvents(t *testing.T, fx *consoleFixture) int { |
| + t.Helper() |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + for _, e := range events { |
| + if strings.HasPrefix(e.Action, "domain-") { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// TestAPIDomainSerialBump: boxes converge off control/serial, so every |
| +// EFFECTIVE write announces itself — and the two no-ops, a re-attach that |
| +// changed nothing and a detach that removed nothing, announce nothing and |
| +// write no audit row. That matters more than it looks: titogo's router |
| +// re-attaches its hostnames on every reload pass, so redundant attaches are |
| +// the STEADY STATE — a bump per pass would keep the whole fleet converging |
| +// onto nothing, and an audit row per pass would bury the tenant's log. |
| +func TestAPIDomainSerialBump(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-serial") |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial before any domain write = %d, want 0", got) |
| + } |
| + attachDomain(t, fx, member, "tickets.example.com", "go.tito.io") |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial after an attach = %d, want 1", got) |
| + } |
| + if got := domainEvents(t, fx); got != 1 { |
| + t.Fatalf("audit rows after an attach = %d, want 1", got) |
| + } |
| + // The router's steady state: the same attach again, changing nothing. |
| + if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); got.Created { |
| + t.Fatalf("no-op re-attach = %+v, want created false", got) |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial after a no-op re-attach = %d, want it unmoved at 1", got) |
| + } |
| + if got := domainEvents(t, fx); got != 1 { |
| + t.Fatalf("audit rows after a no-op re-attach = %d, want it unmoved at 1", got) |
| + } |
| + // A converge IS a write: new route, so the fleet hears about it. |
| + if got := attachDomain(t, fx, member, "tickets.example.com", "two.go.tito.io"); got.Created { |
| + t.Fatalf("converge attach = %+v, want created false", got) |
| + } |
| + if got := serial(t, fx); got != 2 { |
| + t.Fatalf("serial after a converge = %d, want 2", got) |
| + } |
| + if got := domainEvents(t, fx); got != 2 { |
| + t.Fatalf("audit rows after a converge = %d, want 2", got) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, |
| + domainsURL(fx, "bab", "titogo")+"/tickets.example.com", member, ""); code != http.StatusOK { |
| + t.Fatalf("detach = %d %s, want 200", code, out) |
| + } |
| + if got := serial(t, fx); got != 3 { |
| + t.Fatalf("serial after an effective detach = %d, want 3", got) |
| + } |
| + if got := domainEvents(t, fx); got != 3 { |
| + t.Fatalf("audit rows after an effective detach = %d, want 3", got) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, |
| + domainsURL(fx, "bab", "titogo")+"/never.example.com", member, ""); code != http.StatusOK { |
| + t.Fatalf("no-op detach = %d %s, want 200", code, out) |
| + } |
| + if got := serial(t, fx); got != 3 { |
| + t.Fatalf("serial after a no-op detach = %d, want it unmoved at 3", got) |
| + } |
| + if got := domainEvents(t, fx); got != 3 { |
| + t.Fatalf("audit rows after a no-op detach = %d, want it unmoved at 3", got) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index b2c0a2c42..48e177bea 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -261,8 +261,8 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob) |
| // The instance provisioning API (apiinstances.go) — the only door onto |
| - // instances/ from outside a box, and the only one an APP's own token |
| - // may speak to. `instance-token` is a literal segment beside the |
| + // instances/ and domains/ from outside a box, and the only family an |
| + // APP's own token may speak to. `instance-token` is a literal segment beside the |
| // {host} wildcards; the mux prefers a literal regardless of |
| // registration order, and it deliberately sits under a DIFFERENT |
| // prefix from instances/{host} so a host can never be mistaken for the |
| @@ -279,6 +279,13 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet) |
| s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceDelete) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instance-token", s.handleAPIInstanceToken) |
| + // The extra-hostname API (apidomains.go, issue #151) — the only door onto |
| + // domains/ from outside a box, sharing the instance family's gate exactly |
| + // (instanceGate), so titogo's router attaches its customers' hostnames |
| + // with the CARLOS_INSTANCE_TOKEN it already holds. |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainList) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainAttach) |
| + s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/domains/{host}", s.handleAPIDomainDetach) |
| // The config API (apiconfig.go) — `carlos env` and `carlos secrets` |
| // through the same gate as ship/promote, so deploy access no longer |
| // means bucket credentials (issue #43). Secrets arrive already sealed; |
diff --git a/internal/console/server.go b/internal/console/server.go| index fada08534..f89334711 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -210,6 +210,15 @@ type Config struct { |
| // host-agnostic, and POSTs through an unexpected redirect are how |
| // clients break) simply aren't listed. |
| RedirectHosts []string |
| + // DenyDomains are hostnames (and every host under them) the /domains |
| + // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The |
| + // deployment's own names, in other words: the edge's reserved tunnel |
| + // hostname, retired console origins, anything platform-managed that is |
| + // not already derivable from this config. The two the console DOES |
| + // derive itself — Origin's hostname and AppsDomain — are added by |
| + // deniedDomains() (apidomains.go), so a deployment cannot forget them. |
| + // Empty is legal and means "only the derived two". |
| + DenyDomains []string |
| // StoreProvisioner is the backend the object-storage doors provision |
| // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| // ordinary deployment shape, not a broken one: members may still DECLARE |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| new file mode 100644 |
| index 000000000..46a90b812 |
| --- /dev/null |
| +++ b/internal/domains/aliases.go |
| @@ -0,0 +1,74 @@ |
| +// Alias reconcile pass — derives the registry's route_aliases table from |
| +// the global claims recorded in the bucket (#151). See claims.go for the |
| +// claim shape and registry.go's RouteAlias for the storage shape; this file |
| +// is the seam that turns one into the other. |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ReconcileAliases derives route_aliases from the global claim records. |
| +// The table is wholly pass-owned: every row traces to a claim, so removal |
| +// needs no ownership flag. The authoritative tenancy gate is HERE — the |
| +// door's route check is best-effort (it cannot see the registry); a claim |
| +// only materializes when the named route exists locally AND its |
| +// account/app match the claim. Routes always win a host collision: an |
| +// alias must never shadow a real route. |
| +func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error { |
| + claims, err := ListClaims(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + byHost := make(map[string]registry.Route, len(routes)) |
| + for _, rt := range routes { |
| + byHost[rt.Host] = rt |
| + } |
| + existing, err := reg.ListAliases() |
| + if err != nil { |
| + return err |
| + } |
| + desired := map[string]bool{} |
| + for _, c := range claims { |
| + if c.Route == "" { // first-route claims (#82's case) are not aliases |
| + continue |
| + } |
| + rt, ok := byHost[c.Route] |
| + if !ok { |
| + continue // primary not on this box — another box's claim |
| + } |
| + if rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| + log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s", |
| + c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App) |
| + continue |
| + } |
| + if _, taken := byHost[c.Host]; taken { |
| + log.Printf("domains: alias %s skipped: a real route owns that host", c.Host) |
| + continue |
| + } |
| + desired[c.Host] = true |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App, |
| + CreatedAt: now().Unix(), |
| + }); err != nil { |
| + log.Printf("domains: upsert alias %s: %v", c.Host, err) |
| + } |
| + } |
| + for _, a := range existing { |
| + if !desired[a.Alias] { |
| + if err := reg.DeleteAlias(a.Alias); err != nil { |
| + log.Printf("domains: remove alias %s: %v", a.Alias, err) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go| new file mode 100644 |
| index 000000000..3e202c599 |
| --- /dev/null |
| +++ b/internal/domains/aliases_test.go |
| @@ -0,0 +1,245 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// aliasRegWith opens a fresh registry and seeds it with routes, the same |
| +// pattern reconcile_test.go's regWith uses (that helper lives in the |
| +// domains_test package, unreachable from here). |
| +func aliasRegWith(t *testing.T, routes ...registry.Route) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return reg |
| +} |
| + |
| +// putClaim writes c's JSON straight to its ClaimKey, bypassing AttachClaim's |
| +// CAS dance — the reconcile pass reads whatever is in the bucket, not just |
| +// what AttachClaim would have produced. |
| +func putClaim(t *testing.T, s release.Store, c Claim) { |
| + t.Helper() |
| + c.Schema = 1 |
| + body, err := json.Marshal(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), ClaimKey(c.Host), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func aliasNow() func() time.Time { |
| + return func() time.Time { return time.Unix(1755000000, 0) } |
| +} |
| + |
| +func TestReconcileAliasesMaterializes(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 1 { |
| + t.Fatalf("got %d aliases, want 1: %+v", len(aliases), aliases) |
| + } |
| + a := aliases[0] |
| + if a.Alias != "tickets.example.com" || a.Host != "canary/live" || |
| + a.Account != "bab" || a.App != "kass" || a.CreatedAt != 1755000000 { |
| + t.Fatalf("alias row mismatch: %+v", a) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesOwnershipGate(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + // Claim names the right route but the wrong app. |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "somethingelse", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesOwnershipGateAccountMismatch(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + // Route's account is a VALID sqid ("bad") so release.RouteAccount |
| + // returns it verbatim rather than falling back to OpsAccount — the |
| + // mismatch must come from the comparison itself, not an invalid-account |
| + // fallback masking it. |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bad", Kind: "instance", Status: "ready", |
| + }) |
| + // Claim names the right route and the right app, but a different |
| + // (also valid) account. |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (account mismatch must refuse): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesRoutesWin(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, |
| + registry.Route{Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready"}, |
| + registry.Route{Host: "tickets.example.com", App: "other", Account: "bab", Kind: "instance", Status: "ready"}, |
| + ) |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (a real route owns that host): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesMissingRouteSkips(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) // no routes at all — this box doesn't have the route |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesRemovesOrphans(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: "orphan.example.com", Host: "canary/live", Account: "bab", App: "kass", |
| + CreatedAt: 1000, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No claim backs orphan.example.com. |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (orphan should have been removed): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesRouteless(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + // A first-route claim (#82's case): no Route set yet. |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(aliases) != 0 { |
| + t.Fatalf("got %d aliases, want 0 (routeless claim must be ignored): %+v", len(aliases), aliases) |
| + } |
| +} |
| + |
| +func TestReconcileAliasesIdempotent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t, registry.Route{ |
| + Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready", |
| + }) |
| + putClaim(t, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + }) |
| + if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(first) != 1 { |
| + t.Fatalf("got %d aliases after first pass, want 1: %+v", len(first), first) |
| + } |
| + firstCreatedAt := first[0].CreatedAt |
| + |
| + // Second pass, later clock: CreatedAt must be preserved, not bumped. |
| + later := func() time.Time { return time.Unix(1755000000+3600, 0) } |
| + if err := ReconcileAliases(context.Background(), s, reg, later); err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(second) != 1 { |
| + t.Fatalf("got %d aliases after second pass, want 1: %+v", len(second), second) |
| + } |
| + if second[0].CreatedAt != firstCreatedAt { |
| + t.Fatalf("CreatedAt changed on re-run: first=%d second=%d", firstCreatedAt, second[0].CreatedAt) |
| + } |
| + if second[0].Alias != first[0].Alias || second[0].Host != first[0].Host || |
| + second[0].Account != first[0].Account || second[0].App != first[0].App { |
| + t.Fatalf("alias row changed on re-run: first=%+v second=%+v", first[0], second[0]) |
| + } |
| +} |
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| new file mode 100644 |
| index 000000000..7b00329c4 |
| --- /dev/null |
| +++ b/internal/domains/claims.go |
| @@ -0,0 +1,251 @@ |
| +// Global hostname claims — the desired-state object behind attaching an |
| +// extra hostname to a route (#151). A claim lives at ClaimKey(host), the |
| +// SAME key regardless of which account or app is asking, so the bucket key |
| +// itself is the cross-account uniqueness gate: two accounts racing for the |
| +// same host cannot both win, because there is only ever one object to Put. |
| +// Later tasks derive registry rows from these claims; nothing here talks to |
| +// the registry. |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "regexp" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ClaimsPrefix is where every hostname claim lives. |
| +const ClaimsPrefix = "domains/" |
| + |
| +// ClaimKey is the bucket key for host's claim. |
| +func ClaimKey(host string) string { return ClaimsPrefix + host + ".json" } |
| + |
| +// Claim is the wire shape at ClaimKey(Host) — the global record of who owns |
| +// an extra hostname attached to a route. Route is omitempty because a claim |
| +// can exist ahead of the route it will back (reserved but not yet wired); |
| +// CreatedBy is omitempty because a claim minted by a box-side process, not a |
| +// human door, has no operator to name. |
| +type Claim struct { |
| + Schema int `json:"schema"` // 1 |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Route string `json:"route,omitempty"` |
| + CreatedAt string `json:"created_at"` |
| + CreatedBy string `json:"created_by,omitempty"` |
| + // Wildcard and Catchall are the woodstar-carloku branch's two claim |
| + // capabilities (its migration spec §4-5 gives them meaning; nothing in |
| + // this package acts on either). They are deliberately orthogonal: |
| + // Wildcard opts the domain into a delegated-ACME wildcard cert (one |
| + // *.<host> cert covering named aliases, no per-host CT-logged certs); |
| + // Catchall derives the *.<host> catch-all alias entry to the primary |
| + // route, and the attach surface refuses it without Wildcard (a |
| + // catch-all nobody can serve TLS for is a config error, not a state). |
| + // Both are declared here so a converge rewrite by this binary |
| + // round-trips them instead of stripping fields it doesn't know — the |
| + // additive-wire rule applied to writers, not just readers. Converge |
| + // preserves only DECLARED fields (json.Unmarshal drops unknown ones), |
| + // which is exactly why any future claim field must be added here in |
| + // the same change that first writes it. |
| + Wildcard bool `json:"wildcard,omitempty"` |
| + Catchall bool `json:"catchall,omitempty"` |
| +} |
| + |
| +// ErrHostUnavailable is returned by AttachClaim when host is already claimed |
| +// by a different account or app. Deliberately neutral: it never names the |
| +// current holder, so a caller cannot use this door to discover who owns a |
| +// hostname they do not. |
| +var ErrHostUnavailable = errors.New("hostname unavailable") |
| + |
| +// getClaimETag reads and parses host's claim, carrying the etag it was read |
| +// under. Not-found stays os.ErrNotExist-wrapped. A schema newer than this |
| +// binary understands is refused rather than silently truncated by |
| +// json.Unmarshal — the GetManifestETag precedent (provision.go). |
| +func getClaimETag(ctx context.Context, s release.Store, host string) (*Claim, string, error) { |
| + raw, etag, err := s.Get(ctx, ClaimKey(host)) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + var c Claim |
| + if err := json.Unmarshal(raw, &c); err != nil { |
| + return nil, "", fmt.Errorf("domains: claim %s: %w", host, err) |
| + } |
| + if c.Schema > 1 { |
| + return nil, "", fmt.Errorf("domains: claim %s: schema %d is newer than this binary understands — upgrade carlos", host, c.Schema) |
| + } |
| + return &c, etag, nil |
| +} |
| + |
| +// GetClaim reads host's claim. Not-found stays os.ErrNotExist-wrapped. |
| +func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) { |
| + c, _, err := getClaimETag(ctx, s, host) |
| + return c, err |
| +} |
| + |
| +// AttachClaim writes host's global claim, create-only by key: the bucket |
| +// key IS the cross-account uniqueness gate. Same owner re-attaching is |
| +// idempotent (converging Route in place); any other owner gets |
| +// ErrHostUnavailable — deliberately neutral, never naming the holder. |
| +// |
| +// created reports that this call brought the claim into existence; changed |
| +// reports that it WROTE — a create or a route converge, and false for the |
| +// pure no-op where the stored claim already said what the caller asked for. |
| +// The two are separate answers because they drive different things: created |
| +// is what the door tells the caller, changed is what the door audits and |
| +// bumps control/serial on. Callers that re-attach on a loop (titogo's router |
| +// re-attaches every hostname on each reload pass) make no-op attaches the |
| +// steady state, and a serial that moved for a call that wrote nothing would |
| +// send every box in the fleet looking for a change that isn't there. |
| +func AttachClaim(ctx context.Context, s release.Store, c Claim) (created, changed bool, err error) { |
| + c.Schema = 1 |
| + body, err := json.Marshal(c) |
| + if err != nil { |
| + return false, false, err |
| + } |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, c.Host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil { |
| + return true, true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // lost the create race — re-read and judge the winner |
| + } else { |
| + return false, false, err |
| + } |
| + } else if err != nil { |
| + return false, false, err |
| + } |
| + if cur.Account != c.Account || cur.App != c.App { |
| + return false, false, ErrHostUnavailable |
| + } |
| + if cur.Route == c.Route { |
| + return false, false, nil // nothing to write: this call is a no-op |
| + } |
| + // Converge repoints Route and nothing else: the stored claim keeps |
| + // its CreatedAt (the claim's age) and Wildcard (TLS opt-in state a |
| + // route repoint has no business clearing). Writing the caller's |
| + // whole claim here would strip both. |
| + next := *cur |
| + next.Route = c.Route |
| + nbody, err := json.Marshal(next) |
| + if err != nil { |
| + return false, false, err |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), nbody, etag); err == nil { |
| + return false, true, nil // not a create, but a real write |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // converge race — one retry, then surface |
| + } else { |
| + return false, false, err |
| + } |
| + } |
| +} |
| + |
| +// DetachClaim removes host's claim, but only for the owner that holds it: |
| +// a mismatched account/app is quietly ignored (removed=false, nil error) |
| +// 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. |
| +func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (bool, error) { |
| + cur, err := GetClaim(ctx, s, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != account || cur.App != app { |
| + return false, nil |
| + } |
| + if err := s.Delete(ctx, ClaimKey(host)); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// ListClaims returns every valid claim in the bucket. An unparseable or |
| +// newer-schema object is logged and skipped — one bad claim must never take |
| +// the whole listing down. |
| +func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) { |
| + keys, err := s.List(ctx, ClaimsPrefix) |
| + if err != nil { |
| + return nil, fmt.Errorf("domains: list claims: %w", err) |
| + } |
| + var claims []Claim |
| + for _, key := range keys { |
| + raw, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("domains: claim %s: %v", key, err) |
| + continue |
| + } |
| + var c Claim |
| + if err := json.Unmarshal(raw, &c); err != nil { |
| + log.Printf("domains: claim %s: unparseable: %v", key, err) |
| + continue |
| + } |
| + if c.Schema > 1 { |
| + log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema) |
| + continue |
| + } |
| + claims = append(claims, c) |
| + } |
| + return claims, nil |
| +} |
| + |
| +// hostLabelRe mirrors provision.labelRe (which itself mirrors |
| +// domains.accountNameRe — see that var's comment) — pinned HERE rather than |
| +// imported from internal/provision, because internal/provision/reconcile.go |
| +// already imports internal/domains for account/route materialization; |
| +// importing provision.ValidHostShape back would be a cycle. Same DNS label |
| +// rule: lowercase a-z, 0-9, hyphen, no leading/trailing hyphen, 1-63 chars. |
| +var hostLabelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| + |
| +// validHostShape is provision.ValidHostShape's check, duplicated for the |
| +// import-cycle reason hostLabelRe explains: every label of host must match |
| +// hostLabelRe, and the whole host must fit in the DNS 253-character ceiling. |
| +func validHostShape(host string) bool { |
| + if host == "" || len(host) > 253 { |
| + return false |
| + } |
| + for _, label := range strings.Split(host, ".") { |
| + if !hostLabelRe.MatchString(label) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// NormalizeAliasHost lowercases raw, strips one trailing dot, and validates |
| +// the result: validHostShape (the same label rules a route host is held to) |
| +// AND at least one interior dot — a bare label like "localhost" is never a |
| +// public hostname, however it parses. |
| +func NormalizeAliasHost(raw string) (string, bool) { |
| + h := strings.ToLower(raw) |
| + h = strings.TrimSuffix(h, ".") |
| + if !validHostShape(h) || !strings.Contains(h, ".") { |
| + return "", false |
| + } |
| + return h, true |
| +} |
| + |
| +// HostDenied reports whether host equals any entry in deny, or is a |
| +// subdomain of one (ends in "."+entry). Empty entries are skipped, so a |
| +// deny list padded with "" (e.g. from a blank config line) can never match |
| +// every host. |
| +func HostDenied(host string, deny []string) bool { |
| + for _, entry := range deny { |
| + if entry == "" { |
| + continue |
| + } |
| + if host == entry || strings.HasSuffix(host, "."+entry) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go| new file mode 100644 |
| index 000000000..078f10ed4 |
| --- /dev/null |
| +++ b/internal/domains/claims_test.go |
| @@ -0,0 +1,256 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestAttachClaimCreates(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + created, changed, err := AttachClaim(ctx, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", |
| + Route: "canary/live", CreatedAt: "2026-08-12T00:00:00Z", CreatedBy: "paul@tito.io", |
| + }) |
| + if err != nil || !created || !changed { |
| + t.Fatalf("AttachClaim: created=%v changed=%v err=%v, want true true nil", created, changed, err) |
| + } |
| + got, err := GetClaim(ctx, s, "tickets.example.com") |
| + if err != nil { |
| + t.Fatalf("GetClaim: %v", err) |
| + } |
| + if got.Schema != 1 || got.Host != "tickets.example.com" || got.Account != "bab" || |
| + got.App != "kass" || got.Route != "canary/live" || got.CreatedAt != "2026-08-12T00:00:00Z" || |
| + got.CreatedBy != "paul@tito.io" { |
| + t.Fatalf("GetClaim round-trip mismatch: %+v", got) |
| + } |
| +} |
| + |
| +func TestAttachClaimIdempotent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| + if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // changed=false is what makes this a NO-OP rather than a write: the door |
| + // audits and bumps control/serial off that answer, and a re-attach on |
| + // every router reload pass is the steady state, not the exception. |
| + created, changed, err := AttachClaim(ctx, s, claim) |
| + if err != nil || created || changed { |
| + t.Fatalf("AttachClaim re-attach: created=%v changed=%v err=%v, want false false nil", created, changed, err) |
| + } |
| + after, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if *before != *after { |
| + t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after) |
| + } |
| +} |
| + |
| +func TestAttachClaimConvergesRoute(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live", |
| + CreatedAt: "2026-08-12T00:00:00Z", Wildcard: true, Catchall: true} |
| + if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A converge repoints Route and nothing else: the caller's claim here |
| + // deliberately carries no CreatedAt and no Wildcard, and both stored |
| + // values must survive — a route repoint must never strip another |
| + // branch's TLS opt-in or restamp the claim's age. created=false but |
| + // changed=TRUE: a converge IS a store write, so the door must audit it |
| + // and announce it to the fleet. |
| + created, changed, err := AttachClaim(ctx, s, Claim{Host: claim.Host, Account: "bab", App: "kass", Route: "canary/next"}) |
| + if err != nil || created || !changed { |
| + t.Fatalf("AttachClaim route change: created=%v changed=%v err=%v, want false true nil", created, changed, err) |
| + } |
| + got, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Route != "canary/next" { |
| + t.Fatalf("Route = %q, want canary/next", got.Route) |
| + } |
| + if !got.Wildcard || !got.Catchall || got.CreatedAt != "2026-08-12T00:00:00Z" { |
| + t.Fatalf("converge must preserve Wildcard+Catchall+CreatedAt, got wildcard=%v catchall=%v created_at=%q", |
| + got.Wildcard, got.Catchall, got.CreatedAt) |
| + } |
| +} |
| + |
| +func TestAttachClaimOtherOwnerRefused(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| + if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + other := claim |
| + other.Account = "bad" |
| + if _, _, err := AttachClaim(ctx, s, other); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("other account: err = %v, want ErrHostUnavailable", err) |
| + } |
| + |
| + otherApp := claim |
| + otherApp.App = "console" |
| + if _, _, err := AttachClaim(ctx, s, otherApp); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("other app: err = %v, want ErrHostUnavailable", err) |
| + } |
| + |
| + after, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if *before != *after { |
| + t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after) |
| + } |
| +} |
| + |
| +func TestAttachClaimNewerSchemaRefused(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + body := []byte(`{"schema":2,"host":"tickets.example.com","account":"bab","app":"kass"}`) |
| + if _, err := s.Put(ctx, ClaimKey("tickets.example.com"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, err := AttachClaim(ctx, s, Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}) |
| + if err == nil || !strings.Contains(err.Error(), "schema") { |
| + t.Fatalf("AttachClaim over newer schema: err = %v, want error mentioning schema", err) |
| + } |
| + got, _, gerr := s.Get(ctx, ClaimKey("tickets.example.com")) |
| + if gerr != nil { |
| + t.Fatal(gerr) |
| + } |
| + if string(got) != string(body) { |
| + t.Fatalf("object changed on newer-schema refusal: got %s", got) |
| + } |
| +} |
| + |
| +func TestDetachClaimRemoves(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"} |
| + if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + removed, err := DetachClaim(ctx, s, claim.Host, claim.Account, claim.App) |
| + if err != nil || !removed { |
| + t.Fatalf("DetachClaim: removed=%v err=%v, want true nil", removed, err) |
| + } |
| + if _, err := GetClaim(ctx, s, claim.Host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("GetClaim after detach: err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +func TestDetachClaimUnknownQuiet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + removed, err := DetachClaim(ctx, s, "nope.example.com", "bab", "kass") |
| + if err != nil || removed { |
| + t.Fatalf("DetachClaim unknown: removed=%v err=%v, want false nil", removed, err) |
| + } |
| +} |
| + |
| +func TestDetachClaimOtherOwnerQuiet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"} |
| + if _, _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + removed, err := DetachClaim(ctx, s, claim.Host, "bad", "console") |
| + if err != nil || removed { |
| + t.Fatalf("DetachClaim other owner: removed=%v err=%v, want false nil", removed, err) |
| + } |
| + if _, err := GetClaim(ctx, s, claim.Host); err != nil { |
| + t.Fatalf("GetClaim after other-owner detach: %v, want claim still present", err) |
| + } |
| +} |
| + |
| +func TestListClaimsSkipsGarbage(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, _, err := AttachClaim(ctx, s, Claim{Host: "a.example.com", Account: "bab", App: "kass"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := AttachClaim(ctx, s, Claim{Host: "b.example.com", Account: "bad", App: "console"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ClaimKey("garbage.example.com"), []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + claims, err := ListClaims(ctx, s) |
| + if err != nil { |
| + t.Fatalf("ListClaims: %v", err) |
| + } |
| + if len(claims) != 2 { |
| + t.Fatalf("ListClaims returned %d claims, want 2: %+v", len(claims), claims) |
| + } |
| + hosts := map[string]bool{} |
| + for _, c := range claims { |
| + hosts[c.Host] = true |
| + } |
| + if !hosts["a.example.com"] || !hosts["b.example.com"] { |
| + t.Fatalf("ListClaims missing expected hosts: %+v", claims) |
| + } |
| +} |
| + |
| +func TestNormalizeAliasHost(t *testing.T) { |
| + cases := []struct { |
| + raw string |
| + want string |
| + ok bool |
| + }{ |
| + {"Tickets.Example.COM.", "tickets.example.com", true}, |
| + {"localhost", "", false}, |
| + {"a..b", "", false}, |
| + {"", "", false}, |
| + {strings.Repeat("a", 254), "", false}, |
| + } |
| + for _, c := range cases { |
| + got, ok := NormalizeAliasHost(c.raw) |
| + if ok != c.ok { |
| + t.Errorf("NormalizeAliasHost(%q) ok = %v, want %v", c.raw, ok, c.ok) |
| + continue |
| + } |
| + if ok && got != c.want { |
| + t.Errorf("NormalizeAliasHost(%q) = %q, want %q", c.raw, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestHostDenied(t *testing.T) { |
| + deny := []string{"oncarlos.com"} |
| + cases := []struct { |
| + host string |
| + want bool |
| + }{ |
| + {"x.oncarlos.com", true}, |
| + {"oncarlos.com", true}, |
| + {"xoncarlos.com", false}, |
| + } |
| + for _, c := range cases { |
| + if got := HostDenied(c.host, deny); got != c.want { |
| + t.Errorf("HostDenied(%q, %v) = %v, want %v", c.host, deny, got, c.want) |
| + } |
| + } |
| + if HostDenied("anything.example.com", []string{"", "oncarlos.com"}) { |
| + t.Fatal("empty deny entry must never match") |
| + } |
| +} |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index f879b461e..c5d4f72f5 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -406,6 +406,19 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| routes = kept |
| + // aliases are the extra hostnames route_aliases holds (Task 2/#151), |
| + // health-stamped by sections 4 and 5 below exactly like the routes they |
| + // point at. Listed ONCE, above both sections, and guarded the same way |
| + // each section already guards itself — a box with neither Resolve nor |
| + // AcmeDir configured pays no aliases query at all. |
| + var aliases []registry.RouteAlias |
| + if cfg.Resolve != nil || cfg.AcmeDir != "" { |
| + aliases, err = reg.ListAliases() |
| + if err != nil { |
| + return fmt.Errorf("domains: list aliases: %w", err) |
| + } |
| + } |
| + |
| // --- 4. DNS health -------------------------------------------------------- |
| if cfg.Resolve != nil { |
| edge := map[string]bool{} |
| @@ -414,20 +427,17 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| now := cfg.Now().Unix() |
| for _, rt := range routes { |
| - status := "" // resolver failure is UNKNOWN, never a guess |
| - if ips, err := cfg.Resolve(rt.Host); err == nil { |
| - status = "elsewhere" |
| - for _, ip := range ips { |
| - if edge[ip] { |
| - status = "pointing" |
| - break |
| - } |
| - } |
| - } |
| + status := dnsStatus(cfg.Resolve, rt.Host, edge) |
| if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| log.Printf("domains: stamp dns on %s: %v", rt.Host, err) |
| } |
| } |
| + for _, a := range aliases { |
| + status := dnsStatus(cfg.Resolve, a.Alias, edge) |
| + if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| + log.Printf("domains: stamp dns on alias %s: %v", a.Alias, err) |
| + } |
| + } |
| } |
| // --- 5. TLS health -------------------------------------------------------- |
| @@ -438,10 +448,33 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err) |
| } |
| } |
| + for _, a := range aliases { |
| + at := hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain) |
| + if err := reg.SetAliasCertExpiresAt(a.Alias, at); err != nil { |
| + log.Printf("domains: stamp cert expiry on alias %s: %v", a.Alias, err) |
| + } |
| + } |
| } |
| return nil |
| } |
| +// dnsStatus resolves host through resolve and classifies the answer against |
| +// edge: a resolver failure is UNKNOWN ("") — never a guess — an edge-IP match |
| +// is "pointing", anything else is "elsewhere". Shared by the route and alias |
| +// loops in section 4 so the two verdicts can never drift apart. |
| +func dnsStatus(resolve func(host string) ([]string, error), host string, edge map[string]bool) string { |
| + ips, err := resolve(host) |
| + if err != nil { |
| + return "" |
| + } |
| + for _, ip := range ips { |
| + if edge[ip] { |
| + return "pointing" |
| + } |
| + } |
| + return "elsewhere" |
| +} |
| + |
| // OpsSqid is the built-in ops account's frozen sqid (registry id 1). Every |
| // deployment has it, console or not. |
| var OpsSqid = Sqid(1) |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 4c25b21b0..ab3b8b652 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -624,6 +624,83 @@ func TestReconcileStampsCertExpiry(t *testing.T) { |
| } |
| } |
| +// aliasByName finds an alias row by hostname among reg.ListAliases(), the |
| +// same read-back-from-the-registry shape mustGet uses for routes. |
| +func aliasByName(t *testing.T, reg *registry.Registry, alias string) registry.RouteAlias { |
| + t.Helper() |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range aliases { |
| + if a.Alias == alias { |
| + return a |
| + } |
| + } |
| + t.Fatalf("alias %s missing", alias) |
| + return registry.RouteAlias{} |
| +} |
| + |
| +func TestReconcileStampsAliasDNS(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "app.test", Kind: "static", Status: "ready"}, |
| + ) |
| + for _, a := range []string{"points-alias.test", "elsewhere-alias.test", "broken-alias.test"} { |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: a, Host: "app.test"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + c := cfg(t) |
| + c.Resolve = func(host string) ([]string, error) { |
| + switch host { |
| + case "points-alias.test": |
| + return []string{"198.51.100.9", "203.0.113.7"}, nil // edge IP among others |
| + case "elsewhere-alias.test": |
| + return []string{"198.51.100.9"}, nil |
| + default: |
| + return nil, errors.New("NXDOMAIN or worse") |
| + } |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := c.Now().Unix() |
| + for alias, want := range map[string]string{ |
| + "points-alias.test": "pointing", |
| + "elsewhere-alias.test": "elsewhere", |
| + "broken-alias.test": "", // resolver failure is UNKNOWN, never a guess |
| + } { |
| + got := aliasByName(t, reg, alias) |
| + if got.DNSStatus != want { |
| + t.Errorf("%s dns_status = %q, want %q", alias, got.DNSStatus, want) |
| + } |
| + if got.DNSCheckedAt != now { |
| + t.Errorf("%s dns_checked_at = %d, want %d", alias, got.DNSCheckedAt, now) |
| + } |
| + } |
| +} |
| + |
| +func TestReconcileStampsAliasCert(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + ) |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "kass-alias.training", Host: "kass.training"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + acmeDir := t.TempDir() |
| + notAfter := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) |
| + writeCertBundle(t, acmeDir, "kass-alias.training", notAfter) |
| + c := cfg(t) |
| + c.AcmeDir = acmeDir |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := aliasByName(t, reg, "kass-alias.training"); got.CertExpiresAt != notAfter.Unix() { |
| + t.Errorf("kass-alias.training cert_expires_at = %d, want %d", got.CertExpiresAt, notAfter.Unix()) |
| + } |
| +} |
| + |
| func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
diff --git a/internal/edge/alias_test.go b/internal/edge/alias_test.go| new file mode 100644 |
| index 000000000..0951bc54f |
| --- /dev/null |
| +++ b/internal/edge/alias_test.go |
| @@ -0,0 +1,277 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "slices" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// upsertAlias writes one route_aliases row, the shape the reconcile pass |
| +// (internal/domains) derives from a claim record. The edge only ever reads |
| +// Alias and Host; account/app are carried for realism. |
| +func upsertAlias(t *testing.T, reg *registry.Registry, alias, host string) { |
| + t.Helper() |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: alias, Host: host, Account: "acct", App: "app", CreatedAt: 1, |
| + }); err != nil { |
| + t.Fatalf("upsert alias %s -> %s: %v", alias, host, err) |
| + } |
| +} |
| + |
| +// TestAliasServesPrimaryBackend is the feature itself: a hostname attached to |
| +// a ready route reaches that route's backend, with the customer's own Host |
| +// header preserved end to end (the app must see the name the browser asked |
| +// for, not the platform hostname). |
| +func TestAliasServesPrimaryBackend(t *testing.T) { |
| + backend, seenHost := hostEchoBackend(t, "hello from the primary") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + code, body := getHost(t, srv.URL, "www.customer.example") |
| + if code != http.StatusOK { |
| + t.Fatalf("status = %d, want 200 for an attached hostname", code) |
| + } |
| + if body != "hello from the primary" { |
| + t.Fatalf("body = %q, want the primary route's backend body", body) |
| + } |
| + if *seenHost != "www.customer.example" { |
| + t.Fatalf("backend saw Host %q, want www.customer.example (inbound Host must be preserved)", *seenHost) |
| + } |
| +} |
| + |
| +// TestAliasSharesUpstreamEntry is the composition pin (#152, multi-upstream |
| +// HA): the alias must map to the primary's OWN entry — the identical proxy |
| +// handler value and the primary's route row — never a proxy rebuilt for the |
| +// alias hostname. Everything downstream is keyed off route.Host (activator |
| +// entry, lease, live symlink, cgroup) and off the proxy value's per-primary |
| +// state, so a second handler would silently fork both. |
| +func TestAliasSharesUpstreamEntry(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "primary") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + m := e.current() |
| + primary, ok := m["app.acct.oncarlos.com"] |
| + if !ok { |
| + t.Fatalf("primary route missing from the upstream table") |
| + } |
| + alias, ok := m["www.customer.example"] |
| + if !ok { |
| + t.Fatalf("alias missing from the upstream table") |
| + } |
| + // Identity, not equivalent behaviour: the route carries no Version, so the |
| + // handler is the *httputil.ReverseProxy pointer itself and == compares the |
| + // pointer, which is exactly the claim being pinned. |
| + if alias.proxy != primary.proxy { |
| + t.Fatalf("alias proxy %p is not the primary's proxy %p — an alias must never get a rebuilt handler", alias.proxy, primary.proxy) |
| + } |
| + if alias.route.Host != "app.acct.oncarlos.com" { |
| + t.Fatalf("alias route.Host = %q, want the PRIMARY's host (activator/version/purge keying all read it)", alias.route.Host) |
| + } |
| + if !alias.alias { |
| + t.Fatalf("alias entry not marked alias — wildcard parent derivation would treat a customer domain as a platform host") |
| + } |
| + if primary.alias { |
| + t.Fatalf("primary entry marked alias") |
| + } |
| +} |
| + |
| +// TestAliasAllowed covers the autocert HostPolicy: an attached hostname is a |
| +// host this box serves, so it may be certed; a stranger's SNI may not. |
| +func TestAliasAllowed(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "primary") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + if !e.Allowed("www.customer.example") { |
| + t.Fatalf("Allowed(www.customer.example) = false, want true for an attached hostname") |
| + } |
| + if e.Allowed("stranger.example") { |
| + t.Fatalf("Allowed(stranger.example) = true, want false") |
| + } |
| +} |
| + |
| +// TestAliasNeverShadowsRoute: routes always win. An alias row whose hostname |
| +// collides with a real route must not displace it — the route's own backend |
| +// keeps answering. |
| +func TestAliasNeverShadowsRoute(t *testing.T) { |
| + one, _ := hostEchoBackend(t, "one") |
| + two, _ := hostEchoBackend(t, "two") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "one.example.com", Kind: "service", |
| + Addr: one.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert one: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "two.example.com", Kind: "service", |
| + Addr: two.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert two: %v", err) |
| + } |
| + // An alias claiming a hostname that is already a route of its own. |
| + upsertAlias(t, reg, "two.example.com", "one.example.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + code, body := getHost(t, srv.URL, "two.example.com") |
| + if code != http.StatusOK || body != "two" { |
| + t.Fatalf("got %d %q, want 200 \"two\" — a real route must win a collision with an alias", code, body) |
| + } |
| + if up := e.current()["two.example.com"]; up.alias { |
| + t.Fatalf("route entry for two.example.com was overwritten by the alias") |
| + } |
| +} |
| + |
| +// TestAliasSkipsMissingPrimary: an alias whose primary is not in the table |
| +// (not ready here, or not local to this box) is skipped entirely rather than |
| +// mapped to nothing — it must 404 as an unknown host and must not be certed. |
| +func TestAliasSkipsMissingPrimary(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "should never be reached") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "pending.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "pending", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "www.customer.example", "pending.acct.oncarlos.com") |
| + // And one pointing at a host with no route row at all. |
| + upsertAlias(t, reg, "gone.customer.example", "nosuch.acct.oncarlos.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + for _, host := range []string{"www.customer.example", "gone.customer.example"} { |
| + if code, _ := getHost(t, srv.URL, host); code != http.StatusNotFound { |
| + t.Fatalf("status for %s = %d, want 404 (alias with no ready primary)", host, code) |
| + } |
| + if e.Allowed(host) { |
| + t.Fatalf("Allowed(%s) = true, want false — no certificate for an alias to nowhere", host) |
| + } |
| + } |
| +} |
| + |
| +// routeRecordingActivator records the route each wake was handed, so a test |
| +// can prove which host the activator was keyed on. |
| +type routeRecordingActivator struct { |
| + routes []registry.Route |
| +} |
| + |
| +func (a *routeRecordingActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + a.routes = append(a.routes, rt) |
| + proxy.ServeHTTP(w, r) // "wake" is instant; proxy straight through |
| + return true |
| +} |
| + |
| +func (a *routeRecordingActivator) RetireExcept(keep map[string]bool) {} |
| + |
| +// TestAliasWakesPrimaryOnce is the #129-class regression pin: a request to an |
| +// attached hostname wakes the PRIMARY's single activator entry. The activator |
| +// keys its entry, lease, live symlink and cgroup on route.Host, so an alias |
| +// arriving with its own host would start a second copy of the app beside the |
| +// first and fight it for the socket. |
| +func TestAliasWakesPrimaryOnce(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "woken") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "hib.acct.oncarlos.com", Kind: "instance", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "www.customer.example", "hib.acct.oncarlos.com") |
| + |
| + act := &routeRecordingActivator{} |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + code, body := getHost(t, srv.URL, "www.customer.example") |
| + if code != http.StatusOK || body != "woken" { |
| + t.Fatalf("got %d %q, want 200 woken", code, body) |
| + } |
| + if len(act.routes) != 1 { |
| + t.Fatalf("activator saw %d wakes, want exactly 1", len(act.routes)) |
| + } |
| + if got := act.routes[0].Host; got != "hib.acct.oncarlos.com" { |
| + t.Fatalf("activator was handed route.Host = %q, want the primary hib.acct.oncarlos.com", got) |
| + } |
| +} |
| + |
| +// TestCurrentHostsSkipsAliases: wildcard parent derivation reads currentHosts, |
| +// so a customer hostname riding a platform-minted primary must never appear |
| +// there — otherwise a name like x.y.<apps-domain> could mint a wildcard parent |
| +// and burn an ACME issuance on a domain the wildcard never covered. |
| +func TestCurrentHostsSkipsAliases(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "primary") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", Platform: true, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + upsertAlias(t, reg, "shop.acct.oncarlos.com", "app.acct.oncarlos.com") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + got := e.currentHosts() |
| + if !slices.Contains(got, "app.acct.oncarlos.com") { |
| + t.Fatalf("currentHosts = %v, want it to contain the platform-minted primary", got) |
| + } |
| + if slices.Contains(got, "shop.acct.oncarlos.com") { |
| + t.Fatalf("currentHosts = %v, must NOT contain an attached hostname", got) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 16cbeaae6..7bd670660 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -150,6 +150,12 @@ type upstream struct { |
| // whether its process is live (it's the edge's child), so "dead backend" |
| // detection is the activator's state table, not a failed dial. |
| hibernating bool |
| + // alias marks an entry mapped in from route_aliases: it shares the |
| + // primary's proxy/route/hibernating verbatim (so hibernation wakes the |
| + // primary's actEntry and #152's per-primary prober state applies), and |
| + // the flag exists so wildcard parent derivation (currentHosts) never |
| + // treats a customer hostname as a platform host. |
| + alias bool |
| } |
| // New builds an Edge over reg and performs the startup reload. act may be nil |
| @@ -418,6 +424,29 @@ func (e *Edge) Reload() error { |
| } |
| m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib} |
| } |
| + // Attached hostnames (#151): each alias resolves to the primary's OWN |
| + // entry — same proxy value, same route (so activator keying, version |
| + // header, and purge tenancy are all the primary's). Routes always win |
| + // a collision; an alias whose primary isn't in m (not ready, not |
| + // local) is skipped — never a 502 route to nowhere. Errors abort the |
| + // reload like every other registry read here: a transient failure must |
| + // leave the old table serving. |
| + aliases, err := e.reg.ListAliases() |
| + if err != nil { |
| + return err |
| + } |
| + for _, a := range aliases { |
| + if _, taken := m[a.Alias]; taken { |
| + log.Printf("edge: alias %s shadowed by a real route — skipped", a.Alias) |
| + continue |
| + } |
| + up, ok := m[a.Host] |
| + if !ok { |
| + continue |
| + } |
| + up.alias = true |
| + m[a.Alias] = up |
| + } |
| // Fleet channel auth: registry-backed, so this refreshes on the SAME poll |
| // that rebuilds routes — Task 1's Fingerprint moves on every fleet |
| // mutation (including token rotation), and the edge's existing 2s |
| @@ -488,11 +517,16 @@ func (e *Edge) current() map[string]upstream { |
| // never meant to cover. Platform-minted app routes set Platform:true; static |
| // parents (e.g. tito's go.tito.io) come from CARLOS_WILDCARD_DOMAINS via env, |
| // so they are unaffected by this filter. |
| +// |
| +// Attached hostnames (#151) are excluded by the !up.alias half: an alias |
| +// entry carries the PRIMARY's route row verbatim, Platform flag included, so |
| +// without the flag a customer domain would inherit its platform primary's |
| +// trust and mint a parent for a zone the platform doesn't own. |
| func (e *Edge) currentHosts() []string { |
| m := e.current() |
| out := make([]string, 0, len(m)) |
| for h, up := range m { |
| - if up.route.Platform { |
| + if up.route.Platform && !up.alias { |
| out = append(out, h) |
| } |
| } |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index f96443f79..ad3a0d460 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -362,13 +362,24 @@ func canonicalPrefix(raw string) (string, bool) { |
| // the box (knownAccounts -> ParseRecordKey) |
| // apps/<sqid>/** its owning account's whole app estate |
| // fleets/<fleet>/** its own corner |
| +// domains/** every tenant's hostname claims (see below) |
| // |
| -// The last line is not in F3's enumeration and is not a widening of it: the |
| -// fleet's own prefix is the only place it may WRITE, and every write on this |
| -// wire is a CAS (read the etag, then Put with If-Match). A write scope you |
| -// cannot read is a write scope you cannot use — the binding takeover guard |
| -// and the heartbeat both read their own object first. It grants access to |
| -// nothing beyond what aclWrite already grants. |
| +// The fleets/<fleet>/** line is not in F3's enumeration and is not a |
| +// widening of it: the fleet's own prefix is the only place it may WRITE, and |
| +// every write on this wire is a CAS (read the etag, then Put with If-Match). |
| +// A write scope you cannot read is a write scope you cannot use — the |
| +// binding takeover guard and the heartbeat both read their own object first. |
| +// It grants access to nothing beyond what aclWrite already grants. |
| +// |
| +// domains/ is different in kind, not degree (#151): a claim record |
| +// (domains.Claim) is a hostname→account/app/route binding, and that binding |
| +// is cross-tenant-readable BY DESIGN — it is exactly the information DNS and |
| +// the serving edge already make public to anyone who resolves the hostname, |
| +// so scoping it to sqid or fleet would hide nothing and would only break |
| +// ReconcileAliases, the alias pass every box runs to reconcile its own |
| +// routes' extra hostnames against the global claim set. Write stays |
| +// console-only: aclWrite never answers true for a domains/ key, so this read |
| +// grant cannot be used to mint or steal a claim. |
| func aclRead(sqid, fleet, key string) bool { |
| if sqid == "" || fleet == "" { |
| return false |
| @@ -377,6 +388,9 @@ func aclRead(sqid, fleet, key string) bool { |
| case "control/serial", "bootstrap/carlos": |
| return true |
| } |
| + if strings.HasPrefix(key, "domains/") { |
| + return true |
| + } |
| return strings.HasPrefix(key, "accounts/"+sqid+"/") || |
| strings.HasPrefix(key, "apps/"+sqid+"/") || |
| strings.HasPrefix(key, "fleets/"+fleet+"/") |
| @@ -400,6 +414,15 @@ func aclDir(sqid, fleet, dir string) bool { |
| if dir == "accounts/"+sqid+"/" { |
| return true |
| } |
| + // domains/ (#151): cross-tenant-readable by the same reasoning as |
| + // aclRead, so every child prefix under it is in scope regardless of |
| + // tenant. ListClaims lists domains/ flat today (List, not ListDirs), so |
| + // this branch is not exercised in production yet — it exists so a future |
| + // nested claim layout does not silently lose read access the way |
| + // accounts/ almost did. |
| + if strings.HasPrefix(dir, "domains/") { |
| + return true |
| + } |
| return strings.HasPrefix(dir, "apps/"+sqid+"/") || |
| strings.HasPrefix(dir, "fleets/"+fleet+"/") |
| } |
| @@ -412,7 +435,11 @@ func aclDir(sqid, fleet, dir string) bool { |
| // |
| // It can never write a release artifact, a channel pointer, or an instance |
| // record, which is the tamper surface the whole spec exists to close (F6: |
| -// retention does not arm on a fleet box either). |
| +// retention does not arm on a fleet box either). It can never write a |
| +// domains/ claim either (#151): aclRead's cross-tenant grant there is |
| +// read-only by design — a claim is a console-mediated hostname reservation, |
| +// and a box that could write one could mint or steal any hostname on the |
| +// platform, not just its own account's. |
| // |
| // The logs scope is SEGMENT-EXACT on purpose, and the exactness is the |
| // security property: the grant is apps/<sqid>/<ONE app segment>/logs/**, never |
| @@ -495,6 +522,15 @@ func aclListScope(sqid, fleet, prefix string) (string, bool) { |
| return "", false |
| case prefix == "accounts" || prefix == "accounts/": |
| return "accounts/", true |
| + case prefix == "domains" || prefix == "domains/": |
| + // #151: the OTHER shared scope, and shared for the same reason as |
| + // accounts/ above but simpler — every claim is cross-tenant-readable |
| + // (aclRead), so unlike apps/ and fleets/ there is no per-tenant |
| + // subtree to narrow into. ReconcileAliases always lists the whole |
| + // prefix; a narrower ask (e.g. "domains/x.example.json") is refused |
| + // rather than widened, matching accounts/'s deliberate refusal of |
| + // "accounts/<sqid>/" — see that case's comment. |
| + return "domains/", true |
| case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet: |
| return ownFleet, true |
| case strings.HasPrefix(prefix, ownFleet): |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index a2eb8011d..3a0df9c7a 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -142,6 +142,14 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"}, |
| {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"}, |
| + // --- domains/ claims (#151): cross-tenant-readable by design, the |
| + // alias pass (ReconcileAliases) reads them on every box; console-only |
| + // write --- |
| + {"domains/app.example.com.json", opRead, true, "hostname claim, own or any other tenant's"}, |
| + {"domains/app.example.com.json", opWrite, false, "boxes read claims, only the console writes them"}, |
| + {"domains/app.example.com.json", opDelete, false, "nor delete them"}, |
| + {"domains", opRead, false, "an object AT the bare name, not the claims prefix"}, |
| + |
| // --- shapes canonicalKey must kill before the ACL is consulted --- |
| {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"}, |
| {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"}, |
| @@ -231,6 +239,12 @@ func TestACLListScope(t *testing.T) { |
| // this pin makes visible. |
| {"accounts/" + testSqid + "/", "", false}, |
| {"accounts/" + testSqid, "", false}, |
| + // domains/ (#151): the OTHER shared scope — every claim is |
| + // cross-tenant-readable, so (unlike apps/ and fleets/) there is no |
| + // per-tenant subtree to narrow into; only the bare prefix maps. |
| + {"domains/", "domains/", true}, |
| + {"domains", "domains/", true}, |
| + {"domains/app.example.com.json", "", false}, |
| {"fleets/", "fleets/pi/", true}, |
| {"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true}, |
| {"fleets/other/", "", false}, |
| @@ -264,6 +278,12 @@ func TestACLDirIsPerTenant(t *testing.T) { |
| "fleets/other/status/": false, |
| "fleets/" + testFleet + "x/y/": false, |
| "control/": false, |
| + // domains/ (#151): cross-tenant by design, so no tenant scoping to |
| + // pin here — every child prefix under it is in scope. ListClaims |
| + // lists domains/ flat (List, not ListDirs) today, so this path isn't |
| + // exercised in production; the row exists so a future nested claim |
| + // layout doesn't silently lose read access. |
| + "domains/anything/": true, |
| } |
| for dir, want := range cases { |
| if got := aclDir(testSqid, testFleet, dir); got != want { |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 4b6c310f8..b08dc9a83 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1115,13 +1115,16 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) { |
| reg: reg, store: store, pub: pub, optDir: optDir, |
| restart: restart, passOpts: opts, |
| // The serial-triggered full pass set, in spec order: mint routes |
| - // (reconcile), then stage them (adopt), then deliver their config. |
| - // Each error is one log line, never fatal — the hourly floors stand |
| - // underneath every leg. |
| + // (reconcile), then reconcile aliases, then stage them (adopt), then deliver |
| + // their config. Each error is one log line, never fatal — the hourly floors |
| + // stand underneath every leg. |
| fullPass: func(ctx context.Context) { |
| if err := reconcileSerialized(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil { |
| log.Printf("agent: fast converge reconcile: %v", err) |
| } |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil { |
| + log.Printf("agent: fast converge alias pass: %v", err) |
| + } |
| if err := release.Pass(ctx, reg, store, pub, optDir, restart, opts...); err != nil { |
| log.Printf("agent: fast converge adopt pass: %v", err) |
| } |
| @@ -1467,6 +1470,9 @@ func instancesLoop(reg *registry.Registry) { |
| if err := reconcileSerialized(ctx, store, reg, cfg); err != nil { |
| log.Printf("agent: instances pass: %v", err) |
| } |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil { |
| + log.Printf("agent: instances alias pass: %v", err) |
| + } |
| if !isFleetBox { |
| if err := ReconcileFleetBindings(ctx, store, reg); err != nil { |
| log.Printf("agent: fleet bindings reconcile: %v", err) |
| @@ -1831,6 +1837,18 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p |
| } |
| now := time.Now() |
| + // Aliases carries route_aliases' DNS/cert stamps (Task 2/5) up to the box |
| + // snapshot (issue #151) — read fresh alongside routes above rather than |
| + // folded into the caller's Probes in statusLoop, since reg is already in |
| + // scope here and a failed read must not sink the whole snapshot: logged |
| + // and left nil (CollectBox already treats nil the same as "no alias rows |
| + // exist"), same best-effort shape as every other probe in this loop. |
| + if aliases, err := reg.ListAliases(); err != nil { |
| + log.Printf("agent: status tick: list aliases: %v", err) |
| + } else { |
| + p.Aliases = aliases |
| + } |
| + |
| box := status.CollectBox(routes, p, now) |
| if labelOverride != "" { |
| box.Label = labelOverride |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index a0778d0b8..124ce77a6 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -333,6 +333,22 @@ CREATE TABLE IF NOT EXISTS routes ( |
| return err |
| } |
| } |
| + // route_aliases is a derived table (a later reconcile pass owns every |
| + // row, materializing domains.Claim records into it) — a plain CREATE |
| + // TABLE IF NOT EXISTS, no legacy shape to carry forward. |
| + if _, err := r.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS route_aliases ( |
| + alias TEXT PRIMARY KEY, |
| + host TEXT NOT NULL, |
| + account TEXT NOT NULL DEFAULT '', |
| + app TEXT NOT NULL DEFAULT '', |
| + dns_status TEXT NOT NULL DEFAULT '', |
| + dns_checked_at INTEGER NOT NULL DEFAULT 0, |
| + cert_expires_at INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL DEFAULT 0 |
| +);`); err != nil { |
| + return err |
| + } |
| if err := r.migrateAccounts(); err != nil { |
| return err |
| } |
| @@ -1167,9 +1183,88 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now) |
| return out, rows.Err() |
| } |
| -// Fingerprint summarizes the routes, fleets and fleet_boxes tables over a |
| -// fresh connection, for the edge's cheap change poll. It is content-accurate: |
| -// it hashes the actual VALUES of the reload-relevant columns, not a |
| +// RouteAlias is one derived row of the route_aliases table: an extra |
| +// hostname that resolves to an existing route's host, materialized from a |
| +// domains.Claim by a later reconcile pass (this package owns the storage |
| +// shape only — nothing here decides which claims win). Alias is the extra |
| +// hostname being attached; Host is the route it points at. DNSStatus, |
| +// DNSCheckedAt and CertExpiresAt are box-stamped health state, same shape as |
| +// the identically-named columns on routes, and are deliberately excluded |
| +// from Fingerprint (see there) so a stamping pass alone never forces an edge |
| +// reload. |
| +type RouteAlias struct { |
| + Alias string |
| + Host string |
| + Account string |
| + App string |
| + DNSStatus string |
| + DNSCheckedAt int64 |
| + CertExpiresAt int64 |
| + CreatedAt int64 |
| +} |
| + |
| +// ListAliases returns every route alias, ordered by alias. |
| +func (r *Registry) ListAliases() ([]RouteAlias, error) { |
| + rows, err := r.sql.Query(`SELECT alias, host, account, app, dns_status, |
| +dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []RouteAlias |
| + for rows.Next() { |
| + var a RouteAlias |
| + if err := rows.Scan(&a.Alias, &a.Host, &a.Account, &a.App, &a.DNSStatus, |
| + &a.DNSCheckedAt, &a.CertExpiresAt, &a.CreatedAt); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// UpsertAlias inserts or updates an alias row by its hostname. Only host, |
| +// account and app are overwritten on conflict — the DNS/cert stamps and |
| +// created_at are reconcile-pass- and box-stamp-owned state that a repoint |
| +// must not reset out from under the passes that maintain them (the same |
| +// preserved-column shape Upsert uses for routes, applied here by simply |
| +// leaving those columns out of the SET list rather than a CASE guard, since |
| +// every writer of this table already knows it must not carry stamps). |
| +func (r *Registry) UpsertAlias(a RouteAlias) error { |
| + _, err := r.sql.Exec(` |
| +INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at) |
| +VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| +ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, app=excluded.app`, |
| + a.Alias, a.Host, a.Account, a.App, a.DNSStatus, a.DNSCheckedAt, a.CertExpiresAt, a.CreatedAt) |
| + return err |
| +} |
| + |
| +// DeleteAlias removes an alias by its hostname. Idempotent: an alias already |
| +// gone is a success, same contract as Delete on routes. |
| +func (r *Registry) DeleteAlias(alias string) error { |
| + _, err := r.sql.Exec(`DELETE FROM route_aliases WHERE alias = ?`, alias) |
| + return err |
| +} |
| + |
| +// SetAliasDNSStatus stamps an alias's DNS health verdict + check time, |
| +// narrowly (same rationale as SetDNSStatus on routes: it lands on its own |
| +// cadence and must not race a concurrent UpsertAlias repoint). |
| +func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error { |
| + _, err := r.sql.Exec(`UPDATE route_aliases SET dns_status = ?, dns_checked_at = ? WHERE alias = ?`, status, at, alias) |
| + return err |
| +} |
| + |
| +// SetAliasCertExpiresAt stamps an alias's cached-cert NotAfter, narrowly. |
| +// 0 = unknown (same rationale as SetCertExpiresAt on routes). |
| +func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error { |
| + _, err := r.sql.Exec(`UPDATE route_aliases SET cert_expires_at = ? WHERE alias = ?`, at, alias) |
| + return err |
| +} |
| + |
| +// Fingerprint summarizes the routes, fleets, fleet_boxes and route_aliases |
| +// tables over a fresh connection, for the edge's cheap change poll. It is |
| +// content-accurate: it hashes the actual VALUES of the reload-relevant |
| +// columns, not a |
| // length-weighted aggregate, so two DIFFERENT values of the SAME length |
| // (channel canary/1 -> canary/2, a git short-sha version bump, a rotated |
| // sha256 token hash, a failover restamp box-a -> box-b) change the result |
| @@ -1293,5 +1388,28 @@ FROM fleet_boxes ORDER BY fleet, label`) |
| return "", err |
| } |
| + // route_aliases: only alias/host/account/app decide which hostnames the |
| + // edge routes and where. dns_status/dns_checked_at/cert_expires_at are |
| + // deliberately excluded here for the same reason the routes SELECT above |
| + // omits its own dns_status/cert_expires_at columns — a box-side pass |
| + // stamps them on a 5-minute cadence, and hashing a value that moves on |
| + // that cadence would force a pointless fleet-wide edge reload every pass. |
| + fmt.Fprint(h, "\x1droute_aliases\x1e") |
| + arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer arows.Close() |
| + for arows.Next() { |
| + var alias, host, account, app string |
| + if err := arows.Scan(&alias, &host, &account, &app); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1e", alias, host, account, app) |
| + } |
| + if err := arows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| } |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index c537e3415..0f8717b7b 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1834,3 +1834,189 @@ func TestSetPlacementUnknownHostErrors(t *testing.T) { |
| t.Fatal("SetPlacement on an unknown host: got nil error, want one") |
| } |
| } |
| + |
| +// TestRouteAliasCRUD pins UpsertAlias/ListAliases/DeleteAlias's contract: a |
| +// re-upsert repoints host/account/app but must NOT reset the DNS/cert stamps |
| +// or created_at — those are reconcile-pass-owned and box-stamped state, the |
| +// same shape as Upsert's preserved columns on routes. |
| +func TestRouteAliasCRUD(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertAlias(RouteAlias{ |
| + Alias: "extra.example.com", Host: "app.example.com", |
| + Account: "bab", App: "myapp", CreatedAt: 1000, |
| + }); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + got, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListAliases = %+v, want exactly one row", got) |
| + } |
| + if got[0].Alias != "extra.example.com" || got[0].Host != "app.example.com" || |
| + got[0].Account != "bab" || got[0].App != "myapp" || got[0].CreatedAt != 1000 { |
| + t.Fatalf("round-tripped alias = %+v, want the upserted values", got[0]) |
| + } |
| + |
| + // Stamp the row the way a box-side pass would, THEN re-upsert a repoint — |
| + // the stamps and created_at must survive. |
| + if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 2000); err != nil { |
| + t.Fatalf("SetAliasDNSStatus: %v", err) |
| + } |
| + if err := reg.SetAliasCertExpiresAt("extra.example.com", 3000); err != nil { |
| + t.Fatalf("SetAliasCertExpiresAt: %v", err) |
| + } |
| + if err := reg.UpsertAlias(RouteAlias{ |
| + Alias: "extra.example.com", Host: "other.example.com", |
| + Account: "bad", App: "otherapp", CreatedAt: 9999, |
| + }); err != nil { |
| + t.Fatalf("re-UpsertAlias: %v", err) |
| + } |
| + got, err = reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListAliases after re-upsert = %+v, want exactly one row", got) |
| + } |
| + a := got[0] |
| + if a.Host != "other.example.com" || a.Account != "bad" || a.App != "otherapp" { |
| + t.Fatalf("re-upsert did not repoint host/account/app: %+v", a) |
| + } |
| + if a.DNSStatus != "pointing" || a.DNSCheckedAt != 2000 || a.CertExpiresAt != 3000 { |
| + t.Fatalf("re-upsert clobbered stamps: %+v", a) |
| + } |
| + if a.CreatedAt != 1000 { |
| + t.Fatalf("re-upsert clobbered created_at: got %d, want the FIRST insert's 1000", a.CreatedAt) |
| + } |
| + |
| + // Delete is idempotent: gone is success, called twice is still success. |
| + if err := reg.DeleteAlias("extra.example.com"); err != nil { |
| + t.Fatalf("DeleteAlias: %v", err) |
| + } |
| + if err := reg.DeleteAlias("extra.example.com"); err != nil { |
| + t.Fatalf("DeleteAlias (already gone): %v", err) |
| + } |
| + got, err = reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("ListAliases after delete = %+v, want none", got) |
| + } |
| +} |
| + |
| +// TestRouteAliasStamps pins SetAliasDNSStatus/SetAliasCertExpiresAt as narrow |
| +// UPDATEs that persist independently of UpsertAlias — same rationale as |
| +// SetDNSStatus/SetCertExpiresAt on routes. |
| +func TestRouteAliasStamps(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + if err := reg.SetAliasDNSStatus("extra.example.com", "elsewhere", 111); err != nil { |
| + t.Fatalf("SetAliasDNSStatus: %v", err) |
| + } |
| + if err := reg.SetAliasCertExpiresAt("extra.example.com", 222); err != nil { |
| + t.Fatalf("SetAliasCertExpiresAt: %v", err) |
| + } |
| + got, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListAliases = %+v, want exactly one row", got) |
| + } |
| + if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 { |
| + t.Fatalf("stamps did not persist: %+v", got[0]) |
| + } |
| + |
| + // A same-host UpsertAlias (no repoint) must not disturb the stamps either. |
| + if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + got, err = reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 { |
| + t.Fatalf("UpsertAlias disturbed stamps that survived a plain re-upsert: %+v", got[0]) |
| + } |
| +} |
| + |
| +// TestFingerprintCoversAliases pins the fingerprint contract from the task |
| +// brief: an alias write moves the fingerprint (so the edge reloads and |
| +// starts routing it), but a STAMP write never does — stamping runs on a |
| +// 5-minute cadence and hashing it would force a pointless fleet-wide edge |
| +// reload every pass (same reasoning as the routes section's own omission of |
| +// dns_status/cert_expires_at). |
| +func TestFingerprintCoversAliases(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + fp1, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com", Account: "bab", App: "myapp"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + fp2, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp2 == fp1 { |
| + t.Fatal("Fingerprint blind to an alias UpsertAlias") |
| + } |
| + |
| + if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 12345); err != nil { |
| + t.Fatalf("SetAliasDNSStatus: %v", err) |
| + } |
| + fp3, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp3 != fp2 { |
| + t.Fatalf("Fingerprint moved on an alias stamp (%s -> %s) — the edge would reload for a change that does not touch routing", fp2, fp3) |
| + } |
| + |
| + if err := reg.DeleteAlias("extra.example.com"); err != nil { |
| + t.Fatalf("DeleteAlias: %v", err) |
| + } |
| + fp4, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp4 == fp2 { |
| + t.Fatal("Fingerprint blind to a DeleteAlias") |
| + } |
| +} |
| + |
| +// TestMigrateIdempotentWithAliases pins migrate()'s additive contract for the |
| +// new table: opening an already-migrated registry a second time (CREATE |
| +// TABLE IF NOT EXISTS route_aliases) must not error. |
| +func TestMigrateIdempotentWithAliases(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + r1, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("first Open: %v", err) |
| + } |
| + if err := r1.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + if err := r1.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + r2, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("second Open over the same path: %v", err) |
| + } |
| + defer r2.Close() |
| + got, err := r2.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 || got[0].Alias != "extra.example.com" { |
| + t.Fatalf("route_aliases row lost across a second Open: %+v", got) |
| + } |
| +} |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 39b18fc08..f5a4ad64b 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -93,6 +93,15 @@ type Probes struct { |
| // unknown/not-applicable for both, never a guess. |
| Release string |
| Tunnel string |
| + |
| + // Aliases is this box's route_aliases rows (registry.RouteAlias), the |
| + // agent's reg.ListAliases() read at statusTick — carried through raw so |
| + // CollectBox can fold each row's DNS/cert stamp into Box.Metrics.Aliases |
| + // alongside RouteVersions above. A failed read is the caller's problem |
| + // (log-and-continue, see agent.go's statusTick), never CollectBox's: nil |
| + // here means "no aliases known this tick," identical on the wire to "no |
| + // alias rows exist." |
| + Aliases []registry.RouteAlias |
| } |
| // BoxMetrics is the raw, PRIVATE block: real numbers, never banded. It lives |
| @@ -118,6 +127,33 @@ type BoxMetrics struct { |
| // no versioned route, same "absent, not empty" rule as the rest of this |
| // block. |
| RouteVersions map[string]string `json:"route_versions,omitempty"` |
| + // Aliases is issue #151's per-hostname DNS/cert status, keyed by the |
| + // alias hostname — the box's own route_aliases rows only (CollectBox is |
| + // called per-box, same scope as RouteVersions above), never a fleet-wide |
| + // join. It lives here for the identical reason RouteVersions does: |
| + // status/boxes/<label>.json is bucket-private (this package's own doc |
| + // comment, and dryrun.go's leak-scan self-check, which guards the PUBLIC |
| + // feed only — fleet.json, index.html, status.css — and deliberately |
| + // EXPECTS raw values inside status/boxes/*.json). BuildFleet copies Box's |
| + // fields into FleetBox one at a time by name and never names Metrics, so |
| + // these hostnames can never reach the public aggregation. Task 9's |
| + // console list door is the intended reader. Omitted entirely (not a |
| + // present-but-empty map) when this box has no alias rows, same |
| + // "absent, not empty" rule as RouteVersions. |
| + Aliases map[string]AliasReading `json:"aliases,omitempty"` |
| +} |
| + |
| +// AliasReading is one alias hostname's status as CollectBox saw it this |
| +// tick: Route is the route it points at (registry.RouteAlias.Host), DNSStatus |
| +// and CertExpiresAt are the box-stamped health columns (Task 2/5), carried |
| +// through unbanded like the rest of BoxMetrics. Route has no omitempty — an |
| +// alias with no route would be a data bug worth seeing, not a field worth |
| +// hiding; DNSStatus/CertExpiresAt omit when unstamped ("" / 0), same |
| +// unknown-stays-absent convention as every other Metrics field. |
| +type AliasReading struct { |
| + Route string `json:"route"` |
| + DNSStatus string `json:"dns_status,omitempty"` |
| + CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| } |
| // RouteCounts is aggregate-only: no host, no socket path, just how many. |
| @@ -340,6 +376,21 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| counts.Resident++ |
| } |
| } |
| + // aliasReadings is issue #151's Metrics.Aliases source, built once here |
| + // (not a second pass) from p.Aliases — nil (not an empty map) until the |
| + // first entry, same "nothing to report" convention as routeVersions |
| + // above, and for the identical reason: an all-alias-less box's Metrics |
| + // attachment check below must see exactly "nothing to report". |
| + var aliasReadings map[string]AliasReading |
| + for _, a := range p.Aliases { |
| + if aliasReadings == nil { |
| + aliasReadings = map[string]AliasReading{} |
| + } |
| + aliasReadings[a.Alias] = AliasReading{ |
| + Route: a.Host, DNSStatus: a.DNSStatus, CertExpiresAt: a.CertExpiresAt, |
| + } |
| + } |
| + |
| if awakeKnown && p.Listening != nil { |
| counts.Awake, counts.Asleep = &awake, &asleep |
| } |
| @@ -382,18 +433,20 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| } |
| // Metrics attaches only when something is known — an all-unknown Probes |
| - // AND no versioned route publishes no block at all, the same "unknown |
| - // stays absent, never a guess" rule every other field on Box follows, |
| - // and it keeps a box that can't answer any of the seven fields |
| - // indistinguishable on the wire from a box running code that predates |
| - // Metrics entirely. |
| + // AND no versioned route AND no alias reading publishes no block at all, |
| + // the same "unknown stays absent, never a guess" rule every other field |
| + // on Box follows, and it keeps a box that can't answer any of the eight |
| + // fields indistinguishable on the wire from a box running code that |
| + // predates Metrics entirely. |
| if p.MemTotalBytes != nil || p.MemAvailBytes != nil || p.Load1 != nil || |
| - p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || len(routeVersions) > 0 { |
| + p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || |
| + len(routeVersions) > 0 || len(aliasReadings) > 0 { |
| box.Metrics = &BoxMetrics{ |
| MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes, |
| Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes, |
| Release: p.Release, Tunnel: p.Tunnel, |
| RouteVersions: routeVersions, |
| + Aliases: aliasReadings, |
| } |
| } |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 3d0a93e94..1a2daa907 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -340,6 +340,44 @@ func TestCollectBoxRouteVersionsEmptyOmitsField(t *testing.T) { |
| } |
| } |
| +// ---- Task 6: alias readings (route_aliases DNS/cert stamps, private) ----- |
| + |
| +// TestCollectBoxAliases proves CollectBox maps Probes.Aliases (registry rows |
| +// carried in by the agent's own reg.ListAliases() call, see agent.go's |
| +// statusTick) onto Box.Metrics.Aliases, keyed by the alias hostname — the |
| +// same private home as RouteVersions (status/boxes/<label>.json only, never |
| +// BuildFleet's public fleet.json) — and that an empty Probes.Aliases omits |
| +// the "aliases" key entirely rather than publishing a present-but-empty map. |
| +func TestCollectBoxAliases(t *testing.T) { |
| + p := status.Probes{ |
| + MachineID: "m", |
| + Aliases: []registry.RouteAlias{ |
| + {Alias: "extra.test", Host: "h.test", DNSStatus: "ok", CertExpiresAt: 1234567890}, |
| + }, |
| + } |
| + b := status.CollectBox(nil, p, time.Now()) |
| + if b.Metrics == nil { |
| + t.Fatal("metrics block missing — Aliases should have attached it") |
| + } |
| + got, ok := b.Metrics.Aliases["extra.test"] |
| + if !ok || got.Route != "h.test" || got.DNSStatus != "ok" || got.CertExpiresAt != 1234567890 { |
| + t.Fatalf("aliases[extra.test] = %+v (ok=%v), want {Route:h.test DNSStatus:ok CertExpiresAt:1234567890}", got, ok) |
| + } |
| + |
| + // Empty Probes.Aliases, but something else known so Metrics still |
| + // attaches — the omission must be specific to the aliases key, not a |
| + // side effect of the whole Metrics block being absent. |
| + mt := int64(1) |
| + b2 := status.CollectBox(nil, status.Probes{MachineID: "m", MemTotalBytes: &mt}, time.Now()) |
| + j, err := json.Marshal(b2) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(j), "aliases") { |
| + t.Fatalf("empty Probes.Aliases must omit the aliases key entirely, got: %s", j) |
| + } |
| +} |
| + |
| // ---- Task 3: fleet merge + publisher -------------------------------------- |
| // teststore is a fresh DirStore over a scratch directory — every fleet test |
diff --git a/main.go b/main.go| index 57a13f569..75fea7ef0 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -53,6 +53,8 @@ func main() { |
| runRoute(args) |
| case "instances": |
| runInstances(args) |
| + case "domains": |
| + runDomains(args) |
| case "ship": |
| runShip(args) |
| case "promote": |
| @@ -153,6 +155,8 @@ Your apps — ship and run software on a CARLOS deployment |
| env plain per-app config vars: set | unset | list |
| secrets sealed per-app secrets: set | unset | list | genkey |
| instances an app's instances: enable | create | list | delete |
| + domains a customer's own hostname attached to an app's route: |
| + attach | detach | list |
| logs an app's merged log timeline: its own output plus platform |
| events (wakes, restarts, failures); -f follows live |
| ledger open ledgers: append | head | list | publish | blob | verify |