package web import ( "context" "encoding/json" "net/http" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // 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 // networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent. 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 | unknown Reachable bool `json:"reachable"` Mounted bool `json:"mounted"` Configured bool `json:"configured"` } // handleNetStorageAdd proxies POST /api/storage/netstorage/add → agent /netstorage/add, then registers a // Kind=network StoragePath (no password persisted). 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) { 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" { writeDiskJSON(w, http.StatusBadRequest, false, "protokoll: nfs vagy smb", nil) return } server, export := strings.TrimSpace(req.Server), strings.TrimSpace(req.Export) if server == "" || export == "" { writeDiskJSON(w, http.StatusBadRequest, false, "a szerver és a megosztás kötelező", nil) return } if proto == "smb" && (req.Username == "" || req.Password == "") { 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 } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } res, err := agent.AddNetStorage(r.Context(), agentapi.AddNetStorageRequest{ Name: name, Protocol: proto, Server: server, Export: export, MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec, Username: req.Username, Password: req.Password, }) if err != nil { s.logger.Printf("[ERROR] [web] netstorage add %q via agent failed: %v", name, err) writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) return } label := strings.TrimSpace(req.Label) if label == "" { label = "Hálózati tárhely: " + name } // Register the Kind=network path. NO password is persisted — only the non-secret descriptors. sp := settings.StoragePath{ Path: res.GuestPath, Label: label, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), Kind: settings.StorageKindNetwork, Protocol: proto, Server: server, Export: export, MappedUID: uid, MappedGID: gid, } if err := s.settings.AddStoragePath(sp); err != nil { s.logger.Printf("[ERROR] [web] netstorage register %q failed: %v", name, err) writeDiskJSON(w, http.StatusInternalServerError, false, "regisztráció sikertelen", nil) return } s.logger.Printf("[INFO] [web] network storage added: %s (%s %s:%s) → %s", name, proto, server, export, res.GuestPath) writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "name": name, "path": res.GuestPath}) } // 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{} if agent, err := s.agentClient(); err == nil { lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page defer cancel() if mounts, lerr := agent.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) for _, sp := range s.settings.GetStoragePaths() { if !sp.IsNetwork() { continue } name := pathBase(sp.Path) 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 } items = append(items, it) } return items } // 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}) } // 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 }