The 2-user split (left4me + l4d2-sandbox) has been inherited as a constraint across multiple recent plans (idmap-on-mount, build-time- idmap, helper consolidation) without ever being designed end-to-end. Three plausible configurations: collapse to 1 user (rejected for security), keep at 2 users (status quo), or split web from game into 3 users for blast-radius limiting on either side. Doc captures the threat-model heuristics, cross-uid file-access plumbing options (shared group vs. world-read), idmap implications, a step-by-step migration sketch for the 3-user variant, and explicit out-of-scope items (per-instance gameserver uids, etc.). Detailed enough that a future session can pick a configuration and execute without re-deriving the design space. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
21 KiB
How many system users should left4me have? — 1, 2, or 3
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.
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.