controller v0.92.0: NAS network storage Part A2 (registry + UI + per-share health)
Controller-side of NAS network storage, proxying to agent A1 /netstorage/*. Distinct 'network' storage kind (no drive lifecycle); add/list/remove + per-share health UI; unreachable NAS is a recoverable warning, never the drive missing/stop cascade; SMB creds pass through to the agent, never persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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/<name> path.
|
||||
func pathBase(p string) string {
|
||||
p = strings.TrimRight(p, "/")
|
||||
if i := strings.LastIndexByte(p, '/'); i >= 0 {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user