chore(deploy): cleanup left4me-web hardening + docs for kernel overlayfs

Drop MountFlags=shared (the assumption that it propagated fuse mounts
to host was incorrect on systemd 257 with ProtectSystem+ReadWritePaths).
Restore PrivateTmp=true (was dropped in 593611e for fuse propagation
that did not work). Rewrite the comment block to describe the new
model: mounts go through the left4me-overlay helper which nsenters
into PID 1's mount namespace, so the unit's mount-ns layout is no
longer load-bearing.

Update the three user-facing READMEs (root, l4d2host, deploy) to drop
fuse-overlayfs / fusermount3 prereqs and call out the kernel overlayfs
mount path through the privileged helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 12:29:49 +02:00
parent 172e574a00
commit 9985ecc56c
No known key found for this signature in database
5 changed files with 21 additions and 15 deletions

View file

@ -56,7 +56,7 @@ See `deploy/README.md` for the Linux test deployment contract, including the run
- Typer, PyYAML, pytest
- Flask, SQLAlchemy, Alembic
- HTMX (vendored locally), custom CSS, SSE
- systemd user units, fuse-overlayfs, steamcmd
- systemd units, kernel overlayfs (mounted via the `left4me-overlay` privileged helper), steamcmd
## Recommended Implementation Order

View file

@ -19,7 +19,7 @@ The deployment uses these paths:
- `/var/lib/left4me/runtime`: per-instance runtime mount directories.
- `/var/lib/left4me/tmp`: temporary files used by deployment/runtime operations.
- `/usr/local/lib/systemd/system`: global systemd unit files, including `left4me-server@.service`.
- `/usr/local/libexec/left4me`: privileged helper commands, including `left4me-systemctl` and `left4me-journalctl`.
- `/usr/local/libexec/left4me`: privileged helper commands, including `left4me-systemctl`, `left4me-journalctl`, and `left4me-overlay` (the latter mounts the per-instance kernel overlay in PID 1's mount namespace via `nsenter`).
- `/etc/sudoers.d/left4me`: sudoers rules allowing the web/runtime commands to call the helpers non-interactively.
Static units are generated for `/var/lib/left4me`. If `LEFT4ME_ROOT` changes, regenerate and reinstall the unit files instead of reusing the existing static units.

View file

@ -15,17 +15,17 @@ EnvironmentFile=/etc/left4me/web.env
ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 1 --threads 32 --bind 0.0.0.0:8000 'l4d2web.app:create_app()'
Restart=on-failure
RestartSec=3
# NoNewPrivileges intentionally not set: the worker invokes fusermount3
# (setuid-root) and sudo to run the systemctl wrapper.
# ProtectSystem=full + ReadWritePaths implicitly give this unit a
# private mount namespace. MountFlags=shared makes its mount events
# propagate back to the host so per-instance fuse-overlayfs mounts are
# visible to the gameserver units (which inherit host mounts at their
# own unshare time). Without it, the per-instance mount only exists
# inside the worker's namespace and the gameserver units fail CHDIR.
# NoNewPrivileges intentionally not set: the worker invokes sudo to run
# the left4me-systemctl, left4me-journalctl, and left4me-overlay
# privileged helpers, all setuid via sudo.
# ProtectSystem=full + ReadWritePaths implicitly give this unit a private
# mount namespace, but mount visibility no longer depends on it: overlay
# mounts are performed by the left4me-overlay helper, which nsenters into
# PID 1's mount namespace, so the resulting mount lives in the host
# namespace where the per-instance gameserver units can see it.
ProtectSystem=full
ReadWritePaths=/var/lib/left4me
MountFlags=shared
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -37,11 +37,17 @@ def test_web_unit_contains_required_runtime_contract():
assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in unit
assert "--workers 1" in unit
assert "--threads 32" in unit
# NoNewPrivileges must remain unset because sudo (used by the overlay,
# systemctl and journalctl helpers) is setuid.
assert "NoNewPrivileges=true" not in unit
assert "PrivateTmp=true" not in unit
# Restored now that fuse-overlayfs propagation is no longer the mechanism.
assert "PrivateTmp=true" in unit
assert "ProtectSystem=full" in unit
assert "ReadWritePaths=/var/lib/left4me" in unit
assert "MountFlags=shared" in unit
# Mounts now happen in PID 1's namespace via the left4me-overlay helper,
# so MountFlags propagation is irrelevant — and the previous assumption
# that MountFlags=shared made it work was incorrect.
assert "MountFlags=" not in unit
def test_server_unit_contains_required_runtime_contract():

View file

@ -49,7 +49,7 @@ Validated on Debian 13 during the `ckn@10.0.4.128` smoke test:
- Python 3.12+ with virtualenv/pip tooling for installing `l4d2host`.
- `steamcmd` available on `PATH` and able to self-update as the runtime user.
- 32-bit compatibility libraries for SteamCMD on amd64 Debian: `libc6-i386`, `lib32gcc-s1`, `lib32stdc++6`.
- `fuse-overlayfs` and `fusermount3` for per-instance overlay mounts.
- Kernel overlayfs (`mount -t overlay`); mount/umount go through the `left4me-overlay` privileged helper, which `nsenter`s into PID 1's mount namespace.
- `systemctl --user` and `journalctl --user` available for the runtime user.
- User lingering enabled when services must survive SSH sessions: `sudo loginctl enable-linger <user>`.
- `/var/lib/left4me` created and writable by the runtime user, unless `LEFT4ME_ROOT` is set to another deployment-managed root.
@ -61,7 +61,7 @@ sudo apt-get update
sudo apt-get install -y \
python3 python3-venv python3-pip \
curl ca-certificates tar gzip \
fuse-overlayfs fuse3 \
util-linux \
libc6-i386 lib32gcc-s1 lib32stdc++6
sudo mkdir -p /opt/steamcmd /var/lib/left4me/{installation,overlays,instances,runtime,tmp}