Files
felhom-controller/controller/internal/web/storage_handlers.go
T
admin 4913130514 controller v0.50.0: slice 10 P4 — dual-role drives + backup-aware wipe warning
4A: user-data drives are backup-target-eligible (not role-locked) — surfaced in
the drive purpose note. 4B: handleStorageImpact returns backup_copies (apps whose
cross-drive backups live on the drive, via backupCopiesOnPath); the wipe/eject
modal warns they'd be destroyed (stays customer-confirmable — copies redundant).
Cross-drive backup engine remains out of scope. Test: TestBackupCopiesOnPath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:00:27 +02:00

536 lines
23 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin
// orchestrator over the agent's authoritative disk endpoints (format/assign/eject, all data-bearing-
// gated on the agent) + the local StoragePath registry. It holds NO destructive authority: a data-
// bearing format is REFUSED by the agent, and the only thing the controller does with that refusal is
// surface the exact `felhom-opsign` command — there is no force-format path here.
// diskAgent is the subset of *agentapi.Client the orchestration needs (an interface so it's testable
// 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, 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)
GuestAttach(ctx context.Context, where string) error
}
// mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -).
var mountNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,40}$`)
// validFSTypes are the filesystems the init flow offers (the agent re-validates).
var validFSTypes = map[string]bool{"ext4": true, "xfs": true}
// mountWhere builds + validates the mount target from a user-supplied name → "/mnt/<name>".
func mountWhere(name string) (string, error) {
name = strings.TrimSpace(name)
if !mountNameRe.MatchString(name) {
return "", fmt.Errorf("érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)")
}
return "/mnt/" + name, nil
}
// fsUUIDForDevice re-lists the agent's disks and returns the fs UUID of the storage backed by `device`
// (the only way the de-privileged controller learns the UUID it must pass to assign). "" if not found.
func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
for _, d := range disks.Disks {
if d.BackingDevice == device {
return d.FSUUID()
}
}
return ""
}
// storageInitResult is the outcome of an init attempt (JSON-rendered to the wizard).
type storageInitResult struct {
Registered bool `json:"registered"`
Where string `json:"where,omitempty"`
// 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 → (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 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 {
res.Opsign = fr.PendingOp.OpsignCommand()
}
return res, nil // STOP — no bypass; the UI surfaces Opsign.
}
if err != nil {
return storageInitResult{}, fmt.Errorf("formázás sikertelen: %w", err)
}
if !fr.Formatted {
return storageInitResult{}, fmt.Errorf("az eszköz nem lett megformázva (%s)", fr.Reason)
}
// 2. Resolve the NEW fs UUID (re-list disks; the device's storage now carries a fresh UUID).
disks, err := agent.Disks(ctx)
if err != nil {
return storageInitResult{}, fmt.Errorf("formázás kész, de a meghajtólista nem olvasható: %w", err)
}
uuid := fsUUIDForDevice(disks, device)
if uuid == "" {
return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót")
}
// 3. Mount (benign assign) + 4. register.
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
}
if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err
}
s.attachIntoGuest(ctx, agent, where)
return storageInitResult{Registered: true, Where: where}, nil
}
// attachIntoGuest passes an enrolled drive INTO the guest (slice 10 P2) so the controller + apps can
// use it. Best-effort: the StoragePath registration is the durable intent, so a transient attach
// failure is logged (not fatal) — P3 self-heal reconcile will complete it on the next tick.
func (s *Server) attachIntoGuest(ctx context.Context, agent diskAgent, where string) {
if err := agent.GuestAttach(ctx, where); err != nil {
s.logger.Printf("[WARN] [web] enroll: guest-attach %s failed (registered; will be retried): %v", where, err)
return
}
s.logger.Printf("[INFO] [web] enroll: drive bound into guest: %s", where)
}
// runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate)
// and registers it. The UUID is resolved server-side from the device.
func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
disks, err := agent.Disks(ctx)
if err != nil {
return storageInitResult{}, fmt.Errorf("a meghajtólista nem olvasható: %w", err)
}
uuid := fsUUIDForDevice(disks, device)
if uuid == "" {
return storageInitResult{}, fmt.Errorf("a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)")
}
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
}
if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err
}
s.attachIntoGuest(ctx, agent, where)
return storageInitResult{Registered: true, Where: where}, nil
}
// pendingActivationDrives returns registered storage paths that are NOT yet live-mounted in this
// container but whose backing drive the agent reports present+attached — enrolled drives waiting for
// the guest restart that activates their bind (slice 10 P2; the host-side live inject is blocked on an
// unprivileged guest). The customer activates them with the "Újraindítás most" button (one restart
// batches all). Best-effort: agent unreachable → none.
func (s *Server) pendingActivationDrives() []string {
paths := s.settings.GetStoragePaths()
if len(paths) == 0 {
return nil
}
agent, err := s.agentClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := agent.Disks(ctx)
if err != nil {
return nil
}
attached := map[string]bool{}
for _, d := range resp.Disks {
if d.MountPath != "" && d.State == "attached" {
attached[d.MountPath] = true
}
}
var pending []string
for _, sp := range paths {
if sp.Decommissioned {
continue
}
if attached[sp.Path] && !system.IsMountPoint(sp.Path) {
pending = append(pending, sp.Path)
}
}
return pending
}
// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
// default) and refreshes the FileBrowser mounts so it's usable immediately.
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
if strings.TrimSpace(label) == "" {
label = settings.InferStorageLabel(where)
}
sp := settings.StoragePath{
Path: where,
Label: label,
IsDefault: setDefault,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.settings.AddStoragePath(sp); err != nil {
return fmt.Errorf("regisztráció sikertelen: %w", err)
}
go s.SyncFileBrowserMounts()
return nil
}
// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------
// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
// client-side from GET /api/disks; the form posts to /api/storage/{init,attach}).
func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request, tmpl string) {
title := "Új meghajtó inicializálása"
if tmpl == "storage_attach" {
title = "Meglévő meghajtó csatolása"
}
data := s.baseData(tmpl, title)
s.render(w, tmpl, data)
}
// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
s.handleStorageInit(w, r)
case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost:
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)
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
s.handleStorageRegister(w, r)
case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost:
s.handleStorageActivate(w, r)
default:
http.NotFound(w, r)
}
}
type storageProvReq struct {
Device string `json:"device"`
FSType string `json:"fstype"`
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) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
where, err := mountWhere(req.MountName)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
agent, err := s.agentClient()
if err != nil {
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, 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
}
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{}
}
// P4 (4B): a user-data drive is ALSO backup-target-eligible — it may hold cross-drive backup copies
// of OTHER drives' app data. A wipe destroys those copies too, so name them in the confirmation.
// (The copies are redundant — the originals live on the source drive — so the wipe stays customer-
// confirmable, NOT operator-signature; the warning just makes the loss explicit.)
backupCopies := backupCopiesOnPath(where)
if backupCopies == nil {
backupCopies = []string{}
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"where": where, "apps": apps, "backup_copies": backupCopies,
})
}
// backupCopiesOnPath lists the apps whose CROSS-DRIVE (secondary) backup copies are stored on the
// drive mounted at `where` (slice 10 P4) — the felhom-data/backups/secondary/<app> dirs. A wipe of
// this drive removes these copies. Best-effort filesystem scan; empty until the cross-drive backup
// ENGINE (a follow-on slice) actually writes here. Shared/aggregate dirs (restic repo, _infra) are
// not apps and are skipped.
func backupCopiesOnPath(where string) []string {
secondary := filepath.Join(where, appbackup.FelhomDataDir, "backups", "secondary")
entries, err := os.ReadDir(secondary)
if err != nil {
return nil // no secondary backups here (or the path isn't readable) — nothing to warn about
}
var apps []string
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "restic" || name == "_infra" { // shared repo / infra, not a per-app copy
continue
}
apps = append(apps, name)
}
return 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})
}
// handleStorageActivate reboots the guest to activate pending drive binds (slice 10 P2). The agent
// reboots detached + returns 202; this controller restarts with the guest, so the caller's response
// may be cut short — the UI handles that and reloads after the restart window.
func (s *Server) handleStorageActivate(w http.ResponseWriter, r *http.Request) {
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
if err := agent.GuestReboot(r.Context()); err != nil {
s.logger.Printf("[ERROR] [web] guest reboot (activate pending drives) failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
s.logger.Printf("[WARN] [web] guest restart requested to activate pending drive binds")
writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"rebooting": true})
}
// handleStorageRegister records an ALREADY-mounted, unregistered user-data drive into the StoragePath
// registry — no format, no eject. It is the natural primary action for a mounted-but-unregistered data
// drive (e.g. felhom-usb): the customer's intent is to USE the existing data, not wipe it. It reuses
// registerStoragePath (the manual-add path) — AddStoragePath dedupes, so a double-register is a clean
// error, not a duplicate.
func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Where string `json:"where"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", 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
}
if err := s.registerStoragePath(req.Where, req.Label, req.SetDefault); err != nil {
s.logger.Printf("[WARN] [web] storage register %s failed: %v", req.Where, err)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s", req.Where)
// Pass the drive into the guest too (slice 10 P2) — registering a host-only mount otherwise leaves
// it guest-invisible (the exact gap that produced the "nem elérhető" banner).
if agent, aerr := s.agentClient(); aerr == nil {
s.attachIntoGuest(r.Context(), agent, req.Where)
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": req.Where})
}
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
where, err := mountWhere(req.MountName)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := s.runStorageAttach(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}
// handleStorageEject unmounts a host mount (benign, data preserved) and DEREGISTERS its StoragePath.
// It surfaces the agent's dependent-guest warning. (Distinct from the signature-gated decommission.)
func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
var req struct {
Where string `json:"where"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", 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
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := agent.EjectDisk(r.Context(), req.Where)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
// Deregister the path (best-effort — the unmount already succeeded).
if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr)
} else {
go s.SyncFileBrowserMounts()
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}