Implementation plan for 2026-05-15-deployment-responsibility-design.md. Bite-sized steps per task; each task ends with both repos committed and ovh.left4me idempotent. Tasks: (1) sysctl consolidation canary, (2) hardening drop-ins, (3) sudoers symlink, (4) scripts relocation + symlinks, (5) cleanup + docs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1135 lines
45 KiB
Markdown
1135 lines
45 KiB
Markdown
# Deployment Responsibility Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make `left4me/deploy/` the single source of truth for static application-shape deployment artifacts (hardening drop-ins, sudoers, sysctl, helpers). ckn-bw delivers them via target-side symlinks into the (root-owned) `/opt/left4me/src/deploy/...` checkout.
|
|
|
|
**Architecture:** Five independent landable migration steps. Sysctl consolidation is the canary that validates the symlink mechanism end-to-end before higher-stakes artifacts (hardening, sudoers, scripts) follow. Base systemd unit bodies + slice CPU pinning stay bw-managed; only static drop-ins and stand-alone files move. Each step touches both repos (left4me + ckn-bw) and is verified on `ovh.left4me` via `bw apply` + on-host inspection.
|
|
|
|
**Tech Stack:** bundlewrap (`bw apply`, `symlinks{}`/`files{}` item types), systemd (`systemctl daemon-reload`, drop-ins), sysctl (`sysctl --system`), Python (pytest, ckn-bw items.py + metadata.py), git.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`. Reread it before starting if anything below is ambiguous.
|
|
|
|
---
|
|
|
|
## Preflight
|
|
|
|
- [ ] **Step 0.1: Confirm prereq landed.**
|
|
|
|
```bash
|
|
ssh root@ovh.left4me 'stat -c "%U:%G %a %n" /opt/left4me/src /var/lib/left4me/.venv /var/lib/left4me/steam'
|
|
```
|
|
|
|
Expected: `/opt/left4me/src` is `root:root 755`; `.venv` and `steam` are `left4me:left4me`. If not, stop — the runtime-state relocation (`2026-05-15-runtime-state-relocation-design.md`) hasn't landed and target-side symlinks won't be safe.
|
|
|
|
- [ ] **Step 0.2: Capture baseline state. Save the output to `/tmp/preflight-baseline.txt` for cross-checking later.**
|
|
|
|
```bash
|
|
ssh root@ovh.left4me '
|
|
echo "=== sysctl ==="
|
|
sysctl kernel.yama.ptrace_scope net.core.rmem_max net.ipv4.tcp_congestion_control
|
|
echo "=== left4me-web hardening (selected) ==="
|
|
systemctl show -p ProtectSystem,ProtectHome,PrivateTmp,ProtectProc,SystemCallArchitectures,SystemCallFilter left4me-web.service
|
|
echo "=== left4me-server@ hardening (selected, instance 1) ==="
|
|
systemctl show -p ProtectSystem,PrivateUsers,PrivatePIDs,NoNewPrivileges,CapabilityBoundingSet,SystemCallArchitectures left4me-server@1.service 2>/dev/null || echo "(no instance 1 running; use any active instance or the unit-file value via systemctl cat)"
|
|
echo "=== sudo for left4me ==="
|
|
sudo -l -U left4me 2>&1 | head -30
|
|
'
|
|
```
|
|
|
|
Save the output; you will diff against these values after each migration step.
|
|
|
|
---
|
|
|
|
## Task 1: Sysctl canary — consolidate `99-left4me.conf` + symlink
|
|
|
|
**Goal:** Move `kernel.yama.ptrace_scope` from ckn-bw metadata into the existing sysctl drop-in in left4me; replace the verbatim mirror in ckn-bw with a symlink item. Smallest possible change that validates the full mechanism (file content in left4me, symlink in ckn-bw, content propagates on `bw apply`, on-host kernel state matches).
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/left4me/deploy/files/etc/sysctl.d/99-left4me.conf`
|
|
- Modify: `~/Projekte/left4me/deploy/tests/test_example_units.py:182-198`
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py:86-96`
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/items.py` (replace `files` entry, add `symlinks` block)
|
|
- Delete: `~/Projekte/ckn-bw/bundles/left4me/files/etc/sysctl.d/99-left4me.conf`
|
|
|
|
### Steps
|
|
|
|
- [ ] **Step 1.1: Append `ptrace_scope` to the sysctl drop-in in left4me.**
|
|
|
|
Edit `deploy/files/etc/sysctl.d/99-left4me.conf`, append (preserving the trailing newline):
|
|
|
|
```
|
|
# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces with
|
|
# SystemCallFilter=~@debug + PrivateUsers=true in the gameserver unit.
|
|
# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md.
|
|
kernel.yama.ptrace_scope = 2
|
|
```
|
|
|
|
- [ ] **Step 1.2: Update the left4me test to assert the new line.**
|
|
|
|
In `deploy/tests/test_example_units.py:182-198`, add one entry to the tuple in `test_sysctl_conf_present_with_perf_settings`:
|
|
|
|
```python
|
|
def test_sysctl_conf_present_with_perf_settings():
|
|
assert SYSCTL_CONF.is_file()
|
|
text = SYSCTL_CONF.read_text()
|
|
for line in (
|
|
"net.core.rmem_max = 8388608",
|
|
"net.core.wmem_max = 8388608",
|
|
"net.core.rmem_default = 524288",
|
|
"net.core.wmem_default = 524288",
|
|
"net.core.netdev_max_backlog = 5000",
|
|
"net.core.netdev_budget = 600",
|
|
"vm.swappiness = 10",
|
|
"net.ipv4.udp_rmem_min = 16384",
|
|
"net.ipv4.udp_wmem_min = 16384",
|
|
"net.core.default_qdisc = fq_codel",
|
|
"net.ipv4.tcp_congestion_control = bbr",
|
|
"kernel.yama.ptrace_scope = 2",
|
|
):
|
|
assert line in text, f"missing {line!r} in 99-left4me.conf"
|
|
```
|
|
|
|
- [ ] **Step 1.3: Run the test and watch it pass.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
pytest deploy/tests/test_example_units.py::test_sysctl_conf_present_with_perf_settings -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 1.4: Commit the left4me side.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add deploy/files/etc/sysctl.d/99-left4me.conf deploy/tests/test_example_units.py
|
|
git commit -m "deploy/sysctl: absorb kernel.yama.ptrace_scope into the drop-in
|
|
|
|
Single source of truth for left4me sysctl tuning. The metadata entry
|
|
in ckn-bw (sysctl/kernel/yama/ptrace_scope) is removed in lockstep;
|
|
the live value is unchanged.
|
|
|
|
Part of 2026-05-15-deployment-responsibility-design.md migration step 1
|
|
(canary)."
|
|
git push
|
|
```
|
|
|
|
- [ ] **Step 1.5: Remove the metadata entry in ckn-bw.**
|
|
|
|
In `~/Projekte/ckn-bw/bundles/left4me/metadata.py`, delete the `'sysctl'` block from `defaults` (lines ~86-96):
|
|
|
|
```python
|
|
# DELETE THIS BLOCK:
|
|
'sysctl': {
|
|
# Block ptrace except from CAP_SYS_PTRACE holders. Belt-and-braces
|
|
# with SystemCallFilter=~@debug + PrivateUsers=true in the gameserver
|
|
# unit. See:
|
|
# left4me docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
|
|
'kernel': {
|
|
'yama': {
|
|
'ptrace_scope': '2',
|
|
},
|
|
},
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 1.6: Replace the bw `files{}` entry with a `symlinks{}` entry in ckn-bw.**
|
|
|
|
In `~/Projekte/ckn-bw/bundles/left4me/items.py`:
|
|
|
|
(a) Find the `files` dict and **remove** the `'/etc/sysctl.d/99-left4me.conf'` entry:
|
|
|
|
```python
|
|
# DELETE FROM `files = { ... }`:
|
|
'/etc/sysctl.d/99-left4me.conf': {
|
|
'source': 'etc/sysctl.d/99-left4me.conf',
|
|
'mode': '0644',
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'triggers': [
|
|
'action:left4me_sysctl_reload',
|
|
],
|
|
},
|
|
```
|
|
|
|
(b) Add a new `symlinks` block (if one doesn't exist yet) near the `files` dict:
|
|
|
|
```python
|
|
symlinks = {
|
|
'/etc/sysctl.d/99-left4me.conf': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_sysctl_reload',
|
|
],
|
|
},
|
|
}
|
|
```
|
|
|
|
The `needs: git_deploy:/opt/left4me/src` is critical — without it bw may try to create the symlink before the checkout exists on a fresh host.
|
|
|
|
- [ ] **Step 1.7: Delete the verbatim mirror.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
rm bundles/left4me/files/etc/sysctl.d/99-left4me.conf
|
|
rmdir bundles/left4me/files/etc/sysctl.d 2>/dev/null || true # if empty
|
|
```
|
|
|
|
- [ ] **Step 1.8: Run `bw test` to catch config errors locally.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw test
|
|
```
|
|
|
|
Expected: no errors for the left4me bundle.
|
|
|
|
- [ ] **Step 1.9: Apply to the production host.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me
|
|
```
|
|
|
|
Expected output: shows the file→symlink replacement (bw removes the file, creates the symlink), fires `left4me_sysctl_reload`. The `sysctl` bundle no longer emits a separate ptrace_scope file (the metadata entry is gone), so that file goes away too. Net new state: one symlink at `/etc/sysctl.d/99-left4me.conf` plus the kernel parameter set.
|
|
|
|
- [ ] **Step 1.10: Verify on the host.**
|
|
|
|
```bash
|
|
ssh root@ovh.left4me '
|
|
echo "=== symlink ==="
|
|
ls -la /etc/sysctl.d/99-left4me.conf
|
|
echo "=== kernel value ==="
|
|
sysctl kernel.yama.ptrace_scope
|
|
echo "=== perf values still set ==="
|
|
sysctl net.core.rmem_max net.ipv4.tcp_congestion_control
|
|
echo "=== bw-generated ptrace_scope file is gone ==="
|
|
ls /etc/sysctl.d/ | grep -i yama || echo "(no yama files — good)"
|
|
'
|
|
```
|
|
|
|
Expected:
|
|
- `/etc/sysctl.d/99-left4me.conf` → `/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf` symlink
|
|
- `kernel.yama.ptrace_scope = 2`
|
|
- `net.core.rmem_max = 8388608` and `net.ipv4.tcp_congestion_control = bbr`
|
|
- No separate ptrace-related file in `/etc/sysctl.d/`
|
|
|
|
- [ ] **Step 1.11: Re-apply to confirm idempotent.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me
|
|
```
|
|
|
|
Expected: `0 fixed, 0 failed`. No actions re-fire.
|
|
|
|
- [ ] **Step 1.12: Commit ckn-bw side.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/metadata.py bundles/left4me/items.py
|
|
git add bundles/left4me/files/etc/sysctl.d/ # captures the deletion
|
|
git commit -m "left4me: symlink /etc/sysctl.d/99-left4me.conf to the checkout
|
|
|
|
Sysctl drop-in lives in left4me/deploy/files/etc/sysctl.d/99-left4me.conf
|
|
(absorbed kernel.yama.ptrace_scope from the metadata entry). Deliver
|
|
via target-side symlink instead of a verbatim copy.
|
|
|
|
Canary for the deployment-responsibility reshape (left4me design doc
|
|
2026-05-15-deployment-responsibility-design.md, step 1). Validated
|
|
end-to-end on ovh.left4me: symlink resolves to the checkout,
|
|
sysctl --system fires on apply, kernel value matches, idempotent."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Hardening drop-ins — extract from reactor, symlink in
|
|
|
|
**Goal:** Extract `HARDENING_COMMON` / `HARDENING_SERVER` / `HARDENING_WEB` from ckn-bw's Python source into two `.conf` drop-in files in left4me. Reactor stops splatting hardening into the unit body. ckn-bw deploys the drop-ins via target-side symlinks. Reference units in `deploy/files/` lose their inline hardening (which moves into the drop-in files).
|
|
|
|
This is the biggest task. It runs `systemctl daemon-reload` on apply and changes the live unit-level enforcement state of `left4me-web.service` and `left4me-server@.service`. Restart both after applying to ensure the new effective directives are in force.
|
|
|
|
**Files:**
|
|
- Create: `~/Projekte/left4me/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf`
|
|
- Create: `~/Projekte/left4me/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf`
|
|
- Modify: `~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service` (strip hardening directives + comments; keep base-unit content)
|
|
- Modify: `~/Projekte/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service` (same)
|
|
- Modify: `~/Projekte/left4me/deploy/tests/test_example_units.py` (move hardening assertions to a new test for the drop-ins; remove from the unit-body tests)
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/metadata.py` (delete `HARDENING_*` constants; remove `**HARDENING_WEB` / `**HARDENING_SERVER` splats from the reactor)
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/items.py` (add `directory` items for the `.service.d/` dirs; add `symlinks` for the drop-ins)
|
|
|
|
### Steps
|
|
|
|
- [ ] **Step 2.1: Create the web drop-in in left4me.**
|
|
|
|
Move the existing hardening directives + their per-directive comments out of `deploy/files/usr/local/lib/systemd/system/left4me-web.service` into a new file at `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf`. The new file's `[Service]` section contains every directive currently between the per-directive comment block in the reference unit. Concretely:
|
|
|
|
```ini
|
|
# Hardening drop-in for left4me-web.service.
|
|
#
|
|
# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys
|
|
# it to /etc/systemd/system/left4me-web.service.d/10-hardening.conf via a
|
|
# target-side symlink into the checkout.
|
|
#
|
|
# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md
|
|
# and 2026-05-15-hardening-test-plan.md for the threat model and the
|
|
# verification matrix.
|
|
#
|
|
# This unit is the web app; some sudo-incompatible directives are
|
|
# intentionally absent:
|
|
# NoNewPrivileges — blocks sudo's setuid escalation
|
|
# PrivateUsers — breaks sudo's host-root mapping
|
|
# RestrictSUIDSGID — blocks setuid()/setgid()
|
|
# CapabilityBoundingSet= — empty value would deny sudo's caps
|
|
# ~@privileged in SystemCallFilter — blocks sudo's setuid syscall
|
|
# All of those are unconditional on the gameserver unit (no sudo there).
|
|
[Service]
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
PrivateTmp=true
|
|
ProtectProc=invisible
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectKernelLogs=true
|
|
ProtectClock=true
|
|
ProtectControlGroups=true
|
|
ProtectHostname=true
|
|
LockPersonality=true
|
|
SystemCallArchitectures=native
|
|
SystemCallFilter=@system-service
|
|
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete
|
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
RestrictNamespaces=true
|
|
RestrictRealtime=true
|
|
RemoveIPC=true
|
|
KeyringMode=private
|
|
UMask=0027
|
|
```
|
|
|
|
(Confirm the exact directive list matches `HARDENING_COMMON` + the web-specific additions in ckn-bw's `metadata.py:131-210`. Any directive in `HARDENING_WEB` belongs here.)
|
|
|
|
- [ ] **Step 2.2: Create the gameserver drop-in in left4me.**
|
|
|
|
`deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf`:
|
|
|
|
```ini
|
|
# Hardening drop-in for left4me-server@.service.
|
|
#
|
|
# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys
|
|
# it to /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
|
|
# via a target-side symlink into the checkout.
|
|
#
|
|
# Gameserver unit: full hardening profile. No sudo path inside; no
|
|
# sudo-incompatibility carve-outs.
|
|
[Service]
|
|
NoNewPrivileges=true
|
|
RestrictSUIDSGID=true
|
|
CapabilityBoundingSet=
|
|
AmbientCapabilities=
|
|
# srcds_linux is i386 (Source 2007 engine). Bare 'native' kills every
|
|
# 32-bit syscall and traps srcds_run in a respawn loop.
|
|
SystemCallArchitectures=native x86
|
|
SystemCallFilter=@system-service
|
|
SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged
|
|
TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media
|
|
BindReadOnlyPaths=/var/lib/left4me/installation
|
|
BindReadOnlyPaths=/var/lib/left4me/overlays
|
|
BindReadOnlyPaths=/etc/left4me/host.env
|
|
BindReadOnlyPaths=/etc/ssl
|
|
BindReadOnlyPaths=/etc/ca-certificates
|
|
BindReadOnlyPaths=/etc/resolv.conf
|
|
BindReadOnlyPaths=/etc/nsswitch.conf
|
|
BindReadOnlyPaths=/etc/alternatives
|
|
BindPaths=/var/lib/left4me/runtime/%i
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
PrivateUsers=true
|
|
# PrivatePIDs is the test-plan amendment that closes D2.b: same-uid
|
|
# ProtectProc=invisible cannot hide gunicorn from srcds (both run as
|
|
# uid 980); a private PID namespace does.
|
|
PrivatePIDs=true
|
|
PrivateTmp=true
|
|
PrivateDevices=true
|
|
PrivateIPC=true
|
|
RestrictNamespaces=true
|
|
RestrictRealtime=true
|
|
ProtectProc=invisible
|
|
ProcSubset=pid
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectKernelLogs=true
|
|
ProtectClock=true
|
|
ProtectControlGroups=true
|
|
ProtectHostname=true
|
|
LockPersonality=true
|
|
RemoveIPC=true
|
|
KeyringMode=private
|
|
UMask=0027
|
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
# Lock srcds bindable sockets to the game port range. Hard-coded range
|
|
# because systemd directive variable substitution is uneven for
|
|
# SocketBindAllow.
|
|
SocketBindAllow=udp:27000-27999
|
|
SocketBindAllow=tcp:27000-27999
|
|
# MemoryDenyWriteExecute=true permanently excluded — Source engine i386
|
|
# .so files have text relocations that need mprotect(W+X) during the
|
|
# dynamic linker's relocation pass.
|
|
```
|
|
|
|
(Same cross-check: the directive list must equal `HARDENING_SERVER` in ckn-bw `metadata.py`.)
|
|
|
|
- [ ] **Step 2.3: Strip hardening from the reference unit files in left4me.**
|
|
|
|
Edit `deploy/files/usr/local/lib/systemd/system/left4me-web.service` and `left4me-server@.service`. Remove every line that is now in the corresponding drop-in. Keep:
|
|
- `[Unit]` section as-is
|
|
- `[Service]` base-unit directives: `Type`, `User`, `Group`, `WorkingDirectory`, `Environment=`, `EnvironmentFile=`, `ExecStartPre=`, `ExecStart=`, `ExecStopPost=`, `Restart=`, `RestartSec=`, `ReadWritePaths=`, `Slice=`, `Nice=`, `IOSchedulingClass=`, `IOSchedulingPriority=`, `OOMScoreAdjust=`, `MemoryHigh=`, `MemoryMax=`, `TasksMax=`, `LimitNOFILE=`, `KillSignal=`, `TimeoutStopSec=`, `LogRateLimitIntervalSec=`
|
|
- `[Install]` section as-is
|
|
- The per-directive comment block at the top describing the sudo-incompatibility carve-outs is fine to drop (it's redundant with the drop-in file); leave one short pointer comment in the unit body if useful.
|
|
|
|
The resulting reference unit is the "base" unit: what the reactor emits before the drop-in adds the hardening profile.
|
|
|
|
- [ ] **Step 2.4: Update the left4me test to reflect the split.**
|
|
|
|
In `deploy/tests/test_example_units.py`:
|
|
|
|
(a) Add path constants near the existing ones:
|
|
|
|
```python
|
|
WEB_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-web.service.d/10-hardening.conf"
|
|
SERVER_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf"
|
|
```
|
|
|
|
(b) Remove hardening assertions from `test_web_unit_contains_required_runtime_contract`. The unit-body assertions that **stay** are the base-unit ones (User, Group, WorkingDirectory, PATH, EnvironmentFile, ExecStart, --workers/--threads, NoNewPrivileges-NOT-set, ReadWritePaths, MountFlags-NOT-set). The assertions that **move to a new test** (`test_web_hardening_dropin`) are the hardening directives (`PrivateTmp=true`, `ProtectSystem=full|strict`, etc. — note: `ProtectSystem=full` assertion in the existing test was already incorrect drift; the new assertion should be `ProtectSystem=strict` matching HARDENING_COMMON).
|
|
|
|
(c) Same surgery for `test_server_unit_contains_required_runtime_contract` → split base-unit assertions from hardening assertions.
|
|
|
|
(d) New tests:
|
|
|
|
```python
|
|
def test_web_hardening_dropin_present_with_directives():
|
|
assert WEB_HARDENING_DROPIN.is_file()
|
|
text = WEB_HARDENING_DROPIN.read_text()
|
|
assert "[Service]" in text
|
|
# COMMON
|
|
for d in (
|
|
"ProtectProc=invisible",
|
|
"ProtectKernelTunables=true",
|
|
"ProtectKernelModules=true",
|
|
"ProtectKernelLogs=true",
|
|
"ProtectClock=true",
|
|
"ProtectControlGroups=true",
|
|
"ProtectHostname=true",
|
|
"LockPersonality=true",
|
|
"ProtectSystem=strict",
|
|
"ProtectHome=true",
|
|
"PrivateTmp=true",
|
|
"RestrictNamespaces=true",
|
|
"RestrictRealtime=true",
|
|
"RemoveIPC=true",
|
|
"KeyringMode=private",
|
|
"UMask=0027",
|
|
"RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
|
|
):
|
|
assert d in text, f"missing {d!r} in web hardening drop-in"
|
|
# WEB-specific
|
|
assert "SystemCallArchitectures=native" in text
|
|
assert "SystemCallFilter=@system-service" in text
|
|
assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete" in text
|
|
# WEB must NOT include the sudo-incompatible directives.
|
|
assert "NoNewPrivileges=" not in text
|
|
assert "PrivateUsers=" not in text
|
|
assert "RestrictSUIDSGID=" not in text
|
|
assert "CapabilityBoundingSet=" not in text
|
|
assert "~@privileged" not in text
|
|
|
|
|
|
def test_server_hardening_dropin_present_with_directives():
|
|
assert SERVER_HARDENING_DROPIN.is_file()
|
|
text = SERVER_HARDENING_DROPIN.read_text()
|
|
assert "[Service]" in text
|
|
# Server adds sudo-incompatible flags on top of COMMON.
|
|
for d in (
|
|
"NoNewPrivileges=true",
|
|
"RestrictSUIDSGID=true",
|
|
"PrivateUsers=true",
|
|
"PrivatePIDs=true",
|
|
"PrivateIPC=true",
|
|
"PrivateDevices=true",
|
|
"CapabilityBoundingSet=",
|
|
"AmbientCapabilities=",
|
|
"SystemCallArchitectures=native x86",
|
|
"ProcSubset=pid",
|
|
"TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media",
|
|
"BindReadOnlyPaths=/var/lib/left4me/installation",
|
|
"BindReadOnlyPaths=/var/lib/left4me/overlays",
|
|
"BindReadOnlyPaths=/etc/left4me/host.env",
|
|
"BindPaths=/var/lib/left4me/runtime/%i",
|
|
"SocketBindAllow=udp:27000-27999",
|
|
"SocketBindAllow=tcp:27000-27999",
|
|
):
|
|
assert d in text, f"missing {d!r} in server hardening drop-in"
|
|
assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged" in text
|
|
# MemoryDenyWriteExecute must remain absent (Source engine compat).
|
|
assert "MemoryDenyWriteExecute" not in text
|
|
```
|
|
|
|
- [ ] **Step 2.5: Run all left4me tests.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
pytest deploy/tests/test_example_units.py -v
|
|
```
|
|
|
|
Expected: all PASS (including the two new drop-in tests and the slimmed-down unit-body tests).
|
|
|
|
- [ ] **Step 2.6: Commit left4me side.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
|
|
git add deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf
|
|
git add deploy/files/usr/local/lib/systemd/system/left4me-web.service
|
|
git add deploy/files/usr/local/lib/systemd/system/left4me-server@.service
|
|
git add deploy/tests/test_example_units.py
|
|
git commit -m "deploy: extract hardening into drop-in files alongside the units
|
|
|
|
Hardening directives leave the base unit body and live in:
|
|
deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf
|
|
deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf
|
|
|
|
Reference units now describe just the base operational shape (exec,
|
|
env, restart, resources). Tests split: base-unit content and hardening
|
|
profile are asserted separately.
|
|
|
|
Part of 2026-05-15-deployment-responsibility-design.md migration
|
|
step 2. ckn-bw lands the matching reactor surgery + symlink delivery."
|
|
git push
|
|
```
|
|
|
|
- [ ] **Step 2.7: Drop the HARDENING constants + splats from ckn-bw.**
|
|
|
|
In `~/Projekte/ckn-bw/bundles/left4me/metadata.py`:
|
|
|
|
(a) Delete `HARDENING_COMMON`, `HARDENING_SERVER`, `HARDENING_WEB` (lines ~122-210). All three constants and their comment block go away.
|
|
|
|
(b) Remove `**HARDENING_WEB` from the `left4me-web.service` `Service` dict (in the `systemd_units` reactor).
|
|
|
|
(c) Remove `**HARDENING_SERVER` from the `left4me-server@.service` `Service` dict.
|
|
|
|
(d) Update the `# Hardening profile — see HARDENING_WEB ...` comments above each splat to point to the drop-in file path instead (e.g., `# Hardening profile delivered via /etc/systemd/system/left4me-web.service.d/10-hardening.conf (symlink into the checkout).`).
|
|
|
|
- [ ] **Step 2.8: Add directory + symlink items for the drop-ins.**
|
|
|
|
In `~/Projekte/ckn-bw/bundles/left4me/items.py`:
|
|
|
|
(a) Add to `directories`:
|
|
|
|
```python
|
|
'/etc/systemd/system/left4me-web.service.d': {
|
|
'owner': 'root', 'group': 'root', 'mode': '0755',
|
|
},
|
|
'/etc/systemd/system/left4me-server@.service.d': {
|
|
'owner': 'root', 'group': 'root', 'mode': '0755',
|
|
},
|
|
```
|
|
|
|
(b) Add one `triggered` action that runs `systemctl daemon-reload`. It will fire on either the symlink itself changing OR on `git_deploy` updating the source content (the symlink path doesn't change in that case, so we need both wirings):
|
|
|
|
```python
|
|
actions['left4me_daemon_reload'] = {
|
|
'command': 'systemctl daemon-reload',
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
}
|
|
```
|
|
|
|
(c) Add to `symlinks` (which now has the sysctl entry from Task 1):
|
|
|
|
```python
|
|
'/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'directory:/etc/systemd/system/left4me-web.service.d',
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_daemon_reload',
|
|
],
|
|
},
|
|
'/etc/systemd/system/left4me-server@.service.d/10-hardening.conf': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'directory:/etc/systemd/system/left4me-server@.service.d',
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_daemon_reload',
|
|
],
|
|
},
|
|
```
|
|
|
|
(d) Wire `left4me_daemon_reload` into the `git_deploy:/opt/left4me/src` `triggers` list (in addition to whatever's already there — `left4me_alembic_upgrade`, `left4me_pip_install`, etc.):
|
|
|
|
```python
|
|
git_deploy = {
|
|
'/opt/left4me/src': {
|
|
'repo': node.metadata.get('left4me/git_url'),
|
|
'rev': node.metadata.get('left4me/git_branch'),
|
|
'triggers': [
|
|
'action:left4me_alembic_upgrade',
|
|
'action:left4me_pip_install',
|
|
'action:left4me_daemon_reload', # NEW
|
|
# …whatever else is already in this list
|
|
],
|
|
},
|
|
}
|
|
```
|
|
|
|
This handles the case where the source content of a hardening drop-in changes (via `git_deploy`) but the symlink itself doesn't.
|
|
|
|
- [ ] **Step 2.9: `bw test` in ckn-bw.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw test
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 2.10: Apply to the production host (interactive).**
|
|
|
|
The web service will get its hardening overlay reloaded on this apply. Expect bw to: remove old reactor-emitted hardening from the unit file, create the new drop-in directories and symlinks, `systemctl daemon-reload`. The service is *not* restarted automatically by bw on a drop-in change. You must restart it explicitly to pick up the new effective directives:
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me
|
|
ssh root@ovh.left4me 'systemctl restart left4me-web.service'
|
|
```
|
|
|
|
Gameserver instances are template-instantiated on demand by the web app. To pick up the new hardening drop-in, any *currently running* instance must be restarted via the web UI (or directly: `systemctl restart left4me-server@<instance>`). New instances started after `daemon-reload` will get the new hardening automatically.
|
|
|
|
- [ ] **Step 2.11: Verify on host.**
|
|
|
|
```bash
|
|
ssh root@ovh.left4me '
|
|
echo "=== drop-ins symlinked ==="
|
|
ls -la /etc/systemd/system/left4me-web.service.d/10-hardening.conf
|
|
ls -la /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
|
|
echo "=== web unit, effective directives ==="
|
|
systemctl show -p ProtectSystem,ProtectHome,PrivateTmp,ProtectProc,SystemCallArchitectures,LockPersonality left4me-web.service
|
|
echo "=== server unit (file-level, since instances spawn on demand) ==="
|
|
systemctl cat left4me-server@.service | grep -E "^Drop-In|10-hardening"
|
|
echo "=== systemctl daemon-reload fired ==="
|
|
systemctl status left4me-web.service | head -5
|
|
'
|
|
```
|
|
|
|
Expected:
|
|
- Both drop-in symlinks point into `/opt/left4me/src/deploy/files/etc/systemd/system/...`
|
|
- `systemctl show` for left4me-web reports the directives from the drop-in (`ProtectSystem=strict`, `PrivateTmp=yes`, etc. — matches Step 0.2's baseline)
|
|
- `systemctl cat left4me-server@.service` shows `# Drop-In: /etc/systemd/system/left4me-server@.service.d/` and references `10-hardening.conf`
|
|
|
|
- [ ] **Step 2.12: Run the relevant hardening test plan checks.**
|
|
|
|
From `docs/superpowers/specs/2026-05-15-hardening-test-plan.md`, pick a small subset that exercises both units in the live state (e.g., Test 1 ProtectSystem, Test 4 PrivateUsers on the server unit). Run them. Expected: pass identically to before.
|
|
|
|
- [ ] **Step 2.13: Re-apply for idempotency.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me
|
|
```
|
|
|
|
Expected: `0 fixed, 0 failed`. Daemon-reload does *not* fire on a no-op apply.
|
|
|
|
- [ ] **Step 2.14: Commit ckn-bw side.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/metadata.py bundles/left4me/items.py
|
|
git commit -m "left4me: hardening lives in drop-ins owned by left4me; deliver via symlink
|
|
|
|
Reactor stops emitting hardening directives in the unit bodies. The
|
|
HARDENING_COMMON / HARDENING_SERVER / HARDENING_WEB constants are gone.
|
|
Effective hardening on the live units now comes from drop-in files
|
|
shipped by left4me at:
|
|
/etc/systemd/system/left4me-web.service.d/10-hardening.conf
|
|
/etc/systemd/system/left4me-server@.service.d/10-hardening.conf
|
|
Both are target-side symlinks into /opt/left4me/src/deploy/files/...
|
|
(safe because /opt/left4me/src is root-owned post-relocation refactor).
|
|
|
|
Verified on ovh.left4me: systemctl show reports the same directives as
|
|
the pre-refactor baseline; relevant hardening test-plan checks pass."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Sudoers symlink — dedupe the verbatim mirror
|
|
|
|
**Goal:** `/etc/sudoers.d/left4me` becomes a symlink into the checkout instead of a file copy from ckn-bw's bundle. Delete the bundle's mirror file.
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/items.py` (move sudoers from `files` → `symlinks`)
|
|
- Delete: `~/Projekte/ckn-bw/bundles/left4me/files/etc/sudoers.d/left4me`
|
|
- (Optional) Create: `~/Projekte/left4me/deploy/tests/test_sudoers.py`
|
|
|
|
### Steps
|
|
|
|
- [ ] **Step 3.1: (Optional) Add a left4me-side syntax test.**
|
|
|
|
The current bw `files{}` entry uses `'test_with': 'visudo -cf {}'`, which won't run for a `symlinks{}` entry. Replace that gate with a left4me-side pytest:
|
|
|
|
```python
|
|
# deploy/tests/test_sudoers.py
|
|
"""Syntax-check the sudoers drop-in via visudo before it leaves the repo."""
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SUDOERS = Path(__file__).resolve().parents[2] / "deploy/files/etc/sudoers.d/left4me"
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("visudo") is None, reason="visudo not installed")
|
|
def test_sudoers_parses():
|
|
result = subprocess.run(
|
|
["visudo", "-cf", str(SUDOERS)],
|
|
capture_output=True, text=True,
|
|
)
|
|
assert result.returncode == 0, f"visudo -cf failed: {result.stdout}{result.stderr}"
|
|
```
|
|
|
|
Run it:
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
pytest deploy/tests/test_sudoers.py -v
|
|
```
|
|
|
|
Expected: PASS (or SKIP on systems without visudo — fine for CI on hosts that have it).
|
|
|
|
- [ ] **Step 3.2: Commit the left4me-side test if added.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add deploy/tests/test_sudoers.py
|
|
git commit -m "deploy/tests: add visudo syntax test for the sudoers drop-in
|
|
|
|
Pre-deploy syntax guard; replaces ckn-bw's per-item test_with which
|
|
won't apply to a symlink-delivered file (see deployment-responsibility
|
|
migration step 3)."
|
|
git push
|
|
```
|
|
|
|
- [ ] **Step 3.3: Move the sudoers entry from `files` to `symlinks` in ckn-bw.**
|
|
|
|
In `~/Projekte/ckn-bw/bundles/left4me/items.py`:
|
|
|
|
(a) Remove from `files`:
|
|
|
|
```python
|
|
# DELETE:
|
|
'/etc/sudoers.d/left4me': {
|
|
'source': 'etc/sudoers.d/left4me',
|
|
'mode': '0440',
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'test_with': 'visudo -cf {}',
|
|
},
|
|
```
|
|
|
|
(b) Add to `symlinks`:
|
|
|
|
```python
|
|
'/etc/sudoers.d/left4me': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
# sudo follows symlinks; with the target file at root:root 0440
|
|
# in a root-owned source tree, sudo accepts it. No daemon-reload
|
|
# equivalent — sudo re-reads /etc/sudoers.d/ on each invocation.
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 3.4: Delete the verbatim mirror.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
rm bundles/left4me/files/etc/sudoers.d/left4me
|
|
rmdir bundles/left4me/files/etc/sudoers.d 2>/dev/null || true
|
|
```
|
|
|
|
- [ ] **Step 3.5: Confirm the source file has the right mode (must be 0440 root:root) in the live checkout.**
|
|
|
|
The on-target permissions of `/opt/left4me/src/deploy/files/etc/sudoers.d/left4me` matter (sudo checks the resolved file). `git_deploy` extracts as root, so the file inherits the source file's mode from git (default 0644). sudo requires 0440 / 0400. The simplest fix: a small ckn-bw action that runs `chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me` after git_deploy. Add to `items.py`:
|
|
|
|
```python
|
|
actions['left4me_chmod_sudoers'] = {
|
|
'command': 'chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
|
|
'unless': 'test "$(stat -c %a /opt/left4me/src/deploy/files/etc/sudoers.d/left4me)" = "440"',
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
# Required so the symlink resolves to a sudo-acceptable file.
|
|
}
|
|
```
|
|
|
|
And add `'action:left4me_chmod_sudoers'` to the `symlinks['/etc/sudoers.d/left4me']` `needs` list.
|
|
|
|
Alternative: set the in-repo file's git mode to 0440 with `chmod 0440 deploy/files/etc/sudoers.d/left4me && git add --chmod=u-w …` so git tracks the mode. Either works; the action approach is more robust against future repo-mode regressions.
|
|
|
|
- [ ] **Step 3.6: `bw test` + apply.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw test
|
|
bw apply ovh.left4me
|
|
```
|
|
|
|
Expected: file removed, action fires the chmod, symlink created.
|
|
|
|
- [ ] **Step 3.7: Verify sudo still works.**
|
|
|
|
```bash
|
|
ssh root@ovh.left4me '
|
|
echo "=== symlink ==="
|
|
ls -la /etc/sudoers.d/left4me
|
|
stat -c "%U:%G %a" /opt/left4me/src/deploy/files/etc/sudoers.d/left4me
|
|
echo "=== sudoers content via sudo -l ==="
|
|
sudo -l -U left4me 2>&1 | head -30
|
|
'
|
|
```
|
|
|
|
Expected:
|
|
- Symlink resolves into the checkout
|
|
- Target file is `root:root 440`
|
|
- `sudo -l -U left4me` lists the same commands as Step 0.2 baseline
|
|
|
|
- [ ] **Step 3.8: Functional check — run a privileged helper via sudo.**
|
|
|
|
Trigger one of the sudoers-allowed commands the way the web app does, e.g. a status check via the systemctl helper:
|
|
|
|
```bash
|
|
ssh root@ovh.left4me 'sudo -u left4me /usr/bin/sudo /usr/local/libexec/left4me/left4me-systemctl status left4me-web.service | head -5'
|
|
```
|
|
|
|
Expected: command runs successfully (returns status output, not "Sorry, user … is not allowed").
|
|
|
|
- [ ] **Step 3.9: Idempotent re-apply + commit.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me # expect 0 fixed
|
|
git add bundles/left4me/items.py bundles/left4me/files/etc/sudoers.d/
|
|
git commit -m "left4me: symlink /etc/sudoers.d/left4me to the checkout
|
|
|
|
Sudoers drop-in lives in left4me/deploy/files/etc/sudoers.d/left4me
|
|
(single source of truth). Deleted the verbatim mirror in this bundle's
|
|
files/ tree. Added an idempotent chmod action so the in-checkout file
|
|
is 0440 root:root — required for sudo to accept it through the symlink.
|
|
|
|
Syntax check on the source file is now a left4me-side pytest
|
|
(deploy/tests/test_sudoers.py) running visudo -cf.
|
|
|
|
Part of 2026-05-15-deployment-responsibility-design.md migration step 3."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Privileged scripts — relocate to `deploy/scripts/` + symlink
|
|
|
|
**Goal:** Move `left4me/scripts/{libexec,sbin}/` into `left4me/deploy/scripts/{libexec,sbin}/` for layout consistency (everything ckn-bw deploys lives under `deploy/`). Replace the `install_left4me_scripts` copy-action with bw `symlinks{}` items, one per script.
|
|
|
|
**Files:**
|
|
- `git mv` in left4me: `scripts/` → `deploy/scripts/`
|
|
- Modify: `~/Projekte/left4me/AGENTS.md`, any docs referencing the old path (grep first)
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/items.py` (drop `install_left4me_scripts` action; add symlink items; update git_deploy triggers)
|
|
|
|
### Steps
|
|
|
|
- [ ] **Step 4.1: Find every reference to the old path in left4me.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
grep -rn "scripts/libexec\|scripts/sbin\|/opt/left4me/src/scripts" \
|
|
--include='*.py' --include='*.md' --include='*.sh' --include='*.toml' --include='*.cfg' \
|
|
. 2>/dev/null | grep -v worktrees
|
|
```
|
|
|
|
Save the file list — every match needs updating.
|
|
|
|
- [ ] **Step 4.2: `git mv` the scripts directory in left4me.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git mv scripts deploy/scripts
|
|
```
|
|
|
|
- [ ] **Step 4.3: Update all references in left4me to the new path.**
|
|
|
|
For each file found in 4.1, replace `scripts/libexec/` → `deploy/scripts/libexec/`, `scripts/sbin/` → `deploy/scripts/sbin/`, `/opt/left4me/src/scripts/` → `/opt/left4me/src/deploy/scripts/`. Use sed or your editor of choice. Be careful: don't touch worktree files or vendored dependencies.
|
|
|
|
- [ ] **Step 4.4: Run the full left4me test suite.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
pytest -v
|
|
```
|
|
|
|
Expected: PASS. If any test fails on a path reference you missed, fix and re-run.
|
|
|
|
- [ ] **Step 4.5: Commit the left4me relocation.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add -A
|
|
git commit -m "deploy: move scripts/{libexec,sbin}/ into deploy/scripts/
|
|
|
|
Layout consistency: everything ckn-bw deploys to the host now lives
|
|
under deploy/. ckn-bw's install_left4me_scripts copy-action goes away
|
|
in lockstep with this commit and is replaced by target-side symlinks.
|
|
|
|
Part of 2026-05-15-deployment-responsibility-design.md migration step 4."
|
|
git push
|
|
```
|
|
|
|
- [ ] **Step 4.6: Replace `install_left4me_scripts` with symlinks in ckn-bw.**
|
|
|
|
In `~/Projekte/ckn-bw/bundles/left4me/items.py`:
|
|
|
|
(a) Delete `actions['install_left4me_scripts']`.
|
|
|
|
(b) Remove `'action:install_left4me_scripts'` from `git_deploy:/opt/left4me/src`'s `triggers` list.
|
|
|
|
(c) Replace `directory:/usr/local/libexec/left4me` (already exists) and add symlink items, one per script (the current four — see `ls /opt/left4me/src/scripts/libexec/ scripts/sbin/` on host for the canonical list):
|
|
|
|
```python
|
|
# Per-script symlinks. Source content lives in left4me/deploy/scripts/.
|
|
# git_deploy:/opt/left4me/src is the prerequisite for each — without it
|
|
# the symlink target wouldn't exist on a fresh apply.
|
|
LEFT4ME_LIBEXEC_SCRIPTS = (
|
|
'left4me-overlay',
|
|
'left4me-systemctl',
|
|
'left4me-journalctl',
|
|
'left4me-script-sandbox',
|
|
)
|
|
LEFT4ME_SBIN_SCRIPTS = (
|
|
'left4me',
|
|
)
|
|
|
|
for _script in LEFT4ME_LIBEXEC_SCRIPTS:
|
|
symlinks[f'/usr/local/libexec/left4me/{_script}'] = {
|
|
'target': f'/opt/left4me/src/deploy/scripts/libexec/{_script}',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'directory:/usr/local/libexec/left4me',
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
}
|
|
|
|
for _script in LEFT4ME_SBIN_SCRIPTS:
|
|
symlinks[f'/usr/local/sbin/{_script}'] = {
|
|
'target': f'/opt/left4me/src/deploy/scripts/sbin/{_script}',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
}
|
|
```
|
|
|
|
(d) Confirm the source scripts have the executable bit set in the checkout. Add an idempotent chmod action if needed:
|
|
|
|
```python
|
|
actions['left4me_chmod_scripts'] = {
|
|
'command': (
|
|
'chmod 0755 '
|
|
'/opt/left4me/src/deploy/scripts/libexec/* '
|
|
'/opt/left4me/src/deploy/scripts/sbin/*'
|
|
),
|
|
'unless': (
|
|
'! find /opt/left4me/src/deploy/scripts -type f \\! -perm 755 -print -quit | grep -q .'
|
|
),
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
}
|
|
```
|
|
|
|
Add `'action:left4me_chmod_scripts'` to each script symlink's `needs:`.
|
|
|
|
- [ ] **Step 4.7: `bw test` + apply.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw test
|
|
bw apply ovh.left4me
|
|
```
|
|
|
|
Expected: install_left4me_scripts action removed from the graph; symlinks created; existing root-owned copies in `/usr/local/{libexec,sbin}/` are replaced by symlinks.
|
|
|
|
- [ ] **Step 4.8: Verify the helpers still work.**
|
|
|
|
```bash
|
|
ssh root@ovh.left4me '
|
|
echo "=== symlinks ==="
|
|
ls -la /usr/local/libexec/left4me/ /usr/local/sbin/left4me
|
|
echo "=== executable check ==="
|
|
/usr/local/libexec/left4me/left4me-systemctl --help 2>&1 | head -3 || true
|
|
echo "=== sudo invocation (the way the web app uses them) ==="
|
|
sudo -u left4me /usr/bin/sudo /usr/local/libexec/left4me/left4me-systemctl status left4me-web.service | head -5
|
|
'
|
|
```
|
|
|
|
Expected: every entry is a symlink into `/opt/left4me/src/deploy/scripts/`; the help/status outputs look normal; sudo invocation still succeeds.
|
|
|
|
- [ ] **Step 4.9: Live gameserver round-trip.**
|
|
|
|
Start a fresh instance via the web app (or `sudo /usr/local/libexec/left4me/left4me-systemctl start left4me-server@test`), confirm srcds_run starts, stop it. The `left4me-overlay` helper (now a symlink into the checkout) gets exercised as part of the ExecStartPre/ExecStopPost.
|
|
|
|
- [ ] **Step 4.10: Idempotent re-apply + commit.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
bw apply ovh.left4me # expect 0 fixed
|
|
git add bundles/left4me/items.py
|
|
git commit -m "left4me: symlink privileged helpers to the checkout
|
|
|
|
/usr/local/libexec/left4me/* and /usr/local/sbin/left4me are now
|
|
target-side symlinks into /opt/left4me/src/deploy/scripts/...
|
|
Replaces the install_left4me_scripts copy-action.
|
|
|
|
Part of 2026-05-15-deployment-responsibility-design.md migration
|
|
step 4. Verified on ovh.left4me: helpers run via sudo from the web
|
|
app context; gameserver mount/unmount round-trip succeeds."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Cleanup + docs
|
|
|
|
**Goal:** Prune now-dead metadata, update both repos' READMEs to describe the new model, and run the end-to-end verification matrix one final time.
|
|
|
|
**Files:**
|
|
- Modify: `~/Projekte/left4me/deploy/README.md`
|
|
- Modify: `~/Projekte/ckn-bw/bundles/left4me/README.md`
|
|
- (Possibly) Modify: `~/Projekte/left4me/AGENTS.md`
|
|
|
|
### Steps
|
|
|
|
- [ ] **Step 5.1: Audit ckn-bw `bundles/left4me/` for any dead references.**
|
|
|
|
```bash
|
|
cd ~/Projekte/ckn-bw
|
|
grep -nE "HARDENING_|install_left4me_scripts|left4me_chown_src" bundles/left4me/*.py
|
|
```
|
|
|
|
Expected: no matches. If there are, they're leftovers from earlier steps — clean them up.
|
|
|
|
- [ ] **Step 5.2: Update `~/Projekte/left4me/deploy/README.md`.**
|
|
|
|
Describe the new model: `deploy/files/` and `deploy/scripts/` are the canonical source for everything ckn-bw deploys via target-side symlinks; base unit bodies in `deploy/files/usr/local/lib/systemd/system/` remain reference fixtures that match the reactor-emitted live form. Hardening profile lives in the drop-in files alongside the unit it hardens.
|
|
|
|
- [ ] **Step 5.3: Update `~/Projekte/ckn-bw/bundles/left4me/README.md`.**
|
|
|
|
Replace the "drops privileged helpers" and "git_deploy then pip_install -e" descriptions with the current model: target-side symlinks for static artifacts (hardening drop-ins, sudoers, sysctl drop-in, helpers); reactor emits per-host unit bodies and slice CPU pinning. Point at the design doc for the rationale.
|
|
|
|
- [ ] **Step 5.4: Update `~/Projekte/left4me/AGENTS.md` if it describes the deployment layout.**
|
|
|
|
Likely just a path update from `scripts/` → `deploy/scripts/`. Grep first.
|
|
|
|
- [ ] **Step 5.5: Full end-to-end verification matrix.**
|
|
|
|
Re-run the Preflight baseline capture and compare against the post-migration state:
|
|
|
|
```bash
|
|
ssh root@ovh.left4me '
|
|
echo "=== sysctl ==="
|
|
sysctl kernel.yama.ptrace_scope net.core.rmem_max net.ipv4.tcp_congestion_control
|
|
echo "=== left4me-web hardening (selected) ==="
|
|
systemctl show -p ProtectSystem,ProtectHome,PrivateTmp,ProtectProc,SystemCallArchitectures left4me-web.service
|
|
echo "=== left4me-server@ hardening (template-level via systemctl cat) ==="
|
|
systemctl cat left4me-server@.service | grep -E "ProtectSystem|PrivateUsers|PrivatePIDs|NoNewPrivileges|SocketBindAllow"
|
|
echo "=== sudo for left4me ==="
|
|
sudo -l -U left4me 2>&1 | head -30
|
|
echo "=== symlinks ==="
|
|
ls -la /etc/sudoers.d/left4me /etc/sysctl.d/99-left4me.conf \
|
|
/etc/systemd/system/left4me-web.service.d/10-hardening.conf \
|
|
/etc/systemd/system/left4me-server@.service.d/10-hardening.conf \
|
|
/usr/local/libexec/left4me/left4me-overlay \
|
|
/usr/local/sbin/left4me
|
|
echo "=== bw idempotent ==="
|
|
'
|
|
cd ~/Projekte/ckn-bw && bw apply ovh.left4me
|
|
```
|
|
|
|
Expected:
|
|
- All sysctl values match baseline
|
|
- All hardening directives reported by systemctl match baseline
|
|
- `sudo -l` output matches baseline
|
|
- All listed paths are symlinks into `/opt/left4me/src/deploy/`
|
|
- `bw apply` reports `0 fixed, 0 failed`
|
|
|
|
- [ ] **Step 5.6: Gameserver round-trip in production mode.**
|
|
|
|
Through the web UI: start an instance, confirm it appears in the live-state panel, run a cvar inspect, stop it. End-to-end confirms the symlinked helpers + hardened gameserver unit all work together.
|
|
|
|
- [ ] **Step 5.7: Commit docs.**
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add deploy/README.md AGENTS.md 2>/dev/null
|
|
git commit -m "deploy/docs: describe the new symlink-based delivery model" || true
|
|
git push
|
|
|
|
cd ~/Projekte/ckn-bw
|
|
git add bundles/left4me/README.md
|
|
git commit -m "left4me/README: describe symlink delivery + reactor scope after the reshape"
|
|
```
|
|
|
|
- [ ] **Step 5.8: Mark the design doc as shipped.**
|
|
|
|
In `~/Projekte/left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`, add a status block at the top:
|
|
|
|
```markdown
|
|
## Status
|
|
|
|
**Shipped 2026-05-15.** All five migration steps landed and verified on
|
|
ovh.left4me. Implementation plan executed:
|
|
`docs/superpowers/plans/2026-05-15-deployment-responsibility.md`.
|
|
```
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
cd ~/Projekte/left4me
|
|
git add docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md
|
|
git commit -m "spec(deployment-responsibility): mark shipped after step-5 verification"
|
|
git push
|
|
```
|
|
|
|
---
|
|
|
|
## What this plan does NOT cover
|
|
|
|
- The build-overlay-unit refactor (`2026-05-15-build-overlay-unit-design.md`) — separate work, lands on top of this. Its hardening profile should ship as a drop-in inline with the dispatcher using the pattern established here.
|
|
- AppArmor profiles (deferred from the defenses survey).
|
|
- Moving base unit bodies / slice CPU pinning into left4me — explicitly out of scope per the design doc.
|
|
|
|
## Sequencing notes
|
|
|
|
- Steps within a task are sequential. Tasks 1-4 can in principle be parallelized across multiple sessions, but they all touch `bw apply ovh.left4me`, and interleaving applies makes failure attribution harder. Land them one at a time.
|
|
- Each task ends with both repos committed and the live host idempotent. That's the natural "checkpoint" — safe to stop between tasks.
|
|
- If a verification step fails: stop, do not paper over. Most likely cause is a wiring mismatch (wrong path, missing `needs`, missing daemon-reload trigger). Use `bw verify` and `systemctl status`/`journalctl -xe` on the host to localize.
|