Files
OpenPXE/crates/iso-store/src/smb_share.rs
T
Miles WardandClaude Opus 4.8 06695c3d77 v0.5.9: El Torito boot detection + retroactive re-introspect; static SSO login button
Storage / boot detection
- Add El Torito boot-catalog detection to ISO introspection. This is the
  authoritative "can this boot at all?" signal: any ISO with a boot catalog
  (BSDs, ESXi, firmware tools, custom spins) is bootable via iPXE sanboot;
  a data/appliance ISO (e.g. a VMware vCenter bundle) has none and is
  honestly flagged. Replaces the crude ">1.5 GB ⇒ unbootable" size guess.
- Re-introspect stale LOCAL ISOs on startup via an introspection-revision
  gate (INTROSPECT_REV). ISOs uploaded by an older binary carried a frozen
  family/boot profile — most visibly a Windows 11 ISO tagged Unknown before
  the UDF/UTF-16 detection landed, which then showed "won't boot" forever.
  An upgrade now re-probes and fixes them in place; no delete-and-re-upload.
- WebUI bootability() keys off family / kernel / el_torito / remote-source
  instead of the size heuristic; dashboard family counts now bucket
  Windows / Linux / other honestly instead of lumping everything non-Windows
  under "Linux".

SSO login button
- The "Sign in with …" button keyed off the auth-gated /api/sso, which 401s
  pre-auth — so the button only survived on a stale in-memory config and
  vanished instance-wide on any fresh login-page load. Ship a minimal,
  non-sensitive SSO descriptor (enabled + idp_name + idp_logo_url, no
  metadata/entity-ID) on the public /api/me; the login card reads that.
  The button is now static whenever SSO is usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 11:47:41 -04:00

997 lines
38 KiB
Rust

//! 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
//! `<work_dir>/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::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<String>,
/// Operator-friendly translation of `last_error`. None when we
/// don't have a friendlier rendition.
pub last_hint: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_scan: Option<OffsetDateTime>,
/// 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<PathBuf>,
}
/// Submission from the UI / API.
#[derive(Debug, Clone, Deserialize)]
pub struct SmbAddRequest {
pub server: String,
pub share: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub guest: bool,
#[serde(default)]
pub port: Option<u16>,
}
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<String>,
}
impl SmbShareError {
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> 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<String, SmbShare>,
}
/// Manages SMB shares and surfaces their ISOs through the IsoStore.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SmbShareManager {
creds_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
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<tokio::sync::Mutex<()>>,
}
impl SmbShareManager {
/// Construct a manager rooted at `work_dir`. Credentials files
/// live under `<work_dir>/smb_creds/` with 0600 permissions; state
/// persists to `<work_dir>/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::<Vec<SmbShare>>(&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<SmbShare, SmbShareError> {
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<u32> {
self.rescan_inner(id).await
}
/// Snapshot of every configured share, sorted by id for stable UI
/// rendering.
#[must_use]
pub fn list(&self) -> Vec<SmbShare> {
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<SmbShare> {
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<SmbStream> {
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");
}
// v0.4.66: surface a useful error if smbclient isn't on the
// PATH. Shouldn't happen on the stock image but custom
// builds may strip it.
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::Invalid(
"smbclient binary not found on $PATH — install the \
Debian `smbclient` package or pull OpenPXE v0.4.66+"
.into(),
)
} else {
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<u32> {
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::default();
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<Vec<SmbListEntry>, (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) => {
// v0.4.66: distinguish ENOENT (missing binary) from
// other exec failures and pre-fill the hint so the
// UI shows a clear remediation instead of the bare
// "No such file or directory (os error 2)". This
// shouldn't fire on the stock image — the Dockerfile
// installs the `smbclient` package — but is a useful
// breadcrumb for anyone running OpenPXE in a stripped
// base image.
let stderr = if e.kind() == std::io::ErrorKind::NotFound {
"smbclient binary not found on $PATH".to_string()
} else {
String::new()
};
return Err((
format!("could not exec smbclient: {e}"),
stderr,
));
}
};
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<String>,
hint: Option<String>,
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<SmbShare> = 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<SmbListEntry> {
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::<u64>() 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<String> {
let s = text.to_ascii_lowercase();
if s.contains("smbclient binary not found")
|| s.contains("smbclient: no such file")
|| (s.contains("could not exec smbclient") && s.contains("os error 2"))
{
// v0.4.66: this only fires on a stripped / custom runtime
// image — the stock OpenPXE container ships `smbclient` from
// the Debian `smbclient` package. The error surfaced on
// v0.4.65 specifically because that release's Dockerfile
// installed `samba` (the server) but not `smbclient` (the
// client CLI). Operators on the stock image should never see
// this; if they do, the fix is to upgrade.
Some(
"smbclient isn't installed in this container. Pull the \
official OpenPXE image v0.4.66 or newer — the stock image \
ships smbclient. If you're running a custom build, add the \
Debian `smbclient` package to your runtime stage."
.into(),
)
} else 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 hint_for_missing_smbclient_calls_out_upgrade() {
// The exec-side path sets `stderr` to "smbclient binary not
// found on $PATH" when ENOENT lands.
let h = hint_for("smbclient binary not found on $PATH").unwrap();
assert!(
h.contains("smbclient") && h.to_lowercase().contains("install"),
"expected upgrade/install guidance, got: {h}"
);
// The raw error path on the API side passes the verbatim
// exec error in `error` plus an empty `stderr`. The
// SmbShareError constructor's hint_for fallback checks error
// too, so this pattern needs to translate as well.
let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)")
.unwrap();
assert!(h2.contains("smbclient"));
}
#[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());
}
}