package console import ( "context" "log" "slices" "strings" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/provision" ) // directAppsDomainHost reports whether host is one label under the apps // domain on a deployment that reserves platform domains. Those names get the // delegated-alias limits: no wildcard, no catchall, no first route. Without // ReservePlatformDomains the apps domain is not reserved, so the answer is // false and the names are ordinary custom domains. func (s *Server) directAppsDomainHost(host string) bool { if !s.cfg.ReservePlatformDomains { return false } domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) if domain == "" || !strings.HasSuffix(host, "."+domain) { return false } label := strings.TrimSuffix(host, "."+domain) return label != "" && !strings.Contains(label, ".") } // hostPolicy is the /domains doors' answer about a hostname, decided from // the name and the deployment's config alone, before any claim is read. It // can therefore never say anything about who holds a claim. type hostPolicy int const ( // hostOpen is an ordinary hostname: any member may attach it. hostOpen hostPolicy = iota // hostPlatform is in the deployment's own zones (platformDomains). Only // a platform operator may attach it, through the --platform door, and // only a platform operator may change or release a claim on it (#209). hostPlatform // hostReserved is refused to everyone, operators included // (reservedDomains, and the console's own hostnames by exact name). hostReserved ) func (p hostPolicy) String() string { switch p { case hostOpen: return "open" case hostPlatform: return "platform" case hostReserved: return "reserved" } return "unknown" } // domainHostPolicy classifies an attach of host by account. Every /domains // door (the CLI API door, the console form's attachDomain, and the paths and // detach doors) asks this one question, so they cannot drift. The console's // own hostnames are reserved by exact name on every deployment // (consoleHosts); reservedDomains and platformDomains cover whole subtrees, // and reserved wins where the two overlap (a tunnel host under a // CONSOLE_DENY_DOMAINS entry stays reserved). func (s *Server) domainHostPolicy(host, account string) hostPolicy { if slices.Contains(s.consoleHosts(), host) { return hostReserved } // provision.SidecarSuffix names a sidecar process, never a hostname // anyone serves. attachDomain refuses it with its own message; this // covers the API door's alias branch, which does not reach that check, // and keeps a deny entry naming the suffix from turning it into a // platform name the operator's door would lift (Astra review, #209). if strings.HasSuffix(host, provision.SidecarSuffix) { return hostReserved } // Another live account's . namespace (#215): reserved // to everyone, the operator's door included, and whatever // ReservePlatformDomains says. if s.otherAccountsNamespace(host, account) { return hostReserved } allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) if allowed { domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) label := strings.TrimSuffix(host, "."+domain) if domains.ValidSqid(label) { return hostReserved } } if domains.HostDenied(host, s.reservedDomains(allowed)) { return hostReserved } if domains.HostDenied(host, s.platformDomains(allowed)) { return hostPlatform } return hostOpen } // domainHostDenied reports whether a MEMBER's attach of host by account is // refused as platform-managed. func (s *Server) domainHostDenied(host, account string) bool { return s.domainHostPolicy(host, account) != hostOpen } // platformOperator reports whether addr may use the platform door (#209): // attach a hostname in the deployment's own zones, and change or release a // claim on one. It is apiSystemRoller's rule, in its order: an owner of the // ops account, or an address on SystemOperators (CARLOS_SYSTEM_OPERATORS). // The owner check comes first and a store error is not the end, so a listed // operator still gets in on a console whose role lookup failed, and a store // error is never what admits anyone. // // It grants no reach into an account: every door that asks it has already // established that addr may act on the account and app in question. func (s *Server) platformOperator(addr string) bool { if addr == "" { return false } role, err := s.store.Role(OpsAccountID, addr) if err != nil { log.Printf("console: platform operator: role: %v", err) } if err == nil && role == RoleOwner { return true } return s.systemOperator(addr) } // principalPlatformOperator is platformOperator for an API caller: only a // person can be one. A service credential acts for an app, never for the // deployment. func (s *Server) principalPlatformOperator(p Principal) bool { return p.Kind == PrincipalUser && s.platformOperator(p.Address) } // platformOperatorMsg is the refusal for a caller who asks for the platform // door, or writes to a platform hostname's claim, without being an operator. 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" // appsNamespace returns the label of the account namespace host falls in — // "bes" for bes. and for anything under it, such as // web.bes. — or "" when host is not under the apps domain or the // label could not be an account (domains.ValidSqid). Shape only: whether an // account with that sqid exists is the caller's question. func (s *Server) appsNamespace(host string) string { domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) if domain == "" { return "" } rest, ok := strings.CutSuffix(host, "."+domain) if !ok || rest == "" { return "" } label := rest[strings.LastIndex(rest, ".")+1:] if !domains.ValidSqid(label) { return "" } return label } // otherAccountsNamespace reports whether host is in ANOTHER account's // namespace (#215): . or anything under it, where sqid // names an account (live or deleted) that is not account. // // Refused on every deployment, with or without ReservePlatformDomains. That // setting decides whether the apps domain is the operator's to hand out, // which is a real choice on a self-hosted box; it was never meant to decide // whether one tenant may take another's addresses. Without this, an account // could attach new.., which serves at once through // the DNS wildcard and their account wildcard certificate, and then stop // them creating an app of that name (claimApp's apexHolder check). // // Only labels some account has held. A sqid-shaped label nobody has held // stays an ordinary name, as it always was on a deployment that does not reserve the apps // domain (www and app are both sqid-shaped). What such a name cannot do is // become a first route, because a first route under the apps domain would // make the edge order a wildcard for its label (firstRouteOutsideNamespace). // // A lookup failure refuses: the cost of a false refusal is a retry, and the // cost of a false admission is another tenant's address. func (s *Server) otherAccountsNamespace(host, account string) bool { label := s.appsNamespace(host) if label == "" || label == account { return false } if s.store == nil { return true // no account table to ask: refuse, as on a lookup failure } // AccountAnyBySqid, not AccountBySqid: a deleted account's namespace // stays reserved too. Its tombstone keeps the sqid from ever being // reissued, and a name claimed under it would keep serving through the // apps-domain wildcard DNS while looking like that account's address. a, _, err := s.store.AccountAnyBySqid(label) if err != nil { log.Printf("console: domains: account lookup for %s: %v — refusing", host, err) return true } return a != nil } // firstRouteOutsideNamespace reports whether a FIRST-ROUTE attach of host by // account would mint a route in a namespace under the apps domain that is not // the account's own: two or more labels under it, with a sqid-shaped label // that is not this account's sqid. Such a route is Provisioned, and the edge // derives an account wildcard parent,