# 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.conf` — `kernel.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/.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_deploy`s the left4me tree to `/opt/left4me/src/`; the new items step installs the drop-ins to `/etc/systemd/system/.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/.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 split** — `2026-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`