package storage import ( "path/filepath" "regexp" "strings" "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. A mounted ESP (/boot/efi) pins the OS disk directly; an LVM/device-mapper root is // traced to its physical parents by the root-free sysfs slaves/ walk (v0.87.0 — before that, a // legacy-boot host with no mounted ESP resolved NOTHING and the all-system fail-safe killed the // drive wizard permanently, IA finding 2). 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). Virtual backing devices (device-mapper/LVM, md-raid — the legacy-boot // common case where / sits on /dev/mapper/pve-root and no ESP is mounted) are walked recursively // through the sysfs slaves/ chain down to their physical parent disks (operator ruling // 2026-07-13: walk topology, never VG names). ok=false ONLY when the topology could not be fully // grounded — no system mountpoint found, or ANY system mount whose backing device the walk could // not resolve to physical disks — and callers then treat every candidate as system (most // protected). That all-system fail-safe is back to being the ERROR case, not the legacy-boot // common case. Root-free: mount table + /dev symlinks + world-readable sysfs only. 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 } disks, dok := physicalDisksOf(host, m.Device) if !dok { // A system mount we cannot ground in physical disks — the WHOLE resolution is // undeterminable. Never return a partial set as ok: a root-backing disk missing // from the set is exactly the catastrophic direction (a system disk offered as // a wizard candidate). return nil, false } for _, d := range disks { set[d] = true } } return set, len(set) > 0 } // physicalDisksOf resolves a mounted device to the PHYSICAL whole disks backing it. Plain // disks/partitions resolve directly (wholeDiskOf); a virtual device (dm-*, md*) is walked via // its sysfs slaves. ok=false when the device cannot be grounded (network/dataset sources, // unknown names, or any unresolvable slave branch). func physicalDisksOf(host HostReader, device string) ([]string, bool) { if device == "" { return nil, false } dev := device if resolved, err := filepath.EvalSymlinks(device); err == nil { dev = resolved // canonicalize /dev/mapper/pve-root → /dev/dm-1, by-uuid links, … } if wd, wok := wholeDiskOf(dev); wok { return []string{wd}, true // already a raw disk or a recognizable partition } if !strings.HasPrefix(dev, "/dev/") { return nil, false // ZFS dataset, NFS, overlay, … — not a block topology we can walk } return walkSlaves(host, filepath.Base(dev), map[string]bool{}) } // walkSlaves recursively resolves a VIRTUAL block device name (dm-*, md*) to physical whole // disks via /sys/block//slaves. Per-branch conservatism (operator ruling): ANY slave that // cannot be resolved — a dangling entry, an unrecognizable name, a virtual device with no // listable slaves — fails the WHOLE walk. The candidate/protected verdict must never rest on a // partially-understood topology. visited doubles as the cycle/degenerate-depth guard. func walkSlaves(host HostReader, name string, visited map[string]bool) ([]string, bool) { if name == "" || visited[name] || len(visited) > 32 { return nil, false } visited[name] = true slaves, hasDir := host.BlockSlaves(name) if !hasDir || len(slaves) == 0 { // No /sys/block entry (not a whole device) or nothing beneath a supposed virtual // device — either way this branch cannot be grounded. return nil, false } var out []string for _, s := range slaves { if wd, wok := wholeDiskOf("/dev/" + s); wok { out = append(out, wd) // a physical disk or a partition of one (sda3 → /dev/sda) continue } sub, sok := walkSlaves(host, s, visited) if !sok { return nil, false } out = append(out, sub...) } return out, true } // 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 }