package web import ( "context" "encoding/json" "errors" "net/http" "path" "sort" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" ) // Agent-backed disk management (slice 8C, Phase B.2). // // Disk EXECUTION (scan/format/mount/migrate) lives in the host agent now; the // controller is Docker-only and holds no Proxmox/disk credentials. These handlers // are THIN proxies: they build an agentapi.Client from cfg.LocalAPI and forward // list/assign/eject/format to the agent's GET/POST /disks endpoints, returning the // agent's view as JSON. A data-bearing format is refused by the agent (operator // authorization required) and surfaced here as HTTP 409. // ServeDiskAPI dispatches /api/disks and /api/disks/* routes. // Wired in main.go behind RequireAuth + CsrfProtect. func (s *Server) ServeDiskAPI(w http.ResponseWriter, r *http.Request) { if s.isDebug() { s.logger.Printf("[DEBUG] [web] ServeDiskAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) } switch { case r.URL.Path == "/api/disks" && r.Method == http.MethodGet: s.agentDisksListHandler(w, r) case r.URL.Path == "/api/disks/candidates" && r.Method == http.MethodGet: s.agentDiskCandidatesHandler(w, r) case r.URL.Path == "/api/disks/assign" && r.Method == http.MethodPost: s.agentDiskAssignHandler(w, r) case r.URL.Path == "/api/disks/eject" && r.Method == http.MethodPost: s.agentDiskEjectHandler(w, r) case r.URL.Path == "/api/disks/format" && r.Method == http.MethodPost: s.agentDiskFormatHandler(w, r) default: http.NotFound(w, r) } } // agentClient returns the shared pinned client for the host agent's per-guest local API. // Returns a clear error if the local API is not configured (unprovisioned guest). // // The client is built ONCE and reused (cfg.LocalAPI is static per process — a config-apply triggers a // graceful self-restart). Reusing one *agentapi.Client (one pooled http.Transport) eliminates the // per-call Transport leak that accumulated idle ESTABLISHED sockets to the agent's :8443 and exhausted // the ephemeral source-port range after ~5 days of uptime. *agentapi.Client/*http.Client are safe for // concurrent use, so the (19) callers need no extra locking. The empty-endpoint guard stays OUTSIDE the // Once so it is re-checked each call (defensive; the endpoint is static); only a real build result // (client, or a construction error like a bad fingerprint — neither changes without a restart) is memoized. func (s *Server) agentClient() (*agentapi.Client, error) { if s.cfg.LocalAPI.Endpoint == "" { return nil, errors.New("agent not configured") } s.agentCliOnce.Do(func() { s.agentCli, s.agentCliErr = agentapi.New( s.cfg.LocalAPI.Endpoint, s.cfg.LocalAPI.Token, s.cfg.LocalAPI.Fingerprint) if s.agentCliErr == nil { // v0.116.0: per-call DEBUG traces into the capture ring (method/path/status/duration). s.agentCli.SetLogger(s.logger) } }) return s.agentCli, s.agentCliErr } // ProbeAgentChannel runs one controller→agent channel health probe using the PRODUCTION memoized // client (NOT a fresh one — spike SPIKE-controller-agent-channel-health-2026-06-29: it self-heals, // reflects exactly what the disk UI sees, and avoids the per-call transport leak the singleton fixed). // It returns whether the failure was a CONSTRUCTION error (agentClient() couldn't build — a latching // config fault, distinct from a runtime channel failure) and the error (nil = channel up). The probe // is GET /storage (cheap, read-only — the same call probeLocalAPI uses at startup). This is the // channelhealth.Probe seam. func (s *Server) ProbeAgentChannel(ctx context.Context) (constructionErr bool, err error) { client, cerr := s.agentClient() if cerr != nil { return true, cerr } _, serr := client.Storage(ctx) return false, serr } // writeDiskJSON writes the standard {ok,data,error} envelope used by the disk API. func writeDiskJSON(w http.ResponseWriter, status int, ok bool, errMsg string, data interface{}) { // Cloudflare replaces origin 502/504 bodies with its OWN HTML error page (no passthru on // this plan) — the page JS then fails to parse JSON and the customer sees a SyntaxError // alert instead of the Hungarian message (live-hit 2026-07-13: the M1 decommission refusal // surfaced as " 0 { c.Partitions, c.WholeDiskInUse = free, true out = append(out, c) } continue } out = append(out, c) } return out } // Formatting a part needs a disk with nothing mounted (the agent's Formattable — its root wrapper refuses otherwise); // USING a part with a filesystem erases nothing and stays offered. resp.Initialize = keep(resp.Initialize, func(p agentapi.CandidatePartition) bool { return p.Formattable }) resp.Attach = keep(resp.Attach, func(p agentapi.CandidatePartition) bool { return p.State == agentapi.PartFilesystem && p.Mountable }) return resp } // deviceBacksRegistered reports whether formatting `device` would touch a registered drive: the device itself, or a // whole disk one of whose parts is registered (the same join as dropRegisteredDrives). FAIL-SAFE: an unreadable mount // table cannot prove the device free → true. func deviceBacksRegistered(device, mountsText string, registered map[string]bool) bool { if strings.TrimSpace(mountsText) == "" { return true } inUse := map[string]bool{} for _, row := range parseMountTable(mountsText) { if registered[path.Clean(row[1])] && strings.HasPrefix(row[0], "/dev/") { inUse[row[0]] = true } } return candidateBacksInUse(agentapi.DiskCandidate{Device: device}, inUse) } // freePartitions keeps the parts that are not a registered drive, not already Felhom's (state in_use), and usable for // the list in question. func freePartitions(parts []agentapi.CandidatePartition, inUse map[string]bool, usable func(agentapi.CandidatePartition) bool) []agentapi.CandidatePartition { var out []agentapi.CandidatePartition for _, p := range parts { if p.State == agentapi.PartInUse || inUse[p.Device] || !usable(p) { continue } out = append(out, p) } return out } // candidateBacksInUse reports whether a candidate's disk is a source in inUse: the whole disk itself, // its mountable partition, or any partition of it (/dev/sdb1, /dev/nvme0n1p1 under /dev/sdb, /dev/nvme0n1). func candidateBacksInUse(c agentapi.DiskCandidate, inUse map[string]bool) bool { if inUse[c.Device] || (c.MountSource != "" && inUse[c.MountSource]) { return true } if c.Device == "" { return false } for src := range inUse { rest, ok := strings.CutPrefix(src, c.Device) if !ok || rest == "" { continue } rest = strings.TrimPrefix(rest, "p") if rest != "" && strings.Trim(rest, "0123456789") == "" { return true } } return false } // mergeAttachCandidates adds the mounted-but-unregistered stores to `attach` and returns the result. // `initialize` is passed through untouched — the ONE line that keeps the format wizard's protection // intact, and the reason this is a separate function rather than two appends at the call site: it can // be tested, and a change to it fails a test instead of shipping. func mergeAttachCandidates(resp agentapi.CandidatesResult, stores []mountedStore) agentapi.CandidatesResult { resp.Attach = append(resp.Attach, mountedStoreCandidates(stores)...) return resp } // mountedStoreCandidates renders mounted-but-unregistered stores in the picker's shape. MountSource // carries the mountpoint (the thing the register action needs); Device is display only. func mountedStoreCandidates(stores []mountedStore) []agentapi.DiskCandidate { out := make([]agentapi.DiskCandidate, 0, len(stores)) for _, m := range stores { out = append(out, agentapi.DiskCandidate{ Device: m.Device, FSType: m.FSType, MountSource: m.Path, DataBearing: true, Mountable: true, // Size is deliberately absent: measuring it means statfs on a possibly-wedged device // inside a request handler, and a picker entry is actionable without it. AlreadyMounted: true, }) } return out } // sortDisksForView orders the agent's disk list deterministically (user-data → system → backup → // unrecognized; alphabetical by storage name within each tier). A stable Go-side contract beats // relying on map iteration order or template JS alone. func sortDisksForView(disks []agentapi.DiskInfo) { sort.SliceStable(disks, func(i, j int) bool { if ri, rj := diskRoleRank(disks[i].Role), diskRoleRank(disks[j].Role); ri != rj { return ri < rj } return disks[i].Name < disks[j].Name }) } // diskRoleRank ranks a role for the overview ordering (lower sorts first). func diskRoleRank(role string) int { switch role { case "user-data": return 0 case "system": return 1 case "backup": return 2 default: return 3 } } // agentDiskAssignHandler proxies POST /api/disks/assign → agent POST /disks/assign. func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request) { var req struct { UUID string `json:"uuid"` Where string `json:"where"` FSType string `json:"fstype"` Options string `json:"options"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil) return } if req.UUID == "" || req.Where == "" { writeDiskJSON(w, http.StatusBadRequest, false, "uuid and where are required", nil) return } client, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } if err := client.AssignDisk(r.Context(), req.UUID, req.Where, req.FSType, req.Options); err != nil { s.logger.Printf("[ERROR] [web] disk assign via agent failed: %v", err) writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", map[string]interface{}{ "uuid": req.UUID, "where": req.Where, }) } // agentDiskEjectHandler proxies POST /api/disks/eject → agent POST /disks/eject. func (s *Server) agentDiskEjectHandler(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, "invalid request body", nil) return } if req.Where == "" { writeDiskJSON(w, http.StatusBadRequest, false, "where is required", nil) return } client, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.EjectDisk(r.Context(), req.Where) if err != nil { s.logger.Printf("[ERROR] [web] disk eject via agent failed: %v", err) writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", resp) } // agentDiskFormatHandler proxies POST /api/disks/format → agent POST /disks/format. // A data-bearing format refusal (ErrFormatRefused) is surfaced as HTTP 409 so the UI // can show "operator authorization required". func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) { var req struct { Device string `json:"device"` FSType string `json:"fstype"` Confirmed bool `json:"confirmed"` DurableID string `json:"durable_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil) return } if req.Device == "" { writeDiskJSON(w, http.StatusBadRequest, false, "device is required", nil) return } client, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil) return } resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID) if errors.Is(err, agentapi.ErrNeedsConfirmation) { s.logger.Printf("[INFO] [web] disk format needs customer confirmation (user-data): %s", req.Device) writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp) return } if errors.Is(err, agentapi.ErrFormatRefused) { s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device) writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp) return } if err != nil { s.logger.Printf("[ERROR] [web] disk format via agent failed: %v", err) writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil) return } writeDiskJSON(w, http.StatusOK, true, "", resp) }