carlos / platform Public

domains: reserving platform domains is opt-in (CONSOLE_RESERVE_PLATFORM_DOMAINS)

The /domains door always refused everything under PLATFORM_APPS_DOMAIN and
under the console's own hostnames. That rule protects tenants on a
deployment that hosts apps for other people (the flagship, carloku). On a
self-hosted deployment, one person owns the whole zone, and the rule
refused ordinary names such as blog.<console-domain>. Almost every
self-hoster meets it.

The subtree rules now apply only with CONSOLE_RESERVE_PLATFORM_DOMAINS=true.
Every deployment still refuses:
- the console's origin host and its retired redirect hosts, by exact name
  (an alias there points the console's own address at an app);
- CONSOLE_DENY_DOMAINS and the tunnel host, with their subtrees;
- a hostname another app's route already holds (deploymentRouteTaken).

A value that is not a boolean stops the console at boot.

ROLLOUT: a shared-platform console must set
CONSOLE_RESERVE_PLATFORM_DOMAINS=true BEFORE it runs this build. Without
it, tenants can claim unused names under the apps domain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev d700f51d80d6fa641c63b453ca62ca87f2059f3c parent c1d40ee
8 files changed, +208 −61
  • README.md +7 −0
  • cmd/console/main.go +25 −4
  • docs/site/cli.md +8 −0
  • internal/console/apidomains.go +74 −38
  • internal/console/apidomains_test.go +57 −13
  • internal/console/domainattach.go +2 −2
  • internal/console/domainattach_test.go +23 −0
  • internal/console/server.go +12 −4

CI failed — run details

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 0205bf4eb..f1616bfcf 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -281,6 +281,7 @@ func serve() {
SteeringHost: steeringHost(),
RedirectHosts: redirectHosts(),
DenyDomains: denyDomains(),
+ ReservePlatformDomains: reservePlatformDomains(),
StoreProvisioner: storeProvisioner,
StoreScanner: storeScanner,
ScanSeedFromDeprecatedEnv: seedAntivirus,
@@ -801,14 +802,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 0963b364d..a76238c8e 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -551,6 +551,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 36a809882..2d9a48fab 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,54 +55,89 @@ 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").
+// hostDenied reports whether an attach of host is refused as
+// platform-managed. Two lists feed it, and they match differently:
//
-// The derived three are not optional and not the operator's to forget:
+// - deniedDomains: each entry AND every host under it;
+// - consoleHosts: the entry itself only.
//
-// - 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.
+// Both /domains doors (the CLI API door and the console form's
+// attachDomain) ask this one question, so the two cannot drift.
+func (s *Server) hostDenied(host string) bool {
+ return domains.HostDenied(host, s.deniedDomains()) || slices.Contains(s.consoleHosts(), host)
+}
+
+// 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. 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.
//
-// 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.
-func (s *Server) deniedDomains() []string {
- deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2)
- deny = append(deny, s.cfg.DenyDomains...)
+// Only the names themselves are refused here. 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 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 != "" {
- deny = append(deny, h)
+ hosts = append(hosts, h)
}
}
- if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" {
- deny = append(deny, d)
- }
for _, h := range s.cfg.RedirectHosts {
if h = normalizeHost(h); h != "" {
- deny = append(deny, h)
+ hosts = append(hosts, h)
}
}
- return deny
+ return hosts
+}
+
+// deniedDomains are the entries whose whole subtree the /domains door
+// refuses. The list always holds 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, so the list applies on every deployment.
+//
+// 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.
+//
+// Without the setting, none of these are 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.
+//
+// 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() []string {
+ deny := slices.Clone(s.cfg.DenyDomains)
+ if !s.cfg.ReservePlatformDomains {
+ return deny
+ }
+ if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" {
+ deny = append(deny, d)
+ }
+ return append(deny, s.consoleHosts()...)
}
// resolveAliasRoute decides which of the app's routes a claim points at.
@@ -276,7 +312,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
"invalid hostname — attach a full hostname like tickets.example.com")
return
}
- if domains.HostDenied(host, s.deniedDomains()) {
+ if s.hostDenied(host) {
jsonError(w, http.StatusBadRequest, "that hostname is platform-managed")
return
}
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 d71f53f2f..5532080fd 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -104,14 +104,14 @@ 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 + hostDenied), the
// same pair the CLI API door runs — one validation spot, two doors.
host, ok := domains.NormalizeAliasHost(host)
if !ok {
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"not a valid hostname — attach a full hostname like tickets.example.com"}
}
- if domains.HostDenied(host, s.deniedDomains()) {
+ if s.hostDenied(host) {
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"that hostname is platform-managed"}
}
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go
index 246924e41..85ad9b044 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,27 @@ func TestAttachPlatformSpaceRefused(t *testing.T) {
}
}
+// TestAttachPlatformSpaceOpenByDefault: without ReservePlatformDomains, the
+// form door (attachDomain) accepts a name under the apps domain. 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)
+ }
+}
+
func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) {
fx, acct := attachFixture(t)
shipVersion(t, fx, "story", "v1")
diff --git a/internal/console/server.go b/internal/console/server.go
index c3afb458c..decaafe86 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -525,11 +525,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