diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index a351173..42e6d85 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,36 @@ +## felhom-host-install 1.21.0 — F-LEAK: the restore-test scratch band gets its own path-scoped grant (2026-07-28) + +Campaign 8's F-LEAK: a restore-test whose restore **fails** leaves a scratch guest the agent cannot +destroy — `403 missing privilege VM.Allocate` — so a half-restored guest holds its disks until a human +removes it. The cause is structural, not a missing privilege in the role: `FelhomAgentGuest` is granted +at `/pool/felhom`, and **a guest only joins that pool when its restore completes**. A failed restore +therefore produces a guest that exists, is in no pool, and is out of the token's reach. + +`apply_scoped_acl` now also grants `FelhomAgentGuest` at each `/vms/` in +`PVE_SCRATCH_VMID_MIN..PVE_SCRATCH_VMID_MAX` (990000–990009, the band the restore-test already picks +from), to **both** the user and the token — the privsep intersection rule applies here as everywhere. + +**This is not a widening.** PVE ACLs are path-scoped: ten explicit `/vms/` grants authorise the +agent on exactly those ten IDs. Granting at `/vms` was rejected — that would authorise destroying every +guest on the box, including a co-tenant's. Verified live: + +| path | `VM.Allocate` | +|---|---| +| `/vms/990000` (scratch band) | **yes** — 13 privs | +| `/vms/100`, `/vms/9999` | no — 3 privs (base only) | +| `/vms/990010` (one past the band) | no — 3 privs | + +Two supporting changes, both load-bearing: +- `remove_scoped_acl` deletes the band grants **before** the role delete. PVE refuses to delete a role + still referenced by any ACL, so omitting this would have broken the uninstall. +- `step_verify` asserts the band grants. A missing one is otherwise invisible until a restore-test + *fails*, which is precisely the case that leaked a guest. + +**The alternative that does not work, recorded so it is not retried:** adopting the stranded guest into +the pool first. It was implemented (agent v0.107.0), shipped, and refuted live on 2026-07-28 — +`PUT /pools/felhom` with `vms=` **also** requires `VM.Allocate` on `/vms/`, so `Pool.Allocate` +cannot bootstrap its own membership. That code was removed in agent v0.108.0. + ## felhom-host-install 1.20.0 — R-82: a fresh box defaults to local-daily + offsite-weekly (2026-07-26) The `backup` defaults gain `backup_targets: [{target_id: "felhom-pbs", cadence_seconds: 604800, diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 65a4880..30afaaf 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -184,7 +184,7 @@ set -euo pipefail -SCRIPT_VERSION="1.20.0" # the SINGLE version source (F-1): -h, the run banners, and the hub +SCRIPT_VERSION="1.21.0" # the SINGLE version source (F-1): -h, the run banners, and the hub # Setup-tab copy (hub internal/web/configs.go hostInstallVersion — # scripts/hostinstall_gates.py asserts the two stay equal) all follow it. # 1.16.0: the FELHOM_ESCROW sudoers alias (controller-driven escrow @@ -306,6 +306,21 @@ PVE_PRIVS_BASE="Sys.Audit SDN.Use Datastore.Audit" # Storages the agent reads/writes (archive+dump=local, restore=local-lvm, offsite DR=felhom-pbs). The # offsite felhom-pbs MUST be included or the agent's DR backup 403s (SPIKE residual #1). --acl-storages overrides. PVE_STORAGES=(local local-lvm felhom-pbs) +# F-LEAK (Campaign 8, v1.21.0): the restore-test's scratch VMID band, granted the GUEST role at each +# /vms/ PATH. WHY THIS EXISTS: the Guest role is granted at /pool/felhom, and a guest only joins +# that pool when its restore COMPLETES. A restore-test whose restore FAILS therefore leaves a scratch +# guest that exists but is in no pool — and the agent's own teardown then 403s on VM.Allocate, so it +# leaks a half-restored guest holding its disks until a human removes it. +# THE ALTERNATIVE THAT DOES NOT WORK: adopting the stranded guest into the pool first. Tried, shipped, +# and REFUTED live on 2026-07-28 — `PUT /pools/felhom` with vms= ALSO requires VM.Allocate on +# /vms/, so Pool.Allocate cannot bootstrap its own membership. +# WHY THIS IS NOT A WIDENING: PVE ACLs are path-scoped, so ten explicit /vms/ grants authorise the +# agent on exactly these ten scratch IDs and nowhere else. It is NOT granted at /vms — that would +# authorise destroying every guest on the box, including a co-tenant's. Verify with +# pvesh get /access/permissions --userid 'felhom-agent@pve!agent' --path /vms/ +# which must NOT list VM.Allocate. The agent keeps its own in-band check; this is the outer fence. +PVE_SCRATCH_VMID_MIN=990000 +PVE_SCRATCH_VMID_MAX=990009 # FELHOM_INSTALL_STATE_DIR: test-harness-only override (hostinstall-mode-harness.sh) so dry/refusal # cases can never touch a live install's state.json. Production runs never set it. @@ -578,7 +593,13 @@ apply_scoped_acl() { for s in "${PVE_STORAGES[@]}"; do _grant "/storage/$s" "$PVE_ROLE_STORE" done - log_success " scoped ACL applied (Base@/, Guest@/pool/$PVE_POOL, Store@[${PVE_STORAGES[*]}])" + # F-LEAK: the scratch band, per-VMID. A failed restore-test's scratch never joins the pool, so the + # pool grant cannot reach it; these path-scoped grants can, and reach nothing else. + local v + for ((v = PVE_SCRATCH_VMID_MIN; v <= PVE_SCRATCH_VMID_MAX; v++)); do + _grant "/vms/$v" "$PVE_ROLE_GUEST" + done + log_success " scoped ACL applied (Base@/, Guest@/pool/$PVE_POOL + /vms/${PVE_SCRATCH_VMID_MIN}..${PVE_SCRATCH_VMID_MAX}, Store@[${PVE_STORAGES[*]}])" } # _acl_grant_present PATH TYPE UGID ROLE — true if that exact ACL grant exists. @@ -594,6 +615,13 @@ remove_scoped_acl() { local s if _acl_grant_present "/pool/$PVE_POOL" user "$PVE_USER" "$PVE_ROLE_GUEST"; then run pveum acl delete "/pool/$PVE_POOL" --users "$PVE_USER" --roles "$PVE_ROLE_GUEST"; fi if _acl_grant_present "/pool/$PVE_POOL" token "${PVE_USER}!${PVE_TOKENID}" "$PVE_ROLE_GUEST"; then run pveum acl delete "/pool/$PVE_POOL" --tokens "${PVE_USER}!${PVE_TOKENID}" --roles "$PVE_ROLE_GUEST"; fi + # F-LEAK scratch-band grants. These MUST be removed before the role delete below — PVE refuses to + # delete a role that is still referenced by any ACL, so leaving them behind breaks the uninstall. + local v + for ((v = PVE_SCRATCH_VMID_MIN; v <= PVE_SCRATCH_VMID_MAX; v++)); do + if _acl_grant_present "/vms/$v" user "$PVE_USER" "$PVE_ROLE_GUEST"; then run pveum acl delete "/vms/$v" --users "$PVE_USER" --roles "$PVE_ROLE_GUEST"; fi + if _acl_grant_present "/vms/$v" token "${PVE_USER}!${PVE_TOKENID}" "$PVE_ROLE_GUEST"; then run pveum acl delete "/vms/$v" --tokens "${PVE_USER}!${PVE_TOKENID}" --roles "$PVE_ROLE_GUEST"; fi + done for s in "${PVE_STORAGES[@]}"; do if _acl_grant_present "/storage/$s" user "$PVE_USER" "$PVE_ROLE_STORE"; then run pveum acl delete "/storage/$s" --users "$PVE_USER" --roles "$PVE_ROLE_STORE"; fi if _acl_grant_present "/storage/$s" token "${PVE_USER}!${PVE_TOKENID}" "$PVE_ROLE_STORE"; then run pveum acl delete "/storage/$s" --tokens "${PVE_USER}!${PVE_TOKENID}" --roles "$PVE_ROLE_STORE"; fi @@ -2530,6 +2558,9 @@ step_verify() { fi local _pairs=("/ $PVE_ROLE_BASE" "/pool/$PVE_POOL $PVE_ROLE_GUEST") _pair _apath _arole _acls2 for _acls2 in "${PVE_STORAGES[@]}"; do _pairs+=("/storage/$_acls2 $PVE_ROLE_STORE"); done + # F-LEAK: assert the scratch band too — a missing grant here is invisible until a restore-test + # FAILS, which is exactly the case that leaked a guest before v1.21.0. + local _sv; for ((_sv = PVE_SCRATCH_VMID_MIN; _sv <= PVE_SCRATCH_VMID_MAX; _sv++)); do _pairs+=("/vms/$_sv $PVE_ROLE_GUEST"); done for _pair in "${_pairs[@]}"; do _apath="${_pair% *}"; _arole="${_pair#* }" if _acl_grant_present "$_apath" user "$PVE_USER" "$_arole" \