carlos / platform Public

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>
Paul Campbell 8c15d9f391169f5b329ab15faf9d73b056081ad6 parent 8d8c617
3 files changed, +55 −0
  • internal/host/bootstrap.go +3 −0
  • internal/host/host_test.go +1 −0
  • internal/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