deploy: extract hardening into drop-in files alongside the units

Hardening directives leave the base unit body and live in:
  deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
  deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf

Reference units now describe just the base operational shape (exec,
env, restart, resources). Tests split: base-unit content and hardening
profile are asserted separately.

Part of 2026-05-15-deployment-responsibility-design.md migration
step 2. ckn-bw lands the matching reactor surgery + symlink delivery.
This commit is contained in:
mwiegand 2026-05-15 19:16:59 +02:00
parent 949f1bae78
commit e9c172a619
No known key found for this signature in database
5 changed files with 209 additions and 142 deletions

View file

@ -0,0 +1,74 @@
# Hardening drop-in for left4me-server@.service.
#
# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys
# it to /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
# via a target-side symlink into the checkout.
#
# Gameserver unit: full hardening profile. No sudo path inside; no
# sudo-incompatibility carve-outs.
[Service]
NoNewPrivileges=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
AmbientCapabilities=
# srcds_linux is i386 (Source 2007 engine). Bare 'native' kills every
# 32-bit syscall and traps srcds_run in a respawn loop.
SystemCallArchitectures=native x86
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged
TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media
BindReadOnlyPaths=/var/lib/left4me/installation
BindReadOnlyPaths=/var/lib/left4me/overlays
# Workshop VPKs in overlays are symlinks into workshop_cache;
# without this bind they dangle inside the unit and Source
# silently fails to load the addons.
BindReadOnlyPaths=/var/lib/left4me/workshop_cache
# Steam SDK: srcds dlopen's ~/.steam/sdk32/steamclient.so for
# Steam master-server registration. Without this, SteamAPI_Init
# fails and the server falls back to LAN-only mode regardless
# of sv_lan=0 — clients then get "LAN servers are restricted
# to local clients (class C)". .steam holds symlinks into
# /var/lib/left4me/steam, so both paths need to be bound back
# through TemporaryFileSystem.
BindReadOnlyPaths=/var/lib/left4me/.steam
BindReadOnlyPaths=/var/lib/left4me/steam
BindReadOnlyPaths=/etc/left4me/host.env
BindReadOnlyPaths=/etc/ssl
BindReadOnlyPaths=/etc/ca-certificates
BindReadOnlyPaths=/etc/resolv.conf
BindReadOnlyPaths=/etc/nsswitch.conf
BindReadOnlyPaths=/etc/alternatives
BindPaths=/var/lib/left4me/runtime/%i
ProtectSystem=strict
ProtectHome=true
PrivateUsers=true
# PrivatePIDs is the test-plan amendment that closes D2.b: same-uid
# ProtectProc=invisible cannot hide gunicorn from srcds (both run as
# uid 980); a private PID namespace does.
PrivatePIDs=true
PrivateTmp=true
PrivateDevices=true
PrivateIPC=true
RestrictNamespaces=true
RestrictRealtime=true
ProtectProc=invisible
ProcSubset=pid
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
LockPersonality=true
RemoveIPC=true
KeyringMode=private
UMask=0027
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Lock srcds bindable sockets to the game port range. Hard-coded range
# because systemd directive variable substitution is uneven for
# SocketBindAllow.
SocketBindAllow=udp:27000-27999
SocketBindAllow=tcp:27000-27999
# W+X mprotect (text relocations in Source engine i386 .so files) is
# incompatible with the memory-deny-write-execute directive; that
# directive is therefore intentionally absent from this drop-in.

View file

@ -0,0 +1,39 @@
# Hardening drop-in for left4me-web.service.
#
# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys
# it to /etc/systemd/system/left4me-web.service.d/10-hardening.conf via a
# target-side symlink into the checkout.
#
# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
# and 2026-05-15-hardening-test-plan.md for the threat model and the
# verification matrix.
#
# This unit is the web app; some sudo-incompatible directives are
# intentionally absent:
# NoNewPrivileges — blocks sudo's setuid escalation
# PrivateUsers — breaks sudo's host-root mapping
# RestrictSUIDSGID — blocks setuid()/setgid()
# CapabilityBoundingSet — empty value would deny sudo's caps
# @privileged exclusion in SystemCallFilter — blocks sudo's setuid syscall
# All of those are unconditional on the gameserver unit (no sudo there).
[Service]
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectProc=invisible
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
LockPersonality=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RemoveIPC=true
KeyringMode=private
UMask=0027

View file

@ -1,10 +1,11 @@
# left4me gameserver — system unit, one instance per gameserver. # left4me gameserver — system unit, one instance per gameserver.
# #
# This is the REFERENCE COPY of the deployed unit. The live source is # This is the REFERENCE COPY of the deployed unit base body. The live
# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py # source is the systemd/units reactor at
# (look for 'left4me-server@.service'). Hardening directives live in # ~/Projekte/ckn-bw/bundles/left4me/metadata.py (look for
# the HARDENING_SERVER constant near the top of the same file. # 'left4me-server@.service').
# This file is hand-synced; edit both together. #
# Hardening: see left4me-server@.service.d/10-hardening.conf
# #
# Threat model: docs/superpowers/specs/2026-05-15-hardening-threat-model.md # Threat model: docs/superpowers/specs/2026-05-15-hardening-threat-model.md
# Defenses survey: docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md # Defenses survey: docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
@ -29,10 +30,11 @@ EnvironmentFile=/var/lib/left4me/instances/%i/instance.env
# once ExecStartPre's overlay mount succeeds. # once ExecStartPre's overlay mount succeeds.
WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2 WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2
# `+` prefix runs the helper as PID 1 (root, all caps, host # `+` prefix runs the helper as PID 1 (root, all caps, host
# namespaces) — required because the unit has NoNewPrivileges=true # namespaces) — required because the hardening drop-in sets
# AND PrivateUsers=true; both block sudo's setuid path. nsenter into # NoNewPrivileges and PrivateUsers; both block sudo's setuid path.
# PID 1's mount namespace ensures the umount in ExecStopPost succeeds # nsenter into PID 1's mount namespace ensures the umount in
# without EBUSY from the unit's own slave-mount tree. # ExecStopPost succeeds without EBUSY from the unit's own
# slave-mount tree.
ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i
# Run from the merged overlay, NOT installation/. srcds_run cds to its # Run from the merged overlay, NOT installation/. srcds_run cds to its
# own dirname before exec'ing srcds_linux; the binary's path determines # own dirname before exec'ing srcds_linux; the binary's path determines
@ -57,72 +59,5 @@ KillSignal=SIGINT
TimeoutStopSec=15s TimeoutStopSec=15s
LogRateLimitIntervalSec=0 LogRateLimitIntervalSec=0
# === Identity / privilege drop ===
NoNewPrivileges=true # block setuid escalation (defense: D3)
RestrictSUIDSGID=true # block setuid()/setgid() syscalls
CapabilityBoundingSet= # drop all caps — no privilege to escalate
AmbientCapabilities=
# === Filesystem virtualization ===
# Mask /var/lib, /etc, /opt, etc. with empty tmpfs; bind back only
# what srcds needs. The DB (/var/lib/left4me/left4me.db) and web.env
# (/etc/left4me/web.env) are intentionally not bound — they don't
# exist in this unit's filesystem view (defenses: D1.a, D1.b).
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
BindReadOnlyPaths=/etc/ssl
BindReadOnlyPaths=/etc/ca-certificates
BindReadOnlyPaths=/etc/resolv.conf
BindReadOnlyPaths=/etc/nsswitch.conf
BindReadOnlyPaths=/etc/alternatives
BindPaths=/var/lib/left4me/runtime/%i
ProtectSystem=strict # belt-and-braces with TemporaryFileSystem
ProtectHome=true
# === Process namespacing ===
PrivateUsers=true # own user namespace; cross-uid ptrace blocked (D2)
PrivatePIDs=true # own PID namespace; hides peer-srcds + gunicorn (D2.b, D5)
PrivateTmp=true
PrivateDevices=true
PrivateIPC=true
RestrictNamespaces=true # block unshare()/clone(CLONE_NEW*)
# === /proc and /sys ===
ProtectProc=invisible # foreign-uid /proc hidden (paired with PrivatePIDs for full hide)
ProcSubset=pid # /proc shows only PID dirs, no kallsyms/cpuinfo
ProtectKernelTunables=true # /proc/sys, /sys read-only
ProtectKernelModules=true # no module load/unload
ProtectKernelLogs=true # no /dev/kmsg or syslog()
ProtectClock=true # no settimeofday()
ProtectControlGroups=true # /sys/fs/cgroup read-only
ProtectHostname=true # no sethostname()
LockPersonality=true # no personality() switches
# === Syscall filter ===
# srcds_linux is i386 (Source 2007 engine). 'native x86' allows both
# x86_64 (from srcds_run + the dynamic linker) and i386 (from srcds_linux).
# Bare 'native' traps srcds_run in a respawn loop.
SystemCallArchitectures=native x86
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged
# ~@debug is the load-bearing block for D2.a: drops ptrace(), process_vm_readv/writev().
# ~@privileged blocks anything requiring CAP_*, redundant with empty bounding set.
# MemoryDenyWriteExecute=true is NOT set — Source engine i386 .so files
# have text relocations that need mprotect(W+X) during dynamic-linker pass.
# === Network ===
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # AF_UNIX needed for journald
# Lock srcds bindable sockets to the game port range.
SocketBindAllow=udp:27000-27999
SocketBindAllow=tcp:27000-27999
# === Misc hygiene ===
RestrictRealtime=true # no real-time scheduling
RemoveIPC=true # clean up SysV IPC on unit stop
KeyringMode=private # private kernel keyring
UMask=0027
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,22 +1,11 @@
# left4me web application — system unit. # left4me web application — system unit.
# #
# This is the REFERENCE COPY of the deployed unit. The live source is # This is the REFERENCE COPY of the deployed unit base body. The live
# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py # source is the systemd/units reactor at
# (look for 'left4me-web.service'). Hardening directives live in # ~/Projekte/ckn-bw/bundles/left4me/metadata.py (look for
# the HARDENING_WEB constant near the top of the same file. # 'left4me-web.service').
# This file is hand-synced; edit both together.
# #
# Several directives that the gameserver uses are intentionally absent # Hardening: see left4me-web.service.d/10-hardening.conf
# from this unit:
# NoNewPrivileges — blocks sudo's setuid escalation
# PrivateUsers — breaks sudo's host-root mapping
# RestrictSUIDSGID — blocks setuid()/setgid()
# CapabilityBoundingSet= — empty value would deny sudo's caps
# ~@privileged in SystemCallFilter — blocks sudo's setuid syscall
# The web app invokes privileged helpers (left4me-systemctl,
# left4me-overlay, left4me-script-sandbox) via sudo, so these
# directives can't be applied here. A future refactor replacing sudo
# with systemctl-managed transient units would unlock them.
# #
# Threat model + defenses + tests: see docs/superpowers/specs/2026-05-15-hardening-* # Threat model + defenses + tests: see docs/superpowers/specs/2026-05-15-hardening-*
@ -35,7 +24,7 @@ EnvironmentFile=/etc/left4me/host.env
EnvironmentFile=/etc/left4me/web.env EnvironmentFile=/etc/left4me/web.env
# Placeholder values for --workers / --threads. Live emission interpolates # Placeholder values for --workers / --threads. Live emission interpolates
# from metadata.get('left4me/gunicorn_workers') and gunicorn_threads. # from metadata.get('left4me/gunicorn_workers') and gunicorn_threads.
ExecStart=/var/lib/left4me/.venv/bin/gunicorn --workers 4 --threads 4 --bind 127.0.0.1:8000 'l4d2web.app:create_app()' ExecStart=/var/lib/left4me/.venv/bin/gunicorn --workers 1 --threads 32 --bind 127.0.0.1:8000 'l4d2web.app:create_app()'
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
@ -44,42 +33,5 @@ RestartSec=3
# (server@ uses BindPaths to bind only its instance dir). # (server@ uses BindPaths to bind only its instance dir).
ReadWritePaths=/var/lib/left4me ReadWritePaths=/var/lib/left4me
# === Filesystem ===
ProtectSystem=strict # tightened from prior 'full'; via HARDENING_COMMON
ProtectHome=true
PrivateTmp=true
# === /proc + kernel ===
# Note: ProcSubset=pid is intentionally NOT set on the web unit.
# It hides /proc/sys/kernel/random/boot_id which journalctl reads at
# startup, and the web invokes `sudo -n left4me-journalctl` to stream
# live server logs into the UI. The server unit can keep ProcSubset=pid
# because srcds doesn't shell out to journalctl.
ProtectProc=invisible # foreign-uid /proc hidden (defense: D4)
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
LockPersonality=true
# === Syscall filter (sudo-compatible — note absence of ~@privileged) ===
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete
# ~@debug blocks ptrace + process_vm_readv/writev (D4).
# ~@privileged intentionally omitted — sudo needs setuid().
# === Network ===
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# === Misc hygiene ===
RestrictNamespaces=true
RestrictRealtime=true
RemoveIPC=true
KeyringMode=private
UMask=0027
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -19,6 +19,8 @@ SYSCTL_CONF = DEPLOY / "files/etc/sysctl.d/99-left4me.conf"
SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf" SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf"
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env" HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template" 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(): def test_global_unit_files_exist_at_product_level_paths():
@ -41,14 +43,14 @@ def test_web_unit_contains_required_runtime_contract():
# NoNewPrivileges must remain unset because sudo (used by the overlay, # NoNewPrivileges must remain unset because sudo (used by the overlay,
# systemctl and journalctl helpers) is setuid. # systemctl and journalctl helpers) is setuid.
assert "NoNewPrivileges=true" not in unit assert "NoNewPrivileges=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 "ReadWritePaths=/var/lib/left4me" in unit
# Mounts now happen in PID 1's namespace via the left4me-overlay helper, # Mounts now happen in PID 1's namespace via the left4me-overlay helper,
# so MountFlags propagation is irrelevant — and the previous assumption # so MountFlags propagation is irrelevant — and the previous assumption
# that MountFlags=shared made it work was incorrect. # that MountFlags=shared made it work was incorrect.
assert "MountFlags=" not in unit 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(): def test_server_unit_contains_required_runtime_contract():
@ -69,15 +71,14 @@ def test_server_unit_contains_required_runtime_contract():
assert "ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run" in unit assert "ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run" in unit
assert "$L4D2_ARGS" in unit assert "$L4D2_ARGS" in unit
assert "${L4D2_ARGS}" not in unit assert "${L4D2_ARGS}" not in unit
assert "NoNewPrivileges=true" in unit # Hardening directives belong in the drop-in; must not appear in the base unit.
assert "PrivateTmp=true" in unit assert "NoNewPrivileges=" not in unit
assert "PrivateDevices=true" in unit assert "PrivateTmp=" not in unit
assert "ProtectHome=true" in unit assert "PrivateDevices=" not in unit
assert "ProtectSystem=strict" in unit assert "ProtectHome=" not in unit
assert "ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays" in unit assert "ProtectSystem=" not in unit
assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in unit assert "RestrictSUIDSGID=" not in unit
assert "RestrictSUIDSGID=true" in unit assert "LockPersonality=" not in unit
assert "LockPersonality=true" in unit
def test_server_unit_mounts_overlay_via_exec_start_pre(): def test_server_unit_mounts_overlay_via_exec_start_pre():
@ -232,3 +233,69 @@ def test_sandbox_resolv_conf_exists():
if first_octet == 172: if first_octet == 172:
second_octet = int(ns.split(".")[1]) second_octet = int(ns.split(".")[1])
assert not (16 <= second_octet <= 31), ns 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",
"ProcSubset=pid",
"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