F-LEAK third attempt: band-scoped fenced destroy (v0.110.0)

The per-VM ACL is consumed by the destroy it authorises (PVE remove_vm_access,
LXC.pm:906), so it works once per slot. Fourth root-fenced exception, band-enforced in
sudoers literally + in code + at the caller. API destroy still tried first.
This commit is contained in:
2026-07-28 11:28:54 +02:00
parent ff7f68e089
commit 50751b8901
5 changed files with 243 additions and 10 deletions
+49 -9
View File
@@ -198,7 +198,7 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS
launched := false
defer func() {
if launched {
e.teardownScratch(ctx, base)
e.teardownScratch(ctx, base, spec.ScratchMin, spec.ScratchMax)
return
}
e.append(withState(base, OpFailed))
@@ -446,7 +446,12 @@ func sizeToGB(s string) int {
// teardownScratch destroys the scratch guest (benign, gated) and records the entry terminal.
// On any teardown failure it leaves the entry in-flight so Recover reaps the guest later.
func (e *Engine) teardownScratch(ctx context.Context, base JournalEntry) {
//
// F-LEAK (Campaign 8): the API destroy is tried FIRST and is the normal path. It fails on a scratch
// left by a FAILED restore, because such a guest never joined /pool/felhom and the token's
// VM.Allocate lives there — so a band-scoped fallback through the fenced root path follows. See
// proxmox.DestroyScratchLXC for the two API-side fixes that were built and refuted live.
func (e *Engine) teardownScratch(ctx context.Context, base JournalEntry, scratchMin, scratchMax int) {
// Cancel-immune + bounded, so a shutdown mid-test still tears down.
tctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
@@ -459,13 +464,14 @@ func (e *Engine) teardownScratch(ctx context.Context, base JournalEntry) {
}
upid, err := e.api.DestroyLXC(tctx, base.VMID)
if err != nil {
// F-LEAK (Campaign 8): a 403 "missing privilege VM.Allocate" here means this scratch is not a
// felhom-pool member — a FAILED restore never completes the `--pool` association, and the
// token's VM.Allocate is granted at /pool/felhom, never at /. Fixed by GRANTING VM.Allocate on
// the scratch VMID band itself (host-install v1.21.0), path-scoped so the agent still cannot
// reach a non-scratch guest. NOT fixable from here: adopting the guest into the pool was tried
// and refused — `PUT /pools/{pool}` ALSO requires VM.Allocate on the VM being added, so
// Pool.Allocate alone cannot bootstrap membership (proven live 2026-07-28).
// A 403 "missing privilege VM.Allocate" here means this scratch is not a felhom-pool member: a
// FAILED restore never completes the `--pool` association, and the token's VM.Allocate is
// granted at /pool/felhom. Fall back to the band-scoped fenced destroy — WITHOUT it the guest
// leaks and holds its disks until a human removes it.
if e.destroyScratchPrivileged(tctx, base.VMID, scratchMin, scratchMax, err) {
e.append(withState(base, OpSucceeded))
return
}
e.logger.Error("restore-test: scratch teardown failed; left for Recover", "vmid", base.VMID, "err", err)
return
}
@@ -568,3 +574,37 @@ func withUPID(base JournalEntry, upid string, state OpState) JournalEntry {
base.At = time.Now().UTC()
return base
}
// destroyScratchPrivileged is the F-LEAK fallback: destroy a stranded scratch through the fenced root
// path when the API token cannot. Reports whether the guest is gone.
//
// It refuses unless the guest is BOTH agent-created scratch provenance (this journal entry) and inside
// the configured band. That is the innermost of three checks — sudoers matches the vmid literally and
// proxmox.DestroyScratchLXC re-checks the band — because this op DESTROYS and the band must not rest
// on a single guard.
func (e *Engine) destroyScratchPrivileged(ctx context.Context, vmid, bandMin, bandMax int, apiErr error) bool {
if e.hostRun == nil {
e.logger.Warn("restore-test: no host-root runner wired — cannot reclaim the stranded scratch",
"vmid", vmid, "api_err", apiErr)
return false
}
if bandMin <= 0 || bandMax < bandMin {
e.logger.Error("restore-test: scratch band is not configured — refusing the privileged teardown",
"vmid", vmid, "min", bandMin, "max", bandMax)
return false
}
if vmid < bandMin || vmid > bandMax {
e.logger.Error("restore-test: refusing the privileged teardown — vmid is outside the scratch band",
"vmid", vmid, "min", bandMin, "max", bandMax)
return false
}
e.logger.Warn("restore-test: API teardown failed (stranded scratch is in no pool) — reclaiming via the fenced root path",
"vmid", vmid, "api_err", apiErr)
if err := proxmox.NewPrivileged(e.hostRun, "").DestroyScratchLXC(ctx, vmid, bandMin, bandMax); err != nil {
e.logger.Error("restore-test: privileged scratch teardown ALSO failed; left for Recover",
"vmid", vmid, "err", err)
return false
}
e.logger.Warn("restore-test: stranded scratch guest reclaimed via the fenced root path", "vmid", vmid)
return true
}