The hardening-extraction subagent (commit just prior) re-introduced
ProcSubset=pid into the server@ drop-in because the design plan's
template had it. The directive had previously been removed from the
live unit by ckn-bw 4339289 — it hides /proc/cpuinfo and breaks
SteamAPI master-server registration, leaving the server in LAN-only
fallback ("LAN servers are restricted to local clients (class C)").
Add a negative assertion in the drop-in test so the regression cannot
sneak back in via a copy-paste edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""Lockdown tests for the curated examples kept under `deploy/files/`.
|
|
|
|
`deploy/` is reference material. The production units are emitted by
|
|
ckn-bw's `systemd_units` reactor in `bundles/left4me/metadata.py`;
|
|
when reactor output drifts intentionally, update these examples to match.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
DEPLOY = ROOT / "deploy"
|
|
|
|
|
|
WEB_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-web.service"
|
|
SERVER_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-server@.service"
|
|
GAME_SLICE = DEPLOY / "files/usr/local/lib/systemd/system/l4d2-game.slice"
|
|
BUILD_SLICE = DEPLOY / "files/usr/local/lib/systemd/system/l4d2-build.slice"
|
|
SYSCTL_CONF = DEPLOY / "files/etc/sysctl.d/99-left4me.conf"
|
|
SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf"
|
|
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
|
|
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
|
|
WEB_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-web.service.d/10-hardening.conf"
|
|
SERVER_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf"
|
|
|
|
|
|
def test_global_unit_files_exist_at_product_level_paths():
|
|
assert WEB_UNIT.is_file()
|
|
assert SERVER_UNIT.is_file()
|
|
|
|
|
|
def test_web_unit_contains_required_runtime_contract():
|
|
unit = WEB_UNIT.read_text()
|
|
|
|
assert "User=left4me" in unit
|
|
assert "Group=left4me" in unit
|
|
assert "WorkingDirectory=/opt/left4me" in unit
|
|
assert "PATH=/var/lib/left4me/.venv/bin:" in unit
|
|
assert "EnvironmentFile=/etc/left4me/host.env" in unit
|
|
assert "EnvironmentFile=/etc/left4me/web.env" in unit
|
|
assert "ExecStart=/var/lib/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 "ReadWritePaths=/var/lib/left4me" 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
|
|
# Hardening directives belong in the drop-in; must not appear in the base unit.
|
|
assert "PrivateTmp=" not in unit
|
|
assert "ProtectSystem=" not in unit
|
|
|
|
|
|
def test_server_unit_contains_required_runtime_contract():
|
|
unit = SERVER_UNIT.read_text()
|
|
|
|
assert "User=left4me" in unit
|
|
assert "Group=left4me" in unit
|
|
assert "EnvironmentFile=/etc/left4me/host.env" in unit
|
|
assert "EnvironmentFile=/var/lib/left4me/instances/%i/instance.env" in unit
|
|
# `-` prefix: chdir failure is non-fatal so ExecStartPre can run the
|
|
# mount helper before the merged dir exists. ExecStart re-applies and
|
|
# finds the dir once the mount has landed.
|
|
assert "WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2" in unit
|
|
# ExecStart must invoke srcds_run from the *merged* overlay tree, not
|
|
# from installation/. srcds_run cds to its own dirname; if we point at
|
|
# installation/, the engine reads gameinfo.txt and addons from the lower
|
|
# layer and never sees overlay plugins (Metamod/SourceMod) or cfgs.
|
|
assert "ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run" in unit
|
|
assert "$L4D2_ARGS" in unit
|
|
assert "${L4D2_ARGS}" not in unit
|
|
# Hardening directives belong in the drop-in; must not appear in the base unit.
|
|
assert "NoNewPrivileges=" not in unit
|
|
assert "PrivateTmp=" not in unit
|
|
assert "PrivateDevices=" not in unit
|
|
assert "ProtectHome=" not in unit
|
|
assert "ProtectSystem=" not in unit
|
|
assert "RestrictSUIDSGID=" not in unit
|
|
assert "LockPersonality=" not in unit
|
|
|
|
|
|
def test_server_unit_mounts_overlay_via_exec_start_pre():
|
|
"""At boot, systemd auto-starts enabled units before the web app gets a
|
|
chance to run start_instance's pre-start mount. The unit itself must
|
|
re-mount the overlay so reboots are transparent. Pairs with the helper's
|
|
idempotency check (test_overlay_helper_mount_is_idempotent_when_mounted).
|
|
|
|
The unit-level `nsenter --mount=/proc/1/ns/mnt --` is what makes
|
|
umount fast: without it, the helper Python process would inherit
|
|
the unit's per-service mount namespace and pin it alive, blocking
|
|
PID 1's umount until the helper exited. Wrapping with nsenter at
|
|
the Exec line puts the helper itself in PID 1's namespace.
|
|
"""
|
|
unit = SERVER_UNIT.read_text()
|
|
# `+` prefix: runs as PID 1 (root, no sandbox). Required because
|
|
# the unit has NoNewPrivileges=true, which blocks sudo's setuid
|
|
# escalation — and the helper needs root for the mount syscall.
|
|
assert (
|
|
"ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- "
|
|
"/usr/local/libexec/left4me/left4me-overlay mount %i"
|
|
in unit
|
|
)
|
|
# Bound the restart loop; without these, a CHDIR-failure (or any other
|
|
# pre-start error) spins indefinitely.
|
|
assert "StartLimitBurst=5" in unit
|
|
assert "StartLimitIntervalSec=60s" in unit
|
|
|
|
|
|
def test_server_unit_unmounts_overlay_via_exec_stop_post():
|
|
"""Single source of truth for unmount, mirroring the mount path.
|
|
ExecStopPost (not ExecStop) so it runs after srcds has fully exited
|
|
and the cgroup is cleared.
|
|
|
|
Same nsenter-at-Exec-line wrapping as ExecStartPre — without it,
|
|
the helper process would itself hold a reference to the unit's
|
|
per-service mount namespace, and umount in PID 1 would loop on
|
|
EBUSY until the helper gave up. With it, umount succeeds first try.
|
|
"""
|
|
unit = SERVER_UNIT.read_text()
|
|
assert (
|
|
"ExecStopPost=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- "
|
|
"/usr/local/libexec/left4me/left4me-overlay umount %i"
|
|
in unit
|
|
)
|
|
|
|
|
|
def test_server_unit_contains_perf_baseline_directives():
|
|
unit = SERVER_UNIT.read_text()
|
|
|
|
# Slice membership.
|
|
assert "Slice=l4d2-game.slice" in unit
|
|
|
|
# CFS priority bump (no SCHED_FIFO).
|
|
assert "Nice=-5" in unit
|
|
assert "CPUSchedulingPolicy=" not in unit
|
|
|
|
# I/O priority.
|
|
assert "IOSchedulingClass=best-effort" in unit
|
|
assert "IOSchedulingPriority=4" in unit
|
|
|
|
# OOM ordering: game servers survive, sandbox dies first.
|
|
assert "OOMScoreAdjust=-200" in unit
|
|
|
|
# Memory caps with headroom for map-load spikes.
|
|
assert "MemoryHigh=1.5G" in unit
|
|
assert "MemoryMax=2G" in unit
|
|
|
|
# Bounded fork surface.
|
|
assert "TasksMax=256" in unit
|
|
|
|
# Plenty of fds for plugin-heavy setups.
|
|
assert "LimitNOFILE=65536" in unit
|
|
|
|
# srcds clean shutdown via SIGINT, with time to flush. With the
|
|
# helper running in PID 1's mount namespace (via the unit-level
|
|
# nsenter on ExecStopPost), umount has no race window and the
|
|
# default 15 s is plenty for the whole stop transition.
|
|
assert "KillSignal=SIGINT" in unit
|
|
assert "TimeoutStopSec=15s" in unit
|
|
|
|
# Per-unit override of journald rate limiting (default drops srcds output).
|
|
assert "LogRateLimitIntervalSec=0" in unit
|
|
|
|
|
|
def test_l4d2_game_slice_exists_with_high_weights():
|
|
assert GAME_SLICE.is_file()
|
|
text = GAME_SLICE.read_text()
|
|
assert "[Slice]" in text
|
|
assert "CPUWeight=1000" in text
|
|
assert "IOWeight=1000" in text
|
|
|
|
|
|
def test_l4d2_build_slice_exists_with_low_weights():
|
|
assert BUILD_SLICE.is_file()
|
|
text = BUILD_SLICE.read_text()
|
|
assert "[Slice]" in text
|
|
assert "CPUWeight=10" in text
|
|
assert "IOWeight=10" in text
|
|
|
|
|
|
def test_sysctl_conf_present_with_perf_settings():
|
|
assert SYSCTL_CONF.is_file()
|
|
text = SYSCTL_CONF.read_text()
|
|
for line in (
|
|
"net.core.rmem_max = 8388608",
|
|
"net.core.wmem_max = 8388608",
|
|
"net.core.rmem_default = 524288",
|
|
"net.core.wmem_default = 524288",
|
|
"net.core.netdev_max_backlog = 5000",
|
|
"net.core.netdev_budget = 600",
|
|
"vm.swappiness = 10",
|
|
"net.ipv4.udp_rmem_min = 16384",
|
|
"net.ipv4.udp_wmem_min = 16384",
|
|
"net.core.default_qdisc = fq_codel",
|
|
"net.ipv4.tcp_congestion_control = bbr",
|
|
"kernel.yama.ptrace_scope = 2",
|
|
):
|
|
assert line in text, f"missing {line!r} in 99-left4me.conf"
|
|
|
|
|
|
def test_env_templates_contain_required_defaults():
|
|
host_env = HOST_ENV.read_text()
|
|
assert "Deployment units use fixed /var/lib/left4me paths" in host_env
|
|
assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n")
|
|
web_env = WEB_ENV_TEMPLATE.read_text()
|
|
assert web_env.startswith(
|
|
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
|
|
"SECRET_KEY=replace-with-generated-secret\n"
|
|
"JOB_WORKER_THREADS=4\n"
|
|
)
|
|
assert web_env.rstrip().endswith("STEAM_WEB_API_KEY=")
|
|
|
|
|
|
def test_sandbox_resolv_conf_exists():
|
|
assert SANDBOX_RESOLV_CONF.is_file()
|
|
text = SANDBOX_RESOLV_CONF.read_text()
|
|
nameservers = [
|
|
line.split()[1]
|
|
for line in text.splitlines()
|
|
if line.startswith("nameserver ")
|
|
]
|
|
assert len(nameservers) >= 2, "expected at least two nameservers for redundancy"
|
|
# Sanity: the resolvers must be public (not RFC1918 / loopback). We don't
|
|
# pin the exact IPs — Cloudflare/Google/Quad9 are all acceptable.
|
|
for ns in nameservers:
|
|
assert not ns.startswith("127."), ns
|
|
assert not ns.startswith("10."), ns
|
|
assert not ns.startswith("192.168."), ns
|
|
first_octet = int(ns.split(".")[0])
|
|
# Reject 172.16.0.0/12.
|
|
if first_octet == 172:
|
|
second_octet = int(ns.split(".")[1])
|
|
assert not (16 <= second_octet <= 31), ns
|
|
|
|
|
|
def test_web_hardening_dropin_present_with_directives():
|
|
assert WEB_HARDENING_DROPIN.is_file()
|
|
text = WEB_HARDENING_DROPIN.read_text()
|
|
assert "[Service]" in text
|
|
# COMMON
|
|
for d in (
|
|
"ProtectProc=invisible",
|
|
"ProtectKernelTunables=true",
|
|
"ProtectKernelModules=true",
|
|
"ProtectKernelLogs=true",
|
|
"ProtectClock=true",
|
|
"ProtectControlGroups=true",
|
|
"ProtectHostname=true",
|
|
"LockPersonality=true",
|
|
"ProtectSystem=strict",
|
|
"ProtectHome=true",
|
|
"PrivateTmp=true",
|
|
"RestrictNamespaces=true",
|
|
"RestrictRealtime=true",
|
|
"RemoveIPC=true",
|
|
"KeyringMode=private",
|
|
"UMask=0027",
|
|
"RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
|
|
):
|
|
assert d in text, f"missing {d!r} in web hardening drop-in"
|
|
# WEB-specific
|
|
assert "SystemCallArchitectures=native" in text
|
|
assert "SystemCallFilter=@system-service" in text
|
|
assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete" in text
|
|
# WEB must NOT include the sudo-incompatible directives.
|
|
assert "NoNewPrivileges=" not in text
|
|
assert "PrivateUsers=" not in text
|
|
assert "RestrictSUIDSGID=" not in text
|
|
assert "CapabilityBoundingSet=" not in text
|
|
assert "~@privileged" not in text
|
|
|
|
|
|
def test_server_hardening_dropin_present_with_directives():
|
|
assert SERVER_HARDENING_DROPIN.is_file()
|
|
text = SERVER_HARDENING_DROPIN.read_text()
|
|
assert "[Service]" in text
|
|
for d in (
|
|
"NoNewPrivileges=true",
|
|
"RestrictSUIDSGID=true",
|
|
"PrivateUsers=true",
|
|
"PrivatePIDs=true",
|
|
"PrivateIPC=true",
|
|
"PrivateDevices=true",
|
|
"CapabilityBoundingSet=",
|
|
"AmbientCapabilities=",
|
|
"SystemCallArchitectures=native x86",
|
|
"TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media",
|
|
"BindReadOnlyPaths=/var/lib/left4me/installation",
|
|
"BindReadOnlyPaths=/var/lib/left4me/overlays",
|
|
"BindReadOnlyPaths=/etc/left4me/host.env",
|
|
"BindPaths=/var/lib/left4me/runtime/%i",
|
|
"SocketBindAllow=udp:27000-27999",
|
|
"SocketBindAllow=tcp:27000-27999",
|
|
):
|
|
assert d in text, f"missing {d!r} in server hardening drop-in"
|
|
assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged" in text
|
|
# MemoryDenyWriteExecute must remain absent (Source engine compat).
|
|
assert "MemoryDenyWriteExecute" not in text
|
|
# ProcSubset=pid must remain absent — hides /proc/cpuinfo and breaks
|
|
# SteamAPI master-server registration (LAN-only fallback). See
|
|
# ckn-bw 4339289 and the comment block in the drop-in itself.
|
|
for line in text.splitlines():
|
|
bare = line.split("#", 1)[0].strip()
|
|
assert bare != "ProcSubset=pid", "ProcSubset=pid must not be active in the server drop-in"
|