Files
felhom-controller/controller/internal/web/agent_disk_handlers.go
T
admin 36da4089ca
gates / gates (push) Successful in 45s
v0.318.0: a part is offered for formatting only while nothing on its disk is mounted (agent 0.158.1 formattable)
The agent's root mkfs wrapper (security review of agent 0.158.0) refuses any mounted node and trusts no sibling claim; the
wizard now matches it. A free filesystem part of an in-use disk stays offered for use. Red-proved.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 04:05:09 +02:00

416 lines
18 KiB
Go

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 "<!DOCTYPE ... is not valid JSON"). App-level JSON errors must never leave
// the origin as 502/504; 500 carries the body through the edge intact.
if status == http.StatusBadGateway || status == http.StatusGatewayTimeout {
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
resp := map[string]interface{}{"ok": ok}
if errMsg != "" {
resp["error"] = errMsg
}
if data != nil {
resp["data"] = data
}
_ = json.NewEncoder(w).Encode(resp)
}
// agentDisksListHandler proxies GET /api/disks → agent GET /disks.
func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
resp, err := client.Disks(r.Context())
if err != nil {
s.logger.Printf("[ERROR] [web] disk list via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
// Deterministic order: the agent's storage view iterates a Go map (unordered), so the list would
// otherwise reorder on every reload (CLAUDE.md lesson #3). The customer's manageable drives go on
// top, in a stable order: user-data first, then system, then backup, alpha by name within a tier.
sortDisksForView(resp.Disks)
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
// already excludes claimed/OS disks (fail-safe), and the system/backup drives it hides from the format
// wizard stay hidden. It deliberately does NOT exclude Felhom's own drives, so the controller removes
// every drive backing a REGISTERED storage path from both lists (R-542, dropRegisteredDrives) — the
// one filter the controller adds; it only ever removes entries.
//
// R-280: `attach` additionally carries the controller's own mounted-but-unregistered filesystems.
// The agent's scan alone left a rebuilt box with an empty picker under a sentence promising „két
// kattintás", because the drive that must be re-registered is an in-guest filesystem no host-disk
// scan can see. Attaching is non-destructive, so this list is additive by nature — it can only ever
// offer MORE places to put data back, never a new way to erase any. Why the union rather than a
// replacement: the agent's entries serve the case this endpoint was built for — a fresh external
// drive that already carries a filesystem and is not yet mounted — which the mount table cannot
// report precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
resp, err := client.ListCandidates(r.Context())
if err != nil {
s.logger.Printf("[ERROR] [web] disk candidates via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
mounts := readMountTable()
resp = dropRegisteredDrives(resp, mounts, s.registeredStoragePaths())
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
}
// dropRegisteredDrives (R-542) removes from BOTH lists every agent candidate that backs a REGISTERED
// storage path. The agent deliberately lets Felhom re-initialise its own drives (a mount under
// /mnt/felhom-drives is not a foreign claim — felhom-agent internal/storage/claim.go), so a drive the
// household registered and uses came back under `initialize` — the format wizard (storage_init.html)
// renders that list as-is. Measured 2026-09-16 on a fresh box: the registered default data drive was
// offered for formatting and again under `attach`.
//
// The join is the guest's own mount table: a registered path's mount SOURCE is the host device
// (measured on demo-hp 2026-10-06: `/dev/nvme0n1 /mnt/felhom-drives/hdd_1`), which is the candidate's
// Device (whole disk) or MountSource (partition).
//
// ⚠ FAIL-SAFE: an unreadable mount table cannot prove any candidate is NOT in use, so `initialize`
// comes back EMPTY (the wizard then says no drive is available) — never the unfiltered list. `attach`
// is non-destructive and is left as the agent sent it in that case.
func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, registered map[string]bool) agentapi.CandidatesResult {
if strings.TrimSpace(mountsText) == "" {
resp.Initialize = []agentapi.DiskCandidate{}
return resp
}
inUse := map[string]bool{}
for _, row := range parseMountTable(mountsText) {
if registered[path.Clean(row[1])] && strings.HasPrefix(row[0], "/dev/") {
inUse[row[0]] = true
}
}
if len(inUse) == 0 {
return resp
}
keep := func(in []agentapi.DiskCandidate, usable func(agentapi.CandidatePartition) bool) []agentapi.DiskCandidate {
out := make([]agentapi.DiskCandidate, 0, len(in))
for _, c := range in {
if candidateBacksInUse(c, inUse) {
// `16` §6 (v0.318.0): a disk whose ONE part is a registered drive keeps its usable FREE parts on offer —
// the whole disk itself, never (WholeDiskInUse; handleStorageInit refuses it server-side too).
if free := freePartitions(c.Partitions, inUse, usable); len(free) > 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)
}