Files
felhom.eu/documentation/audits/CAMPAIGN-pool-effects-2026-07-01.md
T
admin 6dae9d7558 docs: F3 provisioning runbook (vmid from full host list) + campaign F1/F2 resolution banner
Companion to felhom-agent v0.60.0 (proof-of-launch destroy gating + restore-
test band-advance). New documentation/runbooks/provisioning.md; campaign doc
updated with the resolution status and the operator manifest-bump follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:27:17 +02:00

15 KiB
Raw Blame History

CAMPAIGN — full effects of the felhom pool + scoped-token migration

Resolution update (2026-07-02, agent v0.60.0): F1 FIXED at root — proof-of-launch gating: a compensating destroy (bring-up rollback, restore-test teardown, AND Recover) fires only when RestoreLXC returned a UPID; no UPID ⇒ the transaction created nothing ⇒ never destroy. Recover is now safe by DESIGN (a no-UPID Scratch/Rollback entry is abandoned fail-safe), no longer by the pool-blind "already gone" accident (§2 B1.4). F2 FIXED — the restore-test advances past an "already exists" band vmid (bounded by the band width); a fully-occupied band → Skipped, no false alert. F3 DOCUMENTEDdocumentation/runbooks/provisioning.md. Red-proof-tested + live-proven on felhom-pve (provision onto existing 9001 → no destroy armed; restore-test advanced past a 990000 decoy and passed at 990001). F4/F5/F6 remain open as recorded. Operator follow-up: bump the hub Day-0 artifact manifest to agent 0.60.0 (binary published to Gitea, sha256 45d19b87…51725831).

Date run: 2026-07-02 (runbook authored 2026-07-01) · Host: felhom-pve (demo-felhom, PVE 9.2.2) Agent live: v0.59.0 (runbook table said v0.55.0 — v0.560.59 shipped between authoring and execution; the ListLXC/priv surface is unchanged) ACL: host-install v1.7.0 3-role scoped layout, verified live: FelhomAgentGuest @/pool/felhom (12 privs incl. Pool.Allocate), FelhomAgentStore (Datastore.Allocate,AllocateSpace) @local/local-lvm/felhom-pbs, FelhomAgentBase (Sys.Audit,SDN.Use,Datastore.Audit) @/. Method: the agent's own code paths where they exist (--selftest=bring-up|task|restore-test|hub, run as the felhom-agent user), raw PVE-API probes as the scoped token for the rest (identical paths/params to internal/proxmox/mutate.go). Mutations on scratch pool guests 9310/9311 + throwaway decoys; 9201 got read/non-destructive probes only. No code changed; demo left as found (pool = {9201}, agent active, controller healthy, all scratch/decoys destroyed, 9310's PBS archive pruned).


1. Track A — operation-matrix sweep: 16/16 PASS, zero surprise 403s

The scoped token is functionally COMPLETE for the agent's real operation surface.

# Op How exercised Result
1 RestoreLXC → pool bring-up selftest (golden → 9310, 52s boot+running); PBS archive → 9311 raw probe PASS (both local + PBS archive paths)
2 Vzdump → felhom-pbs raw probe, mode=snapshot, on 9310 PASS (task OK, 100s)
3 DestroyLXC + destroy-unreferenced-disks=1 raw probe on 9310+9311 PASS — all 6 LVM volumes verifiably reaped
4 Snapshot --selftest=task -vmid 9310 PASS
5 Rollback same PASS
6 DeleteSnapshot same PASS
7 SetConfig Disk/Network/CPU/Memory/Options bring-up (Disk/mounts) + raw probes: net1 add+delete, cores, memory, onboot, description (selftest) PASS all five key classes
8 ResizeLXC rootfs +1G raw probe (32G→33G, task OK) PASS
9 Start / Stop bring-up start; stop probe pre-destroy PASS
10 ListLXC probe PASS — but returns ONLY pool guests (Track B)
11 GuestStatus/Config/ListSnapshots selftests + probes PASS (pool guests)
12 Nodes/NodeStatus/Version --selftest=hub collect PASS
13 ListRunningTasks probed DURING a live vzdump PASS — task visible mid-flight
14 ListStorage/NodeStorage/StorageContent/LatestBackupVolID probes incl. felhom-pbs content enumeration PASS (3b-fix intact)
15 TaskStatusOnce/WaitTask used throughout (every async op) PASS
16 TaskLogTail full log of completed vzdump read PASS

Full restore-test cycle (restore→link-down→boot→verify→teardown) also PASS end-to-end under the scoped token (20s), including teardown of its own pool-created scratch.

Transient (not a finding): concurrent SetConfig during a running resize task → HTTP 500 "can't lock file pve-config-9310.lock" — ordinary PVE per-guest config locking, retry succeeds.


2. Track B — pool-blindness (the depth)

Mechanism proven live first: the scoped token's GET /nodes/demo-felhom/lxc returns only {9201} while the host holds 9000/9001/9100/9999/9201. Every ListLXC consumer inherits this view.

B1 — vmid origin + collision verdict the campaign's most important result

Origin (source-traced, definitive): the provision vmid is always externally supplied — both BringUpSpec call sites (--selftest=bring-up, --selftest=provision, cmd/felhom-agent/main.go:1233/1388) take -vmid as an explicit CLI flag. No nextid/next-free scan exists anywhere in the agent or the hub (grep-verified both repos). The only agent-computed vmid is the restore-test scratch (pickScratchVMID over ListLXC, band 990000990009, restoretest.go:419).

Collision-avoidance therefore sits with the operator/hub — and the operator's hub inventory is now pool-only (B3), so on a shared box the operator can pick a vmid occupied by a guest the hub can't see. What happens then:

Adversarial run 1 — bring-up onto existing non-pool 9001:

  1. The bring-up duplicate-guard (bringup.go:180, pool-blind ListLXC) MISSED the existing guest and proceeded.
  2. PVE refused the restore: HTTP 500 "CT 9001 already exists" (agent never sets force) → no clobber.
  3. The compensating rollback then attempted DestroyLXC(9001) — a guest the job never created (gate classed it benign guest_destroy, SameTxnCreated provenance). Only the pool ACL stopped it: HTTP 403 "missing privilege VM.Allocate at /vms/9001". Under the pre-3b broad token this would have DESTROYED the innocent guest.
  4. The dangling in-flight Rollback journal entry self-resolved on the next Recover: pool-blind ListLXC can't see 9001 → "guest already gone" → terminal-clean. (Coincidentally correct: containment guarantees the agent can't have created/kept anything outside the pool.)

Adversarial run 2 — restore-test with a non-pool decoy parked at 990000 (band start):

  1. pickScratchVMID (pool-blind) picked the occupied 990000.
  2. Restore refused ("CT 990000 already exists") → no clobber.
  3. The deferred teardown attempted DestroyLXC(990000) on the innocent decoy → 403, decoy survived.
  4. Journal entry self-resolved clean on next Recover (same pool-blind coincidence).
  5. Result surfaced as FAIL (pass:false, "already exists") — not Skipped — i.e. a false "backup not restorable" signal to the hub.
  6. Because the picker always takes the lowest free-looking vmid and the squatter never becomes visible, it re-picks 990000 every run: one non-pool squatter at the band start = restore-test permanently broken (it never advances to 990001).

Verdict: no collision can clobber or destroy a foreign guest as long as the pool-scoped ACL is in place — but that is a single containment layer over an agent-logic flaw (destroy-what-you-didn't-create), and the failure mode is noisy-but-safe (provision fails, ERROR logs, false restore-test alerts). See fixes F1/F2/F3.

Bonus finding (same run): with a non-pool source guest (the golden 9100), the restore-test cannot read the source config for bind-mount neutralization (403 VM.Audit) → logs "restoring as-is". Harmless for the golden (no bind mounts), but a bind-mount-bearing non-pool source archive would fail the restore-test instead of being neutralized (F4).

B2 — eject dependent-guest scan (disks.go:1026 dependentGuests)

Pool-blind at TWO layers, proven live without a disruptive eject: (a) the scan enumerates via ListLXC → non-pool guests never enter the loop (list = {9201} proven); (b) even if they did, GuestConfig on a non-pool guest 403s (observed live on 9100). On a shared box, ejecting a drive a non-pool guest bind-mounts would be under-warned. Severity: low today (single-tenant demo; Felhom drives are enrolled to Felhom guests), real for the shared-box trial. Fix note in F5.

B3 — hub inventory (collect.go:204)

Confirmed live via --selftest=hub: the host report's guests = only 9201; the four non-pool guests are invisible to the hub/operator. Decision recorded: intended post-containment — the agent manages (and reports) only its pool. The consequence that must be documented (F3): the hub inventory is NOT a full-host view, so operator vmid selection for a new provision must consult the host's real guest list (PVE UI/root), never the hub inventory, on any box that has non-Felhom guests.

B4 — remaining ListLXC consumers

Consumer Behavior with a pool-only list Verdict
stale-lock guard (stalelock.go) Guest enumeration pool-only → recovers only pool guests (intended: non-pool guests aren't the agent's to unlock). Its safety input ListRunningTasks is NOT pool-blind — proven live: a root-initiated vzdump of non-pool 9001 was visible to the token mid-flight (Sys.Audit@/ covers the task list). No misfire vector found. correct
reconcile engine.readActual (engine.go:229) Actual-state is pool-only. Today EmptyProvider → no effect. Latent rule: desired-state must never reference a non-pool guest, or reconcile would treat it as absent and re-provision-over (which then hits the B1 chain, contained). today; rule recorded
recover.go (scratch + bring-up reapers) Existence check pool-only → an out-of-pool collision vmid resolves "already gone" (terminal-clean). Coincidentally correct under containment (see B1.4); would be WRONG (silent leak of a half-built guest) only if the agent could create outside the pool — it can't (restore without pool=felhom 403s). under scoped ACL
--selftest=read list check (main.go:1753) Cosmetic (prints pool guests only).

3. Track C — blast-radius regression: containment intact

Probe Result
Stop non-pool 9001 as token 403 "Permission check failed (/vms/9001, VM.PowerMgmt)"
Vzdump 9201 → non-granted felhom-usb 403 "(/storage/felhom-usb, Datastore.AllocateSpace)" — refused at POST, nothing silently retargeted, no task started
Drive-storage audit (felhom-usb/felhom-flash) readable (status + list, HTTP 200) — 3b-fix (Datastore.Audit@/ in Base) intact, no false detach

4. Track D — pool-membership non-ACL effects: no surprises

  • No leak: the pool name appears in NO customer/controller-facing surface — hub host-report guest entries carry vmid/name/status/controller_version/spec only (verified live); pct config 9201 has no pool field (pool membership lives in PVE user.cfg); the guest-visible bootstrap mount is bootstrap.json only.
  • --adopt-pool (v1.8.0, live run): idempotent — "pool felhom already exists" + "guest 9201 already in pool felhom", guest not otherwise touched.
  • --uninstall --dry-run (live): removes the /pool/felhom ACL grants and correctly refuses to delete a non-empty pool ("pool felhom not empty (members: 9201) — leaving it"). In a real uninstall the destroyed guest auto-leaves the pool first; the guard protects foreign members.

5. Prioritized fix list

# Sev Finding Recommended fix Blocks shared-box trial?
F1 HIGH (logic; contained by ACL) Bring-up rollback + restore-test teardown fire DestroyLXC on a vmid the job never created (create failed "already exists"). One ACL layer away from destroying a foreign guest; any future broad-token/root deployment re-arms it. Arm the compensating destroy only after RestoreLXC returns a UPID (nothing created ⇒ nothing to roll back). A POST network error (outcome unknown) may keep today's behavior. Applies to bringup.go defer + restoretest.go defer (+ their Recover twins are already exists-checked). YES — fix before the trial (defense-in-depth; the trial box has foreign guests by definition)
F2 MED One non-pool squatter at the scratch-band start ⇒ restore-test permanently FAILs (always re-picks the lowest invisible-occupied vmid; reports FAIL not Skip → false "backup unrestorable" alerts). On an "already exists" restore failure, advance to the next band vmid (or pre-verify emptiness via the fenced root pct list); band exhausted ⇒ Skip (existing semantics). Additionally: document 990000990009 as reserved fleet-wide on any Felhom host. YES (cheap; false alerts would drown the trial's signal)
F3 MED (process/doc) Hub inventory is pool-only (by design) while vmid assignment is operator-side ⇒ on a shared box the operator can pick an occupied, hub-invisible vmid. Outcome is safe-but-noisy (B1 chain). Document in the provisioning runbook: pick vmids from the host's full guest list (PVE UI / root pct list+qm list), never from the hub inventory. Longer-term: Day-0/hub could record a host-declared reserved-vmid range. YES (doc-only)
F4 LOW Restore-test can't read a non-pool source guest's config for bind-mount neutralization (403) → "restoring as-is"; a bind-mount-bearing non-pool source archive would false-fail the test. Accept + log (current behavior is fail-safe), or route the source-config read through the fenced root CLI. Today's sources (9201, golden) are unaffected. no
F5 LOW Eject dependentGuests scan is double-pool-blind (list + config 403) → non-pool dependents of an ejected drive are not warned about. Either declare pool-scoped-by-design in the eject UX copy ("Felhom guests using this drive: …"), or scan via the fenced root CLI for the shared-box case. no (drives are Felhom-enrolled; revisit if drives are ever shared)
F6 INFO Stale-lock guard, reconcile readActual, recover reapers: all correct under a pool-only view (details §2 B4); the reconcile desired-state must never reference a non-pool vmid (latent rule, currently unfed). Record the rule next to DesiredProvider when slice-10 feeds it. no

Pre-existing, noted in passing (NOT pool effects): non-root agent can't read /etc/pve/priv/storage/felhom-pbs.pw (known OPEN from the Day-0 bundle — PBS datastore detail skipped in reports); the drive-enrollment candidacy bug has its own pending fix spec (explicitly out of scope per the runbook).

6. Not changed

Validation only — no code, no version bump. felhom-agent untouched (read-only source trace). The demo host was left exactly as found: guests {9000, 9001, 9100, 9201, 9999}, pool felhom = {9201}, agent v0.59.0 active, controller healthy, campaign scratch guests/decoys/PBS archive/probe artifacts all removed.