a2a76e7624
F20-BUG2: the /disks list only carried DurableID in the uuid: scheme (for /disks/assign), but the wipe gate resolves devices in the byid:/byuuid: scheme — so a customer confirming a wipe with the advertised id was refused (binding_mismatch). Added a shared s.deviceDurableID seam used by BOTH handleDisks (new DiskInfo.WipeDurableID) and the format gate, so the id the customer copies from the list is exactly the id the gate accepts. DurableID (uuid:) is unchanged (still feeds assign). F9 (reporting half): added DiskInfo.GuestAttached — whether the drive's namespace is actually bound into THIS guest's config (guestBoundPaths), distinct from mere host presence (State). This is the signal whose absence made the HDD look available when it wasn't attached, and resolves the F2 hdd_configured-vs-/disks disagreement. Tests: wipe_durable_id is the gate scheme + distinct from uuid:; the list's wipe id matches the gate's device-id binding (no mismatch); guest_attached true iff bound into the guest.
684 lines
31 KiB
Go
684 lines
31 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"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 {
|
|
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
|
|
RebootGuest(ctx context.Context, vmid int) error
|
|
}
|
|
|
|
// IntentRecorder persists drive enroll/eject INTENT (slice 10 P3 self-heal), keyed by durable-id, so
|
|
// the watchdog reconciles only enrolled drives and respects an official eject. Satisfied by
|
|
// *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal is ungated).
|
|
type IntentRecorder interface {
|
|
SetEnrolled(durableID string) error
|
|
SetEjected(durableID string) 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:<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.
|
|
GuestAttached bool `json:"guest_attached"`
|
|
}
|
|
|
|
// 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],
|
|
}
|
|
// 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)
|
|
}
|
|
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
|
|
}
|
|
// 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")
|
|
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 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
|
|
}
|
|
// Read the guest config for idempotency + free-slot selection.
|
|
cfg, err := s.guests.GuestConfig(r.Context(), vmid)
|
|
if err != nil {
|
|
s.logger.Error("local-api: guest-attach guest config", "vmid", vmid, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "could not read guest config")
|
|
return
|
|
}
|
|
mounts := cfg.MountPoints()
|
|
// Idempotency: already bound at `where`? (a bind's mp= equals the guest path). Still (re)record the
|
|
// ENROLLED intent — a drive bound at a prior boot must be known to self-heal even if never
|
|
// re-attached this process lifetime.
|
|
for key, spec := range mounts {
|
|
if _, mp, _ := parseMount(spec); mp == where {
|
|
s.recordIntent(r.Context(), where, "enrolled")
|
|
s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
|
|
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
|
|
return
|
|
}
|
|
}
|
|
slot, ok := freeMountSlot(mounts)
|
|
if !ok {
|
|
writeErr(w, http.StatusConflict, "no free mountpoint slot on the guest")
|
|
return
|
|
}
|
|
if err := s.guestAttach.AttachBind(r.Context(), vmid, slot, where); err != nil {
|
|
s.logger.Error("local-api: guest-attach", "vmid", vmid, "where", where, "slot", slot, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
|
|
return
|
|
}
|
|
// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3).
|
|
s.recordIntent(r.Context(), where, "enrolled")
|
|
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 := 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
|
|
}
|
|
if err := s.disks.Format(r.Context(), device, req.FSType); err != nil {
|
|
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+")")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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
|
|
}
|