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>
This commit is contained in:
parent
152c313315
commit
3256ed2ab1
1 changed files with 252 additions and 0 deletions
252
docs/superpowers/specs/2026-05-15-hardening-refactor-design.md
Normal file
252
docs/superpowers/specs/2026-05-15-hardening-refactor-design.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# 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/<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_deploy`s 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 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`
|
||||||
Loading…
Reference in a new issue