agent v0.24.0: role-gate the eject path (system/backup mounts unmount-protected at the agent)

handleDiskEject now resolves the authoritative role of the storage at `where`
and refuses 403 (no Unmount) unless it is user-data. Fails safe to protected on
ambiguity. Adds roleForMountPath + an injectable HostReader seam for testability.
TestEject_RoleGated asserts protected mounts are refused with no Unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:29:09 +02:00
parent 832b73e6e8
commit 7ae82e1d5d
5 changed files with 153 additions and 9 deletions
+41 -2
View File
@@ -100,7 +100,7 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
}
// 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())
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
out := make([]DiskInfo, 0, len(targets))
for _, t := range targets {
di := DiskInfo{
@@ -183,6 +183,17 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
return
}
// ROLE GATE (defense in depth): eject is permitted ONLY for a user-data mount. The agent
// classifies the role of the storage at `where` from its OWN view — never the caller's claim —
// and refuses system/backup. The UI hiding the button is NOT the control: a direct API call (or a
// compromised controller) trying to unmount /var/lib/vz or the PBS mount is refused here. Fails
// SAFE: an unresolvable mount → protected → refused (most-protected-on-ambiguity, like the wipe).
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
s.logger.Warn("local-api: protected — eject refused by role",
"vmid", vmid, "where", req.Where, "role", role)
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — eject refused (role: "+string(role)+")")
return
}
dependents := s.dependentGuests(r.Context(), req.Where)
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
s.logger.Error("local-api: disk eject", "vmid", vmid, "where", req.Where, "err", err)
@@ -338,11 +349,39 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}
// hostReader returns the injected root-free host topology reader, or the production default. The seam
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
func (s *Server) hostReader() storage.HostReader {
if s.host != nil {
return s.host
}
return storage.NewProcHostReader()
}
// roleForMountPath resolves the AUTHORITATIVE protection role of the storage mounted at `where`, from
// the agent's OWN storage view + host topology (never the caller's claim). It mirrors deviceRole but
// keys on the mount path (the eject input). It FAILS SAFE to system (most-protected) on any
// ambiguity — a view error, or no storage target found at `where` — so an unresolvable eject is
// refused rather than silently unmounted.
func (s *Server) roleForMountPath(ctx context.Context, where string) storage.DeviceRole {
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
targets, err := s.storage.Observe(ctx)
if err != nil {
return storage.RoleSystem // can't read the view → treat as protected
}
for _, t := range targets {
if t.MountPath == where {
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
}
}
return storage.RoleSystem // no storage target at this mount → fail safe to protected
}
// 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())
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
if targets, err := s.storage.Observe(ctx); err == nil {
for _, t := range targets {
if t.BackingDevice != "" && t.BackingDevice == device {
+80 -3
View File
@@ -73,6 +73,22 @@ type fakeGuestList struct{ guests []proxmox.Guest }
func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.guests, nil }
// fakeHostReader is a deterministic HostReader fixture for the role classifier: "/" lives on the
// whole disk /dev/sda, so SystemDisks resolves {/dev/sda} (sysKnown=true) and any storage backed by
// a different whole-disk (e.g. /dev/sdb1) classifies as user-data without touching the real host.
type fakeHostReader struct{ mounts []storage.Mount }
func (f fakeHostReader) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
func (f fakeHostReader) ResolveUUID(string) (string, bool) { return "", false }
func (f fakeHostReader) DeviceExists(string) bool { return true }
func (f fakeHostReader) Rotational(string) (bool, bool) { return false, false }
func (f fakeHostReader) Removable(string) (bool, bool) { return false, false }
// sysOnSDA is the default system-disk fixture (root on /dev/sda) used by the disk-server test helpers.
func sysOnSDA() fakeHostReader {
return fakeHostReader{mounts: []storage.Mount{{Device: "/dev/sda1", MountPoint: "/"}}}
}
// newDiskServer builds a server wired with the 8C disk deps (token A → guest 8200).
func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl GuestLister) http.Handler {
t.Helper()
@@ -89,6 +105,7 @@ func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl
Disks: d,
DiskGate: g,
Guests2: gl,
HostReader: sysOnSDA(),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
@@ -256,8 +273,9 @@ func TestAssign_EnsureMount(t *testing.T) {
func TestEject_UnmountAndDependents(t *testing.T) {
d := &fakeDiskOps{}
// storage view: target "bulk" is mounted at /mnt/bulk
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", MountPath: "/mnt/bulk"}}}
// storage view: USER-DATA target "bulk" (usb on a non-system disk) mounted at /mnt/bulk — the
// role-gate permits ejecting it.
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk"}}}
// guest 8200 mounts storage "bulk"; guest 9300 does not
gl := fakeGuestList{guests: []proxmox.Guest{{VMID: 8200}, {VMID: 9300}}}
h := newDiskServerWithGuestConfigs(t, d, sv, gl, map[int]map[string]string{
@@ -295,6 +313,65 @@ func TestEject_UnmountAndDependents(t *testing.T) {
}
}
// A2 (security): the eject path is ROLE-GATED at the agent. A system mount (local dir on the OS disk)
// and a backup mount (PBS) are REFUSED 403 with NO Unmount; only a user-data mount ejects. The UI
// hiding the button is not the control — a direct API call to unmount a protected storage is refused.
func TestEject_RoleGated(t *testing.T) {
sv := fakeStorage{targets: []hub.StorageTarget{
{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk"}, // user-data
{Name: "local", Type: "local", MountPath: "/var/lib/vz"}, // system (builtin dir)
{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"}, // backup (PBS)
}}
// system mount → refused, no Unmount.
d := &fakeDiskOps{}
h := newDiskServer(t, d, &fakeGate{}, sv, nil)
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/var/lib/vz"}`); w.Code != http.StatusForbidden {
t.Fatalf("eject system mount: got %d want 403 (%s)", w.Code, w.Body.String())
}
d.mu.Lock()
if len(d.unmountCalls) != 0 {
t.Fatalf("Unmount called on a system mount — role-gate bypassed: %v", d.unmountCalls)
}
d.mu.Unlock()
// backup mount (PBS) → refused, no Unmount.
d2 := &fakeDiskOps{}
h2 := newDiskServer(t, d2, &fakeGate{}, sv, nil)
if w := do(t, h2, "POST", "/disks/eject", "A", `{"where":"/mnt/pbs"}`); w.Code != http.StatusForbidden {
t.Fatalf("eject backup mount: got %d want 403 (%s)", w.Code, w.Body.String())
}
d2.mu.Lock()
if len(d2.unmountCalls) != 0 {
t.Fatalf("Unmount called on a backup mount — role-gate bypassed: %v", d2.unmountCalls)
}
d2.mu.Unlock()
// user-data mount → ejects (Unmount called once).
d3 := &fakeDiskOps{}
h3 := newDiskServer(t, d3, &fakeGate{}, sv, nil)
if w := do(t, h3, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`); w.Code != http.StatusOK {
t.Fatalf("eject user-data mount: got %d want 200 (%s)", w.Code, w.Body.String())
}
d3.mu.Lock()
if len(d3.unmountCalls) != 1 || d3.unmountCalls[0] != "/mnt/bulk" {
t.Fatalf("user-data eject did not Unmount /mnt/bulk: %v", d3.unmountCalls)
}
d3.mu.Unlock()
// fail-safe: an unknown mount (no storage target) → refused, no Unmount.
d4 := &fakeDiskOps{}
h4 := newDiskServer(t, d4, &fakeGate{}, sv, nil)
if w := do(t, h4, "POST", "/disks/eject", "A", `{"where":"/mnt/unknown"}`); w.Code != http.StatusForbidden {
t.Fatalf("eject unresolvable mount: got %d want 403 (fail-safe) (%s)", w.Code, w.Body.String())
}
d4.mu.Lock()
if len(d4.unmountCalls) != 0 {
t.Fatalf("Unmount called on an unresolvable mount — fail-safe violated: %v", d4.unmountCalls)
}
d4.mu.Unlock()
}
// ---- auth / config ----------------------------------------------------------------------
func TestDisks_CrossGuest403(t *testing.T) {
@@ -334,7 +411,7 @@ func newDiskServerWithGuestConfigs(t *testing.T, d *fakeDiskOps, sv StorageView,
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300},
Disks: d, DiskGate: &fakeGate{}, Guests2: gl,
Disks: d, DiskGate: &fakeGate{}, Guests2: gl, HostReader: sysOnSDA(),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
+10 -3
View File
@@ -16,6 +16,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// GuestAPI is the narrow Proxmox surface the local API needs. Satisfied by *proxmox.Client.
@@ -80,6 +81,10 @@ type Options struct {
Disks DiskOps
DiskGate StorageGate
Guests2 GuestLister
// HostReader is the root-free host topology reader used to classify a device/mount's protection
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
HostReader storage.HostReader
// HostMetrics serves GET /host/metrics (slice 9) — host-wide health (cpu%/mem/load/uptime/
// cpu-temp) + per-storage capacity, host-wide and token-authed (one-customer-per-host). When
// nil the endpoint reports "not configured" (host still reports/reconciles).
@@ -127,9 +132,10 @@ type Server struct {
logger *slog.Logger
now func() time.Time
disks DiskOps // slice 8C (optional)
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
disks DiskOps // slice 8C (optional)
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
hostMetrics HostMetricsProvider // slice 9 (optional)
hostID string // slice 10B: for the data-bearing-format pending-op hint
@@ -169,6 +175,7 @@ func NewServer(o Options) (*Server, error) {
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
host: o.HostReader,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
jobs: map[int]*backupJob{},