domains: the operator's door for platform hostnames (#209)
The deny list now has two tiers. Platform names (CONSOLE_DENY_DOMAINS, and with CONSOLE_RESERVE_PLATFORM_DOMAINS the names under the console's hostnames) are refused to members, but an ops account owner or a CARLOS_SYSTEM_OPERATORS address may attach one to an app in an account they belong to with carlos domains attach --platform. Reserved names (console hosts, steering host, tunnel host, the apps domain) stay refused to everyone; the tunnel host moves out of DenyDomains into Config.TunnelHost so the door cannot reach it. Once claimed, a platform name's claim is the operator's alone: members of the holding account cannot repoint it, flip wildcard/catchall, publish paths or detach it, through the API or the settings page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Can we address carlos/platform #209 ?
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Two-tier deny list plus an operator-only --platform attach; members can't change an operator's platform claim afterwards.
Agent notes
Paths and detach gate hostPlatform only, not hostReserved, so a member holding a legacy claim on a now-reserved name can still release it. Copy reviewed and approved unchanged 2026-10-03.
11 files changed,
+605
−80
AGENTS.md+20 −0README.md+1 −0apimode.go+21 −9cmd/console/main.go+21 −16domains_cli.go+9 −3domains_cli_test.go+15 −0internal/console/apidomains.go+100 −41internal/console/domainattach.go+28 −2internal/console/domainplatform_test.go+295 −0internal/console/domainpolicy.go+88 −9internal/console/server.go+7 −0
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index d42abaf7a..cdca5b6a0 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -423,3 +423,23 @@ usually keeps its console, its apps and its custom domains in one zone that |
| one person owns, and the rule refused ordinary names there. Every deployment |
| still refuses the console's own hostnames by exact name, the steering host, |
| CONSOLE_DENY_DOMAINS, and a hostname another app's route holds. |
| + |
| +## The operator's own hostnames (#209, Paul, 2026-10-03) |
| + |
| +The deny list has two tiers (domainHostPolicy, internal/console/domainpolicy.go). |
| +**Platform** names are CONSOLE_DENY_DOMAINS entries and, with |
| +CONSOLE_RESERVE_PLATFORM_DOMAINS, the names under the console's own hostnames. |
| +Members are refused them. A platform operator (an ops account owner, or an |
| +address on CARLOS_SYSTEM_OPERATORS) may attach one to an app in an account they |
| +are a member of, with `carlos domains attach --platform`. That is how carloku.com |
| +runs as an ordinary console app. **Reserved** names are refused to everyone, |
| +operators included: the console's own hostnames by exact name, the steering |
| +host, the tunnel host (Config.TunnelHost, kept out of DenyDomains so the door |
| +cannot reach it) and, with the reserve setting, the apps domain. |
| + |
| +Once a platform name is claimed, every later write to that claim is the |
| +operator's alone: a member of the holding account cannot repoint it, flip |
| +wildcard or catchall, publish its paths or detach it. These checks read the |
| +hostname, never the claim, so they reveal nothing about who holds it. The |
| +browser form has no platform door. Its attach refuses platform names to |
| +everyone, and its detach admits the operator. |
diff --git a/README.md b/README.md| index c01f67ea8..334ef860b 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -488,6 +488,7 @@ publish an inflated instance count. |
| attach any name except the console's own hostname and |
| `CONSOLE_DENY_DOMAINS`. A value that is not a boolean stops the console at |
| boot. |
| +- `CONSOLE_DENY_DOMAINS`: comma-separated hostnames in the deployment's own domains (flagship: `carloku.com`). `carlos domains attach` refuses each one, and every name under it, to members. An owner of the ops account, or an address on `CARLOS_SYSTEM_OPERATORS`, can still attach one to an app with `carlos domains attach --platform`, and after that only they can change or detach it. The console's own hostnames, the steering host, the tunnel host and, with `CONSOLE_RESERVE_PLATFORM_DOMAINS`, everything under the apps domain stay refused to everyone. |
| - `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks |
| compare against, read by both the agent (checks) and the console |
| (guidance text), so the two can never name different addresses. Default: |
diff --git a/apimode.go b/apimode.go| index 85c63470d..cd43df578 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -3681,15 +3681,27 @@ func (e *domainListError) Unwrap() error { return e.err } |
| // sees them. route and kind together are the door's 400 — an alias target |
| // and a first-route mint are two different instructions. |
| func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (created bool, resolvedRoute string, err error) { |
| - in := struct { |
| - Host string `json:"host"` |
| - Route string `json:"route,omitempty"` |
| - Kind string `json:"kind,omitempty"` |
| - Channel string `json:"channel,omitempty"` |
| - Wildcard *bool `json:"wildcard,omitempty"` |
| - Catchall *bool `json:"catchall,omitempty"` |
| - Message string `json:"message,omitempty"` |
| - }{host, route, kind, channel, wildcard, catchall, firstMutationMessage(messages)} |
| + return s.postDomainAttach(ctx, a, app, domainAttachIn{ |
| + Host: host, Route: route, Kind: kind, Channel: channel, |
| + Wildcard: wildcard, Catchall: catchall, Message: firstMutationMessage(messages), |
| + }) |
| +} |
| + |
| +// domainAttachIn is the attach door's request body. Platform asks for the |
| +// operator's door (#209): a hostname in the deployment's own zones, which |
| +// the console grants only to an ops account owner or a system operator. |
| +type domainAttachIn struct { |
| + Host string `json:"host"` |
| + Route string `json:"route,omitempty"` |
| + Kind string `json:"kind,omitempty"` |
| + Channel string `json:"channel,omitempty"` |
| + Wildcard *bool `json:"wildcard,omitempty"` |
| + Catchall *bool `json:"catchall,omitempty"` |
| + Message string `json:"message,omitempty"` |
| + Platform bool `json:"platform,omitempty"` |
| +} |
| + |
| +func (s *apiSession) postDomainAttach(ctx context.Context, a apiAcct, app string, in domainAttachIn) (created bool, resolvedRoute string, err error) { |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in) |
| if err != nil { |
| return false, "", err |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index b83594daf..9cc6220f7 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -293,6 +293,7 @@ func serve() { |
| SteeringHost: steeringHost(), |
| RedirectHosts: redirectHosts(), |
| DenyDomains: denyDomains(), |
| + TunnelHost: tunnelHost(), |
| ReservePlatformDomains: reservePlatformDomains(), |
| StoreProvisioner: storeProvisioner, |
| StoreScanner: storeScanner, |
| @@ -887,13 +888,14 @@ func reservePlatformDomains() bool { |
| // denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and |
| // everything under them, that `carlos domains attach` refuses (issue #151). |
| // This is the operator's list of names the console cannot derive on its own: |
| -// retired console origins, any other platform-managed domain. The edge's |
| -// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST. The |
| -// door itself adds the console's own hostnames, and, with |
| -// CONSOLE_RESERVE_PLATFORM_DOMAINS, PLATFORM_APPS_DOMAIN (hostDenied, |
| -// internal/console/apidomains.go). An unset variable is therefore a legal |
| -// deployment. Blank entries are dropped: an empty string in the |
| -// list would be a deny entry no hostname could avoid matching. |
| +// retired console origins, any other platform-managed domain. A platform |
| +// operator may still attach one of these to an app with --platform (#209). |
| +// The door itself adds the console's own hostnames, the tunnel host |
| +// (tunnelHost) and, with CONSOLE_RESERVE_PLATFORM_DOMAINS, |
| +// PLATFORM_APPS_DOMAIN (reservedDomains, internal/console/apidomains.go). An |
| +// unset variable is therefore a legal deployment. Blank entries are dropped: |
| +// an empty string in the list would be a deny entry no hostname could avoid |
| +// matching. |
| func denyDomains() []string { |
| var out []string |
| for _, d := range strings.Split(os.Getenv("CONSOLE_DENY_DOMAINS"), ",") { |
| @@ -901,18 +903,21 @@ func denyDomains() []string { |
| out = append(out, d) |
| } |
| } |
| - // The fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the edge's |
| - // variable, but host.env is one file per box) is Allowed by the edge and |
| - // is no registry route, so no derived deny entry names it; a tenant must |
| - // not attach it. #151 made it the operator's job to list; folding it in |
| - // here makes the MUST hold without anyone remembering. Env is read at |
| - // this layer only, as every other console setting is. |
| - if h := strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))); h != "" { |
| - out = append(out, strings.TrimSuffix(h, ".")) |
| - } |
| return out |
| } |
| +// tunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the |
| +// edge's variable, but host.env is one file per box). The edge Allows it and |
| +// no registry route names it, so no derived deny entry covers it; a tenant |
| +// must not attach it. #151 made it the operator's job to list. Passing it |
| +// to the console here makes the MUST hold without anyone remembering, and |
| +// passing it apart from CONSOLE_DENY_DOMAINS keeps it out of reach of the |
| +// operator's own --platform door (#209). Env is read at this layer only, as |
| +// every other console setting is. |
| +func tunnelHost() string { |
| + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))), ".") |
| +} |
| + |
| // storeScannerFor builds the object-storage virus-scanning engine from the |
| // environment, or returns nil — which is the ordinary shape. Scanning is |
| // opt-in and off by default: a deployment that sets nothing gets a nil |
diff --git a/domains_cli.go b/domains_cli.go| index 569514ea0..0be68b260 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -19,12 +19,14 @@ import ( |
| const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags] |
| - attach --app <a> [--route <r>] [--wildcard] [--catchall] [flags] <hostname> |
| + attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [flags] <hostname> |
| claim a customer's hostname for the app, pointed at |
| --route (default: the app's single instance record, if it |
| has exactly one — otherwise --route is required). |
| --wildcard/--catchall are capability flips: naming either |
| one changes it, an unset flag leaves it as it was. |
| + --platform attaches a hostname in this deployment's own |
| + domains. Platform operators only. |
| detach --app <a> [flags] <hostname> |
| release a hostname claim |
| list --app <a> [flags] |
| @@ -46,7 +48,7 @@ route, and writes it. The hostname comes live on the box that owns the route |
| at its next domains pass. |
| ` |
| -const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| +const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| ` |
| const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| @@ -87,6 +89,7 @@ func runDomainsAttach(args []string) { |
| route := fs.String("route", "", "route hostname to attach to") |
| wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)") |
| catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs --wildcard)") |
| + platform := fs.Bool("platform", false, "attach a hostname in this deployment's own domains (platform operators only)") |
| consoleFlag := consoleOption(fs, "console to attach through (default: the only logged-in one)") |
| acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| @@ -118,7 +121,10 @@ func runDomainsAttach(args []string) { |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, domainsAttachUsage) |
| ctx := context.Background() |
| - created, resolvedRoute, err := sess.domainAttach(ctx, acct, app2, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg) |
| + created, resolvedRoute, err := sess.postDomainAttach(ctx, acct, app2, domainAttachIn{ |
| + Host: host, Route: strings.TrimSpace(*route), |
| + Wildcard: wildcardArg, Catchall: catchallArg, Platform: *platform, |
| + }) |
| if err != nil { |
| log.Fatalf("domains attach: %v", err) |
| } |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index 92872fe70..f3071c28b 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -267,6 +267,7 @@ type fakeDomainsConsole struct { |
| lastAttachBody struct { |
| Host, Route string |
| Wildcard, Catchall *bool |
| + Platform bool |
| } |
| attachCreated bool |
| lastDetachPath string |
| @@ -730,3 +731,17 @@ func TestDomainsCLIUsageErrors(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// TestRunDomainsAttachPlatformFlag: --platform asks for the operator's door |
| +// (#209) and is sent only when named. |
| +func TestRunDomainsAttachPlatformFlag(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) |
| + if fc.lastAttachBody.Platform { |
| + t.Fatalf("bare attach body = %+v, want platform unset", fc.lastAttachBody) |
| + } |
| + runDomains([]string{"attach", "-app", "tito", "--platform", "carloku.example.com"}) |
| + if !fc.lastAttachBody.Platform || fc.lastAttachBody.Host != "carloku.example.com" { |
| + t.Fatalf("--platform body = %+v, want platform true", fc.lastAttachBody) |
| + } |
| +} |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index decd1f942..376751ba6 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -89,55 +89,92 @@ func (s *Server) consoleHosts() []string { |
| return hosts |
| } |
| -// deniedDomains are the entries whose whole subtree the /domains door |
| -// refuses. Every deployment gets two kinds of entry: |
| +// The /domains door refuses whole subtrees in two tiers (#209), and the |
| +// tier decides who may ever get past it. |
| // |
| -// - the operator's CONSOLE_DENY_DOMAINS entries (cmd/console adds the |
| -// tunnel hostname to those). An operator who lists a name has chosen to |
| -// refuse it; |
| -// - the STEERING HOST (default edge.<apps-domain>), which is platform |
| -// infrastructure and never a tenant's name. |
| +// platformDomains are the deployment's OWN zones. Members are refused them; |
| +// a platform operator may attach one to an app through the --platform door, |
| +// because these are exactly the names an operator runs their own site on |
| +// (carloku.com on the flagship, go.tito.io on Tito's): |
| // |
| -// With ReservePlatformDomains set (CONSOLE_RESERVE_PLATFORM_DOMAINS) the |
| -// console also adds the names it can derive from its own config (spec §5: |
| -// "all derived from console/deployment config, not hardcoded"). These are |
| -// the rules for a platform that hosts apps for OTHER people, such as the |
| -// flagship or carloku: |
| +// - the operator's CONSOLE_DENY_DOMAINS entries. An operator who lists a |
| +// name has chosen to refuse it to tenants; |
| +// - with ReservePlatformDomains, everything UNDER the console's own |
| +// hostnames (consoleHosts). On a shared platform those names belong to |
| +// the operator, not to a tenant. |
| // |
| -// - the APPS DOMAIN is the whole platform namespace |
| -// (<app>.<sqid>.<apps-domain>). Every app's public URL is under it, so a |
| -// claim there is one tenant taking another tenant's address. This is the |
| -// sibling-apex hazard that handleAPIInstanceCreate refuses within an |
| -// account, refused here across the deployment. Hosts under the apps |
| -// domain are what `carlos instances` is for; |
| -// - everything UNDER the console's own hostnames (consoleHosts). On a |
| -// shared platform those names belong to the operator, not to a tenant. |
| +// reservedDomains are refused to everyone, the operator included, because a |
| +// claim on any of them takes an address the platform itself or another |
| +// tenant stands on: |
| // |
| -// Without the setting, neither is added. That is the default because a |
| -// self-hosted deployment usually puts its console, its apps domain and its |
| -// custom domains in ONE zone that ONE person owns, and the reserved rules |
| -// refused ordinary names there. Existing routes are still safe without it: |
| +// - the STEERING HOST (default edge.<apps-domain>), platform |
| +// infrastructure; |
| +// - the TUNNEL HOST (CARLOS_TUNNEL_HOST), the fleet hub's own address, |
| +// which the edge Allows with no registry route behind it; |
| +// - with ReservePlatformDomains, the APPS DOMAIN, the whole tenant |
| +// namespace (<app>.<sqid>.<apps-domain>). Every app's public URL is |
| +// under it, so a claim there is one tenant taking another tenant's |
| +// address. This is the sibling-apex hazard that handleAPIInstanceCreate |
| +// refuses within an account, refused here across the deployment. Hosts |
| +// under the apps domain are what `carlos instances` is for. |
| +// |
| +// The console's own hostnames are refused by exact name, to everyone, in |
| +// domainHostPolicy. |
| +// |
| +// Without ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) neither |
| +// derived rule is added. That is the default because a self-hosted |
| +// deployment usually puts its console, its apps domain and its custom |
| +// domains in ONE zone that ONE person owns, and the reserved rules refused |
| +// ordinary names there. Existing routes are still safe without it: |
| // deploymentRouteTaken refuses a hostname that another app's route already |
| -// uses. |
| +// uses. With it set, these are the rules for a platform that hosts apps for |
| +// OTHER people, such as the flagship or carloku (spec §5: "all derived from |
| +// console/deployment config, not hardcoded"). |
| // |
| // exemptAppsDomain is the CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS delegation |
| -// (domainHostDenied). It drops the apps domain, and every entry that is the |
| +// (domainHostPolicy). It drops, from both tiers, every entry that is the |
| // apps domain or an ancestor of it, so that a delegated one-label alias is |
| -// not refused by a blanket entry above it. The console's own hostnames stay |
| -// refused by exact name in domainHostDenied. |
| +// not refused by a blanket entry above it. |
| // |
| // Derived per call rather than cached at boot: one url.Parse and a short |
| // copy, on a door that then does bucket I/O. This keeps it correct for an |
| // embedder (or a test) that changes the config after NewServer. |
| -func (s *Server) deniedDomains(exemptAppsDomain bool) []string { |
| +func (s *Server) platformDomains(exemptAppsDomain bool) []string { |
| + entries := slices.Clone(s.cfg.DenyDomains) |
| + if s.cfg.ReservePlatformDomains { |
| + entries = append(entries, s.consoleHosts()...) |
| + } |
| + return s.denyEntries(entries, exemptAppsDomain) |
| +} |
| + |
| +// reservedDomains: see platformDomains. |
| +func (s *Server) reservedDomains(exemptAppsDomain bool) []string { |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| - covers := func(entry string) bool { |
| - return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry)) |
| + steeringHost := strings.TrimSuffix(normalizeHost(strings.TrimSpace(s.cfg.SteeringHost)), ".") |
| + if steeringHost == "" && domain != "" { |
| + steeringHost = "edge." + domain |
| } |
| - entries := slices.Clone(s.cfg.DenyDomains) |
| + entries := []string{strings.TrimSuffix(s.cfg.TunnelHost, ".")} |
| if s.cfg.ReservePlatformDomains { |
| entries = append(entries, domain) |
| - entries = append(entries, s.consoleHosts()...) |
| + } |
| + // The steering host is never exempt: it is edge.<apps-domain>, under |
| + // the apps domain rather than above it, and a delegated alias account |
| + // must not be able to take it. |
| + deny := s.denyEntries(entries, exemptAppsDomain) |
| + if steeringHost != "" { |
| + deny = append(deny, steeringHost) |
| + } |
| + return deny |
| +} |
| + |
| +// denyEntries normalizes entries, dropping blanks (an empty entry would be |
| +// one no hostname could avoid matching) and, when exemptAppsDomain is set, |
| +// every entry that is the apps domain or an ancestor of it. |
| +func (s *Server) denyEntries(entries []string, exemptAppsDomain bool) []string { |
| + domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| + covers := func(entry string) bool { |
| + return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry)) |
| } |
| deny := make([]string, 0, len(entries)+1) |
| for _, entry := range entries { |
| @@ -147,11 +184,7 @@ func (s *Server) deniedDomains(exemptAppsDomain bool) []string { |
| } |
| deny = append(deny, entry) |
| } |
| - steeringHost := strings.TrimSuffix(normalizeHost(strings.TrimSpace(s.cfg.SteeringHost)), ".") |
| - if steeringHost == "" && domain != "" { |
| - steeringHost = "edge." + domain |
| - } |
| - return append(deny, steeringHost) |
| + return deny |
| } |
| // resolveAliasRoute decides which of the app's routes a claim points at. |
| @@ -308,6 +341,11 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // caller actually named. |
| Wildcard *bool `json:"wildcard,omitempty"` |
| Catchall *bool `json:"catchall,omitempty"` |
| + // Platform asks for the operator's door (#209): with it, a hostname |
| + // in the deployment's own zones (hostPlatform) may be attached. It |
| + // lifts nothing else. Reserved names stay refused, and the caller |
| + // must still be in this account (domainsGate). |
| + Platform bool `json:"platform,omitempty"` |
| } |
| if !readJSON(w, r, &in) { |
| return |
| @@ -316,6 +354,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + // Decided from the caller and the flag alone, before the hostname is |
| + // read, so the answer cannot vary with what the deny list holds. |
| + if in.Platform && !s.principalPlatformOperator(p) { |
| + jsonError(w, http.StatusForbidden, platformOperatorMsg) |
| + return |
| + } |
| // Path-traversal defence stated as such: this hostname becomes a bucket |
| // key segment (domains/<host>.json) and, on the box, a registry alias row |
| // and a certificate name. Nothing below runs until it is a well-formed, |
| @@ -326,7 +370,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| "invalid hostname — attach a full hostname like tickets.example.com") |
| return |
| } |
| - if s.domainHostDenied(host, acct.Sqid) || (s.directAppsDomainHost(host) && ((in.Wildcard != nil && *in.Wildcard) || (in.Catchall != nil && *in.Catchall))) { |
| + if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !in.Platform) || (s.directAppsDomainHost(host) && ((in.Wildcard != nil && *in.Wildcard) || (in.Catchall != nil && *in.Catchall))) { |
| jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| return |
| } |
| @@ -355,7 +399,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // from the core's result: on the first-route branch the host IS the |
| // route the record just declared; on the alias branch it is the |
| // route the claim names. |
| - res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal, message) |
| + res, ref := s.attachDomainAs(ctx, acct, app, host, in.Kind, in.Channel, principal, in.Platform, message) |
| if ref != nil { |
| jsonError(w, ref.Status, ref.Msg) |
| return |
| @@ -432,7 +476,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if changed { |
| s.auditActivity(acct, app, principal, Activity{ |
| Action: "domain-attach", TargetType: "instance", Target: route, Message: message, |
| - Detail: "attached hostname " + host + " -> " + route + " (cli)", |
| + Detail: "attached hostname " + host + " -> " + route + platformNote(in.Platform) + " (cli)", |
| }) |
| // The claim is what a box's domains pass mints an alias row from, so |
| // this is a box-consumed write (carlos-deploy §3): announce it, and |
| @@ -646,6 +690,13 @@ func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, "invalid hostname") |
| return |
| } |
| + // A prefix map on a platform hostname routes slices of the operator's |
| + // own site, so it is the operator's to publish (#209). From the name |
| + // alone, before the body or the claim is read: no neutrality cost. |
| + if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.principalPlatformOperator(p) { |
| + jsonError(w, http.StatusForbidden, platformOperatorMsg) |
| + return |
| + } |
| // A POINTER to the map, so that an ABSENT paths field is refused rather |
| // than read as an empty one. The distinction matters here in a way it |
| // does not on a merging door: this write is wholesale, so a body whose |
| @@ -781,6 +832,14 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, "invalid hostname") |
| return |
| } |
| + // detachDomain's rule (#209): a platform hostname's claim is the |
| + // operator's to release. From the name alone, before the claim is read, |
| + // so the door's quiet-in-both-directions answer is untouched for every |
| + // hostname a member could hold. |
| + if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.principalPlatformOperator(p) { |
| + jsonError(w, http.StatusForbidden, platformOperatorMsg) |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| // The first-route half (#82): a claim with an empty Route minted the |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 59d6df883..4ec6cf467 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -95,6 +95,15 @@ type attachResult struct { |
| // a route, static otherwise) and the channel is the one the host's own |
| // existing route follows, or failing that the app's sole shipped channel. |
| func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, messages ...string) (attachResult, *doorRefusal) { |
| + return s.attachDomainAs(ctx, acct, app, host, kindFlag, channelFlag, principal, false, messages...) |
| +} |
| + |
| +// attachDomainAs is attachDomain with the platform door (#209) named. |
| +// platform true lets a hostPlatform name through and nothing else; the |
| +// CALLER has already established that principal is a platform operator |
| +// (handleAPIDomainAttach refuses the flag otherwise). The browser form never |
| +// passes it. |
| +func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, platform bool, messages ...string) (attachResult, *doorRefusal) { |
| message := "" |
| if len(messages) != 0 { |
| message = messages[0] |
| @@ -111,7 +120,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| "not a valid hostname — attach a full hostname like tickets.example.com"} |
| } |
| - if s.domainHostDenied(host, acct.Sqid) { |
| + if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !platform) { |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| "that hostname is platform-managed"} |
| } |
| @@ -148,7 +157,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| if changed { |
| s.auditActivity(acct, app, principal, Activity{ |
| Action: "domain-attach", TargetType: "instance", Target: route, Message: message, |
| - Detail: host + " → " + route, |
| + Detail: host + " → " + route + platformNote(platform), |
| }) |
| s.bumpSerial(ctx) |
| } |
| @@ -268,6 +277,14 @@ func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, pri |
| if !ok { |
| return notAttached |
| } |
| + // A platform hostname's claim is the operator's to release (#209). |
| + // Decided from the name alone, before the claim is read, so it says |
| + // nothing about who holds it. Reserved names are left alone: nobody can |
| + // claim one now, and a member holding one from before it was reserved |
| + // must still be able to let it go. |
| + if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.platformOperator(principal) { |
| + return &doorRefusal{http.StatusForbidden, platformOperatorMsg} |
| + } |
| claim, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| if err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| @@ -497,3 +514,12 @@ func claimRefusal(host string, err error) *doorRefusal { |
| return &doorRefusal{http.StatusInternalServerError, |
| "could not claim this domain — try again in a moment"} |
| } |
| + |
| +// platformNote marks an audit row written through the platform door (#209), |
| +// so the holding account's activity log says the operator placed the claim. |
| +func platformNote(platform bool) string { |
| + if platform { |
| + return " (platform operator)" |
| + } |
| + return "" |
| +} |
diff --git a/internal/console/domainplatform_test.go b/internal/console/domainplatform_test.go| new file mode 100644 |
| index 000000000..a56f7cb73 |
| --- /dev/null |
| +++ b/internal/console/domainplatform_test.go |
| @@ -0,0 +1,295 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The platform door (#209): a deployment's operator attaching a hostname in |
| +// the deployment's own zones (CONSOLE_DENY_DOMAINS — carloku.com on the |
| +// flagship, go.tito.io on Tito's) to an app, with members still refused. |
| + |
| +// platformFixture is instanceFixture on a deployment that reserves platform |
| +// domains, lists carloku.test as its own, and has a tunnel host INSIDE that |
| +// zone — the case where the deny list's two halves must not be confused. |
| +// paul owns the ops account; alice is an ordinary member of it. |
| +func platformFixture(t *testing.T, code string) (fx *consoleFixture, alice, paul string) { |
| + t.Helper() |
| + fx, alice = instanceFixture(t, code) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| + fx.srv.cfg.DenyDomains = []string{"carloku.test"} |
| + fx.srv.cfg.TunnelHost = "hub.carloku.test" |
| + return fx, alice, cliToken(t, fx, "paul@keymail.dev", code+"-paul") |
| +} |
| + |
| +func platformAttachBody(host, route string) string { |
| + b, err := json.Marshal(map[string]any{"host": host, "route": route, "platform": true}) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +func TestDomainHostPolicyMatrix(t *testing.T) { |
| + s := &Server{cfg: Config{ |
| + AppsDomain: "apps.test", |
| + ReservePlatformDomains: true, |
| + Origin: "https://console.carloku.test", |
| + RedirectHosts: []string{"retired.carloku.test"}, |
| + DenyDomains: []string{"carloku.test", "go.tito.test"}, |
| + TunnelHost: "hub.carloku.test", |
| + }} |
| + cases := []struct { |
| + host string |
| + want hostPolicy |
| + }{ |
| + {"tickets.example.com", hostOpen}, |
| + {"carloku.test", hostPlatform}, |
| + {"www.carloku.test", hostPlatform}, |
| + {"go.tito.test", hostPlatform}, |
| + {"a.console.carloku.test", hostPlatform}, // under the console's own name |
| + {"console.carloku.test", hostReserved}, // the console itself |
| + {"retired.carloku.test", hostReserved}, // a retired console origin |
| + {"hub.carloku.test", hostReserved}, // the tunnel host, though under a platform entry |
| + {"x.hub.carloku.test", hostReserved}, |
| + {"edge.apps.test", hostReserved}, // the steering host |
| + {"apps.test", hostReserved}, |
| + {"site.bab.apps.test", hostReserved}, // a tenant's address |
| + } |
| + for _, c := range cases { |
| + if got := s.domainHostPolicy(c.host, "bab"); got != c.want { |
| + t.Errorf("domainHostPolicy(%q) = %v, want %v", c.host, got, c.want) |
| + } |
| + if got := s.domainHostDenied(c.host, "bab"); got != (c.want != hostOpen) { |
| + t.Errorf("domainHostDenied(%q) = %v, want %v", c.host, got, c.want != hostOpen) |
| + } |
| + } |
| + // Without ReservePlatformDomains the apps domain and the console's |
| + // subtree are ordinary names, as before; the operator's own list and the |
| + // tunnel host are not. |
| + s.cfg.ReservePlatformDomains = false |
| + for host, want := range map[string]hostPolicy{ |
| + "site.bab.apps.test": hostOpen, |
| + "a.console.carloku.test": hostPlatform, // still under carloku.test |
| + "carloku.test": hostPlatform, |
| + "hub.carloku.test": hostReserved, |
| + } { |
| + if got := s.domainHostPolicy(host, "bab"); got != want { |
| + t.Errorf("without reserve: domainHostPolicy(%q) = %v, want %v", host, got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestPlatformAttachRefusesMembers: an ordinary member gets the old refusal |
| +// without the flag and a 403 with it, and no claim is written either way. |
| +func TestPlatformAttachRefusesMembers(t *testing.T) { |
| + fx, alice, _ := platformFixture(t, "code-plat-member") |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| + attachBody("carloku.test", "go.tito.io")); code != http.StatusBadRequest { |
| + t.Errorf("member attach without --platform = %d %s, want 400", code, out) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| + platformAttachBody("carloku.test", "go.tito.io")) |
| + if code != http.StatusForbidden { |
| + t.Errorf("member attach with --platform = %d %s, want 403", code, out) |
| + } |
| + // The flag is refused on an ordinary hostname too: the answer depends on |
| + // the caller alone, never on what the deny list holds. |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice, |
| + platformAttachBody("tickets.example.com", "go.tito.io")); code != http.StatusForbidden { |
| + t.Errorf("member attach of an ordinary host with --platform = %d %s, want 403", code, out) |
| + } |
| + claimAbsent(t, fx, "carloku.test") |
| + claimAbsent(t, fx, "tickets.example.com") |
| +} |
| + |
| +// TestPlatformAttachRefusesServices: an app token is never an operator, flag |
| +// or no flag. |
| +func TestPlatformAttachRefusesServices(t *testing.T) { |
| + fx, _, _ := platformFixture(t, "code-plat-svc") |
| + tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), tok, |
| + platformAttachBody("carloku.test", "go.tito.io")) |
| + if code != http.StatusForbidden { |
| + t.Errorf("app token attach with --platform = %d %s, want 403", code, out) |
| + } |
| + claimAbsent(t, fx, "carloku.test") |
| +} |
| + |
| +// TestPlatformAttachByOpsOwner: the ops owner needs the flag, and with it |
| +// gets an ordinary claim naming them as its author. |
| +func TestPlatformAttachByOpsOwner(t *testing.T) { |
| + fx, _, paul := platformFixture(t, "code-plat-owner") |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| + attachBody("carloku.test", "go.tito.io")); code != http.StatusBadRequest { |
| + t.Fatalf("owner attach WITHOUT --platform = %d %s, want 400", code, out) |
| + } |
| + claimAbsent(t, fx, "carloku.test") |
| + for _, host := range []string{"carloku.test", "www.carloku.test"} { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| + platformAttachBody(host, "go.tito.io")) |
| + if code != http.StatusOK { |
| + t.Fatalf("owner attach %s with --platform = %d %s, want 200", host, code, out) |
| + } |
| + c := claimOf(t, fx, host) |
| + if c.Account != fx.sqid || c.App != "titogo" || c.Route != "go.tito.io" || c.CreatedBy != "paul@keymail.dev" { |
| + t.Errorf("claim %s = %+v", host, c) |
| + } |
| + } |
| + // An operator's flag on an ordinary hostname is harmless: it attaches |
| + // exactly as it would without. |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| + platformAttachBody("tickets.example.com", "go.tito.io")); code != http.StatusOK { |
| + t.Errorf("owner attach of an ordinary host with --platform = %d %s, want 200", code, out) |
| + } |
| +} |
| + |
| +// TestPlatformAttachBySystemOperator: an address on CARLOS_SYSTEM_OPERATORS |
| +// may use the door on an account it is a member of, and only there; the ops |
| +// owner gets no reach into an account they are not in either. |
| +func TestPlatformAttachBySystemOperator(t *testing.T) { |
| + fx, _, paul := platformFixture(t, "code-plat-sysop") |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "carloku", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(42, "website", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(42, "bill@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + bill := cliToken(t, fx, "bill@keymail.dev", "code-plat-bill") |
| + |
| + body := platformAttachBody("carloku.test", "website.cdef.oncarlos.test") |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), bill, body); code != http.StatusForbidden { |
| + t.Fatalf("bill before being listed = %d %s, want 403", code, out) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), paul, body); code != http.StatusNotFound { |
| + t.Fatalf("ops owner on an account they are not in = %d %s, want 404", code, out) |
| + } |
| + claimAbsent(t, fx, "carloku.test") |
| + |
| + fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), bill, body); code != http.StatusOK { |
| + t.Fatalf("listed system operator = %d %s, want 200", code, out) |
| + } |
| + if c := claimOf(t, fx, "carloku.test"); c.Account != "cdef" || c.App != "website" || c.CreatedBy != "bill@keymail.dev" { |
| + t.Errorf("claim = %+v", c) |
| + } |
| +} |
| + |
| +// TestPlatformAttachReservedStaysRefused: the door lifts the operator's own |
| +// list and nothing else. The console's names, the steering host, the tunnel |
| +// host and the tenant namespace stay refused to the operator too. |
| +func TestPlatformAttachReservedStaysRefused(t *testing.T) { |
| + fx, _, paul := platformFixture(t, "code-plat-reserved") |
| + fx.srv.cfg.Origin = "https://console.carloku.test" |
| + for _, host := range []string{ |
| + "console.carloku.test", |
| + "hub.carloku.test", |
| + "edge.oncarlos.test", |
| + "oncarlos.test", |
| + "titogo.bab.oncarlos.test", |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| + platformAttachBody(host, "go.tito.io")) |
| + if code != http.StatusBadRequest { |
| + t.Errorf("operator attach of reserved %s = %d %s, want 400", host, code, out) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| + // The shared core behind the static first-route branch must draw the |
| + // same line on its own, with the door already granted. |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("account: %v", err) |
| + } |
| + for _, host := range []string{"console.carloku.test", "hub.carloku.test", "edge.oncarlos.test", "titogo.bab.oncarlos.test"} { |
| + if _, ref := fx.srv.attachDomainAs(context.Background(), acct, "titogo", host, "static", "", "paul@keymail.dev", true); ref == nil || ref.Status != http.StatusUnprocessableEntity { |
| + t.Errorf("core attach of reserved %s with the door granted = %+v, want 422", host, ref) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| +} |
| + |
| +// TestPlatformClaimMembersCannotChangeIt: once the operator has attached a |
| +// platform hostname, the holding account's members cannot repoint it, flip |
| +// its capabilities, carve up its paths or release it. Only the operator can. |
| +func TestPlatformClaimMembersCannotChangeIt(t *testing.T) { |
| + fx, alice, paul := platformFixture(t, "code-plat-hold") |
| + const host = "carloku.test" |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| + platformAttachBody(host, "go.tito.io")); code != http.StatusOK { |
| + t.Fatalf("operator attach = %d %s", code, out) |
| + } |
| + before := claimOf(t, fx, host) |
| + |
| + refusals := []struct { |
| + what, method, url, body string |
| + }{ |
| + {"repoint", http.MethodPost, domainsURL(fx, "bab", "titogo"), attachBody(host, "two.go.tito.io")}, |
| + {"repoint with --platform", http.MethodPost, domainsURL(fx, "bab", "titogo"), platformAttachBody(host, "two.go.tito.io")}, |
| + {"catchall", http.MethodPost, domainsURL(fx, "bab", "titogo"), `{"host":"` + host + `","route":"go.tito.io","wildcard":true,"catchall":true}`}, |
| + {"paths", http.MethodPut, pathsURL(fx, "bab", "titogo", host), `{"paths":{"admin":"go.tito.io"}}`}, |
| + {"detach", http.MethodDelete, domainsURL(fx, "bab", "titogo") + "/" + host, ""}, |
| + } |
| + for _, r := range refusals { |
| + code, _, out := rawReq(t, r.method, r.url, alice, r.body) |
| + if code != http.StatusBadRequest && code != http.StatusForbidden { |
| + t.Errorf("member %s = %d %s, want a refusal", r.what, code, out) |
| + } |
| + } |
| + after := claimOf(t, fx, host) |
| + if after.Route != before.Route || after.Wildcard || after.Catchall || len(after.Paths) != 0 { |
| + t.Fatalf("claim changed under member writes: %+v", after) |
| + } |
| + |
| + if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), paul, |
| + `{"paths":{"admin":"go.tito.io"}}`); code != http.StatusOK { |
| + t.Errorf("operator paths = %d %s, want 200", code, out) |
| + } |
| + code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/"+host, paul, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("operator detach = %d %s, want 200", code, out) |
| + } |
| + var got domainDetachResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil || !got.Removed { |
| + t.Fatalf("operator detach body %s (err %v), want removed", out, err) |
| + } |
| + claimAbsent(t, fx, host) |
| +} |
| + |
| +// TestPlatformBrowserDoors: the settings page offers no platform door. Its |
| +// attach refuses a platform hostname even to the operator, and its detach |
| +// lets the operator, and only the operator, release one. |
| +func TestPlatformBrowserDoors(t *testing.T) { |
| + fx, _, paul := platformFixture(t, "code-plat-browser") |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("account: %v", err) |
| + } |
| + ctx := context.Background() |
| + if _, ref := fx.srv.attachDomain(ctx, acct, "titogo", "carloku.test", "", "", "paul@keymail.dev"); ref == nil || ref.Status != http.StatusUnprocessableEntity { |
| + t.Fatalf("browser attach by the operator = %+v, want 422", ref) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul, |
| + platformAttachBody("carloku.test", "go.tito.io")); code != http.StatusOK { |
| + t.Fatalf("operator attach = %d %s", code, out) |
| + } |
| + if ref := fx.srv.detachDomain(ctx, acct, "titogo", "carloku.test", "alice@keymail.dev"); ref == nil || ref.Status != http.StatusForbidden { |
| + t.Fatalf("browser detach by a member = %+v, want 403", ref) |
| + } |
| + claimOf(t, fx, "carloku.test") |
| + if ref := fx.srv.detachDomain(ctx, acct, "titogo", "carloku.test", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("browser detach by the operator = %+v, want released", ref) |
| + } |
| + claimAbsent(t, fx, "carloku.test") |
| +} |
diff --git a/internal/console/domainpolicy.go b/internal/console/domainpolicy.go| index 7147577ae..a5f7e2aa7 100644 |
| --- a/internal/console/domainpolicy.go |
| +++ b/internal/console/domainpolicy.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "log" |
| "slices" |
| "strings" |
| @@ -24,22 +25,100 @@ func (s *Server) directAppsDomainHost(host string) bool { |
| return label != "" && !strings.Contains(label, ".") |
| } |
| -// domainHostDenied reports whether an attach of host by account is refused |
| -// as platform-managed. Both /domains doors (the CLI API door and the console |
| -// form's attachDomain) ask this one question, so the two cannot drift. The |
| -// console's own hostnames are refused by exact name on every deployment |
| -// (consoleHosts); deniedDomains refuses whole subtrees. |
| -func (s *Server) domainHostDenied(host, account string) bool { |
| +// 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 true |
| + 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 true |
| + return hostReserved |
| } |
| } |
| - return domains.HostDenied(host, s.deniedDomains(allowed)) |
| + 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" |
diff --git a/internal/console/server.go b/internal/console/server.go| index 072bb3a45..4651214b7 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -544,6 +544,13 @@ type Config struct { |
| // not already derivable from this config. Empty is legal. The console's |
| // own hostnames are refused regardless (consoleHosts, apidomains.go). |
| DenyDomains []string |
| + // TunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST). |
| + // The /domains door refuses it, and everything under it, to everyone. |
| + // It is kept apart from DenyDomains because the operator's platform |
| + // door (#209) lifts DenyDomains and must never lift this: the edge |
| + // Allows the name and no registry route backs it, so a claim on it |
| + // would take the fleet's own address. |
| + TunnelHost string |
| // ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) is the |
| // opt-in for a deployment that hosts apps for other people, such as the |
| // flagship or carloku. With it set, the /domains door also refuses |