c5daa9480d
gates / gates (push) Successful in 31s
The agent names the physical disk(s) under each storage place from /sys, udev and /proc/mounts only
(partition -> disk, dm/LVM thin -> slaves, loop -> backing file), keys them (eui / serial:<bus>:<model>
:<serial> / vendor WWN) and says proven or unknown (vm, network, usb_bridge, weak_wwn, no_serial).
GET /disks and the hub report's storage_targets gain kind, physical_disks, identity,
identity_reason (golden updated; the hub copy changes in the same session). POST /disks/same-disk
maps two guest paths to places the agent itself made and answers {different, proven, reason};
an absent drive is unmapped, never the root disk. Tests on fixture trees for every spike case,
red-proofed. CHANGELOG entry follows the release (release-complete gate).
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
446 lines
14 KiB
Go
446 lines
14 KiB
Go
package storage
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Physical-disk identity of a storage place — felhom.eu architecture/16-storage-physical-identity.md §3.
|
||
//
|
||
// A storage place (a PVE storage, an enrolled drive, the guest's data volume, a network share) is
|
||
// reduced to the PHYSICAL whole disks under it — partition → parent disk, device-mapper / LVM thin →
|
||
// sysfs slaves (recursive), loop → its backing file → the filesystem holding that file — and each disk
|
||
// is named by a key the agent trusts: `eui:` for NVMe, `serial:<bus>:<model>:<serial>` from udev,
|
||
// `wwn:` only when no serial exists and the WWN carries a vendor (OUI ≠ 000000).
|
||
//
|
||
// The verdict is AGENT-COMPUTED evidence (`16` §10): it is read from /sys, /run/udev/data and
|
||
// /proc/mounts only, never from a caller's claim, so a compromised controller cannot make two places
|
||
// "different disks". `proven` means every disk under the place has a key the agent trusts; anything
|
||
// else is `unknown` with ONE reason, chosen by precedence (vm > network > usb_bridge > weak_wwn >
|
||
// no_serial). Nothing is cached: every call reads the current topology, so a disk swapped in the same
|
||
// bay is re-judged on the next read (the "right before each Tier-2 run" rule is the caller calling).
|
||
//
|
||
// Pinned by identity_test.go (fixture /sys + udev trees, one per §3 case) — the spike that proved the
|
||
// walk on real devices: felhom.eu documentation/audits/storage-identity-2026-10-15/.
|
||
|
||
const (
|
||
IdentityProven = "proven"
|
||
IdentityUnknown = "unknown"
|
||
|
||
ReasonVM = "vm"
|
||
ReasonNetwork = "network"
|
||
ReasonUSBBridge = "usb_bridge"
|
||
ReasonWeakWWN = "weak_wwn"
|
||
ReasonNoSerial = "no_serial"
|
||
|
||
PlaceKindSystem = "system"
|
||
PlaceKindInternal = "internal"
|
||
PlaceKindDisk = "disk"
|
||
PlaceKindPartition = "partition"
|
||
PlaceKindNetwork = "network"
|
||
)
|
||
|
||
// reasonRank orders the unknown reasons: the first one a place hits, by this precedence, is reported.
|
||
var reasonRank = map[string]int{ReasonVM: 0, ReasonNetwork: 1, ReasonUSBBridge: 2, ReasonWeakWWN: 3, ReasonNoSerial: 4}
|
||
|
||
// PlaceIdentity is what a storage place reports.
|
||
type PlaceIdentity struct {
|
||
PhysicalDisks []string // disk keys, sorted, deduplicated
|
||
Identity string // proven | unknown | "" (not computed — e.g. the device is absent)
|
||
Reason string // set only when Identity == unknown
|
||
}
|
||
|
||
// Computed reports whether a verdict was reached at all.
|
||
func (p PlaceIdentity) Computed() bool { return p.Identity != "" }
|
||
|
||
// IdentityHost is the read surface the resolver needs. SysfsIdentityHost is production; tests use the
|
||
// same struct pointed at fixture trees.
|
||
type IdentityHost interface {
|
||
Mounts() ([]Mount, error)
|
||
// KernelName maps a /dev path (symlinks allowed: /dev/mapper/x, /dev/pve/y) to its kernel block name.
|
||
KernelName(dev string) (string, bool)
|
||
// Parent returns the parent disk's kernel name when name is a partition.
|
||
Parent(name string) (string, bool)
|
||
// Slaves lists /sys/class/block/<name>/slaves.
|
||
Slaves(name string) []string
|
||
// LoopBackingFile returns a loop device's backing file.
|
||
LoopBackingFile(name string) (string, bool)
|
||
// UdevProps returns the udev E: properties of a block device.
|
||
UdevProps(name string) map[string]string
|
||
// Virt returns the VM technology ("none" on bare metal).
|
||
Virt() string
|
||
}
|
||
|
||
// SysfsIdentityHost reads the real host. Every path is a field so tests can point it at fixtures.
|
||
type SysfsIdentityHost struct {
|
||
SysClass string // default /sys/class/block
|
||
UdevData string // default /run/udev/data
|
||
ProcMounts string // default /proc/mounts
|
||
DevResolve func(string) (string, error) // default filepath.EvalSymlinks
|
||
VirtFn func() string // default `systemd-detect-virt --vm`, read once
|
||
|
||
virtOnce sync.Once
|
||
virt string
|
||
}
|
||
|
||
// NewSysfsIdentityHost returns the production reader.
|
||
func NewSysfsIdentityHost() *SysfsIdentityHost { return &SysfsIdentityHost{} }
|
||
|
||
func (h *SysfsIdentityHost) sysClass() string {
|
||
if h.SysClass != "" {
|
||
return h.SysClass
|
||
}
|
||
return "/sys/class/block"
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) Mounts() ([]Mount, error) {
|
||
return (&ProcHostReader{ProcMounts: h.ProcMounts}).Mounts()
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) KernelName(dev string) (string, bool) {
|
||
if !strings.HasPrefix(dev, "/dev/") {
|
||
return "", false
|
||
}
|
||
resolve := h.DevResolve
|
||
if resolve == nil {
|
||
resolve = filepath.EvalSymlinks
|
||
}
|
||
real := dev
|
||
if r, err := resolve(dev); err == nil {
|
||
real = r
|
||
}
|
||
name := filepath.Base(real)
|
||
if _, err := os.Stat(filepath.Join(h.sysClass(), name)); err != nil {
|
||
return "", false
|
||
}
|
||
return name, true
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) Parent(name string) (string, bool) {
|
||
p := filepath.Join(h.sysClass(), name)
|
||
if _, err := os.Stat(filepath.Join(p, "partition")); err != nil {
|
||
return "", false
|
||
}
|
||
real, err := filepath.EvalSymlinks(p)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return filepath.Base(filepath.Dir(real)), true
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) Slaves(name string) []string {
|
||
entries, err := os.ReadDir(filepath.Join(h.sysClass(), name, "slaves"))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(entries))
|
||
for _, e := range entries {
|
||
out = append(out, e.Name())
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) LoopBackingFile(name string) (string, bool) {
|
||
if !strings.HasPrefix(name, "loop") {
|
||
return "", false
|
||
}
|
||
b, err := os.ReadFile(filepath.Join(h.sysClass(), name, "loop", "backing_file"))
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
f := strings.TrimSpace(string(b))
|
||
return f, f != ""
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) UdevProps(name string) map[string]string {
|
||
out := map[string]string{}
|
||
devB, err := os.ReadFile(filepath.Join(h.sysClass(), name, "dev"))
|
||
if err != nil {
|
||
return out
|
||
}
|
||
dir := h.UdevData
|
||
if dir == "" {
|
||
dir = "/run/udev/data"
|
||
}
|
||
f, err := os.Open(filepath.Join(dir, "b"+strings.TrimSpace(string(devB))))
|
||
if err != nil {
|
||
return out
|
||
}
|
||
defer f.Close()
|
||
sc := bufio.NewScanner(f)
|
||
for sc.Scan() {
|
||
line := sc.Text()
|
||
if !strings.HasPrefix(line, "E:") {
|
||
continue
|
||
}
|
||
k, v, ok := strings.Cut(line[2:], "=")
|
||
if ok {
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (h *SysfsIdentityHost) Virt() string {
|
||
h.virtOnce.Do(func() {
|
||
if h.VirtFn != nil {
|
||
h.virt = h.VirtFn()
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
// --vm: a container is not a reason (the agent runs on the host); exit 1 + "none" on metal.
|
||
out, _ := exec.CommandContext(ctx, "systemd-detect-virt", "--vm").Output()
|
||
h.virt = strings.TrimSpace(string(out))
|
||
if h.virt == "" {
|
||
h.virt = "unknown"
|
||
}
|
||
})
|
||
return h.virt
|
||
}
|
||
|
||
// IdentityResolver turns devices and host paths into PlaceIdentity.
|
||
type IdentityResolver struct {
|
||
Host IdentityHost
|
||
}
|
||
|
||
// NewIdentityResolver returns a resolver over the real host.
|
||
func NewIdentityResolver() *IdentityResolver {
|
||
return &IdentityResolver{Host: NewSysfsIdentityHost()}
|
||
}
|
||
|
||
var networkFSTypes = map[string]bool{
|
||
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "fuse.sshfs": true, "9p": true, "autofs": true, "ceph": true, "glusterfs": true,
|
||
}
|
||
|
||
// NetworkPlace is the fixed verdict for a network share or a remote store.
|
||
func NetworkPlace() PlaceIdentity {
|
||
return PlaceIdentity{PhysicalDisks: []string{}, Identity: IdentityUnknown, Reason: ReasonNetwork}
|
||
}
|
||
|
||
// MountFor returns the mount that holds path (longest mountpoint prefix; the LAST entry wins on an equal
|
||
// mountpoint, which is the one stacked on top — an automount's real filesystem over its autofs trigger).
|
||
func MountFor(mounts []Mount, path string) (Mount, bool) {
|
||
p := filepath.Clean(path)
|
||
var best Mount
|
||
found := false
|
||
for _, m := range mounts {
|
||
mp := filepath.Clean(m.MountPoint)
|
||
if p != mp && !strings.HasPrefix(p, strings.TrimSuffix(mp, "/")+"/") {
|
||
continue
|
||
}
|
||
if !found || len(mp) >= len(filepath.Clean(best.MountPoint)) {
|
||
best, found = m, true
|
||
}
|
||
}
|
||
return best, found
|
||
}
|
||
|
||
// HostPath resolves the filesystem holding a HOST path.
|
||
func (r *IdentityResolver) HostPath(path string) PlaceIdentity {
|
||
return r.hostPath(path, 0)
|
||
}
|
||
|
||
func (r *IdentityResolver) hostPath(path string, depth int) PlaceIdentity {
|
||
if r == nil || r.Host == nil || depth > 4 {
|
||
return PlaceIdentity{}
|
||
}
|
||
mounts, err := r.Host.Mounts()
|
||
if err != nil {
|
||
return PlaceIdentity{}
|
||
}
|
||
m, ok := MountFor(mounts, path)
|
||
if !ok {
|
||
return PlaceIdentity{}
|
||
}
|
||
if networkFSTypes[m.FSType] || !strings.HasPrefix(m.Device, "/dev/") {
|
||
return NetworkPlace()
|
||
}
|
||
return r.device(m.Device, depth)
|
||
}
|
||
|
||
// Device resolves a block device (a /dev path, symlinks allowed).
|
||
func (r *IdentityResolver) Device(dev string) PlaceIdentity {
|
||
return r.device(dev, 0)
|
||
}
|
||
|
||
func (r *IdentityResolver) device(dev string, depth int) PlaceIdentity {
|
||
if r == nil || r.Host == nil || dev == "" {
|
||
return PlaceIdentity{}
|
||
}
|
||
name, ok := r.Host.KernelName(dev)
|
||
if !ok {
|
||
return PlaceIdentity{}
|
||
}
|
||
keys := map[string]bool{}
|
||
reason := ""
|
||
if !r.walk(name, depth, map[string]bool{}, keys, &reason) {
|
||
return PlaceIdentity{}
|
||
}
|
||
out := PlaceIdentity{PhysicalDisks: sortedKeys(keys), Identity: IdentityProven}
|
||
if reason != "" {
|
||
out.Identity, out.Reason = IdentityUnknown, reason
|
||
}
|
||
return out
|
||
}
|
||
|
||
// walk adds the keys of every physical disk under name; ok=false when a branch cannot be grounded.
|
||
func (r *IdentityResolver) walk(name string, depth int, seen, keys map[string]bool, reason *string) bool {
|
||
if seen[name] {
|
||
return true // a diamond (a thin pool's tmeta and tdata on one partition): already counted
|
||
}
|
||
if len(seen) > 64 {
|
||
return false
|
||
}
|
||
seen[name] = true
|
||
if parent, ok := r.Host.Parent(name); ok {
|
||
return r.walk(parent, depth, seen, keys, reason)
|
||
}
|
||
if file, ok := r.Host.LoopBackingFile(name); ok {
|
||
p := r.hostPath(file, depth+1)
|
||
if !p.Computed() {
|
||
return false
|
||
}
|
||
for _, k := range p.PhysicalDisks {
|
||
keys[k] = true
|
||
}
|
||
noteReason(reason, p.Reason)
|
||
return true
|
||
}
|
||
if slaves := r.Host.Slaves(name); len(slaves) > 0 {
|
||
for _, s := range slaves {
|
||
if !r.walk(s, depth, seen, keys, reason) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
if strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "loop") {
|
||
return false // a virtual device with nothing beneath it cannot be grounded
|
||
}
|
||
key, why := r.diskKey(name)
|
||
keys[key] = true
|
||
noteReason(reason, why)
|
||
return true
|
||
}
|
||
|
||
func noteReason(cur *string, why string) {
|
||
if why == "" {
|
||
return
|
||
}
|
||
if *cur == "" || reasonRank[why] < reasonRank[*cur] {
|
||
*cur = why
|
||
}
|
||
}
|
||
|
||
// diskKey names one whole disk and says why it is not trusted ("" when it is).
|
||
func (r *IdentityResolver) diskKey(name string) (key, reason string) {
|
||
p := r.Host.UdevProps(name)
|
||
serial := strings.TrimSpace(p["ID_SERIAL_SHORT"])
|
||
model := strings.TrimSpace(p["ID_MODEL"])
|
||
bus := strings.TrimSpace(p["ID_BUS"])
|
||
wwn := strings.ToLower(strings.TrimSpace(p["ID_WWN"]))
|
||
if bus == "" && strings.HasPrefix(name, "nvme") {
|
||
bus = "nvme"
|
||
}
|
||
usb := bus == "usb" || strings.Contains(p["DEVPATH"], "/usb")
|
||
switch {
|
||
case strings.HasPrefix(name, "nvme") && (strings.HasPrefix(wwn, "eui.") || strings.HasPrefix(wwn, "nvme.")):
|
||
key = "eui:" + strings.TrimPrefix(strings.TrimPrefix(wwn, "eui."), "nvme.")
|
||
case serial != "":
|
||
key = "serial:" + bus + ":" + model + ":" + serial
|
||
if usb && FakeBridgeSerial(serial) {
|
||
reason = ReasonUSBBridge
|
||
}
|
||
case wwn != "" && WWNHasVendor(wwn):
|
||
key = "wwn:" + wwn
|
||
case wwn != "":
|
||
key, reason = "wwn:"+wwn, ReasonWeakWWN
|
||
default:
|
||
key, reason = "dev:"+name, ReasonNoSerial
|
||
}
|
||
if v := r.Host.Virt(); v != "none" {
|
||
reason = ReasonVM // inside a VM a virtual disk says nothing about the host disk (spike cases 5–6)
|
||
}
|
||
return key, reason
|
||
}
|
||
|
||
// WWNHasVendor reports whether an NAA-5/6 WWN carries a registered vendor (OUI ≠ 000000). Other NAA
|
||
// formats are taken as they are. Measured: the N100's AirDisk SSD reports 0x5000000123456f1f.
|
||
func WWNHasVendor(wwn string) bool {
|
||
h := strings.TrimPrefix(strings.ToLower(wwn), "0x")
|
||
if len(h) < 7 {
|
||
return false
|
||
}
|
||
if h[0] == '5' || h[0] == '6' {
|
||
return h[1:7] != "000000"
|
||
}
|
||
return true
|
||
}
|
||
|
||
// FakeBridgeSerial reports a serial that a USB bridge reports for any disk: too short, one repeated
|
||
// character, or a known placeholder. UNPROVEN on a real enclosure (R-1077) — the rule is the design's.
|
||
func FakeBridgeSerial(s string) bool {
|
||
s = strings.ToUpper(strings.TrimSpace(s))
|
||
if len(s) < 6 {
|
||
return true
|
||
}
|
||
if strings.Count(s, s[:1]) == len(s) {
|
||
return true
|
||
}
|
||
switch s {
|
||
case "123456789", "1234567890", "123456789ABC", "0123456789ABCDEF", "1234567890ABCDEF", "DEADBEEF":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func sortedKeys(m map[string]bool) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// Relation compares two places (`16` §4). A SHARED disk key is a positive fact, so two places on one disk
|
||
// are "same, proven" even when that disk is unknown (inside a VM, two parts of one virtual disk). Otherwise
|
||
// proven is true only when both verdicts are proven; different then means "no disk in common". When proven
|
||
// is false, different only says the known keys do not overlap — callers must act on proven first.
|
||
// reason: "" (proven, different), "same_disk", "unmapped" (a side has no verdict), or the unknown reason.
|
||
func Relation(a, b PlaceIdentity) (different, proven bool, reason string) {
|
||
if !a.Computed() || !b.Computed() {
|
||
return false, false, "unmapped"
|
||
}
|
||
set := map[string]bool{}
|
||
for _, k := range a.PhysicalDisks {
|
||
set[k] = true
|
||
}
|
||
shared := false
|
||
for _, k := range b.PhysicalDisks {
|
||
if set[k] {
|
||
shared = true
|
||
}
|
||
}
|
||
if shared {
|
||
return false, true, "same_disk"
|
||
}
|
||
if a.Identity == IdentityProven && b.Identity == IdentityProven {
|
||
return true, true, ""
|
||
}
|
||
why := a.Reason
|
||
if why == "" || (b.Reason != "" && reasonRank[b.Reason] < reasonRank[why]) {
|
||
why = b.Reason
|
||
}
|
||
return true, false, why
|
||
}
|