R-67: the NAS share appears in FileBrowser (v0.160.0)

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
This commit is contained in:
2026-07-22 14:12:43 +02:00
parent 9610906916
commit 59cd260e57
7 changed files with 295 additions and 25 deletions
+83 -25
View File
@@ -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