feat: carlos-app@ tenant template — DynamicUser + full sandbox by default
Ephemeral uid per tenant service (no user administration), state in /var/lib/carlos-app/<host>, hardened unit inherited by every future tenant. Multi-process app families (router+instances) still need a static app user — documented in the template.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+55
−0
internal/host/bootstrap.go+3 −0internal/host/host_test.go+1 −0internal/host/units/carlos-app@.service+51 −0
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index d886ee8a8..4e925c3ca 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -81,6 +81,9 @@ func bootstrap(root string) error { |
| {filepath.Join(root, "opt", "carlos", "live"), 0o755}, |
| // Host-wide config (host.env, 0600 root-owned, staged by the operator). |
| {filepath.Join(root, "etc", "carlos"), 0o755}, |
| + // Per-app env files for carlos-app@ tenants (systemd reads them as |
| + // root before dropping to the unit's DynamicUser, so 0600 root works). |
| + {filepath.Join(root, "etc", "carlos", "apps"), 0o755}, |
| {unitDir, 0o755}, |
| {filepath.Join(root, "etc", "tmpfiles.d"), 0o755}, |
| } |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index f2a27ee37..016507543 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -14,6 +14,7 @@ import ( |
| var expectedUnits = []string{ |
| "carlos-adopt.service", |
| "carlos-adopt.timer", |
| + "carlos-app@.service", |
| "carlos-edge.service", |
| "carlos-litestream.service", |
| "carlos-restore-verify.service", |
diff --git a/internal/host/units/carlos-app@.service b/internal/host/units/carlos-app@.service| new file mode 100644 |
| index 000000000..bd2876a56 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-app@.service |
| @@ -0,0 +1,51 @@ |
| +# The tenant template: one instance per route host (%i = the host, which |
| +# is also the live/<host> symlink the adopt pass repoints and the unit |
| +# the agent's restartRoute try-restarts). |
| +# |
| +# DynamicUser: systemd mints an ephemeral uid per service at start — apps |
| +# share no identity with each other or the platform, and nobody manages |
| +# user accounts. State lives in /var/lib/carlos-app/<host> (chowned to |
| +# the fresh uid each start); the app finds it via $STATE_DIRECTORY. |
| +# The IMDS firewall gate (harden-host) blocks dynamic uids by |
| +# construction — only root and carlos may mint host credentials. |
| +# |
| +# Caveat: DynamicUser is per-unit, so an app whose processes must share |
| +# files across units (a router + instance family like titogo's) needs a |
| +# static app user instead — this template is for single-process tenants. |
| +# |
| +# Apps needing different arguments override ExecStart via a drop-in. |
| + |
| +[Unit] |
| +Description=CARLOS app %i |
| +After=network-online.target |
| + |
| +[Service] |
| +DynamicUser=yes |
| +StateDirectory=carlos-app/%i |
| +ExecStart=/opt/carlos/live/%i serve |
| +EnvironmentFile=-/etc/carlos/apps/%i.env |
| +Restart=always |
| +NoNewPrivileges=yes |
| +ProtectSystem=strict |
| +PrivateTmp=yes |
| +ProtectProc=invisible |
| +ProcSubset=pid |
| +ProtectHome=yes |
| +PrivateDevices=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +RemoveIPC=yes |
| +LockPersonality=yes |
| +RestrictRealtime=yes |
| +RestrictNamespaces=yes |
| +CapabilityBoundingSet= |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=512M |
| +TasksMax=256 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |