diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f91ef5..527de4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.36.4 — serialize AttachDrive/DetachDrive (no double-bind race) (2026-06-15) + +A mutex on GuestBinder serializes AttachDrive/DetachDrive so a controller-triggered reconnect and the +agent`s periodic reconcile can no longer both pass the isHostMountpoint check and double-bind the same +stable path (a TOCTOU race observed live as 2 stacked binds during rapid eject/reconnect). + ## v0.36.3 — DetachDrive loop-umounts stacked binds (2026-06-15) DetachDrive now umounts ALL stacked binds at a stable path (bounded loop), not just one layer — so an diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 38d1ba6..ad304c5 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -44,7 +44,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.36.3" +var version = "0.36.4" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots diff --git a/internal/localapi/guestbind.go b/internal/localapi/guestbind.go index 08897ca..cffd6ef 100644 --- a/internal/localapi/guestbind.go +++ b/internal/localapi/guestbind.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "strconv" + "sync" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) @@ -35,6 +36,10 @@ const felhomDataNS = "felhom-data" type GuestBinder struct { runner proxmox.Runner logger *slog.Logger + // mountMu serializes AttachDrive/DetachDrive so a concurrent reconnect (controller-triggered) and the + // agent's periodic reconcile can't both pass the isHostMountpoint check and double-bind the same + // stable path (a TOCTOU race — observed live as 2 stacked binds). + mountMu sync.Mutex } // NewGuestBinder builds a binder over the given root-CLI runner. diff --git a/internal/localapi/intermediary.go b/internal/localapi/intermediary.go index c155cc8..a5af2b8 100644 --- a/internal/localapi/intermediary.go +++ b/internal/localapi/intermediary.go @@ -151,6 +151,8 @@ func (b *GuestBinder) installSharedParentUnit(ctx context.Context) error { // (umount + mount) to fire a fresh propagation event into the current guest namespace. Idempotent when // the guest already sees it. func (b *GuestBinder) AttachDrive(ctx context.Context, vmid int, where string) (string, error) { + b.mountMu.Lock() // serialize vs a concurrent DetachDrive/AttachDrive (no double-bind TOCTOU) + defer b.mountMu.Unlock() stable := StablePathForRaw(where) if stable == "" { return "", fmt.Errorf("guest-attach: %q is not a /mnt/ mount", where) @@ -289,6 +291,8 @@ func hostBtime() string { // leaving the bare HOST-ROOT-owned stable dir → fail-closed (the guest can't write to it even as root, // since host uid 0 is unmapped). No pct, no reboot. Idempotent: a non-mountpoint is a no-op. func (b *GuestBinder) DetachDrive(ctx context.Context, where string) error { + b.mountMu.Lock() // serialize vs a concurrent AttachDrive (so detach can't race a re-bind) + defer b.mountMu.Unlock() stable := StablePathForRaw(where) if stable == "" { return fmt.Errorf("guest-detach: %q is not a /mnt/ mount", where)