59cd260e57
Network shares bind their share ROOT :rslave into FileBrowser — no skeleton, no userdata scoping, nothing written toward the NAS. Gate is the stub classifier (stub ⇒ excluded from mounts AND sources — an exposed stub swallows uploads the real mount later shadows); idle autofs is healthy and included. Drives byte-identical. Add/remove trigger the debounced sync. Phase-0 probe on demo-hp: GO (in-container rslave access wakes the idle trigger). Red-proofs A + B run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
384 lines
17 KiB
Go
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, 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
|
|
}
|
|
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, 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
|
|
}
|
|
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
|
|
}
|