Files
felhom-agent/internal/storage/claim.go
T
admin 703db166e7
gates / gates (push) Successful in 8s
v0.127.0: a mount Felhom itself made is not 'something else' (R-220)
After a rebuild the customer's own drives could not be re-attached: candidates
returned initialize:[] attach:[] while both drives sat there, and the deploy
refused with 'choose an attached drive from the list' — a list that was empty.
Measured live three times.

Mechanism: enrolment mounts a drive TWICE, at /mnt/felhom-drives/<name> and at
the raw /mnt/<name> it creates on the host. The host survives a guest rebuild;
the controller's registry does not. So classifyClaim saw a mount outside the
managed prefix and concluded 'claimed by something else' — about our own mount.

The fix is CORROBORATED, not a widened prefix: a non-managed mountpoint is
forgiven only when the SAME device is also mounted under the managed path, a
pairing only our enrolment produces. A disk another system uses — /srv/data,
/media/x, even /mnt/someone-elses-disk — has no counterpart and is STILL
refused, with its own test and a red-proof showing an over-wide fix offering it
for formatting.

Read from /proc/mounts deliberately: the lsblk invocation is pinned verbatim in
configs/felhom-agent.sudoers, so using the plural MOUNTPOINTS would have coupled
this to a sudoers rollout. /proc/mounts is world-readable — no sudo, no new
allowlisted command, no config change.

Fail-safe: an unreadable mount table corroborates NOTHING, so the device
classifies exactly as before. 'Could not corroborate' must never read as 'ours'.

29 packages ok, vet clean, agent gates OK.
2026-08-06 12:55:28 +02:00

375 lines
14 KiB
Go

package storage
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
// The "unclaimed-disk" filter (Impl-1, SPIKE-drive-enrollment-2026-07-01 §SQ2). It answers a single
// safety-critical question: is a block device provably FREE for Felhom to format, i.e. NOT claimed by
// the OS or by anything but Felhom's own drives? It is the mandatory guard `Format` runs before mkfs
// (the sudoers permits `mkfs /dev/*`, so the agent's code — this filter — is the real destructive-op
// guard; the pool-scoped token ACL does not touch a sudo mkfs).
//
// FAIL-SAFE is the rule: the filter returns unclaimed ONLY when it can positively read every required
// signal AND none indicates a claim. Any read error, undeterminable topology, or any claim signal →
// CLAIMED → refuse. It never offers a disk it cannot prove is free.
// felhomDrivesPrefix — a mount under here is one of Felhom's OWN managed drives, which is NOT a foreign
// claim: re-initialising our own drive stays allowed (the DataBearing wipe-confirm still gates the data
// loss). A mount anywhere else is a foreign claim → refuse.
const felhomDrivesPrefix = "/mnt/felhom-drives"
// memberFSTypes are lsblk/blkid FSTYPE values meaning the device is a MEMBER of a higher-level
// construct (LVM/ZFS/mdraid/LUKS) or active swap — always a claim, never a plain formattable data disk.
var memberFSTypes = map[string]bool{
"LVM2_member": true,
"zfs_member": true,
"linux_raid_member": true,
"crypto_LUKS": true,
"swap": true,
}
// claimNode is one block node — the whole disk or a partition/child — with the signals we classify on.
type claimNode struct {
name string
fstype string
mountpoint string
}
// claimFacts is the gathered evidence for one candidate device. classifyClaim is a PURE function of it
// (so the decision logic is fully unit-testable from fixtures, with no host).
type claimFacts struct {
device string
wholeDisk string
wholeDiskOK bool
isSystem bool
readonly bool
nodes []claimNode // the whole disk + its children (partitions)
lvmPV bool // pvs (authoritative): the disk / a partition is an LVM physical volume
zfsMember bool // zpool (authoritative): the disk / a partition is a ZFS pool member
// felhomOwnedMounts (R-220) — mountpoints OUTSIDE /mnt/felhom-drives that are nevertheless Felhom's
// OWN, corroborated from the host mount table: the same device is also mounted at the managed path.
// Empty means "nothing corroborated", which is the fail-safe direction.
felhomOwnedMounts map[string]bool
gatherErr string // non-empty ⇒ a REQUIRED read failed ⇒ fail-safe CLAIMED
}
// classifyClaim is the pure guard verdict. unclaimed=true ONLY when the device is provably free for
// Felhom to format; every claim signal / error / ambiguity ⇒ unclaimed=false with a human reason.
func classifyClaim(f claimFacts) (unclaimed bool, reason string) {
if f.gatherErr != "" {
return false, "could not determine device claims: " + f.gatherErr
}
if !f.wholeDiskOK {
return false, "undeterminable device topology (not a recognizable raw disk)"
}
if f.isSystem {
return false, "system/OS disk"
}
if f.readonly {
return false, "read-only device"
}
if f.lvmPV {
return false, "device holds an LVM physical volume"
}
if f.zfsMember {
return false, "device is a ZFS pool member"
}
for _, n := range f.nodes {
if memberFSTypes[n.fstype] {
return false, "device holds a " + n.fstype + " (" + n.name + ")"
}
// ── R-220 — A MOUNT FELHOM ITSELF MADE IS NOT "SOMETHING ELSE". ───────────────────────
//
// Enrolment mounts a drive TWICE: at the managed path `/mnt/felhom-drives/<name>` and at the
// raw `/mnt/<name>` it creates on the host. The host — and therefore that raw mount — survives
// a guest rebuild, while the controller's registry does not. So after a rebuild the customer's
// own drives looked foreign, `attach` returned an empty list, and the refusal told them to
// choose from it. Measured live three times (CAMPAIGN-11 Phase 1, and the R-201 re-walk twice);
// unmounting only the raw mounts flipped `attach: []` to both drives every time.
//
// The fence this must NOT breach: a disk genuinely in use by something else stays refused. So
// the exemption is not "any /mnt/* path" — it is CORROBORATED: the same device must ALSO be
// mounted at Felhom's managed path, which is a state only Felhom's own enrolment produces.
// A foreign disk at /srv/data or /media/x has no such counterpart and is still refused.
if n.mountpoint != "" && !underFelhomDrives(n.mountpoint) && !f.felhomOwnedMounts[n.mountpoint] {
return false, "device is mounted at " + n.mountpoint + " (" + n.name + ")"
}
}
// Fail-safe backstop (audit D2): a successful-but-EMPTY lsblk (or a tree that does not even contain
// the target whole-disk) means the member/mount loop above inspected nothing — that is undeterminable
// topology, not proof of freedom. Without this, "unclaimed" rested on the untested assumption that
// lsblk always ERRORS (non-zero exit) on a bad device rather than emitting empty success.
if len(f.nodes) == 0 {
return false, "empty block topology (undeterminable) — refusing"
}
base := path.Base(f.wholeDisk)
found := false
for _, n := range f.nodes {
if n.name == base {
found = true
break
}
}
if !found {
return false, "target disk " + base + " absent from block topology (undeterminable) — refusing"
}
return true, "unclaimed"
}
// underFelhomDrives reports whether a mountpoint is one of Felhom's own managed drive mounts.
func underFelhomDrives(mp string) bool {
mp = path.Clean(mp) // mountpoints are always unix paths — path.Clean, not filepath.Clean (Windows tests)
return mp == felhomDrivesPrefix || strings.HasPrefix(mp, felhomDrivesPrefix+"/")
}
// deviceUnclaimed gathers the claim evidence for device and returns the pure verdict.
func (h *SudoHostOps) deviceUnclaimed(ctx context.Context, device string) (bool, string) {
return classifyClaim(h.gatherClaimFacts(ctx, device))
}
// gatherClaimFacts reads every claim signal via host-visible reads (NOT the PVE token): the OS-disk set
// (mount table), the whole-disk lsblk tree (member FSTYPE + mountpoints — the authoritative backbone),
// the /sys read-only flag, and — when their tools are installed — the authoritative LVM (pvs) and ZFS
// (zpool) sources. A tool that is genuinely ABSENT contributes "no claim of that kind" (lsblk still
// detects the member FSTYPE); a tool that is present but ERRORS is a fail-safe CLAIMED.
func (h *SudoHostOps) gatherClaimFacts(ctx context.Context, device string) claimFacts {
f := claimFacts{device: device}
wd, ok := wholeDiskOf(device)
f.wholeDisk, f.wholeDiskOK = wd, ok
// OS/system disk — fail-safe inside (unknown topology / unknown system set ⇒ system).
sys, sysKnown := SystemDisks(h.host)
f.isSystem = isSystemBacked(device, sys, sysKnown)
if !ok {
return f // classifyClaim refuses on !wholeDiskOK
}
// Read-only flag (/sys/block/<disk>/ro is world-readable).
if ro, rerr := readWholeDiskRO(wd); rerr != nil {
f.gatherErr = "read-only flag unreadable"
return f
} else {
f.readonly = ro
}
// lsblk tree on the WHOLE disk — REQUIRED (error ⇒ claimed). Reuses the allowlisted command.
lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", wd)
if lerr != nil {
f.gatherErr = "lsblk failed"
return f
}
nodes, perr := parseLsblkNodes(lout)
if perr != nil {
f.gatherErr = "lsblk parse failed"
return f
}
f.nodes = nodes
// R-220: corroborate which non-managed mountpoints are nevertheless Felhom's own.
f.felhomOwnedMounts = felhomOwnedMounts(device, nodes, h.mountTable)
// LVM PV (authoritative). pvs installed but erroring ⇒ fail-safe claimed; absent ⇒ rely on lsblk's
// LVM2_member FSTYPE (already in nodes).
if h.binaryPresent(h.bins.Pvs) {
pvset, err := h.lvmPVSet(ctx)
if err != nil {
f.gatherErr = "pvs failed"
return f
}
for _, n := range nodes {
if pvset["/dev/"+n.name] {
f.lvmPV = true
}
}
if pvset[wd] {
f.lvmPV = true
}
}
// ZFS members (authoritative). Absent ⇒ no ZFS on this host; present but erroring ⇒ claimed.
if h.binaryPresent(h.bins.Zpool) {
member, err := h.zfsMembers(ctx, nodes, wd)
if err != nil {
f.gatherErr = "zpool failed"
return f
}
f.zfsMember = member
}
return f
}
// binaryPresent reports whether an absolute binary path exists (distinguishes "tool not installed"
// from "tool present but errored" — only the latter is a fail-safe CLAIMED).
func (h *SudoHostOps) binaryPresent(path string) bool {
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// readWholeDiskRO reads /sys/block/<disk>/ro ("1" ⇒ read-only). Whole-disk path e.g. /dev/sdd. A
// package var so tests can stub the /sys read (the only host read in the gather that isn't a stubbable
// runner/HostReader call).
var readWholeDiskRO = func(wholeDisk string) (bool, error) {
name := filepath.Base(wholeDisk)
b, err := os.ReadFile(filepath.Join("/sys/block", name, "ro"))
if err != nil {
return false, err
}
return strings.TrimSpace(string(b)) == "1", nil
}
// --- lsblk tree parsing (per-node FSTYPE + mountpoint for the disk and every child) ---
type lsblkDev struct {
Name string `json:"name"`
FSType string `json:"fstype"`
MountPoint string `json:"mountpoint"`
Children []lsblkDev `json:"children"`
}
// parseLsblkNodes flattens `lsblk -J` output into every node (the disk + all descendants).
func parseLsblkNodes(out []byte) ([]claimNode, error) {
var doc struct {
BlockDevices []lsblkDev `json:"blockdevices"`
}
if err := json.Unmarshal(out, &doc); err != nil {
return nil, err
}
var nodes []claimNode
var walk func(d lsblkDev)
walk = func(d lsblkDev) {
nodes = append(nodes, claimNode{name: d.Name, fstype: d.FSType, mountpoint: d.MountPoint})
for _, c := range d.Children {
walk(c)
}
}
for _, d := range doc.BlockDevices {
walk(d)
}
return nodes, nil
}
// lvmPVSet returns the set of PV device paths (canonicalized to whole-disk where possible is done by
// the caller; here we return the raw pv_name paths as pvs reports them, e.g. /dev/sda3 or /dev/sdb).
func (h *SudoHostOps) lvmPVSet(ctx context.Context) (map[string]bool, error) {
out, stderr, err := h.runner.Run(ctx, h.bins.Pvs, "--reportformat", "json", "--noheadings", "-o", "pv_name")
if err != nil {
return nil, fmt.Errorf("pvs: %w: %s", err, trim(stderr))
}
var doc struct {
Report []struct {
PV []struct {
PVName string `json:"pv_name"`
} `json:"pv"`
} `json:"report"`
}
if jerr := json.Unmarshal(out, &doc); jerr != nil {
return nil, jerr
}
set := map[string]bool{}
for _, r := range doc.Report {
for _, pv := range r.PV {
if n := strings.TrimSpace(pv.PVName); n != "" {
set[n] = true
}
}
}
return set, nil
}
// zfsMembers reports whether any of the device's nodes (or the whole disk) is a ZFS pool member, by
// scanning `zpool status -P` (which prints full /dev paths). Conservative substring match on the node
// device paths — a false positive only ever REFUSES (safe direction).
func (h *SudoHostOps) zfsMembers(ctx context.Context, nodes []claimNode, wholeDisk string) (bool, error) {
out, stderr, err := h.runner.Run(ctx, h.bins.Zpool, "status", "-P")
if err != nil {
return false, fmt.Errorf("zpool: %w: %s", err, trim(stderr))
}
text := string(out)
if strings.Contains(text, wholeDisk) {
return true, nil
}
for _, n := range nodes {
if n.name != "" && strings.Contains(text, "/dev/"+n.name) {
return true, nil
}
}
return false, nil
}
// mountTableSource yields the host mount table as (device, mountpoint) pairs. A seam so the R-220
// corroboration is unit-testable without a host. nil ⇒ the real /proc/mounts.
type mountTableSource func() ([][2]string, error)
// procMounts reads /proc/mounts — WORLD-READABLE, so this needs no sudo and no allowlisted command.
// That matters: the lsblk invocation is pinned verbatim in the sudoers file
// (`lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT /dev/*`), so switching it to the plural MOUNTPOINTS
// would have meant shipping a sudoers change with the binary — a far larger blast radius than this
// finding warrants. Reading the mount table directly sidesteps that entirely.
func procMounts() ([][2]string, error) {
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return nil, err
}
var out [][2]string
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
// /proc/mounts escapes spaces as \040; unescape so a path with a space still compares.
out = append(out, [2]string{fields[0], strings.ReplaceAll(fields[1], `\040`, " ")})
}
return out, nil
}
// felhomOwnedMounts returns the mountpoints of `device` (and its children) that sit OUTSIDE
// /mnt/felhom-drives but are still Felhom's own, corroborated by the same device also being mounted
// UNDER /mnt/felhom-drives. That pairing is what enrolment produces and nothing else does.
//
// ⚠ FAIL-SAFE: an unreadable mount table returns an EMPTY set, never a permissive one. The device then
// classifies exactly as it did before R-220 — refused — because "we could not corroborate" must never
// read as "it is ours".
func felhomOwnedMounts(device string, nodes []claimNode, src mountTableSource) map[string]bool {
if src == nil {
src = procMounts
}
table, err := src()
if err != nil {
return nil // unreadable ⇒ corroborate nothing
}
// Every device name this disk answers to: the whole disk and each child node.
devs := map[string]bool{device: true}
if wd, ok := wholeDiskOf(device); ok {
devs[wd] = true
}
for _, n := range nodes {
devs["/dev/"+n.name] = true
}
// A device is Felhom-managed only if it is mounted under the managed prefix.
managed := map[string]bool{}
for _, row := range table {
if devs[row[0]] && underFelhomDrives(row[1]) {
managed[row[0]] = true
}
}
if len(managed) == 0 {
return nil
}
owned := map[string]bool{}
for _, row := range table {
if managed[row[0]] && !underFelhomDrives(row[1]) {
owned[path.Clean(row[1])] = true
}
}
return owned
}