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

331 lines
16 KiB
Markdown

# Idmapped lowerdirs for left4me kernel-overlayfs
> **SUPERSEDED 2026-05-15** by the uid-collapse refactor
> ([`2026-05-15-uid-collapse.md`](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 dst``dst`
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.