| 1 | package console |
| 2 | |
| 3 | // An app's platform apex is <app>.<sqid>.<AppsDomain>, and it is that app's. |
| 4 | // Two things can put somebody else there. The instances create door is one: |
| 5 | // a sibling registering an instance at another app's apex, which that door |
| 6 | // refuses (live or trashed — apiinstances.go). The other is the name arriving |
| 7 | // AFTER the instance: an app created, renamed or moved to a name whose apex a |
| 8 | // sibling already registered while the name was free. Nothing mints a route |
| 9 | // over a host that already has one (the domains pass defers to byHost for |
| 10 | // ever), so the new app would never have its own URL — and `carlos email |
| 11 | // events push` would post its recipients' addresses to the sibling. This file |
| 12 | // is the check those doors share, and a startup report for anything that got |
| 13 | // there before the check existed. |
| 14 | |
| 15 | import ( |
| 16 | "context" |
| 17 | "errors" |
| 18 | "log" |
| 19 | "os" |
| 20 | "strings" |
| 21 | |
| 22 | "github.com/carlosframework/platform/internal/domains" |
| 23 | "github.com/carlosframework/platform/internal/provision" |
| 24 | ) |
| 25 | |
| 26 | // apexHost is the platform apex app would have in the account with this |
| 27 | // sqid, or "" on a deployment with no apps domain (there is no apex space to |
| 28 | // collide in). |
| 29 | func (s *Server) apexHost(sqid, app string) string { |
| 30 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 31 | if domain == "" { |
| 32 | return "" |
| 33 | } |
| 34 | return app + "." + sqid + "." + domain |
| 35 | } |
| 36 | |
| 37 | // apexHold is who already holds an apex: the app a route or an instance |
| 38 | // record belongs to — "" for a route that names no app (a status page, a bare |
| 39 | // proxy, anything hand-added) — whether that app is in the trash, and the |
| 40 | // account a route says it serves when that is not the apex's own. |
| 41 | type apexHold struct { |
| 42 | Holder string |
| 43 | Trashed bool |
| 44 | OtherAccount string |
| 45 | } |
| 46 | |
| 47 | // apexErr says which store an apexHolder failure came from, so a door can |
| 48 | // tell a member the truth about what could not be checked. |
| 49 | type apexErr struct { |
| 50 | bucket bool |
| 51 | err error |
| 52 | } |
| 53 | |
| 54 | func (e *apexErr) Error() string { return e.err.Error() } |
| 55 | func (e *apexErr) Unwrap() error { return e.err } |
| 56 | |
| 57 | // apexBucketFailure reports whether err came from the release store rather |
| 58 | // than from this box's registry or database. |
| 59 | func apexBucketFailure(err error) bool { |
| 60 | var ae *apexErr |
| 61 | return errors.As(err, &ae) && ae.bucket |
| 62 | } |
| 63 | |
| 64 | // apexHolder answers whether the platform apex name would have in acct is |
| 65 | // already held by something that is not name's own — nil when it is free. |
| 66 | // It looks where a holder could be: a route at the host, whatever put it |
| 67 | // there, and an instance record at it under any other app of the account, |
| 68 | // live or trashed, which the provisioning reconciler would turn into a route. |
| 69 | // |
| 70 | // A route is name's own only when it says so on both counts: its app is name |
| 71 | // and its account, if it names one, is acct. A route with no app at all holds |
| 72 | // the host exactly as firmly as one with an app — the domains pass defers to |
| 73 | // it all the same. |
| 74 | // |
| 75 | // The route half reads THIS console box's registry, as appHasRoutes and |
| 76 | // routeAccountFor do; a route hand-added on another box is invisible to it, |
| 77 | // while a reconciler-minted one is covered by its record. It fails CLOSED: |
| 78 | // an error is an error, never "free". |
| 79 | func (s *Server) apexHolder(ctx context.Context, acct *Account, name string) (*apexHold, error) { |
| 80 | host := s.apexHost(acct.Sqid, name) |
| 81 | if host == "" { |
| 82 | return nil, nil |
| 83 | } |
| 84 | return s.hostHolder(ctx, acct, host, name) |
| 85 | } |
| 86 | |
| 87 | // hostHolder answers whether host is already held by an app of acct other |
| 88 | // than own — nil when it is free, or when the only holder is own itself. |
| 89 | // apexHolder is this question asked of an app's platform apex; the instance |
| 90 | // create door asks it of whatever host a record is about to claim, because |
| 91 | // instance records are keyed per app (provision.RecordKey) and nothing in |
| 92 | // the key stops two apps of one account from both writing the same host. The |
| 93 | // provisioning pass would then keep whichever record it listed first and |
| 94 | // skip the other with a log line (provision/reconcile.go, "duplicate host"), |
| 95 | // so the loser looks created and serves nothing, or serves the other app's |
| 96 | // process under its own name. |
| 97 | // |
| 98 | // A flat canary host (<canary>-<app>.<domain>, discussion 207) is how that |
| 99 | // became easy to reach: canary "a" of app "b-c" and canary "a-b" of app "c" |
| 100 | // are the same label. |
| 101 | // |
| 102 | // Where a holder could be, and the fail-closed rule, are apexHolder's: a |
| 103 | // route at the host, whatever put it there, and an instance record at it |
| 104 | // under any other app of the account, live or trashed. |
| 105 | func (s *Server) hostHolder(ctx context.Context, acct *Account, host, own string) (*apexHold, error) { |
| 106 | name := own |
| 107 | if s.cfg.Registry != "" { |
| 108 | reg, err := openRegistry(s.cfg.Registry) |
| 109 | if err != nil { |
| 110 | return nil, &apexErr{err: err} |
| 111 | } |
| 112 | rt, err := reg.Get(host) |
| 113 | if err != nil { |
| 114 | return nil, &apexErr{err: err} |
| 115 | } |
| 116 | if rt != nil && (rt.App != name || (rt.Account != "" && rt.Account != acct.Sqid)) { |
| 117 | hold := &apexHold{Holder: rt.App} |
| 118 | if rt.Account != "" && rt.Account != acct.Sqid { |
| 119 | hold.OtherAccount = rt.Account |
| 120 | } |
| 121 | return hold, nil |
| 122 | } |
| 123 | } |
| 124 | if s.cfg.Releases == nil { |
| 125 | return nil, nil |
| 126 | } |
| 127 | live, err := s.store.AppsIn(acct.ID) |
| 128 | if err != nil { |
| 129 | return nil, &apexErr{err: err} |
| 130 | } |
| 131 | trashed, err := s.store.TrashedAppsIn(acct.ID) |
| 132 | if err != nil { |
| 133 | return nil, &apexErr{err: err} |
| 134 | } |
| 135 | type candidate struct { |
| 136 | app string |
| 137 | trashed bool |
| 138 | } |
| 139 | var apps []candidate |
| 140 | for _, a := range live { |
| 141 | apps = append(apps, candidate{app: a}) |
| 142 | } |
| 143 | for _, t := range trashed { |
| 144 | apps = append(apps, candidate{app: t.App, trashed: true}) |
| 145 | } |
| 146 | for _, c := range apps { |
| 147 | if c.app == name { |
| 148 | continue |
| 149 | } |
| 150 | rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, c.app, host) |
| 151 | if errors.Is(err, os.ErrNotExist) { |
| 152 | continue |
| 153 | } |
| 154 | if err != nil { |
| 155 | return nil, &apexErr{bucket: true, err: err} |
| 156 | } |
| 157 | if rec != nil { |
| 158 | return &apexHold{Holder: c.app, Trashed: c.trashed}, nil |
| 159 | } |
| 160 | } |
| 161 | return nil, nil |
| 162 | } |
| 163 | |
| 164 | // hostClaimHolder is the other half of hostHolder: the global hostname claim |
| 165 | // (domains.ClaimKey, one object per host whoever asks) that an attached alias |
| 166 | // is derived from. An alias has no instance record and, until the domains |
| 167 | // pass materializes it, no route either, so hostHolder cannot see it; the |
| 168 | // claim is the authoritative record and one GET. A route minted at a host |
| 169 | // somebody else has claimed would shadow their alias at the edge. |
| 170 | // |
| 171 | // It returns who the claim belongs to when that is not own in acct, nil when |
| 172 | // there is no claim or it is own's. A claim of another account comes back |
| 173 | // with only OtherAccount set and no Holder: domains.ErrHostUnavailable is |
| 174 | // deliberately neutral about who owns a hostname, and this door must not be a |
| 175 | // way round that. Anything but "no such claim" is an error: it fails closed. |
| 176 | func (s *Server) hostClaimHolder(ctx context.Context, acct *Account, host, own string) (*apexHold, error) { |
| 177 | if s.cfg.Releases == nil { |
| 178 | return nil, nil |
| 179 | } |
| 180 | c, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| 181 | if errors.Is(err, os.ErrNotExist) { |
| 182 | return nil, nil |
| 183 | } |
| 184 | if err != nil { |
| 185 | return nil, &apexErr{bucket: true, err: err} |
| 186 | } |
| 187 | switch { |
| 188 | case c.Account != acct.Sqid: |
| 189 | return &apexHold{OtherAccount: c.Account}, nil |
| 190 | case c.App != own: |
| 191 | return &apexHold{Holder: c.App}, nil |
| 192 | } |
| 193 | return nil, nil |
| 194 | } |
| 195 | |
| 196 | // claimHeldSentence is hostHeldSentence for a hostname claim. A claim of |
| 197 | // another account is refused without saying whose or that it is a claim. |
| 198 | func claimHeldSentence(host string, hold *apexHold) string { |
| 199 | if hold.OtherAccount != "" { |
| 200 | return hostHeldSentence(host, nil) |
| 201 | } |
| 202 | return hostHeldSentence(host, hold) |
| 203 | } |
| 204 | |
| 205 | // apexHeldSentence is the refusal, in one voice for every door that gives |
| 206 | // it, with the advice that fits who holds the host. |
| 207 | func apexHeldSentence(host string, hold *apexHold) string { |
| 208 | switch { |
| 209 | case hold == nil: |
| 210 | return host + " — this app's platform address — is already held" |
| 211 | case hold.OtherAccount != "" && hold.Holder != "": |
| 212 | return host + " — this app's platform address — is already routed to " + hold.Holder + " in account " + hold.OtherAccount + "; that is an operator repair" |
| 213 | case hold.OtherAccount != "": |
| 214 | return host + " — this app's platform address — is already routed for account " + hold.OtherAccount + "; that is an operator repair" |
| 215 | case hold.Holder == "": |
| 216 | return host + " — this app's platform address — already has a route with no app; an operator has to remove it first" |
| 217 | case hold.Trashed: |
| 218 | return host + " — this app's platform address — is already an instance of " + hold.Holder + |
| 219 | ", which is in the trash; restore " + hold.Holder + " and remove that instance, or purge it, first" |
| 220 | } |
| 221 | return host + " — this app's platform address — is already an instance of " + hold.Holder + |
| 222 | "; remove that instance first, or choose another name" |
| 223 | } |
| 224 | |
| 225 | // hostHeldSentence is the instance create door's refusal for a host another |
| 226 | // app of the account already holds (hostHolder), with the advice that fits |
| 227 | // who holds it. It does not claim the host is anyone's apex: that is |
| 228 | // apexHeldSentence's case, and the door's own apex check answers it first. |
| 229 | func hostHeldSentence(host string, hold *apexHold) string { |
| 230 | switch { |
| 231 | case hold == nil: |
| 232 | return host + " is already held" |
| 233 | case hold.OtherAccount != "" && hold.Holder != "": |
| 234 | return host + " is already routed to " + hold.Holder + " in account " + hold.OtherAccount + "; that is an operator repair" |
| 235 | case hold.OtherAccount != "": |
| 236 | return host + " is already routed for account " + hold.OtherAccount + "; that is an operator repair" |
| 237 | case hold.Holder == "": |
| 238 | return host + " already has a route with no app; an operator has to remove it first" |
| 239 | case hold.Trashed: |
| 240 | return host + " is already held by " + hold.Holder + ", which is in the trash; choose another address" |
| 241 | } |
| 242 | return host + " is already held by " + hold.Holder + "; choose another address" |
| 243 | } |
| 244 | |
| 245 | // ReportApexSquats logs, once, every route that sits at an app's platform apex |
| 246 | // but belongs to a different app — the state the checks above now prevent, |
| 247 | // left over from before they existed. It reports and repairs nothing: which |
| 248 | // app is in the wrong place is a person's call, and moving a live route is |
| 249 | // not something to do quietly at boot. Started from cmd/console, never from |
| 250 | // NewServer. |
| 251 | func (s *Server) ReportApexSquats(ctx context.Context) { |
| 252 | for _, line := range s.apexSquats(ctx) { |
| 253 | log.Printf("console: apex squat: %s", line) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // apexSquats is ReportApexSquats' finding, one line per squat. |
| 258 | func (s *Server) apexSquats(ctx context.Context) []string { |
| 259 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 260 | if domain == "" || s.cfg.Registry == "" { |
| 261 | return nil |
| 262 | } |
| 263 | reg, err := openRegistry(s.cfg.Registry) |
| 264 | if err != nil { |
| 265 | log.Printf("console: apex squat check: open registry: %v", err) |
| 266 | return nil |
| 267 | } |
| 268 | routes, err := reg.List() |
| 269 | if err != nil { |
| 270 | log.Printf("console: apex squat check: list routes: %v", err) |
| 271 | return nil |
| 272 | } |
| 273 | var out []string |
| 274 | for _, rt := range routes { |
| 275 | rest, ok := strings.CutSuffix(rt.Host, "."+domain) |
| 276 | if !ok { |
| 277 | continue |
| 278 | } |
| 279 | label, sqid, ok := strings.Cut(rest, ".") |
| 280 | if !ok || strings.Contains(sqid, ".") { |
| 281 | continue |
| 282 | } |
| 283 | // The same test apexHolder applies: a route is the label's own only |
| 284 | // when its app is the label and its account, if it names one, is the |
| 285 | // apex's. |
| 286 | if rt.App == label && (rt.Account == "" || rt.Account == sqid) { |
| 287 | continue |
| 288 | } |
| 289 | acct, err := s.store.AccountBySqid(sqid) |
| 290 | if err != nil || acct == nil { |
| 291 | continue |
| 292 | } |
| 293 | live, trashed, err := s.store.AppNameState(acct.ID, label) |
| 294 | if err != nil || (!live && !trashed) { |
| 295 | continue |
| 296 | } |
| 297 | state := "live" |
| 298 | if trashed { |
| 299 | state = "in the trash" |
| 300 | } |
| 301 | owner := rt.App |
| 302 | if owner == "" { |
| 303 | owner = "no app" |
| 304 | } |
| 305 | inAcct := rt.Account |
| 306 | if inAcct == "" { |
| 307 | inAcct = "no account" |
| 308 | } |
| 309 | out = append(out, rt.Host+" is app "+label+"'s platform apex ("+label+" is "+state+"), but the route belongs to "+owner+" ("+inAcct+")") |
| 310 | } |
| 311 | return out |
| 312 | } |
| 313 | |