From 59cd260e57e8d8846ccf5e8081b28d5d335bd250 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 22 Jul 2026 14:12:43 +0200 Subject: [PATCH] R-67: the NAS share appears in FileBrowser (v0.160.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU --- CHANGELOG.md | 34 ++++ REUSE.md | 1 + controller/README.md | 10 ++ .../internal/web/filebrowser_network_test.go | 162 ++++++++++++++++++ controller/internal/web/handlers.go | 108 +++++++++--- .../internal/web/netstorage_handlers.go | 3 + controller/internal/web/netstorage_job.go | 2 + 7 files changed, 295 insertions(+), 25 deletions(-) create mode 100644 controller/internal/web/filebrowser_network_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 659b2e8..1da3eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ ## Changelog +### v0.160.0 — R-67: the NAS share appears in FileBrowser (2026-07-22) + +No agent coupling; MinAgent unchanged. Origin: the R-64 pairing drill — the share said „Elérhető" +and the customer had no way to BROWSE it; FileBrowser synced drives only. + +**Phase-0 probe (GO, demo-hp, 2026-07-22):** with the Felhom-Share automount confirmed IDLE (autofs +trigger in /proc/mounts, no cifs mount), `docker run --rm -v …/Felhom-Share:/probe:rslave alpine ls +/probe` listed the real share content and left cifs mounted — an in-container access through an +rslave bind DOES wake the idle trigger, one namespace further than the spike's in-guest proof. The +design shipped as specified, no fallback fork needed. + +**The change:** `syncFileBrowserMounts`' path loop is extracted into the pure +`buildFileBrowserPaths` (deps injected: mount probe / FS classifier / skeleton fn / logger), which +now returns BOTH the mount lines and the config source set so the two can never disagree. Network +shares get their own branch: + +- bind = the share ROOT, `…/:/srv/:rslave` — `:rslave` is load-bearing (host-side + automount wake / idle-unmount events propagate into the running container); +- NO `EnsureUserdataSkeleton`, no userdata scoping — nothing is ever written toward the NAS; +- the drive-absent gate does NOT apply (an idle automount is healthy and would be skipped + forever); the gate is the `stub` classifier verdict instead — **the data-safety wrong case**: + exposing a local stub dir lets a customer upload files the real mount will later shadow, so a + stub share is excluded from mounts AND sources this pass with a WARN. autofs / network / + unknown / nil-classifier all include (fail open). +- Drive behavior is byte-identical (tested: the drive line with a share present equals the + drives-only render; drives always stay in the source list as before). +- NAS add-success (`runNetAdd` done) and remove (`handleNetStorageRemove`) now trigger + `SyncFileBrowserMounts()`; removal drops the source + mount on the next sync (F2 change + detection forces the recreate). + +Tests: `filebrowser_network_test.go` scenarios A–D. Red-proofs recorded in REPORT.md: A (network +routed through the drive branch → the skeleton-call assertion fails with the NAS path recorded) +and B (stub gate dropped → the stub share leaks into mounts + sources). + ### v0.159.0 — R-66: the box's own address becomes visible (2026-07-22) No agent coupling; MinAgent unchanged. Controller-only, three XS legs with one theme: **the box diff --git a/REUSE.md b/REUSE.md index 7e69793..198d2c3 100644 --- a/REUSE.md +++ b/REUSE.md @@ -213,6 +213,7 @@ | `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line | | `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease | | `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba ` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned | +| `buildFileBrowserPaths` + `fbPathDeps` (R-67, v0.160.0) | controller/internal/web/handlers.go | pure assembly of one FileBrowser sync pass: (mount lines, config source paths) from the registry, with per-kind gates | filebrowser_network_test.go. **Two storage classes, two DIFFERENT gates:** drives keep the drive-absent gate + userdata scoping + skeleton (byte-identical to pre-R-67 — tested); network shares bind the share ROOT `:rslave` with the STUB gate instead (`classifyFSPath`; stub ⇒ excluded from mounts AND sources — an exposed stub swallows uploads the real mount later shadows; idle autofs / unknown ⇒ include, fail open). NEVER call `EnsureUserdataSkeleton` toward a network path (red-proven); never force-wake an idle trigger in the sync (doctrine) | | `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) | | `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed | | `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart | diff --git a/controller/README.md b/controller/README.md index 402f1a5..541a421 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1309,6 +1309,16 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > `syncFileBrowserMounts` (system + additional drives); a **deploy belt** in `composeExecCustomEnv` > (on `up`) pre-creates every `${USERDATA_PATH}/...` bind source the app declares, so Docker never > auto-creates a userdata dir as guest-root (covers apps not in the skeleton). +> - **NAS shares browse too (v0.160.0, R-67):** a registered network storage binds its share ROOT +> into FileBrowser (`/mnt/felhom-drives/:/srv/:rslave`) — no userdata scoping and NO +> skeleton (Felhom convention dirs are never written onto a customer's own NAS; the sync is +> read-only toward the share). The gate differs from drives: an idle autofs trigger is HEALTHY +> and included (first access wakes it — Phase-0-probed through an rslave bind on demo-hp), while +> a `stub` classifier verdict (this namespace sees a local dir, not the NAS) EXCLUDES the share +> from both the mounts and the source list that pass — uploading into a stub would be silently +> shadowed by the real mount later. `unknown` fails open. NAS add-success and remove trigger the +> same debounced `SyncFileBrowserMounts`; pure assembly lives in `buildFileBrowserPaths` +> (handlers.go) with every edge seamed. > - **FileBrowser** mounts `/userdata` (was `appdata`) and runs as uid 1000 → it can create > folders + upload into the 2775 setgid tree (fixes the old permission-denied); app internals > (`appdata/`) are no longer browsable. **(v0.66.2)** its entrypoint is wrapped diff --git a/controller/internal/web/filebrowser_network_test.go b/controller/internal/web/filebrowser_network_test.go new file mode 100644 index 0000000..2605231 --- /dev/null +++ b/controller/internal/web/filebrowser_network_test.go @@ -0,0 +1,162 @@ +package web + +import ( + "bytes" + "log" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// R-67 — the NAS share appears in FileBrowser. Scenarios A–D drive buildFileBrowserPaths with +// every edge seamed (no drive, no NAS, no docker anywhere near these tests). + +func fbTestDrive() settings.StoragePath { + return settings.StoragePath{Path: "/mnt/felhom-drives/hdd_1", Label: "Külső HDD", Schedulable: true} +} + +func fbTestShare() settings.StoragePath { + return settings.StoragePath{ + Path: "/mnt/felhom-drives/Felhom-Share", Label: "Felhom Share", Schedulable: true, + Kind: settings.StorageKindNetwork, Protocol: "smb", Server: "192.168.0.104", Export: "Felhom-Share", + } +} + +// fbDeps returns deps where the drive is a live mountpoint, the classifier is scripted, and the +// skeleton fake RECORDS every invocation — the A red-proof hangs off that recording. +func fbDeps(classifyResult string, skeletonCalls *[]string, logBuf *bytes.Buffer) fbPathDeps { + var lg *log.Logger + if logBuf != nil { + lg = log.New(logBuf, "", 0) + } + return fbPathDeps{ + isMount: func(string) bool { return true }, + classify: func(string) string { return classifyResult }, + ensureSkeleton: func(path string) error { + *skeletonCalls = append(*skeletonCalls, path) + return nil + }, + logger: lg, + } +} + +// Scenario A: one drive + one network share (healthy verdict) → the drive's userdata line is +// BYTE-IDENTICAL to a drives-only pass (the B′ invariant), the share binds its ROOT with :rslave, +// the config carries both sources, and the skeleton ran for the drive ONLY — never toward the NAS. +func TestFileBrowserNetworkShareIncluded(t *testing.T) { + drive, share := fbTestDrive(), fbTestShare() + var calls []string + mounts, cfgPaths := buildFileBrowserPaths([]settings.StoragePath{drive, share}, fbDeps(system.FSClassNetwork, &calls, nil)) + + var onlyCalls []string + onlyMounts, _ := buildFileBrowserPaths([]settings.StoragePath{drive}, fbDeps(system.FSClassNetwork, &onlyCalls, nil)) + + // B′: the drive line with the share present is byte-identical to the drives-only render. + if len(onlyMounts) != 1 || len(mounts) != 2 { + t.Fatalf("mounts: got %v / drives-only %v", mounts, onlyMounts) + } + if mounts[0] != onlyMounts[0] { + t.Errorf("drive line changed by the network branch:\n with: %q\n only: %q", mounts[0], onlyMounts[0]) + } + wantDrive := " - " + appbackup.UserdataDir(drive.Path) + ":/srv/hdd_1" + if mounts[0] != wantDrive { + t.Errorf("drive line = %q, want %q", mounts[0], wantDrive) + } + // The share binds its ROOT (not a userdata subtree) with :rslave — the propagation flag is + // load-bearing (idle-wake events must reach the running container). + wantShare := " - /mnt/felhom-drives/Felhom-Share:/srv/Felhom-Share:rslave" + if mounts[1] != wantShare { + t.Errorf("share line = %q, want %q", mounts[1], wantShare) + } + // Skeleton: exactly the drive, NEVER the NAS. (Red-proof: routing network paths through the + // drive branch makes this fail with the share path recorded.) + if len(calls) != 1 || calls[0] != drive.Path { + t.Errorf("skeleton calls = %v, want exactly [%s] — a skeleton toward the NAS writes Felhom convention dirs onto the customer's own NAS", calls, drive.Path) + } + // Config has both sources, share named by its display label. + cfg := infra.RenderFileBrowserConfig(cfgPaths) + for _, m := range []string{`- path: "/srv/hdd_1"`, `- path: "/srv/Felhom-Share"`, `name: "Felhom Share"`} { + if !strings.Contains(cfg, m) { + t.Errorf("config missing %q:\n%s", m, cfg) + } + } + // And the full compose render carries both lines (the renderer passes propagation through). + compose := infra.RenderFileBrowserCompose("example.hu", mounts) + if !strings.Contains(compose, wantShare) || !strings.Contains(compose, wantDrive) { + t.Errorf("compose lost a mount line:\n%s", compose) + } +} + +// Scenario B: stub verdict → the share is excluded from BOTH mounts and sources this pass, the +// drive is untouched, and the warn is logged. The wrong case this gate kills: a customer uploads +// into a local stub dir that the real mount later shadows — their files silently vanish from view. +// Red-proof: dropping the stub gate fails the absent-line assertions. +func TestFileBrowserNetworkStubExcluded(t *testing.T) { + drive, share := fbTestDrive(), fbTestShare() + var calls []string + var logBuf bytes.Buffer + mounts, cfgPaths := buildFileBrowserPaths([]settings.StoragePath{drive, share}, fbDeps(system.FSClassStub, &calls, &logBuf)) + + if len(mounts) != 1 || strings.Contains(mounts[0], "Felhom-Share") { + t.Errorf("stub share leaked into mounts: %v", mounts) + } + cfg := infra.RenderFileBrowserConfig(cfgPaths) + if strings.Contains(cfg, "Felhom-Share") { + t.Errorf("stub share leaked into the source list:\n%s", cfg) + } + if !strings.Contains(cfg, `- path: "/srv/hdd_1"`) { + t.Errorf("drive source lost while excluding the stub share:\n%s", cfg) + } + if !strings.Contains(logBuf.String(), "local stub") { + t.Errorf("stub exclusion not logged: %q", logBuf.String()) + } +} + +// Scenario C: the autofs-trigger verdict (idle automount) INCLUDES the share — idle is healthy; +// the wrong case here is treating idle like a detached drive (the pre-R-67 gate skipped an idle +// share forever). unknown also includes (fail open), as does a nil classifier. +func TestFileBrowserNetworkIdleIncluded(t *testing.T) { + share := fbTestShare() + for _, verdict := range []string{system.FSClassAutofs, system.FSClassNetwork, system.FSClassUnknown} { + var calls []string + mounts, cfgPaths := buildFileBrowserPaths([]settings.StoragePath{share}, fbDeps(verdict, &calls, nil)) + if len(mounts) != 1 || !strings.Contains(mounts[0], ":rslave") { + t.Errorf("verdict %q: share not mounted: %v", verdict, mounts) + } + if len(cfgPaths) != 1 { + t.Errorf("verdict %q: share not in sources", verdict) + } + } + // nil classifier (no seam wired) must fail OPEN — never hide the share. + var calls []string + d := fbDeps("", &calls, nil) + d.classify = nil + if mounts, _ := buildFileBrowserPaths([]settings.StoragePath{share}, d); len(mounts) != 1 { + t.Errorf("nil classifier hid the share: %v", mounts) + } +} + +// Scenario D: removal — a registry without the share renders with no trace of it, and the F2 +// change detection sees the difference (forces the recreate that drops the live mount). +func TestFileBrowserNetworkRemoval(t *testing.T) { + drive, share := fbTestDrive(), fbTestShare() + var calls []string + withMounts, withCfg := buildFileBrowserPaths([]settings.StoragePath{drive, share}, fbDeps(system.FSClassNetwork, &calls, nil)) + withoutMounts, withoutCfg := buildFileBrowserPaths([]settings.StoragePath{drive}, fbDeps(system.FSClassNetwork, &calls, nil)) + + oldCompose := infra.RenderFileBrowserCompose("example.hu", withMounts) + newCompose := infra.RenderFileBrowserCompose("example.hu", withoutMounts) + oldCfg := infra.RenderFileBrowserConfig(withCfg) + newCfg := infra.RenderFileBrowserConfig(withoutCfg) + + if strings.Contains(newCompose, "Felhom-Share") || strings.Contains(newCfg, "Felhom-Share") { + t.Error("removed share left a trace in the renders") + } + if !fbNeedsRecreate([]byte(oldCfg), []byte(newCfg), []byte(oldCompose), []byte(newCompose)) { + t.Error("F2 change detection missed the share removal — the live mount would linger") + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index f071ba8..762d85c 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "log" "net/http" "net/url" "os" @@ -2119,33 +2120,18 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { return } - // Build volume mount lines. SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer - // browses ONLY userdata — app internals (appdata/) and the recovery units + Tier 2 copies - // (backups/) are NOT mounted into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), - // and FileBrowser runs as uid 1000 → it can create folders + upload files (the old appdata mount - // was guest-root 0755 → permission-denied). Pre-create the full skeleton with the convention. - var storageMounts []string - for _, sp := range paths { - mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1" - // Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached — - // don't create its userdata skeleton (would write onto the rootfs) and don't mount it into - // FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates' - // external-only rule (system paths, not under StableParentDir, are never skipped). - if skipFileBrowserPath(sp.Path, system.IsMountPoint) { - s.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path) - continue - } - if err := appbackup.EnsureUserdataSkeleton(sp.Path); err != nil { - s.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err) - } - userdataSrc := appbackup.UserdataDir(sp.Path) - line := fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName) - storageMounts = append(storageMounts, line) - } + // Build volume mount lines + the config source set (R-67: the two must agree — a source with + // no mount behind it renders a broken sidebar entry). + storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{ + isMount: system.IsMountPoint, + classify: s.classifyFSPath, + ensureSkeleton: appbackup.EnsureUserdataSkeleton, + logger: s.logger, + }) - // Generate and write config.yaml (sources + sidebar entries per drive) + // Generate and write config.yaml (sources + sidebar entries per drive/share) configPath := stackDir + "/config.yaml" - fbConfig := generateFileBrowserConfig(paths) + fbConfig := generateFileBrowserConfig(configPaths) // Capture the current on-disk content BEFORE any writes, so we can detect whether this sync // actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml @@ -2215,6 +2201,78 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { } } +// fbPathDeps are the injectable edges of buildFileBrowserPaths — everything that would otherwise +// touch the real mount table, the real filesystem or a real statfs, so the A/B/C/D scenarios run +// without a drive, a NAS or docker. +type fbPathDeps struct { + isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint) + classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open) + ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY + logger *log.Logger +} + +// buildFileBrowserPaths computes one FileBrowser sync pass's volume mount lines + the source-list +// paths, with the per-kind gates applied. Two storage classes, two DIFFERENT gates: +// +// DRIVES (v0.66.0 semantics, unchanged byte-for-byte): scope to the `userdata/` subtree, pre-create +// the skeleton, and apply the drive-absent gate — a detached external drive must not get a skeleton +// written onto the rootfs. Drives always stay in the config source list (pre-R-67 behavior kept). +// +// NETWORK SHARES (R-67): the drive-absent gate does NOT apply — an idle automount trigger is +// HEALTHY (first access mounts it; the old gate skipped an idle share forever). The gate here is +// the STUB classifier instead, and it is a data-safety gate, not cosmetics: exposing a local stub +// dir lets a customer upload into a directory the real mount will later SHADOW — their files +// silently vanish from view. A stub share is excluded from BOTH the mounts and the source list +// this pass (a source without a mount is a broken sidebar entry). autofs-trigger / real network fs +// / unknown all include (unknown fails open — a wedged NAS must not hide the share forever). +// The bind is the share ROOT with :rslave — load-bearing: host-side automount wake and +// idle-unmount events must propagate into the RUNNING container (Phase-0 probe 2026-07-22 proved +// an in-container access through an rslave bind wakes the idle trigger). Never a skeleton, never +// any write toward the NAS — Felhom conventions must not be written onto a customer's own NAS. +func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageMounts []string, configPaths []settings.StoragePath) { + configPaths = make([]settings.StoragePath, 0, len(paths)) + for _, sp := range paths { + mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"; ".../Felhom-Share" → "Felhom-Share" + if sp.IsNetwork() { + if d.classify != nil && d.classify(sp.Path) == system.FSClassStub { + if d.logger != nil { + d.logger.Printf("[WARN] [web] FileBrowser: %s namespace sees a local stub, not the NAS — excluded until propagation recovers", mountName) + } + continue + } + storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s:rslave", sp.Path, mountName)) + configPaths = append(configPaths, sp) + continue + } + // Drives are ALWAYS in the source list (pre-R-67 behavior: the config listed every + // registered path; only the mount obeys the drive-absent gate). + configPaths = append(configPaths, sp) + // Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached — + // don't create its userdata skeleton (would write onto the rootfs) and don't mount it into + // FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates' + // external-only rule (system paths, not under StableParentDir, are never skipped). + if skipFileBrowserPath(sp.Path, d.isMount) { + if d.logger != nil { + d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path) + } + continue + } + // SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata — + // app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted + // into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as + // uid 1000 → it can create folders + upload files (the old appdata mount was guest-root 0755 + // → permission-denied). Pre-create the full skeleton with the convention. + if err := d.ensureSkeleton(sp.Path); err != nil { + if d.logger != nil { + d.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err) + } + } + userdataSrc := appbackup.UserdataDir(sp.Path) + storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName)) + } + return storageMounts, configPaths +} + // fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either // the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the // first-ever run the old files are empty → differs from the freshly generated content → true (creates diff --git a/controller/internal/web/netstorage_handlers.go b/controller/internal/web/netstorage_handlers.go index a36ab01..285bb19 100644 --- a/controller/internal/web/netstorage_handlers.go +++ b/controller/internal/web/netstorage_handlers.go @@ -325,6 +325,9 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request) 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}) } diff --git a/controller/internal/web/netstorage_job.go b/controller/internal/web/netstorage_job.go index 814f88f..9f7f62c 100644 --- a/controller/internal/web/netstorage_job.go +++ b/controller/internal/web/netstorage_job.go @@ -283,6 +283,8 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la s.netAdd.set(job) logx.Infof(s.logger, "[web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q) in %dms", req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn, time.Since(start).Milliseconds()) + // R-67: the fresh share becomes browsable — same debounced/mutexed path the drive flows use. + go s.SyncFileBrowserMounts() } // pollAgentVerify polls the agent's verify slot until it leaves `running` (or ctx expires).