controller: F8 share-row stub fusion + F4 mapped_uid range validation (WIP, pre-build)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
This commit is contained in:
2026-07-12 09:37:12 +02:00
parent 0f311adaa1
commit 5d91fc8cce
3 changed files with 180 additions and 3 deletions
@@ -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())
}
})
}
}
+61 -2
View File
@@ -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 <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 {
@@ -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())})
@@ -20,7 +20,7 @@
{{if .NetworkStoragePaths}}
<div class="storage-paths-list">
{{range .NetworkStoragePaths}}
<div class="storage-path-item{{if eq .Health "unreachable"}} storage-disconnected{{end}}">
<div class="storage-path-item{{if or (eq .Health "unreachable") (eq .Health "stub")}} storage-disconnected{{end}}">
<div class="storage-path-header">
<div class="storage-path-info">
<span class="storage-path-label">{{.Label}}</span>
@@ -31,6 +31,7 @@
{{if .Orphan}}<span class="badge badge-warn" title="Beállítva a gazdagépen, de nincs regisztrálva">Árva</span>
{{else if eq .Health "ok"}}<span class="badge badge-ok" title="A megosztás elérhető és csatlakoztatva van">Elérhető</span>
{{else if eq .Health "idle"}}<span class="badge badge-neutral" title="Elérhető, jelenleg készenlétben (igény szerint csatlakozik)">Készenlét</span>
{{else if eq .Health "stub"}}<span class="badge badge-warn" title="A megosztás a gazdagépen elérhető, de az alkalmazások környezetében üres helyi könyvtár látszik — az adatok NEM a NAS-ra kerülnének">Hibás — az alkalmazások nem a NAS-t látják</span>
{{else if eq .Health "unreachable"}}<span class="badge badge-warn" title="A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla">Nem elérhető</span>
{{else}}<span class="badge badge-neutral" title="Az állapot jelenleg nem lekérdezhető">Ismeretlen</span>{{end}}
</div>