| 1 | // The CLI's extra-hostname API (#151): the verbs that attach a customer's |
| 2 | // own hostname to an app's route, as a data write in the deployment bucket, |
| 3 | // so an app holding no AWS credentials and no box access can offer |
| 4 | // custom-domain hosting to ITS customers in one call. The claim it writes is |
| 5 | // the whole authorization; the agent's domains pass turns it into an alias |
| 6 | // row, on the box that actually owns the route. |
| 7 | // |
| 8 | // Attach, detach and the prefix-map publish go through domainsGate, below — |
| 9 | // the instance family's principal, binding and readiness checks, but with the |
| 10 | // instance-verb WORKLOAD budget swapped for a CONVERGENCE-RATE ceiling |
| 11 | // (domainsCeilingBurst, spec §4a): a Pages hub with wildcard DNS makes no |
| 12 | // per-repo hostname call at all, so gating these on the per-minute instance |
| 13 | // budget would uplift a call that isn't the workload that budget was sized |
| 14 | // for. The exemption applies to every principal, person and service alike — |
| 15 | // it was never about who is calling, only about what the door is. A debugging |
| 16 | // session chasing a 429 here wants domainsCeilingBurst, not cliInstanceBurst. |
| 17 | // |
| 18 | // The rest of the gate IS the instance family's, verbatim: the same bearer |
| 19 | // shapes reach it, and every refusal before the ceiling is the same |
| 20 | // byte-identical 404. That reuse is deliberate: titogo's router calls this |
| 21 | // door with its CARLOS_INSTANCE_TOKEN, the very credential it already holds |
| 22 | // for instances, and a second hand-rolled gate here would be a second place |
| 23 | // for the ordering (principal, budget, readiness) to drift. |
| 24 | // |
| 25 | // Two neutralities this file exists to keep: |
| 26 | // - a hostname another account holds answers 409 "hostname unavailable" and |
| 27 | // NOTHING else — not who holds it, not which account, not whether the |
| 28 | // holder is in this deployment at all; |
| 29 | // - detach is quiet in both directions — an unknown hostname and another |
| 30 | // owner's hostname answer with the identical 200 body, so detach is not a |
| 31 | // way to ask "does anyone own this?". |
| 32 | // |
| 33 | // Spec: docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md §5. |
| 34 | package console |
| 35 | |
| 36 | import ( |
| 37 | "context" |
| 38 | "errors" |
| 39 | "fmt" |
| 40 | "log" |
| 41 | "net/http" |
| 42 | "net/url" |
| 43 | "os" |
| 44 | "slices" |
| 45 | "sort" |
| 46 | "strconv" |
| 47 | "strings" |
| 48 | "time" |
| 49 | |
| 50 | "github.com/carlosframework/platform/internal/certwarm" |
| 51 | "github.com/carlosframework/platform/internal/domains" |
| 52 | "github.com/carlosframework/platform/internal/edgesfile" |
| 53 | "github.com/carlosframework/platform/internal/hostops" |
| 54 | "github.com/carlosframework/platform/internal/provision" |
| 55 | "github.com/carlosframework/platform/internal/release" |
| 56 | "github.com/carlosframework/platform/internal/status" |
| 57 | ) |
| 58 | |
| 59 | // consoleHosts are the hostnames this console itself answers on: the ORIGIN |
| 60 | // host, plus the REDIRECT HOSTS (CONSOLE_REDIRECT_HOSTS: retired console |
| 61 | // origins such as tito's carlos.tito.io, which this console still answers on |
| 62 | // and 301s to Origin). Every deployment refuses these whether or not it |
| 63 | // reserves platform domains (domainHostDenied). The reason is self-harm, not |
| 64 | // tenant isolation: an alias on one of these names points the console's own |
| 65 | // address at an app. Route-wins ordering on a box does not prevent that. It |
| 66 | // is per-box, and it stops protecting the name when a second box |
| 67 | // materializes the alias. |
| 68 | // |
| 69 | // Only the names themselves are refused on every deployment. Everything |
| 70 | // under them is refused only with ReservePlatformDomains (see |
| 71 | // deniedDomains). A self-hosted console at carlos.example.com whose apps |
| 72 | // live at <app>.carlos.example.com must be able to attach |
| 73 | // blog.carlos.example.com. |
| 74 | // |
| 75 | // The redirect entries go through the same normalizeHost that ServeHTTP's |
| 76 | // retired-host check uses, so the two readings of CONSOLE_REDIRECT_HOSTS |
| 77 | // cannot disagree. NewServer refuses a redirect host equal to the origin. |
| 78 | func (s *Server) consoleHosts() []string { |
| 79 | hosts := make([]string, 0, len(s.cfg.RedirectHosts)+1) |
| 80 | if u, err := url.Parse(s.cfg.Origin); err == nil { |
| 81 | if h := strings.ToLower(u.Hostname()); h != "" { |
| 82 | hosts = append(hosts, h) |
| 83 | } |
| 84 | } |
| 85 | for _, h := range s.cfg.RedirectHosts { |
| 86 | if h = normalizeHost(h); h != "" { |
| 87 | hosts = append(hosts, h) |
| 88 | } |
| 89 | } |
| 90 | return hosts |
| 91 | } |
| 92 | |
| 93 | // The /domains door refuses whole subtrees in two tiers (#209), and the |
| 94 | // tier decides who may ever get past it. |
| 95 | // |
| 96 | // platformDomains are the deployment's OWN zones. Members are refused them; |
| 97 | // a platform operator may attach one to an app through the --platform door, |
| 98 | // because these are exactly the names an operator runs their own site on |
| 99 | // (carloku.com on the flagship, go.tito.io on Tito's): |
| 100 | // |
| 101 | // - the operator's CONSOLE_DENY_DOMAINS entries. An operator who lists a |
| 102 | // name has chosen to refuse it to tenants; |
| 103 | // - with ReservePlatformDomains, everything UNDER the console's own |
| 104 | // hostnames (consoleHosts). On a shared platform those names belong to |
| 105 | // the operator, not to a tenant. |
| 106 | // |
| 107 | // reservedDomains are refused to everyone, the operator included, because a |
| 108 | // claim on any of them takes an address the platform itself or another |
| 109 | // tenant stands on: |
| 110 | // |
| 111 | // - the STEERING HOST (default edge.<apps-domain>), platform |
| 112 | // infrastructure; |
| 113 | // - the TUNNEL HOST (CARLOS_TUNNEL_HOST), the fleet hub's own address, |
| 114 | // which the edge Allows with no registry route behind it; |
| 115 | // - with ReservePlatformDomains, the APPS DOMAIN, the whole tenant |
| 116 | // namespace (<app>.<sqid>.<apps-domain>). Every app's public URL is |
| 117 | // under it, so a claim there is one tenant taking another tenant's |
| 118 | // address. This is the sibling-apex hazard that handleAPIInstanceCreate |
| 119 | // refuses within an account, refused here across the deployment. Hosts |
| 120 | // under the apps domain are what `carlos instances` is for. |
| 121 | // |
| 122 | // The console's own hostnames are refused by exact name, to everyone, in |
| 123 | // domainHostPolicy. |
| 124 | // |
| 125 | // Without ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) neither |
| 126 | // derived rule is added. That is the default because a self-hosted |
| 127 | // deployment usually puts its console, its apps domain and its custom |
| 128 | // domains in ONE zone that ONE person owns, and the reserved rules refused |
| 129 | // ordinary names there. Existing routes are still safe without it: |
| 130 | // deploymentRouteTaken refuses a hostname that another app's route already |
| 131 | // uses. With it set, these are the rules for a platform that hosts apps for |
| 132 | // OTHER people, such as the flagship or carloku (spec §5: "all derived from |
| 133 | // console/deployment config, not hardcoded"). |
| 134 | // |
| 135 | // exemptAppsDomain is the CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS delegation |
| 136 | // (domainHostPolicy). It drops, from both tiers, every entry that is the |
| 137 | // apps domain or an ancestor of it, so that a delegated one-label alias is |
| 138 | // not refused by a blanket entry above it. |
| 139 | // |
| 140 | // Derived per call rather than cached at boot: one url.Parse and a short |
| 141 | // copy, on a door that then does bucket I/O. This keeps it correct for an |
| 142 | // embedder (or a test) that changes the config after NewServer. |
| 143 | func (s *Server) platformDomains(exemptAppsDomain bool) []string { |
| 144 | entries := slices.Clone(s.cfg.DenyDomains) |
| 145 | if s.cfg.ReservePlatformDomains { |
| 146 | entries = append(entries, s.consoleHosts()...) |
| 147 | } |
| 148 | return s.denyEntries(entries, exemptAppsDomain) |
| 149 | } |
| 150 | |
| 151 | // reservedDomains: see platformDomains. |
| 152 | func (s *Server) reservedDomains(exemptAppsDomain bool) []string { |
| 153 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 154 | steeringHost := strings.TrimSuffix(normalizeHost(strings.TrimSpace(s.cfg.SteeringHost)), ".") |
| 155 | if steeringHost == "" && domain != "" { |
| 156 | steeringHost = "edge." + domain |
| 157 | } |
| 158 | entries := []string{strings.TrimSuffix(s.cfg.TunnelHost, ".")} |
| 159 | if s.cfg.ReservePlatformDomains { |
| 160 | entries = append(entries, domain) |
| 161 | } |
| 162 | // The steering host is never exempt: it is edge.<apps-domain>, under |
| 163 | // the apps domain rather than above it, and a delegated alias account |
| 164 | // must not be able to take it. |
| 165 | deny := s.denyEntries(entries, exemptAppsDomain) |
| 166 | if steeringHost != "" { |
| 167 | deny = append(deny, steeringHost) |
| 168 | } |
| 169 | return deny |
| 170 | } |
| 171 | |
| 172 | // denyEntries normalizes entries, dropping blanks (an empty entry would be |
| 173 | // one no hostname could avoid matching) and, when exemptAppsDomain is set, |
| 174 | // every entry that is the apps domain or an ancestor of it. |
| 175 | func (s *Server) denyEntries(entries []string, exemptAppsDomain bool) []string { |
| 176 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 177 | covers := func(entry string) bool { |
| 178 | return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry)) |
| 179 | } |
| 180 | deny := make([]string, 0, len(entries)+1) |
| 181 | for _, entry := range entries { |
| 182 | entry = strings.ToLower(strings.TrimSpace(entry)) |
| 183 | if entry == "" || (exemptAppsDomain && covers(entry)) { |
| 184 | continue |
| 185 | } |
| 186 | deny = append(deny, entry) |
| 187 | } |
| 188 | return deny |
| 189 | } |
| 190 | |
| 191 | // resolveAliasRoute decides which of the app's routes a claim points at. |
| 192 | // ok=false means a response has already been written. |
| 193 | // |
| 194 | // An EXPLICIT route is shape-checked and then accepted as given. The door |
| 195 | // deliberately does not verify that the route exists: instance records are |
| 196 | // only one way a route comes to be, and an operator-minted route (tito's |
| 197 | // go.tito.io, every route on a self-hosted box) has no record at all — |
| 198 | // refusing those here would make the feature unusable for exactly the |
| 199 | // deployment that asked for it. The authoritative check is on the box, at |
| 200 | // reconcile: a claim naming a route that box does not own materializes |
| 201 | // nothing (spec §3), so a wrong route is inert rather than dangerous. |
| 202 | // |
| 203 | // An OMITTED route is defaulted only when there is exactly one thing it could |
| 204 | // mean — the app's single instance record. Zero or several is refused, naming |
| 205 | // the flag that resolves it: guessing an app's primary route out of a set |
| 206 | // would attach a customer's hostname to whichever one sorted first. |
| 207 | func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, acct *Account, app, want string) (string, bool) { |
| 208 | if route := strings.ToLower(strings.TrimSpace(want)); route != "" { |
| 209 | if !provision.ValidHostShape(route) { |
| 210 | jsonError(w, http.StatusBadRequest, "invalid route hostname") |
| 211 | return "", false |
| 212 | } |
| 213 | return route, true |
| 214 | } |
| 215 | recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) |
| 216 | if err != nil { |
| 217 | log.Printf("console: api domains route default %s/%s: %v", acct.Sqid, app, err) |
| 218 | jsonError(w, http.StatusBadGateway, "could not read this app's instances") |
| 219 | return "", false |
| 220 | } |
| 221 | if len(recs) == 1 { |
| 222 | return recs[0].Host, true |
| 223 | } |
| 224 | if len(recs) == 0 { |
| 225 | jsonError(w, http.StatusBadRequest, |
| 226 | "specify --route: the app has no instance records to default to — name the route hostname to attach to") |
| 227 | return "", false |
| 228 | } |
| 229 | jsonError(w, http.StatusBadRequest, |
| 230 | "specify --route: the app has "+strconv.Itoa(len(recs))+" instances") |
| 231 | return "", false |
| 232 | } |
| 233 | |
| 234 | // domainsGate is instanceGate's principal, binding and readiness checks with |
| 235 | // the instance-verb WORKLOAD budget step replaced by a CONVERGENCE-RATE |
| 236 | // ceiling (spec §4a, sharpened by Task 5 review C34). Wildcard DNS changes |
| 237 | // the WORKLOAD question: a Pages hub with wildcard DNS makes NO per-repo |
| 238 | // hostname call at all — one claim on the hub host with both flags, and |
| 239 | // every repo's subdomain serves off the catchall — so gating attach and |
| 240 | // detach on the instance family's per-minute request budget would uplift a |
| 241 | // call that makes no per-repo call at all. That is the whole of what the |
| 242 | // exemption establishes: the legitimate workload does not NEED a budget. It |
| 243 | // does not establish that no budget is safe — every EFFECTIVE attach or |
| 244 | // detach audits and bumps control/serial, which every box polls at 2s and |
| 245 | // reacts to with a full pass, so an unlimited exempt door would let one |
| 246 | // authenticated credential drive fleet-wide convergence at request rate. |
| 247 | // allowDomains (domainsCeilingBurst, svcprincipal.go) is that ceiling: high |
| 248 | // enough that no real caller — including the exempt wildcard workload |
| 249 | // itself — ever notices it, low enough that it still caps how fast one |
| 250 | // credential can make every box look at the world again. |
| 251 | // |
| 252 | // The other thing bounded on this path is the FAILING half of a wildcard |
| 253 | // enable, bounded separately, inside setDomainCapabilities (wildFailBurst) |
| 254 | // — a live, sequential DNS round trip on the request path is a hazard |
| 255 | // whether or not this ceiling would eventually have caught it too. |
| 256 | func (s *Server) domainsGate(w http.ResponseWriter, r *http.Request) (Principal, *Account, string, bool) { |
| 257 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 258 | if !ok { |
| 259 | return Principal{}, nil, "", false |
| 260 | } |
| 261 | p, _ := principalFrom(r.Context()) |
| 262 | if !s.allowDomains(p, time.Now()) { |
| 263 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 264 | return Principal{}, nil, "", false |
| 265 | } |
| 266 | // A console with no deployment bucket configured can answer none of |
| 267 | // these — instanceGate's own check, kept here since this gate stops |
| 268 | // short of calling it. |
| 269 | if s.cfg.Releases == nil { |
| 270 | jsonError(w, http.StatusServiceUnavailable, |
| 271 | "no deployment bucket on this host — the console is running without one") |
| 272 | return Principal{}, nil, "", false |
| 273 | } |
| 274 | return p, acct, app, true |
| 275 | } |
| 276 | |
| 277 | // handleAPIDomainAttach is the point of the feature: one claim written to the |
| 278 | // bucket, in the spec's refusal order — gate, body, hostname, deny list, |
| 279 | // route, write. Idempotent by design (the titogo contract): re-attaching a |
| 280 | // hostname this app already holds is 200 with created false, converging the |
| 281 | // route if it moved, and a hostname anyone ELSE holds is the neutral 409. |
| 282 | // |
| 283 | // The optional kind/channel body fields are `carlos deploy`'s first-route |
| 284 | // extras (#82): kind "static" routes the attach through the shared |
| 285 | // attachDomain core (domainattach.go) instead of the route-defaulting below |
| 286 | // (which needs an instance record a static app never has). The core itself |
| 287 | // decides what the attach means — an app holding a ready route gets an |
| 288 | // alias claim naming it; one holding none gets the claim AND a declared |
| 289 | // static provision record the reconciler materializes as the app's first |
| 290 | // route. route and kind together are refused: an alias target and a |
| 291 | // first-route mint are two different instructions. |
| 292 | func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| 293 | p, acct, app, ok := s.domainsGate(w, r) |
| 294 | if !ok { |
| 295 | return |
| 296 | } |
| 297 | if s.refuseTemporary(w, acct, app, "attach a domain to it") { |
| 298 | return |
| 299 | } |
| 300 | // The CREATE half of the provision-record lifecycle, reached through |
| 301 | // this door rather than the instance one: the first-route branch below |
| 302 | // writes a declared provision.Record (attachDomain, domainattach.go), |
| 303 | // and a record is what transfers reconciler authority over the app's |
| 304 | // route. `POST .../instances` already refuses a service for exactly |
| 305 | // that; leaving this open would let a CI credential acquire the same |
| 306 | // authority by taking the other door. |
| 307 | // |
| 308 | // SERVICE-ONLY, not outright, and the split is the one the flag draws |
| 309 | // everywhere it can: a person attaching a domain to the control plane is |
| 310 | // an ordinary operator act — the same class as writing its instance |
| 311 | // record — while a CI credential doing it is the console's route being |
| 312 | // claimed by something with no standing to claim it. Detach is the |
| 313 | // asymmetric one (refused outright) because REMOVAL is destruction. |
| 314 | // |
| 315 | // Placed straight after domainsGate, which puts it after the family's |
| 316 | // byte-identical 404 and before EVERY branch's write — the static branch |
| 317 | // that delegates to the shared core as well as the alias branch below. |
| 318 | // It does not disturb the neutrality ordering the alias branch's own |
| 319 | // comment protects: that refusal is about which HOSTS exist, readable by |
| 320 | // any caller, whereas this one fires only for a service that is already |
| 321 | // a proven binding of this account and app, and tells it only what the |
| 322 | // promote door would tell it anyway. |
| 323 | // |
| 324 | // The browser twin (handleAppDomainAttach, appdomains.go) needs no |
| 325 | // counterpart: it authenticates a session, so its principal is always a |
| 326 | // person and this refusal could never fire there. The shared core |
| 327 | // takes an audit-actor STRING rather than a Principal, which is the same |
| 328 | // fact stated in the types. |
| 329 | if s.refuseServiceControlPlane(w, p, acct.ID, app, "attach a domain to it") { |
| 330 | return |
| 331 | } |
| 332 | principal := auditActor(p, acct) |
| 333 | var in struct { |
| 334 | Host string `json:"host"` |
| 335 | Route string `json:"route"` |
| 336 | Kind string `json:"kind"` |
| 337 | Channel string `json:"channel"` |
| 338 | Message string `json:"message"` |
| 339 | // Wildcard and Catchall are the woodstar-carloku capability flips |
| 340 | // (Task 5), decoded as pointers so an ABSENT field means "leave |
| 341 | // unchanged" — the same tri-state rule apps place's -target and |
| 342 | // instances' converge flags already live by. nil never reaches |
| 343 | // SetClaimCapabilities: setDomainCapabilities below reads the |
| 344 | // claim's current values first and only overrides the one(s) the |
| 345 | // caller actually named. |
| 346 | Wildcard *bool `json:"wildcard,omitempty"` |
| 347 | Catchall *bool `json:"catchall,omitempty"` |
| 348 | // Platform asks for the operator's door (#209): with it, a hostname |
| 349 | // in the deployment's own zones (hostPlatform) may be attached. It |
| 350 | // lifts nothing else. Reserved names stay refused, and the caller |
| 351 | // must still be in this account (domainsGate). |
| 352 | Platform bool `json:"platform,omitempty"` |
| 353 | } |
| 354 | if !readJSON(w, r, &in) { |
| 355 | return |
| 356 | } |
| 357 | if s.cfg.HostClaim && (in.Wildcard != nil || in.Catchall != nil) { |
| 358 | jsonError(w, http.StatusConflict, claimRefusalText(errClaimAliasCapabilitiesUnsupported)) |
| 359 | return |
| 360 | } |
| 361 | message, ok := checkedActivityMessage(w, in.Message) |
| 362 | if !ok { |
| 363 | return |
| 364 | } |
| 365 | // Decided from the caller and the flag alone, before the hostname is |
| 366 | // read, so the answer cannot vary with what the deny list holds. |
| 367 | if in.Platform && !s.principalPlatformOperator(p) { |
| 368 | jsonError(w, http.StatusForbidden, platformOperatorMsg) |
| 369 | return |
| 370 | } |
| 371 | // "*.<parent>" is an instance catch-all (domaincatchall.go), not a |
| 372 | // hostname: it names no route, carries no capabilities and has no |
| 373 | // platform form, so every other field is refused rather than ignored. |
| 374 | // A person's act, like `carlos instances enable`: a catch-all is |
| 375 | // standing policy over every unclaimed name under parent, which a |
| 376 | // credential the app holds must not be able to set for itself. |
| 377 | if parent, ok := domains.ParseInstanceCatchallHost(in.Host); ok { |
| 378 | if !s.apiPerson(w, r) { |
| 379 | return |
| 380 | } |
| 381 | if in.Route != "" || in.Kind != "" || in.Channel != "" || in.Wildcard != nil || in.Catchall != nil || in.Platform { |
| 382 | jsonError(w, http.StatusBadRequest, |
| 383 | "an instance catch-all takes no route, kind, channel, wildcard, catchall or platform — it serves each instance at <label>."+parent) |
| 384 | return |
| 385 | } |
| 386 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 387 | defer cancel() |
| 388 | if ref := s.catchallAppReady(ctx, acct, app); ref != nil { |
| 389 | jsonError(w, ref.Status, ref.Msg) |
| 390 | return |
| 391 | } |
| 392 | created, ref := s.attachInstanceCatchall(ctx, acct, app, parent, principal, message) |
| 393 | if ref != nil { |
| 394 | jsonError(w, ref.Status, ref.Msg) |
| 395 | return |
| 396 | } |
| 397 | writeJSON(w, http.StatusOK, map[string]any{ |
| 398 | "host": domains.InstanceCatchallHost(parent), "route": "", "created": created, "instances": true, |
| 399 | }) |
| 400 | return |
| 401 | } |
| 402 | // Path-traversal defence stated as such: this hostname becomes a bucket |
| 403 | // key segment (domains/<host>.json) and, on the box, a registry alias row |
| 404 | // and a certificate name. Nothing below runs until it is a well-formed, |
| 405 | // lowercased, dot-bearing hostname. |
| 406 | host, ok := domains.NormalizeAliasHost(in.Host) |
| 407 | if !ok { |
| 408 | jsonError(w, http.StatusBadRequest, |
| 409 | "invalid hostname — attach a full hostname like tickets.example.com") |
| 410 | return |
| 411 | } |
| 412 | if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !in.Platform) || (s.directAppsDomainHost(host) && ((in.Wildcard != nil && *in.Wildcard) || (in.Catchall != nil && *in.Catchall))) { |
| 413 | jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| 414 | return |
| 415 | } |
| 416 | if in.Kind != "" && in.Route != "" { |
| 417 | jsonError(w, http.StatusBadRequest, |
| 418 | "route and kind are two different instructions — name a route to alias, or a kind to mint, not both") |
| 419 | return |
| 420 | } |
| 421 | if in.Kind != "" && in.Kind != "static" { |
| 422 | jsonError(w, http.StatusBadRequest, "kind must be \"static\"") |
| 423 | return |
| 424 | } |
| 425 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 426 | defer cancel() |
| 427 | // The caller's own PENDING first-route claim (#215): a plain re-attach |
| 428 | // is how it gets verified, and only the first-route core knows how — |
| 429 | // look for the proof, and declare the record once it is there. Without |
| 430 | // this the own-first-route short-circuit below would answer for it |
| 431 | // without ever looking, and the CLI would read that as attached. Own |
| 432 | // claim only, so it reveals nothing the list door does not. |
| 433 | if want := strings.ToLower(strings.TrimSpace(in.Route)); in.Kind == "" && (want == "" || want == host) { |
| 434 | if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && |
| 435 | cur.Account == acct.Sqid && cur.App == app && cur.Route == "" && cur.Pending { |
| 436 | in.Kind, in.Route = "static", "" |
| 437 | } |
| 438 | } |
| 439 | if in.Kind == "static" { |
| 440 | // No deployment-route check here: this branch delegates to |
| 441 | // attachDomain, which carries the guard immediately before each of |
| 442 | // its own AttachClaim calls — below attachChannel and every other |
| 443 | // gate that can refuse first. One placed HERE would sit above them |
| 444 | // and be read apart from the ordinary 409 by a prober sending a |
| 445 | // deliberately malformed channel (Task 5 review round 2). |
| 446 | // The shared core decides alias-vs-first-route itself (readyRouteHost |
| 447 | // is ITS question — no pre-read here): a ready route makes this an |
| 448 | // ordinary alias claim naming it, none makes the host the app's |
| 449 | // first route (claim + declared static record). The answer comes |
| 450 | // from the core's result: on the first-route branch the host IS the |
| 451 | // route the record just declared; on the alias branch it is the |
| 452 | // route the claim names. |
| 453 | res, ref := s.attachDomainAs(ctx, acct, app, host, in.Kind, in.Channel, principal, in.Platform, message) |
| 454 | if ref != nil { |
| 455 | setNoCapacityRetryAfter(w, ref) |
| 456 | jsonError(w, ref.Status, ref.Msg) |
| 457 | return |
| 458 | } |
| 459 | route := res.Claim.Route |
| 460 | if res.FirstRoute || (res.Pending && route == "") { |
| 461 | route = host |
| 462 | } |
| 463 | if res.OperationID != "" { |
| 464 | answer := attachAnswer(host, route, res.Created, res.Pending, res.Verify) |
| 465 | answer["operation_id"], answer["phase"] = res.OperationID, res.OperationPhase |
| 466 | writeJSON(w, http.StatusAccepted, answer) |
| 467 | return |
| 468 | } |
| 469 | writeJSON(w, http.StatusOK, attachAnswer(host, route, res.Created, res.Pending, res.Verify)) |
| 470 | return |
| 471 | } |
| 472 | // A first-route claim this app already holds stays first-route. Without |
| 473 | // this, a re-attach naming no route (how a member flips --wildcard or |
| 474 | // --catchall on it) reached resolveAliasRoute, which defaults to the |
| 475 | // app's single instance record — for a first-route app, the record the |
| 476 | // claim itself declared, at this very host — and AttachClaim then |
| 477 | // converged the claim into an alias of its own host. That self-alias |
| 478 | // still served, but every box pass logged it as shadowed by a real |
| 479 | // route, and detach no longer removed the record (its first-route |
| 480 | // branch keys on an empty Route), so the route outlived the claim. |
| 481 | // Naming the host itself as --route means the same thing and is treated |
| 482 | // the same way: a hostname cannot alias itself. |
| 483 | // |
| 484 | // Only the caller's OWN claim takes this branch, so it reveals nothing |
| 485 | // the list door does not already show them; every other case falls |
| 486 | // through to the ordinary path and its neutral refusals unchanged. |
| 487 | if want := strings.ToLower(strings.TrimSpace(in.Route)); want == "" || want == host { |
| 488 | if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && |
| 489 | cur.Account == acct.Sqid && cur.App == app && cur.Route == "" { |
| 490 | if in.Wildcard != nil || in.Catchall != nil { |
| 491 | if !s.setDomainCapabilities(w, r, ctx, acct, app, p, host, in.Wildcard, in.Catchall) { |
| 492 | return |
| 493 | } |
| 494 | } |
| 495 | writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": host, "created": false}) |
| 496 | return |
| 497 | } |
| 498 | } |
| 499 | route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route) |
| 500 | if !ok { |
| 501 | return |
| 502 | } |
| 503 | // The deployment-route refusal sits HERE, immediately before the claim |
| 504 | // write and strictly AFTER resolveAliasRoute — never above it. |
| 505 | // |
| 506 | // Byte-identical bodies are not enough on their own; ORDER is half of |
| 507 | // the neutrality (Task 5 review). When this check ran before |
| 508 | // resolveAliasRoute, the pre-existing "someone else holds it" 409 was |
| 509 | // reachable only after it, so a prober could tell the two apart by |
| 510 | // deliberately failing the LATER gate and reading which refusal came |
| 511 | // back first: |
| 512 | // |
| 513 | // {"host":"<probe>","route":"!!"} -> 409 if <probe> is a live route, |
| 514 | // 400 "invalid route hostname" if not |
| 515 | // {"host":"<probe>"} from an app with no instance records |
| 516 | // -> 409 if <probe> is a live route, |
| 517 | // 400 "specify -route: ..." if not |
| 518 | // |
| 519 | // Both are side-effect-free and enumerate the deployment's non-alias |
| 520 | // route set one hostname at a time, inside the ceiling — exactly the |
| 521 | // existence oracle this file's header forbids. Behind every gate that |
| 522 | // can refuse first, the two 409s are indistinguishable in status, body |
| 523 | // AND reachability. Anything added to this branch later belongs above |
| 524 | // this line, not below it. |
| 525 | // Plans 3a: custom domains need a plan that includes them (above |
| 526 | // heldElsewhere, per the rule). |
| 527 | if ref := s.customDomainRefusal(acct, host); ref != nil { |
| 528 | writeJSON(w, ref.Status, capOrError(ref)) |
| 529 | return |
| 530 | } |
| 531 | if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { |
| 532 | jsonError(w, ref.Status, ref.Msg) |
| 533 | return |
| 534 | } |
| 535 | created, changed, pending, err := s.attachDomainClaim(ctx, acct, app, domains.Claim{ |
| 536 | Host: host, Account: acct.Sqid, App: app, Route: route, |
| 537 | // UTC, like every other timestamp written to the bucket: these claims |
| 538 | // are read on boxes in other regions. |
| 539 | CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| 540 | // Wildcard and Catchall are deliberately NOT populated from `in` |
| 541 | // here, even though the request body may carry them: AttachClaim's |
| 542 | // create path marshals this literal verbatim, so any value placed on |
| 543 | // it lands with no CNAME gate and no delegation-zone check. The only |
| 544 | // legal writer of these two fields is SetClaimCapabilities, called |
| 545 | // below, AFTER this write has already established ownership (a |
| 546 | // review finding on Task 1 — an attach body smuggling |
| 547 | // "wildcard":true alongside host/route must not reach this literal). |
| 548 | }, time.Now(), s.domainProver(ctx, host, acct.Sqid)) |
| 549 | if errors.Is(err, domains.ErrHostUnavailable) { |
| 550 | // Exactly these bytes, whoever holds it: naming the holder — or even |
| 551 | // distinguishing "held by another app of yours" from "held by a |
| 552 | // stranger" — would make this door a hostname-ownership oracle. |
| 553 | jsonError(w, http.StatusConflict, hostUnavailableMsg) |
| 554 | return |
| 555 | } else if msg, ok := claimAliasRefusal(err); ok { |
| 556 | jsonError(w, http.StatusConflict, msg) |
| 557 | return |
| 558 | } else if errors.Is(err, hostops.ErrPending) { |
| 559 | w.Header().Set("Retry-After", "2") |
| 560 | jsonError(w, http.StatusConflict, claimRetireBusyMsg) |
| 561 | return |
| 562 | } else if err != nil { |
| 563 | log.Printf("console: api domains attach %s/%s %s: %v", acct.Sqid, app, host, err) |
| 564 | jsonError(w, http.StatusBadGateway, "could not attach the hostname — try again") |
| 565 | return |
| 566 | } |
| 567 | // Only an actual WRITE audits and announces (spec §5), the same rule |
| 568 | // detach observes below. `created` is the caller's answer; `changed` is |
| 569 | // this one, and they differ on the converge — a re-attach that repointed |
| 570 | // the route created nothing but must reach the fleet. The no-op case is |
| 571 | // not hypothetical: titogo's router re-attaches every hostname on each |
| 572 | // reload pass, so a bump per call would keep every box converging onto |
| 573 | // nothing and an audit row per call would bury the tenant's log. |
| 574 | if changed { |
| 575 | s.auditActivity(acct, app, principal, Activity{ |
| 576 | Action: "domain-attach", TargetType: "instance", Target: route, Message: message, |
| 577 | Detail: "attached hostname " + host + " -> " + route + platformNote(in.Platform) + pendingNote(pending) + " (cli)", |
| 578 | }) |
| 579 | // The claim is what a box's domains pass mints an alias row from, so |
| 580 | // this is a box-consumed write (carlos-deploy §3): announce it, and |
| 581 | // the hostname is serving seconds after the call instead of on the |
| 582 | // hour. Strictly after the write and the audit — a serial that moved |
| 583 | // for a write that failed would send every box looking for nothing. |
| 584 | // Not for a pending claim, which every box skips (#215). |
| 585 | if !pending { |
| 586 | s.bumpSerial(ctx) |
| 587 | } |
| 588 | } |
| 589 | // Capability flip: only when the caller actually named one of the two |
| 590 | // fields — a bare attach (both nil) must never touch Wildcard/Catchall, |
| 591 | // the same "absent means unchanged" contract the CLI's fs.Visit-gated |
| 592 | // flags rely on. |
| 593 | if in.Wildcard != nil || in.Catchall != nil { |
| 594 | if !s.setDomainCapabilities(w, r, ctx, acct, app, p, host, in.Wildcard, in.Catchall) { |
| 595 | return |
| 596 | } |
| 597 | } |
| 598 | writeJSON(w, http.StatusOK, attachAnswer(host, route, created, pending, s.domainChallenge(host, acct.Sqid))) |
| 599 | } |
| 600 | |
| 601 | // attachAnswer is the attach door's 200 body. verified is always present |
| 602 | // (#215): false means the claim is stored but reserved, not served, until |
| 603 | // the TXT record in verify is published and the attach is repeated. verify |
| 604 | // is sent only then, so a client that predates the field sees exactly the |
| 605 | // body it always did for an ordinary attach. |
| 606 | func attachAnswer(host, route string, created, pending bool, verify dnsRecord) map[string]any { |
| 607 | out := map[string]any{"host": host, "route": route, "created": created, "verified": !pending} |
| 608 | if pending { |
| 609 | out["verify"] = verify |
| 610 | } |
| 611 | return out |
| 612 | } |
| 613 | |
| 614 | // wildFailMsg is the ONE 429 body both the peek (wildFailTripped) and the |
| 615 | // consume (allowWildFail) refusals in setDomainCapabilities answer with — a |
| 616 | // caller must not be able to tell which of the two turned it away, since |
| 617 | // both mean exactly the same thing to them: stop retrying for a minute. |
| 618 | const wildFailMsg = "too many wildcard attempts with no _acme-challenge CNAME in place — wait a minute and retry" |
| 619 | |
| 620 | // setDomainCapabilities applies an attach body's optional wildcard/catchall |
| 621 | // flip AFTER the underlying claim attach above has already succeeded and |
| 622 | // established ownership. ok=false means a response has already been |
| 623 | // written — the same resolveAliasRoute convention this file already uses. |
| 624 | // |
| 625 | // Absent fields mean "leave unchanged": the door reads the claim's CURRENT |
| 626 | // capabilities as the baseline and overrides only the field(s) the caller |
| 627 | // actually named, so a caller flipping catchall alone cannot accidentally |
| 628 | // clear an already-enabled wildcard, and vice versa. |
| 629 | func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string, p Principal, host string, wantWildcard, wantCatchall *bool) (ok bool) { |
| 630 | if s.cfg.HostClaim { |
| 631 | jsonError(w, http.StatusConflict, claimRefusalText(errClaimAliasCapabilitiesUnsupported)) |
| 632 | return false |
| 633 | } |
| 634 | principal := auditActor(p, acct) |
| 635 | cur, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| 636 | if err != nil { |
| 637 | log.Printf("console: api domains capabilities %s/%s %s: read after attach: %v", acct.Sqid, app, host, err) |
| 638 | jsonError(w, http.StatusBadGateway, "could not update the hostname's capabilities — try again") |
| 639 | return false |
| 640 | } |
| 641 | wildcard, catchall := cur.Wildcard, cur.Catchall |
| 642 | if wantWildcard != nil { |
| 643 | wildcard = *wantWildcard |
| 644 | } |
| 645 | if wantCatchall != nil { |
| 646 | catchall = *wantCatchall |
| 647 | } |
| 648 | // enabling is the false->true TRANSITION only — re-sending wildcard:true |
| 649 | // for a claim that already has it must not re-run the gate. Two reasons: |
| 650 | // the CNAME lookup is a live, sequential, request-path DNS round trip |
| 651 | // (IMPORTANT-2, Task 5 review), and titogo's router re-attaches every |
| 652 | // hostname (with whatever capability flags it was minted with) on every |
| 653 | // reload pass — gating on the transition is what keeps a resolver blip |
| 654 | // from 422ing an otherwise idempotent, already-successful re-attach. |
| 655 | enabling := wantWildcard != nil && *wantWildcard && !cur.Wildcard |
| 656 | switch { |
| 657 | case wantWildcard != nil && !*wantWildcard: |
| 658 | // Disabling wildcard cascades catchall off in the SAME call — |
| 659 | // turning off never requires DNS, and a caller disabling wildcard is |
| 660 | // not asking for a 422. The door normalizes here rather than calling |
| 661 | // SetClaimCapabilities with catchall still true and letting its own |
| 662 | // ErrCatchallNeedsWildcard refuse a request that was never asking to |
| 663 | // enable anything. |
| 664 | catchall = false |
| 665 | case enabling: |
| 666 | // Enabling wildcard requires a configured delegation zone and a live |
| 667 | // _acme-challenge CNAME delegating to it — the claim-derived- |
| 668 | // wildcards spec's ACME story (§4): this deployment's own zone |
| 669 | // issues the wildcard cert, so it must control the challenge record. |
| 670 | if s.delegationZone == "" { |
| 671 | jsonError(w, http.StatusUnprocessableEntity, "deployment has no delegation zone configured") |
| 672 | return false |
| 673 | } |
| 674 | // PEEKED before the DNS round trip (C33 review): once a principal |
| 675 | // has already tripped wildFail, running the lookup again buys the |
| 676 | // caller nothing but costs the console another live, sequential |
| 677 | // outbound DNS call. A caller who has never failed never trips |
| 678 | // this — wildFailTripped only ever reads TRUE from a count that |
| 679 | // CONSUMED (below) put there — so this can never turn away a |
| 680 | // legitimate, exempt caller. |
| 681 | if s.wildFailTripped(p, time.Now()) { |
| 682 | jsonError(w, http.StatusTooManyRequests, wildFailMsg) |
| 683 | return false |
| 684 | } |
| 685 | if !s.delegationCNAMEDelegated(ctx, host) { |
| 686 | // CONSUMED only here, on an ACTUAL failure (spec §4a). A |
| 687 | // succeeding enable is once-per-domain and exempt (domainsGate |
| 688 | // carries only the convergence ceiling, not a workload budget); |
| 689 | // a failure is what the peek above exists to eventually stop |
| 690 | // paying for. Counted AFTER the lookup, so the bound is on |
| 691 | // FAILURES rather than on attempts — a caller whose CNAME is |
| 692 | // correct never meets it at all. |
| 693 | if !s.allowWildFail(p, time.Now()) { |
| 694 | jsonError(w, http.StatusTooManyRequests, wildFailMsg) |
| 695 | return false |
| 696 | } |
| 697 | jsonError(w, http.StatusUnprocessableEntity, |
| 698 | "create CNAME _acme-challenge."+host+" -> _acme-challenge."+host+"."+s.delegationZone+", then retry") |
| 699 | return false |
| 700 | } |
| 701 | } |
| 702 | if s.directAppsDomainHost(host) && (wildcard || catchall) { |
| 703 | jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| 704 | return false |
| 705 | } |
| 706 | capChanged, err := domains.SetClaimCapabilities(ctx, s.cfg.Releases, host, acct.Sqid, app, wildcard, catchall) |
| 707 | if errors.Is(err, domains.ErrCatchallNeedsWildcard) { |
| 708 | jsonError(w, http.StatusUnprocessableEntity, err.Error()) |
| 709 | return false |
| 710 | } else if errors.Is(err, domains.ErrHostUnavailable) { |
| 711 | // The family's byte-identical 404 discipline, copied verbatim (not |
| 712 | // jsonError — a distinct shape here would itself be a tell). This |
| 713 | // fires only after the attach above already proved ownership, so in |
| 714 | // practice it means the claim changed hands between that write and |
| 715 | // this one — vanishingly rare, but the response must still leak |
| 716 | // nothing to whoever is now asking. |
| 717 | http.NotFound(w, r) |
| 718 | return false |
| 719 | } else if err != nil { |
| 720 | log.Printf("console: api domains capabilities %s/%s %s: %v", acct.Sqid, app, host, err) |
| 721 | jsonError(w, http.StatusBadGateway, "could not update the hostname's capabilities — try again") |
| 722 | return false |
| 723 | } |
| 724 | if capChanged { |
| 725 | s.audit(acct, app, principal, "domain-capabilities", |
| 726 | fmt.Sprintf("set %s wildcard=%v catchall=%v (cli)", host, wildcard, catchall)) |
| 727 | s.bumpSerial(ctx) |
| 728 | // #165's exported index-writer is called here once it lands — a |
| 729 | // capability flip must reach the fast-path claims index (see plan: |
| 730 | // claim-derived-wildcards, Task 5 addendum). |
| 731 | } |
| 732 | return true |
| 733 | } |
| 734 | |
| 735 | // delegationCNAMEDelegated reports whether host's _acme-challenge CNAME |
| 736 | // already points at this deployment's delegation zone — the same check the |
| 737 | // attach gate and the list stamp both use, so a claim's delegation_status |
| 738 | // can never disagree with what an attach would decide right now. Both sides |
| 739 | // are trailing-dot-normalized before comparing: net.DefaultResolver's |
| 740 | // LookupCNAME answers with a fully-qualified name (trailing dot), a hand- |
| 741 | // typed zone rarely carries one, and the comparison must not care which. |
| 742 | // The comparison is also case-INsensitive (strings.EqualFold): DNS names |
| 743 | // compare case-insensitively by definition (RFC 4343), and a resolver |
| 744 | // answers in the record's own authored case — a customer who typed their |
| 745 | // CNAME target in mixed case must not read as a permanently broken |
| 746 | // delegation. |
| 747 | func (s *Server) delegationCNAMEDelegated(ctx context.Context, host string) bool { |
| 748 | got, err := s.lookupCNAME(ctx, "_acme-challenge."+host) |
| 749 | if err != nil { |
| 750 | return false |
| 751 | } |
| 752 | want := "_acme-challenge." + host + "." + s.delegationZone |
| 753 | return strings.EqualFold(strings.TrimSuffix(got, "."), strings.TrimSuffix(want, ".")) |
| 754 | } |
| 755 | |
| 756 | // maxPathsBody is the byte cap on a prefix-map publish, and it is the ONE |
| 757 | // door in this family that cannot live inside readJSON's 8KiB: the ceiling is |
| 758 | // MaxPathsPerClaim entries, each a segment of up to 255 bytes pointing at a |
| 759 | // hostname of up to 253, which is a legitimate ~5MB body. Sized with headroom |
| 760 | // above that rather than to it, so the refusal a caller at the ceiling meets |
| 761 | // is the 422 that NAMES the cap — never a 413 whose fix ("split the request") |
| 762 | // is wrong advice for a map that is wholesale by contract. |
| 763 | const maxPathsBody = 8 << 20 |
| 764 | |
| 765 | // handleAPIDomainPaths answers `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`: |
| 766 | // publish the WHOLE prefix map for a hostname the app already claims. |
| 767 | // |
| 768 | // Wholesale by contract. The caller regenerates the map from its own database |
| 769 | // and publishes the result, so this replaces rather than merges — that is what |
| 770 | // makes a republish a full repair and lets a removal express a rollback. There |
| 771 | // is deliberately no per-prefix add or delete verb: two ways to write the same |
| 772 | // state is two ways for it to drift from the caller's own. |
| 773 | // |
| 774 | // It writes ONE field of an existing claim and creates nothing, which is what |
| 775 | // separates it from attach: a hostname this app does not already hold has no |
| 776 | // prefix map to publish, and says so with the family's neutral 404. |
| 777 | func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) { |
| 778 | p, acct, app, ok := s.domainsGate(w, r) |
| 779 | if !ok { |
| 780 | return |
| 781 | } |
| 782 | // Straight after domainsGate, exactly as the attach door places its own: |
| 783 | // after the family's byte-identical 404, before any read or write. A |
| 784 | // prefix map on the control plane's own hostname hands a slice of the |
| 785 | // console's URL space to another route — /admin served by whatever the |
| 786 | // account points it at — and that is not something a CI credential |
| 787 | // should be able to decide. SERVICE-ONLY, and the split is attach's: a |
| 788 | // person doing it is an ordinary operator act, a machine doing it is the |
| 789 | // console's own address being carved up by something with no standing. |
| 790 | if s.refuseServiceControlPlane(w, p, acct.ID, app, "publish path routes for it") { |
| 791 | return |
| 792 | } |
| 793 | principal := auditActor(p, acct) |
| 794 | // Shape-checked BEFORE the key builder, exactly as the detach and get |
| 795 | // doors do: a {host} segment arrives percent-decoded, so |
| 796 | // "..%2f..%2finstances.json" would reach ClaimKey as a path with |
| 797 | // separators in it. There is no path through this handler on which an |
| 798 | // unnormalized host reaches the store. |
| 799 | // |
| 800 | // And before the BODY, unlike attach — whose hostname arrives in the body |
| 801 | // and cannot be checked any earlier. Here the host is in the path, so |
| 802 | // checking it first means a request naming a malformed one is refused |
| 803 | // without first buffering up to maxPathsBody of it. No neutrality cost: |
| 804 | // this check reads the URL and nothing else, so its answer cannot depend |
| 805 | // on who holds the claim. |
| 806 | host, ok := domains.NormalizeAliasHost(r.PathValue("host")) |
| 807 | if !ok { |
| 808 | jsonError(w, http.StatusBadRequest, "invalid hostname") |
| 809 | return |
| 810 | } |
| 811 | // A prefix map on a platform hostname routes slices of the operator's |
| 812 | // own site, so it is the operator's to publish (#209). From the name |
| 813 | // alone, before the body or the claim is read: no neutrality cost. |
| 814 | if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.principalPlatformOperator(p) { |
| 815 | jsonError(w, http.StatusForbidden, platformOperatorMsg) |
| 816 | return |
| 817 | } |
| 818 | // A POINTER to the map, so that an ABSENT paths field is refused rather |
| 819 | // than read as an empty one. The distinction matters here in a way it |
| 820 | // does not on a merging door: this write is wholesale, so a body whose |
| 821 | // field name is misspelled would otherwise be an empty publish — a silent |
| 822 | // clearing of every prefix on the hostname, answered with 200. An empty |
| 823 | // map is a legitimate instruction and still means exactly that; it just |
| 824 | // has to be said. |
| 825 | var in struct { |
| 826 | Paths *map[string]string `json:"paths"` |
| 827 | } |
| 828 | if !readJSONLimit(w, r, &in, maxPathsBody) { |
| 829 | return |
| 830 | } |
| 831 | if in.Paths == nil { |
| 832 | jsonError(w, http.StatusBadRequest, |
| 833 | "send the whole prefix map as {\"paths\": {...}} — {\"paths\": {}} is how you clear it") |
| 834 | return |
| 835 | } |
| 836 | paths := *in.Paths |
| 837 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 838 | defer cancel() |
| 839 | changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, paths) |
| 840 | if errors.Is(err, domains.ErrTooManyPaths) { |
| 841 | jsonError(w, http.StatusUnprocessableEntity, |
| 842 | fmt.Sprintf("a hostname may carry at most %d path prefixes", domains.MaxPathsPerClaim)) |
| 843 | return |
| 844 | } else if errors.Is(err, domains.ErrBadPathPrefix) { |
| 845 | jsonError(w, http.StatusUnprocessableEntity, |
| 846 | "each path prefix must be a single bare segment: \"paulca\", not \"/paulca\"") |
| 847 | return |
| 848 | } else if errors.Is(err, domains.ErrBadPathTarget) { |
| 849 | // The caller's error, not ours, so it must not land in the 502 below |
| 850 | // telling them to retry a body that will fail identically forever. |
| 851 | // The wrapped message names the offending entry — the one thing that |
| 852 | // makes a 10,000-entry map fixable. |
| 853 | jsonError(w, http.StatusUnprocessableEntity, err.Error()) |
| 854 | return |
| 855 | } else if errors.Is(err, domains.ErrHostUnavailable) { |
| 856 | // The family's byte-identical 404 discipline, copied verbatim from |
| 857 | // setDomainCapabilities (not jsonError — a distinct shape here would |
| 858 | // itself be a tell): a caller who does not hold this claim cannot |
| 859 | // tell "somebody else holds it" from "nobody has claimed it". |
| 860 | http.NotFound(w, r) |
| 861 | return |
| 862 | } else if err != nil { |
| 863 | log.Printf("console: api domains paths %s/%s %s: %v", acct.Sqid, app, host, err) |
| 864 | jsonError(w, http.StatusBadGateway, "could not update the hostname's path routes — try again") |
| 865 | return |
| 866 | } |
| 867 | if changed { |
| 868 | // The COUNT, never the map: a published map runs to MaxPathsPerClaim |
| 869 | // entries, and an audit row is a sentence an operator reads, not a |
| 870 | // copy of the payload. What the trail has to answer is "who moved |
| 871 | // this hostname's routing, and when". |
| 872 | s.audit(acct, app, principal, "domain-paths", |
| 873 | fmt.Sprintf("published %d path prefixes for %s (cli)", len(paths), host)) |
| 874 | // A box-consumed write like every other claim write here: the |
| 875 | // reconcile pass derives route_paths from this field, so announce it |
| 876 | // and the flipped accounts are serving seconds after the call |
| 877 | // instead of on the hour. Gated on `changed` for the reason |
| 878 | // SetClaimPaths has a no-op check at all — the caller republishes |
| 879 | // wholesale as a repair, and a bump per republish would keep every |
| 880 | // box converging onto nothing. |
| 881 | s.bumpSerial(ctx) |
| 882 | } |
| 883 | // The normalized host is the one thing the caller could not already |
| 884 | // know; the map it just sent is not echoed back, both because it is |
| 885 | // megabytes of the caller's own bytes and because nothing here derives |
| 886 | // or alters it. `changed` is the same answer `created` and `removed` |
| 887 | // give on the doors either side of this one. |
| 888 | writeJSON(w, http.StatusOK, map[string]any{ |
| 889 | "host": host, |
| 890 | "prefixes": len(paths), |
| 891 | "changed": changed, |
| 892 | }) |
| 893 | } |
| 894 | |
| 895 | // pathsOrEmpty renders a claim's prefix map for the wire as an object even |
| 896 | // when it is absent. Claim.Paths is omitempty, so nil and empty are one |
| 897 | // stored state; letting nil marshal to `null` would make a consumer tell two |
| 898 | // spellings of the same answer apart before it could iterate. |
| 899 | func pathsOrEmpty(paths map[string]string) map[string]string { |
| 900 | if paths == nil { |
| 901 | return map[string]string{} |
| 902 | } |
| 903 | return paths |
| 904 | } |
| 905 | |
| 906 | // handleAPIDomainDetach releases a hostname. Always 200, whatever was there: |
| 907 | // `removed` reports whether this call is what removed it, and the three ways |
| 908 | // of removing nothing — already gone, never claimed, claimed by someone else |
| 909 | // — are indistinguishable on the wire (DetachClaim's own neutrality, spec |
| 910 | // §5). Only an EFFECTIVE removal audits and bumps the serial: a no-op changed |
| 911 | // nothing for a box to converge onto, and an audit row per probe would let a |
| 912 | // prober write to someone else's account log. |
| 913 | func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| 914 | p, acct, app, ok := s.domainsGate(w, r) |
| 915 | if !ok { |
| 916 | return |
| 917 | } |
| 918 | // The NINTH control-plane door, and the same fuse as the instance |
| 919 | // delete: the first-route branch below calls the very |
| 920 | // provision.DeleteRecord that retires a route, on the same app, and this |
| 921 | // door is reachable by a service credential (svcrole.Publish OR |
| 922 | // svcrole.Instance). Guarding the instance family and leaving this open |
| 923 | // would be a guard with a published way round it, which reads as |
| 924 | // protection and is not — the same reasoning the account-deletion |
| 925 | // refusal was built on. |
| 926 | // |
| 927 | // OUTRIGHT, and NOT conditional on the record-deleting branch. Two |
| 928 | // reasons. A conditional guard is a bug surface: it would have to |
| 929 | // re-derive "does this claim mint the route" ahead of the code that |
| 930 | // derives it, and the two would drift. And detaching a domain from the |
| 931 | // control plane is not an act anyone should perform without clearing the |
| 932 | // flag first — a hostname the fleet reaches the console by is not |
| 933 | // released in passing. |
| 934 | // |
| 935 | // Straight after domainsGate — after the family's byte-identical 404, |
| 936 | // before the hostname is even normalized — so no branch reaches a write |
| 937 | // without passing it, and a flagged app's answer here is CONSTANT across |
| 938 | // valid and malformed hosts rather than leaking shape-validity. |
| 939 | if s.refuseControlPlane(w, acct, app, "detached") { |
| 940 | return |
| 941 | } |
| 942 | principal := auditActor(p, acct) |
| 943 | // An instance catch-all is released by a person, as it was set by one, |
| 944 | // and never through the first-route branch below: a catch-all has an |
| 945 | // empty Route too, and that branch deletes a provision record. |
| 946 | if parent, ok := domains.ParseInstanceCatchallHost(r.PathValue("host")); ok { |
| 947 | if !s.apiPerson(w, r) { |
| 948 | return |
| 949 | } |
| 950 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 951 | defer cancel() |
| 952 | removed, err := s.detachInstanceCatchall(ctx, acct, app, parent, principal) |
| 953 | if msg, ok := claimAliasRefusal(err); ok { |
| 954 | jsonError(w, http.StatusConflict, msg) |
| 955 | return |
| 956 | } |
| 957 | if err != nil { |
| 958 | log.Printf("console: api domains detach %s/%s *.%s: %v", acct.Sqid, app, parent, err) |
| 959 | jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again") |
| 960 | return |
| 961 | } |
| 962 | writeJSON(w, http.StatusOK, map[string]any{"host": domains.InstanceCatchallHost(parent), "removed": removed}) |
| 963 | return |
| 964 | } |
| 965 | // Shape-checked before the key builder, not after: a {host} segment |
| 966 | // arrives percent-decoded, so "..%2f..%2finstances.json" reaches this |
| 967 | // handler as a path with separators in it, and ClaimKey would happily |
| 968 | // join it. There is no path through this handler on which an |
| 969 | // unnormalized host reaches the store. |
| 970 | host, ok := domains.NormalizeAliasHost(r.PathValue("host")) |
| 971 | if !ok { |
| 972 | jsonError(w, http.StatusBadRequest, "invalid hostname") |
| 973 | return |
| 974 | } |
| 975 | // detachDomain's rule (#209): a platform hostname's claim is the |
| 976 | // operator's to release. From the name alone, before the claim is read, |
| 977 | // so the door's quiet-in-both-directions answer is untouched for every |
| 978 | // hostname a member could hold. |
| 979 | if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.principalPlatformOperator(p) { |
| 980 | jsonError(w, http.StatusForbidden, platformOperatorMsg) |
| 981 | return |
| 982 | } |
| 983 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 984 | defer cancel() |
| 985 | // The first-route half (#82): a claim with an empty Route minted the |
| 986 | // app's own primary route via a declared provision record, and that |
| 987 | // record must go BEFORE the claim — the mirror of attach's claim-then- |
| 988 | // record order, so a failure here cannot leave a record materializing a |
| 989 | // route for a host anyone may now claim. Only the caller's own |
| 990 | // first-route claim takes this branch; every other shape (absent, |
| 991 | // another owner's, an alias) skips it silently and DetachClaim's own |
| 992 | // neutrality answers as before. |
| 993 | if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && |
| 994 | cur.Account == acct.Sqid && cur.App == app && cur.Route == "" { |
| 995 | if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, principal, cur.RecordIncarnation); err != nil { |
| 996 | if accepted, ok := claimRetireAcceptedOf(err); ok { |
| 997 | s.audit(acct, app, principal, "domain-detach", "detach of hostname "+host+" accepted, operation "+accepted.operation+" (cli)") |
| 998 | s.bumpSerial(ctx) |
| 999 | } |
| 1000 | if s.writeClaimPending(w, err) { |
| 1001 | return |
| 1002 | } |
| 1003 | log.Printf("console: api domains detach %s/%s %s: record delete: %v", acct.Sqid, app, host, err) |
| 1004 | jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again") |
| 1005 | return |
| 1006 | } |
| 1007 | } |
| 1008 | removed, err := s.detachDomainClaim(ctx, host, acct.Sqid, app) |
| 1009 | if msg, ok := claimAliasRefusal(err); ok { |
| 1010 | jsonError(w, http.StatusConflict, msg) |
| 1011 | return |
| 1012 | } |
| 1013 | if errors.Is(err, hostops.ErrContract) { |
| 1014 | jsonError(w, http.StatusConflict, err.Error()) |
| 1015 | return |
| 1016 | } |
| 1017 | if err != nil { |
| 1018 | log.Printf("console: api domains detach %s/%s %s: %v", acct.Sqid, app, host, err) |
| 1019 | jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again") |
| 1020 | return |
| 1021 | } |
| 1022 | if removed { |
| 1023 | s.audit(acct, app, principal, "domain-detach", "detached hostname "+host+" (cli)") |
| 1024 | // Taking a hostname down is the direction people want FAST. |
| 1025 | s.bumpSerial(ctx) |
| 1026 | } |
| 1027 | writeJSON(w, http.StatusOK, map[string]any{"host": host, "removed": removed}) |
| 1028 | } |
| 1029 | |
| 1030 | // domainListItem is one row of the list answer — the claim's desired state |
| 1031 | // (host, route) joined with what the fleet actually reports for it. Status is |
| 1032 | // "live" or "pending" and never absent; the two reading columns omit when |
| 1033 | // unstamped, the same unknown-stays-absent convention status.AliasReading |
| 1034 | // itself uses. Guidance is the point-your-DNS-here instruction, present |
| 1035 | // exactly when DNSStatus is "" (no answer yet) or "elsewhere" (a real, |
| 1036 | // wrong answer) — never "pointing" (nothing to fix) and never "unresolved" |
| 1037 | // (our lookup failed; telling the member to create a record would blame |
| 1038 | // them for our outage). Same condition as the console Settings page |
| 1039 | // (domainGuidance, view.go), so `carlos deploy`'s DNS-guidance exit and the |
| 1040 | // page a member opens next can never tell them two different stories. |
| 1041 | // Additive: a client that predates it simply never decodes it. |
| 1042 | type domainListItem struct { |
| 1043 | Host string `json:"host"` |
| 1044 | Route string `json:"route"` |
| 1045 | Status string `json:"status"` |
| 1046 | DNSStatus string `json:"dns_status,omitempty"` |
| 1047 | CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| 1048 | Guidance string `json:"guidance,omitempty"` |
| 1049 | // Wildcard and Catchall (Task 5) copy the claim's own capability bits |
| 1050 | // verbatim — always present, true or false, the same "known state" |
| 1051 | // convention Created/Removed use on the attach/detach answers (never |
| 1052 | // omitted the way an unstamped reading is). |
| 1053 | Wildcard bool `json:"wildcard"` |
| 1054 | Catchall bool `json:"catchall"` |
| 1055 | // DelegationStatus stamps "delegated"/"missing" from the SAME CNAME |
| 1056 | // check the attach gate uses, live on every list call — never cached |
| 1057 | // from whenever wildcard was enabled. Omitted (not "unknown") when the |
| 1058 | // deployment has no delegation zone configured, or the claim doesn't |
| 1059 | // have Wildcard: there is nothing to report in either case. |
| 1060 | DelegationStatus string `json:"delegation_status,omitempty"` |
| 1061 | // State is #299's single derived lifecycle word, with Reason (a stable |
| 1062 | // machine slug) and Detail (a human sentence) beside it. Always present. |
| 1063 | // |
| 1064 | // Status above is now LEGACY and frozen: it means "some non-stale box |
| 1065 | // reported ANY reading", which is why a row can read status "live" while |
| 1066 | // state is "misdirected". Both are kept because the wire is additive, but |
| 1067 | // every first-party surface reads State. |
| 1068 | State string `json:"state"` |
| 1069 | Reason string `json:"reason,omitempty"` |
| 1070 | Detail string `json:"detail,omitempty"` |
| 1071 | // CheckedAt is when the reporting box last RESOLVED this hostname (unix |
| 1072 | // seconds), carried through from the box stamp. It is what makes a |
| 1073 | // mirrored state ageable: without it a consumer cannot tell a state |
| 1074 | // confirmed a minute ago from one confirmed before an outage began, and |
| 1075 | // "live" is not monotonic — a customer can change their DNS after the |
| 1076 | // fact. Omitted when no box has stamped a check yet. |
| 1077 | CheckedAt int64 `json:"checked_at,omitempty"` |
| 1078 | // Records is the structured form of Guidance — the same advice as data, |
| 1079 | // so an app need not parse the sentence. Records sharing a "set" are one |
| 1080 | // alternative; a client picks one set per name. |
| 1081 | Records []dnsRecord `json:"records,omitempty"` |
| 1082 | // Instances marks an instance catch-all row ("*.<parent>", |
| 1083 | // domaincatchall.go). Via names the catch-all a row was derived from: |
| 1084 | // such a row is a name the fleet reports serving for one of the app's |
| 1085 | // instances, not a claim of its own, so it cannot be detached by name. |
| 1086 | // Both additive and omitted otherwise. |
| 1087 | Instances bool `json:"instances,omitempty"` |
| 1088 | Via string `json:"via,omitempty"` |
| 1089 | // Reserves, on an instance catch-all's row, is which one-label names |
| 1090 | // under its parent other accounts are refused: "all" once the account |
| 1091 | // has proved the parent in DNS (domains.CatchallProofName), otherwise |
| 1092 | // "instances" — only the names of instances that exist. Additive. |
| 1093 | Reserves string `json:"reserves,omitempty"` |
| 1094 | // Verified is false for a claim whose owner has not yet proved control |
| 1095 | // of the hostname (#215); state is then "unverified" and Records carries |
| 1096 | // the TXT record that proves it, marked required. Always present. |
| 1097 | Verified bool `json:"verified"` |
| 1098 | } |
| 1099 | |
| 1100 | // handleAPIDomainList answers `carlos domains list`: this app's claims, each |
| 1101 | // joined to the box readings sweep so the command alone verifies its own |
| 1102 | // result (the no-infra rule) — DNS status and cert expiry as the box stamped |
| 1103 | // them, or "pending" for a claim no live box has materialized yet. |
| 1104 | func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| 1105 | _, acct, app, ok := s.instanceGate(w, r) |
| 1106 | if !ok { |
| 1107 | return |
| 1108 | } |
| 1109 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 1110 | defer cancel() |
| 1111 | // Claims are keyed globally (domains/<host>.json — the key IS the |
| 1112 | // cross-account uniqueness gate), so the tenant boundary is this filter |
| 1113 | // and nothing else: every row that reaches the answer matched BOTH this |
| 1114 | // account's sqid and this app. |
| 1115 | claims, err := s.claims.List(ctx, s.cfg.Releases) |
| 1116 | if err != nil { |
| 1117 | log.Printf("console: api domains list %s/%s: %v", acct.Sqid, app, err) |
| 1118 | jsonError(w, http.StatusBadGateway, "could not list the hostnames") |
| 1119 | return |
| 1120 | } |
| 1121 | now := time.Now() |
| 1122 | readings := aliasReadings(ctx, s.cfg.Releases, now) |
| 1123 | // The issuer's warm-outcome records (#299 §3e), also once per call. A |
| 1124 | // record exists only while a host is FAILING, so this listing is one |
| 1125 | // object per broken hostname in the deployment and nothing at all in a |
| 1126 | // deployment where every domain works — it does not reintroduce the |
| 1127 | // per-claim cost the per-host door exists to avoid. |
| 1128 | warm := warmRecords(ctx, s.cfg.Releases) |
| 1129 | // Read ONCE per call, not per claim: dnsTargets folds the deployment's |
| 1130 | // edge set, and a per-claim read would refold it for every hostname. |
| 1131 | targets := s.dnsTargets() |
| 1132 | // This app's claims, host-sorted BEFORE the loop below — not just for |
| 1133 | // the answer's own stable order (the Store.List contract makes no |
| 1134 | // promise, and a terminal listing that shuffles between calls reads as |
| 1135 | // churn), but because the sort order also decides which claims fall |
| 1136 | // inside domainListCNAMELookupCap: sorting after the loop would let the |
| 1137 | // store's arbitrary iteration order pick a different 8 hosts every call. |
| 1138 | var mine []domains.Claim |
| 1139 | claimed := make(map[string]bool, len(claims)) |
| 1140 | for _, c := range claims { |
| 1141 | claimed[c.Host] = true |
| 1142 | if c.Account == acct.Sqid && c.App == app { |
| 1143 | mine = append(mine, c) |
| 1144 | } |
| 1145 | } |
| 1146 | sort.Slice(mine, func(i, j int) bool { return mine[i].Host < mine[j].Host }) |
| 1147 | out := make([]domainListItem, 0, len(mine)) |
| 1148 | lookups := 0 |
| 1149 | for _, c := range mine { |
| 1150 | // Each delegation stamp is a live, sequential, uncancellable-per-call |
| 1151 | // DNS round trip on the request path (lookupCNAME) — capped at |
| 1152 | // domainListCNAMELookupCap per list call so an app with dozens of |
| 1153 | // wildcard-enabled hostnames cannot turn one `carlos domains list` |
| 1154 | // into dozens of resolver round trips. Hosts beyond the cap (in the |
| 1155 | // same host-sorted order the answer itself uses) simply omit |
| 1156 | // delegation_status — the same "nothing to report yet" reading a |
| 1157 | // disabled wildcard or an unset zone already gets, never an error. |
| 1158 | if c.Instances { |
| 1159 | out = append(out, s.catchallListItems(ctx, acct, app, c, claimed, readings, warm, targets, now)...) |
| 1160 | continue |
| 1161 | } |
| 1162 | delegation := c.Wildcard && s.delegationZone != "" && lookups < domainListCNAMELookupCap |
| 1163 | if delegation { |
| 1164 | lookups++ |
| 1165 | } |
| 1166 | out = append(out, s.domainItem(ctx, c, readings, warm[c.Host], targets, now, delegation)) |
| 1167 | } |
| 1168 | writeJSON(w, http.StatusOK, map[string]any{ |
| 1169 | "domains": out, |
| 1170 | // The self-describing block (#299 §2c). An app reads managed_domains |
| 1171 | // to know the platform owns verification and certificates here, and |
| 1172 | // stops implementing them itself. |
| 1173 | "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| 1174 | }) |
| 1175 | } |
| 1176 | |
| 1177 | // domainItem builds ONE claim's answer row: the claim joined to what the |
| 1178 | // fleet reports for it, plus the derived state, guidance and records. |
| 1179 | // |
| 1180 | // Shared by the list door and the per-host door deliberately. The two answer |
| 1181 | // the same question about the same hostname and must never derive it two |
| 1182 | // ways — a consumer that refreshes one host and compares it against a listed |
| 1183 | // row has to see the same verdict, or a mirrored status flaps for reasons |
| 1184 | // that have nothing to do with the domain. |
| 1185 | // |
| 1186 | // delegation says whether to spend a live CNAME lookup on this row. The list |
| 1187 | // door caps how many it will spend per call; the per-host door always can, |
| 1188 | // because it is answering about exactly one host. |
| 1189 | func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[string]status.AliasReading, warm *certwarm.Record, targets DNSTargets, now time.Time, delegation bool) domainListItem { |
| 1190 | item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending", |
| 1191 | Wildcard: c.Wildcard, Catchall: c.Catchall} |
| 1192 | reading, seen := readings[c.Host] |
| 1193 | if seen { |
| 1194 | item.Status = "live" |
| 1195 | item.DNSStatus = reading.DNSStatus |
| 1196 | item.CertExpiresAt = reading.CertExpiresAt |
| 1197 | item.CheckedAt = reading.DNSCheckedAt |
| 1198 | } |
| 1199 | item.State, item.Reason, item.Detail = claimState(c, reading, seen, warm, now) |
| 1200 | item.Verified = !c.Pending |
| 1201 | // "unresolved" means the lookup FAILED (our resolver, not the customer's |
| 1202 | // record) — pointing them at "create this record" guidance would blame |
| 1203 | // them for our outage. Only "" (no answer yet) and "elsewhere" (a real, |
| 1204 | // wrong answer) earn guidance. |
| 1205 | if item.DNSStatus != "pointing" && item.DNSStatus != "unresolved" { |
| 1206 | item.Guidance = domainGuidance(c.Host, targets) |
| 1207 | // The structured form of the SAME advice, emitted under exactly the |
| 1208 | // same condition so the two can never disagree about whether there is |
| 1209 | // anything for the member to do. |
| 1210 | item.Records = domainRecords(c.Host, targets, c.Wildcard, s.delegationZone) |
| 1211 | } |
| 1212 | if c.Pending { |
| 1213 | // The proof record, in BOTH forms, under the same lockstep rule as |
| 1214 | // the pointing advice above: a member reading the sentence and an |
| 1215 | // app reading the records must be told to create the same thing. |
| 1216 | v := s.domainChallenge(c.Host, c.Account) |
| 1217 | item.Guidance = verifyGuidance(v) + " " + item.Guidance |
| 1218 | item.Guidance = strings.TrimSpace(item.Guidance) |
| 1219 | item.Records = append(item.Records, v) |
| 1220 | } |
| 1221 | if delegation { |
| 1222 | if s.delegationCNAMEDelegated(ctx, c.Host) { |
| 1223 | item.DelegationStatus = "delegated" |
| 1224 | } else { |
| 1225 | item.DelegationStatus = "missing" |
| 1226 | } |
| 1227 | } |
| 1228 | return item |
| 1229 | } |
| 1230 | |
| 1231 | // handleAPIDomainGet answers ONE hostname — the per-host read (#299 §2e). |
| 1232 | // |
| 1233 | // It exists because the list door cannot carry a refresh loop: ListClaims is |
| 1234 | // one List plus one Get per claim in the WHOLE deployment, filtered by |
| 1235 | // account and app afterwards, so a caller refreshing one hostname pays for |
| 1236 | // every custom domain anyone has attached. This reads exactly one object by |
| 1237 | // key, which is what makes an on-demand refresh — or a light per-host |
| 1238 | // re-check — defensible rather than something to warn callers off. |
| 1239 | // |
| 1240 | // A host this app does not own answers 404, byte-identical to a host nobody |
| 1241 | // has claimed. That is deliberate: the claims keyspace is GLOBAL (the key is |
| 1242 | // the cross-account uniqueness gate), so distinguishing "someone else holds |
| 1243 | // this" from "unclaimed" would turn this door into an existence oracle over |
| 1244 | // every hostname in the deployment. AttachClaim already refuses in the same |
| 1245 | // neutral way, and this must not be the door that leaks what that one |
| 1246 | // protects. |
| 1247 | func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) { |
| 1248 | _, acct, app, ok := s.instanceGate(w, r) |
| 1249 | if !ok { |
| 1250 | return |
| 1251 | } |
| 1252 | // An instance catch-all answers with its own row and the names it |
| 1253 | // derives, the list door's rows for it. Same 404 for a catch-all this |
| 1254 | // app does not hold as for one nobody does. |
| 1255 | if parent, ok := domains.ParseInstanceCatchallHost(r.PathValue("host")); ok { |
| 1256 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 1257 | defer cancel() |
| 1258 | claims, err := s.claims.List(ctx, s.cfg.Releases) |
| 1259 | if err != nil { |
| 1260 | log.Printf("console: api domain get %s/%s *.%s: %v", acct.Sqid, app, parent, err) |
| 1261 | jsonError(w, http.StatusBadGateway, "could not read the hostname") |
| 1262 | return |
| 1263 | } |
| 1264 | host := domains.InstanceCatchallHost(parent) |
| 1265 | claimed := make(map[string]bool, len(claims)) |
| 1266 | var mine *domains.Claim |
| 1267 | for i, c := range claims { |
| 1268 | claimed[c.Host] = true |
| 1269 | if c.Host == host && c.Account == acct.Sqid && c.App == app && c.Instances { |
| 1270 | mine = &claims[i] |
| 1271 | } |
| 1272 | } |
| 1273 | if mine == nil { |
| 1274 | jsonError(w, http.StatusNotFound, "no such hostname") |
| 1275 | return |
| 1276 | } |
| 1277 | now := time.Now() |
| 1278 | items := s.catchallListItems(ctx, acct, app, *mine, claimed, aliasReadings(ctx, s.cfg.Releases, now), |
| 1279 | warmRecords(ctx, s.cfg.Releases), s.dnsTargets(), now) |
| 1280 | writeJSON(w, http.StatusOK, map[string]any{ |
| 1281 | "domain": items[0], "derived": items[1:], |
| 1282 | "deployment": s.domainCapability(ctx, acct.Sqid, app), "paths": map[string]string{}, |
| 1283 | }) |
| 1284 | return |
| 1285 | } |
| 1286 | // Shape-checked BEFORE the key builder, exactly as the detach door does: |
| 1287 | // a {host} segment arrives percent-decoded, so "..%2f..%2finstances.json" |
| 1288 | // would reach ClaimKey as a path with separators in it. There is no path |
| 1289 | // through this handler on which an unnormalized host reaches the store. |
| 1290 | host, ok := domains.NormalizeAliasHost(r.PathValue("host")) |
| 1291 | if !ok { |
| 1292 | jsonError(w, http.StatusBadRequest, "invalid hostname") |
| 1293 | return |
| 1294 | } |
| 1295 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 1296 | defer cancel() |
| 1297 | c, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| 1298 | // An ABSENT claim is not an error here, and collapsing it into the same |
| 1299 | // 404 the wrong-owner case gets is the whole isolation property. Left as |
| 1300 | // a 502, "nobody has claimed this" would be distinguishable from |
| 1301 | // "somebody else holds it" — which is precisely the existence oracle the |
| 1302 | // neutral 404 exists to prevent. Only a real read failure is a 502. |
| 1303 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 1304 | log.Printf("console: api domain get %s/%s %s: %v", acct.Sqid, app, host, err) |
| 1305 | jsonError(w, http.StatusBadGateway, "could not read the hostname") |
| 1306 | return |
| 1307 | } |
| 1308 | if err != nil || c == nil || c.Account != acct.Sqid || c.App != app { |
| 1309 | jsonError(w, http.StatusNotFound, "no such hostname") |
| 1310 | return |
| 1311 | } |
| 1312 | now := time.Now() |
| 1313 | writeJSON(w, http.StatusOK, map[string]any{ |
| 1314 | "domain": s.domainItem(ctx, *c, aliasReadings(ctx, s.cfg.Releases, now), |
| 1315 | warmRecord(ctx, s.cfg.Releases, c.Host), s.dnsTargets(), now, |
| 1316 | c.Wildcard && s.delegationZone != ""), |
| 1317 | "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| 1318 | // The prefix map as PUBLISHED, and this is the only surface that |
| 1319 | // answers for it. `carlos routes` shows the route_paths rows the |
| 1320 | // reconcile pass derived, which is ACTUAL state on one box — the pass |
| 1321 | // silently drops an entry whose target is not a same-account local |
| 1322 | // route, so an entry it refused appears on no other surface at all. |
| 1323 | // That is exactly the state an operator debugs, and a migration whose |
| 1324 | // refusals are invisible fails silently. |
| 1325 | // |
| 1326 | // A sibling key rather than a field on domainItem, deliberately: the |
| 1327 | // list door shares that struct, and a host at MaxPathsPerClaim would |
| 1328 | // put 10,000 entries into every row of `carlos domains list`. The |
| 1329 | // whole map belongs on the door that already narrows to one hostname. |
| 1330 | // |
| 1331 | // Always an object, never null: an absent map and an empty one are one |
| 1332 | // state (Claim.Paths is omitempty), and a client should not have to |
| 1333 | // tell a nil from a {} to loop over it. |
| 1334 | "paths": pathsOrEmpty(c.Paths), |
| 1335 | }) |
| 1336 | } |
| 1337 | |
| 1338 | // domainListCNAMELookupCap bounds how many live CNAME lookups one |
| 1339 | // `carlos domains list` call may perform (IMPORTANT-2, Task 5 review): the |
| 1340 | // stamp is a real DNS round trip per wildcard-enabled hostname, sequential |
| 1341 | // and on the request path, so an unbounded loop turns a listing into a |
| 1342 | // resolver-latency multiplier. Claims beyond the cap (in host-sorted order) |
| 1343 | // list with delegation_status simply omitted, exactly like an unset zone — |
| 1344 | // never an error, never a partial/misleading stamp. |
| 1345 | const domainListCNAMELookupCap = 8 |
| 1346 | |
| 1347 | // aliasReadings builds a hostname -> reading map from every NON-STALE box's |
| 1348 | // own private Metrics.Aliases (status/boxes/*.json), through status.LoadBoxes |
| 1349 | // — the same loader and the same staleness rule stagedVersions uses |
| 1350 | // (apiinstances.go), for the same reason: a box that stopped reporting is |
| 1351 | // EXCLUDED entirely rather than contributing its last-known reading, so a |
| 1352 | // hostname whose only box went dark reads "pending" instead of going on |
| 1353 | // claiming a cert expiry nobody has checked in a week. now is passed in, not |
| 1354 | // read internally, so one request votes on staleness with one clock. |
| 1355 | // |
| 1356 | // Best-effort throughout: a store that cannot be listed degrades to "nothing |
| 1357 | // known" — every claim then lists as pending — rather than failing the call. |
| 1358 | // This join is observability on top of the listing, never load-bearing for |
| 1359 | // it. A nil store is not reachable here (instanceGate's 503 comes first), but |
| 1360 | // the check is kept for any future caller. |
| 1361 | func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[string]status.AliasReading { |
| 1362 | if s == nil { |
| 1363 | return nil |
| 1364 | } |
| 1365 | boxes, err := status.LoadBoxes(ctx, s) |
| 1366 | if err != nil { |
| 1367 | log.Printf("console: api domains: could not load status boxes for hostname readings: %v", err) |
| 1368 | return nil |
| 1369 | } |
| 1370 | readings := map[string]status.AliasReading{} |
| 1371 | for _, b := range boxes { |
| 1372 | if b.Metrics == nil || status.BoxStale(b, now, status.SnapshotPeriod) { |
| 1373 | continue |
| 1374 | } |
| 1375 | for host, reading := range b.Metrics.Aliases { |
| 1376 | // Freshest stamp wins. An alias whose primary is everywhere-class |
| 1377 | // materializes on several boxes, and map iteration order is not a |
| 1378 | // tie-break — without this a box that has stopped stamping can |
| 1379 | // deterministically overwrite a live box's reading with a frozen |
| 1380 | // one. Ties keep the incumbent, so the fold is stable. |
| 1381 | if prior, ok := readings[host]; ok && prior.DNSCheckedAt >= reading.DNSCheckedAt { |
| 1382 | continue |
| 1383 | } |
| 1384 | readings[host] = reading |
| 1385 | } |
| 1386 | } |
| 1387 | return readings |
| 1388 | } |
| 1389 | |
| 1390 | // warmRecords folds the issuer's warm-outcome records (#299 §3e) into a |
| 1391 | // host-keyed map for the LIST door. |
| 1392 | // |
| 1393 | // A record exists only while a host is failing, so the cost is one object |
| 1394 | // per broken hostname in the deployment — usually none. That is the property |
| 1395 | // that makes it safe to fold here at all: the list door already pays one Get |
| 1396 | // per claim (#173), and a second per-claim read would have made a bad cost |
| 1397 | // worse rather than adding a fact. |
| 1398 | // |
| 1399 | // Every failure degrades to "no records". They explain a state; they never |
| 1400 | // gate one, and a domains list that 502s because a telemetry prefix was |
| 1401 | // briefly unreadable would be a worse answer than one that says |
| 1402 | // "issuing-cert". |
| 1403 | func warmRecords(ctx context.Context, s release.Store) map[string]*certwarm.Record { |
| 1404 | out := map[string]*certwarm.Record{} |
| 1405 | if s == nil { |
| 1406 | return out |
| 1407 | } |
| 1408 | keys, err := s.List(ctx, certwarm.Prefix) |
| 1409 | if err != nil { |
| 1410 | log.Printf("console: api domains: could not list %s: %v — certificate failures will read as in progress", certwarm.Prefix, err) |
| 1411 | return out |
| 1412 | } |
| 1413 | for _, key := range keys { |
| 1414 | host := certwarm.HostFromKey(key) |
| 1415 | if host == "" { |
| 1416 | continue |
| 1417 | } |
| 1418 | body, _, err := s.Get(ctx, key) |
| 1419 | if err != nil { |
| 1420 | log.Printf("console: api domains: could not read %s: %v", key, err) |
| 1421 | continue |
| 1422 | } |
| 1423 | rec, err := certwarm.Parse(body) |
| 1424 | if err != nil { |
| 1425 | // A record a NEWER issuer wrote in a shape this console cannot |
| 1426 | // read. Skipping it reports "issuing-cert", which is the right |
| 1427 | // direction to be wrong in. |
| 1428 | log.Printf("console: api domains: could not parse %s: %v", key, err) |
| 1429 | continue |
| 1430 | } |
| 1431 | out[host] = &rec |
| 1432 | } |
| 1433 | return out |
| 1434 | } |
| 1435 | |
| 1436 | // warmRecord reads ONE hostname's record, for the per-host door. |
| 1437 | // |
| 1438 | // Deliberately not warmRecords filtered afterwards: the per-host door's |
| 1439 | // whole reason to exist is that a refresh of one hostname costs one object, |
| 1440 | // and listing a prefix to answer about a single name would give that back. |
| 1441 | // An absent record is the ordinary case and is not an error. |
| 1442 | func warmRecord(ctx context.Context, s release.Store, host string) *certwarm.Record { |
| 1443 | if s == nil { |
| 1444 | return nil |
| 1445 | } |
| 1446 | body, _, err := s.Get(ctx, certwarm.Key(host)) |
| 1447 | if err != nil { |
| 1448 | if !errors.Is(err, os.ErrNotExist) { |
| 1449 | log.Printf("console: api domains: could not read the warm record for %s: %v", host, err) |
| 1450 | } |
| 1451 | return nil |
| 1452 | } |
| 1453 | rec, err := certwarm.Parse(body) |
| 1454 | if err != nil { |
| 1455 | log.Printf("console: api domains: could not parse the warm record for %s: %v", host, err) |
| 1456 | return nil |
| 1457 | } |
| 1458 | return &rec |
| 1459 | } |
| 1460 | |
| 1461 | // hostUnavailableMsg is the ONE refusal string behind every "you cannot have |
| 1462 | // this hostname" answer this package gives: the claim already belongs to |
| 1463 | // someone else, or a live box in this deployment already serves it as a real |
| 1464 | // route. Both reasons answer 409 with these exact bytes and nothing else. |
| 1465 | // |
| 1466 | // It is a constant rather than three literals because the neutrality is the |
| 1467 | // security property. The moment two of these refusals differ — a different |
| 1468 | // status, a different message, even different whitespace — a caller can |
| 1469 | // binary-search the deployment's hostnames: attach, read the shape of the |
| 1470 | // no, learn whether an operator-minted route exists for a name it does not |
| 1471 | // own. domains.ErrHostUnavailable carries the same string for the same |
| 1472 | // reason (claims.go). |
| 1473 | const hostUnavailableMsg = "hostname unavailable" |
| 1474 | |
| 1475 | // deploymentRouteTaken reports whether host is a REAL route — a non-alias |
| 1476 | // home entry — published by some live box in this deployment, and therefore |
| 1477 | // not this caller's to claim. |
| 1478 | // |
| 1479 | // Why the door needs this at all. An account can name any hostname it likes; |
| 1480 | // the claim's bucket key is the cross-account gate, so a name nobody has |
| 1481 | // CLAIMED is free even when a box is already serving it as an operator- or |
| 1482 | // reconciler-minted route. Those rows carry no provision record, so the door |
| 1483 | // has never been able to see them: it deliberately does not verify that a |
| 1484 | // route exists. Until aliases were published fleet-wide that was inert — |
| 1485 | // a claim on another box's route materialized only on the claimant's own |
| 1486 | // box, which did not serve the name. It is not inert now. |
| 1487 | // |
| 1488 | // A host this app ALREADY holds the claim on is its own and is allowed |
| 1489 | // through. That is not a softening of the rule, it is what makes the rule |
| 1490 | // survive contact with the first-route attach: such an attach materializes |
| 1491 | // the host as a real route on the claimant's own box, so from the second |
| 1492 | // call onward the fold reports the caller's own route back at them. Without |
| 1493 | // this, every re-attach would 409 — and titogo's router re-attaches every |
| 1494 | // hostname on each reload pass, so the steady state would be a permanent |
| 1495 | // refusal on hostnames this deployment itself granted. |
| 1496 | // |
| 1497 | // Best-effort, like the fold under it: an unreadable bucket answers "no |
| 1498 | // conflict", and the box-side reconcile pass stays the authoritative gate. |
| 1499 | // An unreadable CLAIM is likewise treated as "not ours" — the conservative |
| 1500 | // direction here is to refuse, and the caller who genuinely owns it loses |
| 1501 | // nothing but a retry. |
| 1502 | func (s *Server) deploymentRouteTaken(ctx context.Context, host, sqid, app string) bool { |
| 1503 | if !edgesfile.DeploymentRoutes(ctx, s.cfg.Releases, time.Now())[host] { |
| 1504 | return false |
| 1505 | } |
| 1506 | c, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| 1507 | if err == nil && c != nil && c.Account == sqid && c.App == app { |
| 1508 | return false |
| 1509 | } |
| 1510 | return true |
| 1511 | } |
| 1512 | |