// 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. // // Attach, detach and the prefix-map publish go through domainsGate, below — // the instance family's principal, binding and readiness checks, but with the // instance-verb WORKLOAD budget swapped for a CONVERGENCE-RATE ceiling // (domainsCeilingBurst, spec §4a): a Pages hub with wildcard DNS makes no // per-repo hostname call at all, so gating these on the per-minute instance // budget would uplift a call that isn't the workload that budget was sized // for. The exemption applies to every principal, person and service alike — // it was never about who is calling, only about what the door is. A debugging // session chasing a 429 here wants domainsCeilingBurst, not cliInstanceBurst. // // The rest of the gate IS the instance family's, verbatim: the same bearer // shapes reach it, and every refusal before the ceiling 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" "fmt" "log" "net/http" "net/url" "os" "slices" "sort" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/certwarm" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/edgesfile" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // consoleHosts are the hostnames this console itself answers on: the ORIGIN // host, plus the REDIRECT HOSTS (CONSOLE_REDIRECT_HOSTS: retired console // origins such as tito's carlos.tito.io, which this console still answers on // and 301s to Origin). Every deployment refuses these whether or not it // reserves platform domains (domainHostDenied). The reason is self-harm, not // tenant isolation: an alias on one of these names points the console's own // address at an app. Route-wins ordering on a box does not prevent that. It // is per-box, and it stops protecting the name when a second box // materializes the alias. // // Only the names themselves are refused on every deployment. Everything // under them is refused only with ReservePlatformDomains (see // deniedDomains). A self-hosted console at carlos.example.com whose apps // live at .carlos.example.com must be able to attach // blog.carlos.example.com. // // The redirect entries go through the same normalizeHost that ServeHTTP's // retired-host check uses, so the two readings of CONSOLE_REDIRECT_HOSTS // cannot disagree. NewServer refuses a redirect host equal to the origin. func (s *Server) consoleHosts() []string { hosts := make([]string, 0, len(s.cfg.RedirectHosts)+1) if u, err := url.Parse(s.cfg.Origin); err == nil { if h := strings.ToLower(u.Hostname()); h != "" { hosts = append(hosts, h) } } for _, h := range s.cfg.RedirectHosts { if h = normalizeHost(h); h != "" { hosts = append(hosts, h) } } return hosts } // The /domains door refuses whole subtrees in two tiers (#209), and the // tier decides who may ever get past it. // // platformDomains are the deployment's OWN zones. Members are refused them; // a platform operator may attach one to an app through the --platform door, // because these are exactly the names an operator runs their own site on // (carloku.com on the flagship, go.tito.io on Tito's): // // - the operator's CONSOLE_DENY_DOMAINS entries. An operator who lists a // name has chosen to refuse it to tenants; // - with ReservePlatformDomains, everything UNDER the console's own // hostnames (consoleHosts). On a shared platform those names belong to // the operator, not to a tenant. // // reservedDomains are refused to everyone, the operator included, because a // claim on any of them takes an address the platform itself or another // tenant stands on: // // - the STEERING HOST (default edge.), platform // infrastructure; // - the TUNNEL HOST (CARLOS_TUNNEL_HOST), the fleet hub's own address, // which the edge Allows with no registry route behind it; // - with ReservePlatformDomains, the APPS DOMAIN, the whole tenant // namespace (..). Every app's public URL is // under it, so a claim there is one tenant taking another tenant's // address. This is the sibling-apex hazard that handleAPIInstanceCreate // refuses within an account, refused here across the deployment. Hosts // under the apps domain are what `carlos instances` is for. // // The console's own hostnames are refused by exact name, to everyone, in // domainHostPolicy. // // Without ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) neither // derived rule is added. That is the default because a self-hosted // deployment usually puts its console, its apps domain and its custom // domains in ONE zone that ONE person owns, and the reserved rules refused // ordinary names there. Existing routes are still safe without it: // deploymentRouteTaken refuses a hostname that another app's route already // uses. With it set, these are the rules for a platform that hosts apps for // OTHER people, such as the flagship or carloku (spec §5: "all derived from // console/deployment config, not hardcoded"). // // exemptAppsDomain is the CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS delegation // (domainHostPolicy). It drops, from both tiers, every entry that is the // apps domain or an ancestor of it, so that a delegated one-label alias is // not refused by a blanket entry above it. // // Derived per call rather than cached at boot: one url.Parse and a short // copy, on a door that then does bucket I/O. This keeps it correct for an // embedder (or a test) that changes the config after NewServer. func (s *Server) platformDomains(exemptAppsDomain bool) []string { entries := slices.Clone(s.cfg.DenyDomains) if s.cfg.ReservePlatformDomains { entries = append(entries, s.consoleHosts()...) } return s.denyEntries(entries, exemptAppsDomain) } // reservedDomains: see platformDomains. func (s *Server) reservedDomains(exemptAppsDomain bool) []string { domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) steeringHost := strings.TrimSuffix(normalizeHost(strings.TrimSpace(s.cfg.SteeringHost)), ".") if steeringHost == "" && domain != "" { steeringHost = "edge." + domain } entries := []string{strings.TrimSuffix(s.cfg.TunnelHost, ".")} if s.cfg.ReservePlatformDomains { entries = append(entries, domain) } // The steering host is never exempt: it is edge., under // the apps domain rather than above it, and a delegated alias account // must not be able to take it. deny := s.denyEntries(entries, exemptAppsDomain) if steeringHost != "" { deny = append(deny, steeringHost) } return deny } // denyEntries normalizes entries, dropping blanks (an empty entry would be // one no hostname could avoid matching) and, when exemptAppsDomain is set, // every entry that is the apps domain or an ancestor of it. func (s *Server) denyEntries(entries []string, exemptAppsDomain bool) []string { domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) covers := func(entry string) bool { return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry)) } deny := make([]string, 0, len(entries)+1) for _, entry := range entries { entry = strings.ToLower(strings.TrimSpace(entry)) if entry == "" || (exemptAppsDomain && covers(entry)) { continue } deny = append(deny, entry) } 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 } // domainsGate is instanceGate's principal, binding and readiness checks with // the instance-verb WORKLOAD budget step replaced by a CONVERGENCE-RATE // ceiling (spec §4a, sharpened by Task 5 review C34). Wildcard DNS changes // the WORKLOAD question: a Pages hub with wildcard DNS makes NO per-repo // hostname call at all — one claim on the hub host with both flags, and // every repo's subdomain serves off the catchall — so gating attach and // detach on the instance family's per-minute request budget would uplift a // call that makes no per-repo call at all. That is the whole of what the // exemption establishes: the legitimate workload does not NEED a budget. It // does not establish that no budget is safe — every EFFECTIVE attach or // detach audits and bumps control/serial, which every box polls at 2s and // reacts to with a full pass, so an unlimited exempt door would let one // authenticated credential drive fleet-wide convergence at request rate. // allowDomains (domainsCeilingBurst, svcprincipal.go) is that ceiling: high // enough that no real caller — including the exempt wildcard workload // itself — ever notices it, low enough that it still caps how fast one // credential can make every box look at the world again. // // The other thing bounded on this path is the FAILING half of a wildcard // enable, bounded separately, inside setDomainCapabilities (wildFailBurst) // — a live, sequential DNS round trip on the request path is a hazard // whether or not this ceiling would eventually have caught it too. func (s *Server) domainsGate(w http.ResponseWriter, r *http.Request) (Principal, *Account, string, bool) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return Principal{}, nil, "", false } p, _ := principalFrom(r.Context()) if !s.allowDomains(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return Principal{}, nil, "", false } // A console with no deployment bucket configured can answer none of // these — instanceGate's own check, kept here since this gate stops // short of calling it. if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — the console is running without one") return Principal{}, nil, "", false } return p, acct, app, true } // 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. // // The optional kind/channel body fields are `carlos deploy`'s first-route // extras (#82): kind "static" routes the attach through the shared // attachDomain core (domainattach.go) instead of the route-defaulting below // (which needs an instance record a static app never has). The core itself // decides what the attach means — an app holding a ready route gets an // alias claim naming it; one holding none gets the claim AND a declared // static provision record the reconciler materializes as the app's first // route. route and kind together are refused: an alias target and a // first-route mint are two different instructions. func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { p, acct, app, ok := s.domainsGate(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "attach a domain to it") { return } // The CREATE half of the provision-record lifecycle, reached through // this door rather than the instance one: the first-route branch below // writes a declared provision.Record (attachDomain, domainattach.go), // and a record is what transfers reconciler authority over the app's // route. `POST .../instances` already refuses a service for exactly // that; leaving this open would let a CI credential acquire the same // authority by taking the other door. // // SERVICE-ONLY, not outright, and the split is the one the flag draws // everywhere it can: a person attaching a domain to the control plane is // an ordinary operator act — the same class as writing its instance // record — while a CI credential doing it is the console's route being // claimed by something with no standing to claim it. Detach is the // asymmetric one (refused outright) because REMOVAL is destruction. // // Placed straight after domainsGate, which puts it after the family's // byte-identical 404 and before EVERY branch's write — the static branch // that delegates to the shared core as well as the alias branch below. // It does not disturb the neutrality ordering the alias branch's own // comment protects: that refusal is about which HOSTS exist, readable by // any caller, whereas this one fires only for a service that is already // a proven binding of this account and app, and tells it only what the // promote door would tell it anyway. // // The browser twin (handleAppDomainAttach, appdomains.go) needs no // counterpart: it authenticates a session, so its principal is always a // person and this refusal could never fire there. The shared core // takes an audit-actor STRING rather than a Principal, which is the same // fact stated in the types. if s.refuseServiceControlPlane(w, p, acct.ID, app, "attach a domain to it") { return } principal := auditActor(p, acct) var in struct { Host string `json:"host"` Route string `json:"route"` Kind string `json:"kind"` Channel string `json:"channel"` Message string `json:"message"` // Wildcard and Catchall are the woodstar-carloku capability flips // (Task 5), decoded as pointers so an ABSENT field means "leave // unchanged" — the same tri-state rule apps place's -target and // instances' converge flags already live by. nil never reaches // SetClaimCapabilities: setDomainCapabilities below reads the // claim's current values first and only overrides the one(s) the // caller actually named. Wildcard *bool `json:"wildcard,omitempty"` Catchall *bool `json:"catchall,omitempty"` // Platform asks for the operator's door (#209): with it, a hostname // in the deployment's own zones (hostPlatform) may be attached. It // lifts nothing else. Reserved names stay refused, and the caller // must still be in this account (domainsGate). Platform bool `json:"platform,omitempty"` } if !readJSON(w, r, &in) { return } if s.cfg.HostClaim && (in.Wildcard != nil || in.Catchall != nil) { jsonError(w, http.StatusConflict, claimRefusalText(errClaimAliasCapabilitiesUnsupported)) return } message, ok := checkedActivityMessage(w, in.Message) if !ok { return } // Decided from the caller and the flag alone, before the hostname is // read, so the answer cannot vary with what the deny list holds. if in.Platform && !s.principalPlatformOperator(p) { jsonError(w, http.StatusForbidden, platformOperatorMsg) return } // "*." is an instance catch-all (domaincatchall.go), not a // hostname: it names no route, carries no capabilities and has no // platform form, so every other field is refused rather than ignored. // A person's act, like `carlos instances enable`: a catch-all is // standing policy over every unclaimed name under parent, which a // credential the app holds must not be able to set for itself. if parent, ok := domains.ParseInstanceCatchallHost(in.Host); ok { if !s.apiPerson(w, r) { return } if in.Route != "" || in.Kind != "" || in.Channel != "" || in.Wildcard != nil || in.Catchall != nil || in.Platform { jsonError(w, http.StatusBadRequest, "an instance catch-all takes no route, kind, channel, wildcard, catchall or platform — it serves each instance at