Ran the 11-test plan against left4me-server@1 (canary) and left4me-web
on left4.me / Debian 13 / systemd 257. Cleaned up all unit drop-ins;
kept the Test 9 sysctl (kernel.yama.ptrace_scope=2) per spec.
Outcomes:
- server@1 systemd-analyze: 7.5 EXPOSED → 1.3 OK
- left4me-web systemd-analyze: 8.7 EXPOSED → 4.1 OK
- All 8 attack vectors in Test 8 (D1.a-c, D2.a-c, D3, D5) blocked
- Test 6 (MemoryDenyWriteExecute) fails as predicted — Source engine
i386 .so files have text relocations; exclude from final composition.
- Test 11 (24-48h soak) skipped per operator decision.
Two amendments to the spec's proposed composition required for the
refactor:
- SystemCallArchitectures=native x86 (not bare 'native') — srcds_linux
is i386, the kernel kills every native-only call.
- PrivatePIDs=true added — ProtectProc=invisible alone cannot hide
gunicorn from srcds because both run as uid 980; PrivatePIDs gives
each instance its own PID namespace and closes D2.b.
Spec bugs surfaced and documented in the "Output" section: PID lookup
via pgrep (race vs. instance), Test 4/10 gdb-from-host doesn't
actually exercise the unit's SECCOMP filter, Test 8 D5 pgrep pattern
won't match. Tooling note corrected: scmp_sys_resolver is in
'seccomp' package, not 'libseccomp-dev'.
Next session: write docs/superpowers/plans/2026-MM-DD-hardening-refactor.md
against the proven composition; supersede the uid-split spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reframe the queued uid-split decision into a broader hardening analysis.
Audit found the same-uid attack surface (DB readable from srcds, ptrace
allowed, RCON stored plaintext) is closable by either uid split or
systemd directive composition; the three specs ground that choice in a
threat model, survey the defenses, and lay out a self-contained test
plan to run on left4.me next. uid-split spec deferred pending results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Short companion to the existing topic-specific handoff docs. Captures
the situationally-fresh state at the end of the 2026-05-15
deploy-dir-rethink + janitorial sweep so a fresh session can pick
up cold: what just landed, what's next (uid-split), what's NOT next
(build-overlay-unit, until uid-split decides), and the
decision-relevant signals that emerged during this session — mostly
that the 2-uid model was freshly load-bearing in the build-time-idmap
work and that srcds hardening already covers most of what a
gameserver-uid split would add.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both items were operational verifications (not code changes) against
the deployed test host ovh.left4me (141.95.32.8).
Item 8: orphan idmap binds in PID 1's mount namespace.
`sudo findmnt --task 1 -o TARGET | grep /var/lib/left4me/runtime/.*/idmap/`
returned zero matches with left4me-server@{1,2}.service both active.
Either swept earlier or never appeared on this host; nothing to umount.
Item 9: Optimized Settings (overlay 8) files-overlay sanity.
Dir is left4me:left4me end-to-end; `sudo find /var/lib/left4me/overlays/8
-type f -uid 981` returned empty. The invariant "files-overlays are
populated by the web app as left4me, never through the sandbox helper"
holds.
Remaining live janitorial items: 7 (conditional on the build-overlay-unit
refactor) and 10 (SourceMod 1.13 calendar reminder, ~late 2026).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Janitorial item 6 in 2026-05-15-janitorial-cleanup.md. The v1 sandbox
design (2026-05-08-l4d2-script-overlays-design.md) was approved
2026-05-08 and superseded the same day by the v2 systemd-only design
(2026-05-08-l4d2-script-sandbox-v2-systemd.md). The current
left4me-script-sandbox helper uses systemd-run in service-unit mode;
no bwrap binary is invoked. The v1 spec still described bubblewrap as
the engine.
- v1 spec gets a top-of-file banner pointing at v2 as the supersede.
Body preserved; the rest of the v1 design (overlay-type unification,
resource caps, helper auth) is still valid — only the sandbox engine
changed.
- l4d2web/services/overlay_builders.py: ScriptBuilder docstring
"bubblewrap + systemd-run" → "hardened systemd-run transient
service" (the as-built reality).
- scripts/tests/test_script_sandbox.py: stray "/bwrap" in a comment
cleaned up. Negative regression assertions (`assert "bwrap" not in
text`) intentionally retained as the guard against accidental
re-introduction.
- Plan docs left untouched (historical action snapshots).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the implementation plan that landed in the preceding commit
(2026-05-15-deploy-dir-rethink.md) under docs/superpowers/plans/, and
marks the two related specs:
- 2026-05-15-deploy-dir-rethink-design.md (the source handoff) gets a
"Resolved by …" banner at the top with a one-paragraph summary of
the decisions taken. Body preserved for archaeology.
- 2026-05-15-janitorial-cleanup.md gets a status banner noting that
items 1, 3, 4, 5 are fully resolved by the deploy-dir-rethink plan
and item 2 is partially resolved with a third option the original
enumeration didn't list: only the truly-dead two static units
(cake.service, nft-mark.service) deleted, the reactor-emitted set
(server@, web, workshop-refresh.{service,timer}, slices) retained
as curated examples. Resolved items left in place but flagged.
Remaining live janitorial items: 6 (bubblewrap doc drift), 7
(conditional on build-overlay-unit refactor), 8 (operational idmap
bind cleanup), 9 (Optimized Settings overlay verification), 10 (SM
1.13 calendar reminder).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the 5 privileged helpers out of deploy/files/usr/local/{libexec,sbin}/
into top-level scripts/{libexec,sbin}/. They are application-inherent code
(invoked at runtime via sudo from l4d2host/l4d2web), not deploy artifacts —
the previous nesting under deploy/files/ confused source-of-truth with
install-target FHS layout.
deploy/ now means "reference exemplar": README explaining the target
layout, plus example sudoers / sysctl / sandbox-resolv.conf / env
templates / curated systemd units (the ones ckn-bw's reactor emits).
Anyone building a fresh deployment (other than ckn-bw) reads this tree.
Dead static artifacts deleted: left4me-apply-cake helper, left4me-cake
+ left4me-nft-mark service units, cake.env, left4me-mark.nft, and the
superseded deploy-test-server.sh installer.
Tests split to match the new shape:
- scripts/tests/{test_overlay,test_script_sandbox,test_systemctl_helper,
test_journalctl_helper,test_helpers_use_fixed_paths,test_sudoers_grants}.py
with shared fixtures in conftest.py
- deploy/tests/test_example_units.py (renamed from test_deploy_artifacts.py)
— slimmed to lock down the curated example units, sysctl, env templates
l4d2host/tests/test_overlay_helper.py: helper-source path updated to
scripts/libexec/left4me-overlay (was building the path segment-by-segment
under deploy/files/, missed by the path-prefix grep during pre-flight).
Runtime install-target paths (/usr/local/{libexec,sbin}/) unchanged, so
l4d2host/service_control.py, l4d2web/services/overlay_builders.py, the
sudoers grants, and the systemd units all keep their existing path
references.
Requires the matching ckn-bw change to bundles/left4me/items.py
(install_left4me_scripts repointed from /opt/left4me/src/deploy/files/...
to /opt/left4me/src/scripts/...). Left4me lands first so a fresh
git_deploy exposes the new source path before the bundle apply runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups bundled into a single commit:
- docs/superpowers/specs/2026-05-15-janitorial-cleanup.md collects
the "do later" small TODOs that surfaced across the recent idmap
+ consolidation work: dead cake-related artifacts, obsolete
static systemd units in deploy/files/, the bubblewrap→systemd-run
doc drift, stale gameserver-side idmap binds on un-checked
instances, calendar reminder for SM 1.13 stable. Each item is
small and self-contained.
- docs/l4d2-server-cvar-reference.md captures the research from
the early-session L4D2 cvar deep-dive: tickrate sweet spots,
nb_update_frequency cheat-protection + sm_cvar workaround,
cvars that don't exist in L4D2 (net_maxcleartime,
z_resolve_zombie_collision_multiplier per RCON probe), recommended
plugins, MetaMod/SourceMod branch tracking, and the empirically-
verified idmap-propagation-through-rebind kernel-6.12 quirk.
Reference material, not a spec — lives at docs/ root.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Explicit clarification so the next agent doesn't go looking for
user-unit friction. left4me-server@.service and left4me-web.service
are system units that drop to User=left4me; the 3-user split is a
literal one-line edit per unit. No lingering, no pam_systemd, no
per-user systemd instance bootstrap. The privileged
ExecStartPre/ExecStopPost steps stay root via the + prefix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 2-user split (left4me + l4d2-sandbox) has been inherited as a
constraint across multiple recent plans (idmap-on-mount, build-time-
idmap, helper consolidation) without ever being designed
end-to-end. Three plausible configurations: collapse to 1 user
(rejected for security), keep at 2 users (status quo), or split web
from game into 3 users for blast-radius limiting on either side.
Doc captures the threat-model heuristics, cross-uid file-access
plumbing options (shared group vs. world-read), idmap implications,
a step-by-step migration sketch for the 3-user variant, and explicit
out-of-scope items (per-instance gameserver uids, etc.). Detailed
enough that a future session can pick a configuration and execute
without re-deriving the design space.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The script content lives in the overlays.script DB column and the
unit's %i is the row id, so the worker-writes-script-to-fs step in
the original sketch is duplication. Document three options (worker
writes / unit fetches via helper / pipe to stdin) and recommend the
unit-fetches variant with RuntimeDirectory= auto-cleanup. Promote
this to the top of the open-decisions list since it shapes the
worker, the unit, and whether a fetch-script helper is added.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The build-time idmap landing today required a nsenter self-wrap in
left4me-script-sandbox to escape the web app's PrivateTmp namespace
before pre-creating the idmapped staging bind. Working but band-aid:
the helper is reinventing what a systemd template unit would do
declaratively. Mirror the left4me-server@.service pattern with a
build-overlay@.service template — ExecStartPre does the idmap bind in
PID 1's namespace by default, the hardening flags live in the unit
file, ExecStopPost tears down. Worker switches to sudo systemctl start.
Doc captures full proposed unit, worker rewrite sketch, sudoers
update, migration order, verification steps, and the ~5h estimate
so a future session can pick this up cold and execute.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The web service runs with PrivateTmp=true, which puts it in its own
mount namespace. Worker invokes the sandbox helper via sudo from there;
the helper's pre-systemd-run `mount --bind --map-users=...` lands in
the web service's namespace. systemd-run then spawns transient units
in PID 1's namespace where the bind is invisible — the BindPaths lookup
finds an empty staging dir owned by root, and the sandbox uid hits
permission-denied on every write.
Mirror the pattern from left4me-overlay's ExecStartPre wrapper: enter
PID 1's mount namespace at the start of the helper via `nsenter
--mount=/proc/1/ns/mnt`. Sentinel env var avoids exec recursion. The
gameserver helper handles this at the unit level; the script helper
doesn't have a unit so we self-wrap.
Diagnosis: 5 failed builds all hit the same EACCES on the first
`mkdir`/`tar mkdir`. Direct SSH-sudo invocations of the same helper
succeeded because SSH-sudo doesn't inherit a private namespace; only
the worker-invoked path is affected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
left4me-script-sandbox now pre-creates an idmapped bind staging path
(--map-users=<left4me_uid>:<sandbox_uid>:1) and points the sandbox's
BindPaths at that staging instead of the raw overlay dir. Writes from
inside the sandbox (uid l4d2-sandbox) land on disk as left4me, so all
overlay content is uniformly left4me-owned end-to-end.
left4me-overlay loses ~165 lines of idmap-on-mount logic: the per-
lowerdir stat + idmap-bind setup, the bind-umount loop in teardown,
the uid lookup helpers, the _is_mountpoint /proc/self/mountinfo parser,
and the LEFT4ME_TEST_* env-var stubs. It's back to a simple "validate
lowerdirs, mount overlay" shape; gameserver mount path no longer needs
to know about producer-side ownership decisions.
Verified on kernel 6.12 that the kernel idmap propagates through
systemd-run's plain re-bind of the staging path. Tests dropped 4
idmap-on-mount specs and one deploy-artifact regression check; added
test_script_sandbox_uses_idmap_staging to pin the new staging path
+ map flags + trap cleanup.
The post-build world-read chmod kludge in the sandbox is also dropped:
the web app reads overlay files via its primary uid (left4me).
Existing overlays on the test server are sandbox-owned from prior runs
and need a one-shot `chown -R left4me:left4me /var/lib/left4me/overlays`
during deploy. New overlays produced by the refactored sandbox are
left4me-owned from creation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Architectural cleanup: the uid translation is a build-time concern
(the sandbox produces sandbox-uid files); having the gameserver path
unwind that producer-side decision on every mount means the mount
helper carries idmap lifecycle code it shouldn't need. Moving the
idmap into the script-sandbox bind makes files land left4me-owned on
disk, drops ~140 lines from left4me-overlay, and makes all overlay
content (workshop + script-built) consistent on-disk.
Verified on left4.me kernel 6.12.86 that the kernel idmap propagates
through plain re-bind, so systemd-run's BindPaths can wrap a
pre-created idmapped staging path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
os.path.ismount() compares st_dev against the parent dir, which silently
returns False for same-fs bind mounts. The idmap binds at runtime/<n>/
idmap/<basename> are exactly that case, so:
- cmd_umount skipped the bind-umount step every stop, leaving orphan
binds in PID 1's mount namespace.
- cmd_mount's idempotency check then "didn't see" the orphan and
re-bound on top, accumulating one mount per start/stop cycle.
Findmnt nesting like
/var/lib/left4me/runtime/2/idmap/overlays_9
└─/var/lib/left4me/runtime/2/idmap/overlays_9
is the visible symptom. Reboot wipes everything so the bug is invisible
on a fresh boot — only stop/start cycles accumulate.
Replace both ismount sites with a _is_mountpoint() helper that reads
/proc/self/mountinfo (column 5 is the mount point). Keep os.path.ismount
for the overlay merged check, where it's reliable (distinct fs type).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 2026-05-15 script-consolidation pass landed a working but
half-finished mental model: deploy/files/ was retroactively promoted
from "historical reference" to "canonical source," but only for the
script files. Several adjacent things (sudoers/sysctl duplication
across both repos, the systemd unit files that ckn-bw's reactor
ignores, deploy-test-server.sh's role, dead-code apply-cake) didn't
get resolved. Capture the open questions and pointers so a future
session can pick this up and commit to a coherent shape.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ckn-bw was shipping the admin CLI wrapper (sudo left4me <flask
subcommand>) verbatim from its own bundle copy. Move ownership of the
file into left4me so ckn-bw's upcoming install-action approach can
deploy it from deploy/files/usr/local/sbin/left4me on the deployed
git checkout, eliminating the cross-repo duplication that masked the
idmap helper update earlier.
Also re-frame deploy/README.md: deploy/files/, deploy/templates/, and
deploy/tests/ are now genuinely canonical (read by ckn-bw via
git_deploy). Only deploy-test-server.sh remains a superseded artifact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spell out that the deploy step for changes to verbatim-shipped files
(privileged helpers, sudoers, sysctl, …) is just re-syncing the bundle's
copy + bw apply. Removes ambiguity for the idmap helper change and any
future edit within the same set.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Guards against silent regression of the idmap bind-mount step in the
privileged kernel-overlayfs helper. Asserts --map-users / --map-groups
argv, the runtime/<name>/idmap/ target path, the LEFT4ME_TEST_* stub-
env-var names, and the collision-detection table.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Issue #1: idmap target now uses parent+name (overlays_workshop instead of
workshop) to prevent basename collisions across allowlist roots; explicit
die() on collision detected in the loop.
Issue #2: env-var uid stubs (renamed to LEFT4ME_TEST_SANDBOX_UID etc.) are
only honoured when LEFT4ME_OVERLAY_PRINT_ONLY=1, so a misconfigured systemd
unit override cannot influence real uid mapping.
Issue #3: os.stat(lowerdir) is wrapped in try/except OSError with a die()
that shell-quotes the path and includes the exception, matching the helper's
existing error style.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Insert an idmapped bind mount in front of each lowerdir whose top-level
uid matches l4d2-sandbox at overlay-mount time, so that overlayfs copy-up
produces left4me-owned upperdir entries instead of EACCES.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Persist the implementation plan for adding idmapped bind mounts to
left4me-overlay so that overlay copy-up from l4d2-sandbox-owned lower
layers (script-built overlays) produces left4me-owned upperdir entries
the gameserver can write. Mechanism verified end-to-end on ovh.left4me
in a temp dir on 2026-05-14.
A 20-attempts-per-60s budget keyed by IP doesn't slow a distributed brute force that rotates source IPs. Add a parallel per-username bucket with the same threshold so a single account can't burn through more than 20 failed logins/min regardless of where they come from. Empty usernames aren't bucketed (would DoS the anonymous 401 path). Successful login clears both buckets.
_load_files_overlay docs already promised "owner or admin" for mutations, but the check only filtered by overlay.type — system overlays (user_id IS NULL) were writable by any logged-in user. Add the explicit 403 for non-admins; read-only routes remain open across all overlay types.
Mirror the delete-route last-admin guard on /admin/users/<id>/deactivate so a future auth-model change (service accounts bypassing require_admin, etc.) can't accidentally lock out the system.
- login_user clears any pre-login session state before stamping user_id/pw_changed_at/admin so a fixated cookie value cannot smuggle data past the login boundary
- logout_user now session.clear()s instead of only popping user_id, removing leftover pw_changed_at/admin markers
- CSRF token comparison uses hmac.compare_digest
- load_current_user rejects sessions where the stamped admin flag no longer matches the user row, preventing a demoted admin from retaining elevated access until next password change (backward-compatible: sessions issued pre-upgrade lack the marker and pass through until next login)
- pendingCommand captured in htmx:beforeRequest (not requestConfig).
- ensureLoaded shares a single inflight Promise across concurrent calls.
- Document why synthetic null-id entries are safe in the cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- _console_line.html: command + reply, error variant, "(no reply)" placeholder.
- server_detail.html: console section between Live State and Files, replays
last 50 history rows server-side; HTMX form appends new lines via hx-swap.
- console-history.js: ArrowUp/Down recall against /console/history JSON;
scroll-to-bottom on load and after each new line.
- CSS: fixed-height scrolling transcript, terminal-ish styling, spinner via
HTMX in-flight class.
- test_console_routes.py: update 4 assertions from legacy [ERROR] literal
to console-error CSS class (matches new semantic markup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ?limit clamp test now actually verifies the clamp instead of just
passing through 5 rows.
- Single is_error assignment per branch, single db.add path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /servers/<id>/console runs a command via rcon.execute_command and
persists every outcome (success / empty / error) to command_history.
- GET /servers/<id>/console/history returns paginated newest-first JSON
for client-side up-arrow recall.
- server_detail() now passes the last 50 history rows as console_history
for server-side replay on page load.
- 404 on ownership mismatch — no admin override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A row per RCON command execution: (user, server, command, reply, is_error,
created_at). Composite index on (user_id, server_id, id) supports the only
query shape — "latest N for this user+server", id DESC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add comment noting _EXEC_REQ_ID/_MARKER_REQ_ID are arbitrary client-chosen
values unrelated to SERVERDATA_* packet-type constants. Update _connect_and_auth
docstring to accurately reflect that OSError/socket.timeout propagate raw from
post-connect send/recv, while only connect failure is wrapped in RconError.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts _connect_and_auth helper from query_status, adds execute_command
using the trailing-marker pattern for multi-packet reassembly, and covers
all paths (happy path, multi-packet, empty reply, auth failure, timeout,
input validation, marker drain) with 10 new tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan for adding a per-server RCON console: HTMX append-swap input form,
fixed-height scrolling transcript replayed from CommandHistory on load,
multi-packet response handling, owner-only access, 30s timeout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Firefox and Safari defer lazy images by one paint cycle even when cached,
causing a blank frame on each innerHTML swap. These avatars are always
in-viewport and cached after the first poll, so lazy loading has no benefit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
outerHTML removes and re-inserts the section on each tick, causing a
blank frame. Keeping the <section> as a stable DOM container and
swapping only innerHTML means avatars and text update in-place without
any teardown/reconstruct cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
64x64 avatarmedium looked soft on high-DPI screens. Switching the
GetPlayerSummaries field to avatarfull (184x184) and constraining
display size to 64px via .live-state .avatar gives sharp rendering on
retina/4k panels at the cost of a slightly larger CDN fetch (still
hot-linked, so no proxying cost).
Also adds the previously-missing CSS for the live-state player grid:
avatar+name+meta arranged in a tight 2-column grid per card, link
spans the avatar+name so the meta stays non-interactive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>