spec(hardening-refactor): revise design — inline-in-reactor, defer drop-in reshape

Going back to the inline-in-reactor shape: hardening directives land in
ckn-bw's systemd_units reactor as shared Python dicts (HARDENING_COMMON
+ HARDENING_SERVER + HARDENING_WEB), spread into each unit's Service
block. Educational reference units in deploy/files/.../*.service stay
and get per-directive comments. Operator discipline hand-syncs the
reference to the reactor; no CI drift test.

The broader responsibility reshape — hardening drop-ins living in
left4me with ckn-bw as a thin file-shipper — is worth pursuing as a
separate dedicated session, not bundled into this refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-15 14:16:02 +02:00
parent 3256ed2ab1
commit 81dc29a9c3
No known key found for this signature in database

View file

@ -7,246 +7,219 @@ Companion: `2026-05-15-hardening-threat-model.md`,
`2026-05-15-hardening-test-plan.md` (executed 2026-05-15, results inline). `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, 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 how they're factored, what's in scope. The implementation plan lays out
steps. the steps.
## Context ## Context
The hardening test plan ran end-to-end on `left4.me` on 2026-05-15 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 (commit `461b8d0`). Outcome: `left4me-server@1` 7.5→1.3 systemd-analyze,
systemd-analyze, web 8.7→4.1, all 8 Test 8 attack vectors blocked) with `left4me-web` 8.7→4.1, all 8 Test 8 attack vectors blocked. Two
two amendments to the spec's proposed directives: `SystemCallArchitectures=native x86` amendments to the spec's proposed composition required: `SystemCallArchitectures=native x86`
(srcds_linux is i386), `PrivatePIDs=true` (same-uid `ProtectProc=invisible` (srcds_linux is i386), `PrivatePIDs=true` (same-uid
can't hide gunicorn from srcds). `MemoryDenyWriteExecute=true` permanently `ProtectProc=invisible` can't hide gunicorn from srcds; PID namespace
excluded (Source engine i386 text relocations). fixes it at the kernel level). `MemoryDenyWriteExecute=true` permanently
excluded (Source engine i386 `.so` files have text relocations).
The composition is *not currently deployed* — Test 7's drop-in was Composition is *not currently deployed* — Test 7's drop-in was cleaned
cleaned up at session end; only the Test 9 sysctl up at session end; only the Test 9 sysctl (`kernel.yama.ptrace_scope=2`)
(`kernel.yama.ptrace_scope=2`) persists. This refactor lands the proven persists. This refactor lands the proven composition permanently via
composition permanently. the ckn-bw bundle.
## Responsibility model ## Approach
**Hardening is application knowledge, not infrastructure knowledge.** Keep the current responsibility split for now: ckn-bw owns systemd unit
Which paths to bind, which syscall groups srcds tolerates (i386 archs, emission (base + hardening), left4me owns the educational reference
text relocations), which directives break sudo on the web app — all of copies and the threat-model/test docs. Hardening directives land in
this is dictated by the left4me threat model and codebase, not by the ckn-bw's `systemd/units` reactor at
host's configuration. The hardening therefore lives **in the left4me `~/Projekte/ckn-bw/bundles/left4me/metadata.py:150+`, factored via
repo**; ckn-bw is responsible for *deploying* it, not *defining* it. shared Python dicts so the two units (and the future
build-overlay-unit refactor) reuse the common base.
The base units (what runs, as which uid, with which env files, with The broader responsibility reshape — hardening as drop-in files
which restart policy) stay in ckn-bw's reactor for now. The broader *living* in left4me with ckn-bw as a thin file-shipper — is a real
"thin ckn-bw" question — should base units also leave the reactor? — direction worth pursuing, but deserves its own session. Deferred.
is a deferred follow-up, deliberately out of scope for this refactor.
### Concretely ## Factoring
- `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` Three dict constants at the top of `metadata.py` (or in a sibling
— drop-in file, owned by left4me, deployed by ckn-bw. Contains the `hardening.py` module if `metadata.py` grows past a comfortable read):
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 ### `HARDENING_COMMON`
`deploy/files/usr/local/lib/systemd/system/left4me-server@.service` and Directives both units take verbatim. ~17 keys:
`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 ```python
HARDENING_COMMON = {
Full content lives in the actual `.conf` files; this section sketches 'ProtectProc': 'invisible',
the shape so the implementation plan has a target. 'ProcSubset': 'pid',
'ProtectKernelTunables': 'true',
### `left4me-server@.service.d/10-hardening.conf` 'ProtectKernelModules': 'true',
'ProtectKernelLogs': 'true',
Header comment: links to threat model + defenses survey + commit 'ProtectClock': 'true',
reference. Then `[Service]` block grouped by concern: 'ProtectControlGroups': 'true',
'ProtectHostname': 'true',
- **Identity / privilege drop**: `NoNewPrivileges`, `RestrictSUIDSGID`, 'LockPersonality': 'true',
`CapabilityBoundingSet=` (empty), `AmbientCapabilities=` (empty) 'ProtectSystem': 'strict',
- **Filesystem virtualization**: `TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media`, 'ProtectHome': 'true',
`BindReadOnlyPaths=` for installation/overlays/host.env/SSL/DNS plumbing, 'PrivateTmp': 'true',
`BindPaths=/var/lib/left4me/runtime/%i`, `ProtectSystem=strict`, 'RestrictNamespaces': 'true',
`ProtectHome=true` 'RestrictRealtime': 'true',
- **Process namespacing**: `PrivateUsers=true`, **`PrivatePIDs=true`**, 'RemoveIPC': 'true',
`PrivateTmp=true`, `PrivateDevices=true`, `PrivateIPC=true`, 'KeyringMode': 'private',
`RestrictNamespaces=true` 'UMask': '0027',
- **/proc + kernel**: `ProtectProc=invisible`, `ProcSubset=pid`, 'RestrictAddressFamilies': 'AF_INET AF_INET6 AF_UNIX',
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
``` ```
### `HARDENING_SERVER`
`{**HARDENING_COMMON, ...server-specific}`. Adds sudo-incompatible
flags + filesystem virtualization + i386 amendment + per-instance PID
namespace + bound socket binds:
- `NoNewPrivileges=true`
- `RestrictSUIDSGID=true`
- `PrivateUsers=true`
- **`PrivatePIDs=true`** *(Test amendment — D2.b / D5)*
- `PrivateIPC=true`
- `PrivateDevices=true`
- `CapabilityBoundingSet=` *(empty value → drop all)*
- `AmbientCapabilities=`
- `SystemCallArchitectures='native x86'` *(Test amendment — i386 srcds)*
- `SystemCallFilter=('@system-service', '~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged')` *(tuple → repeated key)*
- `TemporaryFileSystem='/var/lib /etc /opt /home /root /srv /mnt /media'`
- `BindReadOnlyPaths=('/var/lib/left4me/installation', '/var/lib/left4me/overlays', '/etc/left4me/host.env', '/etc/ssl', '/etc/ca-certificates', '/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/alternatives')`
- `BindPaths='/var/lib/left4me/runtime/%i'`
- `SocketBindAllow=('udp:27000-27999', 'tcp:27000-27999')` *(NEW — lock srcds bindable sockets to the game port range; not tested in Test 7 but cheap defense-in-depth. Concrete range pending verification of `LEFT4ME_PORT_RANGE_*` substitution support in systemd directives; hard-coded range as fallback.)*
### `HARDENING_WEB`
`{**HARDENING_COMMON, ...web-specific}`. Web inherits `ProtectSystem=strict`
from COMMON (was `=full` in the current base unit; this tightens). Adds
a syscall filter *without* `~@privileged` (sudo needs setuid).
**Excludes** `NoNewPrivileges`, `PrivateUsers`, `RestrictSUIDSGID`,
empty `CapabilityBoundingSet` — all sudo-incompatible.
- `SystemCallArchitectures='native'`
- `SystemCallFilter=('@system-service', '~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete')` *(no `~@privileged`)*
Web's existing `ReadWritePaths=/var/lib/left4me` stays in its unit's
inline `Service` dict (web-specific, not common).
### Multi-value directives and empty values
Tuples-of-strings → emitted as repeated `Key=Value` lines by ckn-bw's
systemd-bundle emitter. Existing precedent: `EnvironmentFile` at
`metadata.py:201-204`. Empty values (`CapabilityBoundingSet=`,
`AmbientCapabilities=`) need to emit as `Key=` with nothing after `=`.
Both behaviors verified as the first step of the implementation plan;
fallback approaches if the emitter doesn't handle them: inline-joined
strings where systemd accepts them, or extend the emitter.
## Reference units
Keep `deploy/files/usr/local/lib/systemd/system/left4me-server@.service`
and `deploy/files/usr/local/lib/systemd/system/left4me-web.service` as
**deliberately educational** copies of the deployed units. Each new
hardening directive in the reference gets a one-line comment
explaining the threat it addresses. A cold reader of the repo can open
the reference unit and read the threat model in code form, without
needing to read the ckn-bw bundle or systemd man pages.
Source-of-truth: ckn-bw reactor is what's deployed. Reference units in
left4me are hand-synced. No CI drift test (would be brittle against
comment ordering and structural human-readable formatting); operator
discipline at edit time keeps them aligned. A top-of-file note in each
reference unit points readers at the reactor.
## Scope of the refactor ## Scope of the refactor
1. **Three new files in `deploy/files/etc/...`** with the content 1. **Ckn-bw reactor edits.** Three constants + spread into the two
sketched above. Per-directive comments included. units. Verify tuple-multi-value emission. `metadata.py`.
2. **`ckn-bw/bundles/left4me/metadata.py`** — remove hardening keys 2. **Sysctl drop-in via ckn-bw.** `kernel.yama.ptrace_scope=2`. Move
from `left4me-server@.service` and `left4me-web.service` reactor from host-only `/etc/sysctl.d/99-left4me-ptrace.conf` (applied by
entries. Reactor shrinks to base unit definition only. hand in Test 9) into the bundle's file management. Find the existing
3. **`ckn-bw/bundles/left4me/items.py`** — add deploy step for the sysctl pattern in ckn-bw and follow it.
three drop-in files (mechanism TBD by implementation plan; 3. **Reference unit mirror with educational comments.** Update
candidates: symlinks, `files` entries with `source=` pointing at `deploy/files/usr/local/lib/systemd/system/{left4me-server@,left4me-web}.service`
the post-git_deploy path, or a small dedicated action). to match the reactor's emission, with per-directive comments
4. **Deletion of `deploy/files/usr/local/lib/systemd/system/*.service`** explaining each hardening directive's purpose. Top-of-file note
reference files. Replace with a brief `deploy/README.md` pointer pointing to the reactor.
(or add a section to the existing project README). 4. **Spec bug fixes in the test plan.** Four bugs flagged in
5. **Mark `2026-05-15-user-uid-split-design.md` superseded.** Front-matter `2026-05-15-hardening-test-plan.md`'s output section: PID-lookup
status note + brief explanation that `PrivateUsers` + `PrivatePIDs` race (use `systemctl show -p MainPID --value`), gdb-from-host
+ `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 verification flaw (probe via `systemd-run` inside the same
hardening profile, not via `nsenter` that bypasses it), D5 pgrep hardening profile, not via `nsenter` that bypasses it), D5 pgrep
pattern, `scmp_sys_resolver` package is `seccomp` not pattern, `scmp_sys_resolver` package is `seccomp` not
`libseccomp-dev`. `libseccomp-dev`. Doc-only.
7. **Apt-remove `gdb` + `seccomp` + `libseccomp-dev`** from 5. **Mark `2026-05-15-user-uid-split-design.md` superseded.** Front-matter
`left4.me` after the refactor lands. Test-only tooling; reinstall status note + brief explanation that `PrivateUsers` + `PrivatePIDs`
on demand for future test sessions. + `TemporaryFileSystem` close D1, D2, D3, D5 at the kernel level.
Reference this design + the refactor plan as the replacement.
6. **`SocketBindAllow=` for srcds** (in `HARDENING_SERVER`). Not tested
in Test 7; verify on deploy. Encoding pending — likely hard-coded
port range, since systemd directive variable substitution support
is uneven.
7. **Cleanup unmanaged packages on left4.me.** `apt remove gdb seccomp
libseccomp-dev` after the refactor lands. Test-only tooling;
reinstall on demand for future test sessions.
## Deploy sequencing ## Sequencing the deploy
The composition is verified in transient drop-ins. Landing it via 1. Land ckn-bw commit (reactor changes, sysctl drop-in entry).
`bw apply` does: 2. Land left4me commit (reference units, spec bug fixes, uid-split
spec status update, this design doc, the refactor plan).
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. 3. Push both repos.
4. `bw apply ovh.left4me`. ckn-bw `git_deploy`s the left4me tree to 4. `bw apply ovh.left4me` — applies reactor changes; systemd restarts
`/opt/left4me/src/`; the new items step installs the drop-ins to affected units automatically.
`/etc/systemd/system/<unit>.d/`; the reactor's now-shrunken unit
emission triggers systemd reload; affected units restart.
5. Verify on the host: 5. Verify on the host:
- `systemctl cat left4me-server@1` shows the base unit *plus* the - `systemctl cat left4me-server@1` shows the new directives.
hardening drop-in merged. - Re-run a Test 8 subset (D1.a, D1.b, D2.b via PrivatePIDs, D5 with
- Re-run a Test 8 subset (D1.a DB invisibility, D1.b web.env the corrected pgrep) using the *corrected* probe pattern (per
invisibility, D2.b same-uid /proc hidden, D5 cross-instance spec bug fix in scope item 4). Test 8's full rerun is unnecessary
ptrace blocked) using the *corrected* probe pattern (per spec bug — composition is proven; only the *deployment* needs verifying.
fix in step 6 of "Scope"). - `sysctl kernel.yama.ptrace_scope` = 2.
- Smoke: server@1 active, server@2 active, web responds, RCON - Smoke: server@1 + server@2 + web all active and stable for 10
works, overlay build succeeds. minutes. Web UI: login, server start/stop, log view, overlay
6. Rollback if needed: `git revert` the ckn-bw commit (or the left4me rebuild.
commit — base + drop-ins are decoupled now, so either revert is 6. Rollback if needed: `git revert` the ckn-bw commit + `bw apply`.
bounded) + `bw apply`.
## What this approach buys ## What's out of scope
- **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. - **`MemoryDenyWriteExecute=true`** — permanently excluded.
- **AppArmor profile** — deferred per defenses-survey. - **AppArmor profile** — deferred per defenses-survey.
- **`build-overlay-unit` refactor** (`2026-05-15-build-overlay-unit-design.md`) - **`build-overlay-unit` refactor**
— sequenced after. (`2026-05-15-build-overlay-unit-design.md`) — sequenced after this.
Will reuse `HARDENING_COMMON` (or a variant) when it lands.
- **3-user uid split**`2026-05-15-user-uid-split-design.md` - **3-user uid split**`2026-05-15-user-uid-split-design.md`
superseded by this refactor (item 5 above). superseded by this refactor (scope item 5).
- **Broader configmgmt-responsibility reshape** — base units leaving - **Broader configmgmt-responsibility reshape** — hardening as
the ckn-bw reactor, ckn-bw becoming a thin file-shipper, etc. Real drop-ins living in left4me, ckn-bw becoming a thin file-shipper.
refactor; deserves its own session. Out of scope here. Real direction worth pursuing; deserves a dedicated session.
- **Stale RCON port app bug** — flagged in executor's handoff Out of scope here.
(`2026-05-15-session-handoff.md`). Separate scope. - **Stale RCON port app bug** — flagged in executor's handoff. Separate
scope.
- **Pushing the branch** — operator decides when. - **Pushing the branch** — operator decides when.
## Open items resolved in implementation, not design ## Open items resolved in implementation, not design
- Mechanism for ckn-bw to ship the drop-ins from - Does the systemd-bundle emitter handle `('a', 'b')` tuples as
`/opt/left4me/src/deploy/files/etc/systemd/...` to `/etc/systemd/...`. repeated `Key=` lines, and `''` as `Key=` empty value? Verify as the
Pick during implementation; candidates: symlink, `files` entry with first step of the plan; fallback strategies if not.
`source=` of post-git_deploy path, dedicated action. Decision driven - `SocketBindAllow=` value: hard-coded range vs. variable
by what ckn-bw idioms already exist. substitution. Determined during emitter verification.
- `SocketBindAllow=` value encoding (variable substitution support in
the directive is uncertain — may need hard-coded range or templated
via a sysctl-style approach).
## Pointers ## Pointers
- Threat model: `2026-05-15-hardening-threat-model.md` - Threat model: `2026-05-15-hardening-threat-model.md`
- Defenses survey: `2026-05-15-hardening-defenses-survey.md` - Defenses survey: `2026-05-15-hardening-defenses-survey.md` (§ 5
candidate composition is the basis for the factoring above)
- Test plan + results: `2026-05-15-hardening-test-plan.md` - Test plan + results: `2026-05-15-hardening-test-plan.md`
(commit `461b8d0`) (commit `461b8d0`)
- Executor's handoff: `2026-05-15-session-handoff.md` - Executor's handoff: `2026-05-15-session-handoff.md`
(commit `152c313`) (commit `152c313`)
- Live reactor: `~/Projekte/ckn-bw/bundles/left4me/metadata.py:150+` - 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` - Reference units: `deploy/files/usr/local/lib/systemd/system/`
- Adjacent (sequenced after this): `2026-05-15-build-overlay-unit-design.md` - Deferred uid-split spec: `2026-05-15-user-uid-split-design.md`
- Adjacent (sequenced after): `2026-05-15-build-overlay-unit-design.md`