package web import ( "context" "encoding/json" "errors" "fmt" "net/http" "path" "regexp" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // 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) } // mountNameRe is the safe `/mnt/` 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/". 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 } return storageInitResult{Registered: true, Where: where}, nil } // 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 } return storageInitResult{Registered: true, Where: where}, nil } // 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) 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{} } 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 { 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) }