package localapi import ( "context" "net/http" "strings" "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) } // ---- 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"` } // 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(storage.NewProcHostReader()) 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, } // 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" } } out = append(out, di) } writeOK(w, map[string]any{"vmid": vmid, "disks": out}) } 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 } dependents := s.dependentGuests(r.Context(), req.Where) if err := s.disks.Unmount(r.Context(), req.Where); err != nil { s.logger.Error("local-api: disk eject", "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}) } 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 } // 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). if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil { 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 := storage.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. Wipe. if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil { s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", req.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", req.Device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason()) writeOK(w, FormatResponse{VMID: vmid, Device: req.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+")") } // 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(storage.NewProcHostReader()) 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 }