v0.4.67: NFSv3 alongside SMB (in-process via nfs3_client crate)

NFS is back — done right this time. v0.4.67 ships a pure-Rust NFSv3
client (`nfs3_client` 0.9 from the xetdata/Vaiz crate family) running
in-process inside the openpxe binary. No `mount.nfs`, no kernel
modules, no `CAP_SYS_ADMIN`, no subprocess. Works in every container
that the v0.4.65 SMB path works in (Unraid included).

The v0.4.65 SMB path stays as-is. Operators get both protocols
side-by-side and pick whichever their NAS prefers — or use both
together. NFSv3 has one architectural advantage over the SMB
userspace path: HTTP Range requests work for NFS-sourced ISOs
because NFSv3 READ3 takes an explicit offset. SMB-sourced ISOs still
return 416 for ranges (smbclient CLI can't seek mid-stream).

## What's new

- `crates/iso-store/src/nfs_share.rs` — `NfsShareManager` mirroring
  `SmbShareManager` structurally. Lists ISOs via READDIR3+LOOKUP3+
  GETATTR3, streams files via READ3 in 64 KiB chunks piped to axum
  body streams. Uses `connect_from_privileged_port(false)` because
  the openpxe binary runs as uid 10001 — most modern NFS servers
  allow that; a server that demands privileged ports needs
  `insecure` in /etc/exports, and the hint translation calls that
  out specifically.
- `IsoSource::Nfs { share_id, relative_path }` variant alongside the
  existing `Smb`. `IsoStore::iso_path_for` returns None for both;
  the HTTP handler dispatches to the right share manager.
- `/api/nfs-shares` CRUD + scan endpoints, parallel to
  `/api/smb-shares`. `POST` body: `{ server, export, port? }`.
- `nfs` terminal command back (this time as in-process, not kernel
  mount): `list | add <srv>:<export> [port] | remove | scan`. The
  v0.4.64 `nfs` command name pointing at kernel mount is moot
  history — same name, completely different mechanism.
- Storage tab: a new NFS shares card sits directly below the SMB
  shares card. The form is simpler (no auth fields) since NFSv3
  uses AUTH_SYS and access is gated server-side by client IP.
- Dashboard "Images available" tile sums SMB + NFS reachable shares
  into a generic "N remote shares" line.

## What's the same

- The structured `{error, stderr, hint}` JSON shape on failures
  matches the SMB API exactly, so the UI's error banner renders
  identically.
- Hint translation: NFS3ERR_ACCES → "exports list", NFS3ERR_NOENT →
  "export path doesn't exist", `mount denied` → "/etc/exports may
  need `insecure`", timeouts → "check IP/port/firewall".
- Persistence: `<work_dir>/nfs_shares.json`. No conflict with the
  long-dead v0.4.64 `nfs.json`.

## Why nfs3_client

User picked it: pure-Rust matches the architecture, NFSv3 covers the
real-world cases, AUTH_SYS keeps the UI simple. The crate is at
0.9.0, MIT/Unlicense, rust-version 1.88 (we're on 1.95). Tokio
feature flag enabled. Image size unchanged at compile time — single
musl static binary, no extra OS packages.

## Tests

160 passing (was 150 in v0.4.66, +10):
- nfs_share parser: stable share ids, server normalization (smb://,
  cifs://, \\, // all stripped).
- hint_for(): NFS3ERR_ACCES, NFS3ERR_NOENT, mount denied, unknown.
- status_label() covers the common nfsstat3 codes.
- HTTP integration: nfs-shares list starts empty, missing server
  rejected, export without leading slash rejected.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-28 12:56:46 -04:00
co-authored by Claude Opus 4.7
parent 9f66c269c4
commit 3f9d8568f0
12 changed files with 1466 additions and 69 deletions
+32 -19
View File
@@ -15,27 +15,33 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live.
///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
/// `Smb` entries (v0.4.65) point at a file inside a remote SMB share
/// that the `SmbShareManager` knows how to stream via Samba's
/// userspace `smbclient` CLI. The HTTP handler resolves the share by
/// id at request time and pipes `smbclient -c 'get file -'` straight
/// into the response body — no kernel mount, no local cache.
/// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
/// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
/// userspace `smbclient` CLI subprocess. No kernel mount, no local
/// cache. Sequential whole-file streaming; HTTP Range requests
/// return 416.
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
#[default]
Local,
/// v0.4.65: kernel-mount NFS is gone (it didn't work on Unraid
/// regardless of capabilities — the host kernel needs the nfs
/// client modules loaded). SMB via userspace `smbclient` works in
/// any container.
/// v0.4.65: SMB via userspace `smbclient` works in any container.
Smb {
share_id: String,
/// Filename at the share root. We don't support nested paths
/// in v0.4.65; ISOs live at the top of the share.
relative_path: String,
},
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
Nfs {
share_id: String,
/// Filename at the export root.
relative_path: String,
},
}
/// Where the ISO lands in the PXE menu hierarchy.
@@ -293,10 +299,11 @@ impl IsoStore {
None
}
}
// SMB sources have no local path — they're streamed via
// smbclient subprocess. Callers should check the source
// kind first and dispatch accordingly.
IsoSource::Smb { .. } => None,
// SMB and NFS sources have no local path — they're
// streamed in-process. Callers must inspect the source
// kind first and dispatch to the appropriate share
// manager.
IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None,
}
}
@@ -348,13 +355,19 @@ impl IsoStore {
}
/// Drop every entry that belongs to `share_id`. Used by the SMB
/// share manager when an operator removes a share, or before
/// re-scanning to clean out stale entries.
/// and NFS share managers when an operator removes a share, or
/// before re-scanning to clean out stale entries. The same id
/// space serves both protocols — share ids are slugified from
/// `server+share` (SMB) or `server+export` (NFS) and the
/// protocol-specific prefix prevents collisions.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write();
g.isos.retain(
|_, m| !matches!(&m.source, IsoSource::Smb { share_id: sid, .. } if sid == share_id),
);
g.isos.retain(|_, m| match &m.source {
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
sid != share_id
}
IsoSource::Local => true,
});
}
/// Set or clear an ISO's boot password.