package web import ( "context" "encoding/json" "net/http" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // NAS network storage (Part A2). The controller is a thin proxy over the agent's /netstorage/* (A1) + // the local StoragePath registry (Kind=network). It holds NO mount authority and NEVER persists the SMB // password (it passes the credential straight to the agent's add request, which writes the 0600 file). // A network share is a DISTINCT class: NO eject/decommission/migrate/wipe/SMART — remove is the only // lifecycle action (refuseNetworkLifecycle blocks the drive ops server-side). // defaultMediaUID/GID is the container uid/gid most media apps run as (jellyfin, *arr, immich). The // agent applies the +100000 host offset; this is the in-guest id the share is mapped to. const defaultMediaUID = 1000 // mappedIDMin/Max bound a valid CONTAINER uid/gid (F4). 65534 is `nobody`; a host-side mapped value // (e.g. 101000 = 1000+100000) must never be entered as the app uid. const ( mappedIDMin = 1 mappedIDMax = 65533 ) // validMappedID reports whether id is a valid container uid/gid (F4 range check). func validMappedID(id int) bool { return id >= mappedIDMin && id <= mappedIDMax } // netAddUIDRangeMsg is the friendly F4 refusal for an out-of-range mapped uid/gid. const netAddUIDRangeMsg = "Az alkalmazás felhasználói azonosítója (uid) érvénytelen. Adjon meg 1 és 65533 közötti értéket — a legtöbb médiaalkalmazás az 1000-est használja." // netAddOutdatedMsg is the sync add-time refusal (machine code "agent_outdated") when the agent // predates the coupled verify-before-commit add semantics (pre-v0.81.0). const netAddOutdatedMsg = "Az ügynök frissítése szükséges ehhez a funkcióhoz — a frissítés megérkezése után próbáld újra." // netAddSupport evaluates the coupled-feature probe for the settings-page render with a SHORT // budget — a down agent must not stall the page (the cache usually answers instantly). Returns the // template vocabulary: "yes" | "no" | "unknown"; only "no" swaps the add form for the banner — // flaky states belong to the add-time handling. func (s *Server) netAddSupport() string { agent, err := s.netAgentForAdd() if err != nil { return "unknown" } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() return s.netFeatures.Supports(ctx, agent, agentapi.FeatureNetstorageVerify).String() } // networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent. // Orphan marks an agent-configured share with NO registry entry (a crash-window leftover — Scenario // F's visible closure): the row renders with ONLY the remove action. type networkStorageItem struct { Name string `json:"name"` Label string `json:"label"` Protocol string `json:"protocol"` Server string `json:"server"` Export string `json:"export"` Path string `json:"path"` // the in-guest path apps point HDD_PATH at Health string `json:"health"` // ok | idle | unreachable | stub | unknown Reachable bool `json:"reachable"` Mounted bool `json:"mounted"` Configured bool `json:"configured"` Orphan bool `json:"orphan"` // agent-configured but NOT registered — remove is the only action } // handleNetStorageAdd starts the verify-before-commit orchestration: sync input validation (a bad // request changes nothing), then the detached job (agent add → verify poll → uid-1000 probe → // register LAST). The response is {started:true}; the UI polls /api/storage/netstorage/add/status. func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Protocol string `json:"protocol"` Server string `json:"server"` Export string `json:"export"` MappedUID int `json:"mapped_uid"` MappedGID int `json:"mapped_gid"` IdleTimeoutSec int `json:"idle_timeout_sec"` Username string `json:"username"` Password string `json:"password"` Label string `json:"label"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) return } name := strings.TrimSpace(req.Name) if !mountNameRe.MatchString(name) { logx.Debugf(s.logger, "[web] netstorage add refused by validation: name %q", name) writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név (csak betűk, számok, _ és -)", nil) return } proto := strings.ToLower(strings.TrimSpace(req.Protocol)) if proto != "nfs" && proto != "smb" { logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: protocol %q", name, proto) writeDiskJSON(w, http.StatusBadRequest, false, "protokoll: nfs vagy smb", nil) return } server, export := strings.TrimSpace(req.Server), strings.TrimSpace(req.Export) if server == "" || export == "" { logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: empty server/export", name) writeDiskJSON(w, http.StatusBadRequest, false, "a szerver és a megosztás kötelező", nil) return } if proto == "smb" && (req.Username == "" || req.Password == "") { logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: smb credentials missing", name) writeDiskJSON(w, http.StatusBadRequest, false, "SMB-hez felhasználónév és jelszó szükséges", nil) return } uid, gid := req.MappedUID, req.MappedGID if uid <= 0 { uid = defaultMediaUID } if gid <= 0 { gid = defaultMediaUID } // F4 (CAMPAIGN-3): validate the CONTAINER uid/gid range at the door. The guest maps to // +100000 on the host, so a valid app uid is 1..65533 (65534 = nobody; a host-side mapped // value like 101000 must NOT be entered as the app uid). Out of range previously slipped past the // controller and failed only at the agent with a raw `agent_error` (the campaign's 101000). Refuse // here with a friendly Hungarian 400 — nothing is installed. if !validMappedID(uid) || !validMappedID(gid) { logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: uid/gid out of range (uid=%d gid=%d)", name, uid, gid) writeDiskJSON(w, http.StatusBadRequest, false, netAddUIDRangeMsg, nil) return } agent, err := s.netAgentForAdd() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } // Capability gate (the publish-train backstop): the coupled add semantics shipped with agent // v0.81.0 together with GET /netstorage/verify-status — on an older agent, refuse up front // instead of failing mid-pipeline in `verifying` with a misleading rollback. Runs BEFORE the // single-flight claim (a refused add must not consume the slot). SupportUnknown passes: a down // agent speaks through the existing agent-error paths, never as "too old". support, supSource := s.netFeatures.SupportsWithSource(r.Context(), agent, agentapi.FeatureNetstorageVerify) s.logger.Printf("[DEBUG] [web] netstorage add %q capability gate: %s=%s (source=%s)", name, agentapi.FeatureNetstorageVerify, support, supSource) if support == agentapi.SupportNo { s.logger.Printf("[WARN] [web] netstorage add %q refused: agent predates %s (probe 404)", name, agentapi.FeatureNetstorageVerify) writeDiskJSON(w, http.StatusPreconditionFailed, false, netAddOutdatedMsg, map[string]any{"code": "agent_outdated"}) return } label := strings.TrimSpace(req.Label) if label == "" { label = "Hálózati tárhely: " + name } // The password rides INSIDE the request straight to the agent (0600 creds file) — it is never // persisted controller-side and never appears in the job status. started := s.startNetAdd(agent, agentapi.AddNetStorageRequest{ Name: name, Protocol: proto, Server: server, Export: export, MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec, Username: req.Username, Password: req.Password, }, label) if !started { writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy csatlakoztatás", nil) return } s.logger.Printf("[INFO] [web] network storage add started: %s (%s %s:%s)", name, proto, server, export) writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "name": name}) } // handleNetStorageAddStatus reports the last / in-flight add job (deep copy; "none" when never ran). func (s *Server) handleNetStorageAddStatus(w http.ResponseWriter, r *http.Request) { job := s.netAdd.snapshot() if job == nil { writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"phase": "none"}) return } writeDiskJSON(w, http.StatusOK, true, "", map[string]any{ "phase": job.Phase, "name": job.Name, "category": job.Category, "message": job.Message, "detail": trimNetDetail(job.Detail), "warn": job.Warn, "path": job.Path, "started_at": job.StartedAt, "updated_at": job.UpdatedAt, }) } // networkStorageItems returns the registered network shares merged with the agent's live per-share // health. A share the agent can't currently report (agent down, or not in the live list) is "unknown". // Shared by the JSON list handler and the settings page render. func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem { live := map[string]agentapi.NetworkMountStatus{} lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page defer cancel() if mounts, lerr := s.listNetStorage(lctx); lerr == nil { for _, m := range mounts { live[m.Name] = m } } else { s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr) } items := make([]networkStorageItem, 0) registered := map[string]bool{} for _, sp := range s.settings.GetStoragePaths() { if !sp.IsNetwork() { continue } name := pathBase(sp.Path) registered[name] = true it := networkStorageItem{ Name: name, Label: sp.Label, Protocol: sp.Protocol, Server: sp.Server, Export: sp.Export, Path: sp.Path, Health: "unknown", } if m, ok := live[name]; ok { it.Health = m.Health it.Reachable = m.Reachable it.Mounted = m.Mounted it.Configured = m.Configured } // F8 (CAMPAIGN-3): fuse the consuming-namespace classification so the SHARE ROW tells the same // truth as the stacks/dashboard stub badge — both now read `classifyFSPath`, so they can never // contradict. The agent's `health` derives from a SERVER-LEVEL TCP dial that stays green when a // single export is `exportfs -u`'d (the server still answers on 2049/445); the namespace verdict // is the only thing that sees the export-level outage. See fuseNetHealth. it.Health = s.fuseNetHealth(it.Health, it.Path) items = append(items, it) } // Orphans (Scenario F's visible closure): an agent-configured share with NO registry entry is a // crash-window leftover (e.g. controller died between agent-add and register). Surface it with // ONLY the remove action — re-adding the same name is the repair path (EnsureNetworkMount is // idempotent, verify runs again). var orphans []string for name := range live { if !registered[name] { orphans = append(orphans, name) } } sort.Strings(orphans) if len(orphans) > 0 { logx.Warnf(s.logger, "[web] netstorage: %d orphan agent-side share(s) with no registry entry: %v", len(orphans), orphans) } for _, name := range orphans { m := live[name] items = append(items, networkStorageItem{ Name: name, Label: "Árva megosztás: " + name, Protocol: m.Protocol, Server: m.Server, Export: m.Export, Path: m.Where, Health: m.Health, Reachable: m.Reachable, Mounted: m.Mounted, Configured: m.Configured, Orphan: true, }) } return items } // Net-health values used in the fusion (the agent supplies ok/idle/unreachable/unknown; the // controller ADDS stub — configured + server reachable, but the consuming namespace does NOT see the // network fs at Where, so app data would hit local disk). const ( netHealthUnreachable = "unreachable" netHealthStub = "stub" ) // fuseNetHealth reconciles the agent-reported health with the controller's consuming-namespace // classification (F8, CAMPAIGN-3). Precedence: // - `unreachable` (agent TCP dial failed — a whole-server outage) is the most actionable and WINS; // the classifier is not allowed to override it (the row must say "server down", not "stub"). // - otherwise a `stub` classification at Where (the namespace sees local disk / an empty dir, not // the NAS) OVERRIDES a benign idle/ok — this is the exact F8 contradiction resolved. // - autofs-healthy (idle trigger), a real network fs, or an inconclusive `unknown`/fail-open read // leave the agent-derived health untouched — never manufacture a fault, never force-mount. func (s *Server) fuseNetHealth(agentHealth, where string) string { if agentHealth == netHealthUnreachable { return agentHealth // a whole-server outage is the more actionable truth } if s.classifyFSPath == nil || where == "" { return agentHealth } if s.classifyFSPath(where) == system.FSClassStub { return netHealthStub } return agentHealth } // handleNetStorageList returns the registered network shares merged with the agent's live per-share health. func (s *Server) handleNetStorageList(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"network_storage": s.networkStorageItems(r.Context())}) } // handleNetStorageRemove proxies POST /api/storage/netstorage/remove → agent /netstorage/remove, then // deregisters the path. No decommission/migrate semantics (drive-only). func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) return } name := strings.TrimSpace(req.Name) if !mountNameRe.MatchString(name) { writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név", nil) return } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } if err := agent.RemoveNetStorage(r.Context(), name); err != nil { s.logger.Printf("[ERROR] [web] netstorage remove %q via agent failed: %v", name, err) writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) return } where := settings.NetworkMountRoot + "/" + name if err := s.settings.RemoveStoragePath(where); err != nil { s.logger.Printf("[WARN] [web] netstorage deregister %q: %v", where, err) } s.logger.Printf("[INFO] [web] network storage removed: %s", name) writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name}) } // listNetStorage resolves the agent's live share list (test seam first, then the shared client). func (s *Server) listNetStorage(ctx context.Context) ([]agentapi.NetworkMountStatus, error) { if s.netListFn != nil { return s.netListFn(ctx) } agent, err := s.agentClient() if err != nil { return nil, err } return agent.ListNetStorage(ctx) } // pathBase returns the last path segment (the share name) of a /mnt/felhom-drives/ path. func pathBase(p string) string { p = strings.TrimRight(p, "/") if i := strings.LastIndexByte(p, '/'); i >= 0 { return p[i+1:] } return p }