Commit graph

57 commits

Author SHA1 Message Date
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
f3fe49c60e
fix(left4me): bind /var/lib/left4me/workshop_cache into server unit
Same class of leak as the .steam bind: workshop VPKs in overlays are
symlinks pointing to /var/lib/left4me/workshop_cache/<id>.vpk. With
TemporaryFileSystem=/var/lib in HARDENING_SERVER and workshop_cache
not in BindReadOnlyPaths, the targets are invisible inside the unit's
mount namespace. Source silently fails to load the addons — no log
message, the addon just doesn't appear in-game (saw the ions vocalizer
workshop VPK dangling on server@2).

Add workshop_cache to the bind list. Read-only is fine; srcds reads
the VPKs, doesn't write them (web app populates the cache as left4me).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:11:17 +02:00
9a4e184378
left4me: drop +sv_lan 0 from srcds ExecStart
The original LAN-mode rejection turned out to be a downstream cascade
from Steam SDK init failure (.steam dir invisible + cpu.cpp assert
on hidden /proc/cpuinfo) — fixed in subsequent commits by binding
/var/lib/left4me/.steam + /opt/left4me/steam back through
TemporaryFileSystem and dropping ProcSubset=pid. With Steam master-
server registration now succeeding ("Connection to Steam servers
successful. VAC secure mode is activated."), the server defaults to
internet mode (sv_lan=0) on its own.

Drop the explicit override. If LAN-mode rejection ever recurs the
fix is to identify the upstream Steam-registration issue, not to
re-set this cvar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:56:51 +02:00
4339289bad
fix(left4me): drop ProcSubset=pid from server unit too
Same pattern as the web-unit fix (commit b3f...): ProcSubset=pid hides
/proc/cpuinfo and /proc/sys/*. Source's tier0/cpu.cpp asserts on
cpuinfo read failure; SteamAPI_Init then fails with "create pipe
failed" as a downstream cascade, and srcds registers as LAN (rejecting
external clients with "LAN servers are restricted to local clients").

PrivatePIDs=true (private PID namespace) remains the load-bearing
peer-process isolation: no foreign PIDs visible to srcds in its own
namespace. ProtectProc=invisible is the foreign-uid /proc hide.
ProcSubset=pid was a defense-in-depth layer hiding kernel-introspection
files (cpuinfo, meminfo, sysctls); losing it only exposes host kernel
info, which is not sensitive in this threat model and is the same
information any user on the host already sees.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:44:22 +02:00
caf2332051
fix(left4me): bind /var/lib/left4me/.steam + /opt/left4me/steam into server unit
Server@.service has TemporaryFileSystem virtualizing /var/lib and /opt;
the .steam home dir (which holds symlinks to /opt/left4me/steam/linux{32,64})
wasn't bound back into the unit's filesystem view. srcds dlopen's
~/.steam/sdk32/steamclient.so for Steam master-server registration —
under the unit it returned ENOENT, SteamAPI_Init failed, and the server
fell back to LAN-only mode regardless of +sv_lan 0. Clients then got
"LAN servers are restricted to local clients (class C)" on connect.

Bind both /var/lib/left4me/.steam (the symlinks) and /opt/left4me/steam
(the symlink targets) read-only into the unit. The Steam SDK file is
written by steamcmd as part of the install flow, so RO is fine — srcds
doesn't write back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:42:17 +02:00
6bba2b04f7
fix(left4me): force +sv_lan 0 alongside +ip 0.0.0.0
With +ip 0.0.0.0 (added in previous commit to make TCP RCON reachable
via loopback), Source engine can't auto-determine whether the server
is public-facing and defaults to LAN mode (sv_lan=1). Clients
connecting from public IPs get rejected with "LAN servers are
restricted to local clients (class C)".

Force sv_lan=0 explicitly so public clients can connect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:35:47 +02:00
f5bce30a4a
fix(left4me): srcds binds RCON to all interfaces + close external TCP
Two changes to fix web's "No data / Connection refused" symptoms:

1. ExecStart adds +ip 0.0.0.0 so srcds_linux binds both UDP and TCP
   to all interfaces (incl. loopback). Default Source auto-IP picks
   the primary host IP (141.95.32.8 here), so TCP RCON was only on
   the public IP; web's 127.0.0.1 connect got ECONNREFUSED.

2. nftables/input drops the TCP accept on the game port range. UDP
   stays open for players. Loopback (127.0.0.1) bypasses the input
   chain via the existing 'iifname lo accept' rule in
   bundles/nftables/files/nftables.conf, so web→RCON via loopback
   still works.

Net effect: web's live-state polling + console RCON work via
loopback; RCON is no longer reachable from the public internet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:30:00 +02:00
656be1cf69
fix(left4me): move ProcSubset=pid from COMMON to SERVER-only
Discovered post-deploy: ProcSubset=pid hides /proc/sys/kernel/random/boot_id
which journalctl reads at startup. The web app invokes
`sudo -n left4me-journalctl` to stream live server logs into the UI;
journalctl bails with "Failed to get boot ID" before producing any
output. Web log streaming was silently broken.

Server unit keeps ProcSubset=pid (srcds doesn't invoke journalctl);
web unit drops it. ProtectProc=invisible remains in COMMON — that's
the load-bearing D4 defense (foreign-uid /proc hidden).

Reproducer that confirms the diagnosis:
  systemd-run --pipe --uid=left4me --gid=left4me \
    -p ProcSubset=pid -p ProtectProc=invisible \
    -p ProtectSystem=strict -p PrivateTmp=true \
    [...rest of web hardening...] \
    -- sh -c 'sudo -n left4me-journalctl 2 --lines 3 --follow >/var/lib/left4me/tmp/out 2>&1'
  # cat /var/lib/left4me/tmp/out → "Failed to get boot ID: No such file or directory"
  # rc → 1
With ProcSubset=all: timeout 124 (helper running), 3 lines streamed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:14:33 +02:00
3ce1ee486e
bundles/left4me: drop l4d2-sandbox user; tighten /var/lib/left4me to 0755
Companion to the uid-collapse refactor on the left4me side
(docs/superpowers/plans/2026-05-15-uid-collapse.md). The script-
sandbox now runs as left4me too, defended by the hardening profile
that landed earlier today rather than a kernel uid boundary.

users + groups dicts: remove the l4d2-sandbox entry (uid/gid 981).
/var/lib/left4me mode: 0711 → 0755. The 0711 was specifically a
traverse-only loosening for the sandbox uid; with one user, the
natural mode is back.
2026-05-15 15:51:21 +02:00
130b0b1c9c
bundles/left4me: ship kernel.yama.ptrace_scope=2 sysctl drop-in
Belt-and-braces with the gameserver unit's SystemCallFilter=~@debug +
PrivateUsers=true. Currently applied by hand on left4.me (left over
from the hardening test plan's Test 9); landing in the bundle so it
survives bw apply and is reproducible on any future host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:26 +02:00
c6721e7545
bundles/left4me: spread HARDENING_WEB into left4me-web.service
Adds the sudo-compatible hardening subset to the web unit. Tightens
ProtectSystem=full → strict. NoNewPrivileges, PrivateUsers,
RestrictSUIDSGID, empty CapabilityBoundingSet, and ~@privileged in the
syscall filter intentionally absent (sudo-incompatible until a future
refactor replaces the helper sudo with systemctl-managed transient
units).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:49:10 +02:00
640461c87a
bundles/left4me: spread HARDENING_SERVER into left4me-server@.service
Replaces the inline hardening directives on the gameserver unit with
the shared HARDENING_SERVER dict. Removes legacy ReadOnlyPaths /
ReadWritePaths (superseded by TemporaryFileSystem + BindReadOnlyPaths
+ BindPaths in the dict). Brings the unit to the proven Test 7
composition with the i386 amendment (SystemCallArchitectures=native x86)
and PrivatePIDs=true.

Not deployed until bw apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:46:58 +02:00
85b9af0aaa
bundles/left4me: add HARDENING_{COMMON,SERVER,WEB} constants
Three shared dicts capturing the proven hardening composition from the
left4me hardening test plan (left4me commit 461b8d0). HARDENING_COMMON
is the directive set both managed units take verbatim; HARDENING_SERVER
adds the sudo-incompatible flags + filesystem virtualization + i386
amendment + PrivatePIDs + SocketBindAllow; HARDENING_WEB adds the
sudo-compatible syscall filter.

Not yet spread into the unit emission — that's the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:42:26 +02:00
91b7265136
left4me: install_left4me_scripts reads from scripts/{libexec,sbin}/
left4me moved its privileged helpers out of deploy/files/usr/local/
into top-level scripts/{libexec,sbin}/ (left4me commit 5284e28).
deploy/ is now a reference exemplar, not source-of-truth; the helpers
are application-inherent code that lives where the rest of the
application does.

Repoint install_left4me_scripts at the new source paths under
/opt/left4me/src/scripts/{libexec,sbin}/. Install targets unchanged
(/usr/local/{libexec,sbin}/ on the host), so the apply is a no-op
diff on the deployed file content — only the source-of-install path
moves. Verified green by `bw apply ovh.left4me` against the test
server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:08:36 +02:00
3ccaa919ee
left4me: install privileged scripts from git_deploy artifact
Replaces five verbatim file copies (four libexec helpers + the sbin
admin CLI) with a single triggered install action that copies them
from /opt/left4me/src/deploy/files/usr/local/{libexec,sbin}/ after
git_deploy. left4me's repo is now the only source of truth for the
script content; editing the helper here required a separate commit-
and-sync step that masked yesterday's idmap helper update on the
deployed server.

The new action ships everything under deploy/files/usr/local/libexec/
left4me/ to /usr/local/libexec/left4me/, which incidentally includes
the dead-code left4me-apply-cake. Harmless (nothing references it);
the cake migration cleanup can sweep it later.
2026-05-15 00:46:31 +02:00
9fbd84c3b5
left4me: tighten host.env to 0640 root:left4me
Both env files now follow the same pattern: root owns the config so the
service user can't overwrite its own config, group=left4me so the
sudo -u left4me alembic + seed-overlays actions can source the file
(they failed with 'permission denied' when group=root and mode=0640).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:57:21 +02:00
1445aaff0a
left4me: wire STEAM_WEB_API_KEY through to web.env
Adds the metadata key default (None — node must override) and pipes it
into web.env.mako so the live-state poller can resolve Steam IDs to
persona names + avatars via GetPlayerSummaries.

ovh.left4me gets the actual key as an encrypted vault secret.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:42:51 +02:00
7ad9bbcec3
left4me: schedule daily workshop-refresh via systemd-timers
Adds a left4me-workshop-refresh entry to the systemd-timers bundle,
firing nightly at 04:00 and invoking the new flask workshop-refresh
CLI that enqueues a refresh_workshop_items job. Owner of the job is
NULL (system-enqueued). The bw worker picks it up under existing
scheduler rules; idempotent against an already-queued/running refresh.

Also extends bundles/systemd-timers to accept an optional
environment_files key so the new unit can pull DATABASE_URL etc.
from /etc/left4me/{host,web}.env.
2026-05-12 10:29:45 +02:00
c6caf2a1cf
left4me: per-node system_cpus set; pin HT siblings on ovh.left4me
Replaces bundle-default system_core_count int with a per-node set of
CPU ids; reactor takes set complement for game cores. ovh.left4me sets
{0, 4} to keep both HT siblings of physical core 0 in system.slice
so games don't share L1/L2 with system work. systemd_units reactor
return inlined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:20:28 +02:00
1b3f3ecf97
left4me: per-slice AllowedCPUs= driven by system_core_count
First N cores pin system/user/build (inline on owned slices, drop-ins
on upstream system.slice and user.slice via the systemd/units
'<parent>.d/<basename>.conf' convention). Remainder pins
l4d2-game.slice. Reactor raises on hosts with <2 threads or
system_core_count that leaves no cores for games.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:04:35 +02:00
1d30830824
left4me: install steamcmd + drop importability gate on pip_install
Two changes from the same debug session, both prerequisites for
`l4d2ctl install` to work end-to-end on a fresh node:

1) Install steamcmd via tarball under /opt/left4me/steam.
   - dpkg --add-architecture i386 + libc6:i386 + lib32z1 (32-bit deps;
     bw pkg_apt translates _ to : at install time, hence libc6_i386)
   - curl|tar one-shot, guarded by `test -x steamcmd.sh`
   - LEFT4ME_STEAMCMD in host.env so l4d2host invokes by absolute path
     (mirrors the old bundles/left4dead2/files/setup approach; avoids
     the dirname-$0 trap that bites when steamcmd is reached via a
     PATH symlink)

2) Drop the `unless` on left4me_pip_install. The gate checked
   importability of l4d2host/l4d2web, which is too weak a proxy for
   install state: adding [project.scripts] to pyproject.toml later
   wouldn't be picked up if the package was already importable from a
   prior `pip install -e`. Cost is ~2s/apply for a no-op pip
   resolution — not enough to keep the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:46:45 +02:00
09d236ded5
left4me: trigger alembic_upgrade from git_deploy (catch migrations on code updates)
pip_install's `unless` (import l4d2host, l4d2web) skips when both
packages are already installed — so on a code-only apply, pip_install
doesn't fire and alembic_upgrade (which it triggers) never runs.
The new 0008 migration would silently get skipped, leaving the DB
out of sync with the new schema.

Wire git_deploy → alembic_upgrade directly. alembic upgrade head is
idempotent (no-op when at head); seed_overlays + service:restart
cascade off alembic, so editable-install code changes also get picked
up by gunicorn.

Edge case noted (deferred): a migration-only change with no code
change has the same matching git rev, so this won't fire either. In
practice migrations always come with the code change that uses them.
2026-05-10 21:27:40 +02:00
b5662f7ea7
left4me: explicit source for /usr/local/sbin/left4me (basename collides) 2026-05-10 21:01:18 +02:00
b8648cb53f
left4me: ship a /usr/local/sbin/left4me wrapper for the flask CLI
One-liner instead of "ssh + heredoc + sudo + sh -c + double quotes":
  sudo left4me create-user alice --admin
  sudo left4me seed-script-overlays /opt/left4me/src/examples/script-overlays
  sudo left4me routes

The wrapper sources host.env + web.env, drops to the left4me user,
sets JOB_WORKER_ENABLED=false (admin-side ops shouldn't race the
worker) and PYTHONPATH=/opt/left4me/src, then exec's the flask CLI
with whatever args followed `left4me`. No env-var enumeration: the
sh -c trailing 'sh "$@"' forwards positional args without quoting
hell. README updated to drop the verbose recipe.
2026-05-10 21:00:16 +02:00
ed141a9300
left4me: drop chown_src from git_deploy triggers (self-healing now)
Same constraint pattern: items in a triggers list must be
triggered:True. chown_src dropped triggered:True in the prior commit
to become self-healing every-apply, so it can't stay in git_deploy's
triggers list. Now git_deploy has no triggers at all — chown_src and
pip_install both run every apply, gated by their own `unless` guards.
2026-05-10 18:58:30 +02:00
9d17c69b22
left4me: make chown_src self-healing too
Same problem as pip_install: chown_src was triggered:True and only
fired when git_deploy did. After a partial first-apply where git_deploy
succeeded (extracting root-owned files) but the chown didn't happen
yet, subsequent applies left files root-owned forever — pip_install
fails with "permission denied" trying to write .egg-info/.

Drop triggered:True. Add an unless guard:
  test -z "$(find /opt/left4me/src ! -user left4me -print -quit)"
i.e. skip the chown only when no non-left4me-owned file exists in the
tree.
2026-05-10 18:57:50 +02:00
5bf95cb065
left4me: drop pip_install from pip_upgrade triggers (pip_install now always-runs) 2026-05-10 18:56:30 +02:00
cac04a456b
left4me: make pip_install self-healing on every apply
The previous shape (`triggered: True`, in git_deploy's triggers list)
meant pip_install only ran when something upstream fired. After a
partial first-apply failure (where git_deploy succeeded but pip_install
failed for an unrelated reason), subsequent applies couldn't recover —
git_deploy was already in desired state, nothing fired pip_install.

Drop `triggered: True`. Drop pip_install from git_deploy's triggers
(bw enforces a triggers→triggered:True invariant). Add `unless`:
sudo -u left4me /opt/left4me/.venv/bin/python -c "import l4d2host, l4d2web"
to short-circuit when the venv is already correct. Editable installs
pick up code changes automatically — no need to re-pip on every git
update.

For dep changes (rare), nudge manually:
  bw run ovh.left4me 'sudo -u left4me /opt/left4me/.venv/bin/pip install -e /opt/left4me/src/l4d2host -e /opt/left4me/src/l4d2web'
2026-05-10 18:55:24 +02:00
c2cc3866f3
left4me: chown /opt/left4me/src after git_deploy
bw's git_deploy extracts the git archive as the connecting user (root
after sudo), so files end up root-owned. The subsequent pip install
runs as left4me and needs to write .egg-info/ inside each editable
package, which fails with "permission denied".

Add action:left4me_chown_src triggered by git_deploy and required by
pip_install. Idempotent (chown -R is fine to re-run).
2026-05-10 18:52:37 +02:00
d548235dfe
left4me: declare /opt/left4me/src as a directory: item
bw's git_deploy item assumes the destination directory exists on the
host — its fix path runs `find <dest> -mindepth 1 -delete` to clear
existing contents before unpacking the new archive, which fails on a
fresh box where the directory was never created. Flask follows the
same pattern (bundles/flask/items.py:13).
2026-05-10 18:51:05 +02:00
149ce6c870
left4me: use https git URL so bw clones locally per-apply
bw's git_deploy.py:103 falls into a per-apply temp clone path when the
repo URL contains '://' (HTTPS, ssh://, …). Without that, it requires
a static git_deploy_repos map file pointing at a long-lived local
clone — which is the wrong shape for left4me, where the source of
truth is git.sublimity.de.

Switching the default to the HTTPS URL means anyone with the bundle
gets a working clone-from-source on `bw apply`, no operator-side
mirror map required.

Note: the host will pull whatever is pushed to git.sublimity.de
master. Push local commits before applying.
2026-05-10 18:49:10 +02:00
5d69180466
left4me: terse bundle-membership asserts 2026-05-10 18:34:09 +02:00
7d3554f8a5
left4me: split derived_from_domain into one reactor per consumer
Each reactor now scopes to a single downstream bundle:
  nginx_vhosts    -> nginx/vhosts
  nftables_input  -> nftables/input

Easier to grep "what writes nginx/vhosts" and harder to accidentally
couple unrelated keys together. Same merged metadata.
2026-05-10 18:33:11 +02:00
fc66267656
left4me: reuse nginx bundle's auto-monitoring via check_path
bundles/nginx/metadata.py:91-104 already creates a monitoring/services
entry per nginx/vhost using the vhost's check_protocol/check_path. Set
check_path: '/health' on the left4me vhost so the auto-check hits the
Flask health endpoint, drop the explicit monitoring/services/left4me-web
block from this reactor.

Net effect: same curl command lands in monitoring as before, but the
service name is now 'left4.me' (the hostname, per the nginx reactor's
naming convention) instead of 'left4me-web'.
2026-05-10 18:31:52 +02:00
758660b131
left4me: drop redundant letsencrypt/domains from reactor
bundles/nginx/metadata.py auto-populates letsencrypt/domains from
nginx/vhosts.keys(). Declaring it again in the left4me reactor was a
no-op duplication. Removed; bw metadata still shows the same merged
state (left4.me with reload: [nginx]).
2026-05-10 18:29:15 +02:00
7b291acca1
left4me: refresh README + opt ovh.left4me in via groups
README:
  Updated metadata example to show domain as the only required key.
  Documented the bundle's derived_from_domain reactor as the source of
  nginx/letsencrypt/monitoring/nftables-input wiring, and the
  bundle-defaults source of backup/paths.

nodes/ovh.left4me.py:
  - groups: + backup, + left4me, + webserver
  - bundles: dropped 'left4me' and 'nftables' (come via groups now;
    nftables ships with debian-13).
  - metadata: pinned vm/cores=4, vm/threads=8 (4-core HT box) so the
    nginx bundle's worker_processes resolves; left4me block reduced to
    {'domain': 'left4.me'} — git_url, git_branch, secret_key, and the
    nginx/letsencrypt/monitoring/nftables/backup blocks now come from
    bundle defaults / the derived_from_domain reactor.
2026-05-10 18:24:03 +02:00
90f14b69e4
left4me: pull node-agnostic metadata into the bundle
Nodes should only carry node-specific metadata. Previously each node
running left4me had to declare git_url, git_branch, secret_key, plus
nginx vhost / letsencrypt / monitoring / nftables-input blocks for
every game port. All of those are derivable from one truly node-
specific value: the domain.

Move into the bundle:
  - git_url + git_branch as defaults (override per-node only if needed).
  - secret_key as a per-node vault-derived value
    (random_bytes_as_base64_for f'{node.name} left4me secret_key',
    same convention as postgresql/mosquitto/etc.).
  - backup/paths defaults (set-merged with backup group / node paths).

Add a `derived_from_domain` reactor that reads left4me/domain and
emits:
  - nginx/vhosts/<domain> proxying 127.0.0.1:8000
  - letsencrypt/domains/<domain>
  - monitoring/services/left4me-web (curl /health)
  - nftables/input rules for the configured port range
    (defaults 27015-27115, derived from left4me/port_range_*).

Net effect: a node opting into left4me declares only
  metadata.left4me.domain = 'whatever.tld'
plus the universal node-level stuff (id, vm/cores, network, …).
2026-05-10 18:23:34 +02:00
d425afad02
left4me: write bundle README 2026-05-10 18:07:58 +02:00
f9bf289ef0
left4me: assert nftables + systemd bundle membership
Catches misconfiguration at bw test time if a node attaches left4me
without those two bundles. Both contribute load-bearing metadata
materializers (nftables/output rules; systemd/units → unit files).
2026-05-10 18:06:35 +02:00
a8fc3f2298
left4me: fix bundle defects surfaced by real-node validation
Three issues caught once `bw test ovh.left4me` ran with the bundle
actually attached (vs. the earlier `bw test` with no node opting in,
which only checks parsing):

1. systemd_services + nftables_output reactors didn't read any metadata.
   bw rejects this with "did not request any metadata, you might want
   to use defaults instead". Both contributions are static, so they
   belong in `defaults` — moved.

2. git_deploy:/opt/left4me/src triggered action:left4me_create_venv,
   but create_venv lacked `triggered: True`. bw enforces that any
   action in a triggers list must be `triggered: True`. Removed
   create_venv from the trigger list — it's gated by `unless` for
   idempotency and doesn't need to refire on git updates anyway
   (the venv persists). pip_install stays in triggers so editable
   installs pick up new code.
2026-05-10 18:05:38 +02:00
c82737b162
left4me: contribute uid-based DSCP/priority marks to nftables/output
Replaces the per-app inet left4me_mark table from
deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft with two rules
in the central bundles/nftables/ inet filter table's output chain.
Same selectors (skuid left4me + l4proto udp), same actions (DSCP EF +
priority 6) for both v4 and v6.
2026-05-10 17:53:17 +02:00
b1edcac3c7
left4me: enable+start left4me-web.service via systemd/services
The server@ template intentionally has no svc_systemd entry — instances
are started on-demand by the web app through the left4me-systemctl
helper. Slices are activated implicitly when units use Slice=.
2026-05-10 17:49:50 +02:00
72da6c0a8d
left4me: pin EnvironmentFile order via tuples (was sets)
Sets in libs/systemd.py:18 are sorted alphabetically. The current
output is correct by accident — host.env < web.env, host.env < /var.
Adding a third path later would silently reorder. Tuples preserve
insertion order; generate_unitfile() iterates them the same way.

Environment (HOME=, PATH=) stays a set: each line is an independent
KEY=VALUE assignment, order is irrelevant.
2026-05-10 17:48:03 +02:00
6965441e9a
left4me: emit server@ template + game/build slice units
Translates the remaining three unit files from left4me/deploy/files/.
Server template carries the full hardening + cgroup/IO/Mem keys
verbatim. Slices need the bundles/systemd .slice support added in
prior commit.
2026-05-10 17:43:25 +02:00
6bf46ce9a4
left4me: emit left4me-web.service via systemd/units reactor
Translates left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service
into a Python dict consumed by bundles/systemd/. Two changes vs. the
shell-deploy unit:
  - --bind 0.0.0.0:8000 -> 127.0.0.1:8000 (nginx terminates TLS in front)
  - workers/threads are templated from left4me/gunicorn_{workers,threads}
    (defaults: 1 worker + 32 threads — same as the static unit)
2026-05-10 17:38:15 +02:00