diff --git a/CHANGELOG.md b/CHANGELOG.md index 5213919..f423359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ ## Changelog +### v0.92.0 — NAS network storage Part A2: registry + UI + per-share health (2026-06-30) +The controller side of NAS network storage, proxying to the validated agent foundation (felhom-agent +v0.50.0 `/netstorage/*`). An operator can add a customer's NAS share and point a media app at it — all via +the UI. A NAS is a **distinct storage kind** (NOT a drive): no enroll/eject/decommission/migrate/wipe/SMART. +- **`internal/agentapi/client.go`:** `AddNetStorage`/`ListNetStorage`/`RemoveNetStorage` + the + `NetworkMountStatus` mirror (health `ok|idle|unreachable`; `idle` is benign, only `unreachable` degraded). + The SMB credential is passed STRAIGHT THROUGH to the agent (which writes the 0600 file) — **never persisted + by the controller**. +- **`internal/settings/settings.go`:** `StoragePath.Kind` discriminator (`""`/`drive` | `network`) + the + network descriptors (Protocol/Server/Export/MappedUID/MappedGID — **no password**); `IsNetwork()` + + `IsNetworkStoragePath()`; `NetworkMountRoot`. +- **`internal/web/netstorage_handlers.go` (NEW):** `POST /api/storage/netstorage/{add,remove}` + + `GET /api/storage/netstorage`; registers/deregisters a Kind=network `StoragePath`; merges the agent's live + per-share health for the UI. +- **Kind-gating (the safety centerpiece):** `refuseNetworkLifecycle` blocks the drive ops + (eject/decommission/migrate/wipe) on a network path server-side; the drive-absent **gate** + (`planDriveGates`) and the **missing-storage** surface now SKIP network paths — so an `unreachable` NAS is a + **recoverable warning**, never the drive "missing → stopped" cascade (Scenario C). `networkStorageWarnings` + drives a distinct "Hálózati tárhely nem elérhető" app-card badge. +- **UI (`settings.html`):** a "Hálózati tárhely (NAS)" section — add form (NFS/SMB, server/export, uid, + SMB creds), per-share health badges, remove. Network shares are auto-selectable as a media app's `HDD_PATH` + (they register Schedulable). Hungarian, minimal emoji. +- Tests: agentapi round-trip (creds forwarded, health states); registry Kind-gate **companion** (drive + lifecycle refuses a network path; a drive path is not gate-refused); `unreachable`≠`missing` **companion** + (the drive gate stops an absent drive but NOT a network path). `go build/vet/test ./...` green. + ### v0.91.0 — F2: alert on born/persistent-down channel (not only transitions) (2026-06-29) - **What:** closes F2 from the full-stack testrun — a channel failure present at **startup/reseed** (e.g. the controller boots right after a leaf regen → first observation is `pin_mismatch`) was diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 31d8001..17393b0 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -509,6 +509,92 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme return out, nil } +// ---- NAS network storage (Part A2 → agent A1 /netstorage/*) ------------------------------ +// +// A NAS share is a DISTINCT storage class from a drive: the controller proxies add/list/remove to the +// agent (which owns the host-side automount), holds NO mount authority, and persists NO SMB password +// (it passes the credentials straight through to the agent's add request — the agent writes the 0600 +// creds file). There is NO eject/decommission/migrate/wipe/SMART here — those are drive-only. + +// NetworkMountStatus mirrors the agent's GET /netstorage entry (A1). health ∈ {ok, idle, unreachable}: +// `idle` (reachable + automount idle-unmounted) is BENIGN, not a fault; only `unreachable` is degraded. +type NetworkMountStatus struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Server string `json:"server"` + Export string `json:"export"` + Where string `json:"where"` + Configured bool `json:"configured"` + Mounted bool `json:"mounted"` + Reachable bool `json:"reachable"` + Health string `json:"health"` // ok | idle | unreachable +} + +// Unreachable reports the degraded state (NAS not reachable). `idle` is explicitly NOT unreachable — an +// idle-unmounted automount is the normal steady state, never a warning. +func (n NetworkMountStatus) Unreachable() bool { return n.Health == "unreachable" } + +// AddNetStorageRequest is the controller→agent POST /netstorage/add body (A1). Username/Password are SMB +// only and flow STRAIGHT THROUGH to the agent (which writes the 0600 creds file) — the controller never +// stores the password at rest. +type AddNetStorageRequest struct { + Name string `json:"name"` + Protocol string `json:"protocol"` // nfs | smb + Server string `json:"server"` + Export string `json:"export"` + MappedUID int `json:"mapped_uid"` + MappedGID int `json:"mapped_gid"` + IdleTimeoutSec int `json:"idle_timeout_sec,omitempty"` + Username string `json:"username,omitempty"` // SMB secret — pass-through, never persisted + Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted +} + +// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir points at). +type NetStorageAddResult struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Where string `json:"where"` + GuestPath string `json:"guest_path"` + HostUID int `json:"host_uid"` + HostGID int `json:"host_gid"` +} + +// AddNetStorage mounts a NAS share host-side (the agent automounts it; it propagates into this guest via +// the shared bind). Returns the in-guest path the media app's data dir is pointed at. +func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) { + var out NetStorageAddResult + body, err := c.post(ctx, "/netstorage/add", req) + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /netstorage/add: %w", err) + } + return out, nil +} + +// ListNetStorage returns the configured NAS shares + per-share liveness. +func (c *Client) ListNetStorage(ctx context.Context) ([]NetworkMountStatus, error) { + body, err := c.get(ctx, "/netstorage") + if err != nil { + return nil, err + } + var wrap struct { + NetworkMounts []NetworkMountStatus `json:"network_mounts"` + } + if err := json.Unmarshal(body, &wrap); err != nil { + return nil, fmt.Errorf("agentapi: decode /netstorage: %w", err) + } + return wrap.NetworkMounts, nil +} + +// RemoveNetStorage unmounts + removes a NAS share (the agent drops the mount + creds file). This is NOT a +// drive decommission/migrate — a NAS has no device lifecycle. +func (c *Client) RemoveNetStorage(ctx context.Context, name string) error { + _, err := c.post(ctx, "/netstorage/remove", map[string]string{"name": name}) + return err +} + // postWithStatus issues an authenticated JSON POST and returns the envelope's data payload + the HTTP // status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or // envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it). diff --git a/controller/internal/agentapi/netstorage_test.go b/controller/internal/agentapi/netstorage_test.go new file mode 100644 index 0000000..0f25b3e --- /dev/null +++ b/controller/internal/agentapi/netstorage_test.go @@ -0,0 +1,106 @@ +package agentapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func netStub(t *testing.T) (*httptest.Server, string, *struct { + addBody AddNetStorageRequest + removed string +}) { + captured := &struct { + addBody AddNetStorageRequest + removed string + }{} + mux := http.NewServeMux() + mux.HandleFunc("POST /netstorage/add", func(w http.ResponseWriter, r *http.Request) { + _ = decodeJSON(r, &captured.addBody) + _, _ = w.Write([]byte(`{"ok":true,"data":{"name":"media","protocol":"nfs","where":"/mnt/felhom-drives/media","guest_path":"/mnt/felhom-drives/media","host_uid":101000,"host_gid":101000}}`)) + }) + mux.HandleFunc("GET /netstorage", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"network_mounts":[ + {"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","where":"/mnt/felhom-drives/media","configured":true,"mounted":true,"reachable":true,"health":"ok"}, + {"name":"photos","protocol":"smb","server":"10.0.0.5","export":"photos","where":"/mnt/felhom-drives/photos","configured":true,"mounted":false,"reachable":true,"health":"idle"}, + {"name":"vids","protocol":"nfs","server":"10.0.0.6","export":"/srv/vids","where":"/mnt/felhom-drives/vids","configured":true,"mounted":true,"reachable":false,"health":"unreachable"} + ]}}`)) + }) + mux.HandleFunc("POST /netstorage/remove", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + _ = decodeJSON(r, &body) + captured.removed = body.Name + _, _ = w.Write([]byte(`{"ok":true,"data":{"name":"` + body.Name + `","removed":true}}`)) + }) + s := httptest.NewTLSServer(mux) + return s, strings.TrimPrefix(s.URL, "https://"), captured +} + +func TestNetStorage_Add_ForwardsCredsAndMapping(t *testing.T) { + s, ep, cap := netStub(t) + defer s.Close() + c := clientFor(t, s, ep) + + res, err := c.AddNetStorage(context.Background(), AddNetStorageRequest{ + Name: "media", Protocol: "smb", Server: "10.0.0.5", Export: "media", + MappedUID: 1000, MappedGID: 1000, Username: "u", Password: "p", + }) + if err != nil { + t.Fatal(err) + } + if res.GuestPath != "/mnt/felhom-drives/media" || res.HostUID != 101000 { + t.Fatalf("add result mismatch: %+v", res) + } + // The SMB creds + mapping must be forwarded to the agent verbatim (the agent writes the 0600 file). + if cap.addBody.Username != "u" || cap.addBody.Password != "p" { + t.Fatalf("creds not forwarded to the agent: %+v", cap.addBody) + } + if cap.addBody.MappedUID != 1000 || cap.addBody.Protocol != "smb" { + t.Fatalf("mapping/protocol not forwarded: %+v", cap.addBody) + } +} + +func TestNetStorage_List_HealthStates(t *testing.T) { + s, ep, _ := netStub(t) + defer s.Close() + c := clientFor(t, s, ep) + + mounts, err := c.ListNetStorage(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(mounts) != 3 { + t.Fatalf("want 3 mounts, got %d", len(mounts)) + } + byName := map[string]NetworkMountStatus{} + for _, m := range mounts { + byName[m.Name] = m + } + // ok + idle are NOT degraded; only unreachable is. + if byName["media"].Unreachable() || byName["photos"].Unreachable() { + t.Fatalf("ok/idle must not be unreachable: %+v", byName) + } + if byName["photos"].Health != "idle" { + t.Fatalf("photos should be idle (benign), got %q", byName["photos"].Health) + } + if !byName["vids"].Unreachable() { + t.Fatalf("vids should be unreachable (degraded), got %q", byName["vids"].Health) + } +} + +func TestNetStorage_Remove(t *testing.T) { + s, ep, cap := netStub(t) + defer s.Close() + c := clientFor(t, s, ep) + + if err := c.RemoveNetStorage(context.Background(), "media"); err != nil { + t.Fatal(err) + } + if cap.removed != "media" { + t.Fatalf("remove did not forward the name, got %q", cap.removed) + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 7cfaa7f..15cfbb5 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -115,9 +115,22 @@ type CrossDriveBackup struct { PreferredTarget string `json:"preferred_target,omitempty"` } +// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe +// lifecycle (agent /disks). A NETWORK share is a NAS (Part A2) proxied to the agent /netstorage — a +// DISTINCT class with NO drive lifecycle (no eject/decommission/migrate/wipe/SMART; remove is the only +// lifecycle action). An empty Kind means "drive" (back-compat with already-persisted paths). +const ( + StorageKindDrive = "drive" + StorageKindNetwork = "network" +) + +// NetworkMountRoot is the in-guest path under which the agent propagates NAS shares (mirrors the agent's +// /mnt/felhom-drives bind root). A registered network path is NetworkMountRoot + "/" + . +const NetworkMountRoot = "/mnt/felhom-drives" + // StoragePath represents a registered external storage location. type StoragePath struct { - Path string `json:"path"` // e.g., "/mnt/hdd_1" + Path string `json:"path"` // e.g., "/mnt/hdd_1" (drive) or "/mnt/felhom-drives/" (network) Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB" IsDefault bool `json:"is_default,omitempty"` // new apps use this by default Schedulable bool `json:"schedulable"` // whether new apps can be deployed here @@ -128,8 +141,23 @@ type StoragePath struct { Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp MigratedTo string `json:"migrated_to,omitempty"` // path of target drive + + // Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network + // share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it. + Kind string `json:"kind,omitempty"` + // Network-storage descriptors (Kind=="network" only; mirror the agent A1 add request). NO password + // is stored — the SMB credential is passed through to the agent at add-time and never persisted here. + Protocol string `json:"protocol,omitempty"` // nfs | smb + Server string `json:"server,omitempty"` + Export string `json:"export,omitempty"` + MappedUID int `json:"mapped_uid,omitempty"` + MappedGID int `json:"mapped_gid,omitempty"` } +// IsNetwork reports whether this is a NAS network-storage path (vs a physical drive). The drive +// lifecycle (eject/decommission/migrate/wipe/SMART) must NEVER be applied to a network path. +func (p StoragePath) IsNetwork() bool { return p.Kind == StorageKindNetwork } + // NotificationPrefs holds customer notification preferences. type NotificationPrefs struct { Email string `json:"email,omitempty"` @@ -846,6 +874,20 @@ func (s *Settings) IsStoragePathKnown(path string) bool { return false } +// IsNetworkStoragePath reports whether `path` belongs to a registered NAS network-storage path +// (Kind=="network"). The drive lifecycle (eject/decommission/migrate/wipe) must refuse such a path — +// a NAS has no device lifecycle. Matches the exact path or a child under it. +func (s *Settings) IsNetworkStoragePath(path string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + for _, sp := range s.StoragePaths { + if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") { + return sp.IsNetwork() + } + } + return false +} + // IsStoragePathSchedulable returns whether a path belongs to a registered, // schedulable (active) storage path. Returns false if the path is unknown, // disconnected, decommissioned, or inactive. diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 74ce77d..896d0b1 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -155,6 +155,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption data["Stacks"] = deployedStacks data["MissingStorage"] = s.missingStorageMap(deployedStacks) + data["NetworkWarnings"] = s.networkStorageWarnings(deployedStacks) // NAS unreachable — recoverable warning, not "missing" data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) @@ -202,6 +203,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) { allStacks := s.stackMgr.GetStacks() data["Stacks"] = allStacks data["MissingStorage"] = s.missingStorageMap(allStacks) + data["NetworkWarnings"] = s.networkStorageWarnings(allStacks) // NAS unreachable — recoverable warning, not "missing" // Build storage label lookup for deployed apps storageLabels := make(map[string]string) // stack name → storage label @@ -915,6 +917,11 @@ func (s *Server) settingsData() map[string]interface{} { } var storageViews []StoragePathView for _, sp := range storagePaths { + // NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's + // per-share health — never in the physical-drive list (which offers eject/decommission/wipe). + if sp.IsNetwork() { + continue + } view := StoragePathView{ StoragePath: sp, StoppedApps: sp.StoppedStacks, @@ -942,6 +949,9 @@ func (s *Server) settingsData() map[string]interface{} { storageViews = append(storageViews, view) } data["StoragePaths"] = storageViews + // NAS network storage (Part A2) — separate section with the agent's per-share health (ok/idle/ + // unreachable/unknown). Distinct from the physical-drive list above; no drive lifecycle actions. + data["NetworkStoragePaths"] = s.networkStorageItems(context.Background()) // Recovery info for emergency section data["RetrievalPassword"] = s.settings.GetRetrievalPassword() @@ -1210,6 +1220,12 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) { } for _, sp := range s.settings.GetStoragePaths() { if sp.Path == hddPath { + // A NAS network path is NEVER "missing": its availability is the agent's per-share liveness + // (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop + // cascade. An `unreachable` NAS must not look like a removed drive. + if sp.IsNetwork() { + return "", false + } if sp.Decommissioned || sp.Disconnected { return s.settings.GetStorageLabel(hddPath), true } @@ -1219,6 +1235,65 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) { return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone } +// networkStorageWarnings returns stack-name → share label for every deployed app whose HDD_PATH is a NAS +// network path the agent currently reports `unreachable`. This is a RECOVERABLE warning ("hálózati +// tárhely nem elérhető"), explicitly NOT the drive missing/stop-cascade — the app keeps running and the +// badge clears when the NAS returns. Best-effort: an agent error or no network paths → no warnings. +func (s *Server) networkStorageWarnings(list []stacks.Stack) map[string]string { + out := map[string]string{} + if s.settings == nil || s.stackMgr == nil { + return out + } + netPaths := map[string]settings.StoragePath{} + for _, sp := range s.settings.GetStoragePaths() { + if sp.IsNetwork() { + netPaths[sp.Path] = sp + } + } + if len(netPaths) == 0 { + return out + } + agent, err := s.agentClient() + if err != nil { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + mounts, err := agent.ListNetStorage(ctx) + if err != nil { + s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err) + return out + } + unreachable := map[string]string{} // path → label + for _, m := range mounts { + if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign + continue + } + p := settings.NetworkMountRoot + "/" + m.Name + if sp, ok := netPaths[p]; ok { + lbl := sp.Label + if lbl == "" { + lbl = m.Name + } + unreachable[p] = lbl + } + } + if len(unreachable) == 0 { + return out + } + for _, st := range list { + if !st.Deployed { + continue + } + if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil { + if lbl, bad := unreachable[cfg.Env["HDD_PATH"]]; bad { + out[st.Name] = lbl + } + } + } + return out +} + // missingStorageMap returns stack-name → storage label for every deployed app whose data drive is // currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator). func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string { diff --git a/controller/internal/web/intermediary.go b/controller/internal/web/intermediary.go index 87d42d6..bb5e01d 100644 --- a/controller/internal/web/intermediary.go +++ b/controller/internal/web/intermediary.go @@ -212,6 +212,13 @@ func planDriveGates(paths []settings.StoragePath, disks []agentapi.DiskInfo) []g if sp.Decommissioned { continue } + // NAS network storage is NOT a drive — the agent never lists it in /disks, so the drive-absent gate + // would falsely see it "absent" (present[sp.Path]==false) and STOP its apps. A NAS blip is + // recoverable (the agent's per-share liveness → a warning badge), never the drive stop-cascade. + // Skip network paths here entirely (Scenario C: unreachable ≠ missing). + if sp.IsNetwork() { + continue + } // ONLY gate EXTERNAL drives — those registered under the stable parent /mnt/felhom-drives/. // Internal SSD / system paths (e.g. /mnt/sys_drive/felhom-data) are always-present locals the agent // never reports as drives; gating them on "absence" would falsely stop/block their apps. (Legacy diff --git a/controller/internal/web/netstorage_handlers.go b/controller/internal/web/netstorage_handlers.go new file mode 100644 index 0000000..2863156 --- /dev/null +++ b/controller/internal/web/netstorage_handlers.go @@ -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/ path. +func pathBase(p string) string { + p = strings.TrimRight(p, "/") + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/controller/internal/web/netstorage_handlers_test.go b/controller/internal/web/netstorage_handlers_test.go new file mode 100644 index 0000000..6bce3e9 --- /dev/null +++ b/controller/internal/web/netstorage_handlers_test.go @@ -0,0 +1,152 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// addNetworkPath registers a Kind=network StoragePath in the test server's settings. +func addNetworkPath(t *testing.T, s *Server, name string) { + t.Helper() + if err := s.settings.AddStoragePath(settings.StoragePath{ + Path: settings.NetworkMountRoot + "/" + name, + Label: "NAS " + name, + Schedulable: true, + AddedAt: time.Now().UTC().Format(time.RFC3339), + Kind: settings.StorageKindNetwork, + Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name, + MappedUID: 1000, MappedGID: 1000, + }); err != nil { + t.Fatal(err) + } +} + +func decodeEnvelope(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { + t.Fatalf("decode: %v (%s)", err, w.Body.String()) + } + return m +} + +// TestNetStorage_RegisteredAndListed: a Kind=network path is registered and surfaced by the list handler +// (with health "unknown" when the agent is not reachable in the test). +func TestNetStorage_RegisteredAndListed(t *testing.T) { + s := testServer(t) + addNetworkPath(t, s, "media") + + r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage", nil) + w := httptest.NewRecorder() + s.handleNetStorageList(w, r) + if w.Code != http.StatusOK { + t.Fatalf("list: got %d want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"name":"media"`) || !strings.Contains(body, `"protocol":"nfs"`) { + t.Fatalf("network share not listed: %s", body) + } + if !strings.Contains(body, `"health":"unknown"`) { + t.Fatalf("expected unknown health with no agent, got: %s", body) + } +} + +// TestNetStorage_KindGate is the §10 companion: the drive-lifecycle handlers REFUSE a Kind=network path. +// A network path → 400 refusal (the Kind-gate short-circuits BEFORE any agent call). The companion +// red-proof: a DRIVE path (or an unknown path) is NOT refused by the gate — it proceeds and fails later +// with a 503 (no agent configured in the test). A Kind-blind impl would treat both alike and FAIL here. +func TestNetStorage_KindGate(t *testing.T) { + s := testServer(t) + addNetworkPath(t, s, "media") + + lifecycle := []struct { + name string + path string + handler func(http.ResponseWriter, *http.Request) + driveCompan bool // a drive path fails cleanly at the agent step (no stackMgr needed) → safe to assert + }{ + {"eject", "/api/storage/eject", s.handleStorageEject, true}, + {"wipe", "/api/storage/wipe", s.handleStorageWipe, true}, + {"decommission", "/api/storage/decommission", s.handleStorageDecommission, false}, + } + for _, lc := range lifecycle { + // A NETWORK path is refused by the Kind-gate (400, before any agent/stackMgr use). + netBody := `{"where":"/mnt/felhom-drives/media","device":"/dev/sdz","mount_name":"media","mode":"anyway"}` + rn := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(netBody)) + wn := httptest.NewRecorder() + lc.handler(wn, rn) + if wn.Code != http.StatusBadRequest { + t.Fatalf("%s on a network path: got %d want 400 (Kind-gate must refuse)", lc.name, wn.Code) + } + if env := decodeEnvelope(t, wn); env["ok"] != false { + t.Fatalf("%s on a network path must be ok:false, got %v", lc.name, env["ok"]) + } + + // COMPANION: a DRIVE path is NOT refused by the Kind-gate — it falls through to the agent step and + // fails 503 (no agent), proving the gate keys on Kind, not the path. (Only for handlers that reach + // the agent before any nil-dependency; decommission-anyway dives into stackMgr, covered by the + // direct refuseNetworkLifecycle unit test instead.) + if !lc.driveCompan { + continue + } + drvBody := `{"where":"/mnt/hdd_1","device":"/dev/sdz","mount_name":"hdd_1","mode":"anyway"}` + rd := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(drvBody)) + wd := httptest.NewRecorder() + lc.handler(wd, rd) + if wd.Code == http.StatusBadRequest { + t.Fatalf("%s on a DRIVE path must NOT hit the Kind-gate 400 (got 400) — the gate would be Kind-blind", lc.name) + } + } +} + +// TestNetStorage_UnreachableNotMissing is the §10 health-surface companion: an `unreachable` NAS must +// surface as a recoverable WARNING, never the drive "missing → stopped" cascade. Here: (1) a network path +// is NEVER reported "missing" by missingStorageLabel, and (2) the drive-absent gate produces NO Stop +// action for a network path even though it is absent from the agent's /disks list. The companion shows a +// real DRIVE path under the same parent, absent from /disks, DOES get a Stop action — proving the network +// exemption (not luck) is what prevents the cascade. An impl that mapped unreachable→missing FAILS (1). +func TestNetStorage_UnreachableNotMissing(t *testing.T) { + s := testServer(t) + addNetworkPath(t, s, "media") // registered network path /mnt/felhom-drives/media + + // (1) a network path is never "missing" (no stop-cascade label), regardless of agent reachability. + if lbl, missing := s.missingStorageLabel("/mnt/felhom-drives/media"); missing { + t.Fatalf("a network path must NOT be 'missing' (got missing=true, label=%q)", lbl) + } + + // (2) the drive-absent gate skips the network path: with the agent reporting NO disks (the NAS is not + // a drive — Scenario D), the network path is absent from /disks but must produce NO Stop action. + netPaths := s.settings.GetStoragePaths() + if acts := planDriveGates(netPaths, nil); len(acts) != 0 { + t.Fatalf("the drive gate must produce NO action for a network path, got %+v", acts) + } + + // COMPANION: a real DRIVE path under the same parent, absent from /disks, DOES get a Stop action — + // proving the gate WOULD have stopped the network path's apps if it weren't exempted. + drivePaths := []settings.StoragePath{{Path: StableParentDir + "/realhdd", Schedulable: true}} + acts := planDriveGates(drivePaths, nil) + if len(acts) != 1 || !acts[0].Stop { + t.Fatalf("control: an absent DRIVE path under the parent must get a Stop action, got %+v", acts) + } +} + +// TestRefuseNetworkLifecycle is the unit-level discriminator: network → refused, drive/unknown → not. +func TestRefuseNetworkLifecycle(t *testing.T) { + s := testServer(t) + addNetworkPath(t, s, "media") + + w1 := httptest.NewRecorder() + if !s.refuseNetworkLifecycle(w1, "/mnt/felhom-drives/media") { + t.Fatal("a network path must be refused") + } + w2 := httptest.NewRecorder() + if s.refuseNetworkLifecycle(w2, "/mnt/hdd_1") { + t.Fatal("a drive/unknown path must NOT be refused by the network gate") + } +} diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index f4dfdab..f75ffde 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -217,6 +217,19 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error return nil } +// refuseNetworkLifecycle blocks a drive-lifecycle action (eject/decommission/migrate/wipe) on a NAS +// network-storage path. A network share is a DISTINCT class with no device lifecycle — its only +// lifecycle action is /api/netstorage/remove. Returns true (and writes the refusal) when the path is a +// network path; the caller must then return. This is the server-side Kind-gate that backs the UI gating. +func (s *Server) refuseNetworkLifecycle(w http.ResponseWriter, where string) bool { + if s.settings != nil && s.settings.IsNetworkStoragePath(where) { + writeDiskJSON(w, http.StatusBadRequest, false, + "ez hálózati tárhely (NAS) — a meghajtó-műveletek (leválasztás/leszerelés/áthelyezés/törlés) nem alkalmazhatók rá; használd az „Eltávolítás” gombot", nil) + return true + } + return false +} + // ---- HTTP handlers (behind RequireAuth + CsrfProtect) ----------------------------------------- // storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven @@ -253,6 +266,13 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.handleStorageMigrateStatus(w, r) case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost: s.handleStorageDecommission(w, r) + // NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*). + case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost: + s.handleNetStorageAdd(w, r) + case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet: + s.handleNetStorageList(w, r) + case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost: + s.handleNetStorageRemove(w, r) case r.URL.Path == "/api/storage/disconnect" && r.Method == http.MethodPost: s.handleStorageDisconnect(w, r) case r.URL.Path == "/api/storage/reconnect" && r.Method == http.MethodPost: @@ -276,6 +296,9 @@ func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) return } + if s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Source)) || s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Target)) { + return + } id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target)) if err != nil { writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil) @@ -328,6 +351,9 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) return } + if s.refuseNetworkLifecycle(w, req.Where) { + return + } switch req.Mode { case "migrate": @@ -554,6 +580,9 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) return } + if s.refuseNetworkLifecycle(w, req.Where) { + return + } // Server-side type-to-confirm: the typed name must match the mount's basename exactly. if strings.TrimSpace(req.MountName) != path.Base(req.Where) { writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil) @@ -716,6 +745,9 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) return } + if s.refuseNetworkLifecycle(w, req.Where) { + return + } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index a54caa5..5beed38 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -158,6 +158,7 @@ {{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} {{$ms := index $.MissingStorage .Name}}{{if $ms}}⚠ Hiányzó tárhely: {{$ms}}{{end}} + {{$nw := index $.NetworkWarnings .Name}}{{if $nw}}⚠ Hálózati tárhely nem elérhető: {{$nw}}{{end}} {{if and .Deployed (routeUnpublished .State)}}⚠ URL nem elérhető{{end}} {{if .Protected}} diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 1bc7334..cd48171 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -372,6 +372,105 @@ pollUntilBack(); {{end}} + +
+

Hálózati tárhely (NAS)

+

+ Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene). + A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai + meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás. +

+ {{if .NetworkStoragePaths}} +
+ {{range .NetworkStoragePaths}} +
+
+
+ {{.Label}} + {{.Protocol}} · {{.Server}}:{{.Export}} → {{.Path}} +
+
+ {{if eq .Health "ok"}}Elérhető + {{else if eq .Health "idle"}}Készenlét + {{else if eq .Health "unreachable"}}Nem elérhető + {{else}}Ismeretlen{{end}} +
+
+
+ +
+
+ {{end}} +
+ {{else}} +

Nincs hálózati tárhely beállítva.

+ {{end}} + +
+ Hálózati tárhely hozzáadása +
+
+
+
+
+
+
+
+
+
+ + A legtöbb médiaalkalmazás 1000-es uid-del fut.
+ + +
+
+
+
+ + {{if .Meta.Description}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 8f41621..6570c1d 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3146,6 +3146,7 @@ a.stat-card:hover { /* badges missing from the global sheet */ .badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; } +.badge-neutral { background: rgba(139, 148, 158, 0.18); color: #8b949e; } .badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); } .badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); } .badge-info { background: rgba(0, 136, 204, 0.18); color: var(--accent-light); }