package localapi import ( "context" "fmt" "net/http" "strconv" "strings" "time" "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) } // 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/. // 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-usable signal — distinct from the host having the bind). Backs BoundUnderParent. 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:" 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:/byuuid:). 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"` // GuestPath is the drive's STABLE in-guest path in the intermediary-mount model // (/mnt/felhom-drives/). This is what the controller repoints HDD_PATH to and registers as the // storage path. Set for /mnt/ 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's felhom-data is currently bound under the shared parent // at GuestPath (a host mount-table check) — i.e. live + usable in the guest in the intermediary model. // The controller's drive-absent gate + auto-restart key on this (and State). BoundUnderParent bool `json:"bound_under_parent"` } // 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) 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], } // Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent. // Only user-data /mnt/ 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 di.BoundUnderParent = s.boundUnderParent(r.Context(), vmid, gp) } } // 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 } } out = append(out, di) } // 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}) } 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 } 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 } 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/ 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 } } 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 } 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/ 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. 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/ 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/ 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). done := s.startFormatDetached(req.Device, "", req.FSType) if err := s.awaitFormat(r.Context(), done, vmid, req.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", req.Device, "err", err) writeErr(w, http.StatusBadGateway, "format failed: "+err.Error()) return } writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, 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) 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 (the usable-in-guest signal the controller's gate keys on — 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. 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) } // 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` 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 { s.logger.Warn("reconcile: storage view unavailable — skipping", "err", err) return } for vmid, ids := range s.guestBinds.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 { targets, err := s.storage.Observe(ctx) if err != nil { return "" } for _, t := range targets { if t.MountPath == where { return t.DurableID } } 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() } // 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, or no storage target found at `where` — 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 { return storage.RoleSystem // can't read the view → treat as protected } for _, t := range targets { if t.MountPath == where { return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown) } } return storage.RoleSystem // no storage target at this mount → 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 }