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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user