Merge branch 'reserve-platform-domains-opt-in' into main
11 files changed,
+268
−75
AGENTS.md+17 −0README.md+7 −0cmd/console/main.go+25 −4docs/site/cli.md+8 −0internal/console/apidomains.go+84 −46internal/console/apidomains_test.go+57 −13internal/console/domainattach.go+1 −1internal/console/domainattach_test.go+24 −0internal/console/domainpolicy.go+16 −0internal/console/domainpolicy_test.go+17 −7internal/console/server.go+12 −4
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index 78dc719c1..542447ec0 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -381,3 +381,20 @@ the domain apex and nested account namespaces remain protected. Alias targets |
| still require an existing app route. First-route creation and wildcard or |
| catchall capabilities are refused. Global claims and deployment-route conflict |
| checks remain authoritative. Tito's initial approved account is bap (Pullup). |
| + |
| +The delegation only means something on a deployment that sets |
| +CONSOLE_RESERVE_PLATFORM_DOMAINS=true (below). Without that setting, the |
| +apps domain is not reserved, so every account can already attach names under |
| +it, and the delegated-alias limits do not apply. |
| + |
| +## Reserving platform domains is opt-in (James, 2026-09-17) |
| + |
| +`carlos domains attach` refuses everything under PLATFORM_APPS_DOMAIN and |
| +under the console's own hostnames ONLY when the console sets |
| +CONSOLE_RESERVE_PLATFORM_DOMAINS=true. That rule protects tenants from each |
| +other, so a deployment that hosts apps for other people (the flagship, |
| +carloku, Tito) must set it. The default is off: a self-hosted deployment |
| +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. |
diff --git a/README.md b/README.md| index e58ae5774..fb49dafa2 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -340,6 +340,13 @@ publish an inflated instance count. |
| **Unset = the whole feature is inert**: no minting, no manifests read, |
| the enable door answers 503 — the same litestream convention as |
| everything else here. |
| +- `CONSOLE_RESERVE_PLATFORM_DOMAINS` — set to `true` on a deployment that |
| + hosts apps for other people (the flagship, carloku). `carlos domains |
| + attach` then refuses every name under `PLATFORM_APPS_DOMAIN` and under the |
| + console's own hostname. **Unset = false**: a self-hosted deployment can |
| + 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. |
| - `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/cmd/console/main.go b/cmd/console/main.go| index b8af53cf5..57d6a15b5 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -282,6 +282,7 @@ func serve() { |
| SteeringHost: steeringHost(), |
| RedirectHosts: redirectHosts(), |
| DenyDomains: denyDomains(), |
| + ReservePlatformDomains: reservePlatformDomains(), |
| StoreProvisioner: storeProvisioner, |
| StoreScanner: storeScanner, |
| ScanSeedFromDeprecatedEnv: seedAntivirus, |
| @@ -809,14 +810,34 @@ func redirectHosts() []string { |
| return out |
| } |
| +// reservePlatformDomains parses CONSOLE_RESERVE_PLATFORM_DOMAINS, the opt-in |
| +// for a deployment that hosts apps for other people (the flagship, carloku). |
| +// When it is set, `carlos domains attach` also refuses everything under |
| +// PLATFORM_APPS_DOMAIN and under the console's own hostnames |
| +// (console.Config.ReservePlatformDomains). Unset means false. A value that |
| +// strconv.ParseBool cannot read stops the boot. A typo in a security setting |
| +// must not run the console as if the setting were off. |
| +func reservePlatformDomains() bool { |
| + v := strings.TrimSpace(os.Getenv("CONSOLE_RESERVE_PLATFORM_DOMAINS")) |
| + if v == "" { |
| + return false |
| + } |
| + b, err := strconv.ParseBool(v) |
| + if err != nil { |
| + log.Fatalf("console: CONSOLE_RESERVE_PLATFORM_DOMAINS %q is not a boolean (use true or false)", v) |
| + } |
| + return b |
| +} |
| + |
| // 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 |
| -// console's own origin host and PLATFORM_APPS_DOMAIN are added by the door |
| -// itself (deniedDomains, internal/console/apidomains.go), so an unset |
| -// variable is a legal — and still safe — deployment. Blank entries are dropped: an empty string in the |
| +// 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. |
| func denyDomains() []string { |
| var out []string |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 1efd9a9f8..6a731adb4 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -645,6 +645,14 @@ that route at its next domains pass, certificate and all. The `list` verb |
| joins the claims to the fleet's own readings sweep, so DNS state, certificate |
| expiry and delegation are readable without leaving the terminal. |
| +The console always refuses its own hostname. By default it accepts every |
| +other name, including names under the console's domain and under the apps |
| +domain. That fits a self-hosted deployment, where one person owns the whole |
| +zone. A deployment that hosts apps for other people sets |
| +`CONSOLE_RESERVE_PLATFORM_DOMAINS=true` on the console. Then the console also |
| +refuses every name under the apps domain and under its own hostname, because |
| +those names belong to the operator and not to a tenant. |
| + |
| ### carlos features |
| Sets or lists an app's own feature flags — switches the app defines, the |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 6334642dc..decd1f942 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -41,6 +41,7 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + "slices" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -54,43 +55,94 @@ import ( |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| -// deniedDomains is the deny list every attach is checked against: the |
| -// operator's CONSOLE_DENY_DOMAINS entries (cmd/console folds the tunnel |
| -// hostname into those), plus the names this console can derive without |
| -// being told (spec §5: "all derived from |
| -// console/deployment config, not hardcoded"). |
| +// consoleHosts are the hostnames this console itself answers on: the ORIGIN |
| +// host, plus the REDIRECT HOSTS (CONSOLE_REDIRECT_HOSTS: retired console |
| +// origins such as tito's carlos.tito.io, which this console still answers on |
| +// and 301s to Origin). Every deployment refuses these whether or not it |
| +// reserves platform domains (domainHostDenied). The reason is self-harm, not |
| +// tenant isolation: an alias on one of these names points the console's own |
| +// address at an app. Route-wins ordering on a box does not prevent that. It |
| +// is per-box, and it stops protecting the name when a second box |
| +// materializes the alias. |
| // |
| -// The derived three are not optional and not the operator's to forget: |
| +// Only the names themselves are refused on every deployment. Everything |
| +// under them is refused only with ReservePlatformDomains (see |
| +// deniedDomains). A self-hosted console at carlos.example.com whose apps |
| +// live at <app>.carlos.example.com must be able to attach |
| +// blog.carlos.example.com. |
| // |
| -// - the ORIGIN host is the console itself — an alias there would point the |
| -// door's own hostname at a tenant's route; |
| -// - the APPS DOMAIN is the whole platform apex namespace |
| -// (<app>.<sqid>.<apps-domain>): every app's public URL lives under it, |
| -// and a claim there would be one tenant claiming another tenant's |
| -// address — the sibling-apex hazard handleAPIInstanceCreate refuses |
| -// within an account, refused here across the deployment. Hosts under the |
| -// apps domain are what `carlos instances` exists for; this door is for |
| -// hostnames the caller's own DNS controls; |
| -// - the REDIRECT HOSTS are retired console origins (CONSOLE_REDIRECT_HOSTS |
| -// — tito's carlos.tito.io), which this console still answers on and |
| -// 301s to Origin. They are guaranteed distinct from the origin host |
| -// (NewServer refuses a self-redirect), so nothing else in this list |
| -// covers them, and a tenant claiming one would be claiming a console |
| -// address that operators, bookmarks and old CLI credentials still reach. |
| -// Route-wins ordering on a box is not the answer: it is per-box, and |
| -// stops being protection the moment a second box materializes the alias. |
| +// The redirect entries go through the same normalizeHost that ServeHTTP's |
| +// retired-host check uses, so the two readings of CONSOLE_REDIRECT_HOSTS |
| +// cannot disagree. NewServer refuses a redirect host equal to the origin. |
| +func (s *Server) consoleHosts() []string { |
| + hosts := make([]string, 0, len(s.cfg.RedirectHosts)+1) |
| + if u, err := url.Parse(s.cfg.Origin); err == nil { |
| + if h := strings.ToLower(u.Hostname()); h != "" { |
| + hosts = append(hosts, h) |
| + } |
| + } |
| + for _, h := range s.cfg.RedirectHosts { |
| + if h = normalizeHost(h); h != "" { |
| + hosts = append(hosts, h) |
| + } |
| + } |
| + return hosts |
| +} |
| + |
| +// deniedDomains are the entries whose whole subtree the /domains door |
| +// refuses. Every deployment gets two kinds of entry: |
| +// |
| +// - 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. |
| +// |
| +// 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 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. |
| // |
| -// Derived per call rather than cached at boot — one url.Parse and a short |
| -// copy on a door that then does bucket I/O — so it stays honest for an |
| -// embedder (or a test) that sets any of these after NewServer. The redirect |
| -// entries go through the same normalizeHost ServeHTTP's retired-host check |
| -// uses, so the two readings of CONSOLE_REDIRECT_HOSTS cannot disagree. |
| +// 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: |
| +// deploymentRouteTaken refuses a hostname that another app's route already |
| +// uses. |
| +// |
| +// exemptAppsDomain is the CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS delegation |
| +// (domainHostDenied). It drops the apps domain, and 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. |
| +// |
| +// 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 { |
| - deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2) |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| - for _, entry := range s.cfg.DenyDomains { |
| + covers := func(entry string) bool { |
| + return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry)) |
| + } |
| + entries := slices.Clone(s.cfg.DenyDomains) |
| + if s.cfg.ReservePlatformDomains { |
| + entries = append(entries, domain) |
| + entries = append(entries, s.consoleHosts()...) |
| + } |
| + deny := make([]string, 0, len(entries)+1) |
| + for _, entry := range entries { |
| entry = strings.ToLower(strings.TrimSpace(entry)) |
| - if exemptAppsDomain && (entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry))) { |
| + if entry == "" || (exemptAppsDomain && covers(entry)) { |
| continue |
| } |
| deny = append(deny, entry) |
| @@ -99,21 +151,7 @@ func (s *Server) deniedDomains(exemptAppsDomain bool) []string { |
| if steeringHost == "" && domain != "" { |
| steeringHost = "edge." + domain |
| } |
| - deny = append(deny, steeringHost) |
| - if u, err := url.Parse(s.cfg.Origin); err == nil { |
| - if h := strings.ToLower(u.Hostname()); h != "" { |
| - deny = append(deny, h) |
| - } |
| - } |
| - if d := domain; d != "" && !exemptAppsDomain { |
| - deny = append(deny, d) |
| - } |
| - for _, h := range s.cfg.RedirectHosts { |
| - if h = normalizeHost(h); h != "" { |
| - deny = append(deny, h) |
| - } |
| - } |
| - return deny |
| + return append(deny, steeringHost) |
| } |
| // resolveAliasRoute decides which of the app's routes a claim points at. |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 909d1f34b..a8088c186 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -248,29 +248,34 @@ func TestAPIDomainAttachConflictNeutral(t *testing.T) { |
| } |
| } |
| -// TestAPIDomainAttachValidation: every hostname this door refuses to take, |
| -// each with nothing written behind it — malformed, bare-label, under a |
| -// configured deny domain, under the deployment's apps domain, the console's |
| -// own origin host, and a RETIRED console origin (CONSOLE_REDIRECT_HOSTS, |
| -// which this console still answers on and 301s away — tito's carlos.tito.io). |
| +// TestAPIDomainAttachValidation: every hostname this door refuses on a |
| +// deployment that reserves platform domains (the flagship, carloku), each |
| +// with nothing written behind it: malformed, bare-label, under a configured |
| +// deny domain, under the deployment's apps domain, the console's own origin |
| +// host and a name under it, and a RETIRED console origin |
| +// (CONSOLE_REDIRECT_HOSTS, which this console still answers on and 301s |
| +// away, such as tito's carlos.tito.io). |
| func TestAPIDomainAttachValidation(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-validate") |
| fx.srv.cfg.DenyDomains = []string{"", "denied.test"} |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| fx.srv.cfg.RedirectHosts = []string{"", "Carlos.Tito.Test:8443"} |
| + fx.srv.cfg.ReservePlatformDomains = true |
| origin, err := url.Parse(fx.srv.cfg.Origin) |
| if err != nil { |
| t.Fatal(err) |
| } |
| for _, host := range []string{ |
| - "Bad_Host!", // not a hostname at all |
| - "nodots", // a bare label is never a public hostname |
| - "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| - "denied.test", // the deny entry itself |
| - "one.bab.oncarlos.test", // platform apex space (spec §5) |
| - origin.Hostname(), // the console's own origin host |
| - "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it |
| - "old.carlos.tito.test", // and anything under it |
| + "Bad_Host!", // not a hostname at all |
| + "nodots", // a bare label is never a public hostname |
| + "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| + "denied.test", // the deny entry itself |
| + "one.bab.oncarlos.test", // platform apex space (spec §5) |
| + "oncarlos.test", // the apps domain itself |
| + origin.Hostname(), // the console's own origin host |
| + "x." + origin.Hostname(), // and anything under it |
| + "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it |
| + "old.carlos.tito.test", // and anything under it |
| } { |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| attachBody(host, "go.tito.io")) |
| @@ -286,6 +291,45 @@ func TestAPIDomainAttachValidation(t *testing.T) { |
| } |
| } |
| +// TestAPIDomainAttachUnreservedDefault: ReservePlatformDomains is off by |
| +// default, which is the self-hosted shape. There the console, the apps |
| +// domain and the custom domains usually share one zone that one person |
| +// owns. The console's own hostnames and the operator's CONSOLE_DENY_DOMAINS |
| +// are still refused. The apps domain, and names under the console's |
| +// hostnames, are not. |
| +func TestAPIDomainAttachUnreservedDefault(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-unreserved") |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.DenyDomains = []string{"denied.test"} |
| + fx.srv.cfg.AppsDomain = origin.Hostname() // the console sits at the apps-domain apex |
| + fx.srv.cfg.RedirectHosts = []string{"Carlos.Tito.Test:8443"} |
| + |
| + for _, host := range []string{ |
| + "x.denied.test", // the operator's own list still applies |
| + origin.Hostname(), // the console's own origin host |
| + "carlos.tito.test", // a retired console origin |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody(host, "go.tito.io")) |
| + if code != http.StatusBadRequest { |
| + t.Errorf("attach %q = %d %s, want 400", host, code, out) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| + for _, host := range []string{ |
| + "blog." + origin.Hostname(), // under the console host AND the apps domain |
| + "one.bab." + origin.Hostname(), // platform-shaped, but no route holds it |
| + "old.carlos.tito.test", // under a retired console origin |
| + } { |
| + if got := attachDomain(t, fx, member, host, "go.tito.io"); !got.Created { |
| + t.Errorf("attach %q unreserved = %+v, want created true", host, got) |
| + } |
| + } |
| +} |
| + |
| // TestAPIDomainAttachRouteDefault: the route may be omitted when there is |
| // exactly one thing it could mean. Two instance records make it ambiguous, |
| // and ambiguity names the flag that resolves it rather than guessing. |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 2f4aff914..59d6df883 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -104,7 +104,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| } |
| // Policy first: shape and platform space are decided before any write, |
| // so a refused host never reaches a bucket key. Both checks are #151's |
| - // exported validation surface (internal/domains + deniedDomains), the |
| + // exported validation surface (internal/domains + domainHostDenied), the |
| // same pair the CLI API door runs — one validation spot, two doors. |
| host, ok := domains.NormalizeAliasHost(host) |
| if !ok { |
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go| index 6ea586e6d..f76774433 100644 |
| --- a/internal/console/domainattach_test.go |
| +++ b/internal/console/domainattach_test.go |
| @@ -16,6 +16,7 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -613,6 +614,7 @@ func TestAttachHeldHostIsNeutral409(t *testing.T) { |
| func TestAttachPlatformSpaceRefused(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| shipVersion(t, fx, "story", "v1") |
| seedChannel(t, fx, "story", "canary/live", "v1") |
| @@ -632,6 +634,28 @@ func TestAttachPlatformSpaceRefused(t *testing.T) { |
| } |
| } |
| +// TestAttachPlatformSpaceOpenByDefault: without ReservePlatformDomains, the |
| +// form door (attachDomain) accepts a name under the apps domain, even as |
| +// the app's first route. This is the self-hosted case. Only the console's |
| +// own hostname is still refused. |
| +func TestAttachPlatformSpaceOpenByDefault(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + |
| + const host = "blog.oncarlos.com" |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("attach %q under the apps domain, unreserved: refused %+v", host, ref) |
| + } |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", origin.Hostname(), "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { |
| + t.Fatalf("attach the console's own origin host: refusal = %+v, want 422", ref) |
| + } |
| +} |
| + |
| // TestAttachRefusesReservedSidecarSuffix: the attach door must refuse a |
| // custom domain under provision.SidecarSuffix — never a panic, the family's |
| // ordinary 422 (the same status HostDenied's platform-space refusal above |
diff --git a/internal/console/domainpolicy.go b/internal/console/domainpolicy.go| index 6d2d2b295..7147577ae 100644 |
| --- a/internal/console/domainpolicy.go |
| +++ b/internal/console/domainpolicy.go |
| @@ -7,7 +7,15 @@ import ( |
| "github.com/carlosframework/platform/internal/domains" |
| ) |
| +// 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 |
| @@ -16,7 +24,15 @@ 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 { |
| + if slices.Contains(s.consoleHosts(), host) { |
| + return true |
| + } |
| allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host) |
| if allowed { |
| domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
diff --git a/internal/console/domainpolicy_test.go b/internal/console/domainpolicy_test.go| index 3fa13e27e..23753ed45 100644 |
| --- a/internal/console/domainpolicy_test.go |
| +++ b/internal/console/domainpolicy_test.go |
| @@ -29,7 +29,7 @@ func TestDirectAppsDomainHost(t *testing.T) { |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
| - s := &Server{cfg: Config{AppsDomain: c.appsDomain}} |
| + s := &Server{cfg: Config{AppsDomain: c.appsDomain, ReservePlatformDomains: true}} |
| if got := s.directAppsDomainHost(c.host); got != c.want { |
| t.Errorf("directAppsDomainHost(%q) with AppsDomain=%q = %v, want %v", c.host, c.appsDomain, got, c.want) |
| } |
| @@ -42,10 +42,11 @@ func TestDomainHostDeniedPolicyMatrix(t *testing.T) { |
| const alias = "widget." + appsDomain |
| base := func() Config { |
| return Config{ |
| - AppsDomain: appsDomain, |
| - Origin: "https://origin." + appsDomain, |
| - DenyDomains: []string{"blocked." + appsDomain}, |
| - RedirectHosts: []string{"retired." + appsDomain}, |
| + AppsDomain: appsDomain, |
| + ReservePlatformDomains: true, |
| + Origin: "https://origin." + appsDomain, |
| + DenyDomains: []string{"blocked." + appsDomain}, |
| + RedirectHosts: []string{"retired." + appsDomain}, |
| } |
| } |
| cases := []struct { |
| @@ -86,6 +87,7 @@ func TestDomainHostDeniedPolicyMatrix(t *testing.T) { |
| func TestAPIDomainAttachDeniesAppsDomainAliasByDefault(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-default") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| attachBody("widget.oncarlos.test", "go.tito.io")) |
| if code != http.StatusBadRequest { |
| @@ -97,6 +99,7 @@ func TestAPIDomainAttachDeniesAppsDomainAliasByDefault(t *testing.T) { |
| func TestAPIDomainAttachAllowsAppsDomainAlias(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-allow") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| if got := attachDomain(t, fx, member, "widget.oncarlos.test", "go.tito.io"); !got.Created { |
| @@ -119,6 +122,7 @@ func TestAPIDomainAttachAllowsAppsDomainAlias(t *testing.T) { |
| func TestAPIDomainAttachAppsDomainAliasRejectsWildcardAndCatchall(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-wild") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| const host = "widget.oncarlos.test" |
| @@ -140,6 +144,7 @@ func TestAPIDomainAttachAppsDomainAliasRejectsWildcardAndCatchall(t *testing.T) |
| func TestAPIDomainAttachAppsDomainAliasStillRefusesForeignClaim(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-alias-conflict") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| const host = "widget.oncarlos.test" |
| if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{ |
| @@ -162,6 +167,7 @@ func TestAPIDomainAttachAppsDomainAliasStillRefusesDeploymentRoute(t *testing.T) |
| ctx := context.Background() |
| fx, member := instanceFixture(t, "code-dom-alias-route") |
| fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| const host = "widget.oncarlos.test" |
| if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), |
| @@ -181,6 +187,7 @@ func TestAPIDomainAttachAppsDomainAliasStillRefusesDeploymentRoute(t *testing.T) |
| func TestAttachDomainCoreAppsDomainAlias(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.Registry = attachRegistry(t, |
| registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| @@ -210,6 +217,7 @@ func TestAttachDomainCoreAppsDomainAlias(t *testing.T) { |
| func TestAttachDomainCoreAppsDomainAliasStillRefusesHeldHost(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| fx.srv.cfg.Registry = attachRegistry(t, |
| registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| @@ -227,8 +235,9 @@ func TestAttachDomainCoreAppsDomainAliasStillRefusesHeldHost(t *testing.T) { |
| func TestAppsDomainAliasAccountAuthority(t *testing.T) { |
| s := &Server{cfg: Config{ |
| AppsDomain: "platform.example.test", AppsDomainAliasAccounts: []string{"bap"}, |
| - Origin: "https://console.platform.example.test", |
| - DenyDomains: []string{"example.test", "platform.example.test", "blocked.platform.example.test"}, |
| + ReservePlatformDomains: true, |
| + Origin: "https://console.platform.example.test", |
| + DenyDomains: []string{"example.test", "platform.example.test", "blocked.platform.example.test"}, |
| }} |
| for _, tc := range []struct { |
| host, account string |
| @@ -258,6 +267,7 @@ func TestAppsDomainAliasAccountAuthority(t *testing.T) { |
| func TestAppsDomainAliasCannotMintFirstRoute(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid} |
| fx.srv.cfg.Registry = attachRegistry(t) |
| for _, kind := range []string{"", "static"} { |
diff --git a/internal/console/server.go b/internal/console/server.go| index a922475b6..f0fd18ef3 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -526,11 +526,19 @@ type Config struct { |
| // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The |
| // deployment's own names, in other words: the edge's reserved tunnel |
| // hostname, retired console origins, anything platform-managed that is |
| - // not already derivable from this config. The two the console DOES |
| - // derive itself — Origin's hostname and AppsDomain — are added by |
| - // deniedDomains() (apidomains.go), so a deployment cannot forget them. |
| - // Empty is legal and means "only the derived two". |
| + // not already derivable from this config. Empty is legal. The console's |
| + // own hostnames are refused regardless (consoleHosts, apidomains.go). |
| DenyDomains []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 |
| + // everything under AppsDomain and under the console's own hostnames, |
| + // because on a shared platform those names belong to the operator and |
| + // not to a tenant. It is off by default. A self-hosted deployment |
| + // usually keeps its console, its apps and its custom domains in one |
| + // zone that one person owns, and the reserved rules refused ordinary |
| + // names there. See deniedDomains. |
| + ReservePlatformDomains bool |
| // StoreProvisioner is the backend the object-storage doors provision |
| // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| // ordinary deployment shape, not a broken one: members may still DECLARE |