carlos / platform Public

cmd/console: read CARLOS_SIDECAR_ACCOUNTS into the sidecar pause

Comma-separated account sqids, trimmed and lower-cased. An entry that is
not a sqid stops serve before it opens anything. Unset or empty is no
gate, so deployments that never set it are unaffected. Documented in the
main.go header, the README config surface and the sidecars CLI page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0e45d8450bd21f142550bf9f3c9aec6dd6025412 parent 2631502
5 files changed, +148 −0
  • README.md +7 −0
  • cmd/console/main.go +45 −0
  • cmd/console/sidecaraccounts_serve_test.go +47 −0
  • cmd/console/sidecaraccounts_test.go +43 −0
  • docs/site/cli.md +6 −0
diff --git a/README.md b/README.md
index 7a4b80895..c6ec2a3ac 100644
--- a/README.md
+++ b/README.md
@@ -644,6 +644,13 @@ publish an inflated instance count.
`carlos system geoip publish` instead (docs/site/cli.md, "Platform GeoIP").
Uploads are staged in `geoip-staging/` next to `CONSOLE_DB`, which the
console clears at startup.
+- `CARLOS_SIDECAR_ACCOUNTS`: comma-separated account sqids (not names) that
+ may still enable and create native sidecars. Set, every other account gets
+ 403 `sidecars-paused` from `carlos sidecars enable` and `carlos sidecars
+ create`. List, get, wake and delete still work, and existing sidecars keep
+ running. Native sidecars run on the flat tier, outside the nested tenants'
+ isolation, until nested sidecars land (discussion #370). **Unset or empty =
+ no gate.** An entry that is not a sqid stops the console at boot.
**Canonical-host caveat**, stated honestly: an app that pins its own host
(e.g. keymail's `--host keymail.dev`) will redirect or 404 on its platform
diff --git a/cmd/console/main.go b/cmd/console/main.go
index c95cfa05f..1e53ead69 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -51,6 +51,14 @@
// CARLOS_TEMPORARY_APPS=1 turns on temporary apps (carlos deploy
// --temporary); off by default, see the temporary static sites rollout.
//
+// CARLOS_SIDECAR_ACCOUNTS (comma-separated account sqids) pauses native
+// sidecars for every account it does not name: enabling sidecars and
+// creating one answer 403 sidecars-paused, while list, get, wake and delete
+// keep working and existing sidecars keep running. Native sidecars run on
+// the flat tier, outside the nested tenants' isolation, until discussion
+// #370 lands. Unset or empty is no gate. An entry that is not a sqid stops
+// the console at startup.
+//
// Tenant email (task 17) reads CARLOS_MAIL_OPERATORS (comma-separated
// addresses; narrows the fleet-operator bit further for the pause/cap
// door only — see mailOperators' doc comment), CARLOS_MAIL_REGION,
@@ -107,6 +115,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/console"
"github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/geo"
"github.com/carlosframework/platform/internal/geoip"
"github.com/carlosframework/platform/internal/lease"
@@ -291,6 +300,12 @@ func serve() {
} else {
log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only")
}
+ // Before anything is opened: a typo in the allowlist must stop the
+ // console rather than quietly pause, or unpause, the wrong accounts.
+ sidecarAccts, err := sidecarAccounts(os.Getenv)
+ if err != nil {
+ log.Fatalf("console: %v", err)
+ }
hostClaim := hostClaimEnabled(os.Getenv)
st := openServeDB(hostClaim)
storeProvisioner, storeDeployment := storeBackend()
@@ -457,6 +472,9 @@ func serve() {
// owners (discussion #176). Unset is the owner-only console.
SystemOperators: systemOperators(),
PlatformDomain: platformDomain(),
+ // The sidecar pause (internal/console/sidecarsgate.go). Unset is no
+ // gate; set, only these accounts may enable or create a sidecar.
+ SidecarAccounts: sidecarAccts,
}
// The Phase 1 moves switch (CARLOS_MOVES_PHASE1, off by default, index
// D13): applied after the literal so a test can prove it reaches the
@@ -875,6 +893,33 @@ func systemOperators() []string {
return out
}
+// sidecarAccounts parses CARLOS_SIDECAR_ACCOUNTS (comma-separated account
+// sqids): the accounts that may still enable and create native sidecars
+// while every other account is paused (internal/console/sidecarsgate.go,
+// discussion #370). Each entry is trimmed and lower-cased, and must be a
+// sqid (domains.ValidSqid), never an account name: "ops" is not one, and a
+// name that matched nothing would pause the very account it meant to keep.
+// An invalid entry is an error for serve to stop on.
+//
+// Unset or empty is nil, and nil is no gate: every account may enable and
+// create sidecars, exactly as before the list existed. This is the opposite
+// direction to the operator lists above on purpose, so a deployment that
+// never sets it is unaffected.
+func sidecarAccounts(getenv func(string) string) ([]string, error) {
+ var out []string
+ for _, a := range strings.Split(getenv("CARLOS_SIDECAR_ACCOUNTS"), ",") {
+ a = strings.ToLower(strings.TrimSpace(a))
+ if a == "" {
+ continue
+ }
+ if !domains.ValidSqid(a) {
+ return nil, fmt.Errorf("CARLOS_SIDECAR_ACCOUNTS: %q is not an account sqid (the account's id, e.g. bab, never its name)", a)
+ }
+ out = append(out, a)
+ }
+ return out, nil
+}
+
// platformDomain reads CARLOS_PLATFORM_DOMAIN — the domain the relay's own
// hostnames hang off (relay.<region>.<domain>), which an enabled app is
// handed as its ICE server URLs.
diff --git a/cmd/console/sidecaraccounts_serve_test.go b/cmd/console/sidecaraccounts_serve_test.go
new file mode 100644
index 000000000..4f9c9b05b
--- /dev/null
+++ b/cmd/console/sidecaraccounts_serve_test.go
@@ -0,0 +1,47 @@
+package main_test
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestServeStopsOnABadSidecarAccount: serve reads CARLOS_SIDECAR_ACCOUNTS
+// and an entry that is not a sqid stops the console before it opens
+// anything. The timeout is the failure mode it guards: a serve that ignored
+// the variable would carry on and listen.
+func TestServeStopsOnABadSidecarAccount(t *testing.T) {
+ bin := buildConsole(t)
+ tmp := t.TempDir()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, bin, "serve")
+ cmd.Dir = tmp
+ cmd.Env = append(os.Environ(),
+ "CONSOLE_ORIGIN=http://127.0.0.1:1",
+ "CONSOLE_ADDR=127.0.0.1:0",
+ "CONSOLE_DB="+filepath.Join(tmp, "console.db"),
+ // 32 zero bytes: a well-formed Ed25519 public key, so serve gets
+ // past the key check to the variable under test.
+ "CARLOS_RELEASE_PUBKEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "CARLOS_RELEASE_KEY=",
+ "CARLOS_SIDECAR_ACCOUNTS=bab,ops",
+ )
+ out, err := cmd.CombinedOutput()
+ if ctx.Err() != nil {
+ t.Fatalf("serve did not stop on a bad CARLOS_SIDECAR_ACCOUNTS: %s", out)
+ }
+ if err == nil {
+ t.Fatalf("serve exited 0 with a bad CARLOS_SIDECAR_ACCOUNTS: %s", out)
+ }
+ if !strings.Contains(string(out), "CARLOS_SIDECAR_ACCOUNTS") || !strings.Contains(string(out), `"ops"`) {
+ t.Fatalf("serve's refusal does not name the variable and the entry: %s", out)
+ }
+ if _, err := os.Stat(filepath.Join(tmp, "console.db")); err == nil {
+ t.Fatalf("serve opened its database before refusing the variable")
+ }
+}
diff --git a/cmd/console/sidecaraccounts_test.go b/cmd/console/sidecaraccounts_test.go
new file mode 100644
index 000000000..a0dd819ae
--- /dev/null
+++ b/cmd/console/sidecaraccounts_test.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+ "slices"
+ "testing"
+)
+
+// CARLOS_SIDECAR_ACCOUNTS reaches Config.SidecarAccounts through
+// sidecarAccounts: trimmed, lower-cased, each a sqid. Unset and empty are
+// nil, which is no gate; any entry that is not a sqid is an error serve
+// stops on (TestServeStopsOnABadSidecarAccount runs that through the binary).
+func TestSidecarAccountsParsesTheList(t *testing.T) {
+ for _, tc := range []struct {
+ val string
+ want []string
+ wantErr bool
+ }{
+ {"", nil, false},
+ {" ", nil, false},
+ {",", nil, false},
+ {"bab", []string{"bab"}, false},
+ {" BAB , cdef ,", []string{"bab", "cdef"}, false},
+ {"bab,ops", nil, true}, // a name, not a sqid: o is not in the alphabet
+ {"bab,ab", nil, true}, // too short
+ {"bab,b-b", nil, true}, // not a letter
+ {"bab;cdef", nil, true}, // wrong separator reads as one bad entry
+ {"x1234567890abcdefg", nil, true},
+ } {
+ got, err := sidecarAccounts(func(k string) string {
+ if k != "CARLOS_SIDECAR_ACCOUNTS" {
+ t.Fatalf("read %s", k)
+ }
+ return tc.val
+ })
+ if (err != nil) != tc.wantErr {
+ t.Errorf("CARLOS_SIDECAR_ACCOUNTS=%q: err %v, want error %v", tc.val, err, tc.wantErr)
+ continue
+ }
+ if !slices.Equal(got, tc.want) {
+ t.Errorf("CARLOS_SIDECAR_ACCOUNTS=%q: %v, want %v", tc.val, got, tc.want)
+ }
+ }
+}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 0a8e384f2..3d0dd287a 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1016,6 +1016,12 @@ it back from, so `carlos sidecars ls` shows the build its box last started
for each sidecar, beside the version its channel points at. One that has not
started on any box reads as unknown rather than as current.
+A console operator can pause sidecars for every account not named in
+`CARLOS_SIDECAR_ACCOUNTS`, a comma-separated list of account sqids. A paused
+account gets a 403 with the code `sidecars-paused` from `enable` and
+`create`. Its existing sidecars keep running, and `ls`, `wake` and `rm` still
+work. Unset, nothing is paused.
+
### carlos steering
Decides whether one instance's DNS answer varies by where the request comes