Files
felhom-agent/internal/localapi/disks.go
T
admin c17cfde236 slice 8C Phase A: agent disk endpoints + data-bearing classifier gate + mkfs (v0.12.0)
internal/storage: mkfs executor (Format, device-pinned, narrow FELHOM_FORMAT
sudoers) + data-bearing device inspection (InspectDevice/DeviceProbe via
blkid+lsblk; conservative — ambiguous=data-bearing). internal/localapi: /disks
(+ data-bearing flag), /disks/assign (EnsureMount), /disks/eject (Unmount +
dependent guests), /disks/format. SECURITY CENTERPIECE: the agent inspects the
device itself; data-bearing format -> ClassStorageWipe gate -> pending_signature
refused; the caller's claim is never trusted. Additive (no controller change yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:52:22 +02:00

261 lines
9.8 KiB
Go

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
// slice-4 reversibility gate. Satisfied by an adapter over reconcile.Gate in main.go. In 8C an
// unsigned destructive op returns (false, "pending_signature"); the signed path is slice 10.
type StorageGate interface {
AuthorizeWipe(device string) (allowed bool, reason string)
}
// 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 | ""
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
DataReason string `json:"data_reason,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
}
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,
}
// 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"`
// NOTE: any caller-supplied "blank"/"force" claim is deliberately IGNORED — the agent
// inspects the device itself (8C invariant).
}
// FormatResponse is POST /disks/format.
type FormatResponse struct {
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
}
// 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() {
// Destructive: route through the gate. With no operator signature (8C) → pending_signature.
allowed, reason := s.diskGate.AuthorizeWipe(req.Device)
s.logger.Warn("local-api: refusing format of a data-bearing device",
"vmid", vmid, "device", req.Device, "why", probe.Reason(), "gate", reason)
if !allowed {
writeStatus(w, http.StatusForbidden, false,
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason()},
"device is data-bearing — format requires operator authorization ("+reason+")")
return
}
// A signed completion would land here in slice 10; 8C never reaches it (gate refuses unsigned).
writeErr(w, http.StatusForbidden, "data-bearing format is not supported in this slice")
return
}
// Blank device → benign → mkfs.
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})
}
// 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
}