left4me/docs/superpowers/specs/2026-05-15-hardening-refactor-design.md
mwiegand 3256ed2ab1
spec(hardening-refactor): design — drop-ins owned by left4me, ckn-bw deploys
Hardening composition is application knowledge (which paths to bind, that
srcds is i386, what breaks sudo). It belongs in the left4me repo as
drop-in .conf files under deploy/files/etc/systemd/system/<unit>.d/.
ckn-bw shrinks: keeps the base units in its reactor, removes the
hardening keys, ships the drop-ins to /etc/systemd/system/. Existing
educational reference units in deploy/files/.../*.service are deleted in
favor of the drop-ins, which carry per-directive comments. Broader
configmgmt-responsibility reshape (base units leaving the reactor)
deliberately deferred to a future session.

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

12 KiB

Hardening refactor — design

Status: approved design; implementation plan to follow at docs/superpowers/plans/2026-05-15-hardening-refactor.md. Companion: 2026-05-15-hardening-threat-model.md, 2026-05-15-hardening-defenses-survey.md, 2026-05-15-hardening-test-plan.md (executed 2026-05-15, results inline).

This doc records the shape of the refactor — where the artifacts live, who owns what, what's in scope. The implementation plan lays out the steps.

Context

The hardening test plan ran end-to-end on left4.me on 2026-05-15 (commit 461b8d0). The composition is proven (server@1 7.5→1.3 systemd-analyze, web 8.7→4.1, all 8 Test 8 attack vectors blocked) with two amendments to the spec's proposed directives: SystemCallArchitectures=native x86 (srcds_linux is i386), PrivatePIDs=true (same-uid ProtectProc=invisible can't hide gunicorn from srcds). MemoryDenyWriteExecute=true permanently excluded (Source engine i386 text relocations).

The composition is not currently deployed — Test 7's drop-in was cleaned up at session end; only the Test 9 sysctl (kernel.yama.ptrace_scope=2) persists. This refactor lands the proven composition permanently.

Responsibility model

Hardening is application knowledge, not infrastructure knowledge. Which paths to bind, which syscall groups srcds tolerates (i386 archs, text relocations), which directives break sudo on the web app — all of this is dictated by the left4me threat model and codebase, not by the host's configuration. The hardening therefore lives in the left4me repo; ckn-bw is responsible for deploying it, not defining it.

The base units (what runs, as which uid, with which env files, with which restart policy) stay in ckn-bw's reactor for now. The broader "thin ckn-bw" question — should base units also leave the reactor? — is a deferred follow-up, deliberately out of scope for this refactor.

Concretely

  • deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf — drop-in file, owned by left4me, deployed by ckn-bw. Contains the proven Test 7 directive set with the two amendments and the SocketBindAllow= addition. Per-directive comments document the threat each addresses.
  • deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf — drop-in file, owned by left4me, deployed by ckn-bw. Contains the Test 10 sudo-compatible subset.
  • deploy/files/etc/sysctl.d/99-left4me-ptrace.confkernel.yama.ptrace_scope = 2. Same pattern: lives here, ckn-bw ships it.
  • ~/Projekte/ckn-bw/bundles/left4me/metadata.py systemd/units reactor — hardening directives removed from the left4me-server@.service and left4me-web.service entries. Reactor keeps the base shape only: Type, User, Group, EnvironmentFile, ExecStart + ExecStartPre/ExecStopPost (the +-prefixed nsenter overlay-mount), Restart, Slice, resource limits (MemoryMax, LimitNOFILE, OOMScoreAdjust, etc.).
  • ~/Projekte/ckn-bw/bundles/left4me/items.py — small addition: ship the three drop-in files from /opt/left4me/src/deploy/files/etc/... to /etc/.... Mechanism (symlink vs. file-copy via files entry) is a small implementation decision, deferred to the plan.

What gets deleted

deploy/files/usr/local/lib/systemd/system/left4me-server@.service and deploy/files/usr/local/lib/systemd/system/left4me-web.service (the existing reference samples). These were intended as educational copies of the ckn-bw-emitted units, but with hardening now living as drop-ins in this repo, the educational value moves to those drop-ins. The base unit definition lives only in the ckn-bw reactor; a top-level deploy/README.md (or addition to an existing README) points readers at ~/Projekte/ckn-bw/bundles/left4me/metadata.py for the base shape and at deploy/files/etc/systemd/system/<unit>.d/ for the hardening.

Drop-in content sketches

Full content lives in the actual .conf files; this section sketches the shape so the implementation plan has a target.

left4me-server@.service.d/10-hardening.conf

Header comment: links to threat model + defenses survey + commit reference. Then [Service] block grouped by concern:

  • Identity / privilege drop: NoNewPrivileges, RestrictSUIDSGID, CapabilityBoundingSet= (empty), AmbientCapabilities= (empty)
  • Filesystem virtualization: TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media, BindReadOnlyPaths= for installation/overlays/host.env/SSL/DNS plumbing, BindPaths=/var/lib/left4me/runtime/%i, ProtectSystem=strict, ProtectHome=true
  • Process namespacing: PrivateUsers=true, PrivatePIDs=true, PrivateTmp=true, PrivateDevices=true, PrivateIPC=true, RestrictNamespaces=true
  • /proc + kernel: ProtectProc=invisible, ProcSubset=pid, all six Protect*=true (kernel tunables/modules/logs/clock/control-groups/hostname), LockPersonality=true
  • Syscall filter: SystemCallArchitectures=native x86 (the i386 amendment, with comment explaining srcds is 32-bit Source 2007), SystemCallFilter=@system-service + ~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged
  • Network: RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX, SocketBindAllow= for the gameserver port range (encoding TBD by the plan — may need to hard-code the range, since systemd directive variable substitution isn't universal)
  • Hygiene: RestrictRealtime=true, RemoveIPC=true, KeyringMode=private, UMask=0027

Plus an inline comment block explaining MemoryDenyWriteExecute=true is permanently excluded and why.

left4me-web.service.d/10-hardening.conf

Header comment: explicit list of directives omitted and why (sudo). Then [Service] block:

  • Filesystem: ProtectSystem=strict (was =full in the base unit; this tightens), ProtectHome=true
  • /proc + kernel: same set as the server drop-in
  • Syscall filter: SystemCallArchitectures=native, SystemCallFilter=@system-service + ~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete without ~@privileged (sudo needs setuid)
  • Network: RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
  • Hygiene: RestrictNamespaces=true, RestrictRealtime=true, RemoveIPC=true, UMask=0027

Omitted (with header-comment rationale): NoNewPrivileges, PrivateUsers, RestrictSUIDSGID, CapabilityBoundingSet=, ~@privileged. All sudo-incompatible. A future "replace sudo with systemctl-managed transient units" refactor (the build-overlay-unit spec moves in that direction) will let us tighten this drop-in.

etc/sysctl.d/99-left4me-ptrace.conf

One-line file:

# Block ptrace unless tracer has CAP_SYS_PTRACE. Belt-and-braces with
# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit.
kernel.yama.ptrace_scope = 2

Scope of the refactor

  1. Three new files in deploy/files/etc/... with the content sketched above. Per-directive comments included.
  2. ckn-bw/bundles/left4me/metadata.py — remove hardening keys from left4me-server@.service and left4me-web.service reactor entries. Reactor shrinks to base unit definition only.
  3. ckn-bw/bundles/left4me/items.py — add deploy step for the three drop-in files (mechanism TBD by implementation plan; candidates: symlinks, files entries with source= pointing at the post-git_deploy path, or a small dedicated action).
  4. Deletion of deploy/files/usr/local/lib/systemd/system/*.service reference files. Replace with a brief deploy/README.md pointer (or add a section to the existing project README).
  5. Mark 2026-05-15-user-uid-split-design.md superseded. Front-matter status note + brief explanation that PrivateUsers + PrivatePIDs
    • TemporaryFileSystem close D1, D2, D3, D5 at the kernel level. This design + the refactor plan are the replacement.
  6. Spec bug fixes in 2026-05-15-hardening-test-plan.md. The four bugs documented in the test plan's "Output" section: PID-lookup race (use systemctl show -p MainPID --value), gdb-from-host verification flaw (probe via systemd-run inside the same hardening profile, not via nsenter that bypasses it), D5 pgrep pattern, scmp_sys_resolver package is seccomp not libseccomp-dev.
  7. Apt-remove gdb + seccomp + libseccomp-dev from left4.me after the refactor lands. Test-only tooling; reinstall on demand for future test sessions.

Deploy sequencing

The composition is verified in transient drop-ins. Landing it via bw apply does:

  1. Land left4me commit (new drop-ins + reference cleanup + spec bug fixes + uid-split spec status update + this design doc + the refactor plan).
  2. Land ckn-bw commit (reactor shrink + drop-in deploy mechanism).
  3. Push both repos.
  4. bw apply ovh.left4me. ckn-bw git_deploys the left4me tree to /opt/left4me/src/; the new items step installs the drop-ins to /etc/systemd/system/<unit>.d/; the reactor's now-shrunken unit emission triggers systemd reload; affected units restart.
  5. Verify on the host:
    • systemctl cat left4me-server@1 shows the base unit plus the hardening drop-in merged.
    • Re-run a Test 8 subset (D1.a DB invisibility, D1.b web.env invisibility, D2.b same-uid /proc hidden, D5 cross-instance ptrace blocked) using the corrected probe pattern (per spec bug fix in step 6 of "Scope").
    • Smoke: server@1 active, server@2 active, web responds, RCON works, overlay build succeeds.
  6. Rollback if needed: git revert the ckn-bw commit (or the left4me commit — base + drop-ins are decoupled now, so either revert is bounded) + bw apply.

What this approach buys

  • Test artifact = production artifact. The Test 7 drop-in is literally what gets deployed. No translation, no factoring step between proof and prod.
  • Application-level clarity. A cold reader of left4me opens deploy/files/etc/systemd/system/<unit>.d/10-hardening.conf and reads the threat model in code form, per-directive comments explaining each defense.
  • Easy rollback. Drop-ins are removable as a unit. Base unit is unaffected if hardening misfires.
  • Pattern for the build-overlay-unit refactor. When that work lands (queued), its unit ships its own drop-in the same way.
  • Smaller ckn-bw reactor. Removing the hardening from the reactor makes the reactor a clearer "this service exists" definition.

What's out of scope (explicit, deferred)

  • MemoryDenyWriteExecute=true — permanently excluded.
  • AppArmor profile — deferred per defenses-survey.
  • build-overlay-unit refactor (2026-05-15-build-overlay-unit-design.md) — sequenced after.
  • 3-user uid split2026-05-15-user-uid-split-design.md superseded by this refactor (item 5 above).
  • Broader configmgmt-responsibility reshape — base units leaving the ckn-bw reactor, ckn-bw becoming a thin file-shipper, etc. Real refactor; deserves its own session. Out of scope here.
  • Stale RCON port app bug — flagged in executor's handoff (2026-05-15-session-handoff.md). Separate scope.
  • Pushing the branch — operator decides when.

Open items resolved in implementation, not design

  • Mechanism for ckn-bw to ship the drop-ins from /opt/left4me/src/deploy/files/etc/systemd/... to /etc/systemd/.... Pick during implementation; candidates: symlink, files entry with source= of post-git_deploy path, dedicated action. Decision driven by what ckn-bw idioms already exist.
  • SocketBindAllow= value encoding (variable substitution support in the directive is uncertain — may need hard-coded range or templated via a sysctl-style approach).

Pointers

  • Threat model: 2026-05-15-hardening-threat-model.md
  • Defenses survey: 2026-05-15-hardening-defenses-survey.md
  • Test plan + results: 2026-05-15-hardening-test-plan.md (commit 461b8d0)
  • Executor's handoff: 2026-05-15-session-handoff.md (commit 152c313)
  • Live reactor: ~/Projekte/ckn-bw/bundles/left4me/metadata.py:150+
  • Deferred uid-split spec (to be marked superseded): 2026-05-15-user-uid-split-design.md
  • Adjacent (sequenced after this): 2026-05-15-build-overlay-unit-design.md