diff --git a/REPORT.md b/REPORT.md index 17a09e9..863075a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,57 +1,79 @@ -# REPORT — Slice 7 close-out: PBS recovery-code escrow creation (v0.9.0) (2026-06-10) +# 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](CHANGELOG.md). Implements the agent half of `TASK — Slice 7 close-out: PBS -> recovery-code escrow creation`. The hub opaque-storage half is in `felhom.eu` (hub v0.8.0). +> [CHANGELOG.md](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 first code that touches the PBS client encryption key `K` and introduces the customer recovery -code `R`. Default posture is **zero-knowledge**: Felhom holds an opaque `R`-wrapped blob it cannot -open; the customer holds `R`. The wrap→recover→restore round-trip was proven on a fenced throwaway -before any code (`felhom.eu/documentation/tests/slice7-escrow-spike-findings.md`) — the `R`-recovered -key restored a real encrypted snapshot. +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**. -## Secret discipline (the overriding rule — held throughout) +## What landed -- `R`: `crypto/rand`, ≥128 bits, surfaced **exactly once** (selftest stdout), **never** logged, - persisted, committed, or in this report. The wrap pty's echoed passphrase is **discarded** (F-A2). -- `K`: read by location, **never modified** (Wrap operates on a copy; the live key file is - byte-unchanged — asserted live by sha256), never logged. -- The blob is opaque ciphertext; the hub stores bytes and never decrypts. +- **`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 `0600` → **`chown 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. -## What landed (`internal/escrow`) +## Design note — the back-half's host-root surface -- **`Create`** → generate `R` (10 EFF-wordlist words ≈ 129 bits) → **Wrap** `K` under `R` via the - PBS-native `proxmox-backup-client key change-passphrase --kdf scrypt`, driven over a **stdlib pty** - (`x/sys/unix`; the command is TTY-only, spike F-A1) with **output discarded** (F-A2) → **self-verify** - the blob unwraps back to `K` (fingerprint match) before shipping → return `R` SEPARATELY (so it - can't be logged via the result) + the opaque blob. -- **Opt-in outputs:** (b) `R`-wrapped offline copy (two-factor, no extra trust); (a) raw paperkey - (single-factor, unrevocable — loud caveat). -- **`--selftest=escrow-create`** (`-storage`/`-paperkey`/`-offline`/`-upload`): surfaces `R` once, - prints the opaque blob's size/fingerprint/posture, and with `-upload` PUTs it to the hub - (`/api/v1/hosts/{host_id}/escrow`, per-host key). -- Config: `escrow{posture, pbs_storage_id}` (zero_knowledge default); `PBSEncKeyPath` (the `.enc`). -- Runtime dependency: the `proxmox-backup-client` CLI. +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). `R` entropy ≥128 / -10-word format / 200-draw uniqueness; the agent→hub wire-contract key-set (mirrors the hub). The -integration round-trip (wrap→unwrap fingerprint match, **wrong-`R` fails**, **live `K` byte-unchanged**, -blob ≠ plaintext) is guarded to linux+`proxmox-backup-client` and exercised by the live selftest. +`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) -`--selftest=escrow-create -storage felhom-pbs`: `R` (10 words) surfaced **once** to stdout; blob -**383 B opaque** (key fingerprint `01:36:e9…`, posture `zero_knowledge`, ~129-bit `R`); -**self-verify** confirmed recoverability; the live `K` (`felhom-pbs.enc`) **sha256 byte-unchanged**; -the exact `R` was **absent** from stderr and the agent journal. *(Upload to the hub validated after -hub v0.8.0 deploy — see felhom.eu REPORT.)* +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 /storage` → **200**, saw its mount. Self-scoping against the live server: own→200, +cross-guest `?vmid=9999`→**403**, 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) -Escrow **consumption / restore-mode serving** → slice 10 (host-loss DR; the spike pre-validated it). -The **Felhom-holds-a-key** posture (the one dangerous topology cell) is documented (doc 03 §8a) and -implemented only on an explicit customer opt-in. No secrets committed. +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.