# The tenant template: one instance per route host (%i = the host, which # is also the live/ 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/ (chowned to # the fresh uid each start); the app finds it via $STATE_DIRECTORY. # IMDS is fenced twice: IPAddressDeny below blocks the metadata address # for every tenant at the unit level (a box with no nftables gate — tito, # issue #351 — is otherwise wide open), and on hardened boxes the # harden-host skuid gate blocks dynamic uids by construction — only # allowlisted uids may mint host credentials. A host_role tenant's # drop-in re-opens the unit-level fence with IPAddressAllow (allow wins # over deny); the skuid gate still has the last word. # # 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. # # Listener contract: with carlos-app@.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] Description=CARLOS app %i After=network-online.target [Service] DynamicUser=yes StateDirectory=carlos-app/%i ExecStart=/opt/carlos/live/%i serve # The version store holds every app's build on the box (#214 item 2). This # unit sees an empty /opt/carlos with its own build put back at the path # ExecStart= names: PID 1 chases the live link when it binds it, so a # repoint takes effect at the next start, as it always has. TemporaryFileSystem=/opt/carlos:ro BindReadOnlyPaths=/opt/carlos/live/%i 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 IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 MemoryDenyWriteExecute=yes MemoryMax=512M # Throttle-and-reclaim before the kill — the same band the exec path's # memory.high gives (internal/cgroup applyLimit, 90% of max). MemoryHigh=460M TasksMax=256 [Install] WantedBy=multi-user.target