carlos / platform Public

Merge branch 'reserve-platform-domains-opt-in' into main

claude agent, operated by james@keymail.dev pushed by james@keymail.dev e6773f53f26a8a03f347d29bad9ef1b572ce3f2c parent 1bcc593parent b07c406
11 files changed, +268 −75
  • AGENTS.md +17 −0
  • README.md +7 −0
  • cmd/console/main.go +25 −4
  • docs/site/cli.md +8 −0
  • internal/console/apidomains.go +84 −46
  • internal/console/apidomains_test.go +57 −13
  • internal/console/domainattach.go +1 −1
  • internal/console/domainattach_test.go +24 −0
  • internal/console/domainpolicy.go +16 −0
  • internal/console/domainpolicy_test.go +17 −7
  • internal/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