carlos / platform Public

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>
Paul Campbell 6fab1ba9ba50ed7ee2a5518c9509e8ce99768cbd parent 59847e3
5 files changed, +97 −8
  • internal/host/agent.go +12 −5
  • internal/host/bootstrap.go +10 −3
  • internal/host/host_test.go +22 −0
  • internal/host/units/carlos-app@.service +12 −0
  • internal/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