Files
felhom.eu/documentation/controller/network-storage-nas.md
T

11 KiB

Network storage (NAS) — bulk-media shares with verify-before-commit

Status: authoritative feature doc, code-verified against felhom-agent v0.81.0 + felhom-controller v0.113.0 (2026-07-11). Evidence base: documentation/audits/SPIKE-nas-storage-2026-06-29.md (the locked mount recipe) and documentation/audits/SPIKE-nas-verify-2026-07-11.md (the verify mechanisms, squash matrix, error taxonomy — commit b57f6c1). Code: agent internal/storage/netmount.go / internal/storage/netverify.go / internal/localapi/netverifyjob.go; controller internal/web/netstorage_job.go / internal/web/netprobe*.go.

What it is

A customer's NAS serves bulk media (film, photo, music) to media apps. The share is mounted host-side by the agent under /mnt/felhom-drives/<name> via a systemd .automount + .mount pair (on-demand + idle-unmount), propagates into the guest through the existing shared mp8 bind, and is selectable as a media app's data path. A NAS is a distinct storage class from a drive: no durable-id, no SMART, no enroll/eject/decommission/wipe/migrate — remove is the only lifecycle action (refuseNetworkLifecycle blocks the drive verbs).

The locked mount recipe

  • NFS (nfs4): vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0. soft = failure isolation (clean EIO, never a wedge). retry=0 (SPIKE-nas-verify Q4-vi): without it a dead-NAS on-demand access wedges the accessing app until systemd's 90 s start cap (measured 91 s); with it the access fails clean in ~3.8 s (ENODEV) and each autofs re-access is a fresh attempt. retry only governs retrying a FAILED first attempt — the happy path is untouched. The uid mapping is the EXPORT's job (squash), never the client mount.
  • SMB (cifs): vers=3.0,credentials=<0600 file>,uid=<uid+100000>,gid=<gid+100000>,forceuid, forcegid,file_mode=0664,dir_mode=0775,_netdev. No retry= (a mount.nfs option; mount.cifs rejects it). Credentials live in an agent-written 0600 file, never in the registry, never controller-persisted.
  • The +100000 rule: container uid/gid N = host N+100000 (unprivileged-LXC idmap).

Verify-before-commit (the add pipeline)

POST /api/storage/netstorage/add never registers blind. The controller orchestrates (detached job, single-flight, ~150 s budget, status on GET /api/storage/netstorage/add/status):

  1. agent add (POST /netstorage/add): role-gate → sync fast-fail (spec validation + 2 s TCP pre-probe; an unreachable server is refused with NOTHING installed) → stage SMB creds → install unit pair + enable the automount → start the agent's detached verify job.
  2. agent verify (polled on GET /netstorage/verify-status every 2 s): a directory read through the automount triggers a REAL mount (spike Q1: an in-guest or host-side access wakes it, ~1 s LAN); success is judged from /proc/mounts only (truth table below); a failure is classified from the mount unit's journal and auto-rolled-back agent-side (units + creds).
  3. in-guest uid-1000 write probe: the controller re-execs itself (felhom-controller --netprobe <dir>) with SysProcAttr.Credential{Uid:1000,Gid:1000} — create a .felhom-proba-<random> dot-file, write a nonce, read back, compare, delete. This catches the squash trap: an export that mounts fine but denies every uid-1000 write (spike Q3 row c). A failed delete after a good write is a WARN, not a failure.
  4. register (AddStoragePath, Kind=network, Schedulable) — the LAST step. The worst crash outcome is an agent-side orphan (surfaced in the list as a remove-only "Árva megosztás" row), never a registered-but-broken path.

Any failure = full rollback: nothing registered, no units left installed, no creds file. If the agent restarts mid-verify, its in-memory verify slot reports phase none; the controller treats that as verify-lost and rolls back (the orphan row is the belt-and-braces surface).

Mount-success truth table (SPIKE-nas-verify §8)

Trigger ReadDir(where) /proc/mounts shows nfs4/cifs at where Verdict
ok yes mount OK → the uid-1000 probe decides writability
EACCES / EPERM yes mount OK (the agent user just can't read it — a 0700 export is fine)
any result no mount FAILED → classify from the journal
still blocked at 95 s timeout (black-holed-but-routed; systemd's 90 s cap resolves it)

Readability is NEVER the truth source — /proc/mounts is.

Error taxonomy (live-measured; classification is string-based BY DESIGN — every failure is rc=32)

Category Journal substring (verbatim) Meaning
unreachable No route to host / Connection refused / Connection timed out (or the 2 s sync pre-probe) no NAS behind the address
nfs_export reason given by server: No such file or directory export missing OR not permitted — MERGED: NFSv4 returns the identical string for both (Q4 ii≡iii); the customer message names both possibilities
smb_auth mount error(13) wrong SMB username/password
smb_share mount error(2) SMB share name not found
timeout Mounting timed out. Terminating routed but not answering (systemd 90 s cap)
mount_failed (none matched / journal unavailable) generic; detail carries the raw journal
not_writable (probe exit 2) mounts, but uid-1000 cannot write — the squash trap
probe_io (probe exit 3) write readback failed/differed

The journal is read unprivileged (journalctl -u <unit> -n 20 -o cat): the agent user is in the systemd-journal group (host-install ≥ v1.13.0 adds it; existing hosts: usermod -aG systemd-journal felhom-agent && systemctl restart felhom-agent). No sudoers grant — journal access is group-based by rule. Journal unavailable degrades to mount_failed with a hint; the rollback still runs.

NAS-side recipes (protocol-honest guidance — what the UI tells the customer)

  • SMB (Synology, QNAP — the consumer default, listed FIRST in the UI): a plain user account with rw on the share is sufficient — zero server-side uid configuration (spike Q5). Files land on the NAS owned by the connecting account; the client mount forces the guest view to uid/gid 1000. Hardlinks worked on Debian Samba/ext4 — re-verify per appliance.
  • NFS, simple recipe (Route A — spike Q3 row b, ACCEPTED): enable "map all users / all squash" on the export, rw, to ANY local user. The guest-uid-1000 app gets full read/write/rename/hardlink/delete. Caveats:
    • guest-visible ownership is nobody:nogroup (65534) — cosmetic for apps that just read/write;
    • chown fails with an immediate clean EPERM (no hang);
    • chmod SUCCEEDS and persists server-side (the squashed identity owns every file) — modes are app-controlled; operators should know a mode change on the NAS side is real.
  • NFS, full-fidelity recipe (TrueNAS / Linux server): rw,all_squash,anonuid=<uid+100000>,anongid=<uid+100000> (e.g. 101000 for a uid-1000 app) — ownership information is exact end-to-end.
  • The pinned WRONG case (what not_writable usually means): anonuid=<x> without all_squash is a no-op for non-root traffic — the app's wire uid (101000) hits the export unmapped and fails on others-perms. The consumer recipe REQUIRES the map-ALL-users mode; an anonymous-uid field alone does nothing. The UI's not_writable message and the guidance block tell this same story.

Health model

Per-share liveness only (ok | idle | unreachable): idle (automount idle-unmounted) is the benign steady state; unreachable degrades the affected share's apps ONLY — never box health, never the drive missing→stop cascade (kind-gated). Timing/budget note for integrators: the verify worst case is systemd's 90 s (black-holed server) — verify traffic therefore rides the detached job + status poll, never a single long HTTP call (the agentapi client keeps its global 15 s timeout).

Synology (DSM) — validated recipe (SPIKE-nas-dsm-2026-07-11, real DSM 7.2)

The consumer recipes were validated end-to-end against a real DSM 7.2 (virtual-dsm) through the live add pipeline. Exact steps for the customer/operator:

  • Enable NFS first (off by default, and defaults to NFSv3 which our mount rejects): Control Panel → File Services → NFS → "Enable NFS service" → Maximum NFS protocol: NFSv4.1 → Apply.
  • NFS rule: Control Panel → Shared Folder → → Edit → NFS Permissions → Create → the Felhom host IP, Read/Write, Squash: "Map all users to admin" → Save. The export path shown on that tab (/volume1/<share>) is what goes in the add form.
  • SMB: Control Panel → User & Group → create a user with Read/Write on the share — nothing else (no force-user). Hardlinks work on DSM's SMB stack (the Q5 caveat is closed for Synology).
  • Route A on DSM behaves exactly as on Debian: guest sees nobody, chown clean-EPERM, chmod persists. The SMB error taxonomy (smb_auth/smb_share) classifies identically to Debian.

QNAP remains a stated caveat — no emulator exists; not validated.

Reboot survival (Q1c) — automatic since agent v0.84.0, live-proven 2026-07-11

The Q1c failure (a fresh guest namespace inherits real mounts but NOT an idle autofs trigger → silent local stub after any guest reboot; RCA documentation/audits/AUDIT-nas-cwa-rca-2026-07-11.md) is FIXED by the pair agent v0.84.0 + controller v0.117.0:

  • Agent ReassertNetworkMounts (storage ReassertNetworkAutomounts): per share — real mount → skip (inherited); idle autofs trigger → systemctl stop + enable --now the .automount (existing sudoers verbs; the fresh trigger-mount event propagates into every running guest's slave bind), then best-effort per-guest GuestSeesMount verify. Runs (a) from the guest-hook post-start phase (root, direct systemctl, non-fatal by contract) and (b) once at agent startup (guests that autostarted before the agent). Never from periodic health paths — an idle trigger is healthy.
  • Controller consuming-namespace verification (the detection net beneath it): statfs f_type classifier; --netprobe refuses a non-network dir (not_network_fs, full rollback); deploys onto a stubbed network HDD_PATH are refused (409); a distinct dashboard stub badge ("Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja") wins over the recoverable unreachable badge.

Live proof (2026-07-11 21:20, demo pct reboot 9201): the start-task transcript itself carried felhom-agent guest-hook: vmid 9201 post-start — network share nas-media visible in guest (rearmed); post-boot with NO manual action the guest had trigger + nfs4, calibre-web auto-started healthy on the NAS library, uid-1000 write-through OK, health status=ok on the first cycle, no storage badge. (The backlog spec documentation/backlog/FOLLOWUP-nas-automount-guest-reboot-reassert.md is implemented by this.)

Open items

  • QNAP appliance fidelity pass (no emulator) before GA — Synology is now validated.
  • The demo's nas-media share predates retry=0; re-adding re-creates the unit with the current option string.