Persist the implementation plan for adding idmapped bind mounts to left4me-overlay so that overlay copy-up from l4d2-sandbox-owned lower layers (script-built overlays) produces left4me-owned upperdir entries the gameserver can write. Mechanism verified end-to-end on ovh.left4me in a temp dir on 2026-05-14.
16 KiB
Idmapped lowerdirs for left4me kernel-overlayfs
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:
- Source dir owned
l4d2-sandbox:l4d2-sandbox(uid 981). mount --bind --map-users=981:980:1 --map-groups=981:980:1 src dst—dstview shows uid 980 (left4me).- Overlay mount with the idmapped path as
lowerdir=— merged view also shows uid 980. 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).upper/after writes is entirelyleft4me-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_uidandleft4me_uid(and gids) viapwd.getpwnam/grp.getgrnam. Hard fail with a clear message if either is missing. - On
mount <name>: before constructing thelowerdir=string, for each resolved lowerdir, stat it; if the top-level dir'sst_uidequalsl4d2_sandbox_uid, createruntime/<n>/idmap/<basename>(mode0700, root-owned), and if it's not already a mountpoint, execmount --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 thelowerdir=colon string in place of the original path. - On
umount <name>: after the existingumountofmerged, iterateruntime/<n>/idmap/*,umounteach that is a mountpoint, thenshutil.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 emitsmount --bind --map-users=...argv and the overlaylowerdir=references the idmap path.test_mount_skips_idmap_for_left4me_owned_lowerdir— assert no bind argv, raw path inlowerdir=.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:
- ckn-bw apply (or scp the helper into place on the test server) to get the new helper deployed.
- Stop server 2:
sudo systemctl stop left4me-server@2. - Clear the stale
l4d2-sandbox-owned upperdir SM dirs:sudo rm -rf /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/{logs,data}. - Start server 2:
sudo systemctl start left4me-server@2. - Confirm
journalctl -u left4me-server@2 -o cat -n 50shows the newmount --bind --map-users=...line. - RCON
sm_cvar nb_update_frequency 0.0333. Expect noPlatform returned error: "Permission denied"log line. sudo ls -ln /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/logs/. Expect uid 980 (left4me).- 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=:
os.makedirs(runtime_dir / "idmap", exist_ok=True)(root-owned, mode0o700; only the helper writes here).- Resolve
l4d2_sandbox_uid = pwd.getpwnam("l4d2-sandbox").pw_uidandleft4me_uid = pwd.getpwnam("left4me").pw_uid. Cache. Fail fast with a clear message if either user is missing. - For each
lowerdirin the resolved list, computeidmapped_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.
- Create the target directory under
- 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,umountit. 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,chownit to a fake-l4d2-sandbox(usemonkeypatchon the uid lookup if running unprivileged), run helper in PRINT_ONLY, assert amount --bind --map-users=...line appears and thelowerdir=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 andlowerdir=references the raw path.test_umount_unwinds_idmap_binds: pre-createruntime/<n>/idmap/fooas 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:
- 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. - Automatic: have
start_instanceproactively delete known-SM writable dirs fromupper/if their uid isl4d2-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:
bw apply ovh.left4me(or scp the updated helper into place).- Stop server 2:
sudo systemctl stop left4me-server@2. - Clean the stale broken SM upperdir:
sudo rm -rf /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/{logs,data}. - Start server 2:
sudo systemctl start left4me-server@2. - From inside
left4me-overlay mountargv (checkjournalctl -u left4me-server@2 -o cat -n 50), confirmmount --bind --map-users=...was executed. - RCON to server 2,
sm_cvar nb_update_frequency 0.0333. Expect noPlatform returned error: "Permission denied"log line. ls -ln /var/lib/left4me/runtime/2/upper/left4dead2/addons/sourcemod/logs/. Expect files owned byleft4me's numeric uid.sudo umount /var/lib/left4me/runtime/2/mergedshould still work; then verifyruntime/2/idmap/is cleaned up byExecStopPost. Restart and confirm idempotent (no leftover binds).
Local tests:
pytest l4d2host/tests/test_overlay_helper.py -qpytest 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 containingleft4me-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: alwaysleft4me-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 matchingExecStopPostumount, exactly like the overlay mount itself. - Crash mid-build: idmap binds are created only at
mounttime, 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-failurere-invokes ExecStartPre. The helper checksos.path.ismounton each idmap target and skips already-mounted ones. Idempotent. runtime/<n>/idmap/cleanup on_purge_instance: existingshutil.rmtree(runtime_dir)afterdisable_servicealready 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 theX-mount.idmap=mount-option syntax — clearer and easier to log.
Out of scope
- Web app uid split (
l4d2-webseparate fromleft4me) — 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 uppwd.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-sandboxwith 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.