c358a361d0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
150 lines
10 KiB
Markdown
150 lines
10 KiB
Markdown
# 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 → <share> → 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.
|
|
|
|
## Open items
|
|
|
|
- **Q1c CLOSED — FAIL (SPIKE-nas-dsm-2026-07-11 §Q1c, supervised):** a configured NAS automount
|
|
trigger does **not** survive a guest reboot. `mp8` is `shared`→`shared,slave`; slave propagation
|
|
only carries mounts established AFTER the guest's post-reboot bind, so the pre-existing host autofs
|
|
trigger is absent from the guest's fresh namespace, and an in-guest access sees an **empty
|
|
directory** (not the share) without triggering the host mount. The controller's per-share health
|
|
still reads `idle`/`ok` (it inspects the HOST automount), masking the gap. **Fix = agent-side
|
|
`ReassertNetworkMounts` on guest reboot** (mirror `ReassertGuestBinds`), spec'd at
|
|
`documentation/backlog/FOLLOWUP-nas-automount-guest-reboot-reassert.md`; interim workaround = re-add
|
|
the share. Until fixed, a customer guest reboot requires re-touching NAS shares.
|
|
- 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.
|