v0.23.0: device-ROLE classification + tiered storage-wipe gate (user-data customer-confirmable; system/backup operator-only)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// Role classification is AGENT-AUTHORITATIVE (the agent's own storage view + host topology). These
|
||||
// assert the demo storages map to the right protection tier, and that ambiguity fails safe to the
|
||||
// MOST-PROTECTED role (system) — never silently user-data.
|
||||
|
||||
// demoHost models the demo N100: the OS disk /dev/sda (ESP at /dev/sda1, root at /dev/sda2) plus an
|
||||
// external data disk /dev/sdb (felhom-usb at /dev/sdb1).
|
||||
func demoHost() *fakeHostReader {
|
||||
return &fakeHostReader{
|
||||
mounts: []Mount{
|
||||
{Device: "/dev/sda2", MountPoint: "/", FSType: "ext4"},
|
||||
{Device: "/dev/sda1", MountPoint: "/boot/efi", FSType: "vfat"},
|
||||
{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemDisks_FromBootMounts(t *testing.T) {
|
||||
sys, ok := SystemDisks(demoHost())
|
||||
if !ok {
|
||||
t.Fatal("SystemDisks should resolve the OS disk from / and /boot/efi")
|
||||
}
|
||||
if !sys["/dev/sda"] {
|
||||
t.Fatalf("OS whole-disk /dev/sda not in system set: %v", sys)
|
||||
}
|
||||
if sys["/dev/sdb"] {
|
||||
t.Fatalf("external data disk /dev/sdb wrongly classified as system: %v", sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleForStorage_DemoMapping(t *testing.T) {
|
||||
sys, ok := SystemDisks(demoHost())
|
||||
cases := []struct {
|
||||
name string
|
||||
typ string
|
||||
device string
|
||||
want DeviceRole
|
||||
}{
|
||||
{"builtin local (root fs)", hub.StorageTypeLocal, "", RoleSystem},
|
||||
{"local-lvm (lvmthin)", hub.StorageTypeLVMThin, "", RoleSystem},
|
||||
{"felhom-pbs (backup net)", hub.StorageTypePBS, "", RoleBackup},
|
||||
{"nfs share", hub.StorageTypeNFS, "", RoleSystem},
|
||||
{"felhom-usb on external /dev/sdb1", hub.StorageTypeUSB, "/dev/sdb1", RoleUserData},
|
||||
{"local-dir on external /dev/sdb1", hub.StorageTypeLocalDir, "/dev/sdb1", RoleUserData},
|
||||
{"usb-typed but ON the system disk", hub.StorageTypeUSB, "/dev/sda2", RoleSystem},
|
||||
{"local-dir with no device (on root)", hub.StorageTypeLocalDir, "", RoleSystem},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := RoleForStorage(c.typ, c.device, sys, ok); got != c.want {
|
||||
t.Errorf("%s: got role %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleForRawDevice_SystemVsUserData(t *testing.T) {
|
||||
sys, ok := SystemDisks(demoHost())
|
||||
if got := RoleForRawDevice("/dev/sdb1", sys, ok); got != RoleUserData {
|
||||
t.Errorf("external /dev/sdb1: got %q, want user-data", got)
|
||||
}
|
||||
if got := RoleForRawDevice("/dev/sda2", sys, ok); got != RoleSystem {
|
||||
t.Errorf("system /dev/sda2: got %q, want system", got)
|
||||
}
|
||||
// A partition on the OS disk is treated as system (protected), not user-data.
|
||||
if got := RoleForRawDevice("/dev/sda3", sys, ok); got != RoleSystem {
|
||||
t.Errorf("OS-disk partition /dev/sda3: got %q, want system", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguity fails safe: if the system disks cannot be determined, EVERY candidate is system.
|
||||
func TestRole_FailsSafeWhenSystemUnknown(t *testing.T) {
|
||||
noMounts := &fakeHostReader{mountsErr: errFake}
|
||||
sys, ok := SystemDisks(noMounts)
|
||||
if ok {
|
||||
t.Fatal("SystemDisks should report not-ok when mounts cannot be read")
|
||||
}
|
||||
if got := RoleForRawDevice("/dev/sdb1", sys, ok); got != RoleSystem {
|
||||
t.Errorf("unknown system disks → external device must default to system, got %q", got)
|
||||
}
|
||||
if got := RoleForStorage(hub.StorageTypeUSB, "/dev/sdb1", sys, ok); got != RoleSystem {
|
||||
t.Errorf("unknown system disks → usb target must default to system, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
var errFake = &fakeErr{}
|
||||
|
||||
type fakeErr struct{}
|
||||
|
||||
func (*fakeErr) Error() string { return "fake" }
|
||||
Reference in New Issue
Block a user