v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:44:50 +02:00
parent 2c32c821fe
commit 12064dcd88
13 changed files with 696 additions and 182 deletions
+120 -9
View File
@@ -25,7 +25,7 @@ import (
// without a live agent). *agentapi.Client satisfies it.
type diskAgent interface {
Disks(ctx context.Context) (agentapi.DisksResponse, error)
FormatDisk(ctx context.Context, device, fstype string) (agentapi.FormatResult, error)
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
}
@@ -60,21 +60,33 @@ func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
type storageInitResult struct {
Registered bool `json:"registered"`
Where string `json:"where,omitempty"`
// Refusal (data-bearing): the operator must sign offline. No bypass.
// NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm),
// then the wizard re-submits with confirmed=true. NOT an operator signature.
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
Role string `json:"role,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// Refusal (system/backup data-bearing): the operator must sign offline. No bypass.
Refused bool `json:"refused,omitempty"`
Reason string `json:"reason,omitempty"`
Opsign string `json:"opsign,omitempty"`
}
// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID →
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs
// NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
// either refusal it performs NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
if !validFSTypes[fstype] {
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
}
// 1. Format — the AGENT inspects the device and decides. A data-bearing device is refused.
fr, err := agent.FormatDisk(ctx, device, fstype)
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
// an operator signature.
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil
}
if errors.Is(err, agentapi.ErrFormatRefused) {
res := storageInitResult{Refused: true, Reason: fr.Reason}
if fr.PendingOp != nil {
@@ -169,6 +181,10 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.handleStorageAttach(w, r)
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
s.handleStorageEject(w, r)
case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost:
s.handleStorageWipe(w, r)
case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet:
s.handleStorageImpact(w, r)
default:
http.NotFound(w, r)
}
@@ -180,6 +196,10 @@ type storageProvReq struct {
MountName string `json:"mount_name"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
// Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing
// wipe (the durable id the agent returned on the prior NeedsConfirmation response).
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
}
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
@@ -202,11 +222,15 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
if res.NeedsConfirmation {
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
return
}
if res.Refused {
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
return
@@ -214,6 +238,93 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusOK, true, "", res)
}
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI.
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
apps := s.appsUsingPath(where)
if apps == nil {
apps = []string{}
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps})
}
// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
// deregisters + frees the device) then formats with the customer's confirmation bound to the device's
// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup
// device is refused by the agent regardless of what the controller sends. The mount name must be typed
// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side).
func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
Where string `json:"where"`
MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where)
FSType string `json:"fstype"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
return
}
fstype := req.FSType
if !validFSTypes[fstype] {
fstype = "ext4"
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath.
if _, eerr := agent.EjectDisk(r.Context(), req.Where); eerr != nil {
s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr)
} else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr)
} else {
go s.SyncFileBrowserMounts()
}
// 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then
// re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
if errors.Is(perr, agentapi.ErrFormatRefused) {
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
return
}
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
if perr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
return
}
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true})
return
}
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
if ferr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
}
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {