diff --git a/controller/internal/web/netstorage_coherence_test.go b/controller/internal/web/netstorage_coherence_test.go new file mode 100644 index 0000000..b73df49 --- /dev/null +++ b/controller/internal/web/netstorage_coherence_test.go @@ -0,0 +1,117 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// F8 (CAMPAIGN-3): the share row's health FUSES the agent view with the consuming-namespace +// classification, so it can never contradict the stacks-page stub badge (both read classifyFSPath). +func TestFuseNetHealth_Table(t *testing.T) { + cases := []struct { + name string + agentHealth string + class string + want string + }{ + // THE F8 contradiction, resolved: agent says benign idle+reachable, namespace says stub → stub. + {"idle+stub→stub", "idle", system.FSClassStub, netHealthStub}, + {"ok+stub→stub", "ok", system.FSClassStub, netHealthStub}, + // Healthy idle trigger must NOT be downgraded (the over-eager autofs=stub mutant fails here). + {"idle+autofs→idle", "idle", system.FSClassAutofs, "idle"}, + {"ok+network→ok", "ok", system.FSClassNetwork, "ok"}, + // A whole-server outage (agent unreachable) is more actionable and WINS over stub. + {"unreachable+stub→unreachable", "unreachable", system.FSClassStub, netHealthUnreachable}, + // An inconclusive classification never manufactures a fault. + {"idle+unknown→idle", "idle", system.FSClassUnknown, "idle"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := testServer(t) + s.classifyFSPath = func(string) string { return c.class } + if got := s.fuseNetHealth(c.agentHealth, "/mnt/felhom-drives/media"); got != c.want { + t.Errorf("fuseNetHealth(%q, class=%q) = %q, want %q", c.agentHealth, c.class, got, c.want) + } + }) + } +} + +// The row surfaced by the list handler reflects the fused stub verdict (end-to-end through +// networkStorageItems). COMPANION red-proof: drop the fuseNetHealth call → the row shows the raw +// agent health (unknown here, or idle live) and this stub assertion fails. +func TestNetStorageItems_FusesStub(t *testing.T) { + s := testServer(t) + addNetworkPath(t, s, "media") + // The consuming namespace sees a stub at the share path (the export-level outage the agent's + // server-level dial is blind to). + s.classifyFSPath = func(p string) string { + if strings.HasSuffix(p, "/media") { + return system.FSClassStub + } + return system.FSClassUnknown + } + items := s.networkStorageItems(context.Background()) + if len(items) != 1 { + t.Fatalf("want 1 item, got %d", len(items)) + } + if items[0].Health != netHealthStub { + t.Errorf("share row health = %q, want %q (F8 fusion — the row must match the stacks stub badge)", items[0].Health, netHealthStub) + } +} + +// F4 (CAMPAIGN-3): an out-of-range mapped_uid is refused at the door with a friendly 400 — the agent +// is never reached (the campaign's 101000 previously leaked a raw agent_error). +func TestNetStorageAdd_UIDRangeValidation(t *testing.T) { + body := func(uid int) string { + b, _ := json.Marshal(map[string]any{ + "name": "media", "protocol": "nfs", "server": "10.0.0.5", "export": "/srv/media", + "mapped_uid": uid, "mapped_gid": uid, + }) + return string(b) + } + + // 101000 → 400 with the friendly message; nothing reaches the agent. + t.Run("101000 refused with friendly 400", func(t *testing.T) { + s := testServer(t) + r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body(101000))) + w := httptest.NewRecorder() + s.handleNetStorageAdd(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("uid 101000: got %d want 400 (agent must never be reached)", w.Code) + } + if !strings.Contains(w.Body.String(), "érvénytelen") { + t.Errorf("expected the friendly uid message, got: %s", w.Body.String()) + } + }) + + // 65534 (nobody) → 400 (boundary just above valid). + t.Run("65534 refused", func(t *testing.T) { + s := testServer(t) + r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body(65534))) + w := httptest.NewRecorder() + s.handleNetStorageAdd(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("uid 65534: got %d want 400", w.Code) + } + }) + + // Valid uids pass the range check — they proceed past validation and fail later at the agent + // lookup (503, no agent in the test). NOT a 400 → the uid check let them through. + for _, uid := range []int{1000, 65533, 0 /* defaults to 1000 */} { + t.Run("valid uid passes range check", func(t *testing.T) { + s := testServer(t) + r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body(uid))) + w := httptest.NewRecorder() + s.handleNetStorageAdd(w, r) + if w.Code == http.StatusBadRequest { + t.Fatalf("uid %d was wrongly refused as out-of-range (400): %s", uid, w.Body.String()) + } + }) + } +} diff --git a/controller/internal/web/netstorage_handlers.go b/controller/internal/web/netstorage_handlers.go index e72b0bf..fa72158 100644 --- a/controller/internal/web/netstorage_handlers.go +++ b/controller/internal/web/netstorage_handlers.go @@ -11,6 +11,7 @@ import ( "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) + @@ -23,6 +24,19 @@ import ( // 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." @@ -50,8 +64,8 @@ type networkStorageItem struct { 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 + 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"` @@ -108,6 +122,16 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) { if gid <= 0 { gid = defaultMediaUID } + // F4 (CAMPAIGN-3): validate the CONTAINER uid/gid range at the door. The guest maps to + // +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 { @@ -191,6 +215,12 @@ func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem { 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 @@ -219,6 +249,35 @@ func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem { 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())}) diff --git a/controller/internal/web/templates/storage_network.html b/controller/internal/web/templates/storage_network.html index ee66a07..907bf9f 100644 --- a/controller/internal/web/templates/storage_network.html +++ b/controller/internal/web/templates/storage_network.html @@ -20,7 +20,7 @@ {{if .NetworkStoragePaths}}
{{range .NetworkStoragePaths}} -
+
{{.Label}} @@ -31,6 +31,7 @@ {{if .Orphan}}Árva {{else if eq .Health "ok"}}Elérhető {{else if eq .Health "idle"}}Készenlét + {{else if eq .Health "stub"}}Hibás — az alkalmazások nem a NAS-t látják {{else if eq .Health "unreachable"}}Nem elérhető {{else}}Ismeretlen{{end}}