| 1 | // Instance catch-alls — a claim on "*.<parent>" that serves each of an |
| 2 | // app's instances at <label>.<parent> (docs/superpowers/specs/ |
| 3 | // 2026-10-05-instance-catchall-design.md). The claim is written here; the |
| 4 | // alias rows are derived box-side (domains.ReconcileAliases). |
| 5 | // |
| 6 | // Both doors share this core, the way attachDomain is shared: the CLI API |
| 7 | // door (handleAPIDomainAttach/Detach, which branch here on a "*." host) and |
| 8 | // the app Domains page. |
| 9 | package console |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "errors" |
| 14 | "log" |
| 15 | "net/http" |
| 16 | "os" |
| 17 | "slices" |
| 18 | "strings" |
| 19 | "time" |
| 20 | |
| 21 | "golang.org/x/net/publicsuffix" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/certwarm" |
| 24 | "github.com/carlosframework/platform/internal/domains" |
| 25 | "github.com/carlosframework/platform/internal/provision" |
| 26 | "github.com/carlosframework/platform/internal/status" |
| 27 | ) |
| 28 | |
| 29 | // catchallParentMsg is the refusal for a catch-all whose parent the account |
| 30 | // does not hold. One message whether nobody holds it or someone else does: |
| 31 | // AttachInstanceCatchall does not tell the two apart, and neither does this. |
| 32 | func catchallParentMsg(parent string) string { |
| 33 | return "attach " + parent + " to an app in this account first — an instance catch-all on *." + parent + " needs " + parent + " itself held by the same account" |
| 34 | } |
| 35 | |
| 36 | // catchallParentRefused reports whether parent may never carry an instance |
| 37 | // catch-all on this deployment, decided from the name and the config alone. |
| 38 | // |
| 39 | // A catch-all claims every one-label name under parent at once, so the |
| 40 | // refusal looks at the names UNDER parent as well as parent itself: |
| 41 | // |
| 42 | // - parent, or anything under it, in the deny tiers (domainHostPolicy): |
| 43 | // the deployment's own zones are never a tenant's catch-all, operators |
| 44 | // included; |
| 45 | // - any reserved, platform or console hostname strictly under parent, |
| 46 | // which a derived name could otherwise land on (the box cannot see the |
| 47 | // deny lists, so the door is the only place this can be refused); |
| 48 | // - the apps domain, or anything under it: the account-sqid and app-apex |
| 49 | // namespace, where instance hosts already live; |
| 50 | // - a public suffix (co.uk, github.io): a catch-all reserves the names |
| 51 | // under its parent to one account at the attach door, and that must |
| 52 | // never cover a registry's whole namespace; |
| 53 | // - the reserved sidecar suffix. |
| 54 | func (s *Server) catchallParentRefused(parent, account string) bool { |
| 55 | if s.domainHostPolicy(parent, account) != hostOpen { |
| 56 | return true |
| 57 | } |
| 58 | if strings.HasSuffix(parent, provision.SidecarSuffix) { |
| 59 | return true |
| 60 | } |
| 61 | if suffix, _ := publicsuffix.PublicSuffix(parent); suffix == parent { |
| 62 | return true |
| 63 | } |
| 64 | under := func(entry string) bool { |
| 65 | entry = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(entry)), ".") |
| 66 | return entry != "" && (entry == parent || strings.HasSuffix(entry, "."+parent) || strings.HasSuffix(parent, "."+entry)) |
| 67 | } |
| 68 | if under(s.cfg.AppsDomain) { |
| 69 | return true |
| 70 | } |
| 71 | entries := slices.Concat(s.consoleHosts(), s.reservedDomains(false), s.platformDomains(false)) |
| 72 | return slices.ContainsFunc(entries, under) |
| 73 | } |
| 74 | |
| 75 | // attachInstanceCatchall claims "*.<parent>" for acct's app. The caller has |
| 76 | // established that principal is a person in this account. |
| 77 | // |
| 78 | // A temporary app is refused here, as attachDomainAs refuses one: the |
| 79 | // browser door reaches this without passing through attachDomain. |
| 80 | func (s *Server) attachInstanceCatchall(ctx context.Context, acct *Account, app, parent, principal, message string) (created bool, ref *doorRefusal) { |
| 81 | // Host-claim consoles: catch-all claims are written only by the legacy |
| 82 | // capability path, an intentionally unsupported operation here. |
| 83 | if s.cfg.HostClaim { |
| 84 | return false, &doorRefusal{http.StatusConflict, claimRefusalText(errClaimAliasCapabilitiesUnsupported)} |
| 85 | } |
| 86 | if ref := s.bucketRefusal(); ref != nil { |
| 87 | return false, ref |
| 88 | } |
| 89 | if msg, status := s.temporaryRefusal(acct, app, "attach a domain to it"); msg != "" { |
| 90 | return false, &doorRefusal{Status: status, Msg: msg} |
| 91 | } |
| 92 | if s.catchallParentRefused(parent, acct.Sqid) { |
| 93 | return false, &doorRefusal{http.StatusBadRequest, "that hostname is platform-managed"} |
| 94 | } |
| 95 | // Plans 3a: a catch-all is a custom domain too. |
| 96 | if ref := s.customDomainRefusal(acct, parent); ref != nil { |
| 97 | return false, ref |
| 98 | } |
| 99 | created, err := domains.AttachInstanceCatchall(ctx, s.cfg.Releases, parent, acct.Sqid, app, |
| 100 | time.Now().UTC().Format(time.RFC3339), principal) |
| 101 | switch { |
| 102 | case errors.Is(err, domains.ErrParentNotHeld): |
| 103 | return false, &doorRefusal{http.StatusConflict, catchallParentMsg(parent)} |
| 104 | case err != nil: |
| 105 | return false, claimRefusal(domains.InstanceCatchallHost(parent), err) |
| 106 | } |
| 107 | if created { |
| 108 | host := domains.InstanceCatchallHost(parent) |
| 109 | s.auditActivity(acct, app, principal, Activity{ |
| 110 | Action: "domain-attach", TargetType: "app", Target: app, Message: message, |
| 111 | Detail: "attached instance catch-all " + host + " -> <label>." + parent + " for each instance", |
| 112 | }) |
| 113 | s.bumpSerial(ctx) |
| 114 | } |
| 115 | return created, nil |
| 116 | } |
| 117 | |
| 118 | // detachInstanceCatchall releases acct's app's "*.<parent>". Quiet in both |
| 119 | // directions, like every detach: removed=false whether it was never there or |
| 120 | // is someone else's. |
| 121 | func (s *Server) detachInstanceCatchall(ctx context.Context, acct *Account, app, parent, principal string) (removed bool, err error) { |
| 122 | if s.cfg.Releases == nil { |
| 123 | return false, errors.New("no deployment bucket on this host") |
| 124 | } |
| 125 | host := domains.InstanceCatchallHost(parent) |
| 126 | removed, err = s.detachDomainClaim(ctx, host, acct.Sqid, app) |
| 127 | if err != nil { |
| 128 | return false, err |
| 129 | } |
| 130 | if removed { |
| 131 | s.audit(acct, app, principal, "domain-detach", "detached instance catch-all "+host) |
| 132 | s.bumpSerial(ctx) |
| 133 | } |
| 134 | return removed, nil |
| 135 | } |
| 136 | |
| 137 | // heldElsewhere is the attach doors' last check before a claim write: a |
| 138 | // real route another app serves (deploymentRouteTaken), or a live instance's |
| 139 | // name under another account's instance catch-all (catchallInstanceName). |
| 140 | // Both answer with the same neutral 409, from the same place in each door, |
| 141 | // so neither reads apart from an ordinary "someone holds it". |
| 142 | // |
| 143 | // A bucket read the catch-all check could not make is answered as a |
| 144 | // retryable error rather than a guess: refusing would read as "someone |
| 145 | // holds it", and admitting would skip the guard. |
| 146 | func (s *Server) heldElsewhere(ctx context.Context, host, sqid, app string) *doorRefusal { |
| 147 | if s.deploymentRouteTaken(ctx, host, sqid, app) { |
| 148 | return claimRefusal(host, domains.ErrHostUnavailable) |
| 149 | } |
| 150 | held, err := s.catchallInstanceName(ctx, host, sqid, app) |
| 151 | if err != nil { |
| 152 | log.Printf("console: attach %s: instance catch-all check: %v", host, err) |
| 153 | return &doorRefusal{http.StatusBadGateway, "could not check the hostname — try again"} |
| 154 | } |
| 155 | if held { |
| 156 | return claimRefusal(host, domains.ErrHostUnavailable) |
| 157 | } |
| 158 | return nil |
| 159 | } |
| 160 | |
| 161 | // catchallInstanceName reports whether host is a one-label name under |
| 162 | // another account's live instance catch-all that the catch-all reserves. |
| 163 | // |
| 164 | // What it reserves depends on whether the catch-all's account has proved it |
| 165 | // controls the parent in DNS (catchallProven): |
| 166 | // |
| 167 | // - PROVED: every one-label name under the parent. A team's name is then |
| 168 | // safe before its instance exists, and a deleted team's label is not up |
| 169 | // for grabs either. With wildcard DNS pointing at the edge, an |
| 170 | // unreserved label would be one any account could attach and serve, |
| 171 | // with a real certificate, on somebody else's domain. |
| 172 | // - NOT PROVED: only the labels the catch-all's app actually has instances |
| 173 | // at, under any instance domain it has ever used. Attach needs no DNS |
| 174 | // proof, so a parent claim alone proves nothing, and holding every label |
| 175 | // would let one unproven apex claim plus a catch-all block the domain's |
| 176 | // real owner from every name under it. |
| 177 | // |
| 178 | // The caller's own existing claim on host is never refused here: a claim |
| 179 | // made before the catch-all keeps working, re-attaches (titogo's router |
| 180 | // re-attaches every hostname on each pass) and route converges included. |
| 181 | // The same allowance deploymentRouteTaken makes, for the same reason. |
| 182 | // |
| 183 | // Costs one GET in the common case (no catch-all over the name); the rest |
| 184 | // is paid only when another account's catch-all is there. |
| 185 | func (s *Server) catchallInstanceName(ctx context.Context, host, sqid, app string) (bool, error) { |
| 186 | c, err := domains.ForeignInstanceCatchall(ctx, s.cfg.Releases, host, sqid) |
| 187 | if err != nil || c == nil { |
| 188 | return false, err |
| 189 | } |
| 190 | if own, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil && own.Account == sqid && own.App == app { |
| 191 | return false, nil |
| 192 | } else if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 193 | return false, err |
| 194 | } |
| 195 | parent, _ := domains.ParseInstanceCatchallHost(c.Host) |
| 196 | if s.catchallProven(ctx, parent, c.Account) { |
| 197 | return true, nil |
| 198 | } |
| 199 | counts, err := domains.InstanceLabelCounts(ctx, s.cfg.Releases, c.Account, c.App) |
| 200 | if err != nil { |
| 201 | return false, err |
| 202 | } |
| 203 | label, _, _ := strings.Cut(host, ".") |
| 204 | return counts[label] > 0, nil |
| 205 | } |
| 206 | |
| 207 | // catchallProven reports whether parent's DNS carries account's catch-all |
| 208 | // proof (domains.CatchallProofName/Value). Asked live, every time, so the |
| 209 | // answer follows the domain's DNS rather than a stamp taken once: a domain |
| 210 | // that changes hands stops proving the old account's catch-all the moment |
| 211 | // its new owner's zone stops carrying the record. |
| 212 | // |
| 213 | // Any lookup failure is "not proved" — the narrower reservation, never a |
| 214 | // refusal — exactly as the mail ownership proof treats one (#216). |
| 215 | func (s *Server) catchallProven(ctx context.Context, parent, account string) bool { |
| 216 | if s.lookupTXT == nil || parent == "" { |
| 217 | return false |
| 218 | } |
| 219 | vals, err := s.lookupTXT(ctx, domains.CatchallProofName(parent)) |
| 220 | if err != nil { |
| 221 | return false |
| 222 | } |
| 223 | want := domains.CatchallProofValue(account) |
| 224 | for _, v := range vals { |
| 225 | if strings.TrimSpace(v) == want { |
| 226 | return true |
| 227 | } |
| 228 | } |
| 229 | return false |
| 230 | } |
| 231 | |
| 232 | // catchallAppReady refuses a catch-all for an app with no instance |
| 233 | // manifest. Such an app has no instances to serve, and it is the one shape a |
| 234 | // rename can still move (handleAppRename renames only apps that never |
| 235 | // shipped), which would leave the claim naming an app that no longer exists. |
| 236 | // |
| 237 | // A temporary app is refused first, so the answer names carlos apps keep |
| 238 | // rather than an instances step the app cannot take. |
| 239 | func (s *Server) catchallAppReady(ctx context.Context, acct *Account, app string) *doorRefusal { |
| 240 | if ref := s.bucketRefusal(); ref != nil { |
| 241 | return ref |
| 242 | } |
| 243 | if msg, status := s.temporaryRefusal(acct, app, "attach a domain to it"); msg != "" { |
| 244 | return &doorRefusal{Status: status, Msg: msg} |
| 245 | } |
| 246 | _, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| 247 | switch { |
| 248 | case errors.Is(err, os.ErrNotExist): |
| 249 | return &doorRefusal{http.StatusConflict, |
| 250 | app + " has no instances — enable them first (carlos instances enable), then attach the catch-all"} |
| 251 | case err != nil: |
| 252 | log.Printf("console: instance catch-all %s/%s: manifest: %v", acct.Sqid, app, err) |
| 253 | return &doorRefusal{http.StatusBadGateway, "could not read this app's instance settings — try again"} |
| 254 | } |
| 255 | return nil |
| 256 | } |
| 257 | |
| 258 | // derivedName is one name an instance catch-all serves, as the fleet |
| 259 | // reports it. |
| 260 | type derivedName struct { |
| 261 | Host, Route string |
| 262 | Reading status.AliasReading |
| 263 | } |
| 264 | |
| 265 | // catchallDerived lists the names parent's catch-all is serving for acct's |
| 266 | // app, from the fleet's readings (aliasReadings), host-sorted. |
| 267 | // |
| 268 | // The readings carry a hostname and the route it points at, and nothing |
| 269 | // about whose they are, so a name is only reported as this app's when all |
| 270 | // of these hold: it is one label under parent; nobody claims it (a derived |
| 271 | // row exists only where no claim does, and a claimed name is listed as its |
| 272 | // own claim — or, if it is someone else's, not at all); its route is |
| 273 | // exactly that label under the app's own instance domain. Another account's |
| 274 | // instance can never be under that domain, so no other tenant's hostname or |
| 275 | // route reaches this answer. |
| 276 | // |
| 277 | // One manifest read. A failure lists no derived names, never wrong ones. |
| 278 | func (s *Server) catchallDerived(ctx context.Context, acct *Account, app, parent string, claimed map[string]bool, readings map[string]status.AliasReading) []derivedName { |
| 279 | m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| 280 | if err != nil { |
| 281 | if !errors.Is(err, os.ErrNotExist) { |
| 282 | log.Printf("console: instance catch-all %s/%s: manifest: %v", acct.Sqid, app, err) |
| 283 | } |
| 284 | return nil |
| 285 | } |
| 286 | var out []derivedName |
| 287 | for host, reading := range readings { |
| 288 | label, rest, ok := strings.Cut(host, ".") |
| 289 | if !ok || rest != parent || claimed[host] { |
| 290 | continue |
| 291 | } |
| 292 | if reading.Route != label+"."+m.Domain { |
| 293 | continue |
| 294 | } |
| 295 | out = append(out, derivedName{Host: host, Route: reading.Route, Reading: reading}) |
| 296 | } |
| 297 | slices.SortFunc(out, func(a, b derivedName) int { return strings.Compare(a.Host, b.Host) }) |
| 298 | return out |
| 299 | } |
| 300 | |
| 301 | // catchallListItems is the list door's answer for one catch-all: its own |
| 302 | // row, then a row per derived name marked Via. The catch-all row has no |
| 303 | // reading of its own (no box resolves "*.<parent>"), so its state speaks |
| 304 | // for the names under it: live once any of them is, checking until then. |
| 305 | func (s *Server) catchallListItems(ctx context.Context, acct *Account, app string, c domains.Claim, claimed map[string]bool, readings map[string]status.AliasReading, warm map[string]*certwarm.Record, targets DNSTargets, now time.Time) []domainListItem { |
| 306 | parent, _ := domains.ParseInstanceCatchallHost(c.Host) |
| 307 | derived := s.catchallDerived(ctx, acct, app, parent, claimed, readings) |
| 308 | head := domainListItem{Host: c.Host, Status: "pending", Instances: true, |
| 309 | State: StateChecking, Reason: "no-instances-reported", |
| 310 | Detail: "Serves each instance at <label>." + parent + ". No edge has reported one yet: an instance needs its route on a box running this build, and checks run every few minutes."} |
| 311 | rows := make([]domainListItem, 0, len(derived)) |
| 312 | pointing := false |
| 313 | for _, d := range derived { |
| 314 | item := s.domainItem(ctx, domains.Claim{Host: d.Host, Route: d.Route}, readings, warm[d.Host], targets, now, false) |
| 315 | item.Via = c.Host |
| 316 | rows = append(rows, item) |
| 317 | pointing = pointing || d.Reading.DNSStatus == "pointing" |
| 318 | } |
| 319 | if len(derived) > 0 { |
| 320 | head.Status, head.State, head.Reason = "live", StateLive, "instances" |
| 321 | head.Detail = "Serves each instance at <label>." + parent + "; the names the fleet reports are listed below it." |
| 322 | } |
| 323 | if !pointing { |
| 324 | // The wildcard record is what makes every label resolve here; the |
| 325 | // guidance for "*.<parent>" says exactly that. |
| 326 | head.Guidance = domainGuidance(c.Host, targets) |
| 327 | head.Records = domainRecords(c.Host, targets, false, "") |
| 328 | } |
| 329 | // Whether the catch-all reserves every label, or only its instances' |
| 330 | // (catchallInstanceName). Said on every listing, with the record to |
| 331 | // publish when it is missing, because the difference is exactly whether |
| 332 | // another account can take a team's name before the team exists. |
| 333 | head.Reserves = "instances" |
| 334 | if s.catchallProven(ctx, parent, acct.Sqid) { |
| 335 | head.Reserves = "all" |
| 336 | head.Detail += " Every <label>." + parent + " is reserved for this account." |
| 337 | } else { |
| 338 | head.Detail += " Only the names of existing instances are reserved; another account could claim any other <label>." + parent + |
| 339 | ". Publish TXT " + domains.CatchallProofName(parent) + " = \"" + domains.CatchallProofValue(acct.Sqid) + "\" to reserve them all." |
| 340 | head.Records = append(head.Records, dnsRecord{ |
| 341 | Type: "TXT", Name: domains.CatchallProofName(parent), Value: domains.CatchallProofValue(acct.Sqid), |
| 342 | TTL: domainRecordTTL, Set: "proof", |
| 343 | }) |
| 344 | } |
| 345 | return append([]domainListItem{head}, rows...) |
| 346 | } |
| 347 | |
| 348 | // catchallDomainViews is the Settings page's form of catchallListItems: the |
| 349 | // catch-all's own row (detachable, by typing "*.<parent>") and one row per |
| 350 | // derived name, which is not. |
| 351 | func (s *Server) catchallDomainViews(ctx context.Context, acct *Account, app string, c domains.Claim, claimed map[string]bool, readings map[string]status.AliasReading, warm map[string]*certwarm.Record, now time.Time) []DomainView { |
| 352 | items := s.catchallListItems(ctx, acct, app, c, claimed, readings, warm, s.dnsTargets(), now) |
| 353 | out := make([]DomainView, 0, len(items)) |
| 354 | for _, it := range items { |
| 355 | dv := DomainView{Domain: it.Host, Guidance: it.Guidance, State: it.State, Reason: it.Reason, Detail: it.Detail} |
| 356 | if it.Instances { |
| 357 | dv.Type, dv.Claimed = "instance catch-all", true |
| 358 | } else { |
| 359 | dv.Type = "via " + it.Via |
| 360 | } |
| 361 | out = append(out, dv) |
| 362 | } |
| 363 | return out |
| 364 | } |
| 365 | |