package web import ( "context" "encoding/json" "errors" "net/http" "sort" "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 "