# CAMPAIGN 2 — DR primitives · self-update · escrow · storage · faults · endurance (2026-07-07) > Unattended full-system validation on the demo node (felhom-pve / guest 9201), run during the day > 2026-07-07 (07:20–14:11 CEST). **No production code changed.** Throwaway harness + evidence bundle > live on DooPlex (`~/campaign2-nomercy/`); the committed artifacts are this document + the evidence > snapshot under `documentation/tests/campaign2-evidence-2026-07-07/`. > > **Headline — read this first.** The raw tally looks alarming (≈45 FAIL) but is **misleading**: it > decomposes into **one genuine, pilot-critical product finding** plus a **single dominant harness > bug** that mis-scored most of P3–P9, plus a handful of smaller harness-calibration issues. The > product, wherever it was actually exercised with a valid session or through the agent directly, > was **healthy**: auth+CSRF enforced, backups sound, the PBS-verify + crash-recovery primitives > work, fault recovery works, and **zero resource leaks** across a ~7 h run. > > **The one real finding (R1, headline):** the non-root host agent **cannot restore guest 9201** > because the standard Felhom guest has **bind-mount mountpoints** (`mp8: /mnt/felhom-drives`, > `mp9: …/bootstrap`), and `pct restore` refuses bind mounts unless root. This breaks **both** the > product's own restore-verification primitive (`--selftest=restore-test`) **and** the offsite DR > bring-up (`--selftest=bring-up -mode dr`). *A backup you cannot restore is not a backup* — and > right now, non-root, we cannot restore the demo guest. This is exactly what "exercise the real > primitives" was meant to surface. --- ## Run header | Item | Value | |---|---| | Date | 2026-07-07, 07:20–14:11 CEST | | Host / Guest | felhom-pve (192.168.0.162), node demo-felhom / guest 9201 (LAN 192.168.0.127) | | Controller / Agent / Hub | **v0.102.0** (`cd0ebd2`) / **v0.73.0** (`e04b75e`) / v0.35.0 | | Catalog | `2ebe082` (healthcheck-swept) | | Auth mode | **ON** for the run (dashboard password + CSRF, via `settings.json`); restored **OFF** at P10 | | Scratch band | agent-config band 990000–990009 (restore-test) + granted 9601–9620 (DR) — asserted empty at P0 | | Restore points | **R0** `ct/9201/2026-07-06T22:34:15Z` verify PASS; **R1** taken by P7 | | Soak / wallclock | 4 h soak cap / 8 h30 m wallclock (finished well inside) | | Seed | 20260707 | | Escrow R | provided at kickoff (chmod-600, never echoed) — but the escrow **blob was not locatable** by the harness, so the unwrap did not run (H5) | | D1 envelopes | signed + verified through the **real** `authz.Verifier` pipeline at P0; **moved early** (after P3) to stay inside the 10:07Z TTL — see note below | **D1-early adaptation (logged):** the envelopes had a 12 h TTL from the previous evening (expire 2026-07-07T10:07Z). The as-ordered D1 slot (after the 4 h soak) would have run past expiry. Because 0.73.1 is the **same source tree** as the deployed 0.73.0 (only the `-X main.version` string differs), running P4–P9 on 0.73.1 would have been functionally identical, so D1 was moved to run right after P3. (In the event D1 could not be delivered at all — see H3 — so this was moot; the agent stayed 0.73.0.) --- ## Result tally (raw, then triaged) | Status | Raw | |---|---| | PASS | 15 | | FAIL | 45 | | SKIP | 3 | | SOAK "breaks" | 170 (all one artifact) | **After triage the 45 FAIL decompose as:** - **1 genuine product finding** (R1/R2, surfaced by 3 independent agent-side tests: T-SELFRESTORE, T-DR-BRINGUP, and contributing to the P9/DR chain). - **~30 mis-scored by one harness bug** (H1, auth-session expiry — every controller POST 403'd on CSRF and every GET returned login HTML from P3 onward). - **4 = one harness bug** (H3, D1 injection had no `sqlite3` in the hub pod → self-update never delivered; agent correctly stayed 0.73.0). - **2 = stale dryrun journal entries** (H4). - **1 = missing test fixture** (H5, escrow blob not locatable → SKIP mis-marked FAIL). - The remainder are the soak (H2) and derived/dependent skips. --- ## THE genuine finding — R1 + R2 (pilot-critical) ### R1 — non-root `pct restore` of a bind-mount guest fails ("only possible for root") - **Symptom (verbatim, 3 tests):** `unable to restore CT - restoring 'mp8' to bind mount is only possible for root` - `T-SELFRESTORE` — `--selftest=restore-test` into scratch 990000 → **FAIL in 2.2 s** (`evidence/T-SELFRESTORE/restore-test.out`, `pass:false`). - `T-DR-BRINGUP` — `--selftest=bring-up -mode dr` into scratch 9601 → **FAIL** (`evidence/T-DR-BRINGUP/bringup.out`). - **Root cause (confirmed):** guest 9201's config carries **bind-mount mountpoints**: ``` mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives # the enrolled-drives bind mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1 ``` Proxmox `pct restore` refuses to restore a bind-mount entry unless it runs as **root**. The agent runs **non-root** (`felhom-agent`), by design (de-privilege). So the restore of the *standard Felhom guest topology* fails. - **Blast radius:** this is not a scratch-guest quirk — it is the **restore path itself**. The product's own "is this backup actually restorable?" primitive and the offsite guest-loss DR bring-up **both fail on any guest that has a bind mount**, which the standard Felhom guest always has (drives bind mp8 + bootstrap bind mp9). **This is the single most important thing to know before pilot.** - **Not fixed** (report-only). Candidate directions for the follow-up task (design decision, not prescribed here): rewrite bind-mount `mpN` entries to a restorable form during restore (needs R2's config-read), restore the bind mounts via a root-scoped helper (break-glass-style, narrow), or restore the rootfs+managed volumes only and re-materialise binds post-restore. **Cross-check with the offsite-WG DR design docs before choosing.** ### R2 — agent token is missing `VM.Audit` (and `VM.Allocate` on scratch) — links into R1 - `restore-test: could not read source config for mp overrides (restoring as-is)` … `GET /nodes/demo-felhom/lxc/9201/config -> HTTP 403: permission denied at /vms/9201 (missing privilege VM.Audit)`. Because the agent could not read the source config, it restored **as-is** and hit R1. **R2 plausibly *enables* R1's fix**: with config-read the agent could rewrite `mp8` before restoring. - `DELETE /nodes/demo-felhom/lxc/990000 … missing privilege VM.Allocate` — scratch teardown 403'd; the leaked scratch was **left for Recover**, which did reconcile it (`recover: leaked-scratch entry resolved; guest already gone`) — so **no leak survived**, but teardown is not clean. - **Live ACL confirmation:** `pveum user permissions felhom-agent@pve --path /vms/9201` shows only `Datastore.Audit` + `Sys.Audit` — **not** `VM.Audit`. The `FelhomAgentGuest` role *does* carry `VM.Audit`/`VM.Allocate`, but the grant on `/pool/felhom` is not resolving to `VM.Audit` at the per-guest path the restore path checks. (Related to the A1 pool-audit thread.) --- ## Other real observations - **F-C2-1 (kickoff, confirmed):** setting the dashboard `password_hash` in **`controller.yaml`** does **not** take effect — the config loader runs `os.ExpandEnv` over the YAML, and a bcrypt hash (`$2a$…`) contains `$…` sequences that get mangled to empty. Worked around by writing the hash to **`settings.json`** (which is not env-expanded). Product-relevant: any operator putting a bcrypt hash in `controller.yaml` gets silent auth-bypass. Report-only. - **T-P9-DESTROY-RESTORE** ran (gated on R1-verify + healthy agent) and the local PBS restore of 9201 *itself* completed at the Proxmox level, but the harness's **post-restore verification** went through the (dead-session) controller and mis-scored — see H1. The vzrestore of a *whole-guest* PBS backup via `pct restore` on the **host** (P9 finale path) is a different code path from the agent's non-root `--selftest` restore and did not hit R1; the destroy→restore of 9201 recovered the guest (it is running and healthy at end-state). --- ## Strong positive results (real signal — do not lose these under the FAIL count) ### No resource leaks — the endurance headline (flight recorder, 351 samples over ~7 h) | Metric | Start | End | Min | Max | Verdict | |---|---|---|---|---|---| | agent RSS (KB) | 22 116 | 19 936 | 17 924 | 22 792 | **stable, trended down** | | agent fd | 79 | 54 | — | 83 | no leak | | agent sockets | 70 | 46 | — | 74 | **no idle-socket leak** (the agentapi precedent is absent) | | controller fd | 0* | 15 | 0* | 30 | no runaway (*0 = sampler pgrep miss) | | controller latency | — | — | 0.008 s | 0.079 s | responsive all run | The controller answered every flight-recorder probe in **8–79 ms** across the whole run; only ~10 of 351 samples were 502/000 (during **deliberate** restarts/reboots), all recovered. (The 341× `401` is the flight recorder itself being unauthenticated — the controller *responded*, fast, it just required auth.) ### What actually passed (valid-session or agent-direct — trustworthy verdicts) - **P1:** all pages 200; **vaultwarden fresh-deploys healthy** (`_ENABLE_SMTP=false`, restarts=0 — F1 fix re-confirmed); canary plant; lifecycle; sync-idempotent; and the **new auth+CSRF grant is enforced** — `T-AUTH-1` (wrong-pw refused, unauth API blocked), `T-CSRF` (tokenless POST → 403, tokened accepted), `T-AUTH-2` all **PASS**. - **P2 backup suite:** `T-BAK-FULL` (DB dumps restorable + volume tars carry canaries bit-identical), `T-BAK-API`, `T-BAK-SINGLEFLIGHT` (409), `T-BAK-PBS` (namespace-pinned) all **PASS**. - **P5 restore primitives:** `T-PBS-VERIFY` **PASS** (`verify_state: ok`, 13 snapshots over 2 datastores, via the agent selftest — immune to the auth bug); `T-RESTORE-TEST-CRASH` **PASS** — the journaled engine's **`Recover` reconciled a leaked scratch after a kill** (`leaked-scratch entry resolved; guest already gone`). *Caveat:* because restore-test now fails fast on R1, the crash-window was small — Recover is proven, but a full mid-restore crash was not deeply exercised. - **P6 faults:** `T-FLT-KILL9` (controller SIGKILL → restart-policy recovery, API 200), `T-FLT-CF` (cloudflared down → external down → recovered), `T-FLT-AGENT` (agent SIGKILL → systemd restart, clean binds) all **PASS**. - **P9-DR:** `T-DR-PBSVERIFY` **PASS** — an offsite snapshot exists and verifies over the tunnel. --- ## Harness bugs found (the real story behind the FAIL count — product NOT implicated) - **H1 — auth-session expiry (DOMINANT).** The harness logs in **once** (P1) and never re-authenticates. Over a multi-hour run the session cookie expired; from **P3 onward** every state-changing controller POST returned `{"ok":false,"error":"CSRF token missing or invalid"}` (403) and every canary GET returned login-page HTML (empty parse). Proof: `evidence/T-STG-ENROLL/init1.txt` (CSRF-403), `evidence/T-RST-VW/restore_resp.txt` (302→login), `canary_check.txt` (`got=` empty). **Mis-scored:** all `T-RST-*`, all `T-STG-*`, `T-FLT-{DOCKERD,HUB-OUT,GITEA-OUT,CRASHLOOP,POWERLOSS}`, all `T-RBT-*`, `T-RES-*`, and the post-restore checks of `T-P9-DESTROY-RESTORE`. **Fix for harness v-next:** `cc_ensure_auth` before every phase's ops (re-login on any 401/302), and re-scrape the session-bound CSRF token after any controller restart/reboot. - **H2 — soak responsiveness probe (`cc_code /` expects 200; auth → 302→login).** 170/170 soak iterations flagged `responsive(code=302 ms=45)` — the controller *was* responsive (45 ms), the probe just wasn't authed. `T-SOAK-LOOP` FAIL is entirely this. Fix: probe an authed page (or accept 302). - **H3 — D1 injection has no `sqlite3` in the hub pod.** All 4 `T-D1-*` failed at the injection step (`exec: "sqlite3": executable file not found in $PATH`). The self-update was **never delivered**, so it was **not tested** (agent correctly stayed 0.73.0). The signed envelopes themselves were valid (verified at P0 through the real `authz.Verifier`). Fix: inject via a hub API/endpoint or a pod with sqlite, or bake the job in a way that survives a minimal image. - **H4 — dryrun polluted the journal.** The pre-launch `run.sh dryrun` wrote synthetic-state FAILs for `T-SEC-PROTECTED` and `T-REMOVE-REDEPLOY` to the **same** `journal.tsv`; the resume-aware overnight run saw them terminal and **skipped re-running them**, preserving the stale FAILs. Fix: fresh journal per run (or a dryrun-scoped journal). - **H5 — escrow blob not locatable.** `T-DR-ESCROW` → `SKIP-reason: no escrow blob reachable (local+hub)`. R was provided, but the harness looked in the wrong place (agent state dir + a hub `/escrow` endpoint that isn't the real path), so the **crown-jewel unwrap + wrong-R red-proof did not run**. Marked FAIL, really a SKIP. Fix: locate the actual escrow blob (hub admin path with the operator key, or the real on-host escrow artifact). - **H6 — flight-recorder probe unauthenticated** (401 for the run) and **`c_rss` sampler flaky** (guest-side `pgrep` interpolation often empty). Cosmetic — latency/leak signal still usable. --- ## Restore-primitive evidence (the campaign's purpose — verdicts) | Primitive | Verdict | Evidence | |---|---|---| | `--selftest=pbs-verify` | **WORKS** | `verify_state: ok`, 13 snapshots / 2 datastores | | journaled `engine.Recover` (leaked-scratch reconcile) | **WORKS** | `T-RESTORE-TEST-CRASH` — reaped a leaked scratch after kill | | `--selftest=restore-test` (restore→boot→verify→teardown) | **BROKEN on bind-mount guests (R1)** | fails in 2.2 s: `mp8 … only possible for root` | | `--selftest=bring-up -mode dr` (offsite guest-loss DR) | **BROKEN on bind-mount guests (R1)** | fails: `mp8 … only possible for root` | | `escrow.Consume(R)` unwrap (+ wrong-R red-proof) | **NOT RUN** (H5 — blob not located) | — | | local whole-guest PBS destroy→restore (host `pct restore`, P9) | guest recovered | 9201 running + healthy at end-state | --- ## Delta vs Campaign 1 | Dimension | Campaign 1 (2026-07-06) | Campaign 2 (2026-07-07) | |---|---|---| | Auth | OFF (XFAIL) | **ON — enforcement proven** (T-AUTH/T-CSRF PASS) … but broke the harness mid-run (H1) | | Restore primitives | not exercised | **exercised — surfaced R1** (the headline) | | Resource leaks | not sampled | **sampled — none** (agent RSS/fd/sockets stable) | | DR bring-up / escrow | not attempted | attempted — R1 blocks bring-up; escrow blocked by H5 | | Self-update (D1) | shipped v0.70.0 (prior) | **not delivered** this run (H3) | | Dominant mis-score cause | ~18 harness bugs (auth-off era) | **1 dominant harness bug** (auth-on session expiry) | --- ## What this campaign STILL does not prove - **The async restore family end-to-end through the UI under load** — H1 killed the harness's restore tests. (It *was* proven in the P1–P3 re-run 2026-07-06: 0.018 s internal / 0.235 s external, canary bit-identical.) - **Storage F2 lifecycle at scale** — all `T-STG-*` are H1 artifacts. (F2 eject/decommission were proven live in the v0.73.0 acceptance 2026-07-06.) - **True offsite guest-loss DR** — blocked by R1; the crown-jewel path did not complete. - **Escrow recovery-code unwrap** — not run (H5). - **Agent self-update** — not delivered (H3). - True CGNAT; multi-customer hub scale; real hardware failure. --- ## End state (demo node healed) - **Guest 9201 running + healthy**, controller v0.102.0 healthy, **25 apps up, 0 unhealthy**. - **Agent v0.73.0 active** (self-update never applied — H3 — so no version drift; the 0.73.1 artifact remains published in Gitea, dormant). - **Auth restored OFF** (pre-campaign state; root page 200). *Note: the "dashboard public + unauth" item remains a pre-existing known security topic, unchanged by this run.* - **Notifications restored** to the exact pre-state (email `nagyfenyvesi.viktor@gmail.com`, 11 events, cooldown 6 h) — the harness P10 SKIPped it (no P0 snapshot in its state dir), restored manually. - **No residue:** 0 scratch guests in 990000–990009 / 9601–9620; no stray timers/rules. - Controller auto-update was already `false` pre-campaign (unchanged). ## Follow-ups (for project-Claude / a future task — not done here) 1. **R1 — bind-mount restore** (pilot-blocking): make `--selftest=restore-test` + DR bring-up work on a bind-mount guest under the non-root agent. Design decision — cross-check the DR docs. 2. **R2 — grant the agent `VM.Audit`** (+ `VM.Allocate` on the scratch band) so restore-test can read source config and tear down cleanly; likely a prerequisite for R1's fix. 3. **Harness v-next:** H1 (re-auth per phase), H2 (authed soak probe), H3 (hub-native D1 injection), H4 (fresh journal), H5 (real escrow-blob locate) — then a clean re-run of P3–P9 + D1 + escrow-DR. 4. **F-C2-1:** either make the controller config loader not `os.ExpandEnv` the `password_hash`, or document that bcrypt hashes must live in `settings.json`. --- # Root-cause correction (2026-07-07) — R1 was a symptom of an empty pool; restore-test was never broken > This section ANNOTATES the report above (history preserved). Live diagnosis after publication > proved the R1/R2 causation runs the OTHER way, and the "fix R1" recommendation is superseded. **What R1 actually was.** The report named R1 ("non-root agent can't `pct restore` a bind-mount guest — architectural, pilot-blocking") as the headline. That inverts symptom and cause. The real chain, every link verified live: 1. `pvesh get /pools/felhom` → `members: []`. **The felhom pool was EMPTY.** 2. Pool-scoped ACL grants (`FelhomAgentGuest` @ `/pool/felhom`, carrying `VM.Audit`/`VM.Allocate`) reach only pool MEMBERS. With 9201 not a member, the agent had only the `/`-base grant on `/vms/9201` (`Datastore.Audit`/`SDN.Use`/`Sys.Audit`) — **no `VM.Audit`**. 3. `restore-test` calls `GuestConfig(9201)` to read the mp layout → **403 VM.Audit** → falls to the "restore as-is" fallback. 4. Because the source config was never read, `bindMountOverrides` (`restoretest.go:334`) — **which already exists and is correct** — never ran. Raw bind mounts reached PVE → `mp8 … only possible for root`. **So restore-test's bind-mount handling was never broken — it just never executed.** The role and the ACL binding were both correct; the fault was **pool membership**. **Why the pool went empty:** `pct restore --pool` sets membership only at CREATE; a restore OVER AN EXISTING VMID (an earlier destroy-restore drill) does NOT re-apply it, and nothing re-added the guest — so 9201 silently dropped out (the 07:41 `VM.Audit` 403 predates the 14:09 finale, consistent with an earlier drill). **Verified fix + live proof (2026-07-07):** - **Part A (heal now):** `pveum pool modify felhom --vms 9201` → 9201 is a member; `pveum user permissions felhom-agent@pve --path /vms/9201` now shows **`VM.Audit` + `VM.Allocate` + `VM.Backup`**. - **Live restore-test then PASSED for the first time** (agent v0.74.0): `neutralizing source bind-mount mountpoints … bind_mounts=2` → scratch 990000 restored + booted + **verified boot+running** + **torn down clean (no `VM.Allocate` 403)** in 4m35s. This confirms **B3** (the scratch-teardown 403 was a CASCADE of the failed bind-mount restore, not an independent gap) and that restore-test needs **no** bind-mount code change. - **Part B (stop the regression):** `felhom-agent v0.74.0` re-asserts pool membership after any restore-with-`Pool` (`Client.PoolAddVMID` + a bring-up call, warn-not-fail). A destroy-restore no longer silently drops membership. **Corrected fix order + superseded recommendation:** - The report's follow-up **#1 ("make `pct restore` work on bind-mount guests without root") is NOT needed** for restore-test and is **superseded** — the existing override handling works once the config read succeeds (i.e. once membership is present). - (A) pool one-liner heals today; (B) v0.74.0 stops the recurrence; restore-test unchanged. - DR `bring-up -mode dr` has a **separate, real** gap (it passes no overrides and its source guest is gone) — scoped and resolved in `SPIKE-dr-bindmount-source-2026-07-07.md` (recommendation: a small known-constant override reusing `bindMountOverrides`, since mp8/mp9 are structural constants). - **F-C2-1** is fixed in `felhom-controller v0.103.0` (config loader no longer `os.ExpandEnv`-corrupts a bcrypt hash; live-proven).