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]>
997 lines
38 KiB
Rust
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());
|
|
}
|
|
}
|