left4me/l4d2host
mwiegand 5eac51a93e
fix(deploy): wrap overlay helper with nsenter so it doesn't pin the unit's mount namespace
systemd's `+` Exec prefix removes sandbox/credentials but does NOT
detach from the unit's per-service mount namespace (created by
PrivateTmp/Protect*). The Python interpreter for the helper was
launched inside that namespace, and even though the helper internally
nsenter'd into PID 1 for the umount syscall, the calling Python
process itself never left the unit's namespace. Its existence pinned
the namespace alive, which kept the slave mount tree alive, which
made PID 1's umount return EBUSY for the entire duration of the
helper's run. The mount became unmountable the moment the helper
exited — empirically verified by polling /proc/*/ns/mnt during stop:
the only PID holding the dying namespace was the helper itself.

Wrap both ExecStartPre and ExecStopPost with `/usr/bin/nsenter
--mount=/proc/1/ns/mnt --` so the helper Python interpreter runs in
PID 1's mount namespace from the start. With the helper out of the
unit's namespace, umount succeeds first try once the cgroup empties.
Reset went from ~25 s with retry/lazy-fallback workarounds to ~0.5 s
clean.

Knock-on cleanups:
- Helper drops internal nsenter for the syscalls (already in PID 1's
  namespace), and drops the eager-retry loop + lazy-umount fallback +
  inner work_inner retry (no race left to ride out).
- Revert TimeoutStopSec=60s back to 15s.
- Tests updated to expect the new argv shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:13:59 +02:00
..
templates feat(deploy): add production-like test deployment 2026-05-06 19:30:10 +02:00
tests fix(deploy): wrap overlay helper with nsenter so it doesn't pin the unit's mount namespace 2026-05-09 15:13:59 +02:00
__init__.py chore(l4d2): flatten component layout 2026-05-05 23:47:06 +02:00
cli.py feat: server Reset action — wipe runtime, keep DB row 2026-05-08 18:10:32 +02:00
instances.py refactor(l4d2-host): unmount via ExecStopPost — single code path mirroring mount 2026-05-09 13:09:52 +02:00
logging.py security: harden boundary inputs and production defaults 2026-05-07 00:53:33 +02:00
logs.py feat(deploy): add production-like test deployment 2026-05-06 19:30:10 +02:00
paths.py security: harden boundary inputs and production defaults 2026-05-07 00:53:33 +02:00
process.py fix(host): enforce flush=True to prevent pipeline block buffering 2026-05-06 20:34:41 +02:00
pyproject.toml fix(deploy): drop deleted l4d2host.fs from pyproject + use nproc --all 2026-05-09 13:11:19 +02:00
README.md chore(deploy): cleanup left4me-web hardening + docs for kernel overlayfs 2026-05-08 12:29:49 +02:00
service_control.py feat(l4d2-host): server lifecycle uses systemctl enable --now / disable --now 2026-05-09 12:28:44 +02:00
spec.py feat(l4d2-web): per-overlay server.cfg aliases — expose checkbox + auto-exec 2026-05-09 01:26:31 +02:00
status.py feat(deploy): add production-like test deployment 2026-05-06 19:30:10 +02:00
steam_install.py fix(host): create ~/.steam/sdk32 and sdk64 symlinks during install 2026-05-07 02:11:27 +02:00

l4d2-host-lib

Python host library and CLI for managing L4D2 instances.

CLI

l4d2ctl exposes these write commands in v1:

  • install
  • initialize <name> -f <spec.yaml>
  • start <name>
  • stop <name>
  • delete <name>

It also exposes read commands used by the web app host boundary:

  • status <name> --json
  • logs <name> --lines <n> --follow/--no-follow

Subprocess failures are fail-fast. Raw stderr is written to stderr and the command exits with the same subprocess return code.

Runtime Paths

The host library reads LEFT4ME_ROOT from the environment. It defaults to /var/lib/left4me:

  • ${LEFT4ME_ROOT}/installation
  • ${LEFT4ME_ROOT}/overlays/<overlay-ref>
  • ${LEFT4ME_ROOT}/instances/<name>
  • ${LEFT4ME_ROOT}/runtime/<name>/{upper,work,merged}
  • ${LEFT4ME_ROOT}/tmp

Overlay specs use relative refs below ${LEFT4ME_ROOT}/overlays, for example standard, competitive/base, or users/42/custom. Absolute refs, .., empty path components, and symlink escapes outside the overlays root are rejected.

systemd Integration

l4d2ctl start, stop, status, and logs use non-interactive sudo helper commands:

  • sudo -n /usr/local/libexec/left4me/left4me-systemctl ...
  • sudo -n /usr/local/libexec/left4me/left4me-journalctl ...

Deployment/config management owns the global left4me-server@.service unit under /usr/local/lib/systemd/system. The host library does not install or manage the unit file directly.

Host Prerequisites

The host library intentionally does not install or preflight runtime dependencies. The target host must provide them before running l4d2ctl.

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.
  • Kernel overlayfs (mount -t overlay); mount/umount go through the left4me-overlay privileged helper, which nsenters 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.

Example Debian setup:

sudo apt-get update
sudo apt-get install -y \
  python3 python3-venv python3-pip \
  curl ca-certificates tar gzip \
  util-linux \
  libc6-i386 lib32gcc-s1 lib32stdc++6

sudo mkdir -p /opt/steamcmd /var/lib/left4me/{installation,overlays,instances,runtime,tmp}
sudo chown -R "$USER:$USER" /opt/steamcmd /var/lib/left4me
sudo loginctl enable-linger "$USER"

SteamCMD should be installed so the runtime user can update it. If installing from Valve's tarball, avoid symlinking steamcmd.sh directly because it derives its install root from $0. Use a wrapper instead:

curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz -o /tmp/steamcmd_linux.tar.gz
tar -xzf /tmp/steamcmd_linux.tar.gz -C /opt/steamcmd
sudo tee /usr/local/bin/steamcmd >/dev/null <<'EOF'
#!/bin/sh
exec /opt/steamcmd/steamcmd.sh "$@"
EOF
sudo chmod 755 /usr/local/bin/steamcmd
chmod 755 /opt/steamcmd/steamcmd.sh /opt/steamcmd/linux32/steamcmd
steamcmd +quit

uv is optional deployment tooling. Debian 13 did not provide an uv package during the smoke test, so install it explicitly if you want to use it for faster virtualenv/dependency setup. l4d2ctl does not require uv at runtime.

Host-Local Read APIs

These Python read APIs back the CLI read commands and remain available for host-local callers:

  • get_instance_status(name)
  • stream_instance_logs(name, lines=200, follow=True)