Files
felhom-agent/internal/localapi/disks.go
T
admin 966d8f41ff v0.117.0 — R-117: the liveness signal now tests liveness
BoundUnderParent reported a namespace that returned EIO on every read and write
as healthy, and the gate restarted the customer's apps onto it. Both existing
terms parse a mountinfo line and then test only fields[4], the mount POINT.
Field 3 — major:minor — sat in the same parsed slice and was discarded.

Measured on hardware: raw 8:32 /dev/sdc, bind 8:16 /dev/sdb with `shutdown`,
bound_under_parent true, EIO both directions, and the controller taking its
Return branch and emailing backup_target_restored with no alarm on any channel.

BoundUnderParent gains a third term at both /disks construction sites. The new
bindLiveness reads /proc only and asks two questions: the bind must name the
same device as the raw mount, and the filesystem must not have aborted (ext4
`shutdown` or `emergency_ro`).

The second check is not optional. A device that fails WITHOUT disappearing gives
the identical all-signals-healthy state with the devnos EQUAL and the drive never
Disconnected, so the gate produces neither a Stop nor a Return and nothing is
emitted on any channel, indefinitely (R-117a). A devno-only fix would have passed
every payload test.

Three states, never a bool: {Unknown, Live, StaleDevice, Aborted}, read through
Usable(), where Unknown counts as PRESENT — reporting absent stops a working
customer's apps.

No new recovery path; the existing one was unblocked. AttachDrive's normalize leg
already did the repair and three call sites already invoked it, including the
controller's Return branch before it restarts apps. All three died on
`if n == 1 && GuestSeesMount(...)` returning early. Now: StaleDevice ⇒ re-bind
(repairs live, guest never restarts); Aborted ⇒ quiet no-op, because a re-bind
lands on the same dead superblock and this runs every 20s — an infinite silent
retry that masks the state; it surfaces via BoundUnderParent=false instead.

Ordering trap caught by a test: reading the abort flag before comparing devices
classifies the real return state as aborted (its stale bind carries `shutdown`
too) and refuses the repair while still reporting correctly. The abort flag is
read off the RAW mount in the stale case.

Tests 849 → 863, 29/29 packages green. 6 red-proofs, each verified to have
landed. A hollow test was caught during them: the aborted fixture first used a
/dev/mapper device, for which RoleForStorage derives role=system — a system row
has no GuestPath, never runs the conjunction, and reports false by default, so
the assertion passed vacuously and no mutation could fail it. Found because RP1
failed to fail.
2026-07-30 12:26:42 +02:00

1389 lines
72 KiB
Go

package localapi
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// Disk management (slice 8C, doc 03 §6). The controller's disk-management UX stays in the
// controller; EXECUTION is the agent's. The security centerpiece: the agent decides
// data-bearing-ness by INSPECTING THE ACTUAL DEVICE (agent-internal evidence), never from the
// caller's claim — a compromised controller asserting "this drive is blank" cannot wipe a
// data-bearing drive. Benign ops (list/assign/eject/format-blank) execute self-serve; a
// data-bearing format is classified destructive → the gate refuses it `pending_signature` (the
// operator-signed completion is slice 10).
// DiskOps is the privileged host-storage surface the disk endpoints need. Satisfied by
// *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent.
type DiskOps interface {
EnsureMount(ctx context.Context, spec storage.MountSpec) error
Unmount(ctx context.Context, where string) error
Format(ctx context.Context, device, fstype string) error
InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
// ListCandidateDisks enumerates host disks the Impl-1 unclaimed filter proves are free to enroll
// (Impl-2a discovery). Fail-safe: a device not provably unclaimed is omitted.
ListCandidateDisks(ctx context.Context) ([]storage.CandidateDisk, error)
}
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
// adapter over reconcile.Gate in main.go.
// - user-data → customer-confirmable: allowed iff the request carries the customer's confirmation
// bound to the device's durable id (no operator signature).
// - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored).
type StorageGate interface {
AuthorizeWipe(req WipeRequest) WipeDecision
}
// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are
// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed +
// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match.
type WipeRequest struct {
Role string // "system" | "backup" | "user-data" (agent-classified)
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
Confirmed bool
ConfirmDurableID string
}
// WipeDecision is the gate's verdict.
type WipeDecision struct {
Allowed bool
Tier string // "customer_confirmable" | "destructive"
Reason string // machine reason (audit/UI)
NeedsConfirmation bool // user-data, not-yet-confirmed → ask the customer (NOT a signature)
}
// GuestLister lists the host's guests (to map a mount to the guests that depend on it for the
// eject warning). Satisfied by *proxmox.Client.
type GuestLister interface {
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
}
// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind
// mount (slice 10 P2, Model A) and reboots the guest to activate persisted-but-inactive binds (the
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
type GuestAttacher interface {
// AttachDrive (intermediary model) binds the drive's felhom-data under the shared parent so it
// appears live in the guest at the returned stable path — no pct, no reboot. `where` = raw /mnt/<name>.
// vmid is needed to verify GUEST visibility (a guest reboot needs a fresh re-bind to re-propagate).
AttachDrive(ctx context.Context, vmid int, where string) (guestPath string, err error)
// DetachDrive unmounts the drive's felhom-data from the shared parent (live, fail-closed).
DetachDrive(ctx context.Context, where string) error
// EnsureSharedParent makes the host stable parent shared + installs the boot-persistence unit.
EnsureSharedParent(ctx context.Context) error
// GuestSeesMount reports whether vmid's guest sees `path` as a mount in its own namespace — the
// guest-VISIBILITY signal, distinct from the host having the bind. One of the three terms behind
// BoundUnderParent. It is a path-presence test and NOT a liveness signal (R-117): a stale bind over a
// dead device is still "seen". Liveness is bindLiveness's job.
GuestSeesMount(ctx context.Context, vmid int, path string) bool
// GuestBootID returns a token that changes on every guest boot (host or guest) but is stable across a
// controller-only restart — the deterministic guest-reboot signal the controller recreates apps on.
GuestBootID(ctx context.Context, vmid int) string
// AttachBind is the LEGACY per-drive `pct set -mpN` bind (pre-intermediary). Retained for the
// transition; new attaches use AttachDrive.
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
// DetachBind removes a LEGACY mountpoint slot from the guest config (the decommission C1 fix — a
// removed bind can't brick the next boot with a missing source). Runs on the running guest.
DetachBind(ctx context.Context, vmid int, mountKey string) error
RebootGuest(ctx context.Context, vmid int) error
}
// IntentRecorder persists drive enroll/eject/decommission INTENT (slice 10 P3 self-heal), keyed by
// durable-id, so the watchdog reconciles only enrolled drives and respects an official eject /
// permanent decommission. Get lets the startup re-assert be intent-aware (B2 — skip non-enrolled).
// Satisfied by *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal
// is ungated and the re-assert falls back to legacy bind-all behavior).
type IntentRecorder interface {
SetEnrolled(durableID string) error
SetEjected(durableID string) error
SetDecommissioned(durableID string) error
Get(durableID string) storage.DriveIntent
}
// ---- handlers ---------------------------------------------------------------------------
// DiskInfo is one host drive with its data-bearing flag (for the UI).
type DiskInfo struct {
Name string `json:"name"` // PVE storage id
Type string `json:"type"` // local-dir | usb | lvmthin | …
State string `json:"state"` // attached | disconnected
BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
MountPath string `json:"mount_path"`
Class string `json:"class"` // fast | slow | ""
// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
// the agent's own storage view + host topology — never from the controller. The controller drives
// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
Role string `json:"role"`
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
DataReason string `json:"data_reason,omitempty"`
// Capacity (from the agent's storage view) — for the controller's capacity bar. 0 when unknown.
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
UsedFraction float64 `json:"used_fraction"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
// controller strips the "uuid:" prefix to get the fs UUID it passes to POST /disks/assign —
// the only way the (de-privileged) controller can learn the mount key it cannot read itself.
DurableID string `json:"durable_id,omitempty"`
// WipeDurableID is the device's WIPE-binding durable id in the SAME scheme the format gate resolves
// against (byid:<wwn>/byuuid:<uuid>). F20-BUG2: the customer must confirm a data-bearing wipe with
// THIS id, not DurableID (uuid:) — passing the uuid: id was rejected as a binding_mismatch. "" when
// the device has no durable identity (a wipe of it can't be bound anyway). Distinct from DurableID
// (uuid:, used for /disks/assign) on purpose; both are derived from the agent's own device read.
WipeDurableID string `json:"wipe_durable_id,omitempty"`
// GuestAttached reports whether THIS guest (the token's vmid) actually has the drive's felhom-data
// namespace bound into its config — i.e. the drive is usable IN the guest, not merely present on the
// host. F9: host presence (State=attached) != guest-usable; this is the missing signal that made the
// HDD look available when it wasn't bound. Only meaningful for user-data drives. LEGACY (per-drive
// `pct set -mpN` model) — the intermediary model uses BoundUnderParent.
GuestAttached bool `json:"guest_attached"`
// BackupTarget (E-2) reports that this drive backs the PRIMARY whole-guest backup tier. Additive:
// an older controller ignores it. It is the agent's answer, not the controller's intent flag —
// on a hand-migrated box (E-1) intent is unset while the drive really is the target.
BackupTarget bool `json:"backup_target,omitempty"`
// GuestPath is the drive's STABLE in-guest path in the intermediary-mount model
// (/mnt/felhom-drives/<name>). This is what the controller repoints HDD_PATH to and registers as the
// storage path. Set for /mnt/<name> drives; "" otherwise. Distinct from MountPath (the RAW host PVE
// mount the agent ops on).
GuestPath string `json:"guest_path,omitempty"`
// BoundUnderParent reports whether the drive is live + usable in the guest in the intermediary model.
// The controller's drive-absent gate + auto-restart key on this (and State).
//
// It is a CONJUNCTION of THREE facts, and all three are load-bearing:
// 1. felhom-data is bound under the shared parent at GuestPath (the guest-visible mount check), and
// 2. the drive's RAW host mount is still mounted — i.e. the DEVICE is still there (R-113), and
// 3. the bind actually WORKS: it names the same device as the raw mount, and that filesystem has
// not aborted (R-117, v0.117.0 — see bindLiveness).
// Half 1 alone was the bug R-113 fixed: the raw mount is device-bound and dies with its device, but
// the agent's own bind is not, so half 1 stays true over a stale shell after the device is pulled. The
// controller read that survivor as "present" and the drive-absent alarm could never fire — measured
// live in E-2d (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.2). Half 2 alone would regress boot
// ordering, where the raw mounts early and the bind lands ~18s later; the conjunction keeps that
// window reading absent.
//
// Terms 1 and 2 TOGETHER were still not liveness, which is R-117: both are path-presence tests
// comparing only field 5 of a mountinfo line, so both stay true over a bind that names the drive that
// went away while the raw mount healed onto the returning one via its fs-UUID-keyed unit. Measured
// live: raw on 8:32 /dev/sdc, bind on 8:16 /dev/sdb with `shutdown`, this field TRUE, EIO on every
// read and write, and the gate restarting the customer's apps onto it with no alarm on any channel
// (felhom.eu audits/SPIKE-r117-bind-liveness-2026-07-30.md §5.2).
//
// THE TESTS THAT PIN THIS COMMENT, because for three releases it promised a property nothing tested
// (spike §5.3): disks_bind_liveness_test.go — TestDisks_BindLiveness_StaleBindReadsAbsent (term 3,
// case a), _AbortedFilesystemReadsAbsent (term 3, case b, the steady-state case that emits nothing
// today), _UnknownIsTreatedAsPresent (the cannot-tell rule) and _HealthyReadsPresent (no false
// negative). Each asserts the CONSEQUENCE — what this field reads — not the mechanism.
BoundUnderParent bool `json:"bound_under_parent"`
// Smart is the already-computed per-disk SMART health summary (v0.94.0), serialized here so the
// controller can render a disk-health card + degradation alert WITHOUT any new smartctl load — the
// value is copied straight from the target's Observe-time enrichment. omitempty + a pointer so a
// device that exposes no SMART (USB bridge, unread) is ABSENT, not a misleading zero-value UNKNOWN;
// the controller feature-detects presence and renders "Nincs adat" when nil (never alarms).
Smart *hub.SmartSummary `json:"smart,omitempty"`
}
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
targets, err := s.storage.Observe(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, "could not read storage view")
return
}
// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
// (the agent's own mount/topology read, never the caller's claim).
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
// F9: which host mount paths are actually BOUND into THIS guest's config (guest-usable, not just
// host-present). A bind's mp= equals the guest path, which is the drive's host mount path (`where`).
boundPaths := s.guestBoundPaths(r.Context(), vmid)
// E-2: the PRIMARY tier's storage id — the whole-guest vzdump destination. "" when no tier
// carries a target (the legacy single-tier shape), which correctly flags nothing.
primaryTargetID := s.primaryTier().TargetID
out := make([]DiskInfo, 0, len(targets))
for _, t := range targets {
di := DiskInfo{
Name: t.Name, Type: t.Type, State: t.State,
BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
DurableID: t.DurableID,
Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)),
TotalBytes: t.TotalBytes,
UsedBytes: t.UsedBytes,
UsedFraction: t.UsedFraction,
GuestAttached: t.MountPath != "" && boundPaths[t.MountPath],
// E-2: is THIS drive the whole-guest backup target? The agent is the only component that
// can answer — the controller's own StoragePath.BackupTarget is customer INTENT, and on a
// box migrated by hand (E-1) nobody ever assigned it, so intent is empty while the drive
// really is the target. Reported here so the controller can name the drive in an
// absent-target alarm and hide the destructive controls the agent would refuse anyway.
BackupTarget: t.Name == primaryTargetID,
}
// Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent.
// Only user-data /mnt/<name> drives have a guest path (system/backup mounts never cross in).
if di.Role == string(storage.RoleUserData) {
if gp := StablePathForRaw(t.MountPath); gp != "" {
di.GuestPath = gp
// R-113: AND in device presence. A conjunction, deliberately — it leaves the
// boot-ordering behaviour the controller's gate depends on exactly as it was
// (raw mounted early, bind not yet ⇒ still absent) while closing the case the
// gate could never see (bind outlived the device ⇒ now absent).
// R-117: AND in bind LIVENESS. The two terms above are both path-presence tests, so
// both stay true over a bind that names the drive that went away while the raw mount
// healed onto the returning one — EIO on every call, payload healthy.
di.BoundUnderParent = s.boundUnderParent(r.Context(), vmid, gp) &&
s.devicePresent(t.MountPath) &&
s.bindUsable(gp, t.MountPath)
}
}
// R-116: carry the GUEST PATH on the backup-target row even when its role has flipped to
// system — but ONLY when that flip was caused by the device vanishing.
//
// WHY. The controller keys the drive-absent alarm on the registered StoragePath, which for an
// external drive is the GUEST path. When the device goes, Observe's exactMountDevice fails, so
// t.BackingDevice becomes "" and RoleForStorage returns RoleSystem (role.go:180-181) — the block
// above is skipped and this row loses its guest path. It keeps its MountPath, so the union loop
// below DEDUPES the registry row away (`seen[d.MountPath]`), and /disks ends up carrying NO row
// with that guest path at all. driveTargetByPath then has no entry, isTarget[guestPath] is a
// missing key, and the specific backup_target_absent alarm cannot fire — the generic one goes
// out instead, while the RETURN (rows rejoined) fires the specific recovery. An unmatchable
// pair. Measured live: felhom.eu audits/SESSION-C-2026-07-29.md §5.
//
// THE GATES, each load-bearing:
// di.GuestPath == "" — never touch the user-data path above; this is a fallback, not a rule.
// di.BackupTarget — only the target row. No other system/backup mount gains a guest path,
// so the boundary at :213-214 stands: this is not "system mounts now
// cross into the guest", it is "the drive the alarm is about keeps its
// identity while it is missing".
// t.BackingDevice == "" — ONLY the vanished-device flip. A storage that is RoleSystem because
// it is genuinely system-BACKED has a non-empty BackingDevice and is
// excluded. Without this gate a dir storage at /mnt/<name> living on the
// root disk would acquire a guest path.
//
// Case B (the COMMON fresh-box shape) is safe twice over: the target is the builtin `local` on
// /var/lib/vz, and StablePathForRaw returns "" for anything that is not exactly /mnt/<name>
// (DriveNameFromRaw, intermediary.go:79-88), so nothing is set even before the gates apply.
//
// This cannot make the gate read an absent drive as PRESENT: BoundUnderParent is assigned only
// inside the two guest-path blocks a system-role row never enters, so it stays false, and
// planDriveGates computes present[gp] = present[gp] || d.BoundUnderParent. Inert by construction
// — pinned by TestAbsentTargetRowDoesNotRegisterPresence.
//
// v0.116.0 — WHY v0.115.0 (the MountPath-only form) WAS INERT, measured not reasoned. In the
// absent state t.MountPath is ALSO "" — the same exactMount failure that emptied BackingDevice
// empties it — so StablePathForRaw("") returned "" and this assigned nothing. Captured payload:
// felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md §6.2.
//
// t.ConfigPath is the fix: the storage's CONFIGURED path from storage.cfg, which is configuration
// and therefore survives the device. MountPath is tried FIRST so the present-state path and
// v0.115.0's tested behaviour are byte-identical; ConfigPath is consulted only when the mount is
// genuinely gone. MountPath is deliberately NOT back-filled from ConfigPath — see the union-dedup
// note below for the consumer that would break, and because a path that is not mounted is not a
// "host mountpoint" (this field's own contract, :152-153).
if di.GuestPath == "" && di.BackupTarget && t.BackingDevice == "" {
if gp := StablePathForRaw(t.MountPath); gp != "" {
di.GuestPath = gp
} else {
di.GuestPath = StablePathForRaw(t.ConfigPath)
}
}
// Inspect the backing device for the UI's data-bearing hint (the authoritative check
// is re-run at format time on the actual device).
if t.BackingDevice != "" {
if probe, perr := s.disks.InspectDevice(r.Context(), t.BackingDevice); perr == nil {
di.DataBearing = probe.DataBearing()
di.DataReason = probe.Reason()
} else {
di.DataBearing = true // fail-safe
di.DataReason = "could not inspect device"
}
// F20-BUG2: surface the gate-scheme wipe id (byid:/byuuid:) so a customer-confirmed wipe
// binds with the id the gate accepts. Same seam the gate uses → guaranteed to match.
if wid, werr := s.deviceDurableID(t.BackingDevice); werr == nil {
di.WipeDurableID = wid
}
}
// v0.94.0: surface the already-computed SMART only when it was actually read (Health set).
// A zero-value summary (enrich skipped / no smartctl device) has Health "" → stays omitted, so
// the controller sees "absent" and renders "Nincs adat" rather than a false UNKNOWN.
if t.Smart.Health != "" {
sm := t.Smart
di.Smart = &sm
}
out = append(out, di)
}
// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
// dir-storage). Additive + deduped by mount path — an existing drive already shown via Observe is
// NOT duplicated, and no Observe row is dropped (so this can never regress the current view).
if s.driveTargets != nil {
seen := make(map[string]bool, len(out))
// R-116: dedup ALSO by guest path. `seen` keys on MountPath, the one field the absent state
// empties, so with the device gone /mnt/<name> is absent from `seen` and the registry row was NOT
// skipped — /disks carried the drive TWICE, the Observe row holding BackupTarget with no key and
// the registry row holding both keys with BackupTarget defaulted false. driveTargetByPath
// (controller intermediary.go:602-618) assigns rather than ORs, and the registry row is appended
// LAST, so its false won on both keys. Measured, 4 rows vs 3:
// felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md §6.2.
//
// THE JOIN, and it is the whole point: with the device gone the two records of one drive share NO
// runtime field — no mount, no backing device, and the Observe row's DurableID has degraded off the
// fs-UUID. What they DO share is CONFIGURATION: the Observe row's storage path (storage.cfg) and the
// registry row's unit `Where` (the .mount unit) are the same path, so both derive the same stable
// guest path. That is the key both sides can still compute, which is why the dedup keys on it.
seenGuest := make(map[string]bool, len(out))
for _, d := range out {
if d.MountPath != "" {
seen[d.MountPath] = true
}
if d.GuestPath != "" {
seenGuest[d.GuestPath] = true
}
}
if drives, derr := s.driveTargets.Known(r.Context()); derr == nil {
for _, d := range drives {
if d.MountPath == "" || seen[d.MountPath] {
continue
}
// Same drive as an Observe row that already carries this guest path — skip it. Suppressing
// it rather than teaching it BackupTarget is deliberate: the registry row has a non-empty
// MountPath (from the unit file, stale by then), and the controller reads
// `d.BackupTarget && d.MountPath != ""` as "a real drive with its own mountpoint — HEALTHY"
// (backup_target_offer.go:79). Putting the flag on a row with a stale MountPath would have
// silently regressed R-114, telling the customer the backup target is fine while its drive
// is missing. Pinned by TestAbsentTargetKeepsR114DegradedSignal.
if gp := StablePathForRaw(d.MountPath); gp != "" && seenGuest[gp] {
continue
}
di := DiskInfo{
Name: d.Name, Type: d.Type, State: "attached",
MountPath: d.MountPath, DurableID: d.DurableID,
Role: string(storage.RoleUserData),
GuestAttached: boundPaths[d.MountPath],
}
// Intermediary model: report the stable in-guest path + whether felhom-data is bound live,
// exactly like the Observe path — else the controller reads a registry drive as "Leválasztva".
if gp := StablePathForRaw(d.MountPath); gp != "" {
di.GuestPath = gp
// R-113 + R-117: same three-term conjunction as the Observe path. This path matters
// MORE, not less — a registry drive with no PVE dir-storage is exactly the shape
// E-2d detached, and its State is hardcoded "attached" below, so these checks are
// the only device truth this row carries.
di.BoundUnderParent = s.boundUnderParent(r.Context(), vmid, gp) &&
s.devicePresent(d.MountPath) &&
s.bindUsable(gp, d.MountPath)
}
// A registry drive has no PVE `pvesm status` snapshot, so fill backing device + capacity
// from the host directly: resolve the device by fs-UUID, and statfs the mount for size —
// else the agent-view shows "—" for the device and no size bar.
if d.UUID != "" {
// Resolve to the real /dev node (e.g. /dev/sdd), not the by-uuid symlink path, to match
// how Observe-sourced rows display the backing device.
if dev, err := s.resolveStorageDevice("uuid:" + d.UUID); err == nil {
di.BackingDevice = dev
}
}
// Fix B (v0.95.0): union-path drives skip Observe's enrich, so read SMART here through the
// same seam the dir targets use. Only set when the read actually ran (Health != "").
if di.BackingDevice != "" && s.smart != nil {
if sm := s.smart.SMARTForBacking(r.Context(), di.BackingDevice); sm.Health != "" {
di.Smart = &sm
}
}
if total, used, okc := statfsCapacity(d.MountPath); okc {
di.TotalBytes, di.UsedBytes = total, used
di.UsedFraction = float64(used) / float64(total)
}
out = append(out, di)
}
} else {
s.logger.Warn("disks: registry drive union skipped", "err", derr)
}
}
// Guest boot-id (intermediary model): changes on every guest boot, stable across controller restarts.
// The controller persists it and deterministically recreates drive-backed apps when it changes.
bootID := ""
if s.guestAttach != nil {
bootID = s.guestAttach.GuestBootID(r.Context(), vmid)
}
writeOK(w, map[string]any{"vmid": vmid, "disks": out, "guest_boot_id": bootID})
}
// handleDiskCandidates (Impl-2a) lists the host disks FREE for Felhom to enroll — the raw-device
// discovery the enrollment wizard (Impl-2b) consumes. Read-only + benign (the Impl-1 filter never
// offers a claimed/OS disk). Response splits into `initialize` (all unclaimed) and `attach` (the subset
// already carrying a mountable FS). GET /disks/candidates.
func (s *Server) handleDiskCandidates(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
cands, err := s.disks.ListCandidateDisks(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, "could not scan host disks")
return
}
initialize := make([]storage.CandidateDisk, 0, len(cands))
attach := make([]storage.CandidateDisk, 0)
for _, c := range cands {
initialize = append(initialize, c) // every unclaimed disk can be initialized (format → enroll)
if c.Mountable {
attach = append(attach, c) // already has a mountable FS → attach without formatting
}
}
writeOK(w, map[string]any{"vmid": vmid, "initialize": initialize, "attach": attach})
}
type assignRequest struct {
VMID int `json:"vmid"`
UUID string `json:"uuid"`
Where string `json:"where"`
FSType string `json:"fstype"`
Options string `json:"options"`
}
// handleDiskAssign attaches a drive as a host mount (benign, additive → EnsureMount). Self-serve.
func (s *Server) handleDiskAssign(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
var req assignRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
// EnsureMount validates uuid/where/fstype/options itself (storage/validate.go).
if err := s.disks.EnsureMount(r.Context(), storage.MountSpec{
Name: req.UUID, UUID: req.UUID, Where: req.Where, FSType: req.FSType, Options: req.Options,
}); err != nil {
s.logger.Error("local-api: disk assign", "vmid", vmid, "where", req.Where, "err", err)
writeErr(w, http.StatusBadRequest, "assign failed: "+err.Error())
return
}
s.logger.Info("local-api: disk assigned (host mount ensured)", "vmid", vmid, "where", req.Where)
writeOK(w, map[string]any{"vmid": vmid, "assigned": req.Where})
}
type ejectRequest struct {
VMID int `json:"vmid"`
Where string `json:"where"`
}
// handleDiskEject safe-unmounts a host mount (benign — data preserved, re-attachable) and returns
// the guests that depend on it so the controller can warn which apps lose that storage.
func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
var req ejectRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
if strings.TrimSpace(req.Where) == "" {
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
return
}
// ROLE GATE (defense in depth): eject is permitted ONLY for a user-data mount. The agent
// classifies the role of the storage at `where` from its OWN view — never the caller's claim —
// and refuses system/backup. The UI hiding the button is NOT the control: a direct API call (or a
// compromised controller) trying to unmount /var/lib/vz or the PBS mount is refused here. Fails
// SAFE: an unresolvable mount → protected → refused (most-protected-on-ambiguity, like the wipe).
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
s.logger.Warn("local-api: protected — eject refused by role",
"vmid", vmid, "where", req.Where, "role", role)
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — eject refused (role: "+string(role)+")")
return
}
// E-2c: the role gate above passes a drive that is BOTH user-data and the vzdump target (E-1 put
// the target on the enrolled drive's own mountpoint). Refuse specifically, naming the remedy.
if s.refuseIfBackupTarget(r.Context(), w, "eject", vmid, req.Where) {
return
}
dependents := s.dependentGuests(r.Context(), req.Where)
// Record the EJECT intent BEFORE unmounting (the target still resolves to its durable-id) so the
// self-heal watchdog leaves it alone — an OFFICIAL eject is the only thing that sets this (P3); an
// out-of-band unmount records nothing and is healed.
s.recordIntent(r.Context(), req.Where, "ejected")
// Intermediary model: eject = DETACH the felhom-data bind from under the shared parent (live, fail-
// closed in the guest). The RAW /mnt/<name> host mount is LEFT MOUNTED so a reconnect re-binds it
// cleanly (a non-removable drive isn't re-plugged); a raw unmount here would orphan it. Physical
// removal is the separate "remove from system" action. (Consistent with decommission.)
if s.guestAttach != nil {
if err := s.guestAttach.DetachDrive(r.Context(), req.Where); err != nil {
s.logger.Error("local-api: eject guest-detach (intermediary)", "vmid", vmid, "where", req.Where, "err", err)
writeErr(w, http.StatusBadRequest, "eject failed: "+err.Error())
return
}
}
s.logger.Info("local-api: drive ejected (bind detached, raw mount kept)",
"vmid", vmid, "where", req.Where, "dependent_guests", len(dependents))
writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
}
// handleDiskDecommission is the SELF-SERVE, NON-DESTRUCTIVE permanent removal of a user-data drive
// (B2). It mirrors handleDiskEject EXACTLY — withGuest self-scoping, scopedFromBody, and the same
// user-data ROLE GATE (a system/backup mount is refused 403; fail-safe-to-protected on ambiguity) so
// a compromised controller can't decommission protected storage. Unlike the operator-signed
// DecommissionExecutor it needs no signature: it is the customer's own drive. It records a PERMANENT
// decommission intent (the self-heal watchdog + the intent-aware re-assert never auto-mount/re-bind it
// again), prunes the guest-bind record (hygiene), and unmounts so the drive is physically removable.
// It NEVER calls any format/mkfs path — the data stays on the drive.
func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
var req ejectRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
if strings.TrimSpace(req.Where) == "" {
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
return
}
// ROLE GATE (same as eject): user-data only. The agent classifies from its OWN view, never the
// caller's claim; an unresolvable mount fails safe to protected → refused.
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
s.logger.Warn("local-api: protected — decommission refused by role",
"vmid", vmid, "where", req.Where, "role", role)
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — decommission refused (role: "+string(role)+")")
return
}
// E-2c: same narrow gate as eject — decommission migrates data off and retires the drive, which
// would strand the backup target just as thoroughly.
if s.refuseIfBackupTarget(r.Context(), w, "decommission", vmid, req.Where) {
return
}
dependents := s.dependentGuests(r.Context(), req.Where)
// Resolve the durable-id BEFORE unmounting (it still resolves while mounted) for the bind prune.
id := s.durableIDForMount(r.Context(), req.Where)
// Record the PERMANENT decommission intent first (self-heal never re-mounts it again).
s.recordIntent(r.Context(), req.Where, "decommissioned")
// Hygiene: drop the guest-bind record so the startup re-assert carries no stale id.
if id != "" && s.guestBinds != nil {
if err := s.guestBinds.Remove(vmid, id); err != nil {
s.logger.Warn("local-api: guest-bind remove failed", "vmid", vmid, "durable_id", id, "err", err)
}
}
// C1 FIX (B3 critical bug): delete the guest mountpoint bind for this drive so its now-missing
// source can't brick the guest on the NEXT reboot. The old decommission unmounted but left the
// `mpN` in the config → pre-start mount failure → all apps down. Runs on the running guest (config
// edit, no start lock → no deadlock). Best-effort: a missing slot is already clean.
if s.guestAttach != nil {
// Intermediary model: unmount the felhom-data bind from under the shared parent (live, no reboot).
if err := s.guestAttach.DetachDrive(r.Context(), req.Where); err != nil {
s.logger.Warn("local-api: guest-detach (intermediary) failed", "vmid", vmid, "where", req.Where, "err", err)
}
// Legacy per-drive model: delete any lingering `mpN` slot (no-op post-migration; C1 fix otherwise).
if slot := s.guestSlotForPath(r.Context(), vmid, req.Where); slot != "" {
if err := s.guestAttach.DetachBind(r.Context(), vmid, slot); err != nil {
s.logger.Warn("local-api: guest-detach (legacy mp) failed — mp left in config",
"vmid", vmid, "slot", slot, "where", req.Where, "err", err)
}
}
}
// Intermediary model: decommission is a LOGICAL retire (data stays, re-enrollable). It DetachDrive'd
// the bind under the parent above (the drive is no longer visible to the guest); it does NOT unmount
// the RAW /mnt/<name> host mount — that would orphan the drive (a non-removable SATA drive doesn't get
// re-plugged), so a one-click re-enroll (H3) could not re-bind it. The soft decommission marker blocks
// scheduling; physical removal is the separate "remove from system" action. NEVER format/mkfs here.
s.logger.Info("local-api: drive decommissioned (logical retire, data untouched)",
"vmid", vmid, "where", req.Where, "durable_id", id, "dependent_guests", len(dependents))
writeOK(w, map[string]any{"vmid": vmid, "decommissioned": req.Where, "dependent_guests": dependents})
}
type guestAttachRequest struct {
VMID int `json:"vmid"`
Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
}
// handleDiskGuestAttach binds an enrolled user-data drive's felhom-data namespace into THIS guest as
// an RW bind mount (slice 10 P2, Model A). Self-scoped (the vmid is the token's). Idempotent: if a
// mountpoint already binds `where`, it returns the existing slot without re-attaching. The drive must
// already be mounted on the host at `where` (the enroll flow's assign did that) — this only adds the
// guest passthrough. The customer's non-felhom data on the drive is NOT exposed (only felhom-data).
func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, vmid int) {
if s.guestAttach == nil {
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
return
}
var req guestAttachRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
where := strings.TrimSpace(req.Where)
if !validGuestMountPath(where) {
writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
return
}
// Intermediary model: ensure the host shared parent, then bind the drive's felhom-data UNDER it. It
// propagates into the running guest LIVE — no pct, no slot, no reboot. AttachDrive is idempotent (a
// no-op when the stable path is already a mountpoint), so this is the re-enroll path too.
if err := s.guestAttach.EnsureSharedParent(r.Context()); err != nil {
s.logger.Warn("local-api: guest-attach — shared parent ensure failed (continuing)", "vmid", vmid, "err", err)
}
stable, err := s.guestAttach.AttachDrive(r.Context(), vmid, where)
if err != nil {
s.logger.Error("local-api: guest-attach (intermediary)", "vmid", vmid, "where", where, "err", err)
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
return
}
// Record the drive as ENROLLED so the self-heal reconcile keeps it bound (P3), and persist the
// per-guest bind so the startup reconcile restores it after a re-provision (F9 — now host-side only).
s.recordIntent(r.Context(), where, "enrolled")
s.recordGuestBind(r.Context(), vmid, where)
s.logger.Info("local-api: guest-attach bound under shared parent", "vmid", vmid, "where", where, "guest_path", stable)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "guest_path": stable})
}
type guestRebootRequest struct {
VMID int `json:"vmid"`
}
// handleGuestReboot reboots THIS guest (self-scoped) to activate persisted-but-inactive mountpoint
// binds (slice 10 P2 activation). It runs the reboot DETACHED and returns 202 immediately, so the
// calling controller gets a clean response before the reboot takes it (and the agent — host-side —
// survives the guest reboot). User-triggered ("Újraindítás most"); the controller batches all pending
// drives into one restart.
func (s *Server) handleGuestReboot(w http.ResponseWriter, r *http.Request, vmid int) {
if s.guestAttach == nil {
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
return
}
if r.ContentLength != 0 {
var req guestRebootRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
}
base := s.baseCtx
if base == nil {
base = context.Background()
}
go func() {
// Detached: pct reboot blocks ~30s until the guest is back; don't tie it to the request ctx.
rebootCtx, cancel := context.WithTimeout(base, 5*time.Minute)
defer cancel()
if err := s.guestAttach.RebootGuest(rebootCtx, vmid); err != nil {
s.logger.Error("local-api: guest-reboot failed", "vmid", vmid, "err", err)
}
}()
s.logger.Warn("local-api: guest reboot requested (activating pending drive binds)", "vmid", vmid)
writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "rebooting": true}, "")
}
// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention
// root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
func validGuestMountPath(p string) bool {
if !strings.HasPrefix(p, "/mnt/") || strings.Contains(p, "..") {
return false
}
rest := strings.TrimPrefix(p, "/mnt/")
if rest == "" || strings.ContainsAny(rest, "/ \t") {
return false // exactly one path component under /mnt
}
for _, c := range rest {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
continue
}
return false
}
return true
}
// freeMountSlot returns the lowest mpN (0..255) not present in the guest's current mountpoints. The
// bootstrap mount (mp9) and any existing data mounts are already in `mounts`, so they're skipped.
func freeMountSlot(mounts map[string]string) (string, bool) {
for i := 0; i <= 255; i++ {
key := "mp" + strconv.Itoa(i)
if _, used := mounts[key]; !used {
return key, true
}
}
return "", false
}
type formatRequest struct {
VMID int `json:"vmid"`
Device string `json:"device"`
FSType string `json:"fstype"`
// Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed-
// confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id
// and matches it against DurableID — a confirmation for one disk can't wipe another. Both are
// INERT for system/backup devices (those stay operator-signature only — the role is the agent's,
// never the caller's, so confirmed:true on a system device is refused).
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
// NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent
// inspects the device itself (8C invariant).
}
// FormatResponse is POST /disks/format. On a data-bearing refusal (slice 10B) it SURFACES the
// bound op the operator must sign: the op class + the DURABLE device id (not the mutable path) +
// the fstype — so the operator can `felhom-opsign -op storage_wipe -durable-id <…>` offline, the
// hub queues it, and the agent's signed-jobs runner verifies + executes the wipe (re-resolving the
// durable id). This is the "records/reports the bound op intent so the operator sees what to sign".
type FormatResponse struct {
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
// Role is the agent's authoritative tier for the device (system | backup | user-data).
Role string `json:"role,omitempty"`
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with
// confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator
// signature — the customer authorizes the wipe of their own data drive.
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
DurableID string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data)
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign.
PendingOp *PendingOp `json:"pending_op,omitempty"`
}
// PendingOp is the bound destructive intent the operator must sign offline (slice 10B). Params bind
// to the DURABLE device id so the signed authorization can't be retargeted to another disk.
type PendingOp struct {
Op string `json:"op"` // e.g. "storage_wipe"
HostScope string `json:"host_scope"` // the agent's host id (anti-retarget target)
DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity
FSType string `json:"fstype"` // the filesystem to mkfs after the wipe
}
// errFormatClientGone signals the request context was cancelled (client/controller deadline) while the
// detached mkfs keeps running — the handler returns without writing; the job record records the outcome.
var errFormatClientGone = fmt.Errorf("format client gone (mkfs continues detached)")
// awaitFormat waits for the detached mkfs result, OR returns errFormatClientGone if the request context
// is cancelled first. Crucially the mkfs itself runs off s.baseCtx, so a cancelled request never kills it
// (F20-BUG3) — abandoning the wait here only abandons the HTTP response, not the format.
func (s *Server) awaitFormat(reqCtx context.Context, done <-chan error, vmid int, device string) error {
select {
case err := <-done:
return err
case <-reqCtx.Done():
s.logger.Warn("local-api: format client disconnected — mkfs continues detached (poll GET /disks/format/status)",
"vmid", vmid, "device", device)
return errFormatClientGone
}
}
// handleDiskFormatStatus reports the most-recent/in-flight format job (F20-BUG3), so a controller whose
// request timed out (or that reconnects after an agent restart) can learn the real outcome instead of
// assuming failure. Self-scoped (benign read).
func (s *Server) handleDiskFormatStatus(w http.ResponseWriter, r *http.Request, vmid int) {
if s.formatJobs == nil {
writeOK(w, map[string]any{"vmid": vmid, "phase": "idle"})
return
}
job := s.formatJobs.get()
if job == nil {
writeOK(w, map[string]any{"vmid": vmid, "phase": "idle"})
return
}
writeOK(w, map[string]any{
"vmid": vmid, "phase": job.Phase, "device": job.Device, "fstype": job.FSType,
"durable_id": job.DurableID, "error": job.Error, "started_at": job.StartedAt, "updated_at": job.UpdatedAt,
"job_id": job.JobID,
})
}
// handleDiskFormat is the security centerpiece. The agent INSPECTS the device; if it is
// data-bearing it is classified destructive and the gate refuses it `pending_signature` — the
// caller's claim is never trusted. Only a device the agent itself reads as blank is formatted.
func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid int) {
if s.disks == nil || s.diskGate == nil {
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
return
}
var req formatRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
if err := storage.ValidateBlockDevice(req.Device); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := storage.ValidateFSType(req.FSType); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// AGENT-INTERNAL device inspection — NEVER the caller's claim.
probe, err := s.disks.InspectDevice(r.Context(), req.Device)
if err != nil {
s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err)
// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
}
if !probe.DataBearing() {
// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy). F20-BUG3: run
// it DETACHED off s.baseCtx so a request/client deadline can't SIGKILL mkfs mid-write; we still
// wait here to return the synchronous result (backward-compatible with the controller's client).
//
// [audit D3, AGENT-001's benign-branch twin] anti-retarget: bind the format to the device's
// durable id and re-resolve it to the CURRENT device (re-derive + exact match + re-inspect
// STILL blank) — then format THAT device, never the mutable req.Device path. A /dev
// re-enumeration in the window could otherwise mkfs a data-bearing disk that inherited the
// node, with neither the DataBearing customer-confirm nor any durable-id binding. A device with
// no durable id cannot be bound → refused (a path-only format is what the guard prevents).
blankDurable, derr := s.deviceDurableID(req.Device)
if derr != nil || blankDurable == "" {
s.logger.Warn("local-api: blank format REFUSED — device has no durable id to bind (anti-retarget)",
"vmid", vmid, "device", req.Device, "err", derr)
writeStatus(w, http.StatusConflict, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: false},
"format refused: device has no durable id to bind the format to (path-only formats are not permitted)")
return
}
device, rerr := s.reresolveBlank(r.Context(), blankDurable)
if rerr != nil {
s.logger.Warn("local-api: blank format REFUSED at anti-retarget re-resolve",
"vmid", vmid, "req_device", req.Device, "durable_id", blankDurable, "err", rerr)
writeStatus(w, http.StatusConflict, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: false, DurableID: blankDurable},
"format refused (device may have changed since inspection): "+rerr.Error())
return
}
done := s.startFormatDetached(device, blankDurable, req.FSType, true)
if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
if err == errFormatClientGone {
return // client gone; mkfs continues detached + the job record records the outcome
}
s.logger.Error("local-api: format", "vmid", vmid, "device", device, "err", err)
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
return
}
writeOK(w, FormatResponse{VMID: vmid, Device: device, Formatted: true, DataBearing: false, DurableID: blankDurable, Reason: "blank device formatted " + req.FSType})
return
}
// Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
// confirmation must bind to it.
role := s.deviceRole(r.Context(), req.Device)
deviceDurable, derr := s.deviceDurableID(req.Device)
if derr != nil {
deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
}
dec := s.diskGate.AuthorizeWipe(WipeRequest{
Role: string(role), DeviceDurableID: deviceDurable,
Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID,
})
if dec.Allowed {
// USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it.
// [AGENT-001] anti-retarget: re-resolve the confirmed durable id to the CURRENT
// device, require the re-derived id to match, and confirm it is still
// data-bearing — then format THAT device, never the mutable req.Device path.
// This closes the classify→mkfs TOCTOU (a /dev reassignment in the window could
// otherwise wipe a different physical disk). Mirrors signedjobs.WipeExecutor.
device, rerr := s.reresolveWipe(r.Context(), deviceDurable)
if rerr != nil {
s.logger.Warn("local-api: customer-confirmed wipe REFUSED at anti-retarget re-resolve",
"vmid", vmid, "req_device", req.Device, "durable_id", deviceDurable, "err", rerr)
writeStatus(w, http.StatusConflict, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true,
Role: string(role), DurableID: deviceDurable, Reason: probe.Reason()},
"wipe refused (device may have changed since confirmation): "+rerr.Error())
return
}
// F20-BUG3: run the destructive mkfs DETACHED off s.baseCtx (bound durable id recorded for
// restart-recovery), so a request/client deadline can never SIGKILL it mid-write and corrupt the
// disk. We still wait to return the synchronous result (backward-compatible with the controller).
done := s.startFormatDetached(device, deviceDurable, req.FSType, false)
if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
if err == errFormatClientGone {
return // client gone; the wipe continues detached + survives a restart via the job record
}
s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", device, "err", err)
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
return
}
s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)",
"vmid", vmid, "device", device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason())
writeOK(w, FormatResponse{VMID: vmid, Device: device, Formatted: true, DataBearing: true,
Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"})
return
}
if dec.Tier == "customer_confirmable" {
// USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't
// bind to THIS device. Surface the durable id to confirm against — NOT an operator signature.
msg := "device is data-bearing — customer confirmation required"
if !dec.NeedsConfirmation {
msg = "confirmation does not match this device — refused (" + dec.Reason + ")"
}
s.logger.Warn("local-api: user-data data-bearing format refused",
"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason())
writeStatus(w, http.StatusForbidden, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role),
NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg)
return
}
// SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable
// id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role.
var pending *PendingOp
if deviceDurable != "" {
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType}
s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE",
"vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType,
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable)
} else {
s.logger.Warn("local-api: protected data-bearing format refused (no durable id)",
"vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason())
}
writeStatus(w, http.StatusForbidden, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()},
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}
// boundUnderParent reports whether a drive's felhom-data is bound at its stable guest path AND visible
// inside the guest — a guest reboot leaves the host bind in place but invisible to the guest until
// re-propagated. Injectable via s.boundCheck for tests; defaults to the guest-namespace mount check.
//
// This is VISIBILITY, not liveness (R-117). It compares only the mount point, so it stays true over a
// bind whose device has gone; do not read it as "usable in the guest" — that is the whole three-term
// conjunction at the two /disks construction sites, whose third term is bindUsable.
func (s *Server) boundUnderParent(ctx context.Context, vmid int, stablePath string) bool {
if s.boundCheck != nil {
return s.boundCheck(stablePath)
}
if s.guestAttach == nil {
return isHostMountpoint(stablePath)
}
return s.guestAttach.GuestSeesMount(ctx, vmid, stablePath)
}
// devicePresent reports whether the drive's BACKING DEVICE is still there, by asking whether its RAW
// host mount is still a mountpoint (R-113).
//
// WHY THE RAW MOUNT AND NOT THE BIND. The raw mount at /mnt/<name> is a systemd mount unit bound to
// its device: when the device goes, the unit stops and the mountpoint disappears. The agent's own bind
// of <raw>/felhom-data under the shared parent is an ordinary bind — nothing ties it to the device, so
// its mountinfo entry OUTLIVES the device as a stale shell. Measured live in E-2d with the device
// pulled: `/mnt/mentes2` NOT mounted while `/mnt/felhom-drives/mentes2` still read
// `/dev/sdb[/felhom-data]` (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.2). Keying presence on the
// survivor is exactly why the controller's drive-absent gate could never fire.
//
// An empty raw path means we have nothing to ask about — return TRUE (unknown), never false. Absent
// stops a customer's apps, so "cannot tell" must never be reported as "gone".
func (s *Server) devicePresent(rawMountPath string) bool {
if rawMountPath == "" {
return true // cannot tell → never claim absent
}
if s.deviceCheck != nil {
return s.deviceCheck(rawMountPath)
}
return isHostMountpoint(rawMountPath)
}
// bindUsable is the THIRD term of the BoundUnderParent conjunction (R-117): the bind must not only exist
// and be guest-visible, it must actually WORK. The first two terms are path-presence tests and are both
// satisfied by a bind that names the drive that went away — measured live, with EIO on every read and
// write while the payload read healthy and the gate restarted the customer's apps onto it.
//
// UNKNOWN counts as usable, via BindLiveness.Usable — the same "cannot tell → never absent" rule
// devicePresent applies above, and for the same reason: a false absent stops a working customer's apps.
// Injectable via s.livenessCheck; the default reads /proc only and issues NO block I/O (CLAUDE.md).
func (s *Server) bindUsable(stable, rawMountPath string) bool {
if s.livenessCheck != nil {
return s.livenessCheck(stable, rawMountPath).Usable()
}
return bindLiveness(stable, rawMountPath).Usable()
}
// guestBoundPaths returns the set of guest mountpoint paths (the `mp=` of each entry in the guest's
// config) — i.e. the host drives actually BOUND into the guest. F9: this is the guest-attached signal
// (`GuestAttached`) that distinguishes a guest-usable drive from one merely present on the host. A bind
// created by AttachBind has `mp=<where>` where `where` is the drive's host mount path, so a storage
// target is guest-attached iff its MountPath is in this set. Best-effort: a config-read error yields an
// empty set (reported as not-attached — the safe direction).
func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool {
out := map[string]bool{}
if s.guests == nil {
return out
}
cfg, err := s.guests.GuestConfig(ctx, vmid)
if err != nil {
s.logger.Warn("local-api: guest-attached check — could not read guest config", "vmid", vmid, "err", err)
return out
}
for _, spec := range cfg.MountPoints() {
if _, mp, _ := parseMount(spec); mp != "" {
out[mp] = true
}
}
return out
}
// guestSlotForPath returns the mountpoint slot (mpN) whose bind targets the guest path `where`, or ""
// if none. Used by decommission/eject to find the slot to `--delete` (the C1 fix). Best-effort: a
// config-read error yields "" (nothing to detach — the safe direction).
func (s *Server) guestSlotForPath(ctx context.Context, vmid int, where string) string {
if s.guests == nil {
return ""
}
cfg, err := s.guests.GuestConfig(ctx, vmid)
if err != nil {
s.logger.Warn("local-api: slot-for-path — could not read guest config", "vmid", vmid, "where", where, "err", err)
return ""
}
for slot, spec := range cfg.MountPoints() {
if _, mp, _ := parseMount(spec); mp == where {
return slot
}
}
return ""
}
// recordGuestBind persists that the drive at `where` (by its durable-id) is enrolled into `vmid`, so the
// startup re-assert (ReassertGuestBinds) can restore the bind after a re-provision (F9). Best-effort.
func (s *Server) recordGuestBind(ctx context.Context, vmid int, where string) {
if s.guestBinds == nil {
return
}
id := s.durableIDForMount(ctx, where)
if id == "" {
s.logger.Warn("local-api: guest-bind not recorded — durable-id unresolved", "vmid", vmid, "where", where)
return
}
if err := s.guestBinds.Record(vmid, id); err != nil {
s.logger.Warn("local-api: guest-bind record failed", "vmid", vmid, "where", where, "durable_id", id, "err", err)
return
}
s.logger.Info("local-api: guest-bind recorded for startup re-assert", "vmid", vmid, "where", where, "durable_id", id)
}
// ReassertGuestBinds is the intermediary-model startup reconcile: for every ENROLLED + PRESENT enrolled
// drive, it ensures the drive's felhom-data is bound under the shared parent (host-side, via AttachDrive)
// so it's live in the guest. This is PURE host-side mount work — NO pct, NO guest-config read, NO reboot
// — and it fixes F9 (a re-provision drops nothing host-side) AND drive-returned reconnection for free.
// It first ensures the host shared parent exists (so a fresh boot has it before any bind). "On durable-id
// match" + intent-gated: a swapped, ejected, or decommissioned drive is never auto-bound. AttachDrive is
// idempotent (a no-op when already bound), so this is safe to call repeatedly. Runs at agent startup and
// on a drive-returned event.
func (s *Server) ReassertGuestBinds(ctx context.Context) {
if s.guestBinds == nil || s.guestAttach == nil {
return
}
// Make sure the host stable parent is shared + boot-persistent before we bind anything under it.
if err := s.guestAttach.EnsureSharedParent(ctx); err != nil {
s.logger.Warn("reconcile: shared parent ensure failed — binds may not propagate live", "err", err)
}
// durable-id -> current host mount path (present drives only), from the agent's own storage view.
mountByDurable := map[string]string{}
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.DurableID != "" && t.MountPath != "" {
mountByDurable[t.DurableID] = t.MountPath
}
}
} else {
// Observe failing is not fatal — a RAW enrolled drive wouldn't be in it anyway; fall through to
// the mount-table scan below so raw drives still get re-asserted.
s.logger.Warn("reconcile: storage view unavailable — using mount-table only", "err", err)
}
// Impl-2b: a RAW enrolled drive is NOT a PVE storage, so Observe misses it and its guest-bind would
// never be re-asserted (post-reboot/reconnect). Augment the map from the mount table: each raw
// /mnt/<name> mount → its device fs-UUID (uuid:<…>). Skip the /mnt/felhom-drives bind (AttachDrive
// wants the RAW mount path). Observe entries win (already set).
if s.host != nil {
if mounts, err := s.host.Mounts(); err == nil {
for _, m := range mounts {
if strings.HasPrefix(m.MountPoint, "/mnt/felhom-drives") {
continue // the bind, not the raw mount
}
if uuid, ok := s.host.ResolveUUID(m.Device); ok && uuid != "" {
if _, exists := mountByDurable["uuid:"+uuid]; !exists {
mountByDurable["uuid:"+uuid] = m.MountPoint
}
}
}
}
}
guests := s.guestBinds.Guests()
s.logger.Debug("reconcile: guest-bind re-assert pass",
"guests", len(guests), "resolved_mounts", len(mountByDurable))
for vmid, ids := range guests {
for _, id := range ids {
// Intent-aware (B2, load-bearing): NEVER bind a drive that is not currently `enrolled` — an
// ejected or decommissioned drive must not auto-rebind, even if still host-mounted. A nil
// intent store falls back to bind-all (ungated), matching the watchdog's nil-intent rule.
if s.intent != nil && s.intent.Get(id) != storage.IntentEnrolled {
s.logger.Warn("reconcile: skipping non-enrolled drive (intent-gated)", "vmid", vmid, "durable_id", id, "intent", string(s.intent.Get(id)))
continue
}
where, present := mountByDurable[id]
if !present {
s.logger.Warn("reconcile: enrolled drive not present (durable-id absent) — skipping", "vmid", vmid, "durable_id", id)
continue
}
stable, err := s.guestAttach.AttachDrive(ctx, vmid, where)
if err != nil {
s.logger.Error("reconcile: AttachDrive failed", "vmid", vmid, "where", where, "err", err)
continue
}
s.logger.Info("reconcile: enrolled drive bound under shared parent (live, no reboot)",
"vmid", vmid, "where", where, "guest_path", stable, "durable_id", id)
}
}
}
// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
func (s *Server) durableIDForMount(ctx context.Context, where string) string {
// Preferred: the storage view (PVE storages) already carries the derived durable-id.
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.MountPath == where {
return t.DurableID
}
}
}
// Fallback (Impl-2b): a RAW enrolled drive is NOT a PVE storage, so it never appears in Observe —
// which left its enroll/eject intent + guest-bind unrecorded ("durable-id unresolved"). Resolve the
// fs-UUID directly from the mount table: the device mounted at `where` → its by-uuid identity. This
// is the SAME scheme Observe derives ("uuid:<fs-uuid>"), so intent keys stay consistent.
if s.host != nil {
if mounts, err := s.host.Mounts(); err == nil {
for _, m := range mounts {
if m.MountPoint == where {
if uuid, ok := s.host.ResolveUUID(m.Device); ok && uuid != "" {
return "uuid:" + uuid
}
break
}
}
}
}
return ""
}
// recordIntent records enroll/eject intent for the drive at `where`, best-effort (a nil store, an
// unresolved durable-id, or a write error is logged, never fatal — intent is a self-heal aid, not a
// gate on the user's action). `action` is "enrolled" or "ejected".
func (s *Server) recordIntent(ctx context.Context, where, action string) {
if s.intent == nil {
return
}
id := s.durableIDForMount(ctx, where)
if id == "" {
s.logger.Warn("local-api: intent not recorded — durable-id unresolved", "where", where, "action", action)
return
}
var err error
switch action {
case "enrolled":
err = s.intent.SetEnrolled(id)
case "ejected":
err = s.intent.SetEjected(id)
case "decommissioned":
err = s.intent.SetDecommissioned(id)
}
if err != nil {
s.logger.Warn("local-api: intent record failed", "where", where, "action", action, "durable_id", id, "err", err)
return
}
s.logger.Info("local-api: drive intent recorded", "where", where, "action", action, "durable_id", id)
}
// hostReader returns the injected root-free host topology reader, or the production default. The seam
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
func (s *Server) hostReader() storage.HostReader {
if s.host != nil {
return s.host
}
return storage.NewProcHostReader()
}
// backupTargetAt reports the configured backup TIER whose storage is mounted at `where`, or "" when
// none is. E-2c.
//
// WHY THIS IS NOT A ROLE RECLASSIFICATION. The obvious fix is to make RoleForStorage return
// RoleBackup for the target's storage, and it is wrong here: on both demo boxes the drive that now
// holds the whole-guest archives is ALSO the enrolled user-data drive (E-1 put the vzdump target on
// the drive's own mountpoint, beside felhom-data). Reclassifying it would refuse every legitimate
// eject/decommission of the customer's own data drive — an over-correction that trades one silent
// failure for a permanent obstruction. So this is a SEPARATE, narrower gate that names exactly what
// it protects and leaves the role vocabulary alone.
//
// It resolves through the agent's OWN storage view (never the caller's claim) and fails OPEN — an
// unreadable view returns "" so this gate cannot block on a transient error. That is safe because it
// sits BEHIND the role gate, which already fails SAFE on the same error: an unresolvable mount is
// refused there before it ever reaches this check.
func (s *Server) backupTargetAt(ctx context.Context, where string) string {
if where == "" || s.storage == nil {
return ""
}
targets, err := s.storage.Observe(ctx)
if err != nil {
return "" // fail OPEN — the role gate already fails SAFE on this same error
}
for _, t := range s.tiers {
if t.TargetID == "" {
continue
}
for _, tgt := range targets {
if tgt.Name == t.TargetID && tgt.MountPath == where {
return t.TargetID
}
}
}
return ""
}
// refuseIfBackupTarget refuses a destructive drive op when `where` backs a configured backup tier,
// and reports whether it did. The message names the storage AND the remedy: the operation is not
// forbidden forever, it is ordered — reassign the backup target first, then the drive is free.
//
// Ejecting the drive that holds the only local whole-guest backup is exactly the silent-degradation
// class this arc has been closing: it succeeds, nothing alarms, and the box quietly loses its
// drive-loss protection while still reporting a configured tier.
func (s *Server) refuseIfBackupTarget(ctx context.Context, w http.ResponseWriter, op string, vmid int, where string) bool {
target := s.backupTargetAt(ctx, where)
if target == "" {
return false
}
s.logger.Warn("local-api: protected — "+op+" refused: the mount backs a configured backup tier",
"vmid", vmid, "where", where, "target", target)
writeErr(w, http.StatusConflict,
"this drive is the whole-guest backup target ("+target+") — "+op+" refused. "+
"Reassign the backup target to another drive first, or the box loses its local drive-loss protection.")
return true
}
// roleForMountPath resolves the AUTHORITATIVE protection role of the storage mounted at `where`, from
// the agent's OWN storage view + host topology (never the caller's claim). It mirrors deviceRole but
// keys on the mount path (the eject input). It FAILS SAFE to system (most-protected) on any
// ambiguity — a view error, an unrecognizable mount source, or `where` absent from both the storage
// view and the mount table — so an unresolvable eject is refused rather than silently unmounted.
func (s *Server) roleForMountPath(ctx context.Context, where string) storage.DeviceRole {
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
targets, err := s.storage.Observe(ctx)
if err != nil {
// Can't read the view → protected. The mount-table fallback below must NOT run here: with
// the PVE view down its containment pass is blind, and falling through to raw classification
// could label a backup-backing device user-data — a PERMISSIVE regression.
return storage.RoleSystem
}
for _, t := range targets {
if t.MountPath == where {
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
}
}
// Fallback (campaign F2, 2026-07-06 — mirrors durableIDForMount's Impl-2b): a RAW enrolled
// user-data drive is NOT a PVE storage, so it never appears in Observe — which made this
// function fail-safe every such mount to `system` and the eject/decommission gates 403 EVERY
// user-data drive in the standard topology (journal: where=/mnt/teszt_enroll role=system).
// Resolve the mount's backing device from the host mount table, then classify device-keyed:
// - non-/dev source (NAS "server:/export", tmpfs, …) → system (never drive-ejectable);
// - device on the same whole disk as a KNOWN storage target → THAT target's role (containment:
// felhom-usb/felhom-flash are dir storages with backup content — their disk must never
// become ejectable through this path);
// - otherwise RoleForRawDevice (system-disk membership; fail-safe to system).
mounts, merr := s.hostReader().Mounts()
if merr != nil {
return storage.RoleSystem // can't read the mount table → protected
}
for _, m := range mounts {
if m.MountPoint != where {
continue
}
if !strings.HasPrefix(m.Device, "/dev/") {
return storage.RoleSystem // network/virtual source — has its own lifecycle, protected here
}
// Containment pass over the ALREADY-FETCHED targets (never re-Observe: a racing error there
// would degrade to the permissive raw path). Compare at whole-disk granularity: targets carry
// the PARTITION as BackingDevice (a dir storage on /dev/sdb1) while a raw enrolled drive mounts
// the WHOLE disk (/dev/sdb) — SameWholeDisk normalizes both so a backup disk stays protected.
for _, t := range targets {
if t.BackingDevice != "" && storage.SameWholeDisk(t.BackingDevice, m.Device) {
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
}
}
// No known target claims this device → classify the raw device by system-disk membership.
return storage.RoleForRawDevice(m.Device, sysDisks, sysKnown)
}
return storage.RoleSystem // no storage target and no mount-table entry → fail safe to protected
}
// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.BackingDevice != "" && t.BackingDevice == device {
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
}
}
}
return storage.RoleForRawDevice(device, sysDisks, sysKnown)
}
// dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected
// mount path — best-effort (a scan failure yields an empty list; the eject still proceeds).
func (s *Server) dependentGuests(ctx context.Context, where string) []int {
if s.guestList == nil {
return nil
}
guests, err := s.guestList.ListLXC(ctx)
if err != nil {
s.logger.Warn("local-api: eject dependent-scan: list guests", "err", err)
return nil
}
// Map each storage id whose mount path == `where` (from the storage view) → dependents.
storeForPath := map[string]bool{}
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.MountPath == where {
storeForPath[t.Name] = true
}
}
}
var out []int
for _, g := range guests {
cfg, err := s.guests.GuestConfig(ctx, g.VMID)
if err != nil {
continue
}
for _, mp := range cfg.MountPoints() {
store, mpPath, _ := parseMount(mp)
if storeForPath[store] || mpPath == where {
out = append(out, g.VMID)
break
}
}
}
return out
}