de6b72651b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
81 lines
5.1 KiB
Markdown
81 lines
5.1 KiB
Markdown
# REPORT — felhom-agent v0.45.0: controller-swap under non-root (stdin `tee` + narrow grants)
|
|
|
|
**Date:** 2026-06-29 · **Class:** Risky/supervised · **Baseline:** `main` @ `8a4ccab` was v0.44.0 →
|
|
**v0.45.0** @ `8a4ccab` (this change). Build sha `a393c8bc…`. **No `felhom-controller` change.**
|
|
|
|
## What & why
|
|
|
|
Restores fleet controller-swap / managed auto-update under the **non-root** agent — the capability the
|
|
2026-06-29 sudoers audit deliberately left broken (its only write vector, `bash -c "printf … >"`,
|
|
needed arbitrary in-guest execution). Option A, spike-proven GO
|
|
(`felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md`).
|
|
|
|
## The `writeImage` diff (the only orchestration change)
|
|
|
|
```
|
|
- cmd := fmt.Sprintf("printf '%%s\\n' '%s' > %s", image, controllerImageFile)
|
|
- _, err := c.exec.GuestExec(ctx, vmid, "bash", "-c", cmd)
|
|
+ _, err := c.exec.GuestExecStdin(ctx, vmid, strings.NewReader(image+"\n"), "tee", controllerImageFile)
|
|
```
|
|
The image ref is piped on **stdin** into an in-guest `tee` — no shell, no interpolation. `image+"\n"`
|
|
is byte-identical to the golden's `printf '%s\n'`; the bootstrap reads `IMAGE=$(cat …)`
|
|
(newline-stripping), so the write is consumed identically. `ValidControllerImage` still gates upstream
|
|
in `Swap`. Swap orchestration / rollback / state-file logic are **unchanged**.
|
|
|
|
New stdin seam (routes through the SAME `sudo -n` fenced runner — no hand-rolled exec):
|
|
`proxmox.Runner.RunStdin` + `ExecRunner.RunStdin` (Run with `cmd.Stdin`), `GuestBinder.GuestExecStdin`,
|
|
`GuestExecutor.GuestExecStdin`. The 5 proxmox.Runner test mocks gained a delegating `RunStdin`.
|
|
|
|
## Sudoers — `FELHOM_CONTROLLERSWAP` (5 narrow grants)
|
|
|
|
```
|
|
/usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image # read (fixed)
|
|
/usr/sbin/pct exec [0-9]* -- docker image inspect * # read (target present?)
|
|
/usr/sbin/pct exec [0-9]* -- docker inspect -f * # read (running/health/image)
|
|
/usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service # restart (fixed unit)
|
|
/usr/sbin/pct exec [0-9]* -- tee /etc/felhom-controller-image # write (FIXED path, stdin-fed)
|
|
```
|
|
No general `pct exec`, no `bash -c`. Added to the NOPASSWD line. Capability **manifest** gains the 5
|
|
(Critical: a silently-broken fleet auto-update is operator-alert-worthy) — so the v0.44.0 self-probe
|
|
watches them and the build-test asserts grant↔code coverage.
|
|
|
|
## Tests — all green (`go build/vet/test ./...`, 0 failures)
|
|
|
|
- New `TestControllerSwap_WriteViaStdinTee_NoShell`: the write is stdin-`tee` with exact `image\n`, the
|
|
`tee` target is the fixed path, and **no** shell vector (`bash`/`-c`/`printf`) appears — would FAIL on
|
|
the pre-change impl.
|
|
- Existing swap tests (happy / rollback-on-unhealthy / image-absent / no-healthcheck / bad-image /
|
|
single-flight) pass over the new write path (fake `GuestExecutor` now models `tee` via stdin).
|
|
- `RunStdin` forwards stdin; the 5 mock runners implement it.
|
|
- **Build-time coverage:** `TestManifestCoveredBySudoers` covers the 5 new vectors. **Two red-proofs:**
|
|
`TestRedProof_DroppedGrantFailsCheck` (lxc-info) + new `TestRedProof_DroppedControllerSwapTeeFailsCheck`.
|
|
**Demonstrated red→green on the REAL file:** dropping the `tee` grant →
|
|
`capability "controllerswap-write" … NOT covered by any sudoers grant` → FAIL; restored → PASS.
|
|
|
|
## Live validation (felhom-pve, guest 9201) — deployed + verified
|
|
|
|
- **Deploy:** built `0.45.0` on 180 (sha `a393c8bc…`), shipped 180→local→felhom-pve (sha verified),
|
|
backed up prior (`felhom-agent.bak-0.44.0`), `install` + restart → `is-active` = **active**. Sudoers
|
|
redeployed via the install mechanism (`visudo -cf` staged → `install -m 0440 -o root -g root` →
|
|
re-validate `/etc/sudoers` OK). (Staged file needed a `\r` strip — Windows checkout CRLF — before
|
|
visudo; the installed drop-in is LF-clean.)
|
|
- **Self-probe (live):** `capabilities self-check ok=45 total=45 degraded=0` — the 5 swap vectors are
|
|
granted AND watched.
|
|
- **Grant matches (`sudo -n -l`, as felhom-agent):** all 5 → exit 0
|
|
(`cat` / `docker image inspect <ref>` / `docker inspect -f '<real template>' felhom-controller` /
|
|
`systemctl restart <unit>` / `tee <image file>`).
|
|
- **Negative controls → denied (exit 1):** `bash -c 'echo pwned'`, `tee /etc/passwd`,
|
|
`docker rm -f felhom-controller`, `rm -rf /etc`. The grants are scoped, not a blanket `pct exec`.
|
|
|
|
## NOT yet run — live swap (Scenario D) — **awaiting supervised**
|
|
|
|
The full end-to-end live swap (controller pre-pulls → swap endpoint → `tee` write → restart → healthy
|
|
on target; then a bad image → rollback) **restarts the live controller** and per §13.4 is
|
|
operator-supervised. It was NOT run (this session is unattended). When supervised, trigger it via the
|
|
controller's real update flow (not a hand-set state) and confirm the tee-write → restart → healthy and
|
|
the bad-image rollback over the new write path.
|
|
|
|
## Forward note
|
|
No code→manifest drift auto-check yet (a future static check of `runner.Run`/`GuestExec*` call sites
|
|
vs the manifest). The controller↔agent channel self-health check remains a separate slice.
|