package console // An app's platform apex is .., and it is that app's. // Two things can put somebody else there. The instances create door is one: // a sibling registering an instance at another app's apex, which that door // refuses (live or trashed — apiinstances.go). The other is the name arriving // AFTER the instance: an app created, renamed or moved to a name whose apex a // sibling already registered while the name was free. Nothing mints a route // over a host that already has one (the domains pass defers to byHost for // ever), so the new app would never have its own URL — and `carlos email // events push` would post its recipients' addresses to the sibling. This file // is the check those doors share, and a startup report for anything that got // there before the check existed. import ( "context" "errors" "log" "os" "strings" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/provision" ) // apexHost is the platform apex app would have in the account with this // sqid, or "" on a deployment with no apps domain (there is no apex space to // collide in). func (s *Server) apexHost(sqid, app string) string { domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) if domain == "" { return "" } return app + "." + sqid + "." + domain } // apexHold is who already holds an apex: the app a route or an instance // record belongs to — "" for a route that names no app (a status page, a bare // proxy, anything hand-added) — whether that app is in the trash, and the // account a route says it serves when that is not the apex's own. type apexHold struct { Holder string Trashed bool OtherAccount string } // apexErr says which store an apexHolder failure came from, so a door can // tell a member the truth about what could not be checked. type apexErr struct { bucket bool err error } func (e *apexErr) Error() string { return e.err.Error() } func (e *apexErr) Unwrap() error { return e.err } // apexBucketFailure reports whether err came from the release store rather // than from this box's registry or database. func apexBucketFailure(err error) bool { var ae *apexErr return errors.As(err, &ae) && ae.bucket } // apexHolder answers whether the platform apex name would have in acct is // already held by something that is not name's own — nil when it is free. // It looks where a holder could be: a route at the host, whatever put it // there, and an instance record at it under any other app of the account, // live or trashed, which the provisioning reconciler would turn into a route. // // A route is name's own only when it says so on both counts: its app is name // and its account, if it names one, is acct. A route with no app at all holds // the host exactly as firmly as one with an app — the domains pass defers to // it all the same. // // The route half reads THIS console box's registry, as appHasRoutes and // routeAccountFor do; a route hand-added on another box is invisible to it, // while a reconciler-minted one is covered by its record. It fails CLOSED: // an error is an error, never "free". func (s *Server) apexHolder(ctx context.Context, acct *Account, name string) (*apexHold, error) { host := s.apexHost(acct.Sqid, name) if host == "" { return nil, nil } return s.hostHolder(ctx, acct, host, name) } // hostHolder answers whether host is already held by an app of acct other // than own — nil when it is free, or when the only holder is own itself. // apexHolder is this question asked of an app's platform apex; the instance // create door asks it of whatever host a record is about to claim, because // instance records are keyed per app (provision.RecordKey) and nothing in // the key stops two apps of one account from both writing the same host. The // provisioning pass would then keep whichever record it listed first and // skip the other with a log line (provision/reconcile.go, "duplicate host"), // so the loser looks created and serves nothing, or serves the other app's // process under its own name. // // A flat canary host (-., discussion 207) is how that // became easy to reach: canary "a" of app "b-c" and canary "a-b" of app "c" // are the same label. // // Where a holder could be, and the fail-closed rule, are apexHolder's: a // route at the host, whatever put it there, and an instance record at it // under any other app of the account, live or trashed. func (s *Server) hostHolder(ctx context.Context, acct *Account, host, own string) (*apexHold, error) { name := own if s.cfg.Registry != "" { reg, err := openRegistry(s.cfg.Registry) if err != nil { return nil, &apexErr{err: err} } rt, err := reg.Get(host) if err != nil { return nil, &apexErr{err: err} } if rt != nil && (rt.App != name || (rt.Account != "" && rt.Account != acct.Sqid)) { hold := &apexHold{Holder: rt.App} if rt.Account != "" && rt.Account != acct.Sqid { hold.OtherAccount = rt.Account } return hold, nil } } if s.cfg.Releases == nil { return nil, nil } live, err := s.store.AppsIn(acct.ID) if err != nil { return nil, &apexErr{err: err} } trashed, err := s.store.TrashedAppsIn(acct.ID) if err != nil { return nil, &apexErr{err: err} } type candidate struct { app string trashed bool } var apps []candidate for _, a := range live { apps = append(apps, candidate{app: a}) } for _, t := range trashed { apps = append(apps, candidate{app: t.App, trashed: true}) } for _, c := range apps { if c.app == name { continue } rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, c.app, host) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, &apexErr{bucket: true, err: err} } if rec != nil { return &apexHold{Holder: c.app, Trashed: c.trashed}, nil } } return nil, nil } // hostClaimHolder is the other half of hostHolder: the global hostname claim // (domains.ClaimKey, one object per host whoever asks) that an attached alias // is derived from. An alias has no instance record and, until the domains // pass materializes it, no route either, so hostHolder cannot see it; the // claim is the authoritative record and one GET. A route minted at a host // somebody else has claimed would shadow their alias at the edge. // // It returns who the claim belongs to when that is not own in acct, nil when // there is no claim or it is own's. A claim of another account comes back // with only OtherAccount set and no Holder: domains.ErrHostUnavailable is // deliberately neutral about who owns a hostname, and this door must not be a // way round that. Anything but "no such claim" is an error: it fails closed. func (s *Server) hostClaimHolder(ctx context.Context, acct *Account, host, own string) (*apexHold, error) { if s.cfg.Releases == nil { return nil, nil } c, err := domains.GetClaim(ctx, s.cfg.Releases, host) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, &apexErr{bucket: true, err: err} } switch { case c.Account != acct.Sqid: return &apexHold{OtherAccount: c.Account}, nil case c.App != own: return &apexHold{Holder: c.App}, nil } return nil, nil } // claimHeldSentence is hostHeldSentence for a hostname claim. A claim of // another account is refused without saying whose or that it is a claim. func claimHeldSentence(host string, hold *apexHold) string { if hold.OtherAccount != "" { return hostHeldSentence(host, nil) } return hostHeldSentence(host, hold) } // apexHeldSentence is the refusal, in one voice for every door that gives // it, with the advice that fits who holds the host. func apexHeldSentence(host string, hold *apexHold) string { switch { case hold == nil: return host + " — this app's platform address — is already held" case hold.OtherAccount != "" && hold.Holder != "": return host + " — this app's platform address — is already routed to " + hold.Holder + " in account " + hold.OtherAccount + "; that is an operator repair" case hold.OtherAccount != "": return host + " — this app's platform address — is already routed for account " + hold.OtherAccount + "; that is an operator repair" case hold.Holder == "": return host + " — this app's platform address — already has a route with no app; an operator has to remove it first" case hold.Trashed: return host + " — this app's platform address — is already an instance of " + hold.Holder + ", which is in the trash; restore " + hold.Holder + " and remove that instance, or purge it, first" } return host + " — this app's platform address — is already an instance of " + hold.Holder + "; remove that instance first, or choose another name" } // hostHeldSentence is the instance create door's refusal for a host another // app of the account already holds (hostHolder), with the advice that fits // who holds it. It does not claim the host is anyone's apex: that is // apexHeldSentence's case, and the door's own apex check answers it first. func hostHeldSentence(host string, hold *apexHold) string { switch { case hold == nil: return host + " is already held" case hold.OtherAccount != "" && hold.Holder != "": return host + " is already routed to " + hold.Holder + " in account " + hold.OtherAccount + "; that is an operator repair" case hold.OtherAccount != "": return host + " is already routed for account " + hold.OtherAccount + "; that is an operator repair" case hold.Holder == "": return host + " already has a route with no app; an operator has to remove it first" case hold.Trashed: return host + " is already held by " + hold.Holder + ", which is in the trash; choose another address" } return host + " is already held by " + hold.Holder + "; choose another address" } // ReportApexSquats logs, once, every route that sits at an app's platform apex // but belongs to a different app — the state the checks above now prevent, // left over from before they existed. It reports and repairs nothing: which // app is in the wrong place is a person's call, and moving a live route is // not something to do quietly at boot. Started from cmd/console, never from // NewServer. func (s *Server) ReportApexSquats(ctx context.Context) { for _, line := range s.apexSquats(ctx) { log.Printf("console: apex squat: %s", line) } } // apexSquats is ReportApexSquats' finding, one line per squat. func (s *Server) apexSquats(ctx context.Context) []string { domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) if domain == "" || s.cfg.Registry == "" { return nil } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: apex squat check: open registry: %v", err) return nil } routes, err := reg.List() if err != nil { log.Printf("console: apex squat check: list routes: %v", err) return nil } var out []string for _, rt := range routes { rest, ok := strings.CutSuffix(rt.Host, "."+domain) if !ok { continue } label, sqid, ok := strings.Cut(rest, ".") if !ok || strings.Contains(sqid, ".") { continue } // The same test apexHolder applies: a route is the label's own only // when its app is the label and its account, if it names one, is the // apex's. if rt.App == label && (rt.Account == "" || rt.Account == sqid) { continue } acct, err := s.store.AccountBySqid(sqid) if err != nil || acct == nil { continue } live, trashed, err := s.store.AppNameState(acct.ID, label) if err != nil || (!live && !trashed) { continue } state := "live" if trashed { state = "in the trash" } owner := rt.App if owner == "" { owner = "no app" } inAcct := rt.Account if inAcct == "" { inAcct = "no account" } out = append(out, rt.Host+" is app "+label+"'s platform apex ("+label+" is "+state+"), but the route belongs to "+owner+" ("+inAcct+")") } return out }