From 9985ecc56c476890daa45900a54fa4137dcc114a Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 12:29:49 +0200 Subject: [PATCH] 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) --- README.md | 2 +- deploy/README.md | 2 +- .../lib/systemd/system/left4me-web.service | 18 +++++++++--------- deploy/tests/test_deploy_artifacts.py | 10 ++++++++-- l4d2host/README.md | 4 ++-- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 22f48a9..67fe5ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/README.md b/deploy/README.md index 6e3fa48..c0e9e76 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-web.service b/deploy/files/usr/local/lib/systemd/system/left4me-web.service index 07d30f1..46b375e 100644 --- a/deploy/files/usr/local/lib/systemd/system/left4me-web.service +++ b/deploy/files/usr/local/lib/systemd/system/left4me-web.service @@ -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 diff --git a/deploy/tests/test_deploy_artifacts.py b/deploy/tests/test_deploy_artifacts.py index 394f84a..2fd7945 100644 --- a/deploy/tests/test_deploy_artifacts.py +++ b/deploy/tests/test_deploy_artifacts.py @@ -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(): diff --git a/l4d2host/README.md b/l4d2host/README.md index 6b699de..ad35116 100644 --- a/l4d2host/README.md +++ b/l4d2host/README.md @@ -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 `. - `/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}