left4me/docs/superpowers/plans/2026-05-14-overlay-idmap.md
mwiegand 8971b23617
refactor(sandbox): collapse l4d2-sandbox user into left4me
The hardening refactor that just landed closes the same-uid attack
surface (FS view, ptrace, /proc visibility, signals) for the web +
gameserver units via systemd directives plus system-wide
kernel.yama.ptrace_scope=2. Keeping the script-sandbox on a separate
uid was the inconsistent half-step — defense-in-depth only, with
build-time-idmap complexity attached. One principle wins: harden
once, share the uid.

scripts/libexec/left4me-script-sandbox: drop the idmap block (uid
lookups, STAGING setup, cleanup_staging trap, mount --bind
--map-users), switch User=/Group= to left4me, point BindPaths at
\$OVERLAY_DIR directly. Header comment updated to reflect
hardening-not-uid as the same-uid defense. nsenter self-wrap kept —
it's about mount-namespace escape, not uid.

Tests + comments + companion docs updated. Build-time-idmap and
overlay-idmap plans marked SUPERSEDED; user-uid-split spec revised
to "1 user is correct"; one-line update notes on the hardening
specs and the build-overlay-unit-design.

Companion ckn-bw commit removes the l4d2-sandbox user + group and
tightens /var/lib/left4me from 0711 → 0755 (the traverse-only mode
was specifically for the sandbox uid).
2026-05-15 15:50:57 +02:00

16 KiB

Idmapped lowerdirs for left4me kernel-overlayfs

SUPERSEDED 2026-05-15 by the uid-collapse refactor (2026-05-15-uid-collapse.md). With l4d2-sandbox collapsed into left4me, all overlay content is uniformly left4me-owned end-to-end and no idmap is needed at mount time either. Kept for design-evolution context.

Context

Kernel-overlayfs copy-up preserves the lower-layer file's owner and mode in the upperdir. Script overlays today are built by left4me-script-sandbox running as uid l4d2-sandbox, and the helper finalizes them as l4d2-sandbox:l4d2-sandbox 0755. When the L4D2 server (uid left4me) tries to write into a directory that exists only in the lower layer — e.g. SourceMod's addons/sourcemod/logs/ for log rotation — copy-up succeeds but the result is l4d2-sandbox-owned, so the write fails EACCES. Workshop overlays are unaffected because the web app (uid left4me) builds them as left4me-owned with symlinks into a left4me- owned cache.

We considered four fixes (chown-flip on every rebuild, shared group, collapse sandbox uid into left4me, idmapped lowerdir bind mounts). The user chose the idmap path: disk state stays untouched, the mount stack remaps l4d2-sandbox → left4me at mount time, kernel-overlayfs sees a left4me-owned lower layer, and copy-up creates upperdir entries owned by left4me naturally. No ownership flipping, no shared group, no security regression.

Outcome: sm_cvar writes succeed, SM logs land in runtime/<n>/upper/..., and any future "writes into a sandbox-built lower layer" works without left4me-specific plumbing.

Environment confirmed

Test server: Debian Trixie, kernel 6.12.86+deb13-amd64, util-linux supports mount --map-users <on_disk>:<in_mount>:<count>. Idmapped lowerdirs for overlayfs landed mainline in 6.6, so 6.12 is fine. Verified end-to-end on /var/lib/left4me/ (ext4) in a temp dir on 2026-05-14:

  1. Source dir owned l4d2-sandbox:l4d2-sandbox (uid 981).
  2. mount --bind --map-users=981:980:1 --map-groups=981:980:1 src dstdst view shows uid 980 (left4me).
  3. Overlay mount with the idmapped path as lowerdir= — merged view also shows uid 980.
  4. sudo -u left4me touch merged/addons/sourcemod/logs/L_test.log — succeeds. sudo -u left4me bash -c "echo x >> merged/file-from-sandbox.txt" — succeeds (copy-up of existing file).
  5. upper/ after writes is entirely left4me-owned (uid 980).

Caveat surfaced during testing: --map-users direction is on-disk uid first, not "inner-namespace uid". The util-linux man page calls it <inner>:<outer>:<count> but <inner> means "the filesystem's native view" (on disk) and <outer> means "what the mount exposes outward". Easy to get wrong; do not trust the man page wording.

Approach

The privileged mount helper grows one step before the overlay mount: for each lowerdir whose owning uid is l4d2-sandbox, create an idmapped bind mount at runtime/<n>/idmap/<basename> that remaps that uid to left4me. Use the idmapped paths (instead of raw paths) in the lowerdir= string passed to mount -t overlay. On umount, tear the idmap binds down after the overlay itself is unmounted.

Lowerdirs already owned by left4me (workshop builds, installation/, caches) bypass the idmap step and are used as-is, so workshop overlays keep working without behavior change.

Execution shape

Implementation will be driven by superpowers:subagent-driven-development: fresh implementer subagent per task, followed by a spec-compliance reviewer then a code-quality reviewer. The project's AGENTS.md forbids git worktrees, so all work happens in the live tree. Commits land directly on master per the user-confirmed project pattern.

Tasks ordered for review-friendly progression. Each task is independently committable; the deploy/verify step at the end exercises the whole chain on the real test server.

Task 1 — Idmap bind mounts in left4me-overlay

Edit deploy/files/usr/local/libexec/left4me/left4me-overlay and l4d2host/tests/test_overlay_helper.py together (TDD: write failing PRINT_ONLY-mode tests first, then make them pass).

Behavior to add:

  • Resolve l4d2_sandbox_uid and left4me_uid (and gids) via pwd.getpwnam / grp.getgrnam. Hard fail with a clear message if either is missing.
  • On mount <name>: before constructing the lowerdir= string, for each resolved lowerdir, stat it; if the top-level dir's st_uid equals l4d2_sandbox_uid, create runtime/<n>/idmap/<basename> (mode 0700, root-owned), and if it's not already a mountpoint, exec mount --bind --map-users=<l4d2_sandbox_uid>:<left4me_uid>:1 --map-groups=<l4d2_sandbox_gid>:<left4me_gid>:1 <src> <target>. Use numeric uids/gids in the argv. Substitute the idmap path into the lowerdir= colon string in place of the original path.
  • On umount <name>: after the existing umount of merged, iterate runtime/<n>/idmap/*, umount each that is a mountpoint, then shutil.rmtree(runtime/<n>/idmap, ignore_errors=True). Idempotent.
  • PRINT_ONLY mode emits the bind-mount argv (one line per bind) before the overlay-mount argv, same shell-quoting style.

Tests to add to test_overlay_helper.py (reuse PRINT_ONLY harness):

  • test_mount_idmaps_sandbox_owned_lowerdir — tmp lower owned by faked-sandbox uid, assert helper emits mount --bind --map-users=... argv and the overlay lowerdir= references the idmap path.
  • test_mount_skips_idmap_for_left4me_owned_lowerdir — assert no bind argv, raw path in lowerdir=.
  • test_umount_unwinds_idmap_binds — pre-seed an idmap subdir as a mountpoint sentinel; assert the umount sequence in PRINT_ONLY includes the bind teardown after the overlay umount.

Uid lookup in tests: monkeypatch pwd.getpwnam to return synthetic uids matching what the test's chown set up. (No root required.)

Task 2 — Deploy-artifact regression test

Edit deploy/tests/test_deploy_artifacts.py. Add a single test that opens deploy/files/usr/local/libexec/left4me/left4me-overlay and asserts the strings --map-users and runtime/ followed by idmap (or similar identifying marker) are present. Cheap guard against silent regression of the deploy artifact.

Task 3 — Deploy README mirror note

Edit deploy/README.md. Add one line under the existing ckn-bw mirror notes flagging that the helper file change must be picked up by bundles/left4me/ in ckn-bw (no new group, user, or unit needed).

Task 4 — Persist the plan in-repo

Per AGENTS.md: "the persisted artifact must end up under docs/superpowers/ and be committed." Copy this scratch plan to docs/superpowers/plans/2026-05-14-overlay-idmap.md and commit it. Do this as a separate commit from Task 1 so the plan lands before the implementation.

Task 5 — Deploy and verify on left4.me

Out-of-band, after the code tasks land:

  1. ckn-bw apply (or scp the helper into place on the test server) to get the new helper deployed.
  2. Stop server 2: sudo systemctl stop left4me-server@2.
  3. Clear the stale l4d2-sandbox-owned upperdir SM dirs: sudo rm -rf /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/{logs,data}.
  4. Start server 2: sudo systemctl start left4me-server@2.
  5. Confirm journalctl -u left4me-server@2 -o cat -n 50 shows the new mount --bind --map-users=... line.
  6. RCON sm_cvar nb_update_frequency 0.0333. Expect no Platform returned error: "Permission denied" log line.
  7. sudo ls -ln /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/logs/. Expect uid 980 (left4me).
  8. Restart again to confirm idempotency: idmap binds set up fresh, no leftover mounts from prior start.

Files to modify

1. deploy/files/usr/local/libexec/left4me/left4me-overlay

Single privileged code path; everything else flows from here.

Add a helper function to decide whether a path needs idmapping. Stat the directory; if its st_uid matches the resolved l4d2-sandbox uid, return the idmapped path under runtime/<n>/idmap/; otherwise return the input path unchanged.

In cmd_mount, before constructing lowerdir=:

  1. os.makedirs(runtime_dir / "idmap", exist_ok=True) (root-owned, mode 0o700; only the helper writes here).
  2. Resolve l4d2_sandbox_uid = pwd.getpwnam("l4d2-sandbox").pw_uid and left4me_uid = pwd.getpwnam("left4me").pw_uid. Cache. Fail fast with a clear message if either user is missing.
  3. For each lowerdir in the resolved list, compute idmapped_path(lowerdir). If remapping is required:
    • Create the target directory under runtime/<n>/idmap/<basename> if missing.
    • Skip the bind if it's already mounted there (os.path.ismount).
    • Otherwise exec mount --bind --map-users=<l4d2_sandbox_uid>:<left4me_uid>:1 --map-groups=<l4d2_sandbox_gid>:<left4me_gid>:1 <src> <target>. Direction note: first arg is the on-disk uid, second arg is the uid the mount exposes. We verified empirically that this is what the kernel honors despite ambiguous man-page wording.
  4. Pass the (possibly idmapped) paths into the lowerdir= colon string in the same order.

In cmd_umount, after the existing overlay umount:

  • For each subdirectory under runtime/<n>/idmap/, if it's a mountpoint, umount it.
  • shutil.rmtree(runtime_dir / "idmap", ignore_errors=True) after all binds are gone.
  • Idempotent: re-running after the dir is gone is a no-op.

PRINT_ONLY mode: emit the bind-mount argv before the overlay-mount argv, on separate lines, so test assertions can match. Same shell-quoting.

Allowlist: no change needed — the idmap binds land under runtime/, which is already write-permitted for the helper.

2. l4d2host/tests/test_overlay_helper.py

Add tests using the existing PRINT_ONLY harness:

  • test_mount_idmaps_sandbox_owned_lowerdir: create a tmp lowerdir, chown it to a fake-l4d2-sandbox (use monkeypatch on the uid lookup if running unprivileged), run helper in PRINT_ONLY, assert a mount --bind --map-users=... line appears and the lowerdir= string references the idmap path.
  • test_mount_skips_idmap_for_left4me_owned_lowerdir: tmp lowerdir owned by the test user, assert no bind-mount argv emitted and lowerdir= references the raw path.
  • test_umount_unwinds_idmap_binds: pre-create runtime/<n>/idmap/foo as a mountpoint sentinel, assert the PRINT_ONLY umount sequence includes the bind-mount teardown before the overlay umount? Actually overlay-first, then binds — match the helper order.

Reuse the existing LEFT4ME_OVERLAY_PRINT_ONLY=1 plumbing rather than inventing a new mode.

3. deploy/tests/test_deploy_artifacts.py

Add a grep-style assertion that the helper file contains the strings --map-users and idmap so the deploy artifact can't silently regress.

4. deploy/README.md

One-line mirror note: ckn-bw's bundles/left4me/ ships the helper verbatim, so no new bundle-side change is needed beyond updating the file. No new group, no new user, no new systemd unit. Flag this explicitly so the next deploy-to-prod step is "rebuild the helper file in ckn-bw, bw apply ovh.left4me".

Migration

No on-disk schema change. Existing overlays keep their current ownership (l4d2-sandbox-owned for script builds, left4me-owned for workshop builds). The mount helper picks the right path per lowerdir at next systemctl start <instance>.

Already-running instances on the test server pick up the change after a service restart. Live SourceMod sessions whose addons/sourcemod/logs/ copy-up is already broken in upperdir need a fix too: the upperdir entries are l4d2-sandbox:l4d2-sandbox 0755 from the previous broken copy-up. The helper doesn't touch upper/ on mount, so those stale entries persist.

Two safe migration options:

  1. Manual: on the test server, stop server 2, rm -rf runtime/2/upper/left4dead2/addons/sourcemod/logs runtime/2/upper/left4dead2/addons/sourcemod/data, start it again. Copy-up will redo with idmapped lower → left4me-owned upper.
  2. Automatic: have start_instance proactively delete known-SM writable dirs from upper/ if their uid is l4d2-sandbox. Out of scope for this change unless we hit it again.

Recommend option 1 — one-shot, no code change.

Verification

End-to-end test on left4.me:

  1. bw apply ovh.left4me (or scp the updated helper into place).
  2. Stop server 2: sudo systemctl stop left4me-server@2.
  3. Clean the stale broken SM upperdir: sudo rm -rf /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/{logs,data}.
  4. Start server 2: sudo systemctl start left4me-server@2.
  5. From inside left4me-overlay mount argv (check journalctl -u left4me-server@2 -o cat -n 50), confirm mount --bind --map-users=... was executed.
  6. RCON to server 2, sm_cvar nb_update_frequency 0.0333. Expect no Platform returned error: "Permission denied" log line.
  7. ls -ln /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/logs/. Expect files owned by left4me's numeric uid.
  8. sudo umount /var/lib/left4me/runtime/2/merged should still work; then verify runtime/2/idmap/ is cleaned up by ExecStopPost. Restart and confirm idempotent (no leftover binds).

Local tests:

  • pytest l4d2host/tests/test_overlay_helper.py -q
  • pytest deploy/tests/test_deploy_artifacts.py -q

Risks and edge cases

  • Workshop overlay misidentification: a workshop overlay with a l4d2-sandbox-owned subdir somehow (e.g. partial migration) would get idmapped despite containing left4me-owned files. Files with other uids through an idmap appear as the overflow uid (nobody/65534), which would break reads. Mitigation: check ownership of the top-level overlay directory as the trigger, not file-by-file. If the top is sandbox-owned, trust the whole tree; if the top is left4me-owned, no idmap. This matches what each builder actually produces.
  • installation/ and caches: always left4me-owned, never idmapped.
  • Symlinks inside script overlays: idmap operates at the mount level, not per-inode. Symlink ownership translates the same as files. Targets inside the overlay resolve through the same mount. Targets outside (none in script overlays today; workshop ones don't take this code path) would not be affected.
  • Mount namespace: the helper runs in PID 1's mount namespace via the unit's ExecStartPre=+nsenter .... Bind mounts created there persist until the matching ExecStopPost umount, exactly like the overlay mount itself.
  • Crash mid-build: idmap binds are created only at mount time, not at build time. A crashed build leaves no orphan mounts.
  • Crash mid-start (ExecStartPre fails between bind and overlay mount): systemd's Restart=on-failure re-invokes ExecStartPre. The helper checks os.path.ismount on each idmap target and skips already-mounted ones. Idempotent.
  • runtime/<n>/idmap/ cleanup on _purge_instance: existing shutil.rmtree(runtime_dir) after disable_service already triggers the helper's umount sequence, which removes the idmap dir. No new code.
  • util-linux flag form: prefer --map-users <inner>:<outer>:<count> and --map-groups (numeric uids/gids resolved by the helper) over the X-mount.idmap= mount-option syntax — clearer and easier to log.

Out of scope

  • Web app uid split (l4d2-web separate from left4me) — orthogonal, rejected for this change.
  • Gameserver uid split (separating the gameserver-runtime uid from left4me) — planned for a later session. One forward-compat coupling: the helper looks up pwd.getpwnam("left4me") as the in-mount target uid. When the gameserver moves to its own user (e.g. l4d2-game), change that one string. Everything else (script-sandbox uid, workshop builder uid, file-tree endpoint, idmap cleanup) is uid-agnostic.
  • Replacing l4d2-sandbox with a different uid scheme — kept as defense in depth.
  • Spec doc updates (including the bubblewrap → systemd-run wording correction in the existing script-overlay spec): dropped per user decision; this change ships plan-only.