Files
felhom-controller/controller/internal/web/netstorage_handlers.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00

384 lines
17 KiB
Go

package web
import (
"context"
"encoding/json"
"fmt"
"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 <uid> to
// <uid>+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, s.errText(r, err), 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
}
where := settings.NetworkMountRoot + "/" + name
// C6B-F2 guard (v0.130.0): refuse the removal while a DEPLOYED app's HDD_PATH lives on the
// share. Removing the share under a bound app strands the app's storage AND orphans the host
// automount (the agent's stop steps are tolerated best-effort, so a busy mount gets its unit
// files deleted anyway → an unreapable autofs mount until host reboot — the C6B-F2 orphan).
if apps := s.deployedAppsOnPath(where); len(apps) > 0 {
s.logger.Printf("[WARN] [web] netstorage remove %q refused: deployed app(s) on the share: %s", name, strings.Join(apps, ", "))
writeDiskJSON(w, http.StatusConflict, false, fmt.Sprintf(
"A tároló nem távolítható el, amíg alkalmazás használja: %s. Előbb távolítsa el vagy költöztesse át az alkalmazást.",
strings.Join(apps, ", ")), nil)
return
}
agent, err := s.netAgentForAdd()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), 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, s.errText(r, err), nil)
return
}
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)
// R-67: drop the share's FileBrowser source + mount line on the next sync (F2 change-detection
// forces the recreate).
go s.SyncFileBrowserMounts()
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name})
}
// deployedAppsOnPath returns the display names of DEPLOYED stacks whose HDD_PATH is base itself
// or a subpath of it (apps on a share store <share-root>/<app>). Nil-safe on stackMgr.
func (s *Server) deployedAppsOnPath(base string) []string {
if s.stackMgr == nil || base == "" {
return nil
}
var apps []string
for _, st := range s.stackMgr.GetStacks() {
if !st.Deployed {
continue
}
cfg := s.stackMgr.LoadAppConfigByName(st.Name)
if cfg == nil {
continue
}
hdd := cfg.Env["HDD_PATH"]
if hdd == "" {
continue
}
if hdd == base || strings.HasPrefix(hdd, base+"/") {
name := st.Meta.DisplayName
if name == "" {
name = st.Name
}
apps = append(apps, name)
}
}
return apps
}
// 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/<name> path.
func pathBase(p string) string {
p = strings.TrimRight(p, "/")
if i := strings.LastIndexByte(p, '/'); i >= 0 {
return p[i+1:]
}
return p
}