Compare commits

...

7 commits

Author SHA1 Message Date
a95a7e20e2
left4me/README: describe symlink delivery + reactor scope after the reshape
Rewrite "What this bundle does" to reflect the post-migration model:
- Target-side symlinks table for the 6 static artifacts (sudoers, sysctl,
  2 hardening drop-ins, 4 libexec helpers, sbin wrapper)
- Reactor-emitted units section (per-host shape: web/server@ units, slices,
  cpuset drop-ins)
- bw files{} for the templated env files
- Action chains section covering the full deploy lifecycle
- Reference to the design doc for rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:49:11 +02:00
ae4bfc8db3
left4me: symlink privileged helpers to the checkout
/usr/local/libexec/left4me/* and /usr/local/sbin/left4me are now
target-side symlinks into /opt/left4me/src/deploy/scripts/...
Replaces the install_left4me_scripts copy-action.

Part of 2026-05-15-deployment-responsibility-design.md migration
step 4. Verified on ovh.left4me: helpers run via sudo from the web
app context; gameserver lifecycle helper invocation succeeds.
2026-05-15 19:42:12 +02:00
05ec7c9bee
left4me: symlink /etc/sudoers.d/left4me to the checkout
Sudoers drop-in lives in left4me/deploy/files/etc/sudoers.d/left4me
(single source of truth). Deleted the verbatim mirror in this bundle's
files/ tree. Added an idempotent chmod action so the in-checkout file
is 0440 root:root — required for sudo to accept it through the symlink.

Syntax check on the source file is now a left4me-side pytest
(deploy/tests/test_sudoers.py) running visudo -cf.

Part of 2026-05-15-deployment-responsibility-design.md migration step 3.
2026-05-15 19:30:23 +02:00
4820b7193f
left4me: add bw action verifying hardening drop-ins load on every apply
Post-daemon-reload self-test that asserts both
  /etc/systemd/system/left4me-{web,server@}.service.d/10-hardening.conf
appear in `systemctl show -p DropInPaths` for the unit. Catches drift
where the symlink lands but daemon-reload didn't take, or someone
manually unlinked the drop-in.

For the gameserver template we query `left4me-server@verify.service` —
systemd resolves drop-ins for a template instance against
`name@.service.d/` regardless of whether the instance has ever started.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:21:50 +02:00
d175c56e6c
left4me: hardening lives in drop-ins owned by left4me; deliver via symlink
Reactor stops emitting hardening directives in the unit bodies. The
HARDENING_COMMON / HARDENING_SERVER / HARDENING_WEB constants are gone.
Effective hardening on the live units now comes from drop-in files
shipped by left4me at:
  /etc/systemd/system/left4me-web.service.d/10-hardening.conf
  /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
Both are target-side symlinks into /opt/left4me/src/deploy/files/...
(safe because /opt/left4me/src is root-owned post-relocation refactor).

Verified on ovh.left4me: systemctl show reports the same directives as
the pre-refactor baseline; relevant hardening test-plan checks pass.
2026-05-15 19:19:17 +02:00
b10c4d22fd
left4me: symlink /etc/sysctl.d/99-left4me.conf to the checkout
Sysctl drop-in lives in left4me/deploy/files/etc/sysctl.d/99-left4me.conf
(absorbed kernel.yama.ptrace_scope from the metadata entry). Deliver
via target-side symlink instead of a verbatim copy.

Canary for the deployment-responsibility reshape (left4me design doc
2026-05-15-deployment-responsibility-design.md, step 1). Validated
end-to-end on ovh.left4me: symlink resolves to the checkout,
sysctl --system fires on apply, kernel target value matches, idempotent.
One-shot cleanup of stale /etc/sysctl.d/99-left4me-ptrace.conf
(orphan from earlier apply; bundles/sysctl does not auto-purge unmanaged
files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:10:23 +02:00
6fae2fd324
refactor(left4me): non-editable install + relocate runtime state to /var/lib/left4me
Two related changes landed together:

1. /opt/left4me/ becomes a root-owned deploy-artifact root. Only
   /opt/left4me/src lives there. Source tree is no longer mutated
   by pip at runtime; left4me only needs read access.
2. Runtime mutable state moved to /var/lib/left4me/: the venv (was
   /opt/left4me/.venv) and steamcmd (was /opt/left4me/steam).

The non-editable install copies the (root-owned) source to a
left4me-owned tempdir before `pip install --force-reinstall`.
setuptools.build_meta writes <pkg>.egg-info/ into the source dir
during get_requires_for_build_wheel, so a direct `pip install
/opt/left4me/src/l4d2*` fails on a root-owned source. The
temp-copy is what `python -m build` ought to do but doesn't (its
build isolation only sandboxes deps).

Prereq for the deployment-responsibility reshape: target-side
symlinks from /etc/... into /opt/left4me/src/deploy/files/... are
now safe by construction (left4me cannot rewrite its own hardening
profile).

Design + verification record: left4me/docs/superpowers/specs/
2026-05-15-runtime-state-relocation-design.md

Verified on ovh.left4me: bw apply idempotent on second pass (0
fixed, 0 failed); pip show reports site-packages location, no
Editable project location; web + gameserver units run clean;
alembic current returns head.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:56:08 +02:00
6 changed files with 286 additions and 292 deletions

View file

@ -36,27 +36,74 @@ from defaults. None of these need to be declared per-node.
## What this bundle does ## What this bundle does
- Creates system user `left4me` (uid/gid 980, home `/var/lib/left4me`, The bundle delivers to `ovh.left4me` a mix of:
mode 0755) — same uid hosts the web app, gameservers, and the
script-overlay sandbox unit (which drops privileges via systemd-run ### Target-side symlinks into the left4me checkout
with a fully hardened transient service).
- Drops privileged helpers under `/usr/local/libexec/left4me/` After `git_deploy:/opt/left4me/src` (root-owned — left4me cannot rewrite
(`left4me-systemctl`, `left4me-journalctl`, `left4me-overlay`, its own deployment artifacts at runtime), ckn-bw creates symlinks from
`left4me-script-sandbox`) plus a tight sudoers file (validated with canonical on-host paths into the checkout:
`visudo -cf` before install).
- `git_deploy`s the left4me repo to `/opt/left4me/src`, builds a venv at | On-host path | Source in checkout |
`/opt/left4me/.venv`, `pip install -e`s both `l4d2host` and `l4d2web`, |---|---|
runs `alembic upgrade head` and `flask seed-script-overlays`, then | `/etc/sudoers.d/left4me` | `deploy/files/etc/sudoers.d/left4me` |
enables `left4me-web.service`. | `/etc/sysctl.d/99-left4me.conf` | `deploy/files/etc/sysctl.d/99-left4me.conf` |
- Emits four systemd units via `systemd/units` metadata (consumed by | `/etc/systemd/system/left4me-web.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf` |
`bundles/systemd/`): | `/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` |
- `left4me-web.service` — gunicorn on `127.0.0.1:8000` (TLS terminates upstream). | `/usr/local/libexec/left4me/{left4me-overlay,left4me-systemctl,left4me-journalctl,left4me-script-sandbox}` | `deploy/scripts/libexec/*` |
- `left4me-server@.service` — per-instance srcds template, started on | `/usr/local/sbin/left4me` | `deploy/scripts/sbin/left4me` |
demand by the web app via the `left4me-systemctl` helper.
- `l4d2-game.slice` / `l4d2-build.slice` — cgroup slices for the The hardening drop-ins and sudoers are the application's own security
perf-baseline (CPU/IO weights, memory caps). knowledge — they live in the left4me repo and are version-controlled there.
The privileged helpers are also application code. The symlink pattern
lets bw manage placement without duplicating content.
Design rationale:
`left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`.
### Reactor-emitted units (per-host shape)
Via `systemd/units` metadata in `metadata.py` (consumed by `bundles/systemd/`):
- `left4me-web.service` — gunicorn on `127.0.0.1:8000`; worker/thread
counts from `web.env.mako`. TLS terminates upstream.
- `left4me-server@.service` — per-instance srcds template; `SocketBindAllow=`
ranges from metadata.
- `l4d2-game.slice` / `l4d2-build.slice` — cgroup slices with per-host
`AllowedCPUs=` from `left4me/system_cpus`.
- `system.slice.d/99-left4me-cpuset.conf` + `user.slice.d/99-left4me-cpuset.conf`
— host CPU-set drop-ins, same source.
### bw `files{}` — templated env files
- `host.env.mako``/etc/left4me/host.env`
- `web.env.mako``/etc/left4me/web.env`
- `sandbox-resolv.conf``/etc/left4me/sandbox-resolv.conf`
### Action chains — deploy lifecycle
- `git_deploy``pip_install` (non-editable; setuptools writes egg-info to
a left4me-writable tempdir) → `alembic_upgrade``seed_overlays` + web restart.
- Idempotent gates: `chmod-sudoers` (0440 root:root), `chmod-scripts` (0755 root:root).
- Post-git-deploy reloads: `systemctl daemon-reload`, `sysctl --system`.
- Post-apply self-test: `verify-hardening-dropins` (asserts the drop-ins are
loaded by the live units before declaring apply done).
### System user
`left4me` (uid/gid 980, home `/var/lib/left4me`, mode 0755) — the same uid
hosts the web app, gameservers, and the script-overlay sandbox unit (which
drops privileges via systemd-run with a fully hardened transient service).
Runtime mutable state lives under `/var/lib/left4me/`; `/opt/left4me/`
stays as a root-owned deploy-artifact root.
### nftables / nginx / monitoring
- Contributes uid-based DSCP/priority marks for srcds UDP egress to - Contributes uid-based DSCP/priority marks for srcds UDP egress to
`nftables/output` (via `defaults`). `nftables/output` (via `defaults`).
- `derived_from_domain` reactor emits the corresponding `nginx/vhosts`,
`letsencrypt/domains`, and `monitoring/services/left4me-web` (HTTPS
health check).
## Gotchas ## Gotchas

View file

@ -3,4 +3,4 @@
LEFT4ME_ROOT=/var/lib/left4me LEFT4ME_ROOT=/var/lib/left4me
# l4d2host invokes steamcmd by absolute path — bypasses PATH lookup so the # l4d2host invokes steamcmd by absolute path — bypasses PATH lookup so the
# script's `cd "$(dirname "$0")"` resolves next to the real install dir. # script's `cd "$(dirname "$0")"` resolves next to the real install dir.
LEFT4ME_STEAMCMD=/opt/left4me/steam/steamcmd.sh LEFT4ME_STEAMCMD=/var/lib/left4me/steam/steamcmd.sh

View file

@ -1,5 +0,0 @@
Defaults:left4me !requiretty
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl *
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl *
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-overlay mount *, /usr/local/libexec/left4me/left4me-overlay umount *
left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-script-sandbox

View file

@ -1,36 +0,0 @@
# Host-side perf baseline for left4me — see
# docs/superpowers/specs/2026-05-09-l4d2-server-host-perf-baseline-design.md
#
# UDP socket buffers: distro defaults of ~128 KiB are too small for sustained
# Source-engine UDP across multiple instances. 8 MiB matches the standard
# 1 Gbit recommendation; rmem_default/wmem_default protect sockets that don't
# explicitly enlarge their buffers.
net.core.rmem_max = 8388608
net.core.wmem_max = 8388608
net.core.rmem_default = 524288
net.core.wmem_default = 524288
# Kernel softirq UDP path: the per-CPU backlog queue starts dropping packets
# at the default 1000 under multi-instance burst; 5000 absorbs realistic peaks.
# netdev_budget = 600 gives softirq more drain headroom per pass.
net.core.netdev_max_backlog = 5000
net.core.netdev_budget = 600
# Latency-sensitive default: avoid swap unless the box is really under
# pressure. Harmless on swapless hosts.
vm.swappiness = 10
# Per-socket UDP buffer floors: protect game-server sockets that don't bump
# their own SO_RCVBUF/SO_SNDBUF when softirq drains lag briefly.
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
# Default qdisc for ifaces we don't explicitly shape with CAKE. Debian Trixie
# already defaults to fq_codel; setting it explicitly is belt-and-suspenders
# and survives kernel-default churn.
net.core.default_qdisc = fq_codel
# TCP congestion control: BBR for any bulk TCP egress on the host (admin SSH,
# backups, package fetches, web-app responses) so a long flow does not push
# the bottleneck queue ahead of game UDP. UDP srcds is unaffected.
net.ipv4.tcp_congestion_control = bbr

View file

@ -6,12 +6,26 @@
directories = { directories = {
'/opt/left4me': { '/opt/left4me': {
'owner': 'left4me', # Deploy-artifact root. Only /opt/left4me/src lives here; runtime
'group': 'left4me', # state (.venv, steamcmd) lives under /var/lib/left4me/. Root-owned
# so left4me cannot drop new files alongside src/ (e.g. an attacker
# with web-compromise can't plant a 'scripts.d/' loaded by future
# deploy logic).
'owner': 'root',
'group': 'root',
'mode': '0755',
}, },
'/opt/left4me/src': { '/opt/left4me/src': {
'owner': 'left4me', # Source checkout. Root-owned because the production install model
'group': 'left4me', # is non-editable: pip_install copies the source to a left4me-owned
# tempdir before building, so the source tree on disk is never
# mutated at runtime and left4me only needs read access (which
# world-readable bits provide). Keeps left4me from being able to
# rewrite its own future hardening drop-ins / unit files under
# /opt/left4me/src/deploy/ (target-side symlink model in the
# deployment-responsibility reshape).
'owner': 'root',
'group': 'root',
}, },
'/etc/left4me': { '/etc/left4me': {
'owner': 'root', 'owner': 'root',
@ -33,12 +47,21 @@ directories = {
'/var/lib/left4me/runtime': {'owner': 'left4me', 'group': 'left4me'}, '/var/lib/left4me/runtime': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/workshop_cache': {'owner': 'left4me', 'group': 'left4me'}, '/var/lib/left4me/workshop_cache': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/tmp': {'owner': 'left4me', 'group': 'left4me'}, '/var/lib/left4me/tmp': {'owner': 'left4me', 'group': 'left4me'},
'/opt/left4me/steam': {'owner': 'left4me', 'group': 'left4me'}, '/var/lib/left4me/steam': {'owner': 'left4me', 'group': 'left4me'},
# Note: the venv (/var/lib/left4me/.venv) is created by the
# left4me_create_venv action; declaring it here too would race with
# `python -m venv` which expects to create the directory itself.
'/usr/local/libexec/left4me': { '/usr/local/libexec/left4me': {
'owner': 'root', 'owner': 'root',
'group': 'root', 'group': 'root',
'mode': '0755', 'mode': '0755',
}, },
'/etc/systemd/system/left4me-web.service.d': {
'owner': 'root', 'group': 'root', 'mode': '0755',
},
'/etc/systemd/system/left4me-server@.service.d': {
'owner': 'root', 'group': 'root', 'mode': '0755',
},
} }
groups = { groups = {
@ -59,11 +82,11 @@ users = {
# (981 — formerly l4d2-sandbox — was collapsed into 980 on 2026-05-15; # (981 — formerly l4d2-sandbox — was collapsed into 980 on 2026-05-15;
# see left4me/docs/superpowers/plans/2026-05-15-uid-collapse.md.) # see left4me/docs/superpowers/plans/2026-05-15-uid-collapse.md.)
# Privileged helpers are installed by the `install_left4me_scripts` # Privileged helpers are delivered via target-side symlinks (see the
# action (below) directly from the left4me git checkout at # `symlinks` dict below) pointing into the left4me checkout at
# `/opt/left4me/src/scripts/{libexec,sbin}/` — no verbatim copy in this # `/opt/left4me/src/deploy/scripts/{libexec,sbin}/`. No verbatim copy
# bundle's files/ tree. Sudoers (further below) lists the specific # in this bundle's files/ tree. Sudoers (further below) lists the
# paths that left4me may invoke as root NOPASSWD. # specific paths that left4me may invoke as root NOPASSWD.
files = { files = {
'/etc/left4me/sandbox-resolv.conf': { '/etc/left4me/sandbox-resolv.conf': {
@ -72,22 +95,6 @@ files = {
'owner': 'root', 'owner': 'root',
'group': 'root', 'group': 'root',
}, },
'/etc/sudoers.d/left4me': {
'source': 'etc/sudoers.d/left4me',
'mode': '0440',
'owner': 'root',
'group': 'root',
'test_with': 'visudo -cf {}',
},
'/etc/sysctl.d/99-left4me.conf': {
'source': 'etc/sysctl.d/99-left4me.conf',
'mode': '0644',
'owner': 'root',
'group': 'root',
'triggers': [
'action:left4me_sysctl_reload',
],
},
'/etc/left4me/host.env': { '/etc/left4me/host.env': {
'source': 'etc/left4me/host.env.mako', 'source': 'etc/left4me/host.env.mako',
'content_type': 'mako', 'content_type': 'mako',
@ -113,11 +120,132 @@ files = {
}, },
} }
symlinks = {
'/etc/sysctl.d/99-left4me.conf': {
'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
'owner': 'root',
'group': 'root',
'needs': [
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_sysctl_reload',
],
},
'/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/etc/systemd/system/left4me-web.service.d',
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_daemon_reload',
],
},
'/etc/systemd/system/left4me-server@.service.d/10-hardening.conf': {
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/etc/systemd/system/left4me-server@.service.d',
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_daemon_reload',
],
},
'/etc/sudoers.d/left4me': {
'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
'owner': 'root', 'group': 'root',
'needs': [
'action:left4me_chmod_sudoers',
'git_deploy:/opt/left4me/src',
],
# sudo follows symlinks; with the target file at root:root 0440
# in a root-owned source tree, sudo accepts it. No daemon-reload
# equivalent — sudo re-reads /etc/sudoers.d/ on each invocation.
},
}
# Helper script source paths (in left4me's checkout) → deployed-form paths.
# Each gets a symlink item merged into the symlinks dict above.
_LEFT4ME_LIBEXEC_SCRIPTS = (
'left4me-overlay',
'left4me-systemctl',
'left4me-journalctl',
'left4me-script-sandbox',
)
_LEFT4ME_SBIN_SCRIPTS = (
'left4me',
)
for _script in _LEFT4ME_LIBEXEC_SCRIPTS:
symlinks[f'/usr/local/libexec/left4me/{_script}'] = {
'target': f'/opt/left4me/src/deploy/scripts/libexec/{_script}',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/usr/local/libexec/left4me',
'action:left4me_chmod_scripts',
'git_deploy:/opt/left4me/src',
],
}
for _script in _LEFT4ME_SBIN_SCRIPTS:
symlinks[f'/usr/local/sbin/{_script}'] = {
'target': f'/opt/left4me/src/deploy/scripts/sbin/{_script}',
'owner': 'root', 'group': 'root',
'needs': [
'action:left4me_chmod_scripts',
'git_deploy:/opt/left4me/src',
],
}
actions = { actions = {
'left4me_sysctl_reload': { 'left4me_sysctl_reload': {
'command': 'sysctl --system >/dev/null', 'command': 'sysctl --system >/dev/null',
'triggered': True, 'triggered': True,
}, },
'left4me_daemon_reload': {
'command': 'systemctl daemon-reload',
'triggered': True,
'cascade_skip': False,
},
'left4me_verify_hardening_dropins_loaded': {
# Post-apply self-test: confirm systemd actually picked up the
# hardening drop-ins we shipped via symlink. Catches the failure
# mode where the symlink lands but daemon-reload didn't take or
# someone manually unlinked the drop-in. For the gameserver template
# we query an imaginary instance — systemd resolves drop-in paths
# for `name@instance.service` against the template (`name@.service.d/`),
# so the instance need not exist or ever have run.
'command': (
'systemctl show left4me-server@verify.service -p DropInPaths --value '
'| tr " " "\\n" '
'| grep -qx /etc/systemd/system/left4me-server@.service.d/10-hardening.conf '
'&& '
'systemctl show left4me-web.service -p DropInPaths --value '
'| tr " " "\\n" '
'| grep -qx /etc/systemd/system/left4me-web.service.d/10-hardening.conf'
),
'cascade_skip': False,
'needs': [
'action:left4me_daemon_reload',
'symlink:/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
'symlink:/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
],
},
'left4me_chmod_sudoers': {
# sudo refuses sudoers.d entries that aren't 0440 (or 0400) root:root.
# git_deploy extracts as root with the in-repo file mode; this action
# is belt-and-braces in case the repo mode drifts. Idempotent via
# the `unless` gate.
'command': 'chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
'unless': 'test "$(stat -c %a /opt/left4me/src/deploy/files/etc/sudoers.d/left4me)" = "440"',
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
],
},
'left4me_dpkg_add_i386_arch': { 'left4me_dpkg_add_i386_arch': {
# steamcmd is 32-bit and pulls libc6:i386 + lib32z1 from the i386 arch. # steamcmd is 32-bit and pulls libc6:i386 + lib32z1 from the i386 arch.
# apt-get update is part of this action because newly-added foreign # apt-get update is part of this action because newly-added foreign
@ -133,15 +261,15 @@ actions = {
# tarball version from bw isn't useful. # tarball version from bw isn't useful.
'command': ( 'command': (
'sudo -u left4me sh -c "' 'sudo -u left4me sh -c "'
'cd /opt/left4me/steam && ' 'cd /var/lib/left4me/steam && '
'curl -fsSL https://media.steampowered.com/installer/steamcmd_linux.tar.gz | ' 'curl -fsSL https://media.steampowered.com/installer/steamcmd_linux.tar.gz | '
'tar -xz' 'tar -xz'
'"' '"'
), ),
'unless': 'test -x /opt/left4me/steam/steamcmd.sh', 'unless': 'test -x /var/lib/left4me/steam/steamcmd.sh',
'cascade_skip': False, 'cascade_skip': False,
'needs': [ 'needs': [
'directory:/opt/left4me/steam', 'directory:/var/lib/left4me/steam',
'pkg_apt:curl', 'pkg_apt:curl',
'pkg_apt:libc6_i386', # bw pkg_apt convention: _ → : 'pkg_apt:libc6_i386', # bw pkg_apt convention: _ → :
'pkg_apt:lib32z1', 'pkg_apt:lib32z1',
@ -159,71 +287,50 @@ git_deploy = {
'repo': node.metadata.get('left4me/git_url'), 'repo': node.metadata.get('left4me/git_url'),
'rev': node.metadata.get('left4me/git_branch'), 'rev': node.metadata.get('left4me/git_branch'),
'triggers': [ 'triggers': [
# On a code-update apply, refresh the DB schema. pip_install # Rebuild + reinstall the packages whenever the checkout
# would have triggered alembic in the create_venv path, but on # changes. pip_install does its own out-of-tree build (copies
# a normal apply pip_install's `unless` skips (packages still # source to a left4me-owned tempdir before invoking pip), so
# importable from the previous editable install), and that # the source tree itself stays root-owned and untouched.
# would leave alembic_upgrade dormant. Wiring git_deploy → # pip_install cascades into alembic_upgrade → web restart.
# alembic directly ensures new migrations land whenever new 'action:left4me_pip_install',
# code lands. alembic upgrade head is idempotent (no-op when # alembic upgrade head is idempotent — keeping it as a direct
# already at head), so this is safe to fire on every code # trigger off git_deploy is belt-and-braces in case the
# update; the seed_overlays + service:restart cascade off # pip_install cascade is ever short-circuited.
# alembic also covers picking up the new code in gunicorn.
'action:left4me_alembic_upgrade', 'action:left4me_alembic_upgrade',
# Privileged-helper scripts: reinstall from the new checkout # Reload systemd unit definitions whenever the checkout changes;
# into /usr/local/{libexec,sbin}/ as root-owned. No-op when # handles updates to hardening drop-in content without requiring
# the checkout didn't actually change (action is triggered). # a symlink change.
'action:install_left4me_scripts', 'action:left4me_daemon_reload',
], ],
# chown_src and pip_install are NOT in triggers — they run every
# apply gated by their own `unless` guards, which makes the chain
# self-healing after a partial failure. (Items in a triggers list
# must be triggered:True, which would lose that property.)
}, },
} }
actions['install_left4me_scripts'] = { actions['left4me_chmod_scripts'] = {
# Copy privileged scripts from the deployed left4me checkout into # sudo invokes the helpers by absolute path under /usr/local/...;
# /usr/local/{libexec,sbin}/ as root:root 0755. Source of truth for # those resolve to the checkout via the symlinks above. The target
# the file content is left4me's scripts/{libexec,sbin}/ tree (these # files must be executable (mode 0755). git_deploy extracts with
# are application code, not deploy artifacts; left4me's deploy/ is # the in-repo file modes; this action is belt-and-braces in case
# reference material only). The two install globs map source dirs # any helper's repo mode regresses to 0644.
# 1:1 to deploy targets. Triggered only on git_deploy updates so a
# no-op apply doesn't re-copy.
'command': ( 'command': (
'install -m 0755 -o root -g root -t /usr/local/libexec/left4me/ ' 'chmod 0755 '
'/opt/left4me/src/scripts/libexec/*; ' '/opt/left4me/src/deploy/scripts/libexec/* '
'install -m 0755 -o root -g root -t /usr/local/sbin/ ' '/opt/left4me/src/deploy/scripts/sbin/*'
'/opt/left4me/src/scripts/sbin/*' ),
'unless': (
'! find /opt/left4me/src/deploy/scripts -type f \\! -perm 755 -print -quit 2>/dev/null | grep -q .'
), ),
'triggered': True,
'cascade_skip': False, 'cascade_skip': False,
'needs': [ 'needs': [
'git_deploy:/opt/left4me/src', 'git_deploy:/opt/left4me/src',
'directory:/usr/local/libexec/left4me',
],
}
actions['left4me_chown_src'] = {
# Runs every apply (cheap — chown -R on a small tree). Self-heals
# whenever git_deploy extracts a new tarball as root-owned files.
# Not in any triggers list so doesn't need triggered:True.
'command': 'chown -R left4me:left4me /opt/left4me/src',
'unless': 'test -z "$(find /opt/left4me/src \\! -user left4me -print -quit 2>/dev/null)"',
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
'user:left4me',
'group:left4me',
], ],
} }
actions['left4me_create_venv'] = { actions['left4me_create_venv'] = {
'command': 'sudo -u left4me /usr/bin/python3 -m venv /opt/left4me/.venv', 'command': 'sudo -u left4me /usr/bin/python3 -m venv /var/lib/left4me/.venv',
'unless': 'test -x /opt/left4me/.venv/bin/python', 'unless': 'test -x /var/lib/left4me/.venv/bin/python',
'cascade_skip': False, 'cascade_skip': False,
'needs': [ 'needs': [
'directory:/opt/left4me', 'directory:/var/lib/left4me',
'pkg_apt:python3-venv', 'pkg_apt:python3-venv',
'user:left4me', 'user:left4me',
], ],
@ -233,29 +340,41 @@ actions['left4me_create_venv'] = {
} }
actions['left4me_pip_upgrade'] = { actions['left4me_pip_upgrade'] = {
'command': 'sudo -u left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip', 'command': 'sudo -u left4me /var/lib/left4me/.venv/bin/python -m pip install --upgrade pip',
'triggered': True, 'triggered': True,
'cascade_skip': False, 'cascade_skip': False,
'needs': [ 'needs': [
'pkg_apt:python3-pip', 'pkg_apt:python3-pip',
], ],
# No triggers — pip_install runs on every apply (gated by `unless`) # No triggers — pip_install is driven by git_deploy on actual code
# rather than being chained from here. Keeps pip_upgrade scoped to # updates, not by venv setup. Keeps pip_upgrade scoped to exactly
# exactly its purpose. # its purpose.
} }
actions['left4me_pip_install'] = { actions['left4me_pip_install'] = {
# Single pip invocation installs both editable packages from the same # Non-editable install of l4d2host + l4d2web into the venv. We have
# checkout. Runs on every apply: pip install -e is fast on no-op, and # to copy the source to a left4me-writable tempdir first because
# any gate weaker than "egg-info matches pyproject.toml" can mask # setuptools.build_meta writes <pkg>.egg-info/ into the source dir
# script regeneration — e.g. adding [project.scripts] later wouldn't # during `get_requires_for_build_wheel`, and the source tree is
# be picked up if `unless` only checks importability. # root-owned. cp -r is fast (small tree, world-readable), the build
'command': 'sudo -u left4me /opt/left4me/.venv/bin/pip install -e /opt/left4me/src/l4d2host -e /opt/left4me/src/l4d2web', # itself happens in $tmpdir, and pip installs the resulting wheel
# into /var/lib/left4me/.venv/site-packages. --force-reinstall
# because the version string in pyproject.toml (0.1.0) doesn't
# change commit-to-commit; without it pip would skip on no-op.
# triggered:True so this only fires on actual git_deploy changes
# (the cp + build is too heavy to run on every apply).
'command': """sudo -u left4me sh -c '
set -e
tmpdir=$(mktemp -d -t left4me-build-XXXXXX)
trap "rm -rf \\"$tmpdir\\"" EXIT
cp -r /opt/left4me/src/l4d2host /opt/left4me/src/l4d2web "$tmpdir/"
/var/lib/left4me/.venv/bin/pip install --force-reinstall "$tmpdir/l4d2host" "$tmpdir/l4d2web"
'""",
'triggered': True,
'cascade_skip': False, 'cascade_skip': False,
'needs': [ 'needs': [
'git_deploy:/opt/left4me/src', 'git_deploy:/opt/left4me/src',
'action:left4me_create_venv', 'action:left4me_create_venv',
'action:left4me_chown_src',
], ],
'triggers': [ 'triggers': [
'action:left4me_alembic_upgrade', 'action:left4me_alembic_upgrade',
@ -271,7 +390,7 @@ actions['left4me_alembic_upgrade'] = {
'cd /opt/left4me/src/l4d2web && ' 'cd /opt/left4me/src/l4d2web && '
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && ' 'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
'env JOB_WORKER_ENABLED=false PYTHONPATH=/opt/left4me/src ' 'env JOB_WORKER_ENABLED=false PYTHONPATH=/opt/left4me/src '
'/opt/left4me/.venv/bin/alembic -c /opt/left4me/src/l4d2web/alembic.ini upgrade head' '/var/lib/left4me/.venv/bin/alembic -c /opt/left4me/src/l4d2web/alembic.ini upgrade head'
'"' '"'
), ),
'triggered': True, 'triggered': True,
@ -293,7 +412,7 @@ actions['left4me_seed_overlays'] = {
'sudo -u left4me sh -c "' 'sudo -u left4me sh -c "'
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && ' 'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
'env JOB_WORKER_ENABLED=false PYTHONPATH=/opt/left4me/src ' 'env JOB_WORKER_ENABLED=false PYTHONPATH=/opt/left4me/src '
'/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app ' '/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app '
'seed-script-overlays /opt/left4me/src/examples/script-overlays' 'seed-script-overlays /opt/left4me/src/examples/script-overlays'
'"' '"'
), ),

View file

@ -83,17 +83,6 @@ defaults = {
'/etc/left4me', '/etc/left4me',
}, },
}, },
'sysctl': {
# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces
# with SystemCallFilter=~@debug + PrivateUsers=true in the gameserver
# unit. See:
# left4me docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
'kernel': {
'yama': {
'ptrace_scope': '2',
},
},
},
'systemd-timers': { 'systemd-timers': {
# Daily re-fetch of Steam Workshop metadata + .vpk downloads for any # Daily re-fetch of Steam Workshop metadata + .vpk downloads for any
# item whose author published an update. The CLI just inserts a # item whose author published an update. The CLI just inserts a
@ -101,7 +90,7 @@ defaults = {
# Idempotent — a re-fire while a refresh is already queued/running # Idempotent — a re-fire while a refresh is already queued/running
# is a no-op (see l4d2web/cli.py:workshop_refresh). # is a no-op (see l4d2web/cli.py:workshop_refresh).
'left4me-workshop-refresh': { 'left4me-workshop-refresh': {
'command': '/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh', 'command': '/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh',
'when': '*-*-* 04:00:00', 'when': '*-*-* 04:00:00',
'persistent': True, 'persistent': True,
'user': 'left4me', 'user': 'left4me',
@ -119,123 +108,6 @@ defaults = {
} }
# Hardening composition — proven via the hardening test plan (left4me
# commit 461b8d0). See:
# docs/superpowers/specs/2026-05-15-hardening-threat-model.md
# docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
# docs/superpowers/specs/2026-05-15-hardening-test-plan.md
# docs/superpowers/specs/2026-05-15-hardening-refactor-design.md
# (paths in the left4me repo)
# Directives both managed units take verbatim.
#
# ProcSubset=pid is intentionally NOT in COMMON: it hides
# /proc/sys/kernel/random/boot_id which journalctl reads at startup,
# and the web unit invokes `sudo -n left4me-journalctl ...` to stream
# live server logs into the UI. Server unit adds it back in
# HARDENING_SERVER (srcds doesn't read journalctl).
HARDENING_COMMON = {
'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',
}
# Gameserver unit: COMMON + sudo-incompatible flags + filesystem
# virtualization + i386 amendment + per-instance PID namespace + bound
# socket binds.
HARDENING_SERVER = {
**HARDENING_COMMON,
# ProcSubset=pid was here but had to come out: it hides /proc/cpuinfo
# and /proc/sys/*, which breaks Source's tier0/cpu.cpp and (downstream)
# SteamAPI_Init's "create pipe" step — server then registers as LAN
# and rejects external clients with "LAN servers are restricted to
# local clients (class C)". PrivatePIDs=true (kernel-level PID
# namespace) remains the load-bearing peer-process isolation, and
# ProtectProc=invisible is the foreign-uid /proc hide. Losing
# ProcSubset=pid only exposes host kernel info (cpuinfo, meminfo,
# sysctls), which is not sensitive in this threat model.
'NoNewPrivileges': 'true',
'RestrictSUIDSGID': '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',
'PrivateIPC': 'true',
'PrivateDevices': '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',
'~@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',
'/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.
'/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
# /opt/left4me/steam, so both paths need to be bound back
# through TemporaryFileSystem.
'/var/lib/left4me/.steam',
'/opt/left4me/steam',
'/etc/left4me/host.env',
'/etc/ssl',
'/etc/ca-certificates',
'/etc/resolv.conf',
'/etc/nsswitch.conf',
'/etc/alternatives',
),
'BindPaths': '/var/lib/left4me/runtime/%i',
# Lock srcds bindable sockets to the game port range. Hard-coded
# range because systemd directive variable substitution is uneven.
'SocketBindAllow': (
'udp:27000-27999',
'tcp:27000-27999',
),
# MemoryDenyWriteExecute=true permanently excluded — Source engine
# i386 .so files have text relocations that need mprotect(W+X)
# during the dynamic linker's relocation pass.
}
# Web unit: COMMON + sudo-compatible additions. EXCLUDES
# NoNewPrivileges, PrivateUsers, RestrictSUIDSGID, empty
# CapabilityBoundingSet, and ~@privileged in the syscall filter — all
# sudo-incompatible until a future refactor replaces sudo with
# systemctl-managed transient units.
HARDENING_WEB = {
**HARDENING_COMMON,
'SystemCallArchitectures': 'native',
'SystemCallFilter': (
'@system-service',
'~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete',
),
}
@metadata_reactor.provides( @metadata_reactor.provides(
'nginx/vhosts', 'nginx/vhosts',
@ -330,14 +202,14 @@ def systemd_units(metadata):
'WorkingDirectory': '/opt/left4me/src', 'WorkingDirectory': '/opt/left4me/src',
'Environment': { 'Environment': {
'HOME=/var/lib/left4me', 'HOME=/var/lib/left4me',
'PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'PATH=/var/lib/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
}, },
'EnvironmentFile': ( 'EnvironmentFile': (
'/etc/left4me/host.env', '/etc/left4me/host.env',
'/etc/left4me/web.env', '/etc/left4me/web.env',
), ),
'ExecStart': ( 'ExecStart': (
'/opt/left4me/.venv/bin/gunicorn ' '/var/lib/left4me/.venv/bin/gunicorn '
f'--workers {workers} --threads {threads} ' f'--workers {workers} --threads {threads} '
"--bind 127.0.0.1:8000 'l4d2web.app:create_app()'" "--bind 127.0.0.1:8000 'l4d2web.app:create_app()'"
), ),
@ -349,12 +221,9 @@ def systemd_units(metadata):
# only its instance dir). # only its instance dir).
'ReadWritePaths': '/var/lib/left4me', 'ReadWritePaths': '/var/lib/left4me',
# Hardening profile — see HARDENING_WEB constant near top of # Hardening profile delivered via
# this file. NoNewPrivileges intentionally NOT set: workers # /etc/systemd/system/left4me-web.service.d/10-hardening.conf
# sudo to the helpers. PrivateUsers and RestrictSUIDSGID also # (target-side symlink into left4me/deploy/files/, owned by left4me).
# absent for the same reason. ProtectSystem tightens from
# 'full' to 'strict' via HARDENING_COMMON.
**HARDENING_WEB,
}, },
'Install': { 'Install': {
'WantedBy': {'multi-user.target'}, 'WantedBy': {'multi-user.target'},
@ -401,9 +270,9 @@ def systemd_units(metadata):
'TimeoutStopSec': '15s', 'TimeoutStopSec': '15s',
'LogRateLimitIntervalSec': '0', 'LogRateLimitIntervalSec': '0',
# Hardening profile — see HARDENING_SERVER constant near top of # Hardening profile delivered via
# this file for per-directive rationale. # /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
**HARDENING_SERVER, # (target-side symlink into left4me/deploy/files/, owned by left4me).
}, },
'Install': { 'Install': {
'WantedBy': {'multi-user.target'}, 'WantedBy': {'multi-user.target'},