feat: console domains door — attach/detach/list hostnames (#151)
The /domains family on the CLI API, sharing the instance family's gate
verbatim (instanceGate): a member's token or an APP token bound to that
account+app, token-hash rate limiting, and the Releases-nil 503, in the
order the family fixed. titogo's router attaches its customers' hostnames
with the CARLOS_INSTANCE_TOKEN it already holds.
POST /api/cli/apps/{account}/{app}/domains -> {host,route,created}
DELETE /api/cli/apps/{account}/{app}/domains/{host} -> {host,removed}
GET /api/cli/apps/{account}/{app}/domains -> {domains:[...]}
The neutralities the tests pin: a hostname another account holds is a 409
whose body is exactly {"error":"hostname unavailable"} and nothing else;
detach answers the same 200 shape for a hostname nobody claimed and for
one another account holds, touching neither the claim nor the serial; an
app token bound to a neighbouring app gets the byte-identical 404.
Attach validates in one order — normalize (traversal defence), deny list,
route — and the deny list is the operator's CONSOLE_DENY_DOMAINS plus the
two names the console derives itself: its own origin host and the platform
apps domain (spec §5), so a deployment cannot forget them. An omitted
route defaults only when the app has exactly one instance record.
List joins each claim to the box readings sweep through the same
non-stale rule stagedVersions uses: a hostname a live box reports reads
"live" with its dns_status/cert_expires_at, everything else "pending".
Serial bumps strictly after a successful write and its audit; a no-op
detach bumps nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+774
−2
cmd/console/main.go+20 −0internal/console/apidomains.go+309 −0internal/console/apidomains_test.go+427 −0internal/console/authcli.go+9 −2internal/console/server.go+9 −0
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/internal/console/apidomains.go b/internal/console/apidomains.go| new file mode 100644 |
| index 000000000..1174720b2 |
| --- /dev/null |
| +++ b/internal/console/apidomains.go |
| @@ -0,0 +1,309 @@ |
| +// 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 two names this |
| +// console can derive without being told (spec §5: "all derived from |
| +// console/deployment config, not hardcoded"). |
| +// |
| +// The derived two 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 an alias claim there would be one tenant claiming another |
| +// tenant's address — the same sibling-apex hazard handleAPIInstanceCreate |
| +// refuses within an account, here refused 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. |
| +// |
| +// Parsed per call rather than cached at boot: it is one url.Parse of a short |
| +// string on a door that then does bucket I/O, and it stays honest for an |
| +// embedder (or a test) that sets Origin after NewServer. |
| +func (s *Server) deniedDomains() []string { |
| + deny := make([]string, 0, len(s.cfg.DenyDomains)+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) |
| + } |
| + 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, 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 |
| + } |
| + 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..b67acfe0f |
| --- /dev/null |
| +++ b/internal/console/apidomains_test.go |
| @@ -0,0 +1,427 @@ |
| +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, and the |
| +// console's own origin host. |
| +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" |
| + 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 |
| + } { |
| + 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) |
| + } |
| +} |
| + |
| +// TestAPIDomainSerialBump: boxes converge off control/serial, so every |
| +// EFFECTIVE write announces itself — and a no-op detach, which changed |
| +// nothing a box reads, does not. |
| +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 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 != 2 { |
| + t.Fatalf("serial after an effective detach = %d, want 2", 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 != 2 { |
| + t.Fatalf("serial after a no-op detach = %d, want it unmoved at 2", 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 |