From 15f7529a1c3a9fb814bc100436daefe142ee8aee Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 11 Jun 2026 21:29:18 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 37 ++++++ REPORT.md | 81 ++++++++---- cmd/felhom-agent/main.go | 34 +++-- internal/localapi/disks.go | 161 ++++++++++++++++++------ internal/localapi/disks_test.go | 127 ++++++++++++++----- internal/reconcile/classify.go | 17 +++ internal/reconcile/gate.go | 80 +++++++++++- internal/reconcile/storage_wipe_test.go | 95 ++++++++++++++ internal/storage/role.go | 138 ++++++++++++++++++++ internal/storage/role_test.go | 95 ++++++++++++++ 10 files changed, 759 insertions(+), 106 deletions(-) create mode 100644 internal/reconcile/storage_wipe_test.go create mode 100644 internal/storage/role.go create mode 100644 internal/storage/role_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e20afbb..3b78ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,43 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.23.0 — device-ROLE classification + tiered storage-wipe gate (system/backup operator-only, user-data customer-confirmable) (2026-06-11) + +The storage-authorization redesign (agent half). The gate's destructive-wipe path is now **tiered by +the device's protection ROLE**, which the agent classifies from its OWN inspection — never the +caller's claim (the storage analog of classify.go's data-bearing verdict). + +- **`internal/storage/role.go`** — `DeviceRole` (`system` | `backup` | `user-data`) + the + authoritative classifier. `RoleForStorage` (storage-view targets) and `RoleForRawDevice` (a raw + device, e.g. a fresh disk in the init flow) map a device to its tier via `SystemDisks` (the + whole-disks backing `/`, `/boot`, `/boot/efi`, root-free reads). Rules: `pbs` → backup; `lvmthin` / + builtin `local` / nfs / cifs / unknown → system; `usb` / `local-dir` on a **non-system external + device** → user-data. **Fail-safe**: any ambiguity (system disks unknown, or an unrecognizable + device topology) → **system** (most-protected) — never silently user-data. +- **`GET /disks`** — each `DiskInfo` now carries `role`. The controller drives the UI from it + (system/backup get a lock + no destructive controls; user-data is customer-manageable). +- **Gate tier (`reconcile`)** — new `CustomerConfirmable` disposition + `Gate.AuthorizeStorageWipe`: + - role=**user-data** → **customer-confirmable**: allowed iff the request carries an explicit + customer confirmation **bound to the device's durable id** (the agent re-resolves the durable id + and matches; a confirmation for one disk can't wipe another). **No operator signature.** A + user-data drive is already within the in-guest controller's blast radius (it bind-mounts `/mnt`), + so customer-confirmation adds no new reach. Recorded in the **audit log** with the durable id + (`AuditRecord.DurableID`). + - role=**system**/**backup** → unchanged **operator-signature** (`pending_signature`). The + `confirmed` flag is **IGNORED** — a compromised controller asserting `confirmed:true` on a + protected device is refused **by role**. Every other destructive class (`guest_destroy`, + `decommission`, `restore_overwrite`, `key_rotation`) keeps operator-signature exactly as before. +- **`POST /disks/format`** — accepts `confirmed` + `durable_id` (inert for system/backup). The + data-bearing path tiers by role: user-data customer-confirmed → `mkfs`; user-data unconfirmed → + 403 `needs_confirmation` (+ the durable id to confirm against, NOT an opsign command); system/backup + → 403 with the operator-signature pending op (as before). Blank devices stay benign `mkfs`. +- **Tests** — `role_test.go` (demo-storage mapping + fail-safe), `storage_wipe_test.go` (the gate + refuses a `confirmed` wipe on system/backup → no exec; durable-id mismatch / missing-durable + refused; unknown role fails safe), and the localapi format-handler branches (user-data confirmed → + mkfs; user-data unconfirmed → needs_confirmation, no opsign; confirmed-but-protected → still refused). + +Pairs with the controller's lockout + type-to-confirm UX + drive-list restyle. + ## v0.22.0 — expose durable_id in GET /disks (enable controller-side guided storage) (2026-06-11) One-line, read-only addition: `localapi.DiskInfo` gains `durable_id` (mapped from diff --git a/REPORT.md b/REPORT.md index ad2e9b8..1b6eed9 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,30 +1,65 @@ -# REPORT — agent v0.22.0: expose durable_id in GET /disks (2026-06-11) +# REPORT — felhom-agent v0.23.0: device-ROLE classification + tiered storage-wipe gate -**Repo:** `felhom-agent` · **Version:** 0.22.0 · **Pushed commit:** `4734d4a` · paired with -`felhom-controller` v0.43.0 (the controller-side storage-management rebuild that needs this). +## What this implements -## What shipped (one read-only field) +The **agent half** of the storage-authorization redesign (CC SPEC, Part A). Destructive storage +wipes are now **tiered by the device's protection ROLE**, classified by the agent from its own +inspection — never the controller's or hub's claim. -`internal/localapi/DiskInfo` gains `durable_id` (mapped directly from `StorageTarget.DurableID` — e.g. -`"uuid:"` for usb/local-dir, `"path:…"`/`"store:…"` otherwise). That's the entire change. +### A1 — Device-ROLE classification (authoritative) +- New `internal/storage/role.go`: `DeviceRole` = `system` | `backup` | `user-data`. +- `SystemDisks(host)` resolves the OS whole-disks (the disks backing `/`, `/boot`, `/boot/efi`) from + the mount table + world-readable `/dev` symlinks (root-free; the root-CLI fence is untouched). +- `RoleForStorage(typ, backingDevice, …)` tiers a storage-view target; `RoleForRawDevice(device, …)` + tiers a raw device (a fresh disk in the init flow, not yet a PVE storage). + - `pbs` → **backup**; `lvmthin` / builtin `local` / nfs / cifs / unknown → **system**; + `usb` / `local-dir` on a **non-system external device** → **user-data**. + - **Fail-safe**: any ambiguity → **system** (most-protected) — consistent with destructive-on-ambiguity. +- `GET /disks` → each `DiskInfo` now carries `role`. -**Why it's necessary:** the de-privileged controller cannot read a device's filesystem UUID itself, yet -`POST /disks/assign` mounts **strictly by fs UUID** (`EnsureMount` → `/dev/disk/by-uuid/`). Without -the UUID surfaced somewhere the controller can reach, the guided init/attach flows could not complete the -mount. `GET /disks` already carried everything else (name/type/state/device/mount/class/data_bearing) but -dropped the durable id. The controller strips the `uuid:` prefix to get the assign key. +### A2 — Gate tier (`internal/reconcile`) +- New `CustomerConfirmable` disposition + `StorageRole{System,Backup,UserData}` (string mirror of + `storage.DeviceRole`, kept in reconcile to avoid a storage→reconcile import edge). +- `Gate.AuthorizeStorageWipe(StorageWipeAuthz, signed)`: + - **user-data** → allowed iff `Confirmed` AND the confirmation's durable id matches the device's + agent-re-resolved durable id (`ReasonCustomerConfirmed`); unconfirmed → `ReasonPendingConfirmation`; + mismatch / missing durable → `ReasonBindingMismatch`. Audited with the durable id. + - **system/backup/unknown** → the standard operator-signature path (`pending_signature`); `Confirmed` + is ignored — refused **by role**. +- `AuditRecord.DurableID` added (the customer-visible "who/what/when + durable id"). -**Security:** no new privilege, no subprocess, no behaviour change to `format`/`assign`/`eject` or the -data-bearing signature gate. The agent still inspects the device itself at format time and refuses a -data-bearing wipe with a `pending_op` (the controller surfaces the `felhom-opsign` command). +### A3 — Endpoint plumbing (`internal/localapi`) +- `POST /disks/format` accepts `confirmed` + `durable_id`. The data-bearing path: + - resolves the device's **authoritative role** (`deviceRole` — prefers a matching storage target's + role so PBS is recognized as backup, else raw classification), re-resolves the durable id, and + calls the tiered gate. + - user-data confirmed → `mkfs` (200); user-data unconfirmed/mismatch → 403 with `needs_confirmation` + + the durable id (no opsign); system/backup → 403 with the operator-signature pending op. + - blank devices stay benign `mkfs` (unchanged). +- `StorageGate` interface widened: `AuthorizeWipe(WipeRequest) WipeDecision`. Adapter in `main.go` + bridges to `Gate.AuthorizeStorageWipe`. +- Eject stays **benign** (unmount, data preserved) — its customer confirmation is a controller-side + UI concern, not a gate tier (per the hard rule: confirmation walks the gate ONLY for user-data + `storage_wipe`). -## Validation (live, guest 9201) -`GET /disks` (via the controller's `/api/disks` proxy) now returns `durable_id` per disk, e.g. -`felhom-usb` → `/dev/sdb1`, `data_bearing:true`, `durable_id:uuid:277a2179-a764-4758-b840-9ea741517914`. -The controller's guided init on `sdb` correctly drove the agent to a **data-bearing refusal** (HTTP 403 → -`pending_op{op:storage_wipe, host_scope:demo-felhom-01, durable_id:byid:wwn-0x5000039ddb108568-part1}`), -which the controller surfaced as the operator `felhom-opsign` command — the gate held end-to-end. +## Tests (non-hollow) +- `internal/storage/role_test.go` — demo-storage mapping (`local`/`local-lvm`/`felhom-pbs` protected, + `felhom-usb` on `/dev/sdb1` user-data) + fail-safe when system disks are unknown. +- `internal/reconcile/storage_wipe_test.go` — a `confirmed:true` wipe on **system/backup is refused** + (falls to pending_signature, no exec); user-data durable-id **mismatch refused**; missing durable + refused; unconfirmed → pending_confirmation; unknown role fails safe. +- `internal/localapi/disks_test.go` — format-handler branches: user-data confirmed → mkfs; user-data + unconfirmed → 403 needs_confirmation with **no** operator-signature pending op; + confirmed-but-protected → still refused (role beats confirmation); blank → mkfs without the gate. -## Notes -This pairs with the controller's storage-management rebuild (build-golden default bumped to controller -0.43.0; golden rebaked). No other agent change. +`go build ./... && go vet ./... && go test ./...` all green (Go 1.26, local). + +## Version +`v0.22.0 → v0.23.0` (`version` var in `cmd/felhom-agent/main.go`). + +## Notes / residuals +- The one accepted residual (per spec): a compromised controller could `mkfs` a **user-data** drive + it could already `rm -rf` — same data loss, no new reach. System/backup remain operator-only. +- LVM-root caveat: when `/` is on device-mapper, the OS whole-disk is pinned via `/boot`/`/boot/efi` + (a raw ESP partition on the OS disk on standard Proxmox/Debian installs); the fail-safe default + covers anything that cannot be resolved. diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 729b787..735a432 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -43,7 +43,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.22.0" +var version = "0.23.0" func main() { var ( @@ -580,21 +580,33 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St return srv } -// storageGateAdapter bridges the local-API disk-format path to the slice-4 reversibility gate -// (8C): a data-bearing format is a ClassStorageWipe destructive op, so with no operator signature -// the gate returns pending_signature and the agent refuses. The signed completion is slice 10. +// storageGateAdapter bridges the local-API disk-format path to the reversibility gate, TIERED by the +// agent's authoritative device-role verdict: a USER-DATA data-bearing wipe is customer-confirmable +// (durable-id-bound, no signature); a SYSTEM/BACKUP wipe stays operator-signature (unsigned → +// pending_signature; the signed completion runs via the signed-jobs runner). type storageGateAdapter struct { gate *reconcile.Gate hostID string } -func (a storageGateAdapter) AuthorizeWipe(device string) (bool, string) { - params, _ := json.Marshal(map[string]string{"device": device, "op": "format"}) - dec := a.gate.Authorize( - reconcile.IntentForStorageDestructive(reconcile.ClassStorageWipe, a.hostID, device, params, reconcile.SourceOneShotJob), - nil, // no operator signature in 8C → pending_signature - ) - return dec.Allowed, string(dec.Reason) +func (a storageGateAdapter) AuthorizeWipe(req localapi.WipeRequest) localapi.WipeDecision { + dec := a.gate.AuthorizeStorageWipe(reconcile.StorageWipeAuthz{ + HostID: a.hostID, + Role: req.Role, + DeviceDurableID: req.DeviceDurableID, + Confirmed: req.Confirmed, + ConfirmDurableID: req.ConfirmDurableID, + }, nil) // no operator signature on the inline path → system/backup is pending_signature + tier := "destructive" + if dec.Disposition == reconcile.CustomerConfirmable { + tier = "customer_confirmable" + } + return localapi.WipeDecision{ + Allowed: dec.Allowed, + Tier: tier, + Reason: string(dec.Reason), + NeedsConfirmation: dec.Reason == reconcile.ReasonPendingConfirmation, + } } // reconcileJournalPath chooses the op-journal path: a `journal.log` sibling of the diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index 340bd41..4dfb0ef 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -27,10 +27,31 @@ type DiskOps interface { } // StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the -// slice-4 reversibility gate. Satisfied by an adapter over reconcile.Gate in main.go. In 8C an -// unsigned destructive op returns (false, "pending_signature"); the signed path is slice 10. +// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an +// adapter over reconcile.Gate in main.go. +// - user-data → customer-confirmable: allowed iff the request carries the customer's confirmation +// bound to the device's durable id (no operator signature). +// - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored). type StorageGate interface { - AuthorizeWipe(device string) (allowed bool, reason string) + AuthorizeWipe(req WipeRequest) WipeDecision +} + +// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are +// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed + +// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match. +type WipeRequest struct { + Role string // "system" | "backup" | "user-data" (agent-classified) + DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable) + Confirmed bool + ConfirmDurableID string +} + +// WipeDecision is the gate's verdict. +type WipeDecision struct { + Allowed bool + Tier string // "customer_confirmable" | "destructive" + Reason string // machine reason (audit/UI) + NeedsConfirmation bool // user-data, not-yet-confirmed → ask the customer (NOT a signature) } // GuestLister lists the host's guests (to map a mount to the guests that depend on it for the @@ -49,6 +70,11 @@ type DiskInfo struct { BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm) MountPath string `json:"mount_path"` Class string `json:"class"` // fast | slow | "" + // Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from + // the agent's own storage view + host topology — never from the controller. The controller drives + // the UI from it: system/backup get a lock badge and NO destructive controls; user-data is + // customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless. + Role string `json:"role"` DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint) DataReason string `json:"data_reason,omitempty"` // DurableID is the target's stable identity (e.g. "uuid:" for usb/local-dir). The @@ -68,12 +94,16 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) { writeErr(w, http.StatusBadGateway, "could not read storage view") return } + // Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative + // (the agent's own mount/topology read, never the caller's claim). + sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader()) out := make([]DiskInfo, 0, len(targets)) for _, t := range targets { di := DiskInfo{ Name: t.Name, Type: t.Type, State: t.State, BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint, DurableID: t.DurableID, + Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)), } // Inspect the backing device for the UI's data-bearing hint (the authoritative check // is re-run at format time on the actual device). @@ -159,7 +189,14 @@ type formatRequest struct { VMID int `json:"vmid"` Device string `json:"device"` FSType string `json:"fstype"` - // NOTE: any caller-supplied "blank"/"force" claim is deliberately IGNORED — the agent + // Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed- + // confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id + // and matches it against DurableID — a confirmation for one disk can't wipe another. Both are + // INERT for system/backup devices (those stay operator-signature only — the role is the agent's, + // never the caller's, so confirmed:true on a system device is refused). + Confirmed bool `json:"confirmed"` + DurableID string `json:"durable_id"` + // NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent // inspects the device itself (8C invariant). } @@ -174,7 +211,14 @@ type FormatResponse struct { Formatted bool `json:"formatted"` DataBearing bool `json:"data_bearing"` Reason string `json:"reason"` - // PendingOp is set on a data-bearing refusal — the exact op to sign (slice 10B). + // Role is the agent's authoritative tier for the device (system | backup | user-data). + Role string `json:"role,omitempty"` + // NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with + // confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator + // signature — the customer authorizes the wipe of their own data drive. + NeedsConfirmation bool `json:"needs_confirmation,omitempty"` + DurableID string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data) + // PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign. PendingOp *PendingOp `json:"pending_op,omitempty"` } @@ -217,44 +261,89 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err) // inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed) } - if probe.DataBearing() { - // Destructive: route through the gate. With no operator signature → pending_signature. - allowed, reason := s.diskGate.AuthorizeWipe(req.Device) - if !allowed { - // Surface the bound op the operator must sign (slice 10B): derive the DURABLE device id - // so the signed wipe binds to this exact physical disk (not the mutable path), and the - // runner can re-resolve it at execution. A durable-id derivation failure is non-fatal — - // the refusal still stands; we just can't pre-fill the durable id. - var pending *PendingOp - if durableID, derr := storage.DeviceDurableID(req.Device); derr == nil { - pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: durableID, FSType: req.FSType} - s.logger.Warn("local-api: data-bearing format refused — PENDING OPERATOR SIGNATURE", - "vmid", vmid, "device", req.Device, "durable_id", durableID, "fstype", req.FSType, - "why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+durableID) - } else { - s.logger.Warn("local-api: data-bearing format refused (no durable id)", - "vmid", vmid, "device", req.Device, "why", probe.Reason(), "derive_err", derr) - } - writeStatus(w, http.StatusForbidden, false, - FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason(), PendingOp: pending}, - "device is data-bearing — format requires an operator signature ("+reason+")") + if !probe.DataBearing() { + // Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy). + if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil { + s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err) + writeErr(w, http.StatusBadGateway, "format failed: "+err.Error()) return } - // A signed wipe is executed by the signed-jobs runner (queue → verify gate → durable - // re-resolve + re-inspect → mkfs), NOT this synchronous path. This branch (gate ALLOWED a - // data-bearing format inline) is unreachable: the inline path passes signed=nil → always - // pending. Fail safe. - writeErr(w, http.StatusForbidden, "data-bearing format must be completed via a signed job (felhom-opsign → hub queue)") + writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType}) return } - // Blank device → benign → mkfs. - if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil { - s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err) - writeErr(w, http.StatusBadGateway, "format failed: "+err.Error()) + // Data-bearing → TIER by the agent's authoritative role classification (its own inspection, + // never the caller's claim). The agent also re-resolves the device's durable id; the customer's + // confirmation must bind to it. + role := s.deviceRole(r.Context(), req.Device) + deviceDurable, derr := storage.DeviceDurableID(req.Device) + if derr != nil { + deviceDurable = "" // refusal still stands; binding/pending-op just lack the id + } + dec := s.diskGate.AuthorizeWipe(WipeRequest{ + Role: string(role), DeviceDurableID: deviceDurable, + Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID, + }) + + if dec.Allowed { + // USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it. Wipe. + if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil { + s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", req.Device, "err", err) + writeErr(w, http.StatusBadGateway, "format failed: "+err.Error()) + return + } + s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)", + "vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason()) + writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: true, + Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"}) return } - writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType}) + + if dec.Tier == "customer_confirmable" { + // USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't + // bind to THIS device. Surface the durable id to confirm against — NOT an operator signature. + msg := "device is data-bearing — customer confirmation required" + if !dec.NeedsConfirmation { + msg = "confirmation does not match this device — refused (" + dec.Reason + ")" + } + s.logger.Warn("local-api: user-data data-bearing format refused", + "vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason()) + writeStatus(w, http.StatusForbidden, false, + FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), + NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg) + return + } + + // SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable + // id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role. + var pending *PendingOp + if deviceDurable != "" { + pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType} + s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE", + "vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType, + "why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable) + } else { + s.logger.Warn("local-api: protected data-bearing format refused (no durable id)", + "vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason()) + } + writeStatus(w, http.StatusForbidden, false, + FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()}, + "device is system/backup-protected — format requires an operator signature ("+dec.Reason+")") +} + +// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's +// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification +// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity. +func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole { + sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader()) + if targets, err := s.storage.Observe(ctx); err == nil { + for _, t := range targets { + if t.BackingDevice != "" && t.BackingDevice == device { + return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown) + } + } + } + return storage.RoleForRawDevice(device, sysDisks, sysKnown) } // dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected diff --git a/internal/localapi/disks_test.go b/internal/localapi/disks_test.go index f5a0ceb..0ea21bf 100644 --- a/internal/localapi/disks_test.go +++ b/internal/localapi/disks_test.go @@ -52,14 +52,21 @@ func (f *fakeDiskOps) Unmount(_ context.Context, where string) error { func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) } type fakeGate struct { - allowed bool - reason string - calls []string + mu sync.Mutex + decision WipeDecision + reqs []WipeRequest } -func (g *fakeGate) AuthorizeWipe(device string) (bool, string) { - g.calls = append(g.calls, device) - return g.allowed, g.reason +func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision { + g.mu.Lock() + defer g.mu.Unlock() + g.reqs = append(g.reqs, req) + return g.decision +} +func (g *fakeGate) requests() []WipeRequest { + g.mu.Lock() + defer g.mu.Unlock() + return append([]WipeRequest(nil), g.reqs...) } type fakeGuestList struct{ guests []proxmox.Guest } @@ -96,7 +103,7 @@ func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl // A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted. func TestFormat_BlankDevice_Formats(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set - g := &fakeGate{allowed: false, reason: "pending_signature"} + g := &fakeGate{} h := newDiskServer(t, d, g, nil, nil) w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) @@ -106,39 +113,106 @@ func TestFormat_BlankDevice_Formats(t *testing.T) { if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" { t.Fatalf("mkfs not called for blank device: %v", got) } - if len(g.calls) != 0 { + if len(g.requests()) != 0 { t.Fatal("gate was consulted for a blank-device format (should be benign)") } } -// THE HEADLINE 8C TEST: a caller asks to format a DATA-BEARING device. The agent inspects the -// device itself, classifies it destructive, the gate refuses pending_signature, and **mkfs is -// NEVER called** — the caller's intent cannot wipe data-bearing storage. -func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) { +// THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as +// SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the +// response surfaces the operator-signature pending op (NOT a customer-confirmation prompt). +func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} - g := &fakeGate{allowed: false, reason: "pending_signature"} + g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}} h := newDiskServer(t, d, g, nil, nil) w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) if w.Code != http.StatusForbidden { - t.Fatalf("data-bearing format: got %d want 403", w.Code) + t.Fatalf("data-bearing protected format: got %d want 403", w.Code) } if got := d.formatted(); len(got) != 0 { - t.Fatalf("mkfs WAS called on a data-bearing device — security invariant violated: %v", got) + t.Fatalf("mkfs WAS called on a protected data-bearing device — security invariant violated: %v", got) } - if len(g.calls) != 1 || g.calls[0] != "/dev/sdb" { - t.Fatalf("gate not consulted for the destructive format: %v", g.calls) + if len(g.requests()) != 1 { + t.Fatalf("gate not consulted for the destructive format: %v", g.requests()) } if !strings.Contains(w.Body.String(), "operator signature") { t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String()) } } +// A hand-issued confirmed:true on a data-bearing device the gate tiers as SYSTEM/BACKUP is STILL +// refused (operator-signature) and mkfs is NOT called — role beats confirmation. (The gate's +// role-tiering is itself asserted in reconcile/gate_test; here we assert the handler honors a +// destructive verdict even when the caller claimed confirmed:true.) +func TestFormat_DataBearing_ConfirmedTrueButProtected_StillRefused(t *testing.T) { + d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} + g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}} + h := newDiskServer(t, d, g, nil, nil) + + w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byid:wwn-xyz"}`) + if w.Code != http.StatusForbidden { + t.Fatalf("confirmed-but-protected format: got %d want 403", w.Code) + } + if got := d.formatted(); len(got) != 0 { + t.Fatalf("mkfs called despite protected role — confirmation must not beat role: %v", got) + } + // The handler must have forwarded the caller's confirmation claim to the gate (the gate, not the + // handler, ignores it for protected roles). + if reqs := g.requests(); len(reqs) != 1 || !reqs[0].Confirmed || reqs[0].ConfirmDurableID != "byid:wwn-xyz" { + t.Fatalf("handler did not forward the confirmation claim to the gate: %+v", reqs) + } +} + +// USER-DATA + customer-confirmed (gate Allowed): the wipe proceeds — mkfs IS called, 200. +func TestFormat_DataBearing_UserDataConfirmed_Formats(t *testing.T) { + d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} + g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}} + h := newDiskServer(t, d, g, nil, nil) + + w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byuuid:abc"}`) + if w.Code != http.StatusOK { + t.Fatalf("user-data confirmed format: got %d want 200 (%s)", w.Code, w.Body.String()) + } + if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" { + t.Fatalf("mkfs not called for a customer-confirmed user-data wipe: %v", got) + } +} + +// USER-DATA + NOT yet confirmed (gate refuses pending_confirmation): 403 with needs_confirmation, +// mkfs NOT called, and NO operator-signature pending op (it's the customer's to confirm). +func TestFormat_DataBearing_UserDataNeedsConfirmation(t *testing.T) { + d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} + g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "customer_confirmable", Reason: "pending_confirmation", NeedsConfirmation: true}} + h := newDiskServer(t, d, g, nil, nil) + + w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) + if w.Code != http.StatusForbidden { + t.Fatalf("user-data unconfirmed: got %d want 403", w.Code) + } + if len(d.formatted()) != 0 { + t.Fatal("mkfs called for an unconfirmed user-data wipe") + } + var resp struct { + Data FormatResponse `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if !resp.Data.NeedsConfirmation { + t.Fatalf("response missing needs_confirmation: %s", w.Body.String()) + } + if resp.Data.PendingOp != nil { + t.Fatal("user-data refusal must NOT surface an operator-signature pending op") + } + if strings.Contains(w.Body.String(), "operator signature") { + t.Fatalf("user-data refusal must not ask for an operator signature: %s", w.Body.String()) + } +} + // Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as -// data-bearing → refused, mkfs not called. +// data-bearing → routed through the gate, mkfs not called. func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true - g := &fakeGate{allowed: false, reason: "pending_signature"} + g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}} h := newDiskServer(t, d, g, nil, nil) w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) @@ -150,21 +224,6 @@ func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) { } } -// Even a (hypothetical) gate that ALLOWS does not format a data-bearing device in 8C (the signed -// completion is slice 10). -func TestFormat_DataBearing_GateAllows_StillNoMkfsIn8C(t *testing.T) { - d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasPartitionTable: true}} - g := &fakeGate{allowed: true, reason: "signed"} - h := newDiskServer(t, d, g, nil, nil) - w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`) - if w.Code != http.StatusForbidden { - t.Fatalf("got %d want 403 (8C never formats data-bearing)", w.Code) - } - if len(d.formatted()) != 0 { - t.Fatal("mkfs called on a data-bearing device even though 8C must refuse") - } -} - func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} h := newDiskServer(t, d, &fakeGate{}, nil, nil) diff --git a/internal/reconcile/classify.go b/internal/reconcile/classify.go index 48335e3..9553e24 100644 --- a/internal/reconcile/classify.go +++ b/internal/reconcile/classify.go @@ -49,6 +49,23 @@ const ( Benign Disposition = "benign" // Destructive — an operator signature bound to the action is REQUIRED. Destructive Disposition = "destructive" + // CustomerConfirmable — a USER-DATA storage wipe: authorized by the customer's informed- + // confirmation bound to the device's durable id, NOT an operator signature. This tier is + // reachable ONLY for ClassStorageWipe on an agent-classified user-data device (see + // Gate.AuthorizeStorageWipe). A user-data drive is already within the in-guest controller's + // blast radius (it bind-mounts /mnt), so customer-confirmation adds no new reach — the data was + // already destroyable. Every OTHER destructive class keeps the operator signature. + CustomerConfirmable Disposition = "customer_confirmable" +) + +// Storage ROLE values — the protection tier the AGENT assigns a device by its own inspection +// (mirrors storage.DeviceRole). Kept as plain strings here to avoid a storage→reconcile import edge: +// the localapi adapter passes the agent's classification through verbatim. The gate NEVER derives the +// role from a caller's claim — it only consumes the agent's verdict. +const ( + StorageRoleSystem = "system" + StorageRoleBackup = "backup" + StorageRoleUserData = "user-data" ) // Provenance is AGENT-INTERNAL evidence that an otherwise-destructive action is diff --git a/internal/reconcile/gate.go b/internal/reconcile/gate.go index de214b5..a961d18 100644 --- a/internal/reconcile/gate.go +++ b/internal/reconcile/gate.go @@ -53,7 +53,11 @@ const ( ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature ReasonRejected RefuseReason = "rejected" // signature failed authz verification ReasonRoleDenied RefuseReason = "role_denied" // signer role not authorized for this op class - ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature is for a different action + ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature/confirmation is for a different action/device + + // Storage-wipe customer-confirmable tier (user-data only). + ReasonCustomerConfirmed RefuseReason = "customer_confirmed" // allowed by the customer's durable-id-bound confirmation + ReasonPendingConfirmation RefuseReason = "pending_confirmation" // user-data wipe awaiting the customer's confirmation ) // Decision is the gate verdict. @@ -94,6 +98,9 @@ type AuditRecord struct { Reason RefuseReason KeyID string // matched signer's key id, when signed Nonce string // the op nonce, when signed + // DurableID is the device's durable id on a customer-confirmable storage wipe — the load-bearing + // audit detail ("who/what/when + the durable id") so a customer-authorized wipe is attributable. + DurableID string } // Gate is the reversibility gate: it sits in front of the per-guest queue's executor @@ -172,6 +179,75 @@ func (g *Gate) Authorize(intent Intent, signed *SignedOp) Decision { return d } +// StorageWipeAuthz is the input to AuthorizeStorageWipe. Role is the agent's AUTHORITATIVE +// classification (StorageRole* in classify.go) — never a caller's claim. DeviceDurableID is the +// durable id the AGENT re-resolved for the device. Confirmed/ConfirmDurableID are the customer's +// informed-confirmation, honored ONLY for user-data and ONLY when ConfirmDurableID matches the agent's +// DeviceDurableID (a confirmation for one disk can't wipe another). +type StorageWipeAuthz struct { + HostID string + Role string // StorageRoleSystem | StorageRoleBackup | StorageRoleUserData + TargetID string // storage target identity for the operator-signed (system/backup) binding + DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable) + Confirmed bool + ConfirmDurableID string +} + +// AuthorizeStorageWipe tiers a data-bearing storage wipe by the agent's role verdict: +// +// - user-data → CUSTOMER-CONFIRMABLE: allowed iff the request carries an explicit customer +// confirmation BOUND to the device's durable id (the agent re-resolved DeviceDurableID and it +// matches ConfirmDurableID). No operator signature. The wipe is recorded in the customer-visible +// audit log with the durable id. +// - system / backup / anything else → the standard DESTRUCTIVE path (operator-signature / +// pending_signature). The Confirmed flag is DELIBERATELY IGNORED: no customer confirmation can +// wipe the appliance's system storage or the backup safety-net. A compromised controller +// asserting confirmed:true is refused here BY ROLE — role is the agent's, never the caller's. +// +// signed is the operator signature for the system/backup path (nil on the inline format path → always +// pending_signature; the signed completion runs via the signed-jobs runner). +func (g *Gate) AuthorizeStorageWipe(in StorageWipeAuthz, signed *SignedOp) Decision { + if in.Role == StorageRoleUserData { + if !in.Confirmed { + d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonPendingConfirmation} + g.recordWipe(in, d) + return d + } + // Durable-id binding: the confirmation must name THIS exact device (re-resolved by the agent). + // An unresolved/empty id fails safe (refuse), and a mismatch can't be retargeted to another disk. + if in.DeviceDurableID == "" || in.ConfirmDurableID == "" || in.ConfirmDurableID != in.DeviceDurableID { + d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonBindingMismatch} + g.recordWipe(in, d) + return d + } + d := Decision{Allowed: true, Disposition: CustomerConfirmable, Reason: ReasonCustomerConfirmed} + g.recordWipe(in, d) + return d + } + // system / backup / unknown → operator-signature path. Confirmed is NOT consulted. + intent := IntentForStorageDestructive(ClassStorageWipe, in.HostID, in.TargetID, nil, SourceOneShotJob) + return g.Authorize(intent, signed) +} + +// recordWipe audits a customer-confirmable storage-wipe decision (allowed or refused) with the +// device's durable id — the customer-visible "who/what/when + durable id" record. +func (g *Gate) recordWipe(in StorageWipeAuthz, d Decision) { + g.audit.Record(AuditRecord{ + Time: time.Now().UTC(), + Class: ClassStorageWipe, + HostID: in.HostID, + GuestID: in.TargetID, + Source: SourceOneShotJob, + Disposition: d.Disposition, + Allowed: d.Allowed, + Reason: d.Reason, + DurableID: in.DeviceDurableID, + }) + g.logger.Info("gate decision (storage wipe)", + "role", in.Role, "disposition", d.Disposition, "allowed", d.Allowed, + "reason", d.Reason, "durable_id", in.DeviceDurableID) +} + // roleAuthorizes enforces the doc 04 §4 two-key role model: the cold recovery key // authorizes ONLY key-rotation re-pins; the operational key authorizes ordinary // destructive ops AND planned key-rotation. @@ -278,7 +354,7 @@ func (s SlogAudit) Record(rec AuditRecord) { s.Logger.Info("audit: gate decision", "class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source, "disposition", rec.Disposition, "allowed", rec.Allowed, "reason", rec.Reason, - "key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce)) + "key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce), "durable_id", rec.DurableID) } // auditNonce shortens a nonce for the log (full nonce is high-cardinality; a prefix is diff --git a/internal/reconcile/storage_wipe_test.go b/internal/reconcile/storage_wipe_test.go new file mode 100644 index 0000000..ef0d853 --- /dev/null +++ b/internal/reconcile/storage_wipe_test.go @@ -0,0 +1,95 @@ +package reconcile + +import "testing" + +// The storage-authorization redesign: a USER-DATA data-bearing wipe is customer-confirmable +// (durable-id-bound, no operator signature); SYSTEM and BACKUP stay operator-signature ONLY, and a +// `confirmed:true` claim on them is REFUSED by role (a compromised controller can't relabel a +// protected device to walk the gate). These assert the gate's tiering — the non-hollow checks the +// spec calls for: "the gate refuses a `confirmed` wipe on system/backup (assert no exec); a user-data +// confirmed wipe binds to the durable id (a mismatched id is refused)". + +const durA = "byid:wwn-0x5000c500a" +const durB = "byuuid:1111-2222" + +// user-data + confirmed + matching durable id → allowed (customer-confirmable), audited with the id. +func TestStorageWipe_UserDataConfirmed_Allowed(t *testing.T) { + aud := &captureAudit{} + g := NewGate(nil, testHost, aud, nil) // NO verifier pinned — proves no operator signature is needed + d := g.AuthorizeStorageWipe(StorageWipeAuthz{ + HostID: testHost, Role: StorageRoleUserData, + DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durA, + }, nil) + if !d.Allowed || d.Disposition != CustomerConfirmable || d.Reason != ReasonCustomerConfirmed { + t.Fatalf("user-data confirmed+matching: got allowed=%v disp=%s reason=%s", d.Allowed, d.Disposition, d.Reason) + } + if len(aud.recs) != 1 || !aud.recs[0].Allowed || aud.recs[0].DurableID != durA { + t.Fatalf("customer-confirmed wipe must be audited with the durable id: %+v", aud.recs) + } +} + +// user-data + confirmed but the confirmation binds to a DIFFERENT disk's durable id → refused +// (binding_mismatch). A confirmation for one disk can't wipe another. +func TestStorageWipe_UserDataDurableMismatch_Refused(t *testing.T) { + g := NewGate(nil, testHost, &captureAudit{}, nil) + d := g.AuthorizeStorageWipe(StorageWipeAuthz{ + HostID: testHost, Role: StorageRoleUserData, + DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durB, // confirms B, device is A + }, nil) + if d.Allowed || d.Reason != ReasonBindingMismatch { + t.Fatalf("durable-id mismatch: got allowed=%v reason=%s, want refused binding_mismatch", d.Allowed, d.Reason) + } +} + +// user-data + an UNRESOLVABLE device durable id fails safe (refused) even when confirmed. +func TestStorageWipe_UserDataNoDurable_Refused(t *testing.T) { + g := NewGate(nil, testHost, &captureAudit{}, nil) + d := g.AuthorizeStorageWipe(StorageWipeAuthz{ + HostID: testHost, Role: StorageRoleUserData, + DeviceDurableID: "", Confirmed: true, ConfirmDurableID: "", + }, nil) + if d.Allowed { + t.Fatal("a wipe with no resolvable durable id must not be allowed even when confirmed") + } +} + +// user-data, NOT confirmed → pending_confirmation (ask the customer; not a signature). +func TestStorageWipe_UserDataUnconfirmed_PendingConfirmation(t *testing.T) { + g := NewGate(nil, testHost, &captureAudit{}, nil) + d := g.AuthorizeStorageWipe(StorageWipeAuthz{ + HostID: testHost, Role: StorageRoleUserData, DeviceDurableID: durA, + }, nil) + if d.Allowed || d.Disposition != CustomerConfirmable || d.Reason != ReasonPendingConfirmation { + t.Fatalf("user-data unconfirmed: got allowed=%v disp=%s reason=%s", d.Allowed, d.Disposition, d.Reason) + } +} + +// THE HEADLINE: a `confirmed:true` wipe on a SYSTEM device is REFUSED — it falls to the +// operator-signature path (no signer pinned → pending_signature), NOT customer-confirmable. The +// customer's confirmation is ignored BY ROLE. Same for BACKUP. +func TestStorageWipe_SystemConfirmedTrue_RefusedBySignature(t *testing.T) { + for _, role := range []string{StorageRoleSystem, StorageRoleBackup} { + g := NewGate(nil, testHost, &captureAudit{}, nil) + d := g.AuthorizeStorageWipe(StorageWipeAuthz{ + HostID: testHost, Role: role, + DeviceDurableID: durA, Confirmed: true, ConfirmDurableID: durA, // a matching confirmation — must NOT help + }, nil) + if d.Allowed { + t.Fatalf("role=%s: a confirmed wipe of a protected device was ALLOWED — invariant violated", role) + } + if d.Disposition != Destructive || d.Reason != ReasonPendingSignature { + t.Fatalf("role=%s: got disp=%s reason=%s, want destructive/pending_signature", role, d.Disposition, d.Reason) + } + } +} + +// An UNKNOWN/empty role fails safe to the protected (operator-signature) path, never user-data. +func TestStorageWipe_UnknownRole_FailsSafeDestructive(t *testing.T) { + g := NewGate(nil, testHost, &captureAudit{}, nil) + d := g.AuthorizeStorageWipe(StorageWipeAuthz{ + HostID: testHost, Role: "", Confirmed: true, DeviceDurableID: durA, ConfirmDurableID: durA, + }, nil) + if d.Allowed || d.Disposition != Destructive { + t.Fatalf("unknown role must fail safe to destructive: got allowed=%v disp=%s", d.Allowed, d.Disposition) + } +} diff --git a/internal/storage/role.go b/internal/storage/role.go new file mode 100644 index 0000000..8bef5af --- /dev/null +++ b/internal/storage/role.go @@ -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 +} diff --git a/internal/storage/role_test.go b/internal/storage/role_test.go new file mode 100644 index 0000000..b2ac22c --- /dev/null +++ b/internal/storage/role_test.go @@ -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" }