left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service
mwiegand 434ee20339
refactor(deploy): venv + steam now under /var/lib/left4me
Sync deployment references for the runtime state relocation
shipped via ckn-bw (commit 6fae2fd). /opt/left4me/ is now a
root-owned deploy-artifact root (just src/); .venv and steamcmd
live at /var/lib/left4me/{.venv,steam}.

Touches:
- deploy/files/.../left4me-web.service: PATH + ExecStart
- deploy/files/.../left4me-workshop-refresh.service: WorkingDirectory
  (was /opt/left4me, now /opt/left4me/src to match the web unit),
  PATH, ExecStart
- scripts/sbin/left4me wrapper: flask path
- deploy/tests/test_example_units.py: PATH + ExecStart assertions
  for the web unit; also fix a pre-existing broken assertion that
  read "Environment=PATH=..." (the unit has Environment=HOME=...
  PATH=... on one line, so "Environment=PATH=" was never present)
  - now reads just "PATH=..."
- deploy/README.md: paths
- l4d2host/tests/test_cli.py: LEFT4ME_STEAMCMD fixture path

Design + as-shipped record:
docs/superpowers/specs/2026-05-15-runtime-state-relocation-design.md.
The original (narrower) prereq spec at
docs/superpowers/specs/2026-05-15-handoff-noneditable-install.md
is marked superseded with a pointer to what shipped + why the
scope grew (setuptools writes egg-info to source during PEP 517
build prep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:56:32 +02:00

85 lines
3.2 KiB
Desktop File

# left4me web application — system unit.
#
# 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-web.service'). Hardening directives live in
# the HARDENING_WEB constant near the top of the same file.
# This file is hand-synced; edit both together.
#
# Several directives that the gameserver uses are intentionally absent
# 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-*
[Unit]
Description=left4me web application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=left4me
Group=left4me
WorkingDirectory=/opt/left4me/src
Environment=HOME=/var/lib/left4me PATH=/var/lib/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/etc/left4me/host.env
EnvironmentFile=/etc/left4me/web.env
# Placeholder values for --workers / --threads. Live emission interpolates
# 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()'
Restart=on-failure
RestartSec=3
# Web writes broadly under /var/lib/left4me (DB, instance configs,
# overlays, runtime). Kept inline because it's web-specific
# (server@ uses BindPaths to bind only its instance dir).
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]
WantedBy=multi-user.target