Files
felhom-agent/REPORT.md
T

5.1 KiB

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.