host-install 1.21.0: F-LEAK — grant FelhomAgentGuest on the scratch VMID band
A failed restore-test's scratch guest never joins the felhom pool, so the pool-scoped grant cannot reach it and teardown 403s. Ten path-scoped /vms/<id> grants reach exactly the scratch band and nothing else. Removal path + verify step extended.
This commit is contained in:
@@ -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/<id>` 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/<id>` 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=<id>` **also** requires `VM.Allocate` on `/vms/<id>`, 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,
|
||||
|
||||
@@ -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/<id> 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=<id> ALSO requires VM.Allocate on
|
||||
# /vms/<id>, so Pool.Allocate cannot bootstrap its own membership.
|
||||
# WHY THIS IS NOT A WIDENING: PVE ACLs are path-scoped, so ten explicit /vms/<id> 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/<some-real-vmid>
|
||||
# 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" \
|
||||
|
||||
Reference in New Issue
Block a user