v0.4.65: swap kernel-mount NFS for userspace SMB (smbclient)
v0.4.64's NFS path didn't work on Unraid even with --privileged
because Unraid's base kernel ships without the nfs/nfsv4 client
modules — and no container-side configuration can load a host kernel
module. SMB has the same kernel-mount problem (`mount -t cifs` needs
the cifs module) but it also has a usable *userspace* client: Samba's
`smbclient` CLI, which speaks the SMB protocol over a plain TCP socket
with no kernel involvement. This is the same approach Bootimus uses,
and works in every container regardless of host kernel modules or
container capabilities.
What's gone:
* `crates/iso-store/src/nfs.rs` (in entirety)
* `NfsManager`, `NfsMount`, `NfsAddRequest`, `NfsVersion` types
* `IsoSource::Nfs` variant
* `IsoStore::nfs_root` / `IsoStore::set_nfs_root`
* `/api/nfs`, `/api/nfs/:id`, `/api/nfs/:id/scan` routes
* `nfs` terminal command
* Storage tab's NFS shares card and the v0.4.64 fstab-options
diagnostics work (the whole error path is moot now)
What's new:
* `crates/iso-store/src/smb_share.rs` — `SmbShareManager` that drives
`smbclient` as a subprocess. Indexes shares via `smbclient -c "ls
*.iso"` and streams files via `smbclient -c "get file -"` piped
straight into HTTP response bodies. No local cache, no double disk
usage.
* `IsoSource::Smb { share_id, relative_path }` variant.
* `IsoStore::iso_path_for` returns None for SMB sources — the HTTP
ISO download handler dispatches on the source kind and streams via
the SmbShareManager when it's SMB.
* `/api/smb-shares` + `/api/smb-shares/:id` + `/api/smb-shares/:id/scan`
routes.
* `share` terminal command (`list | add //srv/share [auth] | remove |
scan`). Auth spec is `guest` or `user:password`.
* Storage tab: SMB shares card replaces the NFS one. Two-column form
for server + share name, three-column form for guest checkbox /
username / password. Username and password fields auto-disable when
Guest is checked.
* Credentials live under <work_dir>/smb_creds/<id>.cred at 0600
permissions so they don't leak through `ps`. Persisted state at
<work_dir>/smb_shares.json (sans password — re-entered on add /
re-scan).
Why subprocess and not a Rust crate:
* The Debian runtime image already ships the `samba` package
(Dockerfile line 84) — `smbclient` is right there.
* Library options (pavao, etc.) wrap libsmbclient so they still pull
in the same C library at runtime.
* Subprocess gives operators a verifiable mental model — anything
OpenPXE can do over SMB, they can reproduce by running `smbclient`
manually at a shell.
Range-request limitation, called out in the smb_share.rs module docs
and the UI explainer: `smbclient -c 'get file -'` is a sequential
whole-file stream. HTTP range requests on SMB-sourced ISOs return
416. PXE workloads (iPXE chain, casper sanboot, wimboot) do
whole-file sequential reads, so this works in practice. A follow-up
release can add libsmbclient-based seek if a real workload needs it.
Stderr-to-hint translation patterns mirror v0.4.64's NFS work:
NT_STATUS_LOGON_FAILURE → "check credentials", BAD_NETWORK_NAME →
"check share name", connection refused / timeout → "verify
reachability + firewall", etc. UI renders the raw smbclient error
plus the hint as two lines.
Tests (149 total, was 142 in v0.4.64):
* smb_share parser tests covering ISO + skipped directory, filenames
with spaces, non-ISO filtering.
* hint_for() translation tests for the dominant NT_STATUS codes.
* Server normalization (smb://, cifs://, \\, // prefixes all stripped).
* HTTP integration: shares list starts empty, invalid server / missing
username / path in share name all rejected with actionable hints.
`cargo clippy --workspace --all-targets -- -D warnings` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
07e7c18698
commit
900b65b3ec
@@ -88,7 +88,11 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
"isos" | "images" => Ok(isos_text(state)),
|
||||
"clients" => Ok(clients_text(state)),
|
||||
"queue" => queue_command(state, tail).await,
|
||||
"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<String, String>
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs ────────────────────────────────────────────────────────────────
|
||||
// ── share (v0.4.65: SMB shares) ─────────────────────────────────────────
|
||||
|
||||
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
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 <server>:<export> [v3|v41] [ro|rw]
|
||||
Some("add") => {
|
||||
// share add //server/share [guest|user:password]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs mount <server>:<export> [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<String, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("unmount") => {
|
||||
Some("remove") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs unmount <id>".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 <id>".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 <id>".to_string())?;
|
||||
match s.nfs.rescan(id).await {
|
||||
.ok_or_else(|| "usage: share scan <id>".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 <target> assign every waiting client
|
||||
queue release <entry_id> release one queued client
|
||||
|
||||
nfs list list NFS mounts
|
||||
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
|
||||
nfs unmount <id> unmount and forget a share
|
||||
nfs scan <id> 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 <id> forget an SMB share
|
||||
share scan <id> 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::<String>::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"]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user