carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
9package console
10
11import (
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.
32func 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.
54func (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.
80func (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.
121func (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.
146func (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.
185func (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).
215func (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.
239func (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.
260type 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.
278func (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.
305func (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.
351func (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