Merge origin/main (instance-socket consumers, carloku host redirects)
Both branches appended tests to server_test.go; kept both blocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+912
−54
cmd/console/main.go+34 −13docs/superpowers/specs/2026-08-08-instance-socket-consumers-design.md+161 −0internal/activator/activator_test.go+197 −0internal/activator/backend_exec.go+28 −0internal/console/server.go+84 −1internal/console/server_test.go+165 −0internal/host/bootstrap.go+144 −33internal/host/host_test.go+99 −7
diff --git a/cmd/console/main.go b/cmd/console/main.go| index c91aabf00..467268d8d 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -33,10 +33,13 @@ |
| // and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the |
| // console; unset = read-only), CARLOS_SECRETS_PUBKEY (SERVED to CLI |
| // clients so `carlos secrets set` can seal with a login alone; never used |
| -// here) and PLATFORM_APPS_DOMAIN (the same variable the edge and agent |
| -// read; enables the CLI's member-facing `carlos instances enable`, whose |
| -// manifest domain is pinned to <sqid>.<this domain>). grant/revoke/owners |
| -// need only CONSOLE_DB. |
| +// here), PLATFORM_APPS_DOMAIN (the same variable the edge and agent read; |
| +// enables the CLI's member-facing `carlos instances enable`, whose manifest |
| +// domain is pinned to <sqid>.<this domain>), and CONSOLE_REDIRECT_HOSTS |
| +// (comma-separated retired hostnames this console 301/308s to CONSOLE_ORIGIN |
| +// — opt-in per host, so a host the API must keep answering on, like |
| +// platform.carlosframework.com for CLI tokens, is simply left off). |
| +// grant/revoke/owners need only CONSOLE_DB. |
| package main |
| import ( |
| @@ -223,15 +226,16 @@ func serve() { |
| } |
| st := openConsoleDB() |
| srv, err := console.NewServer(console.Config{ |
| - Origin: origin, |
| - Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| - Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| - Releases: openStore(), |
| - ReleasePub: pub, |
| - Key: key, |
| - EdgeIPs: edgeIPs(), |
| - SecretsPub: secretsPub(), |
| - AppsDomain: appsDomain(), |
| + Origin: origin, |
| + Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| + Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| + Releases: openStore(), |
| + ReleasePub: pub, |
| + Key: key, |
| + EdgeIPs: edgeIPs(), |
| + SecretsPub: secretsPub(), |
| + AppsDomain: appsDomain(), |
| + RedirectHosts: redirectHosts(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -342,6 +346,23 @@ func edgeIPs() []string { |
| return out |
| } |
| +// redirectHosts parses CONSOLE_REDIRECT_HOSTS (comma-separated) — retired |
| +// hostnames this console answers with a permanent redirect to CONSOLE_ORIGIN |
| +// (server.go's ServeHTTP), rather than serving the app in place. Opt-in per |
| +// host on purpose: a host that must keep serving the API — CLI bearer |
| +// tokens are host-agnostic, and a POST redirected through an unexpected hop |
| +// is how clients break — is simply left off this list, unlike EdgeIPs above |
| +// there's no single deployment-wide default to fall back to. |
| +func redirectHosts() []string { |
| + var out []string |
| + for _, h := range strings.Split(os.Getenv("CONSOLE_REDIRECT_HOSTS"), ",") { |
| + if h = strings.TrimSpace(h); h != "" { |
| + out = append(out, h) |
| + } |
| + } |
| + return out |
| +} |
| + |
| func envOr(k, def string) string { |
| if v := os.Getenv(k); v != "" { |
| return v |
diff --git a/docs/superpowers/specs/2026-08-08-instance-socket-consumers-design.md b/docs/superpowers/specs/2026-08-08-instance-socket-consumers-design.md| new file mode 100644 |
| index 000000000..08616e3a9 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-08-instance-socket-consumers-design.md |
| @@ -0,0 +1,161 @@ |
| +# Instance-socket consumers (issue #103) — design |
| + |
| +Date: 2026-08-08. From issue #103: on the tito box, titogo's router (running |
| +as user `titogo`) proxies root-domain surfaces onward to platform-provisioned |
| +instances' unix sockets (`/run/carlos/<host>.sock`) and got |
| +`connect: permission denied` the moment a reconciler-provisioned instance |
| +woke. Two layers blocked the cross-user dial: `/run/carlos` is |
| +`0750 carlos:carlos` with no story for enrolling a consumer, and the socket's |
| +mode is whatever the app's umask/chmod habits produce (titogo bound 0600). |
| +The interim (`usermod -aG carlos titogo` + hand-chmod per wake) works but |
| +doesn't survive a rebind — and the failure is a buyer-facing 502 on an |
| +otherwise healthy instance. Autonomous-session design: the issue offered two |
| +directions and left the choice to the platform; both halves below were chosen |
| +inline, with the trade-offs recorded. |
| + |
| +## Decision 1: a dedicated `carlos-sockets` group, never group `carlos` |
| + |
| +The tito interim enrolled the consumer into group `carlos`. That works for |
| +traversal, but group `carlos` is also the group on `/data` (`0750 |
| +carlos:carlos`) — membership grants group-read of every instance database and |
| +every `<host>.env`, including each instance's `CARLOS_ADMIN_TOKEN`. A grant |
| +whose meaning is "may dial instance sockets" must not silently carry "may |
| +read every instance's secrets". |
| + |
| +So the blessed consumers group is a dedicated system group, |
| +**`carlos-sockets`**: |
| + |
| +- `carlos bootstrap` creates it (`groupadd --system`, idempotent, live root |
| + only — a staging root prints the command, same as `useradd`). |
| +- `/run/carlos` becomes **`2750 carlos carlos-sockets`** — group changed to |
| + the consumers group (members get `r-x`: traversal to the sockets), setgid |
| + bit added so **every socket bound inside inherits the group**. That |
| + inheritance is what makes the mode contract (decision 2) sufficient: the |
| + agent never needs to chgrp anything, and no app needs to know the group |
| + exists. Both the tmpfiles.d entry and bootstrap's own dir handling change |
| + together (they are the same truth in two places). |
| +- `/run/carlos/apps` stays **`0750 carlos carlos`**. Unit tenants get their |
| + listener fd passed by systemd (`SocketUser=carlos`, 0600) and never need |
| + path access — and consumer enrollment deliberately does not reach them. |
| + A `carlos-sockets` member can traverse `/run/carlos` but not `apps/`. |
| + |
| +**Enrollment** is bootstrap's job, per the issue's ask: a repeatable |
| +`-socket-consumer <user>` flag runs `usermod -aG carlos-sockets <user>` on a |
| +live root (printed under a staging root). Idempotent — `usermod -aG` on an |
| +existing member is a no-op, so re-running bootstrap with the same flags |
| +converges. Consumer users are validated (`user.Lookup`) before any mutation: |
| +a typo leaves the box untouched rather than aborting bootstrap halfway. |
| + |
| +**Boot-ordering safety** (review finding, empirically verified): |
| +systemd-tmpfiles *drops* a line whose group doesn't resolve, and |
| +`carlos-edge.service` has `ReadWritePaths=/run/carlos` with no `-` prefix — |
| +so a tmpfiles line naming a group that doesn't exist yet turns the next |
| +reboot into a permanent edge-start failure. Two mechanisms close this: |
| +on a live root the privileged plan (including `groupadd`) runs *before* the |
| +tmpfiles conf is written, and bootstrap also ships |
| +`/etc/sysusers.d/carlos.conf` (`g carlos-sockets -`) — |
| +`systemd-sysusers.service` is ordered `Before=systemd-tmpfiles-setup.service`, |
| +so the group exists at boot even when the conf arrived via a staged-tree |
| +unit refresh that never ran a live bootstrap. |
| + |
| +**Re-run convergence** (review finding): every chown in the privileged plan |
| +converges *toward* the target state — `/run/carlos` is recursively |
| +`carlos:carlos-sockets` (so a re-run, the documented enrollment path, also |
| +converges live instance sockets' group instead of resetting it and |
| +stranding already-enrolled consumers), with `apps/` put back to |
| +`carlos:carlos` right after. |
| + |
| +**Scope of the grant, stated plainly:** membership in `carlos-sockets` is |
| +box-wide — a member may dial *every* exec-backed instance socket on the box. |
| +That is the intended semantic (the consumer is the deployment's own trusted |
| +host app, e.g. titogo's router), not a per-instance ACL; a deployment that |
| +needs per-instance isolation between consumers needs a different mechanism |
| +and should say so in an issue. |
| + |
| +## Decision 2: the agent normalizes the socket mode — the app contract is a |
| +guarantee, not a chore |
| + |
| +The issue's two options were "write 0660 into the app contract" or "the |
| +agent normalizes the mode after health". Docs-only loses to enforcement: |
| +the failure case that motivated this issue *was* an app's own chmod habit, |
| +and the exec'd child inherits the agent's umask either way — a contract no |
| +mechanism enforces is rediscovered per deployment, which is exactly the |
| +state #103 asks us out of. |
| + |
| +So: **`ExecBackend.Start` chmods the instance socket to `0660` immediately |
| +after the health wait succeeds.** Properties that make this the right spot: |
| + |
| +- It runs only on a socket the activator's own child bound — enforced, not |
| + assumed: the health wait tolerates a squatter (it only dials), so `Start` |
| + skips normalization when the child has already exited (the lost-bind-race |
| + shape — that socket is the #17 external-socket advisory's territory), and |
| + an `Lstat` confirms the path is a socket before the chmod, so a planted |
| + symlink can never redirect the mode change elsewhere. |
| +- The agent and the child run as the same user, so the chmod is on a socket |
| + we own; failure is essentially impossible, and is logged loudly rather |
| + than failing the wake (a box with no consumers must not lose a healthy |
| + wake to a normalization it doesn't need). |
| +- After health means after the app has bound (and possibly chmodded) the |
| + socket — the normalization wins against any app habit. The window between |
| + bind and normalization is the health-probe interval (milliseconds); a |
| + consumer's dial in that window fails once and its retry succeeds. |
| + |
| +With decision 1's setgid dir, the normalized socket is |
| +`0660 carlos:carlos-sockets`: group members dial, everyone else is refused. |
| +On a box where bootstrap has never been re-run (no setgid, group absent), |
| +the socket's group is `carlos` and the chmod grants nothing new — the |
| +normalization is inert until the box converges, never a widening. |
| + |
| +The seed path binds no sockets; sidecars bind no sockets; the agent's own |
| +`.control` purge socket is bound with default umask (no group write) and |
| +stays effectively agent-only — none of them normalize. |
| + |
| +**The contract, stated for app authors** (alongside the seed contract): bind |
| +your instance socket at the path the platform passes via `--socket`, with |
| +whatever mode your runtime produces — the platform normalizes it to `0660` |
| +after your first health response. Do not tighten the mode after startup; a |
| +post-health chmod strands the deployment's sibling consumers. |
| + |
| +## What changes where |
| + |
| +- `internal/host/bootstrap.go` — `socketsGroup` const; `tmpfilesConf` line |
| + for `/run/carlos` → `2750 carlos carlos-sockets`; live-run: create group, |
| + `chgrp` + setgid `/run/carlos` (after the recursive `chown` to the service |
| + user, which would otherwise reset the group); `-socket-consumer` |
| + repeatable flag → `usermod -aG`; staging prints all of it. |
| +- `internal/activator/backend_exec.go` — the post-health `os.Chmod(rt.Socket, |
| + 0o660)` in `Start`, log-on-error. |
| +- Tests: bootstrap staging asserts the new tmpfiles line and printed plan; |
| + an ExecBackend test spawns a real child (test-binary re-exec) that binds |
| + its socket, chmods it 0600 (the titogo habit), and serves — asserting the |
| + socket is 0660 after `Start` returns. |
| + |
| +No registry, wire, or storage shape changes. No console surface. |
| + |
| +## Deployment notes |
| + |
| +Per box, after adopting a binary with this change. **Step 1 starts a |
| +consumer outage window** on a box using the interim grant: the moment |
| +`/run/carlos`'s group flips to `carlos-sockets`, a running consumer whose |
| +access came from group `carlos` (supplementary groups are fixed at process |
| +start) loses traversal — so run steps 1–3 as one uninterrupted sequence and |
| +the window is seconds. |
| + |
| +1. Re-run `carlos bootstrap -socket-consumer <user> ...` (live root). On |
| + **tito**: enroll the titogo router/relay users (the ones titogo#535 put |
| + in group `carlos`). On **flagship**: no consumers today; a plain re-run |
| + just converges the dir. Bootstrap's recursive chown also converges the |
| + group on every live instance socket. |
| +2. Converge live sockets' *mode* (group is already done): |
| + `chmod g+rw /run/carlos/*.sock` — or skip and let each instance's next |
| + wake normalize it. |
| +3. `systemctl restart` the consumer processes (picks up the new group |
| + membership; ends the outage window). |
| +4. Only then revoke the interim grant: `gpasswd -d <user> carlos` per |
| + enrolled user, plus a restart of each — the group-`carlos` membership |
| + carries /data group-read (every instance DB and admin token) and the new |
| + group replaces it. |
| + |
| +Follow-ups owed elsewhere: website docs batch (standing rule) — the |
| +app-author contract line belongs on the public app-contract page when that |
| +batch runs. |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index d14d19014..e861d928a 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -3051,3 +3051,200 @@ func TestSeedCreatedTheDB(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// --- Instance-socket mode normalization (issue #103) ------------------------- |
| + |
| +// TestMain lets this test binary double as a fake instance app: Start execs |
| +// the live/<host> symlink, and stageServingLive points that symlink at the |
| +// test binary itself with ACTIVATOR_FAKE_APP=1 in the inherited environment |
| +// (childEnv strips only CARLOS_* vars, so it flows through to the child). |
| +// The shell-script recorders elsewhere in this file can't answer the health |
| +// probe — serving HTTP on a unix socket needs a real program, and the test |
| +// binary is the one program every `go test` run is guaranteed to have. |
| +func TestMain(m *testing.M) { |
| + if os.Getenv("ACTIVATOR_FAKE_APP") == "1" { |
| + fakeAppMain() |
| + return |
| + } |
| + os.Exit(m.Run()) |
| +} |
| + |
| +// fakeAppMain is the app half: bind the --socket, tighten it to 0600 — the |
| +// exact titogo habit that stranded the sibling router in issue #103 — and |
| +// serve 200s until killed (500s under ACTIVATOR_FAKE_APP_UNHEALTHY, the |
| +// never-becomes-healthy app). |
| +func fakeAppMain() { |
| + var socket string |
| + for i, a := range os.Args { |
| + if a == "--socket" && i+1 < len(os.Args) { |
| + socket = os.Args[i+1] |
| + } |
| + } |
| + if socket == "" { |
| + os.Exit(2) |
| + } |
| + ln, err := net.Listen("unix", socket) |
| + if err != nil { |
| + os.Exit(1) |
| + } |
| + if err := os.Chmod(socket, 0o600); err != nil { |
| + os.Exit(1) |
| + } |
| + status := http.StatusOK |
| + if os.Getenv("ACTIVATOR_FAKE_APP_UNHEALTHY") == "1" { |
| + status = http.StatusInternalServerError |
| + } |
| + _ = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| + w.WriteHeader(status) |
| + })) |
| +} |
| + |
| +// stageServingLive points live/<host> at the test binary itself (see |
| +// TestMain) so Start spawns a child that actually binds and serves. |
| +func stageServingLive(t *testing.T, b *ExecBackend, host string) { |
| + t.Helper() |
| + self, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("ACTIVATOR_FAKE_APP", "1") |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(b.liveLink(host)) |
| + if err := os.Symlink(self, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestExecBackendStartNormalizesSocketMode pins the issue #103 contract: the |
| +// mode an instance socket carries after a successful wake is the PLATFORM's |
| +// (0660 — group members, i.e. enrolled carlos-sockets consumers, may dial), |
| +// never the app's own umask or chmod habit. The fake app deliberately |
| +// tightens its socket to 0600 after binding; Start must win. |
| +func TestExecBackendStartNormalizesSocketMode(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + host := "mode.example.com" |
| + stageServingLive(t, b, host) |
| + |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t)} |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + proc, _, err := b.Start(ctx, rt) |
| + if err != nil { |
| + t.Fatalf("Start: %v", err) |
| + } |
| + defer proc.Stop(context.Background()) |
| + |
| + fi, err := os.Stat(rt.Socket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode().Perm() != 0o660 { |
| + t.Fatalf("socket mode after healthy Start = %o, want 660 — an app's own chmod must not strand a sibling consumer", fi.Mode().Perm()) |
| + } |
| +} |
| + |
| +// TestExecBackendFailedWakeDoesNotNormalizeSocketMode pins the other half of |
| +// the contract: normalization happens only AFTER a successful health wait — |
| +// a wake that fails must leave the socket exactly as the app left it. The |
| +// fake app binds and tightens to 0600 but never answers healthily. |
| +func TestExecBackendFailedWakeDoesNotNormalizeSocketMode(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + host := "unhealthy.example.com" |
| + stageServingLive(t, b, host) |
| + t.Setenv("ACTIVATOR_FAKE_APP_UNHEALTHY", "1") |
| + |
| + old := spawnHealthTimeout |
| + spawnHealthTimeout = 500 * time.Millisecond |
| + defer func() { spawnHealthTimeout = old }() |
| + |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t)} |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + if proc, _, err := b.Start(ctx, rt); err == nil { |
| + defer proc.Stop(context.Background()) |
| + t.Fatal("Start succeeded for a never-healthy app") |
| + } |
| + fi, err := os.Stat(rt.Socket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("socket mode after failed wake = %o, want the app's own 600 — a failed wake must not widen anything", fi.Mode().Perm()) |
| + } |
| +} |
| + |
| +// TestExecBackendDoesNotNormalizeSquatterSocket pins the only-our-child |
| +// guard: a stray supervisor squatting the route's socket (the #17 advisory's |
| +// subject) can answer the health probe while the spawned child — which lost |
| +// the bind race and exited — cannot. Start must not widen the squatter's |
| +// socket. The squatter here holds its 200 until the child has verifiably |
| +// exited, making the race the guard closes deterministic. |
| +func TestExecBackendDoesNotNormalizeSquatterSocket(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + host := "squatted.example.com" |
| + marker := filepath.Join(t.TempDir(), "child-ran") |
| + // The child: proof-of-life marker, then immediate exit — the shape of a |
| + // child that could not bind. |
| + script := "#!/bin/sh\ntouch '" + marker + "'\nexit 0\n" |
| + vdir := filepath.Join(b.OptDir, "versions", "v1") |
| + if err := os.MkdirAll(vdir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + bin := filepath.Join(vdir, "app") |
| + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sock := tmpSock(t) |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer ln.Close() |
| + if err := os.Chmod(sock, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| + // Answer only once the child is provably dead: marker written, plus |
| + // a grace for the exit itself to be reaped. |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if _, err := os.Stat(marker); err == nil { |
| + break |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + time.Sleep(300 * time.Millisecond) |
| + w.WriteHeader(http.StatusOK) |
| + })} |
| + go srv.Serve(ln) |
| + defer srv.Close() |
| + |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: sock} |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + proc, _, err := b.Start(ctx, rt) |
| + if err != nil { |
| + t.Fatalf("Start: %v (the squatter answers healthily; the wake itself succeeds)", err) |
| + } |
| + defer proc.Stop(context.Background()) |
| + |
| + fi, err := os.Stat(rt.Socket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("squatter socket mode after Start = %o, want its own 600 — a socket our child did not bind is never ours to widen", fi.Mode().Perm()) |
| + } |
| +} |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 87d27c613..c8514652e 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -488,6 +488,34 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| _ = p.Stop(stopCtx) |
| return nil, "", err |
| } |
| + // The instance-socket mode is the PLATFORM's, not the app's (issue #103): |
| + // normalize to 0660 so a deployment's enrolled cross-user consumers (the |
| + // carlos-sockets group — group-inherited from the setgid /run/carlos, |
| + // see host.tmpfilesConf) can dial, no matter what the child's umask or |
| + // its own post-bind chmod produced — titogo tightened its socket to 0600 |
| + // and stranded the sibling router that proxies to it. After the health |
| + // wait, so the app's own bind/chmod has already happened and this wins. |
| + // |
| + // The two guards make "only a socket THIS backend's child bound" true |
| + // rather than merely intended. waitHealthy tolerates a squatter (its own |
| + // contract — it only dials), so a child that lost the bind race with a |
| + // stray supervisor (EADDRINUSE, exits) can still see the health wait |
| + // succeed off the SQUATTER's answers; a dead child means the socket is |
| + // not ours to widen (that route is the #17 external-socket advisory's |
| + // territory). The Lstat keeps the chmod from ever following a |
| + // non-socket — a symlink planted at the route's path must not become a |
| + // mode change somewhere else. On a box with no consumers group the |
| + // socket's group is just `carlos`, so the wider mode grants nobody |
| + // anything. All three branches log and never fail the wake: a box with |
| + // no consumers must not lose a healthy wake to a normalization it |
| + // doesn't need. |
| + if p.Exited() { |
| + log.Printf("activator: %s: child exited during the health wait (external process serving its socket?) — not normalizing the socket mode", host) |
| + } else if fi, statErr := os.Lstat(rt.Socket); statErr != nil || fi.Mode()&os.ModeSocket == 0 { |
| + log.Printf("activator: %s: %s is not a socket we can normalize (stat: %v)", host, rt.Socket, statErr) |
| + } else if err := os.Chmod(rt.Socket, 0o660); err != nil { |
| + log.Printf("activator: normalize socket mode for %s: %v (enrolled consumers would get permission denied)", host, err) |
| + } |
| return p, spawnVer, nil |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 31ee93dcd..27fec5ef8 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -14,6 +14,7 @@ import ( |
| "fmt" |
| "html/template" |
| "log" |
| + "net" |
| "net/http" |
| "net/url" |
| "os" |
| @@ -178,6 +179,13 @@ type Config struct { |
| // does not exist, and the handler refuses with a pointer at the |
| // operator's store-mode `carlos instances enable`. |
| AppsDomain string |
| + // RedirectHosts are request hosts this console answers with a permanent |
| + // redirect to Origin (CONSOLE_REDIRECT_HOSTS, comma-separated) — retired |
| + // hostnames after a canonical-origin move. Opt-in per host: hostnames |
| + // that must keep serving the API in place (CLI bearer tokens are |
| + // host-agnostic, and POSTs through an unexpected redirect are how |
| + // clients break) simply aren't listed. |
| + RedirectHosts []string |
| } |
| // Server is the console's http.Handler. |
| @@ -189,6 +197,9 @@ type Server struct { |
| instances *instanceFetcher |
| secure bool // Secure cookies iff the origin is https |
| cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go) |
| + // redirects is cfg.RedirectHosts, normalized (lowercase, no port) into a |
| + // lookup set — ServeHTTP checks it before the mux sees the request. |
| + redirects map[string]bool |
| // cliNotified, when non-nil, is signalled after each CLI-token issuance |
| // notification attempt. Tests only — the send is off the request |
| // goroutine, and a test that asserted on it without this would be racing. |
| @@ -224,6 +235,33 @@ type Server struct { |
| // console's Keymail signing key. |
| func NewServer(cfg Config, st *Store) (*Server, error) { |
| cfg.Origin = strings.TrimSuffix(cfg.Origin, "/") |
| + // originHost is what a RedirectHosts entry must never equal: redirecting |
| + // the console's own origin to itself is a loop, not a retirement. |
| + originHost := "" |
| + if u, err := url.Parse(cfg.Origin); err == nil { |
| + originHost = normalizeHost(u.Host) |
| + } |
| + // An origin with no host in it is fatal ONCE there are hosts to retire, |
| + // never before: it is both the loop guard's only reference — an origin |
| + // that parsed to nothing silently permits the very entry the guard exists |
| + // to refuse — and the prefix every Location is built on, where "" would |
| + // hand back the request's own path (a redirect to the retired host) or a |
| + // scheme-relative "//host/…" whose destination the request chooses. |
| + // Deployments with nothing to retire keep whatever origin they had. |
| + if len(cfg.RedirectHosts) > 0 && originHost == "" { |
| + return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS is set, but CONSOLE_ORIGIN %q has no host to redirect to — it needs a scheme, e.g. https://console.example.com", cfg.Origin) |
| + } |
| + redirects := make(map[string]bool, len(cfg.RedirectHosts)) |
| + for _, h := range cfg.RedirectHosts { |
| + h = normalizeHost(h) |
| + if h == "" { |
| + continue |
| + } |
| + if h == originHost { |
| + return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS lists %q, which is this console's own origin host — that config would redirect-loop", h) |
| + } |
| + redirects[h] = true |
| + } |
| km := NewKeymail(cfg.Keymail, cfg.Origin) |
| key, err := st.SigningKey() |
| if err != nil { |
| @@ -238,6 +276,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme), |
| secure: strings.HasPrefix(cfg.Origin, "https://"), |
| cli: newCLILimits(), |
| + redirects: redirects, |
| // Two concurrent ships at a time: roomy for one operator's CI plus a |
| // human running `carlos ship` by hand, tight enough that a fleet of |
| // slow-loris uploads against a stolen or shared token can't pile up |
| @@ -333,7 +372,51 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| return s, nil |
| } |
| -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } |
| +// ServeHTTP checks the host-based redirect BEFORE the mux on purpose: it's |
| +// purely host-based, touches no session/account state, so it sits in front |
| +// of the console's security gates without weakening them (the gates |
| +// ordering rule concerns account-existence leaks; a host-level redirect |
| +// reveals nothing). |
| +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if s.redirects[normalizeHost(r.Host)] { |
| + code := http.StatusMovedPermanently |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + code = http.StatusPermanentRedirect // 308 keeps the method |
| + } |
| + http.Redirect(w, r, s.cfg.Origin+redirectTarget(r.URL), code) |
| + return |
| + } |
| + s.mux.ServeHTTP(w, r) |
| +} |
| + |
| +// redirectTarget is the path and query to hang off Origin: the request's own |
| +// RequestURI, but only where that is the rooted path a browser sends. A |
| +// request target may legally be rootless — `GET mailto:x@evil.com HTTP/1.1` |
| +// parses to an opaque URL whose RequestURI is "x@evil.com" — and pasting that |
| +// onto the origin spells https://console.example.com@evil.com, an open |
| +// redirect wearing the console's own hostname in the userinfo. The console |
| +// serves nothing at a rootless URL, so anything unrooted goes to the front |
| +// door instead. |
| +func redirectTarget(u *url.URL) string { |
| + if uri := u.RequestURI(); strings.HasPrefix(uri, "/") { |
| + return uri |
| + } |
| + return "/" |
| +} |
| + |
| +// normalizeHost reduces a request Host header (or a configured origin/ |
| +// redirect host) to the bare, lowercase hostname the RedirectHosts |
| +// comparison keys on. net.SplitHostPort errors when there's no port to |
| +// strip — a bare hostname, or an unbracketed IPv6 literal — and then the |
| +// only thing left to remove is the brackets it would have taken off with the |
| +// port, so that "[::1]" and "[::1]:8080" key the same. |
| +func normalizeHost(host string) string { |
| + host = strings.ToLower(strings.TrimSpace(host)) |
| + if h, _, err := net.SplitHostPort(host); err == nil { |
| + return h |
| + } |
| + return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") |
| +} |
| // ---- pages ------------------------------------------------------------------ |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 37c322fb7..226e4a1ee 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -2129,3 +2129,168 @@ func TestReleasesPageNoTagsSection(t *testing.T) { |
| t.Fatalf("Tags section rendered with no tags ever cut:\n%s", body) |
| } |
| } |
| + |
| +// newRedirectFixture builds the minimal Server the CONSOLE_REDIRECT_HOSTS |
| +// tests need — same trimmed shape as newMetricsFixture (metricsview_test.go), |
| +// with none of consoleFixture's registry/keymail/HTTP-listener scaffolding, |
| +// but a caller-chosen Origin and RedirectHosts since that's what's under |
| +// test here. |
| +func newRedirectFixture(t *testing.T, origin string, redirectHosts []string) *Server { |
| + t.Helper() |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + srv, err := NewServer(Config{Releases: rel, Origin: origin, RedirectHosts: redirectHosts}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return srv |
| +} |
| + |
| +// serveRedirect drives srv.ServeHTTP directly with a chosen Host header — no |
| +// httptest.Server/listener needed, since the redirect check reads r.Host |
| +// before the mux (or anything network-shaped) is involved. |
| +func serveRedirect(t *testing.T, srv *Server, method, host, target string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(method, target, nil) |
| + req.Host = host |
| + rec := httptest.NewRecorder() |
| + srv.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +func TestRedirectHostsPermanentRedirect(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", "/a/foo?x=1") |
| + if rec.Code != http.StatusMovedPermanently { |
| + t.Fatalf("GET on a redirect host = %d, want 301", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { |
| + t.Fatalf("Location = %q, want the canonical origin with the request untouched", loc) |
| + } |
| +} |
| + |
| +// TestRedirectHostsPreservesMethodWith308 pins the 308 choice for anything |
| +// other than GET/HEAD: a 301/302 lets browsers replay a POST as a GET and |
| +// drop its body, which is exactly the client breakage RedirectHosts must not |
| +// cause on a host still fielding real requests. |
| +func TestRedirectHostsPreservesMethodWith308(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodPost, "dashboard.example.test", "/a/foo?x=1") |
| + if rec.Code != http.StatusPermanentRedirect { |
| + t.Fatalf("POST on a redirect host = %d, want 308 (keeps the method and body on replay)", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { |
| + t.Fatalf("Location = %q, want the canonical origin", loc) |
| + } |
| +} |
| + |
| +func TestRedirectHostsMatchesHostWithPort(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test:443", "/a/foo?x=1") |
| + if rec.Code != http.StatusMovedPermanently { |
| + t.Fatalf("Host header carrying a port = %d, want 301 — the port must not defeat the match", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { |
| + t.Fatalf("Location = %q, want the canonical origin", loc) |
| + } |
| +} |
| + |
| +// TestRedirectHostsLeavesOtherHostsAlone pins the opt-in contract: a host |
| +// that was never listed (here, the console's own origin host) gets the |
| +// route it would always get, not a redirect — this is what keeps |
| +// platform.carlosframework.com serving CLI bearer tokens untouched. |
| +func TestRedirectHostsLeavesOtherHostsAlone(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodGet, "console.example.test", "/healthz") |
| + if rec.Code == http.StatusMovedPermanently || rec.Code == http.StatusPermanentRedirect { |
| + t.Fatalf("an unlisted host was redirected: %d -> %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "ok") { |
| + t.Fatalf("unlisted host = %d %q, want /healthz served normally", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +// TestNewServerRefusesRedirectingItsOwnOrigin pins the loop guard: a |
| +// RedirectHosts entry equal to Origin's own host would 301/308 a request |
| +// straight back to the host that just answered it. |
| +func TestNewServerRefusesRedirectingItsOwnOrigin(t *testing.T) { |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + _, err = NewServer(Config{ |
| + Releases: rel, |
| + Origin: "https://console.example.test", |
| + RedirectHosts: []string{"console.example.test"}, |
| + }, st) |
| + if err == nil { |
| + t.Fatal("NewServer accepted a RedirectHosts entry equal to its own origin host — that config would redirect-loop") |
| + } |
| +} |
| + |
| +// TestNewServerRefusesRedirectHostsWithoutAnOriginHost pins the other half of |
| +// the loop guard: an origin it cannot read a host out of (here the classic |
| +// operator slip, a CONSOLE_ORIGIN with no scheme) leaves the guard with |
| +// nothing to compare against AND leaves every Location without a host of its |
| +// own. Refuse at start rather than serve half a redirect. |
| +func TestNewServerRefusesRedirectHostsWithoutAnOriginHost(t *testing.T) { |
| + for _, origin := range []string{"", "console.example.test"} { |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + if _, err := NewServer(Config{ |
| + Releases: rel, |
| + Origin: origin, |
| + RedirectHosts: []string{"dashboard.example.test"}, |
| + }, st); err == nil { |
| + t.Errorf("NewServer accepted RedirectHosts with Origin %q — there is no host to redirect to", origin) |
| + } |
| + } |
| +} |
| + |
| +// TestRedirectHostsRootlessTargetStaysOnTheOrigin pins the Location against a |
| +// request target that isn't a path. Go parses `GET mailto:x@evil.com` into an |
| +// opaque URL, and origin+RequestURI would then read |
| +// https://console.example.test@evil.com — userinfo, not host — handing an |
| +// outsider a redirect off the deployment with the console's name on the |
| +// front of it. |
| +func TestRedirectHostsRootlessTargetStaysOnTheOrigin(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + for _, target := range []string{"mailto:x@evil.com", "https:evil.com"} { |
| + rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", target) |
| + loc := rec.Header().Get("Location") |
| + if loc != "https://console.example.test/" { |
| + t.Errorf("target %q redirected to %q, want the origin's front door", target, loc) |
| + } |
| + } |
| +} |
| + |
| +func TestNormalizeHost(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {"dashboard.example.test", "dashboard.example.test"}, |
| + {"DASHBOARD.Example.Test:443", "dashboard.example.test"}, |
| + {" dashboard.example.test ", "dashboard.example.test"}, |
| + // An IPv6 literal keys the same with or without its port, which is |
| + // only true if the portless form loses its brackets too. |
| + {"[::1]", "::1"}, |
| + {"[::1]:8080", "::1"}, |
| + } { |
| + if got := normalizeHost(tc.in); got != tc.want { |
| + t.Errorf("normalizeHost(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 283fb3d10..cf5cb53d0 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -34,12 +34,27 @@ var unitsFS embed.FS |
| // CAP_NET_BIND_SERVICE to still bind :80/:443. |
| const serviceUser = "carlos" |
| +// socketsGroup is the blessed cross-user consumers group for exec-backed |
| +// instance sockets (issue #103): a host app's own process (titogo's router |
| +// was the finding) enrolled here may dial every instance socket on the box. |
| +// A DEDICATED group, never serviceUser's own: group `carlos` is also the |
| +// group on /data (0750), so enrolling a consumer there would grant |
| +// group-read of every instance database and env file — every |
| +// CARLOS_ADMIN_TOKEN on the box — when the grant's whole meaning is "may |
| +// dial sockets". |
| +const socketsGroup = "carlos-sockets" |
| + |
| // tmpfilesConf keeps /run/carlos across reboots (/run is tmpfs). Owned by the |
| -// service user so the non-root units can bind their sockets there. The apps |
| -// subdir holds the carlos-app@ tenant sockets: root systemd binds them |
| -// (SocketUser=carlos, 0600), tenants never need path access (the fd is |
| -// passed in), and other=0 on both dirs keeps every non-carlos uid out. |
| -const tmpfilesConf = "d /run/carlos 0750 " + serviceUser + " " + serviceUser + " -\n" + |
| +// service user so the non-root units can bind their sockets there; group is |
| +// the consumers group with SETGID so every socket an exec child binds inside |
| +// inherits it — that inheritance is what lets the activator's post-health |
| +// 0660 normalization (backend_exec.go) grant exactly the enrolled consumers |
| +// and nobody else, with no app aware the group exists. The apps subdir holds |
| +// the carlos-app@ tenant sockets: root systemd binds them (SocketUser=carlos, |
| +// 0600), tenants never need path access (the fd is passed in) — it stays |
| +// carlos:carlos, so consumer enrollment deliberately does not reach unit |
| +// tenants. other=0 on both dirs keeps every unenrolled uid out. |
| +const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" |
| // enabledUnits are what a live bootstrap turns on: the always-on front door, |
| @@ -90,23 +105,35 @@ func systemctlPlan(units []string) [][]string { |
| return plan |
| } |
| -// RunBootstrap is the `carlos bootstrap` verb. Idempotent — safe to re-run. |
| +// RunBootstrap is the `carlos bootstrap` verb. Idempotent — safe to re-run |
| +// (which is also how a box enrolls a new socket consumer after the fact). |
| func RunBootstrap(args []string) { |
| fs := flag.NewFlagSet("bootstrap", flag.ExitOnError) |
| root := fs.String("root", "/", "install root; a staging dir writes files only (no user creation, no systemctl)") |
| offcloud := fs.Bool("offcloud", false, "off-cloud box: require a staged 0600 credentials file instead of an EC2 instance profile") |
| + var consumers repeatableFlag |
| + fs.Var(&consumers, "socket-consumer", "host-app user to enroll in the "+socketsGroup+" group (repeatable); members may dial every instance socket on the box") |
| _ = fs.Parse(args) |
| if *offcloud { |
| - if err := bootstrapOffcloud(*root); err != nil { |
| + if err := bootstrapOffcloud(*root, consumers); err != nil { |
| log.Fatalf("bootstrap: %v", err) |
| } |
| return |
| } |
| - if err := bootstrap(*root); err != nil { |
| + if err := bootstrap(*root, consumers); err != nil { |
| log.Fatalf("bootstrap: %v", err) |
| } |
| } |
| +// repeatableFlag collects every occurrence of a string flag. |
| +type repeatableFlag []string |
| + |
| +func (f *repeatableFlag) String() string { return strings.Join(*f, ",") } |
| +func (f *repeatableFlag) Set(v string) error { |
| + *f = append(*f, v) |
| + return nil |
| +} |
| + |
| // bootstrapOffcloud is bootstrap for a box with no EC2 instance profile |
| // (issue #53 point 4): identical structural work, plus the credential |
| // preconditions — a staged 0600 credentials file scoped (at IAM) to the |
| @@ -116,7 +143,7 @@ func RunBootstrap(args []string) { |
| // |
| // The credential check runs BEFORE any system mutation: a failed precondition |
| // leaves the box untouched, same as a failed bootstrap() would. |
| -func bootstrapOffcloud(root string) error { |
| +func bootstrapOffcloud(root string, consumers []string) error { |
| cred := filepath.Join(root, "etc", "carlos", "credentials") |
| fi, err := os.Stat(cred) |
| if err != nil { |
| @@ -125,7 +152,7 @@ func bootstrapOffcloud(root string) error { |
| if fi.Mode().Perm() != 0o600 { |
| return fmt.Errorf("offcloud: %s must be mode 0600, is %o", cred, fi.Mode().Perm()) |
| } |
| - if err := bootstrap(root); err != nil { |
| + if err := bootstrap(root, consumers); err != nil { |
| return err |
| } |
| // Ownership is a live-box-only step, same as ensureServiceUser/chownToService |
| @@ -145,8 +172,19 @@ func bootstrapOffcloud(root string) error { |
| // sequence (daemon-reload, enable, and arming the timers; see systemctlPlan). |
| // A non-"/" root is the staging/test path: files only, with the privileged |
| // steps printed instead of run. |
| -func bootstrap(root string) error { |
| +func bootstrap(root string, consumers []string) error { |
| live := filepath.Clean(root) == "/" |
| + if live { |
| + // Validate consumer users BEFORE any mutation (the offcloud |
| + // precondition's discipline): a typo in -socket-consumer must leave |
| + // the box untouched, not abort bootstrap halfway with units written |
| + // but timers never armed. |
| + for _, u := range consumers { |
| + if _, err := user.Lookup(u); err != nil { |
| + return fmt.Errorf("-socket-consumer %s: no such user (create the host app's user first): %w", u, err) |
| + } |
| + } |
| + } |
| unitDir := filepath.Join(root, "etc", "systemd", "system") |
| dataDir := filepath.Join(root, "data") |
| runDir := filepath.Join(root, "run", "carlos") |
| @@ -158,7 +196,10 @@ func bootstrap(root string) error { |
| dirs := []dir{ |
| {dataDir, 0o750}, |
| {filepath.Join(dataDir, "acme"), 0o750}, |
| - {runDir, 0o750}, |
| + // Setgid: sockets bound inside inherit the dir's group — the |
| + // consumers group, once a live run (or the tmpfiles line) sets it. |
| + // See socketsGroup/tmpfilesConf. |
| + {runDir, 0o750 | os.ModeSetgid}, |
| // The carlos-app@ tenant sockets (see tmpfilesConf for the perms |
| // rationale). |
| {filepath.Join(runDir, "apps"), 0o750}, |
| @@ -174,6 +215,7 @@ func bootstrap(root string) error { |
| {filepath.Join(root, "etc", "carlos", "apps"), 0o755}, |
| {unitDir, 0o755}, |
| {filepath.Join(root, "etc", "tmpfiles.d"), 0o755}, |
| + {filepath.Join(root, "etc", "sysusers.d"), 0o755}, |
| } |
| for _, d := range dirs { |
| if err := os.MkdirAll(d.path, d.mode); err != nil { |
| @@ -202,6 +244,35 @@ func bootstrap(root string) error { |
| fmt.Printf(" wrote %s\n", path) |
| } |
| + // On a live root the privileged plan runs BEFORE the tmpfiles conf is |
| + // written: the conf's /run/carlos line names socketsGroup, and |
| + // systemd-tmpfiles DROPS a line whose group doesn't resolve — so a |
| + // bootstrap interrupted between "conf written" and "groupadd ran" would |
| + // leave a box that looks healthy until its next reboot, when /run/carlos |
| + // simply isn't created and carlos-edge (ReadWritePaths=/run/carlos, no |
| + // "-" prefix) fails its namespace setup forever. Group first, then the |
| + // conf that names it. |
| + if live { |
| + for _, args := range privilegedPlan(consumers) { |
| + if err := runPrivileged(args); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + |
| + // The sysusers.d snippet closes the same hole for confs that arrive |
| + // WITHOUT a live bootstrap run: a staging root is how a live box |
| + // refreshes its unit files (pinfra's update-platform-binary.sh), and a |
| + // refresh that installs the staged etc/ tree ships the tmpfiles line to |
| + // a box that never ran groupadd. systemd-sysusers.service is ordered |
| + // Before=systemd-tmpfiles-setup.service, so the group exists by the time |
| + // the tmpfiles line is parsed, however the conf got there. |
| + sysusersPath := filepath.Join(root, "etc", "sysusers.d", "carlos.conf") |
| + if err := os.WriteFile(sysusersPath, []byte("g "+socketsGroup+" -\n"), 0o644); err != nil { |
| + return fmt.Errorf("write %s: %v", sysusersPath, err) |
| + } |
| + fmt.Printf(" wrote %s\n", sysusersPath) |
| + |
| tmpfilesPath := filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf") |
| if err := os.WriteFile(tmpfilesPath, []byte(tmpfilesConf), 0o644); err != nil { |
| return fmt.Errorf("write %s: %v", tmpfilesPath, err) |
| @@ -210,29 +281,20 @@ func bootstrap(root string) error { |
| if !live { |
| fmt.Printf("bootstrap: staged under %s — on a real box (-root /) it would also:\n", root) |
| - fmt.Printf(" useradd --system --user-group --no-create-home --shell /usr/sbin/nologin %s\n", serviceUser) |
| - fmt.Printf(" chown -R %s:%s /data /run/carlos\n", serviceUser, serviceUser) |
| // Printed, never run: a staging root is how a LIVE box refreshes its |
| // unit files (pinfra's update-platform-binary.sh stages into |
| // /tmp/carlos-unitstage), so arming anything from here would reach out |
| // of the temp root and touch the running system from a command whose |
| // entire contract is "write files, change nothing". |
| + for _, args := range privilegedPlan(consumers) { |
| + fmt.Printf(" %s\n", strings.Join(args, " ")) |
| + } |
| for _, args := range systemctlPlan(enabledUnits) { |
| fmt.Printf(" systemctl %s\n", strings.Join(args, " ")) |
| } |
| return nil |
| } |
| - if err := ensureServiceUser(); err != nil { |
| - return err |
| - } |
| - // The units run as serviceUser, so it must own where they read/write |
| - // (recursive, so it also fixes ownership of any data already present). |
| - for _, p := range []string{dataDir, runDir} { |
| - if err := chownToService(p); err != nil { |
| - return err |
| - } |
| - } |
| for _, args := range systemctlPlan(enabledUnits) { |
| if err := systemctl(args...); err != nil { |
| return err |
| @@ -256,21 +318,70 @@ func unitNames() ([]string, error) { |
| return names, nil |
| } |
| -// ensureServiceUser creates the locked, home-less system user the units run |
| -// as, idempotently. |
| -func ensureServiceUser() error { |
| - if _, err := user.Lookup(serviceUser); err == nil { |
| - return nil |
| +// privilegedPlan is the exact non-systemctl privileged sequence a live |
| +// bootstrap runs — and the same sequence a staging run PRINTS instead of |
| +// running (systemctlPlan's discipline, extended to the user/group half when |
| +// issue #103 added the consumers group): the two can never drift into |
| +// describing different behaviour. |
| +// |
| +// The sequence: service user, consumers group, ownership. Every chown |
| +// converges TOWARD the target state, never through an intermediate that |
| +// undoes a previous run's work: /run/carlos is recursively |
| +// serviceUser:socketsGroup — recursive on purpose, so a re-run (the |
| +// documented way to enroll another consumer) also converges the group on |
| +// every LIVE instance socket instead of resetting it to serviceUser's group |
| +// and stranding the already-enrolled consumers until each instance rebinds — |
| +// and apps/ (unit-tenant sockets, systemd-bound, no path consumers) is then |
| +// put back to serviceUser:serviceUser. The chmod re-asserts the setgid bit |
| +// the tmpfiles line promises; consumers enroll last, one usermod each |
| +// (`-aG` is append — an existing member is a no-op, so re-runs converge). |
| +// It deliberately does NOT touch group `carlos` memberships: an interim |
| +// grant made there (the tito workaround this replaces) is the operator's to |
| +// revoke, per the deployment notes in the #103 spec. |
| +func privilegedPlan(consumers []string) [][]string { |
| + plan := [][]string{ |
| + {"useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser}, |
| + {"groupadd", "--system", socketsGroup}, |
| + {"chown", "-R", serviceUser + ":" + serviceUser, "/data"}, |
| + {"chown", "-R", serviceUser + ":" + socketsGroup, "/run/carlos"}, |
| + {"chown", "-R", serviceUser + ":" + serviceUser, "/run/carlos/apps"}, |
| + {"chmod", "2750", "/run/carlos"}, |
| + } |
| + for _, u := range consumers { |
| + plan = append(plan, []string{"usermod", "-aG", socketsGroup, u}) |
| + } |
| + return plan |
| +} |
| + |
| +// runPrivileged executes one privilegedPlan step, adding the existence |
| +// guards that keep the raw commands idempotent: useradd and groupadd exit |
| +// non-zero when their subject already exists, and "already provisioned" is |
| +// bootstrap's normal re-run state, not an error. Everything else in the plan |
| +// is idempotent as-is. |
| +func runPrivileged(args []string) error { |
| + // The subject is derived from the command line itself (both commands |
| + // take it as their final argument), never from a package constant — a |
| + // guard keyed to a constant would silently skip any SECOND useradd or |
| + // groupadd a later plan revision adds. |
| + switch subject := args[len(args)-1]; args[0] { |
| + case "useradd": |
| + if _, err := user.Lookup(subject); err == nil { |
| + return nil |
| + } |
| + case "groupadd": |
| + if _, err := user.LookupGroup(subject); err == nil { |
| + return nil |
| + } |
| } |
| - out, err := exec.Command("useradd", "--system", "--user-group", |
| - "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() |
| + out, err := exec.Command(args[0], args[1:]...).CombinedOutput() |
| if err != nil { |
| - return fmt.Errorf("create user %s: %v: %s", serviceUser, err, strings.TrimSpace(string(out))) |
| + return fmt.Errorf("%s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) |
| } |
| return nil |
| } |
| -// chownToService recursively gives a path to the service user. |
| +// chownToService recursively gives a path to the service user (offcloud's |
| +// credentials file; the standard dirs go through privilegedPlan). |
| func chownToService(path string) error { |
| out, err := exec.Command("chown", "-R", serviceUser+":"+serviceUser, path).CombinedOutput() |
| if err != nil { |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index fcca235b5..5734e25d2 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -74,7 +74,7 @@ func TestUnitsEmbedComplete(t *testing.T) { |
| func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| root := t.TempDir() |
| - if err := bootstrap(root); err != nil { |
| + if err := bootstrap(root, nil); err != nil { |
| t.Fatalf("bootstrap: %v", err) |
| } |
| @@ -534,10 +534,10 @@ func TestProvisionTickConfig(t *testing.T) { |
| func TestBootstrapIdempotent(t *testing.T) { |
| root := t.TempDir() |
| - if err := bootstrap(root); err != nil { |
| + if err := bootstrap(root, nil); err != nil { |
| t.Fatalf("first bootstrap: %v", err) |
| } |
| - if err := bootstrap(root); err != nil { |
| + if err := bootstrap(root, nil); err != nil { |
| t.Fatalf("second bootstrap: %v", err) |
| } |
| // Still all present after the re-run. |
| @@ -632,7 +632,7 @@ func TestBootstrapStagingRunsNoSystemctl(t *testing.T) { |
| } |
| t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) |
| - if err := bootstrap(t.TempDir()); err != nil { |
| + if err := bootstrap(t.TempDir(), nil); err != nil { |
| t.Fatalf("bootstrap: %v", err) |
| } |
| if b, err := os.ReadFile(log); err == nil { |
| @@ -640,6 +640,98 @@ func TestBootstrapStagingRunsNoSystemctl(t *testing.T) { |
| } |
| } |
| +// TestBootstrapTmpfilesBlessesConsumersGroup pins the instance-socket |
| +// consumers contract (issue #103): /run/carlos carries the dedicated |
| +// carlos-sockets group with the setgid bit — so every socket an exec child |
| +// binds inside inherits the consumers group — while /run/carlos/apps stays |
| +// carlos:carlos (unit tenants get their fd passed; enrollment deliberately |
| +// does not reach them). The group is dedicated, never `carlos`: membership |
| +// in `carlos` also grants group-read of /data, i.e. every instance's DB and |
| +// CARLOS_ADMIN_TOKEN. |
| +func TestBootstrapTmpfilesBlessesConsumersGroup(t *testing.T) { |
| + root := t.TempDir() |
| + if err := bootstrap(root, nil); err != nil { |
| + t.Fatalf("bootstrap: %v", err) |
| + } |
| + tf, err := os.ReadFile(filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf")) |
| + if err != nil { |
| + t.Fatalf("tmpfiles conf: %v", err) |
| + } |
| + if want := "d /run/carlos 2750 carlos carlos-sockets -"; !strings.Contains(string(tf), want) { |
| + t.Errorf("tmpfiles conf %q missing %q", tf, want) |
| + } |
| + if want := "d /run/carlos/apps 0750 carlos carlos -"; !strings.Contains(string(tf), want) { |
| + t.Errorf("tmpfiles conf %q missing %q — the tenant socket dir must stay out of the consumers group", tf, want) |
| + } |
| + |
| + // Bootstrap's own dir handling must agree with the tmpfiles line: setgid |
| + // on /run/carlos (mode is what makes group inheritance real between |
| + // boots), and NOT on apps/. |
| + fi, err := os.Stat(filepath.Join(root, "run", "carlos")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode()&os.ModeSetgid == 0 { |
| + t.Errorf("/run/carlos mode %v: missing setgid — bound sockets would not inherit the consumers group", fi.Mode()) |
| + } |
| + if fi.Mode().Perm() != 0o750 { |
| + t.Errorf("/run/carlos perm %o, want 750", fi.Mode().Perm()) |
| + } |
| + afi, err := os.Stat(filepath.Join(root, "run", "carlos", "apps")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if afi.Mode()&os.ModeSetgid != 0 { |
| + t.Errorf("/run/carlos/apps mode %v: setgid must not leak onto the tenant socket dir", afi.Mode()) |
| + } |
| + |
| + // The sysusers.d snippet is what makes the tmpfiles line safe at boot on |
| + // a box whose conf arrived via a staged-tree refresh (never a live |
| + // groupadd): systemd-sysusers runs Before=systemd-tmpfiles-setup, and a |
| + // tmpfiles line whose group doesn't resolve is DROPPED — /run/carlos then |
| + // isn't created and carlos-edge (ReadWritePaths, no "-") can't start. |
| + su, err := os.ReadFile(filepath.Join(root, "etc", "sysusers.d", "carlos.conf")) |
| + if err != nil { |
| + t.Fatalf("sysusers conf: %v", err) |
| + } |
| + if want := "g carlos-sockets -"; !strings.Contains(string(su), want) { |
| + t.Errorf("sysusers conf %q missing %q", su, want) |
| + } |
| +} |
| + |
| +// TestBootstrapPrivilegedPlanEnrollsConsumers pins the printed-equals-run |
| +// contract for the group half, the same discipline systemctlPlan established: |
| +// a staging run prints exactly the commands a live run executes, so the two |
| +// can never drift. -socket-consumer users each get a usermod enrollment into |
| +// the dedicated group; no consumers means no usermod lines but the group and |
| +// dir convergence always run. |
| +func TestBootstrapPrivilegedPlanEnrollsConsumers(t *testing.T) { |
| + got := privilegedPlan([]string{"titogo", "titogo-relay"}) |
| + // The /run/carlos chown is recursive AND targets the consumers group: a |
| + // re-run is the documented enrollment path, and a recursive chown back |
| + // to carlos:carlos would reset every live instance socket's group, |
| + // stranding already-enrolled consumers until each instance rebinds. |
| + // apps/ is put back to carlos:carlos right after (unit tenants stay out |
| + // of consumer reach). |
| + want := [][]string{ |
| + {"useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", "carlos"}, |
| + {"groupadd", "--system", "carlos-sockets"}, |
| + {"chown", "-R", "carlos:carlos", "/data"}, |
| + {"chown", "-R", "carlos:carlos-sockets", "/run/carlos"}, |
| + {"chown", "-R", "carlos:carlos", "/run/carlos/apps"}, |
| + {"chmod", "2750", "/run/carlos"}, |
| + {"usermod", "-aG", "carlos-sockets", "titogo"}, |
| + {"usermod", "-aG", "carlos-sockets", "titogo-relay"}, |
| + } |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("privilegedPlan = %v, want %v", got, want) |
| + } |
| + // No consumers: same plan minus the usermod tail. |
| + if got := privilegedPlan(nil); !reflect.DeepEqual(got, want[:6]) { |
| + t.Errorf("privilegedPlan(nil) = %v, want %v", got, want[:6]) |
| + } |
| +} |
| + |
| // TestBootstrapOffcloudRequiresCredentialsFile pins the off-cloud precondition |
| // (issue #53 point 4): a box with no EC2 instance profile must have its scoped |
| // credentials staged — and staged at 0600 — before bootstrap proceeds. This is |
| @@ -647,18 +739,18 @@ func TestBootstrapStagingRunsNoSystemctl(t *testing.T) { |
| // service-user chown below never runs; that only happens on a live root. |
| func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) { |
| root := t.TempDir() |
| - if err := bootstrapOffcloud(root); err == nil { |
| + if err := bootstrapOffcloud(root, nil); err == nil { |
| t.Fatal("want error when <root>/etc/carlos/credentials is absent") |
| } |
| credDir := filepath.Join(root, "etc", "carlos") |
| os.MkdirAll(credDir, 0o755) |
| credFile := filepath.Join(credDir, "credentials") |
| os.WriteFile(credFile, []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n"), 0o644) |
| - if err := bootstrapOffcloud(root); err == nil { |
| + if err := bootstrapOffcloud(root, nil); err == nil { |
| t.Fatal("want error on 0644 credentials (must be 0600)") |
| } |
| os.Chmod(credFile, 0o600) |
| - if err := bootstrapOffcloud(root); err != nil { |
| + if err := bootstrapOffcloud(root, nil); err != nil { |
| t.Fatalf("offcloud bootstrap with staged 0600 credentials: %v", err) |
| } |
| // The normal bootstrap artifacts must exist too — offcloud is a superset. |