host: carlos-app@.socket — carlos-owned tenant sockets + wake-on-connect
systemd binds /run/carlos/apps/<host>.sock as root, chowns it to carlos at 0600, and passes the fd to the DynamicUser tenant (sd_listen_fds). That is the simplest correct perms design: the edge connects, tenants can't reach each other's sockets (closing the loopback cross-talk gap), and the tenant never needs path access — no shared group, no umask games, no rebind window at the edge's dialer. A stopped service restarts on connection, which backstops the activator's health-gated wake. TCP Addr routes stay the fallback for apps without LISTEN_FDS. Bootstrap grows the /run/carlos/apps dir (+tmpfiles), the embed covers *.socket, and the agent wires the activator's SplitBackend so hibernate-flagged backing=unit routes sleep and wake via systemctl.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+97
−8
internal/host/agent.go+12 −5internal/host/bootstrap.go+10 −3internal/host/host_test.go+22 −0internal/host/units/carlos-app@.service+12 −0internal/host/units/carlos-app@.socket+41 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a18848771..cee0d6f36 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -54,10 +54,17 @@ func RunAgent(args []string) { |
| var act edge.Activator |
| var svc *activator.Service |
| if cfg != nil { |
| - svc = activator.New(*cfg, &activator.ExecBackend{ |
| - Cfg: *cfg, |
| - OptDir: release.OptDir(), |
| - DataDir: dd, |
| + // Exec children and carlos-app@ unit tenants share one state machine; |
| + // the split backend dispatches per route on Backing. Note the arming |
| + // gate is still the hibernate bucket env even though unit tenants |
| + // never touch the bucket — one opt-in switch for the whole activator. |
| + svc = activator.New(*cfg, &activator.SplitBackend{ |
| + Exec: &activator.ExecBackend{ |
| + Cfg: *cfg, |
| + OptDir: release.OptDir(), |
| + DataDir: dd, |
| + }, |
| + Unit: &activator.UnitBackend{OptDir: release.OptDir()}, |
| }, reg) // the registry satisfies activator.DueStore directly |
| svc.Start() |
| act = svc |
| @@ -135,7 +142,7 @@ func adoptLoop(reg *registry.Registry) { |
| // cycles the unit iff it's running, best-effort: a missing or stopped unit is |
| // not this pass's problem, and must not poison the build for other hosts. |
| func restartRoute(host string) error { |
| - out, err := exec.Command("systemctl", "try-restart", "carlos-app@"+host+".service").CombinedOutput() |
| + out, err := exec.Command("systemctl", "try-restart", activator.UnitName(host)).CombinedOutput() |
| if err != nil { |
| log.Printf("adopt: try-restart carlos-app@%s: %v: %s (continuing)", host, err, strings.TrimSpace(string(out))) |
| } |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 4e925c3ca..060dd29f0 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -26,7 +26,7 @@ import ( |
| // no separate files to copy. Ported from Eleven's deploy/systemd (renamed, |
| // same hardening) and titogo's deploy/systemd (litestream + restore-verify). |
| // |
| -//go:embed units/*.service units/*.timer |
| +//go:embed units/*.service units/*.timer units/*.socket |
| var unitsFS embed.FS |
| // serviceUser is the unprivileged account the edge unit runs as; bootstrap |
| @@ -35,8 +35,12 @@ var unitsFS embed.FS |
| const serviceUser = "carlos" |
| // tmpfilesConf keeps /run/carlos across reboots (/run is tmpfs). Owned by the |
| -// service user so the non-root units can bind their sockets there. |
| -const tmpfilesConf = "d /run/carlos 0750 " + serviceUser + " " + serviceUser + " -\n" |
| +// 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" + |
| + "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" |
| // enabledUnits are what a live bootstrap turns on: the always-on front door |
| // and the hourly adoption timer. The litestream + restore-verify units install |
| @@ -74,6 +78,9 @@ func bootstrap(root string) error { |
| {dataDir, 0o750}, |
| {filepath.Join(dataDir, "acme"), 0o750}, |
| {runDir, 0o750}, |
| + // The carlos-app@ tenant sockets (see tmpfilesConf for the perms |
| + // rationale). |
| + {filepath.Join(runDir, "apps"), 0o750}, |
| // The version store + per-host live symlinks (release/versions.go). |
| // World-readable/executable so sandboxed instance units can exec their |
| // binary; only root (the adopt pass) writes here. |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 016507543..4d38fafdb 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -15,6 +15,7 @@ var expectedUnits = []string{ |
| "carlos-adopt.service", |
| "carlos-adopt.timer", |
| "carlos-app@.service", |
| + "carlos-app@.socket", |
| "carlos-edge.service", |
| "carlos-litestream.service", |
| "carlos-restore-verify.service", |
| @@ -102,6 +103,23 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| } |
| } |
| + // The tenant socket unit: systemd owns the listener as carlos so the |
| + // edge can connect and tenants can't reach each other (the whole point). |
| + sockUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-app@.socket")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, want := range []string{ |
| + "ListenStream=/run/carlos/apps/%i.sock", |
| + "SocketUser=carlos", |
| + "SocketMode=0600", |
| + "WantedBy=sockets.target", |
| + } { |
| + if !strings.Contains(string(sockUnit), want) { |
| + t.Errorf("carlos-app@.socket: missing %q", want) |
| + } |
| + } |
| + |
| // The standard dirs. |
| for _, d := range []string{ |
| filepath.Join(root, "data"), |
| @@ -109,6 +127,7 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| filepath.Join(root, "opt", "carlos", "live"), |
| filepath.Join(root, "etc", "carlos"), |
| filepath.Join(root, "run", "carlos"), |
| + filepath.Join(root, "run", "carlos", "apps"), |
| } { |
| fi, err := os.Stat(d) |
| if err != nil { |
| @@ -128,6 +147,9 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| if !strings.Contains(string(tf), "/run/carlos") { |
| t.Errorf("tmpfiles conf %q does not mention /run/carlos", tf) |
| } |
| + if !strings.Contains(string(tf), "/run/carlos/apps") { |
| + t.Errorf("tmpfiles conf %q does not keep the tenant socket dir", tf) |
| + } |
| } |
| func TestBootstrapIdempotent(t *testing.T) { |
diff --git a/internal/host/units/carlos-app@.service b/internal/host/units/carlos-app@.service| index bd2876a56..2fc9dde79 100644 |
| --- a/internal/host/units/carlos-app@.service |
| +++ b/internal/host/units/carlos-app@.service |
| @@ -13,6 +13,18 @@ |
| # files across units (a router + instance family like titogo's) needs a |
| # static app user instead — this template is for single-process tenants. |
| # |
| +# Listener contract: with carlos-app@<host>.socket enabled, systemd passes |
| +# the bound unix socket in (LISTEN_FDS=1, fd 3) whenever this service |
| +# starts — socket-activated or systemctl-started alike — and the app must |
| +# serve on it. There is deliberately no Sockets= line and no CARLOS_SOCKET |
| +# env: fd inheritance is opt-in per tenant by enabling the socket unit, so |
| +# a TCP-only app on an Addr route never sees LISTEN_FDS and keeps binding |
| +# its own port (the fallback; see carlos-app@.socket for the perms story). |
| +# |
| +# Hibernation (routes.hibernate + backing=unit): the agent's activator |
| +# stops this service after the route's idle window and starts it, health- |
| +# gated, when a request arrives. Restart=always still owns crashes. |
| +# |
| # Apps needing different arguments override ExecStart via a drop-in. |
| [Unit] |
diff --git a/internal/host/units/carlos-app@.socket b/internal/host/units/carlos-app@.socket| new file mode 100644 |
| index 000000000..fe353b79d |
| --- /dev/null |
| +++ b/internal/host/units/carlos-app@.socket |
| @@ -0,0 +1,41 @@ |
| +# The tenant listener: systemd owns the unix socket at |
| +# /run/carlos/apps/<host>.sock and passes the bound fd to carlos-app@<host> |
| +# (sd_listen_fds — the app sees LISTEN_FDS=1 and serves on fd 3). |
| +# |
| +# Why socket activation instead of the app binding a path itself: it is the |
| +# simplest design that gets the PERMISSIONS right. The DynamicUser tenant |
| +# owns the listening process, but the file is created by root and owned by |
| +# carlos with mode 0600 — so the edge (user carlos) can connect and no |
| +# tenant can reach another tenant's socket. This closes the loopback |
| +# cross-talk gap that TCP Addr routes have. The alternatives all leak: a |
| +# tenant-bound socket is owned by an ephemeral uid the edge can't connect |
| +# to; umask/chmod games make it world-connectable; a shared group doesn't |
| +# exist for DynamicUser without inventing static accounts. Here the tenant |
| +# never needs path access at all — the fd arrives already bound, and the |
| +# socket survives service restarts, so the edge's unix dial never even sees |
| +# the rebind window. |
| +# |
| +# It is also the wake-on-connect half of hibernation: with the service |
| +# stopped (the activator's idle timeout), a connection here starts it — |
| +# including while the agent itself is down. The activator's edge-mediated |
| +# wake (systemctl start + HTTP health wait) remains the primary path |
| +# because it is health-gated: a broken boot becomes a clean 503 at the |
| +# edge, not a connect parked forever in the listen backlog. |
| +# |
| +# Enable per tenant, alongside the route: |
| +# systemctl enable --now carlos-app@<host>.socket |
| +# Apps that cannot accept an inherited listener yet keep a TCP Addr route |
| +# and NO socket unit — the documented fallback (with the cross-talk caveat |
| +# above, until the app learns LISTEN_FDS). |
| + |
| +[Unit] |
| +Description=CARLOS app %i socket |
| + |
| +[Socket] |
| +ListenStream=/run/carlos/apps/%i.sock |
| +SocketUser=carlos |
| +SocketGroup=carlos |
| +SocketMode=0600 |
| + |
| +[Install] |
| +WantedBy=sockets.target |