# left4me gameserver — system unit, one instance per gameserver. # # This is the REFERENCE COPY of the deployed unit. The live source is # the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py # (look for 'left4me-server@.service'). Hardening directives live in # the HARDENING_SERVER constant near the top of the same file. # This file is hand-synced; edit both together. # # Threat model: docs/superpowers/specs/2026-05-15-hardening-threat-model.md # Defenses survey: docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md # Test plan + results: docs/superpowers/specs/2026-05-15-hardening-test-plan.md [Unit] Description=left4me server instance %i After=network-online.target Wants=network-online.target # Bound the restart loop. Without these, a persistent ExecStartPre or # ExecStart failure spins indefinitely. StartLimitBurst=5 StartLimitIntervalSec=60s [Service] Type=simple User=left4me Group=left4me EnvironmentFile=/etc/left4me/host.env EnvironmentFile=/var/lib/left4me/instances/%i/instance.env # `-` prefix: chdir failure is non-fatal. The merged dir only exists # once ExecStartPre's overlay mount succeeds. WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2 # `+` prefix runs the helper as PID 1 (root, all caps, host # namespaces) — required because the unit has NoNewPrivileges=true # AND PrivateUsers=true; both block sudo's setuid path. nsenter into # PID 1's mount namespace ensures the umount in 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 # Run from the merged overlay, NOT installation/. srcds_run cds to its # own dirname before exec'ing srcds_linux; the binary's path determines # gameinfo + addons lookup. ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS ExecStopPost=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i Restart=on-failure RestartSec=5 # === Resource control baseline === # See docs/superpowers/specs/2026-05-09-l4d2-server-host-perf-baseline-design.md Slice=l4d2-game.slice Nice=-5 IOSchedulingClass=best-effort IOSchedulingPriority=4 OOMScoreAdjust=-200 MemoryHigh=1.5G MemoryMax=2G TasksMax=256 LimitNOFILE=65536 KillSignal=SIGINT TimeoutStopSec=15s 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] WantedBy=multi-user.target