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 {