Files
felhom-agent/internal/storage/role.go
T
admin 7545af8a2c fix(localapi): F2 mount-role fallback — enrolled user-data drives ejectable again (v0.73.0)
roleForMountPath resolved role only from the PVE storage view; a bind-mounted
RAW enrolled user-data drive is not a PVE storage, so it fail-safe'd to system
and the eject/decommission gates 403'd EVERY user-data drive in the standard
topology (campaign F2, where=/mnt/teszt_enroll role=system). Add a mount-table
fallback mirroring durableIDForMount Impl-2b: device-keyed classification with a
whole-disk containment pass (new storage.SameWholeDisk) and the Observe-error
early return kept BEFORE the fallback (else a blind view -> permissive). Only
roleForMountPath touched. Tests A1/B1/B2/C1-C3 + 3 red-proofs; existing RoleGated
tests green unmodified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 20:07:45 +02:00

150 lines
6.7 KiB
Go

package storage
import (
"path/filepath"
"regexp"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// Device ROLE classification — the protection tier the AGENT assigns a storage/device by its OWN
// inspection (the PVE storage view + the host mount/topology reads it already gathers), NEVER from
// the controller's or the hub's claim. This is the storage analog of classify.go's data-bearing
// verdict: "provenance is agent-internal, never populated from an external input, else a compromised
// hub could relabel a protected device to walk the gate."
//
// The tier decides who may authorize a destructive wipe of the device:
// - system : the appliance's OS/boot/EFI/guest-rootfs storage. Operator-signature ONLY.
// - backup : the backup safety-net (PBS). Operator-signature ONLY (wiping it destroys the net).
// - user-data : a customer external data drive — already within the controller's blast radius
// (it bind-mounts /mnt), so a customer informed-confirmation may authorize a wipe.
//
// On ANY ambiguity the agent defaults to the MOST-PROTECTED role (system) — consistent with the
// destructive-on-ambiguity invariant: an unrecognized device is treated as protected, never silently
// user-data.
type DeviceRole string
const (
RoleSystem DeviceRole = "system"
RoleBackup DeviceRole = "backup"
RoleUserData DeviceRole = "user-data"
)
// reWholeDisk matches a whole raw disk path (no partition suffix): /dev/sda, /dev/vdb, /dev/nvme0n1.
var reWholeDisk = regexp.MustCompile(`^/dev/(?:sd|hd|vd)[a-z]+$|^/dev/nvme[0-9]+n[0-9]+$`)
// systemMountPoints are the host mountpoints whose backing whole-disk is, by definition, the OS /
// system disk. /boot and /boot/efi are the load-bearing ones: on a typical Proxmox/Debian install
// the ESP is a raw partition directly on the OS disk, so it pins the OS whole-disk even when / is on
// LVM/device-mapper (which we cannot trace back to a raw disk without privileged LVM introspection).
var systemMountPoints = map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
// SystemDisks resolves the set of whole-disk device paths that host the OS (the disks backing /,
// /boot and /boot/efi). ok=false when NONE could be resolved (no system mountpoint mapped to a raw
// disk) — callers then treat every candidate as system (most protected). Root-free: it parses the
// mount table + world-readable /dev symlinks only (the root-CLI fence is untouched).
func SystemDisks(host HostReader) (set map[string]bool, ok bool) {
if host == nil {
return nil, false
}
mounts, err := host.Mounts()
if err != nil {
return nil, false
}
set = map[string]bool{}
for _, m := range mounts {
if !systemMountPoints[cleanMountPath(m.MountPoint)] {
continue
}
if wd, wok := wholeDiskOf(m.Device); wok {
set[wd] = true
}
}
return set, len(set) > 0
}
// wholeDiskOf maps a device path (a partition, a whole disk, or a /dev/disk/by-* symlink) to its
// whole-disk /dev path. ok=false when the result is not a recognizable raw disk (device-mapper / LVM
// / network) — the caller then treats the topology as undeterminable (→ most-protected).
func wholeDiskOf(device string) (string, bool) {
if device == "" {
return "", false
}
dev := device
if resolved, err := filepath.EvalSymlinks(device); err == nil {
dev = resolved // canonicalize /dev/disk/by-uuid/… → /dev/sdXN
}
if m := reNVMePart.FindStringSubmatch(dev); m != nil {
return m[1], true // /dev/nvme0n1p2 → /dev/nvme0n1
}
if m := reSDPart.FindStringSubmatch(dev); m != nil {
return m[1], true // /dev/sdb1 → /dev/sdb
}
if reWholeDisk.MatchString(dev) {
return dev, true // already a whole disk
}
return "", false // /dev/mapper/*, network, unrecognized → undeterminable
}
// isSystemBacked reports whether device's whole-disk is an OS/system disk. It FAILS SAFE: any
// ambiguity (system set unknown, or the device's whole-disk unrecognizable) returns true (system).
func isSystemBacked(device string, sysDisks map[string]bool, sysKnown bool) bool {
if !sysKnown {
return true // can't determine the system disks → treat as system (most protected)
}
wd, ok := wholeDiskOf(device)
if !ok {
return true // unrecognizable device topology → most protected
}
return sysDisks[wd]
}
// RoleForStorage classifies a storage TARGET (from the agent's storage view) into its protection
// tier. typ is the reported storage type; backingDevice is its resolved block device ("" for
// network/lvm/dir-on-root). sysDisks/sysKnown come from SystemDisks (resolved once per request).
func RoleForStorage(typ, backingDevice string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
switch typ {
case hub.StorageTypePBS:
return RoleBackup // the backup safety-net — protected
case hub.StorageTypeUSB, hub.StorageTypeLocalDir:
// A removable/extra dir storage is user-data ONLY when it has its OWN block device that is
// NOT part of the system disk. No device (a dir on the root fs) or a system-disk-backed dir
// → system. This is exactly "local-dir/usb on a non-root external device → user-data".
if backingDevice == "" {
return RoleSystem
}
if isSystemBacked(backingDevice, sysDisks, sysKnown) {
return RoleSystem
}
return RoleUserData
default:
// local (builtin, on the root fs), lvmthin (local-lvm), lvm (thick), nfs, cifs, and anything
// unrecognized → protected. Network shares are NOT customer-managed external drives in this
// model and are not within the controller's blast radius, so protecting them is safe.
return RoleSystem
}
}
// RoleForRawDevice classifies a RAW block device (the format endpoint's target, which may not yet be
// a registered PVE storage — e.g. a fresh external disk in the init flow). It distinguishes system
// from user-data by system-disk membership. A raw /dev path is never a PBS datastore (those are
// network/API), so the backup tier is not reachable here — the storage-list view (RoleForStorage)
// tiers PBS. Defaults to system on ambiguity.
func RoleForRawDevice(device string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
if isSystemBacked(device, sysDisks, sysKnown) {
return RoleSystem
}
return RoleUserData
}
// SameWholeDisk reports whether two device paths live on the same physical whole disk
// (e.g. /dev/sdb and /dev/sdb1). Storage targets carry BOTH granularities in practice (a dir
// storage's BackingDevice is the mounted PARTITION, a raw enrolled drive mounts the WHOLE disk),
// so containment checks must compare at whole-disk level — mirroring isSystemBacked. False when
// either side's whole-disk is unrecognizable (device-mapper/network) — callers fail safe.
func SameWholeDisk(a, b string) bool {
wa, oka := wholeDiskOf(a)
wb, okb := wholeDiskOf(b)
return oka && okb && wa == wb
}