Files
felhom-agent/REPORT.md
T

5.6 KiB

REPORT — slice 8A (agent half): local-API server + provisioning back-half (v0.10.0) (2026-06-10)

Overwrite-latest report (most recent significant work only). Cumulative history lives in CHANGELOG.md. Implements the host-agent half of TASK — Slice 8A. The in-guest controller half is in felhom-controller v0.35.0. No hub change. Grounded by felhom.eu/documentation/tests/slice8a-channel-deploy-spike-findings.md (commit 4a81a96).

Outcome

The agent now exposes the per-guest local API (doc 03 §6) the in-guest controller calls over the bridge, and runs the provisioning back-half that follows the slice-7 bring-up front half. With the slice-7 front half, this completes the full provisioning chain: provision a guest → deploy its (golden-baked) controller → controller comes up configured and talks to the agent — validated live end-to-end on the demo.

What landed

  • internal/localapi — the HTTPS local-API server, the per-guest authorization gate.
    • Serves a persisted self-signed leaf with a stable SHA-256 fingerprint (generated once; a fresh cert each boot would invalidate every baked bootstrap pin). The fingerprint is what the controller pins (leaf-cert SHA-256 — consistency with the agent's PVE/PBS pinning).
    • 7 §6 endpoints, all self-scoped: GET /storage (this guest's mpN mounts + fast/slow class), POST /snapshot, POST /rollback, POST /backup (enqueued, crash-consistent — quiesce is 8B), GET /backup/due (thin in 8A), GET /backup/status, GET /restore-test/status.
    • Token store: durable, crash-safe per-guest token→guest map persisting only a SHA-256 hash (plaintext exists transiently mint→write-to-mount, then discarded); last-write-wins; fsync'd JSONL.
    • Self-scoping: VMID is resolved ONLY from the token; an explicit vmid that disagrees → 403, op never issued for the other guest; absent/unknown → 401.
  • internal/provision — the back-half: mint per-guest token → render the stable bootstrap.json (schema felhom.bootstrap/v1; no registry credential — controller image is golden-baked) → write 0600chown 100000:100000 (mapped guest-root, spike gotcha 1) → attach a read-only bind mount via pct set. Host-side only (F3 — no pct exec). The token is never logged/returned.
  • --selftest=provision — full chain on-demand (front half + back half; keeps the guest).
  • config.LocalAPIConfig (local_api) — optional 6th daemon goroutine; disabled cleanly when unconfigured or on a token-store/cert failure (the daemon still reports/reconciles).
  • configs/build-golden.sh bakes the controller image (pulled once on the trusted build host, then docker logout — no cred baked) + a controller-bootstrap unit that deploys the baked image from the config mount on boot. Plus configs/felhom-localapi-firewall.example and a narrow FELHOM_PROVISION sudoers alias.

Design note — the back-half's host-root surface

The back-half's chown + bind-mount attach are host-root ops that the API token cannot do (bind mounts are root@pam-only). They are NOT added to proxmox.Privileged (fenced to its 3 exceptions, "do not add"); they live in internal/provision and run through the shared Runner (direct as root, or sudo -n with the new sudoers alias), confined to the agent-owned /var/lib/felhom-agent/guests/* path. This is the per-guest provisioning host-root surface — host-side and F3-compliant.

Tests

go test ./... green; -race green on the build server (pure-Go parts). localapi: self-scoping (cross-guest snapshot/rollback/backup → 403, op never issued; own-guest uses the token's VMID), 401 paths, /storage class mapping, /backup enqueue, thin /backup/due, status scoping; token store persists only the hash (plaintext never on disk), last-write-wins, survives reopen, uniqueness; the leaf fingerprint is stable across reload. provision: writes 0600 + chowns + attaches with the right args; the token never appears in the Result; the cross-repo bootstrap.json key-set is pinned.

Live validation (demo-felhom)

Built a new golden with the controller v0.35.0 baked (build-golden.sh). --selftest=provision into guest 8201: front half up (fresh MAC, 13s) → back half minted the token, wrote bootstrap.json (0600, chown 100000:100000, verified root:root 0600 inside the guest) and pct set-attached the read-only mount (mp9). The golden's baked unit deployed the controller (no registry pull); the controller ingested the bootstrap → came up CONFIGURED, not setup mode → reached the real local-API GET /storage200, saw its mount. Self-scoping against the live server: own→200, cross-guest ?vmid=9999403, bogus→401, absent→401. Firewall narrowing demonstrated (source gating: an off-bridge stand-in blocked, the guest allowed). Secret discipline: token plaintext absent from the agent log, store holds only the hash, bootstrap 0600+chowned, selftest never prints the token. The standing demo agent service was upgraded to v0.10.0 (local-API live on 192.168.0.162:8443); the test guest was torn down.

Deferred (stated, not built)

Quiesced app-consistent backup (/backup/due quiesce loop) → 8B. Controller de-privileging + customer disk endpoints → 8C. The BringUpSpec/provision source (hub desired-state) + deterministic attach-before-start sequencing → slice 10 (in 8A the back-half follows the front-half's start; the golden bootstrap unit's After=docker.service network-online.target reliably sequences it after the host-side mount attach). No secrets committed.