docs: REPORT — v0.61.0 audit-fix batch shipped, live-verified on felhom-pve, Gitea-published

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-03 07:31:32 +02:00
parent 3f382bf762
commit 0db09bf34f
+62 -82
View File
@@ -1,98 +1,78 @@
# REPORT — F1 (HIGH) + F2: never destroy a guest this transaction didn't create (v0.60.0)
# REPORT — blast-radius audit fixes B1 + D1 + D2 + D3 (v0.61.0)
**Date:** 2026-07-02 · **Repo:** `felhom-agent` · **Class:** Risky/supervised (destructive
rollback/destroy path) — strictly SAFER. Fixes the pool-effects campaign's F1/F2
(`felhom.eu/documentation/audits/CAMPAIGN-pool-effects-2026-07-01.md`); F3 is the companion doc in
felhom.eu (`documentation/runbooks/provisioning.md`, commit `6dae9d7`).
**Date:** 2026-07-03 · **Repo:** `felhom-agent` · **Class:** Implementation (destructive-op guard
surfaces D1/D3 proven in unit-land + a loop-device recorder harness — NO real mkfs, no live /dev race,
per the §13 STOP). Source of truth:
`felhom.eu/documentation/audits/AUDIT-blast-radius-hostroot-localapi-2026-07-02.md`.
## 1. Baseline → target
agent **v0.59.0** (`55ade9e`) → **v0.60.0** (commit **`b9356d6`** on `main`). felhom.eu doc commit
`6dae9d7` (no bump).
agent **v0.60.0** (`cc93dae`, confirmed before starting) → **v0.61.0**. Commits on `main`:
- **`3f382bf`** — all four fixes + tests + harness + version bump + CHANGELOG/CONTEXT/README.
- (this REPORT is the follow-up docs commit)
## 2. Root cause + what changed
## 2. What shipped
The compensating destroy ran on ASSUMED provenance (`Rollback:true`/`SameTxnCreated`), never verified
against proof-of-launch. A `RestoreLXC` that failed synchronously (no UPID — e.g. PVE refusing a
pre-existing vmid the pool-blind ListLXC couldn't see) still armed `DestroyLXC(vmid)` — destroying a
guest the transaction never created. The pool ACL's 403 only saved the non-pool subset; an in-pool
pre-existing guest would have been destroyed, in-process AND by Recover. Now **a RestoreLXC UPID is the
sole destroy authorization**, in all three destroy paths (the pool ACL is defense-in-depth again):
| Fix | Files | What changed |
|---|---|---|
| **B1** random temp staging | `internal/guesthook/install.go`, `internal/localapi/intermediary.go` (new `stageTemp`), `configs/felhom-agent.sudoers`, `internal/capability/manifest.go` | Fixed, predictable `/tmp` names (local TOCTOU into root-executed hookscript/boot script) → `os.CreateTemp` random names (the `lanresolver` pattern). Sudoers install-SOURCE grants became globs (`/tmp/felhom-guest-hook-*.sh`, `/tmp/felhom-shared-parent-*.{sh,service}`); destinations + modes unchanged. Manifest repr vectors updated to match. |
| **D1** wrapper asymmetry | `configs/felhom-mkfs-guarded.sh`, new `scripts/mkfs-guarded-harness.sh` | Additive re-checks: `/sys/block/<disk>/ro`==1 → die; lsblk-FSTYPE loop refusing exactly `claim.go`'s `memberFSTypes` (LVM2_member/zfs_member/linux_raid_member/crypto_LUKS/swap); pvs via absolute candidates (`/usr/sbin/pvs`, `/sbin/pvs`) with the FSTYPE loop as the independent LVM catch when pvs is absent. Nothing removed/reordered; a blank partitioned disk still formats. |
| **D2** empty-lsblk fail-safe | `internal/storage/claim.go` | `classifyClaim` refuses when the node tree is EMPTY or the target whole-disk is absent from it (previously: empty-but-successful lsblk ⇒ `unclaimed`). Placed after the member/mount loop; the `gatherErr`/`wholeDiskOK`/`isSystem`/`readonly`/`lvmPV`/`zfsMember` ordering untouched. |
| **D3** blank-format anti-retarget | `internal/localapi/wipe_reresolve.go` (shared `antiRetargetResolveExpect` core + new `antiRetargetResolveBlank`), `disks.go`, `formatjob.go`, `server.go` (new `reresolveBlank` seam) | The blank branch now derives the device's durable id (**no durable id ⇒ 409 refuse** — path-only formats not permitted), re-resolves it (resolve → re-derive exact match → re-inspect **STILL !DataBearing**) and formats the RE-RESOLVED device, never `req.Device`. The format-job record carries `blank`; restart recovery re-checks blank jobs with the blank variant. Confirmed (data-bearing) branch untouched. |
- **F1a `internal/reconcile/bringup.go`** — `runBringUp`'s rollback defer gated on `launched` (set only
after the restore POST is accepted); a no-launch failure closes the owning entry terminal-failed with
NO destroy. Pre-restore `OpStarted` append kept (crash-safety). `rollbackBringUp` doc updated: only
ever called launch-proven.
- **F1b `internal/reconcile/restoretest.go`** — `runScratchTest`'s `teardownScratch` defer gated the
same way.
- **F1c `internal/reconcile/recover.go`** — the no-UPID "POST never confirmed → abandon" check now runs
BEFORE the Scratch/Rollback dispatch: a no-UPID guest-creating entry is abandoned (marked failed, NO
destroy) instead of destroy-by-vmid-existence. Recover safe by DESIGN, not by the pool-blind
"already gone" accident.
- **F2 `restoretest.go`** — `RunRestoreTest` band-advance: an "already exists" refusal (new
`pveAlreadyExists`, APIError-typed like `pveConfigLock` — a real restore failure is never
misclassified) advances to the next free band vmid (`pickScratchVMID` + exclude set, bounded by the
band width); band exhausted → `Skipped` (the scheduler raises no record/alert), never FAIL.
- **Accepted residual (rule 4, by design):** a crash in the one-statement window between obtaining the
UPID and journaling it leaks a half-built guest Recover won't destroy — cleanable, preferable to
destroying an innocent guest.
**Not in this batch (per spec §0/§12):** A1 (stale-lock pool-membership — a SPIKE: the role lacks
`Pool.Audit`, no cheap membership read exists); C1, C2, A2, B2B5, E1, E2 (deferred). B2's `..` guard
intentionally skipped — `DriveNameFromRaw` already rejects `/`, so `..` alone can't traverse.
## 3. Green gate
## 3. Tests + red-proofs (each new test shown FAILING on the pre-fix impl, then green on the fix)
`go build ./... && go vet ./... && go test ./...` — clean, 17 packages ok (build server go1.26.0 build
of the deploy binary also clean).
| Fix | Tests | Red-proof result |
|---|---|---|
| B1 | `TestInstallSnippet_RandomTempName`, `TestInstallSharedParent_RandomTempName` (fake runner records the install source; random pattern + two-calls-differ + content + cleanup asserted) | Pre-fix (HEAD files restored): both FAIL — fixed name, identical across calls. Fix restored → green. |
| D2 | `TestClassifyClaim_EmptyNodesRefused`, `TestClassifyClaim_TargetAbsentFromTree` | Pre-fix `claim.go`: both FAIL — `(true,"unclaimed")` returned. Fix restored → green. |
| D3 | Pure: `TestFormatBlankPath_AntiRetarget_{ReassignedDataBearingRefused,ReassignedDifferentDiskRefused,UnresolvableRefused,SameBlankProceeds}`. Handler: `TestFormat_Blank_{FormatsReresolvedDeviceNotCallerPath,ReresolveRefusalNoMkfs,NoDurableIDRefused}` | Pre-fix blank branch (formats `req.Device`, no binding): all 3 handler tests FAIL — mkfs ran on the caller path with HTTP 200 and no durable binding. Fix restored → green. |
| D1 | `scripts/mkfs-guarded-harness.sh` on felhom-pve (root): throwaway loop devices (`losetup`, one `-r` read-only), PATH-shimmed `lsblk` FSTYPE fixture, `/dev/null` bound over pvs for the pvs-absent case, and a RECORDER bind-mounted over `/usr/sbin/mkfs.ext4` in a private mount ns — **no real mkfs possible**; "formats" is proven by the recorder argv, refusal by its absence | **Fixed wrapper: 8/8 PASS** (5 member FSTYPEs refused, read-only refused, pvs-hidden LVM still refused, plain blank disk still reaches mkfs). **Pre-fix wrapper: 7/8 FAIL** — every member/RO/pvs-hidden fixture reached mkfs. The loop-device `-b` check ran for real (no stub). |
## 4. Tests (incl. the red-proofs)
Full gates green on Windows AND on the build server (go1.26.0):
`go build ./... && go vet ./... && go test ./...`**0 failures**. Test count **472 → 483** (+11).
- **F1a red-proof `TestRunBringUp_NoLaunchNoDestroy`** (PVE "already exists" APIError + plain
synchronous error): asserts `DestroyLXC` NOT called + entry terminal. **Companion verified:** with
the `launched` gate reverted it fails `destroys=[8000]` — the exact destroy-innocent-guest bug.
- **F1b `TestRunRestoreTest_RestoreNoLaunchNoTeardown`** (replaces the old
`…RestoreFailureStillTearsDown`, which asserted the BUGGY behavior): no teardown on a no-launch
failure. Red-proof verified (`destroys=[990000]` pre-fix).
- **No-regression (Scenario B):** `TestRunRestoreTest_LaunchedTaskFailureStillTearsDown` + a new
"restore-task failure after launch" case in `TestRunBringUp_CompensatingRollback` — a LAUNCHED
restore whose task fails still destroys the half-built guest.
- **F1c `TestRecover_BringUpNoUPIDAbandoned` + `TestRecover_ScratchNoUPIDAbandoned`:** a no-UPID
Rollback/Scratch entry with a guest PRESENT at the vmid → abandoned via the fail-safe path, no
destroy. Red-proof verified. Existing leaked-guest Recover tests updated to carry the UPID (the
launch proof) their scenario implies.
- **F2 `TestRunRestoreTest_AdvancesPastOccupiedBandVMID`** (squatter at 990000 → PASS at 990001,
squatter never destroyed) + **`…BandFullOfSquattersSkips`** (Skipped, not FAIL, bounded — one try per
band vmid) + `TestPickScratchVMID` exclude-set coverage.
## 4. Build / publish / deploy / verify
## 5. Live proofs (felhom-pve, v0.60.0 deployed)
- Built on 180 from `3f382bf`: `CGO_ENABLED=0 go build -ldflags "-X main.version=0.61.0"`;
`--version``felhom-agent 0.61.0`.
- **Published to Gitea** (generic package, delete-then-put + round-trip GET verified):
`AGENT_VERSION=0.61.0`, `AGENT_SHA256=d5f2f283501dac9daac1c197c9665c98f4577e11d155f5da831b26048c736ed8`.
Credentials sourced out-of-band (`/root/.golden-secrets.env` on the host; not committed anywhere).
- **Deployed to felhom-pve** (backups kept: `felhom-agent.bak-0.60.0`, `felhom-mkfs-guarded.bak-0.60.0`):
binary → `/usr/local/bin/felhom-agent`; **new wrapper**`/usr/local/sbin/felhom-mkfs-guarded`
(grep-confirmed the D1 checks in the installed copy); **new sudoers**`/etc/sudoers.d/felhom-agent`
(`visudo -cf` passed both staged and installed).
- **Verify after restart:** service `active`, `--version` 0.61.0, **0 ERROR lines** in the startup
journal; **capability self-check 46/46 ok, 0 degraded** (proves the new sudoers globs cover the new
manifest vectors live); `ReassertGuestBinds`/reconcile re-bound exactly the three enrolled drives
(felhom-flash, felhom-usb, teszt_enroll) — nothing else; stale-lock recovery ran with nothing to reap
(no stale locks, no false action); local API listening; hub desired-state pulled.
- **B1 live-proven end-to-end:** made the installed shared-parent script stale (appended a marker) →
the next reconcile self-healed it through the REAL sudo path with random-named temps
(`install -m 0755 -- /tmp/felhom-shared-parent-281383295.sh …`,
`install -m 0644 -- /tmp/felhom-shared-parent-2841067761.service …`) — authorized by the new globs.
- Cleanup: all /tmp staging removed from felhom-pve and 180; harness loop devices/workdir self-cleaned.
- **Probe A (headline):** `--selftest=bring-up -vmid 9001` (existing non-pool guest) → PVE refused
("CT 9001 already exists"); **no gate decision, no DestroyLXC attempt of any kind** (pre-fix run had
`gate decision class=guest_destroy` + a 403'd DELETE); 9001 untouched; journal drained in-process
(next run: `recover: examined=0`).
- **Probe D (F2):** decoy parked at 990000 → restore-test logged "band VMID occupied … advancing",
**passed at 990001** (restored+booted+verified+torn-down in 28s), decoy survived, decoy removed.
- **Regression:** forced post-launch failure (`-cores 10000` → config PUT 400 after restore) →
compensating rollback destroyed the half-built 9310 ("bring-up: rolled back"); a normal provision of
9310 then passed (boot+running 22s) and tore down cleanly.
- STOP condition (any destroy attempted against 9001 or the decoy) did not trigger.
## 5. NOT yet live-validated — awaiting a supervised step
## 6. Deployed + published
- **D1 destructive proof:** a real `mkfs` attempt against a crafted ZFS/mdraid/LUKS/swap member disk.
- **D3 race proof:** a live /dev re-enumeration race during a real format.
Both are proven in unit-land + the recorder harness only, per the task's hard STOP.
- felhom-pve: `/usr/local/bin/felhom-agent` = **0.60.0** (backup `felhom-agent.bak-0.59.0`), unit
`active`, clean logs. Demo left healthy: pool `felhom` = {9201}, guests 9001/9100/9201/9999 intact.
- Gitea generic package `felhom-agent/0.60.0` published (HTTP 201), download sha256 round-trip verified:
`45d19b8799cc764333cd95bb64e0515f2246cdb08c0fb36d9b23409051725831`.
- **Operator follow-up:** bump the hub Day-0 artifact manifest (password-gated UI) to agent **0.60.0**
— until then, fresh Day-0 installs still fetch the manifest-pinned older version.
## 6. Observations / follow-ups (recorded, not acted on)
## 7. F3 doc
`felhom.eu/documentation/runbooks/provisioning.md` (commit `6dae9d7`): pick the provision `-vmid` from
the host's FULL guest list (`pct list` + `qm list`), never the pool-only hub inventory; reserved bands
documented (9999, 990000990009 fleet-wide). Campaign doc updated with the F1/F2/F3 resolution banner.
## 8. Observations (not acted on)
- The hub artifact-manifest bump remains operator-gated (hub v0.29.0 dropdowns make it one click).
- Campaign F4 (restore-test can't read a NON-pool source guest's config for bind-mount neutralization)
and F5 (pool-blind eject dependent-scan) remain open as LOW, per the campaign doc.
- The bring-up duplicate-guard (`bringup.go` ListLXC scan) is still pool-blind — acceptable now that a
collision is non-destructive and PVE refuses authoritatively; noted in the F3 runbook.
- **Operator follow-up:** the hub Day-0 artifact manifest still vouches agent **0.60.0** — record
`0.61.0` / sha `d5f2f283…` in the password-gated operator UI (Configs → Day-0 artifacts) so Day-0
self-installs fetch the new build. (CC cannot do this; see hub-artifact-manifest gotcha.)
- **A1 (LOW, pending SPIKE):** stale-lock recovery reaps by a pool-blind `ListLXC` scan — contained by
the pool-scoped token; MUST be fixed before any broad/root-token deployment. Open design question:
which pool-membership read the role can authorize (`Pool.Audit` absent today).
- Deferred audit items: C1 (controller-swap version floor), C2 (NAS server allowlist), A2 (gate
journal cross-check), B3/B4/B5 housekeeping, E1/E2 comment fixes.
- Known pre-existing: the non-root agent cannot read the PBS key (`felhom-pbs.pw` permission denied at
startup — the BUNDLE leftover, unchanged by this batch).