carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
34package console
35
36import (
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.
78func (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.
143func (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.
152func (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.
175func (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.
207func (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.
256func (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.
292func (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.
606func 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.
618const 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.
629func (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.
747func (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.
763const 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.
777func (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.
899func 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.
913func (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.
1042type 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.
1104func (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.
1189func (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.
1247func (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.
1345const 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.
1361func 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".
1403func 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.
1442func 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).
1473const 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.
1502func (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