b49076db4b
handleNetStorageRemove now refuses (409, Hungarian, names the apps) when any DEPLOYED stack's HDD_PATH is the share root or a subpath of it — the C6B live event removed campaign6 under a running sonarr, and the agent's tolerated best-effort stop steps then deleted the unit files under the busy mount, leaving an unreapable orphaned autofs mount until host reboot. The guard cuts that chain off at the product flow. The remove handler resolves the agent via the netAgent seam (netAgentForAdd), making the negative control testable. NOTE: the agent-side residual (tolerate-and-continue stop in felhom-agent netmount.go RemoveNetworkMount) is out of this controller-only task's scope — flagged in REPORT for a follow-up agent task. Red-proof recorded: disabling the guard returns the live pre-fix removed:true.
381 lines
17 KiB
Go
381 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)
|
|
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
|
|
}
|