From 3f9d8568f020f57021b644aae0f29122728ce93e Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Thu, 28 May 2026 12:56:46 -0400 Subject: [PATCH] v0.4.67: NFSv3 alongside SMB (in-process via nfs3_client crate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : [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: `/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) --- Cargo.lock | 50 +- Cargo.toml | 10 +- crates/http-api/src/app.rs | 155 ++++- crates/http-api/src/state.rs | 8 +- crates/http-api/src/terminal.rs | 121 +++- crates/http-api/tests/full_flow.rs | 52 +- crates/iso-store/Cargo.toml | 8 + crates/iso-store/src/lib.rs | 6 + crates/iso-store/src/nfs_share.rs | 917 +++++++++++++++++++++++++++++ crates/iso-store/src/store.rs | 51 +- crates/openpxe/src/main.rs | 15 +- crates/webui/src/app.js | 142 ++++- 12 files changed, 1466 insertions(+), 69 deletions(-) create mode 100644 crates/iso-store/src/nfs_share.rs diff --git a/Cargo.lock b/Cargo.lock index 37aff42..c449d3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,37 @@ dependencies = [ "version_check", ] +[[package]] +name = "nfs3_client" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ac227029a6127f3474b4fb61cbf2d3dddbe9424ad50c24fada68b633687a532" +dependencies = [ + "fastrand", + "nfs3_types", + "tokio", +] + +[[package]] +name = "nfs3_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nfs3_types" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94" +dependencies = [ + "nfs3_macros", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1140,7 +1171,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.66" +version = "0.4.67" dependencies = [ "anyhow", "axum", @@ -1162,7 +1193,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.66" +version = "0.4.67" dependencies = [ "anyhow", "bcrypt", @@ -1181,7 +1212,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.66" +version = "0.4.67" dependencies = [ "anyhow", "bytes", @@ -1195,7 +1226,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.66" +version = "0.4.67" dependencies = [ "anyhow", "axum", @@ -1226,7 +1257,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.66" +version = "0.4.67" dependencies = [ "openpxe-core", "rust-embed", @@ -1236,14 +1267,17 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.66" +version = "0.4.67" dependencies = [ "anyhow", "bcrypt", "bytes", + "futures", "hex", "image", "libc", + "nfs3_client", + "nfs3_types", "openpxe-core", "parking_lot", "serde", @@ -1260,7 +1294,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.66" +version = "0.4.67" dependencies = [ "anyhow", "bytes", @@ -1274,7 +1308,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.66" +version = "0.4.67" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index ca4e699..a182366 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.66" +version = "0.4.67" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" @@ -58,6 +58,14 @@ once_cell = "1.19" parking_lot = "0.12" rust-embed = { version = "8.5", features = ["include-exclude"] } +# v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65) +# kernel-mount NFS path with an in-process implementation that works +# in any container — no kernel modules, no CAP_SYS_ADMIN, no +# subprocess. Ships alongside the userspace SMB consumer; operators +# pick whichever protocol their NAS prefers. +nfs3_client = { version = "0.9", features = ["tokio"] } +nfs3_types = "0.5" + openpxe-core = { path = "crates/core" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-tftp = { path = "crates/tftp" } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 061767f..7d29f66 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -35,7 +35,7 @@ use openpxe_core::{ MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; -use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, SmbAddRequest}; +use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; @@ -141,6 +141,12 @@ pub fn build_router(state: AppState) -> Router { .route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add)) .route("/api/smb-shares/:id", delete(api_smb_shares_remove)) .route("/api/smb-shares/:id/scan", post(api_smb_shares_scan)) + // v0.4.67: NFSv3 share manager (pure-Rust in-process client). + // Ships alongside SMB. Routes are parallel so the UI can + // reuse the same form/error/hint rendering for both. + .route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add)) + .route("/api/nfs-shares/:id", delete(api_nfs_shares_remove)) + .route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan)) // Phase 4: Network info (read-only) + DNS edit. .route("/api/network", get(api_network).put(api_network_put)) // Phase 4: live-log stream + recent buffer for the Terminal tab. @@ -696,6 +702,58 @@ async fn iso_raw( Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(), } } + IsoSource::Nfs { + share_id, + relative_path, + } => { + // v0.4.67: NFS sources support Range requests because + // NFSv3 READ3 takes an explicit offset. We resolve the + // requested byte range here and pass start/len down to + // the streamer which seeks into the file via READ3. + let total = meta.size_bytes; + let range = match parse_range(headers.get(header::RANGE), total) { + Some(triple) => triple, + None if headers.get(header::RANGE).is_some() => { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .body(Body::empty()) + .unwrap(); + } + // No Range header — serve the whole file. + None => (0, total.saturating_sub(1), false), + }; + let (start, end, partial) = range; + let len = if total == 0 { 0 } else { end - start + 1 }; + let max_len = if total == 0 { None } else { Some(len) }; + match state + .nfs_shares + .stream_iso(share_id, relative_path, start, max_len) + .await + { + Ok(stream) => { + let body = Body::from_stream(stream); + let status = if partial { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + let mut builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CONTENT_LENGTH, len); + if partial { + builder = builder.header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ); + } + builder.body(body).unwrap() + } + Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(), + } + } } } @@ -1095,6 +1153,19 @@ async fn api_docs() -> Json { "summary": "Re-list a share for new ISOs."}, ], }, + { + "name": "NFS shares", + "endpoints": [ + {"method": "GET", "path": "/api/nfs-shares", + "summary": "List configured NFSv3 shares with connection state and iso counts."}, + {"method": "POST", "path": "/api/nfs-shares", + "summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."}, + {"method": "DELETE", "path": "/api/nfs-shares/:id", + "summary": "Forget a share and drop its entries from the ISO store."}, + {"method": "POST", "path": "/api/nfs-shares/:id/scan", + "summary": "Re-list a share for new ISOs."}, + ], + }, { "name": "Network", "endpoints": [ @@ -1509,11 +1580,13 @@ async fn api_list_clients(State(state): State) -> Json) -> Json { let smb = state.smb.as_ref().map(|s| s.snapshot()); - // v0.4.65: NFS replaced with SMB shares. The dashboard metric - // shape stays similar (count + reachable) so the UI doesn't have - // to change its top-line tiles. + // v0.4.65+v0.4.67: external storage shares — SMB (userspace + // smbclient) and NFS (in-process nfs3_client). Dashboard tile + // sums both so operators see a single "reachable shares" number. let smb_shares = state.smb_shares.list(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); + let nfs_shares = state.nfs_shares.list(); + let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); let isos = state.iso_store.list(); let clients = state.clients.list(); let queue_entries = state.queue.list(); @@ -1532,7 +1605,9 @@ async fn api_status(State(state): State) -> Json { state .metrics .set_queue_counts(queue_entries.len() as u64, imaging as u64); - state.metrics.set_nfs_active(smb_reachable as u64); + state + .metrics + .set_nfs_active((smb_reachable + nfs_reachable) as u64); state.metrics.record_http(openpxe_core::HttpRoute::Api); let now = time::OffsetDateTime::now_utc(); let uptime_secs = (now - state.started_at).whole_seconds().max(0); @@ -1547,12 +1622,13 @@ async fn api_status(State(state): State) -> Json { "ipxe_assets": openpxe_ipxe_assets::list_assets(), "settings": state.settings.snapshot(), "smb": smb, - // Keep the field names for now so existing UI bindings on - // `iso_count2` / `client_count2` / sidebar counters keep - // working. They cover "external storage shares" generically; - // v0.4.65 the source is SMB instead of NFS. "smb_share_count": smb_shares.len(), "smb_share_reachable": smb_reachable, + // v0.4.67: NFSv3 share counts. The dashboard tile sums these + // with the SMB counts above ("N shares reachable") so the + // top-line metric works regardless of protocol mix. + "nfs_share_count": nfs_shares.len(), + "nfs_share_reachable": nfs_reachable, "host_bindings": state.hosts.len(), "custom_logo": state.branding.has_logo(), "uptime_secs": uptime_secs, @@ -1808,6 +1884,47 @@ async fn api_smb_shares_scan( } } +// ─── NFS share API (v0.4.67) ─────────────────────────────────────────────── +// +// Parallel to the SMB shares API. The pure-Rust NFSv3 client +// (`nfs3_client`) gives us in-process listing and streaming, no +// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range +// requests — NFSv3 READ3 takes an explicit offset. + +async fn api_nfs_shares_list(State(state): State) -> Json { + Json(json!({ "shares": state.nfs_shares.list() })) +} + +async fn api_nfs_shares_add( + State(state): State, + Json(req): Json, +) -> Response { + match state.nfs_shares.add(req).await { + Ok(s) => (StatusCode::CREATED, Json(s)).into_response(), + Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(), + } +} + +async fn api_nfs_shares_remove( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.nfs_shares.remove(&id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + +async fn api_nfs_shares_scan( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.nfs_shares.rescan(&id).await { + Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), + } +} + // ─── Network info API ────────────────────────────────────────────────────── async fn api_network(State(state): State) -> Json { @@ -1922,11 +2039,21 @@ async fn api_metrics(State(state): State) -> Response { state .metrics .set_queue_counts(queue_entries.len() as u64, imaging as u64); - // v0.4.65: gauge tracks reachable external-storage shares. With - // NFS removed it now reflects SMB share reachability instead. - state - .metrics - .set_nfs_active(state.smb_shares.list().iter().filter(|m| m.reachable).count() as u64); + // v0.4.67: gauge tracks reachable external-storage shares + // across both protocols (SMB userspace + NFSv3 in-process). + let smb_ok = state + .smb_shares + .list() + .iter() + .filter(|m| m.reachable) + .count(); + let nfs_ok = state + .nfs_shares + .list() + .iter() + .filter(|m| m.reachable) + .count(); + state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64); let now = time::OffsetDateTime::now_utc(); let uptime = (now - state.started_at).whole_seconds().max(0) as u64; diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index 720d3d4..46a92fe 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -4,7 +4,7 @@ use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore, SsoStore, }; -use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager}; +use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; use std::sync::Arc; use time::OffsetDateTime; @@ -52,6 +52,12 @@ pub struct AppState { /// `smbclient` does the SMB protocol over a plain TCP socket in /// userspace — works in any container, no special caps required. pub smb_shares: SmbShareManager, + /// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer + /// via the `nfs3_client` crate. Ships alongside the SMB manager + /// so operators pick whichever protocol their NAS prefers. + /// In-process (no subprocess); supports HTTP Range requests on + /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. + pub nfs_shares: NfsShareManager, /// Browser chunked upload state. Multipart uploads still go straight /// through `IsoStore`, but the UI uses sessions so large ISO transfers /// can show deterministic progress and leave visible partial files. diff --git a/crates/http-api/src/terminal.rs b/crates/http-api/src/terminal.rs index bf63bef..c54dfc9 100644 --- a/crates/http-api/src/terminal.rs +++ b/crates/http-api/src/terminal.rs @@ -88,12 +88,14 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result { "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 Result { + 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 : [port] + let target = args + .get(1) + .ok_or_else(|| "usage: nfs add : [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::().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 ".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 ".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 forget an SMB share share scan re-list a share for new ISOs + nfs list list configured NFSv3 shares + nfs add : [port] add an NFSv3 share + nfs remove forget an NFS share + nfs scan re-list an NFS share for new ISOs + smb status outbound Samba state (Windows install media) smb start | stop | reload control the outbound smbd diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index ae1d1aa..6cec76c 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -14,7 +14,7 @@ use axum::body::Body; use axum::http::{header, Request, StatusCode}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, SmbShareManager}; +use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager}; use tempfile::tempdir; use tower::ServiceExt; @@ -95,6 +95,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let queue = DeploymentQueue::new(); let settings = SettingsStore::load_or_default(dir.path()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); + let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); let log_bus = LogBus::new(64); let hosts = HostBindings::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); @@ -117,6 +118,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { metrics, smb: None, smb_shares, + nfs_shares, uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus, started_at: time::OffsetDateTime::now_utc(), @@ -531,6 +533,54 @@ async fn smb_shares_list_starts_empty() { assert_eq!(v["shares"].as_array().unwrap().len(), 0); } +// v0.4.67: NFSv3 share manager (parallel to SMB). + +#[tokio::test] +async fn nfs_shares_list_starts_empty() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = get(&app, "/api/nfs-shares").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["shares"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn nfs_shares_add_rejects_missing_server() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = post_json( + &app, + "/api/nfs-shares", + r#"{"server":"","export":"/srv/isos"}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let msg = String::from_utf8_lossy(&b); + assert!( + msg.to_lowercase().contains("server"), + "expected server hint, got: {msg}" + ); +} + +#[tokio::test] +async fn nfs_shares_add_rejects_export_without_leading_slash() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = post_json( + &app, + "/api/nfs-shares", + r#"{"server":"10.0.0.5","export":"srv/isos"}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let msg = String::from_utf8_lossy(&b); + assert!( + msg.to_lowercase().contains("export"), + "expected export-path hint, got: {msg}" + ); +} + #[tokio::test] async fn terminal_help_and_status_round_trip() { let (state, _dir) = build_state().await; diff --git a/crates/iso-store/Cargo.toml b/crates/iso-store/Cargo.toml index 36b663c..d5f6849 100644 --- a/crates/iso-store/Cargo.toml +++ b/crates/iso-store/Cargo.toml @@ -35,5 +35,13 @@ libc = "0.2" # encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc. image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } +# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a +# kernel mount. See crates/iso-store/src/nfs_share.rs for usage. +nfs3_client = { workspace = true } +nfs3_types = { workspace = true } +# Needed for the Stream trait that wraps the mpsc receiver feeding +# NFS read-loop bytes into axum's Body::from_stream. +futures = { workspace = true } + [dev-dependencies] tempfile = "3.12" diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index 2591338..ed56612 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -18,6 +18,7 @@ pub mod entry; pub mod introspect; +pub mod nfs_share; pub mod pxe_logo; pub mod smb; pub mod smb_share; @@ -33,6 +34,11 @@ pub use introspect::{DistroFamily, IntrospectionReport}; // it. pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream}; +// v0.4.67: NFS is back — this time as an in-process userspace NFSv3 +// client (the `nfs3_client` crate) rather than a kernel mount. Same +// "works in any container" property as SMB, plus support for HTTP +// Range requests because NFSv3 READ3 takes an explicit offset. +pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; pub use store::{ generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, diff --git a/crates/iso-store/src/nfs_share.rs b/crates/iso-store/src/nfs_share.rs new file mode 100644 index 0000000..291f23f --- /dev/null +++ b/crates/iso-store/src/nfs_share.rs @@ -0,0 +1,917 @@ +//! NFSv3 share consumer — in-process userspace client. +//! +//! v0.4.67 brings NFS back, this time *the right way*: a pure-Rust +//! NFSv3 client (`nfs3_client` from the xetdata family of crates) +//! that speaks the NFS protocol entirely in userspace over TCP. No +//! `mount.nfs`, no kernel modules, no `CAP_SYS_ADMIN`. Works in every +//! container the SMB path (v0.4.65) works in, including Unraid and +//! restricted-SCC OpenShift. +//! +//! ## How it differs from the v0.4.64 NFS path +//! +//! v0.4.64 shelled out to `mount(8)` and asked the kernel to attach +//! the remote share to the local filesystem. That required nfs client +//! modules on the **host** kernel. Containers that ran on hosts +//! without those modules — Unraid being the dominant case — failed +//! with `mount.nfs: failed to apply fstab options` no matter what +//! capabilities they were granted. +//! +//! v0.4.67 doesn't touch the kernel. The client opens a TCP socket to +//! the NFS server, performs the MOUNT3 RPC to get a root file handle, +//! and uses NFSv3 READDIR3 / LOOKUP3 / READ3 / GETATTR3 to walk the +//! share and stream files. The kernel sees plain TCP traffic, nothing +//! else. +//! +//! ## How it differs from the v0.4.65 SMB path +//! +//! SMB ships in v0.4.65 via the `smbclient` CLI subprocess. NFS ships +//! here via a Rust crate — no subprocess, no PATH lookup, no parsing +//! of human-formatted output. That also means: +//! +//! - **Range requests work** for NFS-sourced ISOs. NFSv3 READ3 +//! takes an explicit offset, so the HTTP handler can seek into +//! the middle of a 5 GB ISO without downloading what comes +//! before. SMB-sourced ISOs still return 416 for ranges because +//! `smbclient -c 'get file -'` is a sequential stream. +//! - **Introspection could work** (we could LOOKUP the ISO9660 PVD +//! at offset 0x8000 and parse the volume label). For v0.4.67 we +//! don't yet — same as SMB, NFS-sourced ISOs register with an +//! `Unknown` family and fall back to generic sanboot. A follow-up +//! can add bounded read-based detection. +//! +//! ## How it's the same as SMB +//! +//! The public surface is deliberately parallel: `NfsShareManager` +//! mirrors `SmbShareManager`, `NfsShare` mirrors `SmbShare`, the API +//! responses use the same `{error, stderr, hint}` shape so the UI's +//! storage tab renders both protocols through one code path. The +//! state file (`/nfs_shares.json`) sits next to +//! `smb_shares.json`. +//! +//! ## No auth +//! +//! NFSv3 uses AUTH_SYS by default — the client tells the server "I'm +//! uid X, gid Y" and the server decides whether to trust that. Most +//! NAS appliances exposing ISO libraries via NFS gate access by +//! **client IP address** rather than uid, so there's nothing for the +//! UI to ask for. (If a future server needs Kerberos or non-default +//! uid mapping we can add those, but for ISO read access nobody does.) + +use crate::introspect::{DistroFamily, IntrospectionReport}; +use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; +use bytes::Bytes; +use nfs3_client::tokio::TokioConnector; +use nfs3_client::Nfs3ConnectionBuilder; +use nfs3_types::nfs3::{ + self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, + Nfs3Result, READ3args, READDIR3args, +}; +use nfs3_types::xdr_codec::Opaque; +use openpxe_core::{Error, Result}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; + +/// Default TCP port for the NFSv3 protocol. The classic split (111 +/// portmapper + dynamic mountd) is supported by the underlying crate; +/// for direct connection-mode NAS appliances 2049 is what answers. +const DEFAULT_NFS_PORT: u16 = 2049; + +/// How long we wait for the initial MOUNT3 + portmap handshake before +/// giving up. Mirrors the SMB pre-flight probe shape so the UI's +/// timeout banner reads consistently across protocols. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(8); + +/// NFSv3 READ3 chunk size. The protocol's hard cap is 1 MiB per +/// reply; 64 KiB is a polite default that nearly every server hands +/// back without fragmentation and keeps in-flight memory bounded +/// during a streaming response. +const READ_CHUNK_BYTES: u32 = 64 * 1024; + +/// Bound on the in-flight queue between the read-loop task and the +/// HTTP body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream — +/// enough to keep the network pipe full without letting a slow +/// client park gigabytes of decoded ISO in RAM. +const STREAM_BUFFER_DEPTH: usize = 16; + +/// One configured NFS share. The id is derived from server+export so +/// re-adding the same coordinates is idempotent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NfsShare { + pub id: String, + pub server: String, + /// Export path on the server (e.g. "/srv/isos"). Must start with + /// "/" to match the NFS server's view; we validate on add. + pub export: String, + /// TCP port — 2049 unless the operator overrode it. + #[serde(default = "default_port")] + pub port: u16, + /// Most recent error talking to the share, or `None` on success. + pub last_error: Option, + /// Operator-friendly translation of `last_error`. None when we + /// don't have a friendlier rendition. + pub last_hint: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_scan: Option, + pub iso_count: u32, + /// True after a successful scan, false on failure. Drives the + /// UI dot. + pub reachable: bool, +} + +/// Submission from the UI / API. +#[derive(Debug, Clone, Deserialize)] +pub struct NfsAddRequest { + pub server: String, + pub export: String, + #[serde(default)] + pub port: Option, +} + +fn default_port() -> u16 { + DEFAULT_NFS_PORT +} + +/// Structured error surfaced to the API and rendered in the UI. Same +/// shape as `SmbShareError` so the storage tab uses one rendering +/// path for both protocols. +#[derive(Debug, Clone, Serialize)] +pub struct NfsShareError { + pub error: String, + pub stderr: String, + pub hint: Option, +} + +impl NfsShareError { + fn from_raw(error: impl Into, stderr: impl Into) -> Self { + let stderr = stderr.into(); + let error = error.into(); + let hint = hint_for(&stderr).or_else(|| hint_for(&error)); + Self { + error, + stderr, + hint, + } + } +} + +#[derive(Debug, Default)] +struct Inner { + shares: HashMap, +} + +/// Manages NFS shares. Cheap to clone — internal state is +/// `Arc>`. +#[derive(Debug, Clone)] +pub struct NfsShareManager { + state_path: Arc, + inner: Arc>, + iso_store: IsoStore, + /// Serializes scan operations on the same manager. Each scan + /// opens its own NFS connection so concurrency isn't a hard + /// requirement, but serializing keeps log output predictable. + op_lock: Arc>, +} + +impl NfsShareManager { + /// Construct a manager. State persists to + /// `/nfs_shares.json`. + #[must_use] + pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { + let state_path = work_dir.join("nfs_shares.json"); + Self { + state_path: Arc::new(state_path), + inner: Arc::new(Mutex::new(Inner::default())), + iso_store, + op_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } + + /// Load persisted state and re-scan every share. Per-share + /// failures are logged but never propagated — startup must not + /// block on a single offline server. + pub async fn load_and_rescan(&self) -> Result<()> { + let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await { + Ok(text) => serde_json::from_str::>(&text).unwrap_or_default(), + Err(_) => Vec::new(), + }; + for mut s in shares { + s.last_error = None; + s.last_hint = None; + s.reachable = false; + self.inner.lock().shares.insert(s.id.clone(), s.clone()); + if let Err(e) = self.rescan_inner(&s.id).await { + tracing::warn!( + target: "openpxe::nfs", + id = %s.id, server = %s.server, export = %s.export, + "rescan on startup failed: {e}" + ); + } + } + Ok(()) + } + + /// Register an NFS share. Validates, probes connectivity by + /// performing a real MOUNT3 + READDIR3, and registers the + /// resulting ISOs with the store. + pub async fn add( + &self, + req: NfsAddRequest, + ) -> std::result::Result { + let server = normalize_server(&req.server); + let export = req.export.trim().to_string(); + if server.is_empty() { + return Err(NfsShareError::from_raw("server is required", "")); + } + if !export.starts_with('/') { + return Err(NfsShareError::from_raw( + "export path must start with '/' (e.g. /srv/isos)", + "", + )); + } + if server.contains('\0') || export.contains('\0') { + return Err(NfsShareError::from_raw("NUL bytes are not allowed", "")); + } + let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT); + + let id = share_id(&server, &export); + let spec = NfsShare { + id: id.clone(), + server, + export, + port, + last_error: None, + last_hint: None, + last_scan: None, + iso_count: 0, + reachable: false, + }; + self.inner.lock().shares.insert(id.clone(), spec); + self.persist_locked(); + + if let Err(e) = self.rescan_inner(&id).await { + let m = self.get(&id); + return Err(NfsShareError { + error: m.as_ref().and_then(|m| m.last_error.clone()) + .unwrap_or_else(|| e.to_string()), + stderr: String::new(), + hint: m.and_then(|m| m.last_hint), + }); + } + Ok(self.get(&id).expect("just inserted")) + } + + /// Remove a share. Drops every ISO sourced from it. Idempotent. + /// Async for symmetry with [`SmbShareManager::remove`] — the + /// SMB version is async because it tears down a credentials + /// file; NFS has nothing to clean up but we keep the signature + /// uniform so the call sites in app.rs / terminal.rs match. + #[allow(clippy::unused_async)] + pub async fn remove(&self, id: &str) -> Result<()> { + let removed = self.inner.lock().shares.remove(id).is_some(); + if removed { + self.iso_store.drop_external_source(id); + self.persist_locked(); + } + Ok(()) + } + + /// Re-walk a share for new / removed ISOs. + pub async fn rescan(&self, id: &str) -> Result { + self.rescan_inner(id).await + } + + #[must_use] + pub fn list(&self) -> Vec { + let g = self.inner.lock(); + let mut v: Vec<_> = g.shares.values().cloned().collect(); + v.sort_by(|a, b| a.id.cmp(&b.id)); + v + } + + #[must_use] + pub fn get(&self, id: &str) -> Option { + self.inner.lock().shares.get(id).cloned() + } + + /// Open a byte stream reading `filename` out of share `share_id`, + /// starting at `start_offset` and reading at most `max_len` + /// bytes. The returned stream yields `Bytes` chunks (≤ + /// `READ_CHUNK_BYTES`) and ends at EOF, after `max_len` bytes, or + /// on the first transport error. + /// + /// Used by the HTTP ISO download handler. Supports HTTP Range + /// requests because NFSv3 READ3 takes an explicit offset — this + /// is the protocol-level advantage NFS has over the SMB + /// userspace path. + /// + /// Kept `async` for symmetry with [`SmbShareManager::stream_iso`] + /// even though the body doesn't await today — a future + /// refinement (e.g. throttling, connection pooling) will need to + /// await without changing call sites. + #[allow(clippy::unused_async)] + pub async fn stream_iso( + &self, + share_id: &str, + filename: &str, + start_offset: u64, + max_len: Option, + ) -> Result { + let share = self + .get(share_id) + .ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?; + // Defensive: NFSv3 LOOKUP3 takes a single name relative to + // the export root, not a path. We don't support nested + // directories in v0.4.67 — ISOs live at the top of the share. + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(Error::Invalid(format!("invalid filename '{filename}'"))); + } + + let (tx, rx) = + tokio::sync::mpsc::channel::>(STREAM_BUFFER_DEPTH); + let server = share.server.clone(); + let export = share.export.clone(); + let port = share.port; + let fname = filename.to_string(); + + // Spawn a task that owns the NFS connection. Owning the + // connection inside the spawn means we don't have to worry + // about borrowing across awaits or sharing the connection + // between scan and stream — each stream gets its own. + let task = tokio::spawn(async move { + let result = stream_loop( + &server, + &export, + port, + &fname, + start_offset, + max_len, + tx.clone(), + ) + .await; + if let Err(e) = result { + // Best-effort signal of the error to the consumer. + // If the receiver has already dropped we just exit. + let _ = tx + .send(Err(std::io::Error::other(e.to_string()))) + .await; + } + }); + + Ok(NfsStream { + rx, + _task: task, + }) + } + + // ── internals ───────────────────────────────────────────────────── + + async fn rescan_inner(&self, id: &str) -> Result { + let _g = self.op_lock.lock().await; + let share = self + .get(id) + .ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?; + let now = OffsetDateTime::now_utc(); + + // Drop prior entries so a deleted file disappears from the + // store on the next scan. + self.iso_store.drop_external_source(id); + + let listing = match list_isos(&share.server, &share.export, share.port).await { + Ok(l) => l, + Err(err) => { + let stderr = err.to_string(); + let hint = hint_for(&stderr); + self.update_status(id, 0, false, Some(stderr.clone()), hint, now); + return Err(Error::Invalid(stderr)); + } + }; + + let mut count = 0u32; + for entry in listing { + let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename)); + // Same approach as SMB: no real introspection over the + // network in v0.4.67. The boot-entry generator falls back + // to filename-based sanboot detection. + let report = IntrospectionReport { + family: DistroFamily::Unknown, + volume_label: None, + kernel_path: None, + initrd_paths: Vec::new(), + has_boot_wim: false, + }; + let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); + let source = IsoSource::Nfs { + share_id: share.id.clone(), + relative_path: entry.filename.clone(), + }; + self.iso_store.register_external( + iso_id, + entry.filename, + entry.size, + report, + boot_entries, + source, + ); + count += 1; + } + self.update_status(id, count, true, None, None, now); + tracing::info!( + target: "openpxe::nfs", + id = %id, server = %share.server, export = %share.export, + iso_count = count, + "NFS share scanned" + ); + Ok(count) + } + + fn update_status( + &self, + id: &str, + iso_count: u32, + reachable: bool, + err: Option, + hint: Option, + ts: OffsetDateTime, + ) { + if let Some(s) = self.inner.lock().shares.get_mut(id) { + s.iso_count = iso_count; + s.reachable = reachable; + s.last_error = err; + s.last_hint = hint; + s.last_scan = Some(ts); + } + self.persist_locked(); + } + + fn persist_locked(&self) { + let shares: Vec = self.inner.lock().shares.values().cloned().collect(); + let path = self.state_path.as_path(); + let tmp = path.with_extension("json.tmp"); + let body = match serde_json::to_vec_pretty(&shares) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::nfs", "serialize: {e}"); + return; + } + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::nfs", "write tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, path) { + tracing::warn!(target: "openpxe::nfs", "rename: {e}"); + } + } +} + +/// HTTP body stream for an NFS-sourced ISO read. +/// +/// Implements `Stream>` so axum can convert +/// it into a response body via `Body::from_stream`. +#[derive(Debug)] +pub struct NfsStream { + rx: tokio::sync::mpsc::Receiver>, + /// Kept alive so the read-loop task isn't dropped while the HTTP + /// client is still consuming bytes. Dropping the stream cancels + /// the task, which is the right behavior on client disconnect. + _task: tokio::task::JoinHandle<()>, +} + +impl futures::Stream for NfsStream { + type Item = std::io::Result; + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.rx.poll_recv(cx) + } +} + +#[derive(Debug, Clone)] +struct NfsListEntry { + filename: String, + size: u64, +} + +/// Connect, READDIR the export root, look up each `*.iso` to get its +/// size + file handle. Returns a flat list. Errors are returned with +/// a human-readable message; the caller decides how to surface them. +async fn list_isos( + server: &str, + export: &str, + port: u16, +) -> std::result::Result, NfsClientError> { + let mut conn = + tokio::time::timeout(CONNECT_TIMEOUT, build_connection(server, export, port)) + .await + .map_err(|_| NfsClientError::Timeout(server.to_string(), port))??; + + let root = conn.root_nfs_fh3(); + let mut entries = Vec::new(); + let mut cookie: u64 = 0; + let mut cookieverf = nfs3::cookieverf3::default(); + loop { + let res = conn + .readdir(&READDIR3args { + dir: root.clone(), + cookie, + cookieverf, + count: 32 * 1024, + }) + .await + .map_err(NfsClientError::Rpc)?; + let ok = match res { + Nfs3Result::Ok(ok) => ok, + Nfs3Result::Err((status, _)) => { + return Err(NfsClientError::Nfsstat(status_label(status))); + } + }; + cookieverf = ok.cookieverf; + let eof = ok.reply.eof; + let dir_entries: Vec> = ok.reply.entries.0; + let next_cookie = dir_entries.last().map(|e| e.cookie); + for entry in dir_entries { + // entry.name is `filename3<'a>(Opaque<'a>)` — XDR opaque + // bytes. Decode to UTF-8 best-effort. + let name_bytes = entry.name.0.as_ref(); + let Ok(name) = std::str::from_utf8(name_bytes) else { + continue; + }; + if name == "." || name == ".." { + continue; + } + if !name.to_ascii_lowercase().ends_with(".iso") { + continue; + } + // Look up the file to get its size + verify it's a + // regular file (not a symlink / directory matching the + // .iso pattern). + let lookup = conn + .lookup(&LOOKUP3args { + what: diropargs3 { + dir: root.clone(), + name: filename3(Opaque::borrowed(name_bytes)), + }, + }) + .await + .map_err(NfsClientError::Rpc)?; + let lookup_ok = match lookup { + Nfs3Result::Ok(o) => o, + Nfs3Result::Err(_) => continue, + }; + let getattr = conn + .getattr(&GETATTR3args { + object: lookup_ok.object, + }) + .await + .map_err(NfsClientError::Rpc)?; + let attrs = match getattr { + Nfs3Result::Ok(o) => o.obj_attributes, + Nfs3Result::Err(_) => continue, + }; + // ftype3::NF3REG == 1 (regular file). Skip everything + // else — directories, symlinks, devices. + if attrs.type_ as u32 != nfs3::ftype3::NF3REG as u32 { + continue; + } + entries.push(NfsListEntry { + filename: name.to_string(), + size: attrs.size, + }); + } + if eof { + break; + } + match next_cookie { + Some(c) if c != 0 => cookie = c, + _ => break, + } + } + + let _ = conn.unmount().await; + Ok(entries) +} + +async fn stream_loop( + server: &str, + export: &str, + port: u16, + filename: &str, + start_offset: u64, + max_len: Option, + tx: tokio::sync::mpsc::Sender>, +) -> std::result::Result<(), NfsClientError> { + let mut conn = + tokio::time::timeout(CONNECT_TIMEOUT, build_connection(server, export, port)) + .await + .map_err(|_| NfsClientError::Timeout(server.to_string(), port))??; + + let root = conn.root_nfs_fh3(); + // Look up the file to get its handle. + let name_bytes = filename.as_bytes(); + let lookup = conn + .lookup(&LOOKUP3args { + what: diropargs3 { + dir: root, + name: filename3(Opaque::borrowed(name_bytes)), + }, + }) + .await + .map_err(NfsClientError::Rpc)?; + let lookup_ok = match lookup { + Nfs3Result::Ok(o) => o, + Nfs3Result::Err((status, _)) => { + return Err(NfsClientError::Nfsstat(status_label(status))); + } + }; + let file_handle: nfs_fh3 = lookup_ok.object; + + let mut offset = start_offset; + let mut remaining = max_len; + loop { + if let Some(r) = remaining { + if r == 0 { + break; + } + } + // Cap the chunk at READ_CHUNK_BYTES and at the remaining budget. + let chunk = match remaining { + Some(r) if r < u64::from(READ_CHUNK_BYTES) => r as u32, + _ => READ_CHUNK_BYTES, + }; + let res = conn + .read(&READ3args { + file: file_handle.clone(), + offset, + count: chunk, + }) + .await + .map_err(NfsClientError::Rpc)?; + let ok = match res { + Nfs3Result::Ok(o) => o, + Nfs3Result::Err((status, _)) => { + return Err(NfsClientError::Nfsstat(status_label(status))); + } + }; + let bytes = Bytes::copy_from_slice(ok.data.as_ref()); + let bytes_len = bytes.len() as u64; + if tx.send(Ok(bytes)).await.is_err() { + // HTTP client dropped — abort gracefully. + break; + } + offset += bytes_len; + if let Some(r) = remaining.as_mut() { + *r = r.saturating_sub(bytes_len); + } + if ok.eof { + break; + } + // Defensive: a server that returns 0 bytes without EOF + // would have us busy-looping. Bail out instead. + if bytes_len == 0 { + break; + } + } + + let _ = conn.unmount().await; + Ok(()) +} + +/// Hand the connection builder the user's settings. `mount_path` is +/// the server-side export (e.g. "/srv/isos"). We disable +/// `connect_from_privileged_port` because the openpxe process runs +/// as uid 10001 and can't bind sub-1024 source ports — and most +/// modern NFS servers no longer require them anyway. If a server +/// does demand it the operator's hint will guide them to the +/// `insecure` export option. +async fn build_connection( + server: &str, + export: &str, + port: u16, +) -> std::result::Result< + nfs3_client::Nfs3Connection>, + NfsClientError, +> { + Nfs3ConnectionBuilder::new(TokioConnector, server, export) + .connect_from_privileged_port(false) + .nfs3_port(port) + .mount() + .await + .map_err(|e| NfsClientError::Connect(e.to_string())) +} + +#[derive(Debug)] +enum NfsClientError { + Connect(String), + Timeout(String, u16), + Rpc(nfs3_client::RpcError), + Nfsstat(String), +} + +impl std::fmt::Display for NfsClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connect(msg) => write!(f, "connect failed: {msg}"), + Self::Timeout(host, port) => write!( + f, + "connect timed out after {}s talking to {host}:{port}", + CONNECT_TIMEOUT.as_secs() + ), + Self::Rpc(e) => write!(f, "RPC failure: {e}"), + Self::Nfsstat(s) => write!(f, "NFS server returned {s}"), + } + } +} + +impl std::error::Error for NfsClientError {} + +/// Best-effort label for an `nfsstat3` so the UI shows a readable +/// name like `NFS3ERR_ACCES` instead of a magic number. +fn status_label(status: nfs3::nfsstat3) -> String { + use nfs3::nfsstat3 as S; + let name = match status { + S::NFS3_OK => "NFS3_OK", + S::NFS3ERR_PERM => "NFS3ERR_PERM", + S::NFS3ERR_NOENT => "NFS3ERR_NOENT", + S::NFS3ERR_IO => "NFS3ERR_IO", + S::NFS3ERR_NXIO => "NFS3ERR_NXIO", + S::NFS3ERR_ACCES => "NFS3ERR_ACCES", + S::NFS3ERR_EXIST => "NFS3ERR_EXIST", + S::NFS3ERR_XDEV => "NFS3ERR_XDEV", + S::NFS3ERR_NODEV => "NFS3ERR_NODEV", + S::NFS3ERR_NOTDIR => "NFS3ERR_NOTDIR", + S::NFS3ERR_ISDIR => "NFS3ERR_ISDIR", + S::NFS3ERR_INVAL => "NFS3ERR_INVAL", + S::NFS3ERR_FBIG => "NFS3ERR_FBIG", + S::NFS3ERR_NOSPC => "NFS3ERR_NOSPC", + S::NFS3ERR_ROFS => "NFS3ERR_ROFS", + S::NFS3ERR_MLINK => "NFS3ERR_MLINK", + S::NFS3ERR_NAMETOOLONG => "NFS3ERR_NAMETOOLONG", + S::NFS3ERR_NOTEMPTY => "NFS3ERR_NOTEMPTY", + S::NFS3ERR_DQUOT => "NFS3ERR_DQUOT", + S::NFS3ERR_STALE => "NFS3ERR_STALE", + S::NFS3ERR_REMOTE => "NFS3ERR_REMOTE", + S::NFS3ERR_BADHANDLE => "NFS3ERR_BADHANDLE", + S::NFS3ERR_NOT_SYNC => "NFS3ERR_NOT_SYNC", + S::NFS3ERR_BAD_COOKIE => "NFS3ERR_BAD_COOKIE", + S::NFS3ERR_NOTSUPP => "NFS3ERR_NOTSUPP", + S::NFS3ERR_TOOSMALL => "NFS3ERR_TOOSMALL", + S::NFS3ERR_SERVERFAULT => "NFS3ERR_SERVERFAULT", + S::NFS3ERR_BADTYPE => "NFS3ERR_BADTYPE", + S::NFS3ERR_JUKEBOX => "NFS3ERR_JUKEBOX", + }; + name.to_string() +} + +/// Translate well-known NFS error strings into actionable hints for +/// the UI. Mirrors the SMB hint table in spirit; the patterns are +/// different because NFS errors travel via NFS3ERR_* codes plus +/// transport-level messages from the Rust crate. +fn hint_for(text: &str) -> Option { + let s = text.to_ascii_lowercase(); + if s.contains("nfs3err_acces") || s.contains("permission denied") { + Some( + "the NFS server rejected this client. Most likely your export \ + is restricted by client IP — add this OpenPXE host (or its \ + subnet) to the export's allowed-clients list on the server." + .into(), + ) + } else if s.contains("nfs3err_noent") || s.contains("nfs3err_notdir") { + Some( + "the export path doesn't exist on the server, or it isn't a \ + directory. Double-check the path (e.g. /srv/isos vs /isos — \ + UniFi UNAS Pro exposes shares under /var/nfs/shared/)." + .into(), + ) + } else if s.contains("nfs3err_stale") || s.contains("nfs3err_badhandle") { + Some( + "the server's view of the share changed under us. Re-scan; \ + if that doesn't help, remove and re-add the share." + .into(), + ) + } else if s.contains("connect timed out") + || s.contains("timed out") + || s.contains("connection timed out") + { + Some( + "no answer from the server within the connect timeout. \ + Verify the IP, the port (default 2049), and any firewall \ + between OpenPXE and the NAS." + .into(), + ) + } else if s.contains("connection refused") { + Some( + "the NFS service isn't accepting connections on this port. \ + Make sure nfsd is running on the server and (for v3) the \ + portmapper on port 111 is reachable." + .into(), + ) + } else if s.contains("no route to host") || s.contains("network is unreachable") { + Some( + "the server isn't reachable on this network. Check the IP \ + and routes." + .into(), + ) + } else if s.contains("mount") && s.contains("denied") { + Some( + "MOUNT3 was denied. The classic cause is that the export's \ + `rw=` / `ro=` list doesn't include this client. \ + Some servers also require `insecure` in /etc/exports for \ + non-privileged source ports — which is what this client \ + uses (we run as uid 10001, no privileged-port capability)." + .into(), + ) + } else { + None + } +} + +fn share_id(server: &str, export: &str) -> String { + slugify_str(&format!("{server}{export}")) +} + +/// Normalize a server input: trim, strip schemes, drop trailing +/// slashes. Matches the SMB normalizer so paste-from-anywhere works. +fn normalize_server(raw: &str) -> String { + let s = raw.trim(); + let s = s + .strip_prefix("nfs://") + .or_else(|| s.strip_prefix("http://")) + .or_else(|| s.strip_prefix("https://")) + .unwrap_or(s); + s.trim_end_matches('/').to_string() +} + +// Silence the unused-import warning on `Cow` — we use it implicitly +// via `Opaque::borrowed` constructions in `list_isos`. +#[allow(dead_code)] +fn _unused() -> Cow<'static, str> { + Cow::Borrowed("") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn share_id_is_stable_and_safe() { + let a = share_id("10.0.0.5", "/srv/isos"); + let b = share_id("10.0.0.5", "/srv/isos"); + assert_eq!(a, b); + assert!(!a.contains('/')); + assert!(!a.contains('.')); + } + + #[test] + fn normalize_server_strips_schemes() { + assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5"); + assert_eq!(normalize_server("nfs://nas.lan/"), "nas.lan"); + assert_eq!(normalize_server("http://192.168.1.51"), "192.168.1.51"); + assert_eq!(normalize_server("nas.lan"), "nas.lan"); + } + + #[test] + fn hint_for_acces_points_to_exports_table() { + let h = hint_for("NFS server returned NFS3ERR_ACCES").unwrap(); + assert!( + h.to_lowercase().contains("export"), + "expected exports guidance, got: {h}" + ); + } + + #[test] + fn hint_for_noent_points_to_export_path() { + let h = hint_for("NFS server returned NFS3ERR_NOENT").unwrap(); + assert!( + h.to_lowercase().contains("path") || h.to_lowercase().contains("directory"), + "expected export-path guidance, got: {h}" + ); + } + + #[test] + fn hint_for_mount_denied_calls_out_insecure_option() { + let h = hint_for("mount denied").unwrap(); + assert!(h.to_lowercase().contains("insecure")); + } + + #[test] + fn hint_for_unknown_is_none() { + assert!(hint_for("some entirely unrelated string").is_none()); + } + + #[test] + fn status_label_covers_common_codes() { + assert_eq!(status_label(nfs3::nfsstat3::NFS3_OK), "NFS3_OK"); + assert_eq!(status_label(nfs3::nfsstat3::NFS3ERR_ACCES), "NFS3ERR_ACCES"); + assert_eq!(status_label(nfs3::nfsstat3::NFS3ERR_NOENT), "NFS3ERR_NOENT"); + } +} diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 8acbfdb..d27680a 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -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`. -/// `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`. +/// `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. diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index bd5a750..892bff9 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -9,7 +9,7 @@ use openpxe_core::{ }; use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager}; +use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; use openpxe_tftp::TftpServer; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -133,6 +133,18 @@ async fn main() -> anyhow::Result<()> { ); } + // v0.4.67: NFSv3 share manager — pure-Rust in-process consumer + // via the `nfs3_client` crate. Sits alongside the SMB manager; + // operators pick whichever protocol their NAS prefers, or use + // both. No subprocess, no kernel mount, works in any container. + let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone()); + if let Err(e) = nfs_shares.load_and_rescan().await { + tracing::warn!( + target: "openpxe::nfs", + "could not reload NFS shares on startup: {e}" + ); + } + // Sniff network details for the Network tab. None of these are // required for PXE to work — they're informational, surfaced in the // UI so an operator doesn't have to drop to a shell to find their @@ -158,6 +170,7 @@ async fn main() -> anyhow::Result<()> { metrics: metrics.clone(), smb: Some(smb.clone()), smb_shares: smb_shares.clone(), + nfs_shares: nfs_shares.clone(), uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus: log_bus.clone(), started_at: time::OffsetDateTime::now_utc(), diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 2697e68..3e85bde 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -167,8 +167,11 @@ el('div', {class: 'trend'}, isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' + isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' + - (status.smb_share_reachable || 0) + ' SMB share' + - ((status.smb_share_reachable || 0) === 1 ? '' : 's')), + // v0.4.67: count both protocols. Label generically since + // operators may be using one, the other, or both. + ((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) + + ' remote share' + + (((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')), ])), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Uptime'), @@ -345,16 +348,20 @@ storage: async () => { // v0.4.65: kernel-mount NFS replaced with userspace SMB via // smbclient — works in any container regardless of host kernel - // modules or capabilities. The /api/nfs endpoint is gone; - // /api/smb-shares is the replacement. - const [isos, settings, smbRes, disk] = await Promise.all([ + // modules or capabilities. + // v0.4.67: NFSv3 added back as an in-process Rust client + // (nfs3_client crate). Both protocols available side-by-side; + // operators pick whichever their NAS prefers. + const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/smb-shares'), + getJSON('/api/nfs-shares'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), ]); const shares = smbRes.shares || []; + const nfsShares = nfsRes.shares || []; // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ @@ -464,10 +471,14 @@ const rowsAndEditors = []; isos.forEach(i => { const b = bootability(i, settings); - // v0.4.65: SMB userspace consumer replaced NFS. The badge - // colours stay the same so the table looks unchanged for - // existing operators. + // v0.4.65: SMB userspace consumer (smbclient). + // v0.4.67: NFS back as in-process Rust client (nfs3_client). + // Both render with the same "remote" badge colour — they + // share the same "on a remote share, can't be deleted from + // here" semantics in the UI. const isSmb = i.source && i.source.kind === 'smb'; + const isNfs = i.source && i.source.kind === 'nfs'; + const isRemote = isSmb || isNfs; const protectedNow = !!i.password_hash; // The inline editor row is hidden by default; the Password @@ -587,8 +598,9 @@ ]), el('td', {class:'num'}, fmtBytes(i.size_bytes)), el('td', {}, - el('span', {class:'src-badge' + (isSmb ? ' nfs' : '')}, - isSmb ? ('smb:' + i.source.share_id) : 'local')), + el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')}, + isSmb ? ('smb:' + i.source.share_id) + : isNfs ? ('nfs:' + i.source.share_id) : 'local')), el('td', {}, protectedNow ? el('span', {class:'tag accent'}, 'protected') @@ -598,11 +610,12 @@ el('button', {class:'ghost', style:'margin-right:6px', onclick: () => { editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none'; }}, protectedNow ? 'Password ✎' : 'Set password'), - isSmb - // v0.4.65: SMB-sourced ISOs live on the remote share — - // OpenPXE doesn't own those bytes. Same pattern as NFS - // had: surface a tag instead of a destructive button. - ? el('span', {class:'tag', style:'opacity:.6'}, 'on SMB') + isRemote + // v0.4.65/v0.4.67: remote-sourced ISOs live on the + // share — OpenPXE doesn't own those bytes. Surface a + // tag instead of a destructive button. + ? el('span', {class:'tag', style:'opacity:.6'}, + isSmb ? 'on SMB' : 'on NFS') : el('button', {class:'danger', onclick: async () => { if (!confirm('Remove this image?')) return; await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); @@ -715,6 +728,72 @@ el('span'), ])) : [el('div', {class:'empty'}, 'No SMB shares configured.')]; + // ── NFS shares section (v0.4.67) ── + // Parallel to SMB shares above. The NFSv3 client is in-process + // (nfs3_client crate) so NFS-sourced ISOs support HTTP Range + // requests — SMB-sourced ones don't (smbclient CLI can't seek + // mid-stream). Otherwise the UX is identical: server + export, + // submit, scan, remove. + const nfsMsg = el('div', {class:'msg'}); + const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'}); + const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'}); + const addNfs = el('button', {onclick: async () => { + if (!nfsServerIn.value || !nfsExportIn.value) { + nfsMsg.replaceChildren(document.createTextNode('Server and export are required.')); + nfsMsg.className = 'msg err'; return; + } + nfsMsg.replaceChildren(document.createTextNode('Connecting…')); + nfsMsg.className = 'msg'; + const r = await postJSON('/api/nfs-shares', { + server: nfsServerIn.value, + export: nfsExportIn.value, + }); + if (r.ok) { + nfsMsg.replaceChildren(document.createTextNode('Connected.')); + nfsMsg.className = 'msg ok'; + render('storage'); + } else { + // Structured {error, stderr, hint} same as SMB. + let bodyJson = null; + let raw = null; + try { bodyJson = await r.clone().json(); } + catch (_) { raw = await r.text().catch(()=> 'connect failed'); } + const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed'); + const hint = bodyJson && bodyJson.hint; + const parts = [el('div', {}, [ + el('strong', {}, 'Connect failed: '), + document.createTextNode(msg), + ])]; + if (hint) { + parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint)); + } + nfsMsg.replaceChildren(...parts); + nfsMsg.className = 'msg err'; + } + }}, 'Add share'); + + const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [ + el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}), + el('div', {}, [ + el('div', {class:'id'}, m.server + ':' + m.export), + el('div', {class:'meta'}, + 'NFSv3 · ' + + (m.reachable ? m.iso_count + ' isos' : 'not reachable')), + m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null, + m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null, + ]), + el('button', {class:'ghost', onclick: async () => { + const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {}); + if (r.ok) render('storage'); + }}, 'Re-scan'), + el('button', {class:'danger', onclick: async () => { + if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return; + await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'}); + render('storage'); + }}, 'Remove'), + el('span'), + ])) : [el('div', {class:'empty'}, 'No NFS shares configured.')]; + // Disk-space card. Free + used + total for the volume hosting the // ISO directory, with a coloured bar. Warns at 80% and goes red at // 95% so the operator sees the runway shrinking before uploads @@ -801,6 +880,39 @@ 'boot time — no local cache, no double disk usage.'), ]), ]), + // v0.4.67: NFS shares card sits right below SMB so operators + // can see both protocols at a glance. The form is simpler + // (no auth) because NFSv3 access control is by client IP on + // the server side, not by client-supplied credentials. + el('div', {class:'card'}, [ + el('header', {}, [ + el('h2', {}, 'NFS shares'), + el('span', {class:'sub'}, nfsShares.length + ' configured'), + ]), + el('div', {class:'body'}, [ + el('div', {class:'form-row cols-2'}, [ + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'NFS server'), + nfsServerIn, + ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Export path'), + nfsExportIn, + ]), + ]), + addNfs, nfsMsg, + el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), + el('p', {class:'msg', style:'margin-top:14px'}, + 'NFSv3 shares are read in-process via a pure-Rust client — ' + + 'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' + + 'every container the SMB path works in (Unraid included). ' + + 'NFSv3 auth is AUTH_SYS only; gate access on the server side ' + + 'by allowing this OpenPXE host’s IP in the export list. ' + + 'ISOs are streamed on demand and HTTP Range requests work — ' + + 'NFSv3 READ3 takes an explicit offset, so clients can seek ' + + 'into a 5 GB ISO without reading what comes before.'), + ]), + ]), el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Available images'),