# How many system users should left4me have? — 1, 2, or 3 **Status: SUPERSEDED 2026-05-15 by the hardening refactor + uid-collapse.** The original question — should left4me have 1, 2, or 3 system users — is now answered: **1 user (after the uid-collapse refactor) is correct.** The defenses that motivated a multi-user split (DB readability from srcds, cross-server ptrace, same-uid /proc visibility, web-side reach into gameserver state) are closed by the systemd hardening composition landed in the hardening-refactor plan (`docs/superpowers/plans/2026-05-15-hardening-refactor.md`): - `PrivateUsers=true` blocks cross-uid ptrace at the kernel level. - `PrivatePIDs=true` hides peer processes even when uids match. - `TemporaryFileSystem=` + minimal binds hide the DB and web.env from srcds entirely. - `SystemCallFilter=~@debug` + empty `CapabilityBoundingSet=` block ptrace at the syscall layer. - System-wide `kernel.yama.ptrace_scope=2` blocks same-uid ptrace. The interim state (`left4me` + `l4d2-sandbox`) recorded earlier in this doc was the principled middle ground — script-sandbox builds keeping a separate uid for kernel-enforced isolation. After the hardening refactor closed the same-uid attack surface for server/web, leaving the sandbox on a separate uid was the inconsistent half-step. The uid-collapse refactor (`docs/superpowers/plans/2026-05-15-uid-collapse.md`) removed `l4d2-sandbox` so the sandbox now runs as `left4me`, defended by the same hardening profile. One principle: hardening covers it. The residual filesystem-ACL surface (DB at `0640 root:left4me`, web.env same) is a separate concern: a uid split would close it via kernel ACLs, but for the current deployment shape it's covered by the systemd-imposed FS view. If the deployment shape changes (multi-tenant host, shell logins as the service uid, additional services running as `left4me` outside these units) the uid split should be revisited. The original content of this spec is preserved below for context. --- **Status: open question, not settled design.** This is a handoff document. Today left4me has 2 system users: `left4me` (web app + gameservers + workshop builds) and `l4d2-sandbox` (script-overlay sandbox). Whether that split is correct — should we collapse, or split further? — has surfaced multiple times across recent design work without ever being settled. A future session should evaluate and decide; this doc gives them enough context to do so cold. ## Why this came up Three relevant moments: 1. **Build-time idmap (2026-05-15, this session)**: when we considered eliminating the `l4d2-sandbox` uid entirely and just running the script sandbox as `left4me`, we noted "sandbox escape could see web.env / DB / running gameservers" — i.e. uid separation is the load-bearing defense layer. We kept `l4d2-sandbox`. Plan at `docs/superpowers/plans/2026-05-15-build-time-idmap.md` flagged this as an out-of-scope future direction. 2. **Idmap-on-mount plan (2026-05-14)**: in `docs/superpowers/plans/2026-05-14-overlay-idmap.md` "Out of scope" section: *"Gameserver uid split (separating the gameserver-runtime uid from `left4me`) — planned for a later session."* No design captured. 3. **In this session's conversation**: when discussing the server-side-symlink option for the helper consolidation, we surfaced that a compromised web app (running as `left4me`) can already reach `left4me`-owned gameserver state, RCON, etc., because they share a uid. Splitting them would localize web compromise. The question never got a structured answer because each plan inherited "we have 2 users" as a constraint, not as a design choice. This doc fixes that. ## Note: these are system units, not user units Both `left4me-server@.service` and `left4me-web.service` are **system units** at `/usr/local/lib/systemd/system/`, started by PID 1, that drop to the unprivileged uid via `User=left4me` `Group=left4me` after their `+`-prefixed ExecStartPre runs as root. They are **not** user units (no `systemctl --user`, no per-user systemd instance, no lingering required). This makes the user-split refactor much simpler than it would be otherwise. Changing `User=`/`Group=` in the unit is a literal one-line edit per unit. None of the typical user-unit friction applies — no lingering setup, no `pam_systemd` dependency, no "how does the user instance get bootstrapped on boot," no socket activation gymnastics. The privileged ExecStartPre/ExecStopPost steps continue to run as root via the `+` prefix regardless of what `User=` is set to. ## Current state (2 users) User → what runs as it: | User | Runs as | Reads | Writes | |---|---|---|---| | `left4me` (uid 980) | Flask web app (`left4me-web.service`), `srcds_run` for each gameserver instance (`left4me-server@.service`), web-driven workshop & files-overlay builds (in-process Python). | DB, env files, /opt/left4me/src, all of /var/lib/left4me/. | DB, /var/lib/left4me/{overlays,instances,runtime,…}, /opt/left4me/src (pip install -e creates egg-info). | | `l4d2-sandbox` (uid 981) | Script-overlay sandbox (`systemd-run`-launched transient unit; will become `build-overlay@.service` if that refactor lands). | `/etc/left4me/sandbox-resolv.conf`, `/etc/ssl`, `/etc/ca-certificates`, the script bind-mounted at `/script.sh`, the idmapped `/overlay` bind. | `/overlay` only. After build-time-idmap refactor, those writes land on disk as `left4me`-owned via the bind's uid translation. | What this *prevents* today: - Sandbox-escape ⇒ can't read the DB (different uid; DB is `root:left4me 0640`). - Sandbox-escape ⇒ can't attach to gameserver processes (different uid; can't ptrace, can't signal). - Sandbox-escape ⇒ can't write to anything outside its bind (`ProtectSystem=strict` + bind list). What this *doesn't* prevent today: - Web-app compromise ⇒ full access to DB, env files, all gameservers (same uid). - Web-app compromise ⇒ can `sudo` the privileged helpers (script-sandbox, overlay mount, systemctl) per sudoers rules. - Web-app compromise ⇒ can replace `/opt/left4me/src/` Python code (it's `left4me`-owned); on next gunicorn reload, attacker code runs as the web app. - Web-app compromise ⇒ can ptrace running gameservers (same uid; can read their memory, inject code). - Gameserver compromise (e.g. RCE via game protocol bug) ⇒ symmetric: read DB, mess with web app, etc. ## Three configurations ### 1-user (`left4me` only) Collapse `l4d2-sandbox` into `left4me`. Sandbox runs as `left4me` with the existing systemd hardening (`ProtectSystem=strict`, narrow binds, seccomp, etc.). - **Pros**: simplest. No idmap needed anywhere (sandbox writes land as `left4me` natively). Drops ~40 lines of helper code. No cross-uid file-access plumbing. - **Cons**: a sandbox escape that the systemd hardening fails to contain (e.g. kernel bug bypassing seccomp; mount-namespace escape) gains the web app's uid — full DB / env / gameserver access. The current 2-user split exists specifically to limit this blast radius. - **When this is OK**: if you're confident systemd hardening is load-bearing and uid separation is belt-and-braces. The cost-of- failure is "web app compromised from a sandbox bug" — judge whether you'd accept that risk. ### 2-user (current: `left4me` + `l4d2-sandbox`) Status quo after the build-time-idmap refactor. Web/game share a uid; sandbox is separate. - **Pros**: existing architecture, working code, all tests pass. One idmap point (build-time), well-understood. - **Cons**: a web-app compromise has full access to gameserver state (same uid as srcds). A gameserver RCE (e.g. via L4D2 network code bug) has full access to the web app's state. - **Threat model**: assumes the web app and gameservers are mutually trusting. Acceptable for solo-operator infra; less so for multi-tenant. ### 3-user (`l4d2-web` + `l4d2-game` + `l4d2-sandbox`) Split `left4me` into two: `l4d2-web` for the web app, `l4d2-game` for gameservers. Keep `l4d2-sandbox`. - **Pros**: localizes web compromise (can no longer attach to running gameservers, read their memory, modify their per-instance config). Symmetric protection for gameserver RCEs (can't reach the DB / env files directly). - **Cons**: significant plumbing for cross-uid file access (overlays, instance state, upper-layer staging). Reintroduces an idmap concern at the *gameserver* boundary (overlay copy-up by `l4d2-game` of `l4d2-web`-owned lowerdirs), unless we use a shared group instead. See "Cross-uid plumbing" below. The bigger uid-set version (per-instance gameserver uids, e.g. `l4d2-game-1`, `l4d2-game-2`) is **out of scope** for this doc — the marginal gain over a single `l4d2-game` is small for a single- host deployment. ## Cross-uid plumbing (the hard part of 3-user) These are the file boundaries that the split affects: ### Overlays (`/var/lib/left4me/overlays//`) - Today: `l4d2-web`-owned (after build-time-idmap migration). - `l4d2-game` needs to read them as lowerdir at overlay mount time. - Sandbox writes through idmap; the idmap target uid must be the one whose writes appear on disk. Three approaches: - **Shared group `l4d2-overlay`**: both `l4d2-web` and `l4d2-game` members. Overlays chgrp'd to `l4d2-overlay`, mode `2775` (setgid so new entries inherit). Sandbox idmap maps to whichever primary uid (probably `l4d2-web` since the web app *creates* the overlay dirs). `l4d2-game` reads via group access. Copy-up by `l4d2-game` produces files owned by `l4d2-game` (its own primary uid) — but since they end up in upper, that's fine. - **World-readable**: overlays mode `0755`; `l4d2-game` reads via "other" access. Simpler but slightly looser perms. Acceptable for internal infra. - **Per-overlay idmap on gameserver mount** (back to what we just removed): bind overlays as idmapped lowerdirs at gameserver start time, presenting them as `l4d2-game`-owned to overlayfs. We already know this works — we just deleted that code path. Don't re-add it; prefer group-based access. ### Upper layer (`/var/lib/left4me/runtime//upper/`) - Today: `left4me`-owned, written by both the web app (server.cfg staging in `start_instance`) and the gameserver (copy-up at runtime). - With 3-user: `l4d2-game` writes via copy-up; `l4d2-web` writes server.cfg staging. Two paths: - **Shared group `l4d2-runtime`** (could be the same as `l4d2-overlay` or distinct). Upper-layer dir mode `2775`, setgid'd, both uids write via group. - **Move server.cfg staging out of `start_instance`** into the systemd unit itself: `ExecStartPre=+...` does the cp as root and chowns to `l4d2-game`. Cleaner separation of concerns; harder to pipe dynamic content (the cfg lines come from the DB blueprint). Either pass them via an env file, or have the web app write `instances//server.cfg` (its own dir) and the unit cps it into upper. ### Database (`/var/lib/left4me/left4me.db`) - Today: `root:left4me 0640`. `left4me` (uid 980) can read and write. - With 3-user: only `l4d2-web` should write. `l4d2-game` shouldn't need DB access at all (gameservers operate from `instance.env` + overlay state; DB is a web-side concern). - Migration: chown to `root:l4d2-web`, mode unchanged. Game uid is not in the `l4d2-web` group, so it can't read the DB. Clean. ### Env files (`/etc/left4me/{host.env,web.env}`) - Today: `root:left4me 0640`. `host.env` for L4D2 server config (used by systemd units), `web.env` for the Flask app (secret_key, DB url, etc.). - With 3-user: - `web.env` → `root:l4d2-web 0640`. Only the web app reads it. - `host.env` is more nuanced — used by gameserver units (`left4me-server@.service` sources it). Today both web and game read it (web uses `host.env` for some operations too). Probably keep readable by both: `root` owner, group membership for both uids. Or duplicate the few values that matter. ### Code (`/opt/left4me/src/`) - Today: `left4me`-owned (for `pip install -e` editable installs; creates `egg-info/` in the source tree). - With 3-user: only the web app needs to run the Python code from this tree. Make it `l4d2-web`-owned. - The gameserver runs `srcds_run` from `/var/lib/left4me/installation/` (Steam-managed) and the overlay binds — doesn't touch `/opt/left4me/src/`. ### Helpers and sudoers - `left4me-overlay`: invoked by `left4me-server@.service` (root via systemd `+` prefix). Doesn't need user accounts at all. - `left4me-script-sandbox` (today): invoked by the web app via sudo. With 3-user, the sudoers grant moves from `left4me` to `l4d2-web`. - `left4me-systemctl` and `left4me-journalctl`: invoked by the web app. Same — sudoers moves to `l4d2-web`. - The admin CLI `/usr/local/sbin/left4me` (the `sudo left4me ` wrapper): drops to `l4d2-web` (the web app's uid) to run flask commands. ## Idmap implications With the build-time idmap landing on 2026-05-15, the sandbox's writes get translated to the in-mount uid → disk-side uid via `mount --bind --map-users=::1`. Currently `disk_uid = left4me`. Under 3-user, the disk-side target of the sandbox idmap should be the uid that "owns" overlay state — i.e. **`l4d2-web`**, since the web app creates the overlay dirs and reads them for the file-tree endpoint. Gameserver access goes through the shared group, not the idmap. This means the helper's `id -u left4me` → `id -u l4d2-web` is the one-line change captured in the build-time-idmap plan. Trivial. ## Threat-model heuristics The decision really turns on what you think is most likely: - "Web app gets RCE'd via a Flask/dependency bug, or session hijack" → 3-user helps (game state survives). - "Gameserver gets RCE'd via L4D2 source-engine bug" → 3-user helps (web/DB survives). L4D2 is old code with known unpatched vulns in the engine; this is non-negligible. - "Sandbox script exploits a kernel bug to escape seccomp" → 2-user already helps. Going to 3-user doesn't add much here. - "Local privilege escalation via sudo helper" → 3-user is modestly better (sudoers grants are narrower per-uid). If you take L4D2 engine RCE seriously, the 2→3 split has real value. If you think the web app is the most exposed surface and the gameservers are behind a NAT/firewall + only running trusted maps, less so. ## Migration plan sketch (for the 3-user option) If you choose 3-user, the rough plan: 1. **Create the new users in ckn-bw bundle**: ```python users = { 'l4d2-web': {'uid': 982, 'gid': 982, 'home': '/var/lib/left4me', 'shell': '/usr/sbin/nologin'}, 'l4d2-game': {'uid': 983, 'gid': 983, 'home': '/var/lib/left4me', 'shell': '/usr/sbin/nologin'}, 'l4d2-sandbox': {'uid': 981, ...}, # unchanged } groups = { 'l4d2-web': {'gid': 982}, 'l4d2-game': {'gid': 983}, 'l4d2-sandbox': {'gid': 981}, 'l4d2-overlay': {'gid': 984, 'members': ['l4d2-web', 'l4d2-game']}, } ``` The old `left4me` user/group can be kept as an alias or removed entirely. 2. **Re-chown across the migration**: - `/opt/left4me/src` → `l4d2-web:l4d2-web` (recursive). - `/var/lib/left4me/left4me.db` → `root:l4d2-web 0640`. - `/etc/left4me/web.env` → `root:l4d2-web 0640`. - `/etc/left4me/host.env` → `root:l4d2-web 0640` (decide if game needs to read it; if so, supplementary group). - `/var/lib/left4me/overlays` → `l4d2-web:l4d2-overlay 2775`. - `/var/lib/left4me/instances` → `l4d2-web:l4d2-web`. - `/var/lib/left4me/runtime` → `l4d2-web:l4d2-overlay 2775` (or whatever combination accommodates upper-layer writes from both uids). - `/var/lib/left4me/installation` → `l4d2-game:l4d2-game` (steamcmd writes here as the game user). 3. **Update `User=`/`Group=` in systemd units**: - `left4me-web.service`: `User=l4d2-web Group=l4d2-web` - `left4me-server@.service`: `User=l4d2-game Group=l4d2-game` - Both: add `SupplementaryGroups=l4d2-overlay` where needed. 4. **Update sudoers** (`/etc/sudoers.d/left4me`): ``` l4d2-web ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl * l4d2-web ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl * l4d2-web ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-script-sandbox # or the build-overlay unit ``` Game uid gets no sudoers grants at all (gameservers don't privesc). 5. **Update the script-sandbox idmap target**: - In `deploy/files/usr/local/libexec/left4me/left4me-script-sandbox`, change `id -u left4me` → `id -u l4d2-web` and matching groups. One-line change. 6. **Update the admin CLI**: - `/usr/local/sbin/left4me` does `sudo -u left4me sh -c '. host.env; . web.env; flask …'`. Change to `sudo -u l4d2-web …`. 7. **Update steamcmd ownership in ckn-bw**: - `actions['left4me_install_steamcmd']` runs as `left4me`; change to `l4d2-game`. - `/opt/left4me/steam` dir → `l4d2-game`. 8. **Add tests** that assert the user-split invariant in `deploy/tests/test_deploy_artifacts.py` (e.g. sudoers grants to the right uid, systemd unit `User=` matches). 9. **Run the migration on the test server**: - Stop all `left4me-server@*` + `left4me-web`. - Run a one-shot `chown` script (or `bw apply` if the bundle does the chowns). - Start everything; verify nothing broken. Estimate: 1-2 working days. Most of the time is debugging surprise cross-uid permission failures (the kind of thing you can't fully predict without trying it). ## Open decisions 1. **Do we actually want this?** Threat-model section above. If the answer is "no, 2-user is fine," close this doc and don't come back to it. The current state is correct and tested. 2. **If yes, web vs game split, or also split per-instance gameserver?** Recommendation: single `l4d2-game` uid for all instances. Per-instance uids add a lot of ceremony for a marginal hardening gain. 3. **`l4d2-overlay` shared group: one group or two?** Could split into `l4d2-overlay-read` (game-membership) and `l4d2-overlay-write` (web-membership), but probably overkill. 4. **Keep the `left4me` user as a no-op alias for compatibility?** Existing systemd units, sudoers, etc. all reference `left4me`; migration is easier if we just rename. But a clean break is easier to reason about. Recommendation: clean break, no alias. 5. **Should we collapse to 1 user instead?** Captured above. The defense-in-depth from `l4d2-sandbox` is real; recommend keeping it. 6. **Does `host.env` need to be readable by both web and game?** Audit what's in it. If it's all gameserver-specific values, `root:l4d2-game`. If split, may need to factor into two env files or use supplementary groups. 7. **`steamcmd` install location and ownership.** Today `steamcmd` self-updates at runtime as `left4me`. With game uid, this needs to run as `l4d2-game` (or the game uid needs write to `/opt/left4me/steam/`). ## Verification (for the 3-user migration) After migration on `left4.me`: ```bash # uids are distinct id l4d2-web; id l4d2-game; id l4d2-sandbox # nothing left4me-owned remains sudo find /var/lib/left4me /opt/left4me /etc/left4me -user left4me 2>/dev/null # expect: empty # the right things own the right things sudo ls -ln /var/lib/left4me/left4me.db # root:l4d2-web 0640 sudo ls -ln /etc/left4me/web.env # root:l4d2-web 0640 sudo ls -ln /opt/left4me/src # l4d2-web:l4d2-web sudo ls -ln /var/lib/left4me/overlays # l4d2-web:l4d2-overlay 2775 sudo ls -ln /var/lib/left4me/installation # l4d2-game:l4d2-game # unit user= is right systemctl show left4me-web -p User # l4d2-web systemctl show left4me-server@2 -p User # l4d2-game # game uid can read overlays via shared group sudo -u l4d2-game cat /var/lib/left4me/overlays/9/left4dead2/addons/sourcemod.vdf # (should succeed) # game uid cannot read DB sudo -u l4d2-game cat /var/lib/left4me/left4me.db 2>&1 # expect: permission denied # web uid cannot ptrace srcds sudo -u l4d2-web gdb --batch -ex "attach $(pgrep -f srcds_linux | head -1)" 2>&1 # expect: Operation not permitted # everything works end-to-end # - server 2 stays running # - script overlay rebuild succeeds # - web UI responds and shows live logs from server 2 ``` ## Risks (for the 3-user migration) - **Surprise file-access failures** in odd corners: the admin CLI, log paths, lock files, alembic migrations, the database WAL files, steamcmd cache, workshop cache. Each is a 30-second fix but they add up. - **Concurrent migration + running services**: stop everything, migrate, restart. Don't try to migrate live. - **Workshop builds**: today the web app calls `steamcmd` directly in-process to download workshop items. With a split, this needs to either move to a sudo'd helper (run as `l4d2-game` since `steamcmd` is game-owned) or duplicate the steam install for the web user. Probably the former. - **Gameserver writes that we didn't catch**: log files, lock files, srcds-internal state. Some may live outside the overlay and need explicit handling. - **The build-time-idmap one-line change might miss something**: the sandbox helper hard-codes `id -u left4me`; that becomes `id -u l4d2-web`. But if any other tooling (deploy scripts, doc commands) references `left4me`, those need updating too. - **ckn-bw migration**: the user/group/file changes also need to land in ckn-bw's `bundles/left4me/items.py` (`users`, `groups`, `directories`, `files`). Cross-repo coordination. ## Pointers - **Current users**: ckn-bw `bundles/left4me/items.py:42-58` (groups + users dicts). - **Existing sudoers**: `deploy/files/etc/sudoers.d/left4me`. - **Systemd unit User= directives**: emitted from ckn-bw `bundles/left4me/metadata.py` systemd_units reactor. Search for `User=left4me`. - **Web env files**: `/etc/left4me/{host,web}.env` templated from ckn-bw `bundles/left4me/files/etc/left4me/{host,web}.env.mako`. - **Database location and mode**: ckn-bw `bundles/left4me/items.py` has the chmod near the steamcmd/alembic actions (the "0640" block). - **Build-time-idmap helper** that needs the one-line target-uid change: `deploy/files/usr/local/libexec/left4me/left4me-script-sandbox`, the `LEFT4ME_UID=$(id -u left4me)` line. - **Admin CLI**: `deploy/files/usr/local/sbin/left4me`. Related design docs: - `docs/superpowers/plans/2026-05-15-build-time-idmap.md` — flagged this as out-of-scope future direction. - `docs/superpowers/specs/2026-05-15-deploy-dir-rethink-design.md` — adjacent open questions about `deploy/` layout. - `docs/superpowers/specs/2026-05-15-build-overlay-unit-design.md` — template-unit refactor for the script sandbox. Both that refactor and this one are orthogonal; either can land first. If both are on the table, doing the unit refactor first means the user-split change touches a clean unit file instead of a bash helper. ## What's NOT in scope - Per-instance gameserver uids (one uid per server). Marginal gain for a single-host deploy. - Splitting the web app process (Flask + gunicorn + job worker) into separate uids. They run in the same Python process; would require a worker-as-subprocess redesign. - Replacing systemd-managed users with PrivateUsers=true (userns mapping). Different mechanism; doesn't replace POSIX uid separation for filesystem access. - Hardening the existing 2-user setup further (seccomp tightening, capability drops, etc.) — out of scope for the *split* decision; could happen in either configuration. ## Decision criteria Do this if: - L4D2 engine RCE potential keeps you up at night. - The web app handles sensitive operations (admin auth, payments, multi-tenancy). - You want to harden as a matter of hygiene even without a specific threat. Skip this if: - Solo-operator infra on a personal VPS, internal-only. - The compromise scenarios above are unlikely or low-impact. - You'd rather spend the day on user-facing features. The current 2-user state is correct and tested. This refactor is upgrade-not-fix.