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
@@ -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
|
||||
//! `<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::{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<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");
|
||||
}
|
||||
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<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 {
|
||||
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<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) => {
|
||||
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<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("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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user