| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/domains" |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | ) |
| 12 | |
| 13 | // directAppsDomainHost reports whether host is one label under the apps |
| 14 | // domain on a deployment that reserves platform domains. Those names get the |
| 15 | // delegated-alias limits: no wildcard, no catchall, no first route. Without |
| 16 | // ReservePlatformDomains the apps domain is not reserved, so the answer is |
| 17 | // false and the names are ordinary custom domains. |
| 18 | func (s *Server) directAppsDomainHost(host string) bool { |
| 19 | if !s.cfg.ReservePlatformDomains { |
| 20 | return false |
| 21 | } |
| 22 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 23 | if domain == "" || !strings.HasSuffix(host, "."+domain) { |
| 24 | return false |
| 25 | } |
| 26 | label := strings.TrimSuffix(host, "."+domain) |
| 27 | return label != "" && !strings.Contains(label, ".") |
| 28 | } |
| 29 | |
| 30 | // hostPolicy is the /domains doors' answer about a hostname, decided from |
| 31 | // the name and the deployment's config alone, before any claim is read. It |
| 32 | // can therefore never say anything about who holds a claim. |
| 33 | type hostPolicy int |
| 34 | |
| 35 | const ( |
| 36 | // hostOpen is an ordinary hostname: any member may attach it. |
| 37 | hostOpen hostPolicy = iota |
| 38 | // hostPlatform is in the deployment's own zones (platformDomains). Only |
| 39 | // a platform operator may attach it, through the --platform door, and |
| 40 | // only a platform operator may change or release a claim on it (#209). |
| 41 | hostPlatform |
| 42 | // hostReserved is refused to everyone, operators included |
| 43 | // (reservedDomains, and the console's own hostnames by exact name). |
| 44 | hostReserved |
| 45 | ) |
| 46 | |
| 47 | func (p hostPolicy) String() string { |
| 48 | switch p { |
| 49 | case hostOpen: |
| 50 | return "open" |
| 51 | case hostPlatform: |
| 52 | return "platform" |
| 53 | case hostReserved: |
| 54 | return "reserved" |
| 55 | } |
| 56 | return "unknown" |
| 57 | } |
| 58 | |
| 59 | // domainHostPolicy classifies an attach of host by account. Every /domains |
| 60 | // door (the CLI API door, the console form's attachDomain, and the paths and |
| 61 | // detach doors) asks this one question, so they cannot drift. The console's |
| 62 | // own hostnames are reserved by exact name on every deployment |
| 63 | // (consoleHosts); reservedDomains and platformDomains cover whole subtrees, |
| 64 | // and reserved wins where the two overlap (a tunnel host under a |
| 65 | // CONSOLE_DENY_DOMAINS entry stays reserved). |
| 66 | func (s *Server) domainHostPolicy(host, account string) hostPolicy { |
| 67 | if slices.Contains(s.consoleHosts(), host) { |
| 68 | return hostReserved |
| 69 | } |
| 70 | // provision.SidecarSuffix names a sidecar process, never a hostname |
| 71 | // anyone serves. attachDomain refuses it with its own message; this |
| 72 | // covers the API door's alias branch, which does not reach that check, |
| 73 | // and keeps a deny entry naming the suffix from turning it into a |
| 74 | // platform name the operator's door would lift (Astra review, #209). |
| 75 | if strings.HasSuffix(host, provision.SidecarSuffix) { |
| 76 | return hostReserved |
| 77 | } |
| 78 | // Another live account's <sqid>.<apps-domain> namespace (#215): reserved |
| 79 | // to everyone, the operator's door included, and whatever |
| 80 | // ReservePlatformDomains says. |
| 81 | if s.otherAccountsNamespace(host, account) { |
| 82 | return hostReserved |
| 83 | } |
| 84 | allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) |
| 85 | if allowed { |
| 86 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 87 | label := strings.TrimSuffix(host, "."+domain) |
| 88 | if domains.ValidSqid(label) { |
| 89 | return hostReserved |
| 90 | } |
| 91 | } |
| 92 | if domains.HostDenied(host, s.reservedDomains(allowed)) { |
| 93 | return hostReserved |
| 94 | } |
| 95 | if domains.HostDenied(host, s.platformDomains(allowed)) { |
| 96 | return hostPlatform |
| 97 | } |
| 98 | return hostOpen |
| 99 | } |
| 100 | |
| 101 | // domainHostDenied reports whether a MEMBER's attach of host by account is |
| 102 | // refused as platform-managed. |
| 103 | func (s *Server) domainHostDenied(host, account string) bool { |
| 104 | return s.domainHostPolicy(host, account) != hostOpen |
| 105 | } |
| 106 | |
| 107 | // platformOperator reports whether addr may use the platform door (#209): |
| 108 | // attach a hostname in the deployment's own zones, and change or release a |
| 109 | // claim on one. It is apiSystemRoller's rule, in its order: an owner of the |
| 110 | // ops account, or an address on SystemOperators (CARLOS_SYSTEM_OPERATORS). |
| 111 | // The owner check comes first and a store error is not the end, so a listed |
| 112 | // operator still gets in on a console whose role lookup failed, and a store |
| 113 | // error is never what admits anyone. |
| 114 | // |
| 115 | // It grants no reach into an account: every door that asks it has already |
| 116 | // established that addr may act on the account and app in question. |
| 117 | func (s *Server) platformOperator(addr string) bool { |
| 118 | if addr == "" { |
| 119 | return false |
| 120 | } |
| 121 | role, err := s.store.Role(OpsAccountID, addr) |
| 122 | if err != nil { |
| 123 | log.Printf("console: platform operator: role: %v", err) |
| 124 | } |
| 125 | if err == nil && role == RoleOwner { |
| 126 | return true |
| 127 | } |
| 128 | return s.systemOperator(addr) |
| 129 | } |
| 130 | |
| 131 | // principalPlatformOperator is platformOperator for an API caller: only a |
| 132 | // person can be one. A service credential acts for an app, never for the |
| 133 | // deployment. |
| 134 | func (s *Server) principalPlatformOperator(p Principal) bool { |
| 135 | return p.Kind == PrincipalUser && s.platformOperator(p.Address) |
| 136 | } |
| 137 | |
| 138 | // platformOperatorMsg is the refusal for a caller who asks for the platform |
| 139 | // door, or writes to a platform hostname's claim, without being an operator. |
| 140 | const platformOperatorMsg = "that hostname is platform-managed. only a platform operator (an ops account owner, or an address on CARLOS_SYSTEM_OPERATORS) can attach or change it" |
| 141 | |
| 142 | // appsNamespace returns the label of the account namespace host falls in — |
| 143 | // "bes" for bes.<apps-domain> and for anything under it, such as |
| 144 | // web.bes.<apps-domain> — or "" when host is not under the apps domain or the |
| 145 | // label could not be an account (domains.ValidSqid). Shape only: whether an |
| 146 | // account with that sqid exists is the caller's question. |
| 147 | func (s *Server) appsNamespace(host string) string { |
| 148 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 149 | if domain == "" { |
| 150 | return "" |
| 151 | } |
| 152 | rest, ok := strings.CutSuffix(host, "."+domain) |
| 153 | if !ok || rest == "" { |
| 154 | return "" |
| 155 | } |
| 156 | label := rest[strings.LastIndex(rest, ".")+1:] |
| 157 | if !domains.ValidSqid(label) { |
| 158 | return "" |
| 159 | } |
| 160 | return label |
| 161 | } |
| 162 | |
| 163 | // otherAccountsNamespace reports whether host is in ANOTHER account's |
| 164 | // namespace (#215): <sqid>.<apps-domain> or anything under it, where sqid |
| 165 | // names an account (live or deleted) that is not account. |
| 166 | // |
| 167 | // Refused on every deployment, with or without ReservePlatformDomains. That |
| 168 | // setting decides whether the apps domain is the operator's to hand out, |
| 169 | // which is a real choice on a self-hosted box; it was never meant to decide |
| 170 | // whether one tenant may take another's addresses. Without this, an account |
| 171 | // could attach new.<their-sqid>.<apps-domain>, which serves at once through |
| 172 | // the DNS wildcard and their account wildcard certificate, and then stop |
| 173 | // them creating an app of that name (claimApp's apexHolder check). |
| 174 | // |
| 175 | // Only labels some account has held. A sqid-shaped label nobody has held |
| 176 | // stays an ordinary name, as it always was on a deployment that does not reserve the apps |
| 177 | // domain (www and app are both sqid-shaped). What such a name cannot do is |
| 178 | // become a first route, because a first route under the apps domain would |
| 179 | // make the edge order a wildcard for its label (firstRouteOutsideNamespace). |
| 180 | // |
| 181 | // A lookup failure refuses: the cost of a false refusal is a retry, and the |
| 182 | // cost of a false admission is another tenant's address. |
| 183 | func (s *Server) otherAccountsNamespace(host, account string) bool { |
| 184 | label := s.appsNamespace(host) |
| 185 | if label == "" || label == account { |
| 186 | return false |
| 187 | } |
| 188 | if s.store == nil { |
| 189 | return true // no account table to ask: refuse, as on a lookup failure |
| 190 | } |
| 191 | // AccountAnyBySqid, not AccountBySqid: a deleted account's namespace |
| 192 | // stays reserved too. Its tombstone keeps the sqid from ever being |
| 193 | // reissued, and a name claimed under it would keep serving through the |
| 194 | // apps-domain wildcard DNS while looking like that account's address. |
| 195 | a, _, err := s.store.AccountAnyBySqid(label) |
| 196 | if err != nil { |
| 197 | log.Printf("console: domains: account lookup for %s: %v — refusing", host, err) |
| 198 | return true |
| 199 | } |
| 200 | return a != nil |
| 201 | } |
| 202 | |
| 203 | // firstRouteOutsideNamespace reports whether a FIRST-ROUTE attach of host by |
| 204 | // account would mint a route in a namespace under the apps domain that is not |
| 205 | // the account's own: two or more labels under it, with a sqid-shaped label |
| 206 | // that is not this account's sqid. Such a route is Provisioned, and the edge |
| 207 | // derives an account wildcard parent, <label>.<apps-domain>, from every |
| 208 | // Provisioned route two labels under the apps domain (edge.parentsFor), so |
| 209 | // each invented label would order a wildcard certificate against the |
| 210 | // deployment's shared ACME budget. otherAccountsNamespace already refuses a |
| 211 | // live account's label; this covers the labels no account holds YET. |
| 212 | // |
| 213 | // Labels that could never be a sqid are left alone here: parentsFor derives |
| 214 | // no parent from them, so a first route under one costs one ordinary |
| 215 | // per-host certificate, as any custom domain does. |
| 216 | func (s *Server) firstRouteOutsideNamespace(host, account string) bool { |
| 217 | domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| 218 | if domain == "" { |
| 219 | return false |
| 220 | } |
| 221 | rest, ok := strings.CutSuffix(host, "."+domain) |
| 222 | if !ok || !strings.Contains(rest, ".") { |
| 223 | return false |
| 224 | } |
| 225 | label := s.appsNamespace(host) |
| 226 | return label != "" && label != account |
| 227 | } |
| 228 | |
| 229 | // domainProofExempt reports whether account's claim on host needs no TXT |
| 230 | // proof, because the platform, not a customer zone, answers for the name: |
| 231 | // the account's own namespace under the apps domain, and the one-label names |
| 232 | // an operator delegated to it (CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS). Neither |
| 233 | // has a zone the member could publish a record in, and both were already |
| 234 | // settled as this account's by the deployment's own config. |
| 235 | func (s *Server) domainProofExempt(host, account string) bool { |
| 236 | if s.appsNamespace(host) == account && account != "" { |
| 237 | return true |
| 238 | } |
| 239 | // A platform hostname (#209) is in the deployment's own zones: only a |
| 240 | // platform operator gets past the door to attach one, and its zone is |
| 241 | // the operator's, so the operator's say-so is the proof. |
| 242 | if s.domainHostPolicy(host, account) == hostPlatform { |
| 243 | return true |
| 244 | } |
| 245 | return domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) |
| 246 | } |
| 247 | |
| 248 | // challengeDeployment is the deployment name ownership tokens are bound to |
| 249 | // (domains.ChallengeToken): the console's own hostname. |
| 250 | func (s *Server) challengeDeployment() string { |
| 251 | if hosts := s.consoleHosts(); len(hosts) > 0 { |
| 252 | return hosts[0] |
| 253 | } |
| 254 | return "" |
| 255 | } |
| 256 | |
| 257 | // domainChallenge is the TXT record that proves account controls host. |
| 258 | func (s *Server) domainChallenge(host, account string) dnsRecord { |
| 259 | return dnsRecord{ |
| 260 | Type: "TXT", Name: domains.ChallengeName(host), |
| 261 | Value: domains.ChallengeToken(s.challengeDeployment(), account, host), |
| 262 | TTL: domainRecordTTL, Set: "verify", Required: true, |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // domainProver returns the proof AttachClaimProven asks for: true when the |
| 267 | // name is proof-exempt, otherwise whether ChallengeName(host) carries this |
| 268 | // account's token right now. Any lookup failure is "not proven" — the claim |
| 269 | // is then written pending and the member retries, which is the safe |
| 270 | // direction for a check whose whole job is to refuse the unproven. |
| 271 | // |
| 272 | // With DomainProof off (the default) every attach is proven by definition, |
| 273 | // so claims are written verified and the resolver is never asked: the |
| 274 | // behaviour this door had before #215. |
| 275 | func (s *Server) domainProver(ctx context.Context, host, account string) func() bool { |
| 276 | return func() bool { |
| 277 | if !s.cfg.DomainProof || s.domainProofExempt(host, account) { |
| 278 | return true |
| 279 | } |
| 280 | want := domains.ChallengeToken(s.challengeDeployment(), account, host) |
| 281 | got, err := s.lookupTXT(ctx, domains.ChallengeName(host)) |
| 282 | if err != nil { |
| 283 | return false |
| 284 | } |
| 285 | for _, v := range got { |
| 286 | if strings.TrimSpace(v) == want { |
| 287 | return true |
| 288 | } |
| 289 | } |
| 290 | return false |
| 291 | } |
| 292 | } |
| 293 | |