The 1/2/3-user question is answered: stay at 2 (left4me + l4d2-sandbox). The defenses that motivated a 3-user split (cross-uid ptrace, cross-server contamination, web-side reach into gameserver state, DB/env exposure to srcds) are closed by the systemd hardening composition: PrivateUsers + PrivatePIDs + TemporaryFileSystem + SystemCallFilter=~@debug + empty CapabilityBoundingSet. The residual filesystem-ACL surface (mode 0640 root:left4me on DB and web.env) is noted as a separate concern — covered for the current deployment shape, revisit if shape changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
How many system users should left4me have? — 1, 2, or 3
Status: SUPERSEDED 2026-05-15 by the hardening refactor.
The original question — should left4me have 1, 2, or 3 system users — is
now answered: 2 users (current state) is correct. The
defenses that motivated a 3-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=trueblocks cross-uid ptrace at the kernel level.PrivatePIDs=truehides peer processes even when uids match.TemporaryFileSystem=+ minimal binds hide the DB and web.env from srcds entirely.SystemCallFilter=~@debug+ emptyCapabilityBoundingSet=block ptrace at the syscall layer.
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 uids, 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:
-
Build-time idmap (2026-05-15, this session): when we considered eliminating the
l4d2-sandboxuid entirely and just running the script sandbox asleft4me, we noted "sandbox escape could see web.env / DB / running gameservers" — i.e. uid separation is the load-bearing defense layer. We keptl4d2-sandbox. Plan atdocs/superpowers/plans/2026-05-15-build-time-idmap.mdflagged this as an out-of-scope future direction. -
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 fromleft4me) — planned for a later session." No design captured. -
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 reachleft4me-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
sudothe privileged helpers (script-sandbox, overlay mount, systemctl) per sudoers rules. - Web-app compromise ⇒ can replace
/opt/left4me/src/Python code (it'sleft4me-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
left4menatively). 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-gameofl4d2-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/<id>/)
- Today:
l4d2-web-owned (after build-time-idmap migration). l4d2-gameneeds 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: bothl4d2-webandl4d2-gamemembers. Overlays chgrp'd tol4d2-overlay, mode2775(setgid so new entries inherit). Sandbox idmap maps to whichever primary uid (probablyl4d2-websince the web app creates the overlay dirs).l4d2-gamereads via group access. Copy-up byl4d2-gameproduces files owned byl4d2-game(its own primary uid) — but since they end up in upper, that's fine. - World-readable: overlays mode
0755;l4d2-gamereads 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/<n>/upper/)
- Today:
left4me-owned, written by both the web app (server.cfg staging instart_instance) and the gameserver (copy-up at runtime). - With 3-user:
l4d2-gamewrites via copy-up;l4d2-webwrites server.cfg staging.
Two paths:
- Shared group
l4d2-runtime(could be the same asl4d2-overlayor distinct). Upper-layer dir mode2775, setgid'd, both uids write via group. - Move server.cfg staging out of
start_instanceinto the systemd unit itself:ExecStartPre=+...does the cp as root and chowns tol4d2-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 writeinstances/<n>/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-webshould write.l4d2-gameshouldn't need DB access at all (gameservers operate frominstance.env+ overlay state; DB is a web-side concern). - Migration: chown to
root:l4d2-web, mode unchanged. Game uid is not in thel4d2-webgroup, so it can't read the DB. Clean.
Env files (/etc/left4me/{host.env,web.env})
- Today:
root:left4me 0640.host.envfor L4D2 server config (used by systemd units),web.envfor the Flask app (secret_key, DB url, etc.). - With 3-user:
web.env→root:l4d2-web 0640. Only the web app reads it.host.envis more nuanced — used by gameserver units (left4me-server@.servicesources it). Today both web and game read it (web useshost.envfor some operations too). Probably keep readable by both:rootowner, group membership for both uids. Or duplicate the few values that matter.
Code (/opt/left4me/src/)
- Today:
left4me-owned (forpip install -eeditable installs; createsegg-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_runfrom/var/lib/left4me/installation/(Steam-managed) and the overlay binds — doesn't touch/opt/left4me/src/.
Helpers and sudoers
left4me-overlay: invoked byleft4me-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 fromleft4metol4d2-web.left4me-systemctlandleft4me-journalctl: invoked by the web app. Same — sudoers moves tol4d2-web.- The admin CLI
/usr/local/sbin/left4me(thesudo left4me <flask cmd>wrapper): drops tol4d2-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=<disk_uid>:<sandbox_uid>: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:
-
Create the new users in ckn-bw bundle:
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
left4meuser/group can be kept as an alias or removed entirely. -
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).
-
Update
User=/Group=in systemd units:left4me-web.service:User=l4d2-web Group=l4d2-webleft4me-server@.service:User=l4d2-game Group=l4d2-game- Both: add
SupplementaryGroups=l4d2-overlaywhere needed.
-
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 unitGame uid gets no sudoers grants at all (gameservers don't privesc).
-
Update the script-sandbox idmap target:
- In
deploy/files/usr/local/libexec/left4me/left4me-script-sandbox, changeid -u left4me→id -u l4d2-weband matching groups. One-line change.
- In
-
Update the admin CLI:
/usr/local/sbin/left4medoessudo -u left4me sh -c '. host.env; . web.env; flask …'. Change tosudo -u l4d2-web ….
-
Update steamcmd ownership in ckn-bw:
actions['left4me_install_steamcmd']runs asleft4me; change tol4d2-game./opt/left4me/steamdir →l4d2-game.
-
Add tests that assert the user-split invariant in
deploy/tests/test_deploy_artifacts.py(e.g. sudoers grants to the right uid, systemd unitUser=matches). -
Run the migration on the test server:
- Stop all
left4me-server@*+left4me-web. - Run a one-shot
chownscript (orbw applyif the bundle does the chowns). - Start everything; verify nothing broken.
- Stop all
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
- 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.
- If yes, web vs game split, or also split per-instance
gameserver? Recommendation: single
l4d2-gameuid for all instances. Per-instance uids add a lot of ceremony for a marginal hardening gain. l4d2-overlayshared group: one group or two? Could split intol4d2-overlay-read(game-membership) andl4d2-overlay-write(web-membership), but probably overkill.- Keep the
left4meuser as a no-op alias for compatibility? Existing systemd units, sudoers, etc. all referenceleft4me; migration is easier if we just rename. But a clean break is easier to reason about. Recommendation: clean break, no alias. - Should we collapse to 1 user instead? Captured above. The
defense-in-depth from
l4d2-sandboxis real; recommend keeping it. - Does
host.envneed 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. steamcmdinstall location and ownership. Todaysteamcmdself-updates at runtime asleft4me. With game uid, this needs to run asl4d2-game(or the game uid needs write to/opt/left4me/steam/).
Verification (for the 3-user migration)
After migration on left4.me:
# 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
steamcmddirectly in-process to download workshop items. With a split, this needs to either move to a sudo'd helper (run asl4d2-gamesincesteamcmdis 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 becomesid -u l4d2-web. But if any other tooling (deploy scripts, doc commands) referencesleft4me, 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.pysystemd_units reactor. Search forUser=left4me. - Web env files:
/etc/left4me/{host,web}.envtemplated from ckn-bwbundles/left4me/files/etc/left4me/{host,web}.env.mako. - Database location and mode: ckn-bw
bundles/left4me/items.pyhas 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, theLEFT4ME_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 aboutdeploy/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.