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:
co-authored by
Claude Opus 4.7
parent
9f66c269c4
commit
3f9d8568f0
@@ -88,12 +88,14 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
"isos" | "images" => Ok(isos_text(state)),
|
||||
"clients" => Ok(clients_text(state)),
|
||||
"queue" => queue_command(state, tail).await,
|
||||
// v0.4.65: `nfs` is gone — replaced with userspace SMB share
|
||||
// consumer. `smb` still controls the outbound Samba server
|
||||
// for Windows install media; `share` lists/manages remote SMB
|
||||
// shares OpenPXE pulls ISOs from.
|
||||
// `smb` controls the outbound Samba server for Windows
|
||||
// install media. `share` lists/manages remote SMB shares
|
||||
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
|
||||
// command for remote NFSv3 shares (v0.4.67, in-process via
|
||||
// nfs3_client — not the v0.4.64 kernel-mount path).
|
||||
"share" | "smb-share" => smb_share_command(state, tail).await,
|
||||
"smb" => smb_command(state, tail).await,
|
||||
"nfs" => nfs_share_command(state, tail).await,
|
||||
"log" => log_command(state, tail),
|
||||
"whoami" => Ok("operator".to_string()),
|
||||
"echo" => Ok(tail.join(" ")),
|
||||
@@ -113,16 +115,20 @@ fn status_text(s: &AppState) -> String {
|
||||
let smb = s.smb.as_ref().map(|m| m.snapshot());
|
||||
let smb_shares = s.smb_shares.list();
|
||||
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
|
||||
// v0.4.67: NFSv3 sources too.
|
||||
let nfs_shares = s.nfs_shares.list();
|
||||
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
|
||||
format!(
|
||||
"OpenPXE {ver}\n\
|
||||
base url: {base}\n\
|
||||
interface: {nic}\n\
|
||||
uptime: {up}\n\
|
||||
isos: {n_isos} (local: {n_local}, smb: {n_smb})\n\
|
||||
isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\
|
||||
clients: {n_clients}\n\
|
||||
queue: {n_entries}\n\
|
||||
smb server: {smb}\n\
|
||||
smb shares: {n_total} configured ({n_active} reachable)\n",
|
||||
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
|
||||
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n",
|
||||
ver = env!("CARGO_PKG_VERSION"),
|
||||
base = s.public_base_url,
|
||||
nic = if s.nic_name.is_empty() {
|
||||
@@ -140,11 +146,17 @@ fn status_text(s: &AppState) -> String {
|
||||
.iter()
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
|
||||
.count(),
|
||||
n_nfs = isos
|
||||
.iter()
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
|
||||
.count(),
|
||||
n_clients = clients.len(),
|
||||
n_entries = queue_entries.len(),
|
||||
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
||||
n_total = smb_shares.len(),
|
||||
n_active = smb_reachable,
|
||||
n_smb_total = smb_shares.len(),
|
||||
n_smb_active = smb_reachable,
|
||||
n_nfs_total = nfs_shares.len(),
|
||||
n_nfs_active = nfs_reachable,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,8 +174,9 @@ fn isos_text(s: &AppState) -> String {
|
||||
for i in isos {
|
||||
let src = match i.source {
|
||||
openpxe_iso_store::IsoSource::Local => "local".to_string(),
|
||||
// v0.4.65: SMB userspace consumer replaced kernel-mount NFS.
|
||||
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
|
||||
// v0.4.67: NFSv3 via in-process nfs3_client.
|
||||
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
@@ -378,6 +391,91 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
|
||||
|
||||
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let shares = s.nfs_shares.list();
|
||||
if shares.is_empty() {
|
||||
return Ok("(no NFS shares configured)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<7} {:<6} TARGET",
|
||||
"ID", "STATUS", "ISOS"
|
||||
);
|
||||
for m in shares {
|
||||
let status = if m.reachable { "ok" } else { "down" };
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<7} {:<6} {}:{}",
|
||||
truncate(&m.id, 24),
|
||||
status,
|
||||
m.iso_count,
|
||||
m.server,
|
||||
m.export,
|
||||
);
|
||||
if let Some(e) = m.last_error {
|
||||
let _ = writeln!(out, " error: {e}");
|
||||
}
|
||||
if let Some(h) = m.last_hint {
|
||||
let _ = writeln!(out, " hint: {h}");
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some("add") => {
|
||||
// nfs add <server>:<export> [port]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
|
||||
let (server, export) = target
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
|
||||
let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
|
||||
let req = openpxe_iso_store::NfsAddRequest {
|
||||
server: server.to_string(),
|
||||
export: export.to_string(),
|
||||
port,
|
||||
};
|
||||
match s.nfs_shares.add(req).await {
|
||||
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
|
||||
Err(e) => {
|
||||
let mut out = format!("add failed: {}", e.error);
|
||||
if let Some(h) = e.hint {
|
||||
out.push_str("\nhint: ");
|
||||
out.push_str(&h);
|
||||
}
|
||||
Err(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("remove") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs remove <id>".to_string())?;
|
||||
match s.nfs_shares.remove(id).await {
|
||||
Ok(()) => Ok(format!("removed {id}")),
|
||||
Err(e) => Err(format!("remove failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some("scan") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
|
||||
match s.nfs_shares.rescan(id).await {
|
||||
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
|
||||
Err(e) => Err(format!("scan failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── smb ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
@@ -519,6 +617,11 @@ OpenPXE terminal — available commands:
|
||||
share remove <id> forget an SMB share
|
||||
share scan <id> re-list a share for new ISOs
|
||||
|
||||
nfs list list configured NFSv3 shares
|
||||
nfs add <srv>:<export> [port] add an NFSv3 share
|
||||
nfs remove <id> forget an NFS share
|
||||
nfs scan <id> re-list an NFS share for new ISOs
|
||||
|
||||
smb status outbound Samba state (Windows install media)
|
||||
smb start | stop | reload control the outbound smbd
|
||||
|
||||
|
||||
Reference in New Issue
Block a user