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>
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 theSocketBindAllow=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.conf—kernel.yama.ptrace_scope = 2. Same pattern: lives here, ckn-bw ships it.~/Projekte/ckn-bw/bundles/left4me/metadata.pysystemd/unitsreactor — hardening directives removed from theleft4me-server@.serviceandleft4me-web.serviceentries. 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 viafilesentry) 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 sixProtect*=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=fullin 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 @obsoletewithout~@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
- Three new files in
deploy/files/etc/...with the content sketched above. Per-directive comments included. ckn-bw/bundles/left4me/metadata.py— remove hardening keys fromleft4me-server@.serviceandleft4me-web.servicereactor entries. Reactor shrinks to base unit definition only.ckn-bw/bundles/left4me/items.py— add deploy step for the three drop-in files (mechanism TBD by implementation plan; candidates: symlinks,filesentries withsource=pointing at the post-git_deploy path, or a small dedicated action).- Deletion of
deploy/files/usr/local/lib/systemd/system/*.servicereference files. Replace with a briefdeploy/README.mdpointer (or add a section to the existing project README). - Mark
2026-05-15-user-uid-split-design.mdsuperseded. Front-matter status note + brief explanation thatPrivateUsers+PrivatePIDsTemporaryFileSystemclose D1, D2, D3, D5 at the kernel level. This design + the refactor plan are the replacement.
- 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 (usesystemctl show -p MainPID --value), gdb-from-host verification flaw (probe viasystemd-runinside the same hardening profile, not viansenterthat bypasses it), D5 pgrep pattern,scmp_sys_resolverpackage isseccompnotlibseccomp-dev. - Apt-remove
gdb+seccomp+libseccomp-devfromleft4.meafter 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:
- Land left4me commit (new drop-ins + reference cleanup + spec bug fixes + uid-split spec status update + this design doc + the refactor plan).
- Land ckn-bw commit (reactor shrink + drop-in deploy mechanism).
- Push both repos.
bw apply ovh.left4me. ckn-bwgit_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.- Verify on the host:
systemctl cat left4me-server@1shows 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.
- Rollback if needed:
git revertthe 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.confand 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-unitrefactor (2026-05-15-build-overlay-unit-design.md) — sequenced after.- 3-user uid split —
2026-05-15-user-uid-split-design.mdsuperseded 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,filesentry withsource=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(commit461b8d0) - Executor's handoff:
2026-05-15-session-handoff.md(commit152c313) - 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