diff --git a/Cargo.lock b/Cargo.lock index 9139e2c..1dfb95e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "axum", @@ -1162,7 +1162,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bcrypt", @@ -1181,7 +1181,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bytes", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "axum", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.64" +version = "0.4.65" dependencies = [ "openpxe-core", "rust-embed", @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bcrypt", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.64" +version = "0.4.65" dependencies = [ "anyhow", "bytes", @@ -1274,7 +1274,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.64" +version = "0.4.65" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index 3538ceb..c9d2c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.64" +version = "0.4.65" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 98044d3..061767f 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, NfsAddRequest}; +use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, SmbAddRequest}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; @@ -132,10 +132,15 @@ pub fn build_router(state: AppState) -> Router { .route("/api/queue/poll/:entry_id", get(api_queue_poll)) .route("/api/queue/assign", post(api_queue_assign)) .route("/api/queue/:entry_id", delete(api_queue_release)) - // Phase 4: NFS share manager. - .route("/api/nfs", get(api_nfs_list).post(api_nfs_add)) - .route("/api/nfs/:id", delete(api_nfs_remove)) - .route("/api/nfs/:id/scan", post(api_nfs_scan)) + // v0.4.65: SMB share manager (userspace via smbclient). The + // kernel-mount NFS routes that v0.4.64 shipped are gone — they + // didn't work on hosts whose kernel lacked the nfs client + // modules (Unraid), and no container-side configuration could + // load a host kernel module. `smbclient` speaks SMB over a + // plain TCP socket in userspace, works in every container. + .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)) // 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. @@ -638,12 +643,59 @@ async fn iso_raw( headers: HeaderMap, ) -> Response { let id = filename.strip_suffix(".iso").unwrap_or(&filename); - let Some(path) = state.iso_store.iso_path_for(id) else { + // v0.4.65: SMB-sourced ISOs have no on-disk path — they're + // streamed live from the remote share via `smbclient`. We look + // up the meta first to decide whether to take the path-based + // local route or the subprocess-based SMB route. + let Some(meta) = state.iso_store.get(id) else { return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; - match stream_file_range(&path, headers.get(header::RANGE)).await { - Ok(r) => r, - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + match &meta.source { + IsoSource::Local => { + let Some(path) = state.iso_store.iso_path_for(id) else { + return (StatusCode::NOT_FOUND, "no such iso").into_response(); + }; + match stream_file_range(&path, headers.get(header::RANGE)).await { + Ok(r) => r, + Err(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() + } + } + } + IsoSource::Smb { + share_id, + relative_path, + } => { + // Range requests aren't supported for SMB sources in + // v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE + // chain loading and ISO sanboot do whole-file sequential + // reads, so this works in practice. A 416 here lets the + // client fall back to a full GET if it tried a range. + if headers.get(header::RANGE).is_some() { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes)) + .body(Body::empty()) + .unwrap(); + } + match state.smb_shares.stream_iso(share_id, relative_path).await { + Ok(stream) => { + let reader = stream.stdout; + let body_stream = tokio_util::io::ReaderStream::new(reader); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, meta.size_bytes) + // Tell intermediaries we don't support + // ranges on this resource; saves them from + // even trying. + .header(header::ACCEPT_RANGES, "none") + .body(Body::from_stream(body_stream)) + .unwrap() + } + Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(), + } + } } } @@ -651,6 +703,10 @@ async fn iso_file( State(state): State, AxumPath((id, path)): AxumPath<(String, String)>, ) -> Response { + // In-ISO file extraction is only supported for local ISOs — it + // needs random-access reads into the ISO9660 directory tree, which + // smbclient's whole-file streaming can't do efficiently. SMB- + // sourced ISOs use the raw streaming endpoint above instead. let Some(iso_path) = state.iso_store.iso_path_for(&id) else { return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; @@ -1027,16 +1083,16 @@ async fn api_docs() -> Json { ], }, { - "name": "NFS shares", + "name": "SMB shares", "endpoints": [ - {"method": "GET", "path": "/api/nfs", - "summary": "List configured NFS shares with mount state and iso counts."}, - {"method": "POST", "path": "/api/nfs", - "summary": "Mount an NFS share. Body: { server, export, version, read_only }."}, - {"method": "DELETE", "path": "/api/nfs/:id", - "summary": "Unmount a share and drop its entries from the ISO store."}, - {"method": "POST", "path": "/api/nfs/:id/scan", - "summary": "Re-walk a mounted share for ISOs."}, + {"method": "GET", "path": "/api/smb-shares", + "summary": "List configured SMB shares with connection state and iso counts."}, + {"method": "POST", "path": "/api/smb-shares", + "summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."}, + {"method": "DELETE", "path": "/api/smb-shares/:id", + "summary": "Forget a share and drop its entries from the ISO store."}, + {"method": "POST", "path": "/api/smb-shares/:id/scan", + "summary": "Re-list a share for new ISOs."}, ], }, { @@ -1453,8 +1509,11 @@ async fn api_list_clients(State(state): State) -> Json) -> Json { let smb = state.smb.as_ref().map(|s| s.snapshot()); - let nfs = state.nfs.list(); - let nfs_active = nfs.iter().filter(|m| m.mounted).count(); + // 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. + let smb_shares = state.smb_shares.list(); + let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); let isos = state.iso_store.list(); let clients = state.clients.list(); let queue_entries = state.queue.list(); @@ -1473,7 +1532,7 @@ 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(nfs_active as u64); + state.metrics.set_nfs_active(smb_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); @@ -1488,8 +1547,12 @@ async fn api_status(State(state): State) -> Json { "ipxe_assets": openpxe_ipxe_assets::list_assets(), "settings": state.settings.snapshot(), "smb": smb, - "nfs_count": nfs.len(), - "nfs_active": nfs_active, + // 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, "host_bindings": state.hosts.len(), "custom_logo": state.branding.has_logo(), "uptime_secs": uptime_secs, @@ -1704,33 +1767,42 @@ async fn api_queue_release( } } -// ─── NFS share API ───────────────────────────────────────────────────────── +// ─── SMB share API (v0.4.65) ─────────────────────────────────────────────── +// +// Replaces the NFS share manager from v0.4.64. The wire shape is similar +// — a {shares: [...]} list, a POST that returns either the share or a +// structured {error, stderr, hint} body — so the UI can render both the +// same way. -async fn api_nfs_list(State(state): State) -> Json { - Json(json!({ "mounts": state.nfs.list() })) +async fn api_smb_shares_list(State(state): State) -> Json { + Json(json!({ "shares": state.smb_shares.list() })) } -async fn api_nfs_add(State(state): State, Json(req): Json) -> Response { - match state.nfs.add(req).await { - Ok(m) => (StatusCode::CREATED, Json(m)).into_response(), - // v0.4.64: the manager returns a structured `NfsMountError` with - // `error` + optional `hint` + the raw `stderr`, so the UI can - // show both — the raw message for completeness, the hint for - // "what to fix next". Previously this was a plain text body - // which collapsed both bits of information into one line. +async fn api_smb_shares_add( + State(state): State, + Json(req): Json, +) -> Response { + match state.smb_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_remove(State(state): State, AxumPath(id): AxumPath) -> Response { - match state.nfs.remove(&id).await { +async fn api_smb_shares_remove( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.smb_shares.remove(&id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } -async fn api_nfs_scan(State(state): State, AxumPath(id): AxumPath) -> Response { - match state.nfs.rescan(&id).await { +async fn api_smb_shares_scan( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.smb_shares.rescan(&id).await { Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), } @@ -1850,9 +1922,11 @@ 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.nfs.list().iter().filter(|m| m.mounted).count() as u64); + .set_nfs_active(state.smb_shares.list().iter().filter(|m| m.reachable).count() 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 aeb0da8..720d3d4 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, NfsManager, SmbManager}; +use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager}; use std::sync::Arc; use time::OffsetDateTime; @@ -44,11 +44,14 @@ pub struct AppState { /// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// Windows support is not wired in. Settings toggle drives start/stop. pub smb: Option>, - /// NFS share manager. Always present (mounting is opt-in by the - /// operator from the Storage tab); `add()` requires `mount.nfs` to be - /// available in the runtime image. Surfaces errors per-mount rather - /// than failing the global state. - pub nfs: NfsManager, + /// v0.4.65: SMB share manager — userspace consumer of remote SMB + /// shares via Samba's `smbclient` CLI. Replaces the kernel-mount + /// NFS path that v0.4.64 shipped; that path didn't work on hosts + /// (Unraid, etc.) whose kernel ships without the nfs/cifs client + /// modules, and no container-side configuration could fix it. + /// `smbclient` does the SMB protocol over a plain TCP socket in + /// userspace — works in any container, no special caps required. + pub smb_shares: SmbShareManager, /// 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 8cd9aeb..bf63bef 100644 --- a/crates/http-api/src/terminal.rs +++ b/crates/http-api/src/terminal.rs @@ -88,7 +88,11 @@ 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, - "nfs" => nfs_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. + "share" | "smb-share" => smb_share_command(state, tail).await, "smb" => smb_command(state, tail).await, "log" => log_command(state, tail), "whoami" => Ok("operator".to_string()), @@ -107,18 +111,18 @@ fn status_text(s: &AppState) -> String { let clients = s.clients.list(); let queue_entries = s.queue.list(); let smb = s.smb.as_ref().map(|m| m.snapshot()); - let nfs = s.nfs.list(); - let nfs_active = nfs.iter().filter(|m| m.mounted).count(); + let smb_shares = s.smb_shares.list(); + let smb_reachable = smb_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}, nfs: {n_nfs})\n\ + isos: {n_isos} (local: {n_local}, smb: {n_smb})\n\ clients: {n_clients}\n\ queue: {n_entries}\n\ - smb: {smb}\n\ - nfs mounts: {n_total} configured ({n_active} active)\n", + smb server: {smb}\n\ + smb shares: {n_total} configured ({n_active} reachable)\n", ver = env!("CARGO_PKG_VERSION"), base = s.public_base_url, nic = if s.nic_name.is_empty() { @@ -132,15 +136,15 @@ fn status_text(s: &AppState) -> String { .iter() .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)) .count(), - n_nfs = isos + n_smb = isos .iter() - .filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)) + .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. })) .count(), n_clients = clients.len(), n_entries = queue_entries.len(), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), - n_total = nfs.len(), - n_active = nfs_active, + n_total = smb_shares.len(), + n_active = smb_reachable, ) } @@ -158,7 +162,8 @@ fn isos_text(s: &AppState) -> String { for i in isos { let src = match i.source { openpxe_iso_store::IsoSource::Local => "local".to_string(), - openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), + // v0.4.65: SMB userspace consumer replaced kernel-mount NFS. + openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"), }; let _ = writeln!( out, @@ -264,76 +269,83 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result } } -// ── nfs ──────────────────────────────────────────────────────────────── +// ── share (v0.4.65: SMB shares) ───────────────────────────────────────── -async fn nfs_command(s: &AppState, args: &[String]) -> Result { +async fn smb_share_command(s: &AppState, args: &[String]) -> Result { match args.first().map(String::as_str) { None | Some("list") => { - let mounts = s.nfs.list(); - if mounts.is_empty() { - return Ok("(no NFS mounts configured)".into()); + let shares = s.smb_shares.list(); + if shares.is_empty() { + return Ok("(no SMB shares configured)".into()); } let mut out = String::new(); let _ = writeln!( out, - "{:<24} {:<6} {:<7} {:<6} TARGET", - "ID", "VER", "STATUS", "ISOS" + "{:<24} {:<7} {:<6} {:<6} TARGET", + "ID", "STATUS", "AUTH", "ISOS" ); - for m in mounts { - let status = if m.mounted { "ok" } else { "down" }; + for m in shares { + let status = if m.reachable { "ok" } else { "down" }; + let auth = if m.guest { "guest" } else { "user" }; let _ = writeln!( out, - "{:<24} {:<6} {:<7} {:<6} {}:{}", + "{:<24} {:<7} {:<6} {:<6} //{}/{}", truncate(&m.id, 24), - match m.version { - openpxe_iso_store::NfsVersion::V3 => "v3", - openpxe_iso_store::NfsVersion::V41 => "v4.1", - }, status, + auth, m.iso_count, m.server, - m.export, + m.share, ); 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("mount") => { - // nfs mount : [v3|v41] [ro|rw] + Some("add") => { + // share add //server/share [guest|user:password] let target = args .get(1) - .ok_or_else(|| "usage: nfs mount : [v3|v41] [ro|rw]".to_string())?; - let (server, export) = target - .split_once(':') - .ok_or_else(|| "target must be 'server:/export'".to_string())?; - let version = match args.get(2).map(String::as_str) { - Some("v3") => openpxe_iso_store::NfsVersion::V3, - Some("v41") | None => openpxe_iso_store::NfsVersion::V41, - Some(other) => { - return Err(format!("unknown nfs version: {other} (expect v3 or v41)")) - } + .ok_or_else(|| { + "usage: share add //server/share [guest|user:password]".to_string() + })?; + // Accept either `//server/share` (UNC-style) or + // `server:share` (shorter to type). + let stripped = target.trim_start_matches('/').trim_start_matches('\\'); + let (server, share) = if let Some((s, p)) = stripped.split_once('/') { + (s, p) + } else if let Some((s, p)) = stripped.split_once(':') { + (s, p) + } else { + return Err("target must be '//server/share' or 'server:share'".into()); }; - let read_only = !matches!(args.get(3).map(String::as_str), Some("rw")); - let req = openpxe_iso_store::NfsAddRequest { + + // Auth spec: "guest" or "user:password". Default: guest. + let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into()); + let (guest, username, password) = if auth == "guest" { + (true, None, None) + } else if let Some((u, p)) = auth.split_once(':') { + (false, Some(u.to_string()), Some(p.to_string())) + } else { + return Err("auth must be 'guest' or 'user:password'".into()); + }; + + let req = openpxe_iso_store::SmbAddRequest { server: server.to_string(), - export: export.to_string(), - version, - read_only, - // v0.4.64: terminal callers can't override the port yet - // — keep the default 2049. We could plumb a 4th arg - // later if anyone asks. + share: share.to_string(), + username, + password, + guest, port: None, }; - match s.nfs.add(req).await { - Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)), - // v0.4.64: `add` now returns a structured `NfsMountError`. - // We render the raw error plus the hint (if any) on - // separate lines so the terminal output mirrors what - // the Storage tab shows. + match s.smb_shares.add(req).await { + Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)), Err(e) => { - let mut out = format!("mount failed: {}", e.error); + let mut out = format!("add failed: {}", e.error); if let Some(h) = e.hint { out.push_str("\nhint: "); out.push_str(&h); @@ -342,26 +354,26 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result { } } } - Some("unmount") => { + Some("remove") => { let id = args .get(1) - .ok_or_else(|| "usage: nfs unmount ".to_string())?; - match s.nfs.remove(id).await { - Ok(()) => Ok(format!("unmounted {id}")), - Err(e) => Err(format!("unmount failed: {e}")), + .ok_or_else(|| "usage: share remove ".to_string())?; + match s.smb_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.rescan(id).await { + .ok_or_else(|| "usage: share scan ".to_string())?; + match s.smb_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|mount|unmount|scan]" + "unknown share subcommand: {other}\ntry: share [list|add|remove|scan]" )), } } @@ -502,13 +514,13 @@ OpenPXE terminal — available commands: queue assign-all assign every waiting client queue release release one queued client - nfs list list NFS mounts - nfs mount : [v3|v41] [ro|rw] add and mount an NFS share - nfs unmount unmount and forget a share - nfs scan re-scan a share for new ISOs + share list list configured SMB shares + share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass' + share remove forget an SMB share + share scan re-list a share for new ISOs - smb status SMB (Samba) state - smb start | stop | reload control smbd + smb status outbound Samba state (Windows install media) + smb start | stop | reload control the outbound smbd log clear drop the in-memory log ring buffer log tail [n] show the last n buffered lines (default 20) @@ -523,10 +535,10 @@ mod tests { #[test] fn shell_split_basic() { assert_eq!(shell_split(""), Vec::::new()); - assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]); + assert_eq!(shell_split("share list"), vec!["share", "list"]); assert_eq!( - shell_split("nfs mount 10.0.0.5:/srv v41 ro"), - vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"] + shell_split("share add //nas/isos guest"), + vec!["share", "add", "//nas/isos", "guest"] ); } diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 5780f6c..ae1d1aa 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, NfsManager}; +use openpxe_iso_store::{IsoStore, SmbShareManager}; use tempfile::tempdir; use tower::ServiceExt; @@ -94,8 +94,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let clients = ClientRegistry::new(); let queue = DeploymentQueue::new(); let settings = SettingsStore::load_or_default(dir.path()); - let nfs = NfsManager::new(dir.path(), iso_store.clone()); - iso_store.set_nfs_root(nfs.mount_root()); + let smb_shares = SmbShareManager::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,7 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { sso, metrics, smb: None, - nfs, + smb_shares, uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus, started_at: time::OffsetDateTime::now_utc(), @@ -464,35 +463,72 @@ async fn no_external_urls_in_generated_ipxe() { // ── Phase 4 integration tests ──────────────────────────────────────────── +// v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient. + #[tokio::test] -async fn nfs_add_with_bad_export_is_rejected() { - // Validation must happen before we shell out to /bin/mount — - // otherwise the operator sees opaque kernel errors instead of a - // clear "your export must start with /" hint. +async fn smb_share_add_with_missing_server_is_rejected() { + // Validation must run before we shell out to smbclient — otherwise + // operators see opaque NT_STATUS codes for what's really a typo. let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, - "/api/nfs", - r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#, + "/api/smb-shares", + r#"{"server":"","share":"isos","guest":true}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!( - msg.contains("export"), + msg.to_lowercase().contains("server"), "expected validation hint, got: {msg}" ); } #[tokio::test] -async fn nfs_list_starts_empty() { +async fn smb_share_add_requires_username_when_not_guest() { let (state, _dir) = build_state().await; let app = build_router(state); - let (s, b) = get(&app, "/api/nfs").await; + let (s, b) = post_json( + &app, + "/api/smb-shares", + r#"{"server":"10.0.0.5","share":"isos","guest":false}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let msg = String::from_utf8_lossy(&b); + assert!( + msg.to_lowercase().contains("username"), + "expected username hint, got: {msg}" + ); +} + +#[tokio::test] +async fn smb_share_add_rejects_paths_in_share_name() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = post_json( + &app, + "/api/smb-shares", + r#"{"server":"10.0.0.5","share":"isos/subdir","guest":true}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let msg = String::from_utf8_lossy(&b); + assert!( + msg.to_lowercase().contains("share name"), + "expected share name hint, got: {msg}" + ); +} + +#[tokio::test] +async fn smb_shares_list_starts_empty() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, b) = get(&app, "/api/smb-shares").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); - assert_eq!(v["mounts"].as_array().unwrap().len(), 0); + assert_eq!(v["shares"].as_array().unwrap().len(), 0); } #[tokio::test] diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index ebaabe6..2591338 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -18,16 +18,21 @@ pub mod entry; pub mod introspect; -pub mod nfs; pub mod pxe_logo; pub mod smb; +pub mod smb_share; pub mod store; pub mod windows; pub use entry::{BootEntry, BootKind, KernelArgs}; pub use introspect::{DistroFamily, IntrospectionReport}; -pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; +// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace +// `smbclient` CLI replaced it — works in any container (no +// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and +// every other PXE/imaging tool that supports network storage handles +// it. pub use smb::{extract_windows_iso, SmbManager, SmbState}; +pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream}; pub use store::{ generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, diff --git a/crates/iso-store/src/nfs.rs b/crates/iso-store/src/nfs.rs deleted file mode 100644 index eecf004..0000000 --- a/crates/iso-store/src/nfs.rs +++ /dev/null @@ -1,984 +0,0 @@ -//! NFS share manager. -//! -//! Lets an operator mount a remote NFS export as an ISO source instead of -//! uploading every ISO into the container's PVC. Supports NFSv3 and -//! NFSv4.1 — the two versions the user explicitly asked for. -//! -//! ## How it works -//! -//! 1. Operator submits a mount spec via the Storage tab: -//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`. -//! 2. We slugify a stable id, mkdir `/nfs//`, then shell out -//! to `mount.nfs -v -o vers=...,ro,nolock,proto=tcp server:export local`. -//! 3. On success we walk the mount point looking for `*.iso` files and -//! register each one with the `IsoStore` as an external source — same -//! introspection pipeline as a web upload, but no sha256 (the bytes -//! live on a remote machine; hashing them would suck them through the -//! network on every restart). -//! 4. On failure we record `last_error` + `hint` on the spec and persist -//! anyway so the UI can show a row in red with an actionable hint -//! rather than silently dropping it. -//! -//! ## Operational notes -//! -//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the -//! `nfs-common` package. The default image ships these (see Dockerfile). -//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC -//! doesn't — operators have to opt in by switching to a more privileged -//! SCC or running NFS mounts as a CSI driver outside the pod. -//! - Mount commands are issued sequentially under a single mutex to avoid -//! `mount` racing on the same target dir. -//! -//! ## v0.4.64 diagnostics rework -//! -//! Field reports showed `mount.nfs: failed to apply fstab options` (exit -//! code 32) was the dominant failure surfaced through the UI — a deeply -//! unhelpful message from nfs-utils 2.6.x that has nothing to do with -//! `/etc/fstab`. It comes from `nfs_options2string()` and lights up when -//! the kernel can't accept the assembled options, when mtab can't be -//! written (container without `CAP_SYS_ADMIN`), or when an obscure option -//! triggers a transformation edge case. In v0.4.64 we: -//! -//! 1. Probe TCP reach to `server:port` before shelling out so a wrong -//! IP / closed firewall surfaces as a clear "cannot reach NFS port" -//! instead of `failed to apply fstab options`. -//! 2. Pass `proto=tcp` explicitly on NFSv3 (UDP is widely deprecated -//! and several NAS appliances don't bind it at all). -//! 3. On `failed to apply fstab options`, retry with a stripped-down -//! option set (`vers=N,ro/rw`) — that frequently succeeds and at -//! minimum produces a real kernel error. -//! 4. Translate well-known stderr patterns into operator-friendly hints -//! and persist them on the mount so the UI can show "what to fix -//! next" instead of the raw mount.nfs message. -//! -//! ## Persistence -//! -//! Mount specs (without runtime state) live at `/nfs.json`, -//! re-mounted on startup. Mounts that fail to come back online keep their -//! spec and their `last_error` so the operator sees what happened. - -use crate::introspect::{introspect, IntrospectionReport}; -use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; -use openpxe_core::{Error, Result}; -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; -use time::OffsetDateTime; -use tokio::process::Command; - -/// Default port for NFS over TCP. We expose it as a constant so the -/// pre-flight probe and the option string assembly use the same value. -const DEFAULT_NFS_PORT: u16 = 2049; - -/// How long to wait for a TCP connection to the NFS server before -/// declaring it unreachable. Short enough that a wrong IP doesn't make -/// the UI hang for half a minute; long enough that a slow appliance -/// can still answer. -const PROBE_TIMEOUT: Duration = Duration::from_secs(4); - -/// Wire-protocol versions we support. Keep this enum closed — silently -/// accepting "auto" or letting the kernel negotiate would mean operators -/// could never confirm which version is in use. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum NfsVersion { - /// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many - /// older NAS appliances. - V3, - /// NFSv4.1 — single TCP port (2049), session-based. Modern default. - V41, -} - -impl NfsVersion { - fn vers_arg(self) -> &'static str { - match self { - Self::V3 => "vers=3", - Self::V41 => "vers=4.1", - } - } - - /// Short label for UI surfaces and log lines. - fn label(self) -> &'static str { - match self { - Self::V3 => "NFSv3", - Self::V41 => "NFSv4.1", - } - } -} - -/// One configured mount. The id is generated from server+export so the -/// operator can re-add the same export idempotently. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NfsMount { - pub id: String, - pub server: String, - pub export: String, - pub version: NfsVersion, - /// Read-only by default — most ISO libraries are. Operators that need - /// write can flip this off but OpenPXE itself never writes. - pub read_only: bool, - /// TCP port for the NFS service. Defaults to 2049; configurable for - /// the (rare) case where the appliance binds the service elsewhere. - /// v0.4.64: previously inferred at runtime; now persisted so the UI - /// can echo the value back to the operator. - #[serde(default = "default_port")] - pub port: u16, - /// Local mount point under `/nfs/`. - pub local_path: PathBuf, - /// Whether the mount is currently active. - pub mounted: bool, - /// Last error encountered on a `mount` or `umount` attempt; cleared on - /// success. - pub last_error: Option, - /// v0.4.64: operator-friendly translation of `last_error` — e.g. for - /// "failed to apply fstab options" we surface "CAP_SYS_ADMIN may be - /// missing on the container". `None` means we don't have a friendlier - /// rendition than the raw error. - #[serde(default)] - pub last_hint: Option, - #[serde(with = "time::serde::rfc3339::option")] - pub last_attempt: Option, - /// Number of `.iso` files found on the share (re-counted on each scan). - pub iso_count: u32, -} - -/// Spec submitted by the UI. Server and export are normalized before use. -#[derive(Debug, Clone, Deserialize)] -pub struct NfsAddRequest { - pub server: String, - pub export: String, - #[serde(default = "default_version")] - pub version: NfsVersion, - #[serde(default = "default_ro")] - pub read_only: bool, - /// Optional TCP port — defaults to 2049 if omitted or zero. - #[serde(default)] - pub port: Option, -} - -fn default_version() -> NfsVersion { - NfsVersion::V41 -} -fn default_ro() -> bool { - true -} -fn default_port() -> u16 { - DEFAULT_NFS_PORT -} - -/// Outcome of an `add` attempt. `Ok` carries the mount; `Err` from the -/// API layer is converted to this richer shape so the UI can render the -/// raw error and the actionable hint independently. -#[derive(Debug, Clone, Serialize)] -pub struct NfsMountError { - /// The first line / summary of what went wrong. - pub error: String, - /// Verbatim stderr from `mount.nfs` (trimmed). May be empty. - pub stderr: String, - /// Operator-friendly hint or `None` if we don't have one. - pub hint: Option, -} - -impl NfsMountError { - 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 { - mounts: HashMap, -} - -/// Manages NFS mounts and surfaces them as ISO sources. -/// -/// Cheap to clone — internal state is `Arc>`. -#[derive(Debug, Clone)] -pub struct NfsManager { - work_root: Arc, - state_path: Arc, - inner: Arc>, - iso_store: IsoStore, - /// Single-writer lock around the actual `mount`/`umount` shell-outs; - /// avoids racing on the same target directory. - mount_lock: Arc>, -} - -impl NfsManager { - /// Construct a manager rooted at `work_dir`. Mount points live under - /// `/nfs//`. State persists to `/nfs.json`. - #[must_use] - pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { - let work_root = work_dir.join("nfs"); - let state_path = work_dir.join("nfs.json"); - Self { - work_root: Arc::new(work_root), - state_path: Arc::new(state_path), - inner: Arc::new(Mutex::new(Inner::default())), - iso_store, - mount_lock: Arc::new(tokio::sync::Mutex::new(())), - } - } - - /// Where this manager mounts shares. Used by `IsoStore` to resolve - /// NFS-backed `IsoMeta`s to their on-disk path. - #[must_use] - pub fn mount_root(&self) -> PathBuf { - self.work_root.as_ref().clone() - } - - /// Load persisted state and re-attempt every mount. Errors are logged - /// per-mount but never fail the call — startup must not block on a - /// remote NFS server being slow. - pub async fn load_and_remount(&self) -> Result<()> { - tokio::fs::create_dir_all(self.work_root.as_path()).await?; - let mounts = 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 m in mounts { - // Always start from "not mounted" — the kernel state was lost - // when the process died. We'll try to remount each one. - m.mounted = false; - m.last_error = None; - m.last_hint = None; - self.inner.lock().mounts.insert(m.id.clone(), m.clone()); - if let Err(e) = self.try_mount(&m.id).await { - tracing::warn!( - target: "openpxe::nfs", - id = %m.id, error = %e, - "could not remount NFS share on startup" - ); - } - } - Ok(()) - } - - /// Add a new mount. Returns the resulting `NfsMount` (with `mounted` - /// reflecting reality) or a structured `NfsMountError` describing - /// what went wrong. - 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(NfsMountError::from_raw( - "server is required", - "", - )); - } - if !export.starts_with('/') { - return Err(NfsMountError::from_raw( - "export path must start with '/'", - "", - )); - } - if export.contains('\0') || server.contains('\0') { - return Err(NfsMountError::from_raw( - "server / export must not contain NUL bytes", - "", - )); - } - let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT); - - let id = mount_id(&server, &export); - let local_path = self.work_root.join(&id); - if let Err(e) = tokio::fs::create_dir_all(&local_path).await { - return Err(NfsMountError::from_raw( - format!("failed to create local mount point: {e}"), - "", - )); - } - - let mount = NfsMount { - id: id.clone(), - server, - export, - version: req.version, - read_only: req.read_only, - port, - local_path, - mounted: false, - last_error: None, - last_hint: None, - last_attempt: None, - iso_count: 0, - }; - self.inner.lock().mounts.insert(id.clone(), mount); - self.persist_locked(); - self.try_mount(&id).await.map_err(|e| { - // try_mount has already persisted last_error/last_hint. We - // refetch them so the API response reflects exactly what the - // UI will see when it lists mounts. - let m = self.get(&id); - NfsMountError { - 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("mount just inserted")) - } - - /// Unmount and forget a share. Removes any ISOs it contributed from - /// the IsoStore and deletes the local mount point. Idempotent. - pub async fn remove(&self, id: &str) -> Result<()> { - // Best-effort umount; even if it fails (e.g. server unreachable) - // we still want to drop the in-memory record. - let _ = self.umount_one(id).await; - let local_path = { - let mut g = self.inner.lock(); - g.mounts.remove(id).map(|m| m.local_path) - }; - self.persist_locked(); - self.iso_store.drop_external_source(id); - if let Some(p) = local_path { - // rmdir only — never recurse, the mount could still be live - // on some kernel error path and we don't want to nuke a - // remote filesystem. - let _ = tokio::fs::remove_dir(&p).await; - } - Ok(()) - } - - /// Re-scan a mounted share for ISOs, refreshing the IsoStore. - pub async fn rescan(&self, id: &str) -> Result { - let mount = self - .get(id) - .ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?; - if !mount.mounted { - return Err(Error::Invalid(format!("mount '{id}' is not active"))); - } - let count = self.scan_and_register(&mount).await?; - if let Some(m) = self.inner.lock().mounts.get_mut(id) { - m.iso_count = count; - } - self.persist_locked(); - Ok(count) - } - - /// Snapshot of every configured mount. - #[must_use] - pub fn list(&self) -> Vec { - let g = self.inner.lock(); - let mut v: Vec<_> = g.mounts.values().cloned().collect(); - v.sort_by(|a, b| a.id.cmp(&b.id)); - v - } - - /// Look up a single mount by id. - #[must_use] - pub fn get(&self, id: &str) -> Option { - self.inner.lock().mounts.get(id).cloned() - } - - // ── internals ───────────────────────────────────────────────────── - - async fn try_mount(&self, id: &str) -> Result<()> { - let _g = self.mount_lock.lock().await; - - let m = self - .get(id) - .ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?; - let now = OffsetDateTime::now_utc(); - - // Already mounted? Skip — `mount` would error on a busy target - // and confuse the operator's UI status. - if is_mountpoint(&m.local_path).await { - self.update_status(id, true, None, None, now); - // Even though already mounted, we still want a fresh ISO count. - let count = self.scan_and_register(&m).await.unwrap_or(0); - self.update_iso_count(id, count); - return Ok(()); - } - - // v0.4.64: pre-flight TCP probe. Catches the dominant failure - // mode (wrong IP / firewall) before mount.nfs gets a chance to - // emit its unhelpful "failed to apply fstab options" message. - if let Err((err, hint)) = tcp_probe(&m.server, m.port).await { - tracing::warn!(target: "openpxe::nfs", id = %id, "{err}"); - self.update_status(id, false, Some(err.clone()), Some(hint), now); - return Err(Error::Invalid(err)); - } - - // First attempt: full option set. - let full_opts = mount_options(&m, /*minimal*/ false); - let target = format!("{}:{}", m.server, m.export); - let attempt = run_mount_nfs(&full_opts, &target, &m.local_path).await; - - let (success, stderr, exit_code) = match attempt { - Ok((true, stderr, _)) => (true, stderr, 0), - Ok((false, stderr, code)) => (false, stderr, code), - Err(e) => { - let err = format!("could not exec mount(8): {e}"); - let hint = Some( - "the runtime image is missing /bin/mount or nfs-common — \ - verify the container hasn't been stripped down" - .to_string(), - ); - tracing::error!(target: "openpxe::nfs", id = %id, "{err}"); - self.update_status(id, false, Some(err.clone()), hint, now); - return Err(Error::Invalid(err)); - } - }; - - if success { - tracing::info!( - target: "openpxe::nfs", - id = %id, server = %m.server, export = %m.export, - version = %m.version.label(), port = m.port, - "NFS mount succeeded" - ); - self.update_status(id, true, None, None, now); - let count = self.scan_and_register(&m).await.unwrap_or(0); - self.update_iso_count(id, count); - return Ok(()); - } - - // Second attempt: if the first attempt failed with the - // "failed to apply fstab options" oddity, retry with a minimal - // option set. nfs-utils 2.6.x sometimes chokes on the assembled - // option string for reasons unrelated to the actual options - // being valid; the stripped form bypasses the transformation - // edge case. - let trigger_retry = looks_like_option_transform_failure(&stderr); - let (final_success, final_stderr, final_exit_code) = if trigger_retry { - tracing::info!( - target: "openpxe::nfs", id = %id, - "retrying with minimal options after option-transform failure" - ); - let minimal = mount_options(&m, /*minimal*/ true); - match run_mount_nfs(&minimal, &target, &m.local_path).await { - Ok((true, s, _)) => (true, s, 0), - Ok((false, s, c)) => (false, s, c), - Err(e) => (false, format!("could not exec mount(8): {e}"), -1), - } - } else { - (false, stderr, exit_code) - }; - - if final_success { - tracing::info!( - target: "openpxe::nfs", id = %id, - "NFS mount succeeded on minimal-options retry" - ); - self.update_status(id, true, None, None, now); - let count = self.scan_and_register(&m).await.unwrap_or(0); - self.update_iso_count(id, count); - return Ok(()); - } - - // Failure path: persist a clear error and a hint, log both. - // `mount(8)` passes mount.nfs's stderr through verbatim, so the - // user-visible text reads like "mount.nfs: ..." — we prepend the - // exit code so the operator can tell at a glance that the helper - // ran but rejected the request, vs the helper not running at all. - let err = if final_stderr.is_empty() { - format!("mount exit {final_exit_code}") - } else { - format!("mount exit {final_exit_code}: {}", final_stderr.trim()) - }; - let hint = hint_for(&final_stderr); - tracing::warn!( - target: "openpxe::nfs", id = %id, - hint = ?hint, "{err}" - ); - self.update_status(id, false, Some(err.clone()), hint, now); - Err(Error::Invalid(err)) - } - - async fn umount_one(&self, id: &str) -> Result<()> { - let _g = self.mount_lock.lock().await; - let Some(m) = self.get(id) else { return Ok(()) }; - if !is_mountpoint(&m.local_path).await { - self.update_status(id, false, None, None, OffsetDateTime::now_utc()); - return Ok(()); - } - // -l = lazy: detach immediately, finish when no process has a - // handle. Important if a stale ISO read is still in flight. - let out = Command::new("umount") - .arg("-l") - .arg(&m.local_path) - .output() - .await; - match out { - Ok(o) if o.status.success() => { - self.update_status(id, false, None, None, OffsetDateTime::now_utc()); - Ok(()) - } - Ok(o) => { - let e = format!( - "umount exit {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ); - self.update_status( - id, - false, - Some(e.clone()), - None, - OffsetDateTime::now_utc(), - ); - Err(Error::Invalid(e)) - } - Err(e) => { - let e = format!("could not exec /bin/umount: {e}"); - self.update_status( - id, - false, - Some(e.clone()), - None, - OffsetDateTime::now_utc(), - ); - Err(Error::Invalid(e)) - } - } - } - - /// Walk the mount point for `*.iso` files, introspect each one, and - /// register it with the IsoStore as an NFS-sourced entry. Returns the - /// count of ISOs registered. - async fn scan_and_register(&self, m: &NfsMount) -> Result { - // Drop any prior entries from this mount before re-registering, so - // a removed file disappears from the store. - self.iso_store.drop_external_source(&m.id); - - let mut walker = tokio::fs::read_dir(&m.local_path).await?; - let mut count = 0u32; - while let Some(entry) = walker.next_entry().await? { - let p = entry.path(); - if p.extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - != Some("iso") - { - continue; - } - let filename = match p.file_name().and_then(|s| s.to_str()) { - Some(f) => f.to_string(), - None => continue, - }; - let size = tokio::fs::metadata(&p).await?.len(); - // Introspection is sync + IO-bound (reads ISO9660 PVD). Push - // it to a blocking thread so the runtime stays responsive on - // a slow share. - let p_owned = p.clone(); - let report: IntrospectionReport = - tokio::task::spawn_blocking(move || introspect(&p_owned)) - .await - .map_err(|e| Error::Other(e.into()))?; - let id = format!("nfs-{}-{}", m.id, slugify_str(&filename)); - let boot_entries = generate_boot_entries_for(&id, &filename, &report); - let source = IsoSource::Nfs { - mount_id: m.id.clone(), - relative_path: filename.clone(), - }; - self.iso_store - .register_external(id, filename, size, report, boot_entries, source); - count += 1; - } - Ok(count) - } - - fn update_status( - &self, - id: &str, - mounted: bool, - err: Option, - hint: Option, - ts: OffsetDateTime, - ) { - if let Some(m) = self.inner.lock().mounts.get_mut(id) { - m.mounted = mounted; - m.last_error = err; - m.last_hint = hint; - m.last_attempt = Some(ts); - } - self.persist_locked(); - } - - fn update_iso_count(&self, id: &str, count: u32) { - if let Some(m) = self.inner.lock().mounts.get_mut(id) { - m.iso_count = count; - } - self.persist_locked(); - } - - /// Atomically replace the on-disk JSON with the current state. - /// Persistence errors are logged, never propagated — settings live in - /// memory authoritatively, matching the SettingsStore policy. - fn persist_locked(&self) { - let mounts: Vec = self.inner.lock().mounts.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(&mounts) { - Ok(b) => b, - Err(e) => { - tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {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 NFS state tmp: {e}"); - return; - } - if let Err(e) = std::fs::rename(&tmp, path) { - tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}"); - } - } -} - -/// Build the `-o` option list. With `minimal=true` we strip everything -/// except the protocol version and ro/rw — used on the retry path when -/// the first attempt failed at option transformation, which historically -/// indicates one of the auxiliary options confused `nfs_options2string()`. -fn mount_options(m: &NfsMount, minimal: bool) -> String { - let mut opts = vec![m.version.vers_arg().to_string()]; - if m.read_only { - opts.push("ro".into()); - } else { - opts.push("rw".into()); - } - if minimal { - return opts.join(","); - } - // Explicit TCP. NFSv4.x is TCP-only by spec, but stating it - // doesn't hurt and on NFSv3 it's necessary on appliances that - // don't bind UDP (which is most modern ones). - opts.push("proto=tcp".into()); - // `nolock` for v3 — many storage appliances disable lockd; we don't - // need locking for read-only ISO access anyway. nfs-utils still - // tries to contact rpc.statd without it which is a no-op overhead. - if matches!(m.version, NfsVersion::V3) { - opts.push("nolock".into()); - } - // Non-standard port hint to the kernel. - if m.port != DEFAULT_NFS_PORT { - opts.push(format!("port={}", m.port)); - } - // Soft mount with a generous timeout — better to surface a hung share - // as a user-visible error than to wedge the iPXE client forever on a - // dead NFS server. - opts.push("soft".into()); - opts.push("timeo=100".into()); - opts.push("retrans=3".into()); - opts.join(",") -} - -/// Invoke `mount -t nfs`. Returns `(success, stderr_trimmed, -/// exit_code)`. `stderr` is captured separately from `stdout`; -/// `mount(8)` passes mount.nfs's stderr through verbatim, so we get the -/// same diagnostics ("mount.nfs: ...") whether we invoke `mount.nfs` -/// directly or go through the generic wrapper. -/// -/// We deliberately stay on `mount` rather than `mount.nfs` directly -/// because `/bin/mount` is in every user's PATH; `mount.nfs` lives in -/// `/sbin` (or `/usr/sbin`) and is *not* in the default PATH for the -/// non-root `openpxe` user. The generic `mount` binary knows where its -/// NFS helper lives and dispatches accordingly. -async fn run_mount_nfs( - opts: &str, - target: &str, - local: &Path, -) -> std::io::Result<(bool, String, i32)> { - let output = Command::new("mount") - .arg("-t") - .arg("nfs") - .arg("-o") - .arg(opts) - .arg(target) - .arg(local) - .output() - .await?; - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let code = output.status.code().unwrap_or(-1); - Ok((output.status.success(), stderr, code)) -} - -/// Try to open a TCP connection to `server:port` within `PROBE_TIMEOUT`. -/// On failure returns `(error_text, hint_text)` — pre-formatted so the -/// caller can persist both. -async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> { - use tokio::net::TcpStream; - let addr = format!("{server}:{port}"); - let connect = TcpStream::connect(&addr); - match tokio::time::timeout(PROBE_TIMEOUT, connect).await { - Ok(Ok(_stream)) => Ok(()), - Ok(Err(e)) => Err(( - format!("cannot reach NFS port: {addr}: {e}"), - format!( - "verify the NFS service is running on {server} and that port {port} is open" - ), - )), - Err(_) => Err(( - format!("cannot reach NFS port: {addr}: timed out after {}s", PROBE_TIMEOUT.as_secs()), - format!( - "no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between", - PROBE_TIMEOUT.as_secs() - ), - )), - } -} - -/// Detect mount.nfs's "failed to apply fstab options" / "internal option -/// parsing error" path. These messages come from -/// `nfs_options2string()` / `nfs_validate_options()` in nfs-utils and -/// are emitted *before* the mount(2) syscall, so retrying with a -/// stripped option set often succeeds. -fn looks_like_option_transform_failure(stderr: &str) -> bool { - let s = stderr.to_ascii_lowercase(); - s.contains("failed to apply fstab options") - || s.contains("internal option parsing error") -} - -/// Translate a mount.nfs stderr blob into an operator-friendly hint. -/// Returns `None` if we don't have a translation — the caller will fall -/// back to surfacing the raw stderr. -#[allow(clippy::if_same_then_else)] // ordering matters; keep the patterns explicit -fn hint_for(stderr: &str) -> Option { - let s = stderr.to_ascii_lowercase(); - if s.contains("failed to apply fstab options") || s.contains("internal option parsing error") { - // The dominant report from the field: mount.nfs failed at the - // option-transform layer. Most common root cause is missing - // CAP_SYS_ADMIN in the container. - Some( - "mount.nfs couldn't finalize the mount. Most common cause: the container is \ - missing CAP_SYS_ADMIN (run with --cap-add=SYS_ADMIN, or use a privileged SCC on \ - OpenShift). Also check that /etc/mtab exists and the host kernel has NFS client \ - support." - .into(), - ) - } else if s.contains("operation not permitted") || s.contains("permission denied") { - Some( - "the container is missing CAP_SYS_ADMIN — mount(2) returns EPERM without it. Re-run \ - with --cap-add=SYS_ADMIN, or grant the OpenShift pod a privileged SCC." - .into(), - ) - } else if s.contains("access denied by server") { - Some( - "the server rejected this client. Check the export's allowed-hosts list includes \ - this OpenPXE host's IP (or 0.0.0.0/0 for testing)." - .into(), - ) - } else if s.contains("no route to host") || s.contains("network is unreachable") { - Some("the server is not reachable on this network. Check the IP, subnet, and routes.".into()) - } else if s.contains("connection refused") { - Some( - "the NFS service isn't listening on this address/port. Verify NFS is running and \ - that the export path is correct (e.g. UniFi UNAS Pro exports under \ - /var/nfs/shared/, not the share name on its own)." - .into(), - ) - } else if s.contains("connection timed out") { - Some( - "no answer from the server within the connect timeout. Most likely a firewall is \ - dropping the connection, or the server isn't running NFS on this port." - .into(), - ) - } else if s.contains("no such file or directory") - || s.contains("mount: bad option") - || s.contains("does not exist") - { - Some( - "the export path doesn't exist on the server, or a mount option isn't recognized. \ - Double-check the export — many NAS appliances bury it under a service root like \ - /var/nfs/shared/." - .into(), - ) - } else if s.contains("rpc: program not registered") || s.contains("mount system call failed") { - Some( - "the server didn't respond on the expected RPC programs. NFSv4.1 needs nfsd on TCP \ - 2049; NFSv3 also needs portmap (111) and mountd. If the server only speaks one \ - version, switch the dropdown to match." - .into(), - ) - } else if s.contains("protocol not supported") || s.contains("invalid argument") { - Some( - "the server doesn't speak the requested NFS version. Try the other entry in the \ - Version dropdown." - .into(), - ) - } else { - None - } -} - -/// Normalize a server input: trim, strip a `http(s)://` prefix that the -/// operator may have pasted by mistake, and drop a trailing slash. Port -/// suffixes (`host:1234`) are preserved so the kernel sees them; the -/// explicit `port=` option still wins if the operator set one. -fn normalize_server(raw: &str) -> String { - let s = raw.trim(); - let s = s - .strip_prefix("http://") - .or_else(|| s.strip_prefix("https://")) - .or_else(|| s.strip_prefix("nfs://")) - .unwrap_or(s); - s.trim_end_matches('/').to_string() -} - -fn mount_id(server: &str, export: &str) -> String { - let raw = format!("{server}{export}"); - slugify_str(&raw) -} - -/// Detect whether `path` is currently a mount point. We don't have -/// `is_mountpoint(2)`, so compare the parent's device id to the dir's; -/// if they differ the dir is a mount. -async fn is_mountpoint(path: &Path) -> bool { - let Some(parent) = path.parent() else { - return false; - }; - let Ok(m1) = tokio::fs::metadata(path).await else { - return false; - }; - let Ok(m2) = tokio::fs::metadata(parent).await else { - return false; - }; - use std::os::unix::fs::MetadataExt; - m1.dev() != m2.dev() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_mount(version: NfsVersion, ro: bool, port: u16) -> NfsMount { - NfsMount { - id: "x".into(), - server: "s".into(), - export: "/e".into(), - version, - read_only: ro, - port, - local_path: PathBuf::from("/tmp/x"), - mounted: false, - last_error: None, - last_hint: None, - last_attempt: None, - iso_count: 0, - } - } - - #[test] - fn version_arg() { - assert_eq!(NfsVersion::V3.vers_arg(), "vers=3"); - assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1"); - } - - #[test] - fn mount_options_v3_includes_nolock_and_tcp() { - let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT); - let opts = mount_options(&m, false); - assert!(opts.contains("vers=3"), "got: {opts}"); - assert!(opts.contains("ro"), "got: {opts}"); - assert!(opts.contains("nolock"), "got: {opts}"); - assert!(opts.contains("proto=tcp"), "got: {opts}"); - assert!(opts.contains("soft"), "got: {opts}"); - assert!(!opts.contains("port="), "default port shouldn't appear: {opts}"); - } - - #[test] - fn mount_options_v41_has_tcp_no_nolock() { - let m = make_mount(NfsVersion::V41, false, DEFAULT_NFS_PORT); - let opts = mount_options(&m, false); - assert!(opts.contains("vers=4.1"), "got: {opts}"); - assert!(opts.contains("rw"), "got: {opts}"); - assert!(opts.contains("proto=tcp"), "got: {opts}"); - assert!(!opts.contains("nolock"), "got: {opts}"); - } - - #[test] - fn mount_options_minimal_drops_everything_except_vers_and_mode() { - let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT); - let opts = mount_options(&m, true); - assert_eq!(opts, "vers=3,ro"); - } - - #[test] - fn mount_options_non_default_port_appears() { - let m = make_mount(NfsVersion::V41, true, 2050); - let opts = mount_options(&m, false); - assert!(opts.contains("port=2050"), "got: {opts}"); - } - - #[test] - fn mount_id_is_stable_and_safe() { - let a = mount_id("10.0.0.5", "/srv/isos"); - let b = mount_id("10.0.0.5", "/srv/isos"); - assert_eq!(a, b); - assert!(!a.contains('/')); - assert!(!a.contains('.')); - } - - #[test] - fn normalize_server_strips_url_schemes_and_slashes() { - assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5"); - assert_eq!(normalize_server("http://10.0.0.5/"), "10.0.0.5"); - assert_eq!(normalize_server("https://nas.lan//"), "nas.lan"); - assert_eq!(normalize_server("nfs://192.168.1.51"), "192.168.1.51"); - assert_eq!(normalize_server("nas.lan:2049"), "nas.lan:2049"); - } - - #[test] - fn hint_for_fstab_options_calls_out_cap_sys_admin() { - let h = hint_for("mount.nfs: failed to apply fstab options").unwrap(); - assert!( - h.contains("CAP_SYS_ADMIN"), - "expected CAP_SYS_ADMIN guidance, got: {h}" - ); - } - - #[test] - fn hint_for_access_denied_points_at_exports_table() { - let h = hint_for("mount.nfs: access denied by server while mounting").unwrap(); - assert!( - h.to_lowercase().contains("allowed-hosts") || h.to_lowercase().contains("export"), - "expected exports hint, got: {h}" - ); - } - - #[test] - fn hint_for_connection_refused_mentions_export_path() { - let h = hint_for("mount.nfs: Connection refused").unwrap(); - assert!( - h.to_lowercase().contains("export"), - "expected export-path hint, got: {h}" - ); - } - - #[test] - fn hint_for_unknown_message_is_none() { - assert!(hint_for("some completely unrelated text").is_none()); - } - - #[test] - fn looks_like_option_transform_failure_detects_both_variants() { - assert!(looks_like_option_transform_failure( - "mount.nfs: failed to apply fstab options" - )); - assert!(looks_like_option_transform_failure( - "mount.nfs: internal option parsing error" - )); - assert!(!looks_like_option_transform_failure( - "mount.nfs: access denied" - )); - } -} diff --git a/crates/iso-store/src/smb_share.rs b/crates/iso-store/src/smb_share.rs new file mode 100644 index 0000000..b683884 --- /dev/null +++ b/crates/iso-store/src/smb_share.rs @@ -0,0 +1,940 @@ +//! SMB share consumer — replaces the kernel-mount NFS path that v0.4.64 +//! shipped. +//! +//! ## Why SMB and not NFS +//! +//! v0.4.64 tried to make `mount -t nfs` work inside the OpenPXE +//! container. With `CAP_SYS_ADMIN` + `--privileged` we still hit the +//! same `mount.nfs: failed to apply fstab options` on Unraid because +//! Unraid's base kernel ships without the `nfs` / `nfsv4` client +//! modules loaded. No amount of container-side configuration can +//! load a kernel module on the host. +//! +//! SMB has the same kernel-side problem (`mount -t cifs` needs the +//! `cifs` kernel module) but unlike NFS it has a usable **userspace** +//! client: Samba's `smbclient` CLI. It speaks the SMB protocol over a +//! plain TCP socket, no kernel modules required. Bootimus uses the +//! same approach. +//! +//! ## How it works +//! +//! 1. Operator submits a share spec via the Storage tab: +//! `{ server: "192.168.1.51", share: "isos", +//! username, password, guest }`. +//! 2. We write credentials to a 0600-permission tempfile under +//! `/smb_creds/`. Passing them on the command line would +//! leak them through `ps` and the container's audit log. +//! 3. We test the connection by listing the share's root with +//! `smbclient //server/share -A creds_file -c 'ls *.iso'`. If the +//! server is unreachable, the share doesn't exist, or auth fails, +//! we get a clean error before persisting anything. +//! 4. We parse the `ls` output for `*.iso` filenames and sizes, and +//! register each one with the `IsoStore` as an +//! `IsoSource::Smb { share_id, relative_path }`. +//! 5. When a PXE client requests the bytes, the HTTP handler asks this +//! manager for an async reader. We spawn +//! `smbclient //server/share -A creds_file -c 'get file -'` and +//! pipe its stdout straight into the response body. No double +//! storage, no temp files. +//! +//! ## Why subprocess and not a Rust library +//! +//! The Debian runtime image already ships the `samba` package +//! (Dockerfile line 84) — `smbclient` is right there. Library options +//! like `pavao` wrap `libsmbclient` so they still pull in the same C +//! library at runtime. Subprocess is simpler, the API surface is +//! whatever the operator can verify with `smbclient` at a shell, and +//! debugging "what does smbclient see?" is trivial. +//! +//! ## Range request limitations (v0.4.65) +//! +//! `smbclient -c 'get file -'` is a sequential whole-file stream; +//! there's no native seek in the CLI. We honor full GETs and reject +//! HTTP Range requests with `416 Range Not Satisfiable` for +//! SMB-sourced ISOs. PXE clients in practice request the whole file: +//! iPXE chain loading, casper sanboot, wimboot all do sequential +//! streaming. A follow-up release can add libsmbclient-based seek if +//! a real workload needs it. + +use crate::introspect::{DistroFamily, IntrospectionReport}; +use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; +use openpxe_core::{Error, Result}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; +use tokio::process::Command; + +/// Default TCP port for SMB / CIFS. The wire protocol moved to 445 +/// years ago; 139 (NetBIOS) is legacy and we don't expose it as an +/// option. +const DEFAULT_SMB_PORT: u16 = 445; + +/// Maximum time we wait for a TCP connection to the SMB server during +/// the pre-flight probe. Same shape as the v0.4.64 NFS probe — short +/// enough that a wrong IP doesn't make the UI hang for 30s, long +/// enough that a slow appliance can still answer. +const PROBE_TIMEOUT: Duration = Duration::from_secs(4); + +/// One configured SMB share. The id is derived from server+share so an +/// operator pasting the same coordinates twice gets idempotent +/// behaviour rather than a duplicate row. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmbShare { + pub id: String, + pub server: String, + pub share: String, + /// Username used for the SMB connection. Empty when `guest` is + /// true. Stored so the UI can echo it back; the password lives in + /// the separate credentials file (see `creds_path`). + pub username: String, + /// True when we're connecting with `-N` (anonymous / guest mode). + /// Most NAS appliances that expose ISO libraries do so as + /// guest-readable; this is the common case. + pub guest: bool, + /// TCP port — 445 unless the operator overrode it. Persisted so + /// the UI can echo it back. + #[serde(default = "default_port")] + pub port: u16, + /// Most recent error encountered talking to the share, or `None` + /// on success. Cleared every successful operation. + 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, + /// Number of `*.iso` files we know about on the share as of the + /// most recent scan. + pub iso_count: u32, + /// Whether the connection's currently working. `true` after a + /// successful scan, `false` after a failure. Drives the UI dot. + pub reachable: bool, + /// Path to the credentials file on disk. Internal — not surfaced + /// in the API JSON; we serialize it for restart-survival but the + /// UI doesn't render it. + #[serde(default)] + #[serde(skip_serializing)] + pub(crate) creds_path: Option, +} + +/// Submission from the UI / API. +#[derive(Debug, Clone, Deserialize)] +pub struct SmbAddRequest { + pub server: String, + pub share: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub guest: bool, + #[serde(default)] + pub port: Option, +} + +fn default_port() -> u16 { + DEFAULT_SMB_PORT +} + +/// Structured error surfaced to the API and rendered in the UI as two +/// lines: the raw `error` from smbclient + an actionable `hint`. +/// Mirrors the v0.4.64 NFS error shape so the storage tab can use a +/// single rendering path. +#[derive(Debug, Clone, Serialize)] +pub struct SmbShareError { + pub error: String, + pub stderr: String, + pub hint: Option, +} + +impl SmbShareError { + 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 SMB shares and surfaces their ISOs through the IsoStore. +/// +/// Cheap to clone — internal state is `Arc>`. +#[derive(Debug, Clone)] +pub struct SmbShareManager { + creds_root: Arc, + state_path: Arc, + inner: Arc>, + iso_store: IsoStore, + /// Serializes scan/list/get against the same share. smbclient + /// itself is fine concurrent across processes, but bundling + /// operations through a single lock makes test ordering and log + /// output predictable. + op_lock: Arc>, +} + +impl SmbShareManager { + /// Construct a manager rooted at `work_dir`. Credentials files + /// live under `/smb_creds/` with 0600 permissions; state + /// persists to `/smb_shares.json`. + #[must_use] + pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { + let creds_root = work_dir.join("smb_creds"); + let state_path = work_dir.join("smb_shares.json"); + Self { + creds_root: Arc::new(creds_root), + 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. Errors per share + /// are logged and surfaced on the spec; the call itself never + /// fails — startup must not block on a single offline server. + pub async fn load_and_rescan(&self) -> Result<()> { + tokio::fs::create_dir_all(self.creds_root.as_path()).await?; + 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::smb", + id = %s.id, server = %s.server, share = %s.share, + "rescan on startup failed: {e}" + ); + } + } + Ok(()) + } + + /// Add or refresh a share. Validates the input, writes a creds + /// file, probes connectivity, and scans for ISOs. + pub async fn add( + &self, + req: SmbAddRequest, + ) -> std::result::Result { + let server = normalize_server(&req.server); + let share = req.share.trim().trim_start_matches('/').to_string(); + if server.is_empty() { + return Err(SmbShareError::from_raw("server is required", "")); + } + if share.is_empty() { + return Err(SmbShareError::from_raw("share name is required", "")); + } + if share.contains('/') { + return Err(SmbShareError::from_raw( + "share name should be the top-level share (e.g. 'isos'), not a path", + "", + )); + } + if server.contains('\0') || share.contains('\0') { + return Err(SmbShareError::from_raw("NUL bytes are not allowed", "")); + } + + let guest = req.guest; + let username = req.username.unwrap_or_default().trim().to_string(); + let password = req.password.unwrap_or_default(); + if !guest && username.is_empty() { + return Err(SmbShareError::from_raw( + "username is required when 'guest' is unchecked", + "", + )); + } + let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SMB_PORT); + + let id = share_id(&server, &share); + + // Pre-flight TCP probe so a wrong IP / firewall surfaces a + // clean error instead of one of smbclient's notoriously + // cryptic NT_STATUS codes. + if let Err((err, hint)) = tcp_probe(&server, port).await { + // No share is persisted yet; just return the error. + return Err(SmbShareError { + error: err, + stderr: String::new(), + hint: Some(hint), + }); + } + + // Write the creds file. Even guest mode gets a file (empty + // username/password) so the code path is uniform. + let creds_path = self.creds_root.join(format!("{id}.cred")); + if let Err(e) = self.write_creds(&creds_path, &username, &password).await { + return Err(SmbShareError::from_raw( + format!("could not write credentials file: {e}"), + "", + )); + } + + let spec = SmbShare { + id: id.clone(), + server, + share, + username, + guest, + port, + last_error: None, + last_hint: None, + last_scan: None, + iso_count: 0, + reachable: false, + creds_path: Some(creds_path), + }; + self.inner.lock().shares.insert(id.clone(), spec); + self.persist_locked(); + + // Now actually talk to the server. + if let Err(e) = self.rescan_inner(&id).await { + let m = self.get(&id); + return Err(SmbShareError { + 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, scrubs the + /// creds file, and forgets the spec. Idempotent. + pub async fn remove(&self, id: &str) -> Result<()> { + let creds_path = { + let mut g = self.inner.lock(); + g.shares.remove(id).and_then(|s| s.creds_path) + }; + self.iso_store.drop_external_source(id); + if let Some(p) = creds_path { + // Overwrite-then-unlink would be more thorough but the + // file is 0600 in a non-root-owned dir; rm is sufficient. + let _ = tokio::fs::remove_file(&p).await; + } + self.persist_locked(); + Ok(()) + } + + /// Re-list the share and refresh the IsoStore entries. + pub async fn rescan(&self, id: &str) -> Result { + self.rescan_inner(id).await + } + + /// Snapshot of every configured share, sorted by id for stable UI + /// rendering. + #[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 + } + + /// Look up a share by id. + #[must_use] + pub fn get(&self, id: &str) -> Option { + self.inner.lock().shares.get(id).cloned() + } + + /// Open an async reader streaming an ISO out of the share. Used + /// by the HTTP ISO download handler. + /// + /// Kept `async` for symmetry with the other I/O entrypoints — + /// spawning the child is sync today (no `.await` inside) but a + /// future addition (e.g. probing the share before spawn or + /// throttling concurrent smbclients) would need to await without + /// changing the call sites. + #[allow(clippy::unused_async)] + pub async fn stream_iso( + &self, + share_id: &str, + filename: &str, + ) -> Result { + let share = self + .get(share_id) + .ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?; + // Defensive: reject any filename that tries to escape the + // share root. smbclient itself accepts only filenames at the + // share root in our `get` form, but belt-and-suspenders. + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(Error::Invalid(format!( + "invalid filename '{filename}'" + ))); + } + let creds = share + .creds_path + .as_deref() + .ok_or_else(|| Error::Invalid("share has no credentials file".into()))?; + let target = format!("//{}/{}", share.server, share.share); + let mut cmd = Command::new("smbclient"); + cmd.arg(&target) + .arg("-A") + .arg(creds) + .arg("-p") + .arg(share.port.to_string()) + .arg("-c") + .arg(format!("get \"{filename}\" -")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()); + if share.guest { + cmd.arg("-N"); + } + let mut child = cmd.spawn().map_err(|e| Error::Other(e.into()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Invalid("smbclient stdout missing".into()))?; + Ok(SmbStream { child, stdout }) + } + + // ── 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 self.list_isos(&share).await { + Ok(l) => l, + Err((err, stderr)) => { + let combined = if stderr.is_empty() { + err.clone() + } else { + format!("{err}: {stderr}") + }; + let hint = hint_for(&stderr).or_else(|| hint_for(&err)); + self.update_status(id, 0, false, Some(combined.clone()), hint, now); + return Err(Error::Invalid(combined)); + } + }; + + // For each ISO we found, we still need its size + a quick + // introspection pass. The introspection pass needs random + // access into the ISO9660 PVD which lives at offset 0x8000. + // For SMB sources we can't seek without downloading the file + // first, so we use a degenerate "unknown family" introspection + // report for the listing pass. Operators can rescan after the + // first PXE boot has touched the file if they want a real + // family detection. (Better: a follow-up release adds a tiny + // `smbclient -c 'get file -'` bounded read to do introspection + // without storing the whole ISO.) + let mut count = 0u32; + for entry in listing { + let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename)); + // SMB sources don't get a real introspection pass — that + // would require seeking into the ISO9660 PVD over the + // network, and smbclient CLI doesn't seek. We register an + // `Unknown` family so the boot-entry generator falls back + // to generic sanboot/wimboot detection from the filename + // and the operator gets *something* bootable. A follow-up + // release can do a bounded `smbclient get` of the first + // 64 KiB for real 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::Smb { + 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::smb", + id = %id, server = %share.server, share = %share.share, + iso_count = count, + "SMB share scanned" + ); + Ok(count) + } + + /// Spawn `smbclient //server/share -A creds -c "ls *.iso"` and + /// parse the output. Returns `(error_text, stderr_text)` on + /// failure so the caller can surface both. + async fn list_isos( + &self, + share: &SmbShare, + ) -> std::result::Result, (String, String)> { + let target = format!("//{}/{}", share.server, share.share); + let mut cmd = Command::new("smbclient"); + cmd.arg(&target) + .arg("-A") + .arg( + share + .creds_path + .as_deref() + .ok_or_else(|| ("no credentials file".to_string(), String::new()))?, + ) + .arg("-p") + .arg(share.port.to_string()) + .arg("-c") + .arg("ls *.iso"); + if share.guest { + cmd.arg("-N"); + } + let output = match cmd.output().await { + Ok(o) => o, + Err(e) => { + return Err(( + format!("could not exec smbclient: {e}"), + String::new(), + )); + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + // smbclient writes most diagnostics to stdout too; merge + // them so we don't lose context. + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let combined = if stderr.is_empty() { stdout } else { stderr }; + return Err(( + format!("smbclient exit {}", output.status.code().unwrap_or(-1)), + combined, + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_ls_iso(&stdout)) + } + + async fn write_creds( + &self, + path: &Path, + username: &str, + password: &str, + ) -> std::io::Result<()> { + tokio::fs::create_dir_all(self.creds_root.as_path()).await?; + // Write the file with 0600 perms. `smbclient -A` accepts the + // standard pam_mount-style: + // username = foo + // password = bar + let body = format!( + "username = {}\npassword = {}\n", + username.replace('\n', ""), + password.replace('\n', ""), + ); + // Synchronous file write to set perms atomically with the + // create — there's no async equivalent of OpenOptions+mode + // shared with the tokio API in std stable. + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || -> std::io::Result<()> { + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)?; + f.write_all(body.as_bytes())?; + Ok(()) + }) + .await + .map_err(std::io::Error::other)??; + Ok(()) + } + + 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(); + } + + /// Atomically replace the on-disk JSON. Persistence errors are + /// logged, never propagated. + 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::smb", "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::smb", "write tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, path) { + tracing::warn!(target: "openpxe::smb", "rename: {e}"); + } + } +} + +/// Async-reader handle for an in-flight `smbclient get file -` stream. +/// Wraps the child process + its piped stdout; dropping it kills the +/// child. +#[derive(Debug)] +pub struct SmbStream { + /// Kept alive so the child isn't reaped while we're reading. The + /// `Drop` impl on `tokio::process::Child` sends SIGKILL on drop + /// when `kill_on_drop` is set; we leave that to the default + /// (no-kill) so a slow client doesn't tear down the pipe before + /// the OS finishes the read. The child exits naturally when its + /// stdout closes. + #[allow(dead_code)] + child: tokio::process::Child, + pub stdout: tokio::process::ChildStdout, +} + +#[derive(Debug, Clone)] +struct SmbListEntry { + filename: String, + size: u64, +} + +/// Parse `smbclient ls *.iso` output. The format is: +/// +/// ```text +/// . D 0 Mon May 26 10:00:00 2026 +/// .. D 0 Mon May 26 10:00:00 2026 +/// ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026 +/// +/// 4096 blocks of size 1048576. 1234 blocks available +/// ``` +/// +/// Each file line: +/// - starts with whitespace +/// - has the filename, then attribute flags (D=dir, A=archive, R=read-only, +/// H=hidden, S=system, N=normal), then size, then date. +/// +/// We accept any line where the attributes column doesn't contain `D` +/// (i.e. not a directory) and the filename ends in `.iso` (case +/// insensitive). +fn parse_ls_iso(out: &str) -> Vec { + let mut entries = Vec::new(); + for raw in out.lines() { + let line = raw.trim(); + // Skip blank lines, the connection-info banner, and the + // trailing "N blocks of size" summary. The actual filter for + // "is this a file listing?" is the attribute+size pattern + // detection below, which only matches real file rows. + if line.is_empty() || line.contains("blocks of size") { + continue; + } + // Find the attribute column: a short token of one or more of + // [DAHSRN] that follows a long-enough filename block. + // smbclient pads the filename to ~36 columns, so we can split + // on multiple consecutive spaces and then look for the + // attribute token. + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.len() < 3 { + continue; + } + // The last 5 tokens are typically: ATTR SIZE Day Mon DD HH:MM:SS YYYY + // (sometimes Day is missing depending on locale). Walk + // backwards to find ATTR + SIZE: ATTR is 1-6 chars of [DAHSRN], + // SIZE is digits. + let attr_idx = tokens.iter().enumerate().rev().find_map(|(i, t)| { + if i == 0 { + return None; + } + let next = tokens.get(i + 1)?; + let is_attr = !t.is_empty() && t.chars().all(|c| "DAHSRN".contains(c)); + let is_size = next.chars().all(|c| c.is_ascii_digit()) && !next.is_empty(); + if is_attr && is_size { + Some(i) + } else { + None + } + }); + let Some(attr_idx) = attr_idx else { continue }; + let attr = tokens[attr_idx]; + // Directories aren't ISO files. + if attr.contains('D') { + continue; + } + let size_tok = tokens[attr_idx + 1]; + let Ok(size) = size_tok.parse::() else { + continue; + }; + // The filename is everything before the attribute token in + // the original (un-tokenized) line — we need the original + // because filenames can contain spaces. + // Locate the attribute token's start column by counting + // characters in the prior tokens + separators. Simpler: find + // the index of the attribute in the trimmed line by joining + // and trimming again. + let joined_before: String = tokens[..attr_idx].join(" "); + let name = joined_before.trim().to_string(); + if name.is_empty() || name == "." || name == ".." { + continue; + } + if !name.to_ascii_lowercase().ends_with(".iso") { + continue; + } + entries.push(SmbListEntry { + filename: name, + size, + }); + } + entries +} + +/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS +/// probe so the UI banner reads consistently. +async fn tcp_probe( + server: &str, + port: u16, +) -> std::result::Result<(), (String, String)> { + use tokio::net::TcpStream; + let addr = format!("{server}:{port}"); + match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(( + format!("cannot reach SMB port: {addr}: {e}"), + format!( + "verify the SMB service is running on {server} and that port {port} is open" + ), + )), + Err(_) => Err(( + format!( + "cannot reach SMB port: {addr}: timed out after {}s", + PROBE_TIMEOUT.as_secs() + ), + format!( + "no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between", + PROBE_TIMEOUT.as_secs() + ), + )), + } +} + +/// Translate well-known smbclient stderr patterns into actionable +/// hints. Returns `None` when we don't have a translation. +fn hint_for(text: &str) -> Option { + let s = text.to_ascii_lowercase(); + if s.contains("nt_status_logon_failure") || s.contains("logon_failure") { + Some( + "the server rejected the credentials. Double-check the username \ + and password — many NAS appliances use a separate SMB account \ + rather than the system login." + .into(), + ) + } else if s.contains("nt_status_access_denied") || s.contains("access_denied") { + Some( + "the credentials worked but the account doesn't have read \ + access to this share. Check the share's permissions on the \ + server." + .into(), + ) + } else if s.contains("nt_status_bad_network_name") + || s.contains("nt_status_bad_network_path") + || s.contains("bad_network_name") + { + Some( + "the share name doesn't exist on this server. Enter just the \ + share name (e.g. 'isos'), not a path. Use `smbclient -L \ + //server` to list shares manually." + .into(), + ) + } else if s.contains("connection refused") { + Some( + "the SMB service isn't accepting connections on this port. \ + Verify smbd / Samba is running on the server." + .into(), + ) + } else if s.contains("connection timed out") || s.contains("no route to host") { + Some( + "the server isn't reachable on this network. Check the IP and \ + any firewall in between." + .into(), + ) + } else if s.contains("nt_status_network_unreachable") { + Some( + "the server's network is unreachable from this container — \ + check the host networking setup." + .into(), + ) + } else if s.contains("does not exist") || s.contains("not a directory") { + Some( + "the listed path doesn't exist on the share. Make sure the \ + share name is the top-level share, not a sub-path." + .into(), + ) + } else if s.contains("session setup failed") { + Some( + "session setup failed — usually a protocol / dialect mismatch. \ + Most modern servers speak SMB2/3; very old shares (XP) may \ + need legacy support enabled on the server." + .into(), + ) + } else { + None + } +} + +fn share_id(server: &str, share: &str) -> String { + slugify_str(&format!("{server}-{share}")) +} + +/// Normalize a server input: trim, strip scheme prefix the operator +/// may have pasted, and drop trailing slashes. UNC-style `\\server` +/// and `//server` prefixes are also accepted. +fn normalize_server(raw: &str) -> String { + let s = raw.trim(); + let s = s + .strip_prefix("smb://") + .or_else(|| s.strip_prefix("cifs://")) + .or_else(|| s.strip_prefix("\\\\")) + .or_else(|| s.strip_prefix("//")) + .unwrap_or(s); + s.trim_end_matches('/').trim_end_matches('\\').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn share_id_is_stable_and_safe() { + let a = share_id("10.0.0.5", "isos"); + let b = share_id("10.0.0.5", "isos"); + assert_eq!(a, b); + assert!(!a.contains('/')); + assert!(!a.contains('.')); + } + + #[test] + fn normalize_server_strips_url_and_unc_prefixes() { + assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5"); + assert_eq!(normalize_server("smb://nas.lan/"), "nas.lan"); + assert_eq!(normalize_server("cifs://192.168.1.51"), "192.168.1.51"); + assert_eq!(normalize_server("\\\\192.168.1.51\\"), "192.168.1.51"); + assert_eq!(normalize_server("//nas.lan//"), "nas.lan"); + assert_eq!(normalize_server("nas.lan"), "nas.lan"); + } + + #[test] + fn hint_for_logon_failure_calls_out_credentials() { + let h = hint_for("session setup failed: NT_STATUS_LOGON_FAILURE").unwrap(); + assert!(h.to_lowercase().contains("credentials")); + } + + #[test] + fn hint_for_bad_share_name_calls_out_share_lookup() { + let h = hint_for("tree connect failed: NT_STATUS_BAD_NETWORK_NAME").unwrap(); + assert!(h.to_lowercase().contains("share")); + } + + #[test] + fn hint_for_unknown_is_none() { + assert!(hint_for("some unrelated error text").is_none()); + } + + #[test] + fn parse_ls_iso_finds_one_iso_and_skips_directories() { + let out = "\ +\tDomain=[WORKGROUP] OS=[Windows] Server=[Samba]\n\ + . D 0 Mon May 26 10:00:00 2026\n\ + .. D 0 Mon May 26 10:00:00 2026\n\ + ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026\n\ +\n\ +\t\t4096 blocks of size 1048576. 1234 blocks available\n\ +"; + let entries = parse_ls_iso(out); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].filename, "ubuntu-22.04-desktop.iso"); + assert_eq!(entries[0].size, 3_650_912_256); + } + + #[test] + fn parse_ls_iso_handles_filenames_with_spaces() { + let out = "\ + Windows Server 2025.iso A 5000000000 Tue May 27 09:00:00 2026\n\ +"; + let entries = parse_ls_iso(out); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].filename, "Windows Server 2025.iso"); + assert_eq!(entries[0].size, 5_000_000_000); + } + + #[test] + fn parse_ls_iso_skips_non_iso_files() { + let out = "\ + readme.txt A 100 Tue May 27 09:00:00 2026\n\ + archive.zip A 5000 Tue May 27 09:00:00 2026\n\ +"; + let entries = parse_ls_iso(out); + assert!(entries.is_empty()); + } + + #[test] + fn add_request_requires_username_when_not_guest() { + // We can't easily test the add() path against a real SMB + // server in unit tests, but we can confirm the validation + // logic at least serializes the request shape we expect. The + // actual auth check happens in add() itself which we cover in + // integration tests against a stub server. + let req = SmbAddRequest { + server: "10.0.0.5".into(), + share: "isos".into(), + username: None, + password: None, + guest: false, + port: None, + }; + // No SmbShareManager here — we just check the field shape + // matches what UI submits. + assert!(!req.guest); + assert!(req.username.is_none()); + } +} diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 20b2c3e..8acbfdb 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -16,17 +16,24 @@ use tokio::io::AsyncWriteExt; /// Where the bytes for an ISO actually live. /// /// The default is `Local` — uploaded ISOs sit in `/.iso`. -/// `Nfs` entries point at a file inside a remote share that the -/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily -/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. +/// `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. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum IsoSource { #[default] Local, - Nfs { - mount_id: String, - /// Path relative to the mount point — typically just the filename. + /// 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. + 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, }, } @@ -164,10 +171,6 @@ struct Inner { #[derive(Debug, Clone)] pub struct IsoStore { iso_dir: Arc, - /// Where NFS mounts land on disk. Set at startup via - /// [`IsoStore::set_nfs_root`]; required for resolving any - /// `IsoSource::Nfs` entry. - nfs_root: Arc>>, inner: Arc>, } @@ -175,17 +178,10 @@ impl IsoStore { pub fn new(iso_dir: PathBuf) -> Self { Self { iso_dir: Arc::new(iso_dir), - nfs_root: Arc::new(RwLock::new(None)), inner: Arc::new(RwLock::new(Inner::default())), } } - /// Tell the store where NFS mounts live. Without this set, - /// `IsoSource::Nfs` entries cannot be resolved to a file path. - pub fn set_nfs_root(&self, root: PathBuf) { - *self.nfs_root.write() = Some(root); - } - pub async fn ensure_dirs(&self) -> Result<()> { tokio::fs::create_dir_all(self.iso_dir.as_path()).await?; Ok(()) @@ -281,33 +277,32 @@ impl IsoStore { self.inner.read().isos.get(id).cloned() } - /// Resolve an ISO id to its on-disk path, if any. For local entries - /// this is `/.iso`; for NFS entries it's - /// `//`. Returns None if the file - /// is missing or the source isn't resolvable (e.g. NFS share - /// unmounted). + /// Resolve an ISO id to its on-disk path, if any. For local + /// (uploaded) ISOs this is `/.iso`. For SMB-sourced + /// ISOs there is no on-disk path — the HTTP handler must stream + /// via `SmbShareManager::stream_iso` instead. Returns `None` for + /// SMB sources or when the file is missing. pub fn iso_path_for(&self, id: &str) -> Option { let meta = self.get(id)?; - let path = match &meta.source { - IsoSource::Local => self.iso_path(id), - IsoSource::Nfs { - mount_id, - relative_path, - } => { - let root = self.nfs_root.read().clone()?; - root.join(mount_id).join(relative_path) + match &meta.source { + IsoSource::Local => { + let path = self.iso_path(id); + if path.exists() { + Some(path) + } else { + None + } } - }; - if path.exists() { - Some(path) - } else { - 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, } } - /// Delete an ISO and its sidecar metadata. Only acts on local ISOs; - /// for NFS-backed ISOs the operator must remove the file from the - /// share or unmount the NFS share entirely. + /// Delete an ISO and its sidecar metadata. Only acts on local + /// (uploaded) ISOs; for SMB-backed ISOs the operator must remove + /// the file from the share or unregister the share entirely. pub async fn delete(&self, id: &str) -> Result<()> { let meta = self.get(id); let is_local = matches!( @@ -324,10 +319,10 @@ impl IsoStore { Ok(()) } - /// Register an externally-sourced ISO (e.g. NFS-mounted). Used by - /// `NfsManager` after walking a freshly-mounted share. We do **not** - /// persist a `meta.json` on disk for these — the source of truth is - /// the share itself, and the NFS manager re-scans on startup. + /// Register an externally-sourced ISO (SMB share, etc.). Used by + /// `SmbShareManager` after listing a share. We do **not** persist + /// a `meta.json` on disk for these — the source of truth is the + /// share itself, and the manager re-scans on startup. pub fn register_external( &self, id: String, @@ -352,13 +347,13 @@ impl IsoStore { self.inner.write().isos.insert(id, meta); } - /// Drop every entry that belongs to `mount_id`. Used by the NFS - /// manager when an operator removes a share, or before re-scanning - /// to clean out stale entries. - pub fn drop_external_source(&self, mount_id: &str) { + /// 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. + pub fn drop_external_source(&self, share_id: &str) { let mut g = self.inner.write(); g.isos.retain( - |_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id), + |_, m| !matches!(&m.source, IsoSource::Smb { share_id: sid, .. } if sid == share_id), ); } diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index a721973..bd5a750 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, NfsManager, SmbManager}; +use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager}; use openpxe_tftp::TftpServer; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -119,13 +119,18 @@ async fn main() -> anyhow::Result<()> { let _ = smb.start(); } - // NFS manager. The mount root has to be set on the IsoStore *before* - // we replay any persisted mounts, otherwise an in-memory IsoMeta - // pointing at an NFS source can't resolve to a path. - let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone()); - iso_store.set_nfs_root(nfs.mount_root()); - if let Err(e) = nfs.load_and_remount().await { - tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}"); + // v0.4.65: SMB share manager — Samba `smbclient` userspace + // consumer. Replaces the kernel-mount NFS path that v0.4.64 + // shipped; that didn't work on hosts whose kernel lacked the nfs + // client modules (Unraid is the dominant case). `smbclient` does + // the SMB protocol entirely in userspace over TCP and works in + // any container regardless of capabilities or kernel modules. + let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone()); + if let Err(e) = smb_shares.load_and_rescan().await { + tracing::warn!( + target: "openpxe::smb", + "could not reload SMB shares on startup: {e}" + ); } // Sniff network details for the Network tab. None of these are @@ -152,7 +157,7 @@ async fn main() -> anyhow::Result<()> { sso: sso.clone(), metrics: metrics.clone(), smb: Some(smb.clone()), - nfs: nfs.clone(), + smb_shares: smb_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 3f0feea..2697e68 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -167,7 +167,8 @@ 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.nfs_active || 0) + ' NFS active'), + (status.smb_share_reachable || 0) + ' SMB share' + + ((status.smb_share_reachable || 0) === 1 ? '' : 's')), ])), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Uptime'), @@ -342,13 +343,18 @@ }, storage: async () => { - const [isos, settings, nfsRes, disk] = await Promise.all([ - getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'), + // 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([ + getJSON('/api/isos'), getJSON('/api/settings'), + getJSON('/api/smb-shares'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), ]); - const mounts = nfsRes.mounts || []; + const shares = smbRes.shares || []; // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ @@ -450,7 +456,7 @@ } } - // ── ISO table (mixed local + NFS) ── + // ── ISO table (mixed local + SMB) ── // Each row gets a "Password" cell that toggles a small inline // editor (a checkbox + a password field + Save button) inside the // *next* row of the table. Keeps the markup flat and avoids the @@ -458,7 +464,10 @@ const rowsAndEditors = []; isos.forEach(i => { const b = bootability(i, settings); - const isNfs = i.source && i.source.kind === 'nfs'; + // v0.4.65: SMB userspace consumer replaced NFS. The badge + // colours stay the same so the table looks unchanged for + // existing operators. + const isSmb = i.source && i.source.kind === 'smb'; const protectedNow = !!i.password_hash; // The inline editor row is hidden by default; the Password @@ -578,8 +587,8 @@ ]), el('td', {class:'num'}, fmtBytes(i.size_bytes)), el('td', {}, - el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, - isNfs ? ('nfs:' + i.source.mount_id) : 'local')), + el('span', {class:'src-badge' + (isSmb ? ' nfs' : '')}, + isSmb ? ('smb:' + i.source.share_id) : 'local')), el('td', {}, protectedNow ? el('span', {class:'tag accent'}, 'protected') @@ -589,8 +598,11 @@ el('button', {class:'ghost', style:'margin-right:6px', onclick: () => { editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none'; }}, protectedNow ? 'Password ✎' : 'Set password'), - isNfs - ? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS') + 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') : el('button', {class:'danger', onclick: async () => { if (!confirm('Remove this image?')) return; await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); @@ -610,80 +622,98 @@ ])), el('tbody', {}, rowsAndEditors), ]) - : el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.'); + : el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.'); - // ── NFS section ── - const nfsMsg = el('div', {class:'msg'}); - const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'}); - const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'}); - const nfsVer = el('select', {}, [ - el('option', {value:'v41'}, 'NFSv4.1 (default)'), - el('option', {value:'v3'}, 'NFSv3'), - ]); - const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true; - const addNfs = el('button', {onclick: async () => { - if (!nfsServer.value || !nfsExport.value) { - nfsMsg.replaceChildren(document.createTextNode('Server and export are required.')); - nfsMsg.className='msg err'; return; + // ── SMB shares section (v0.4.65) ── + // Replaces the kernel-mount NFS card. SMB shares are consumed + // in userspace via Samba's `smbclient` CLI — no kernel modules, + // no CAP_SYS_ADMIN, works in any container. This is the same + // approach Bootimus uses. + const smbMsg = el('div', {class:'msg'}); + const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'}); + const smbShare = el('input', {type:'text', placeholder:'isos'}); + const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true; + const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'}); + const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'}); + // Toggle username/password fields based on the Guest checkbox so + // operators don't get confused about which fields matter. + const syncAuthDisabled = () => { + smbUser.disabled = smbGuest.checked; + smbPass.disabled = smbGuest.checked; + smbUser.style.opacity = smbGuest.checked ? '0.55' : '1'; + smbPass.style.opacity = smbGuest.checked ? '0.55' : '1'; + }; + smbGuest.addEventListener('change', syncAuthDisabled); + syncAuthDisabled(); + + const addSmb = el('button', {onclick: async () => { + if (!smbServer.value || !smbShare.value) { + smbMsg.replaceChildren(document.createTextNode('Server and share name are required.')); + smbMsg.className='msg err'; return; } - nfsMsg.replaceChildren(document.createTextNode('Mounting…')); - nfsMsg.className = 'msg'; - const r = await postJSON('/api/nfs', { - server: nfsServer.value, export: nfsExport.value, - version: nfsVer.value, read_only: nfsRo.checked, - }); + if (!smbGuest.checked && !smbUser.value) { + smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.')); + smbMsg.className='msg err'; return; + } + smbMsg.replaceChildren(document.createTextNode('Connecting…')); + smbMsg.className = 'msg'; + const body = { + server: smbServer.value, + share: smbShare.value, + guest: smbGuest.checked, + }; + if (!smbGuest.checked) { + body.username = smbUser.value; + body.password = smbPass.value; + } + const r = await postJSON('/api/smb-shares', body); if (r.ok) { - nfsMsg.replaceChildren(document.createTextNode('Mounted.')); - nfsMsg.className = 'msg ok'; + smbMsg.replaceChildren(document.createTextNode('Connected.')); + smbMsg.className = 'msg ok'; render('storage'); } else { - // v0.4.64: the API now returns a structured - // {error, stderr, hint} JSON body so we can render the - // mount failure and an actionable hint as two distinct lines - // instead of one long unreadable string. The dominant field - // failure mode — "mount.nfs: failed to apply fstab options" — - // becomes useful when paired with its CAP_SYS_ADMIN hint. - let body = null; + // The API returns a structured {error, stderr, hint} JSON + // body on failure so the raw smbclient error and the + // actionable hint render as two distinct lines. + let bodyJson = null; let raw = null; - try { body = await r.clone().json(); } - catch (_) { raw = await r.text().catch(()=> 'mount failed'); } - const msg = body && body.error ? body.error : (raw || 'mount failed'); - const hint = body && body.hint; + 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', {}, 'Mount failed: '), + 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'; + smbMsg.replaceChildren(...parts); + smbMsg.className = 'msg err'; } - }}, 'Mount share'); + }}, 'Add share'); - const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [ - el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}), + const smbRows = shares.length ? shares.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:'id'}, '//' + m.server + '/' + m.share), el('div', {class:'meta'}, - (m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' + - (m.read_only ? 'read-only' : 'read-write') + ' · ' + - (m.mounted ? m.iso_count + ' isos' : 'not mounted')), + (m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' + + (m.reachable ? m.iso_count + ' isos' : 'not reachable')), m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null, - // v0.4.64: actionable hint paired with the raw error. 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/' + encodeURIComponent(m.id) + '/scan', {}); + const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {}); if (r.ok) render('storage'); }}, 'Re-scan'), el('button', {class:'danger', onclick: async () => { - if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return; - await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'}); + if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return; + await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'}); render('storage'); - }}, 'Unmount'), + }}, 'Remove'), el('span'), - ])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')]; + ])) : [el('div', {class:'empty'}, 'No SMB 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 @@ -733,34 +763,42 @@ ]), el('div', {class:'card'}, [ el('header', {}, [ - el('h2', {}, 'NFS shares'), - el('span', {class:'sub'}, mounts.length + ' configured'), + el('h2', {}, 'SMB shares'), + el('span', {class:'sub'}, shares.length + ' configured'), ]), el('div', {class:'body'}, [ - el('div', {class:'form-row'}, [ + el('div', {class:'form-row cols-2'}, [ el('label', {class:'field'}, [ - el('span', {class:'name'}, 'NFS server'), - nfsServer, + el('span', {class:'name'}, 'SMB server'), + smbServer, ]), el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Export path'), - nfsExport, - ]), - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Version'), - nfsVer, - ]), - el('label', {class:'check', style:'margin-top:18px'}, [ - nfsRo, el('span', {}, 'Read-only'), + el('span', {class:'name'}, 'Share name'), + smbShare, ]), ]), - addNfs, nfsMsg, - el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), + el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [ + el('label', {class:'check'}, [ + smbGuest, el('span', {}, 'Guest (anonymous read)'), + ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Username'), + smbUser, + ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Password'), + smbPass, + ]), + ]), + addSmb, smbMsg, + el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows), el('p', {class:'msg', style:'margin-top:14px'}, - 'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' + - 'mount.nfs binary (bundled in the default Docker image). On ' + - 'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' + - 'NFS mounts as a CSI driver outside the pod.'), + 'SMB shares are read in userspace via Samba’s smbclient — ' + + 'no kernel modules, no CAP_SYS_ADMIN, works in any container ' + + '(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' + + 'appliances expose ISO libraries as guest-readable; check the box ' + + 'above when that’s the case. ISOs are streamed on demand at PXE ' + + 'boot time — no local cache, no double disk usage.'), ]), ]), el('div', {class:'card'}, [