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
@@ -18,16 +18,21 @@
|
||||
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
pub mod nfs;
|
||||
pub mod pxe_logo;
|
||||
pub mod smb;
|
||||
pub mod smb_share;
|
||||
pub mod store;
|
||||
pub mod windows;
|
||||
|
||||
pub use entry::{BootEntry, BootKind, KernelArgs};
|
||||
pub use introspect::{DistroFamily, IntrospectionReport};
|
||||
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
|
||||
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
|
||||
// `smbclient` CLI replaced it — works in any container (no
|
||||
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
|
||||
// every other PXE/imaging tool that supports network storage handles
|
||||
// it.
|
||||
pub use smb::{extract_windows_iso, SmbManager, SmbState};
|
||||
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
|
||||
pub use store::{
|
||||
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
|
||||
UploadHandle,
|
||||
|
||||
@@ -1,984 +0,0 @@
|
||||
//! NFS share manager.
|
||||
//!
|
||||
//! Lets an operator mount a remote NFS export as an ISO source instead of
|
||||
//! uploading every ISO into the container's PVC. Supports NFSv3 and
|
||||
//! NFSv4.1 — the two versions the user explicitly asked for.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! 1. Operator submits a mount spec via the Storage tab:
|
||||
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
|
||||
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
|
||||
//! to `mount.nfs -v -o vers=...,ro,nolock,proto=tcp server:export local`.
|
||||
//! 3. On success we walk the mount point looking for `*.iso` files and
|
||||
//! register each one with the `IsoStore` as an external source — same
|
||||
//! introspection pipeline as a web upload, but no sha256 (the bytes
|
||||
//! live on a remote machine; hashing them would suck them through the
|
||||
//! network on every restart).
|
||||
//! 4. On failure we record `last_error` + `hint` on the spec and persist
|
||||
//! anyway so the UI can show a row in red with an actionable hint
|
||||
//! rather than silently dropping it.
|
||||
//!
|
||||
//! ## Operational notes
|
||||
//!
|
||||
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
|
||||
//! `nfs-common` package. The default image ships these (see Dockerfile).
|
||||
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
|
||||
//! doesn't — operators have to opt in by switching to a more privileged
|
||||
//! SCC or running NFS mounts as a CSI driver outside the pod.
|
||||
//! - Mount commands are issued sequentially under a single mutex to avoid
|
||||
//! `mount` racing on the same target dir.
|
||||
//!
|
||||
//! ## v0.4.64 diagnostics rework
|
||||
//!
|
||||
//! Field reports showed `mount.nfs: failed to apply fstab options` (exit
|
||||
//! code 32) was the dominant failure surfaced through the UI — a deeply
|
||||
//! unhelpful message from nfs-utils 2.6.x that has nothing to do with
|
||||
//! `/etc/fstab`. It comes from `nfs_options2string()` and lights up when
|
||||
//! the kernel can't accept the assembled options, when mtab can't be
|
||||
//! written (container without `CAP_SYS_ADMIN`), or when an obscure option
|
||||
//! triggers a transformation edge case. In v0.4.64 we:
|
||||
//!
|
||||
//! 1. Probe TCP reach to `server:port` before shelling out so a wrong
|
||||
//! IP / closed firewall surfaces as a clear "cannot reach NFS port"
|
||||
//! instead of `failed to apply fstab options`.
|
||||
//! 2. Pass `proto=tcp` explicitly on NFSv3 (UDP is widely deprecated
|
||||
//! and several NAS appliances don't bind it at all).
|
||||
//! 3. On `failed to apply fstab options`, retry with a stripped-down
|
||||
//! option set (`vers=N,ro/rw`) — that frequently succeeds and at
|
||||
//! minimum produces a real kernel error.
|
||||
//! 4. Translate well-known stderr patterns into operator-friendly hints
|
||||
//! and persist them on the mount so the UI can show "what to fix
|
||||
//! next" instead of the raw mount.nfs message.
|
||||
//!
|
||||
//! ## Persistence
|
||||
//!
|
||||
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
|
||||
//! re-mounted on startup. Mounts that fail to come back online keep their
|
||||
//! spec and their `last_error` so the operator sees what happened.
|
||||
|
||||
use crate::introspect::{introspect, IntrospectionReport};
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Default port for NFS over TCP. We expose it as a constant so the
|
||||
/// pre-flight probe and the option string assembly use the same value.
|
||||
const DEFAULT_NFS_PORT: u16 = 2049;
|
||||
|
||||
/// How long to wait for a TCP connection to the NFS server before
|
||||
/// declaring it unreachable. Short enough that a wrong IP doesn't make
|
||||
/// the UI hang for half a minute; long enough that a slow appliance
|
||||
/// can still answer.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Wire-protocol versions we support. Keep this enum closed — silently
|
||||
/// accepting "auto" or letting the kernel negotiate would mean operators
|
||||
/// could never confirm which version is in use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NfsVersion {
|
||||
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
|
||||
/// older NAS appliances.
|
||||
V3,
|
||||
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
|
||||
V41,
|
||||
}
|
||||
|
||||
impl NfsVersion {
|
||||
fn vers_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::V3 => "vers=3",
|
||||
Self::V41 => "vers=4.1",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short label for UI surfaces and log lines.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::V3 => "NFSv3",
|
||||
Self::V41 => "NFSv4.1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One configured mount. The id is generated from server+export so the
|
||||
/// operator can re-add the same export idempotently.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NfsMount {
|
||||
pub id: String,
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
pub version: NfsVersion,
|
||||
/// Read-only by default — most ISO libraries are. Operators that need
|
||||
/// write can flip this off but OpenPXE itself never writes.
|
||||
pub read_only: bool,
|
||||
/// TCP port for the NFS service. Defaults to 2049; configurable for
|
||||
/// the (rare) case where the appliance binds the service elsewhere.
|
||||
/// v0.4.64: previously inferred at runtime; now persisted so the UI
|
||||
/// can echo the value back to the operator.
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
/// Local mount point under `<work_dir>/nfs/`.
|
||||
pub local_path: PathBuf,
|
||||
/// Whether the mount is currently active.
|
||||
pub mounted: bool,
|
||||
/// Last error encountered on a `mount` or `umount` attempt; cleared on
|
||||
/// success.
|
||||
pub last_error: Option<String>,
|
||||
/// v0.4.64: operator-friendly translation of `last_error` — e.g. for
|
||||
/// "failed to apply fstab options" we surface "CAP_SYS_ADMIN may be
|
||||
/// missing on the container". `None` means we don't have a friendlier
|
||||
/// rendition than the raw error.
|
||||
#[serde(default)]
|
||||
pub last_hint: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_attempt: Option<OffsetDateTime>,
|
||||
/// Number of `.iso` files found on the share (re-counted on each scan).
|
||||
pub iso_count: u32,
|
||||
}
|
||||
|
||||
/// Spec submitted by the UI. Server and export are normalized before use.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NfsAddRequest {
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
#[serde(default = "default_version")]
|
||||
pub version: NfsVersion,
|
||||
#[serde(default = "default_ro")]
|
||||
pub read_only: bool,
|
||||
/// Optional TCP port — defaults to 2049 if omitted or zero.
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
fn default_version() -> NfsVersion {
|
||||
NfsVersion::V41
|
||||
}
|
||||
fn default_ro() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_port() -> u16 {
|
||||
DEFAULT_NFS_PORT
|
||||
}
|
||||
|
||||
/// Outcome of an `add` attempt. `Ok` carries the mount; `Err` from the
|
||||
/// API layer is converted to this richer shape so the UI can render the
|
||||
/// raw error and the actionable hint independently.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NfsMountError {
|
||||
/// The first line / summary of what went wrong.
|
||||
pub error: String,
|
||||
/// Verbatim stderr from `mount.nfs` (trimmed). May be empty.
|
||||
pub stderr: String,
|
||||
/// Operator-friendly hint or `None` if we don't have one.
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl NfsMountError {
|
||||
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 {
|
||||
mounts: HashMap<String, NfsMount>,
|
||||
}
|
||||
|
||||
/// Manages NFS mounts and surfaces them as ISO sources.
|
||||
///
|
||||
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NfsManager {
|
||||
work_root: Arc<PathBuf>,
|
||||
state_path: Arc<PathBuf>,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
iso_store: IsoStore,
|
||||
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
|
||||
/// avoids racing on the same target directory.
|
||||
mount_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl NfsManager {
|
||||
/// Construct a manager rooted at `work_dir`. Mount points live under
|
||||
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
|
||||
#[must_use]
|
||||
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
|
||||
let work_root = work_dir.join("nfs");
|
||||
let state_path = work_dir.join("nfs.json");
|
||||
Self {
|
||||
work_root: Arc::new(work_root),
|
||||
state_path: Arc::new(state_path),
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where this manager mounts shares. Used by `IsoStore` to resolve
|
||||
/// NFS-backed `IsoMeta`s to their on-disk path.
|
||||
#[must_use]
|
||||
pub fn mount_root(&self) -> PathBuf {
|
||||
self.work_root.as_ref().clone()
|
||||
}
|
||||
|
||||
/// Load persisted state and re-attempt every mount. Errors are logged
|
||||
/// per-mount but never fail the call — startup must not block on a
|
||||
/// remote NFS server being slow.
|
||||
pub async fn load_and_remount(&self) -> Result<()> {
|
||||
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
|
||||
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
|
||||
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
for mut m in mounts {
|
||||
// Always start from "not mounted" — the kernel state was lost
|
||||
// when the process died. We'll try to remount each one.
|
||||
m.mounted = false;
|
||||
m.last_error = None;
|
||||
m.last_hint = None;
|
||||
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
|
||||
if let Err(e) = self.try_mount(&m.id).await {
|
||||
tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
id = %m.id, error = %e,
|
||||
"could not remount NFS share on startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
|
||||
/// reflecting reality) or a structured `NfsMountError` describing
|
||||
/// what went wrong.
|
||||
pub async fn add(
|
||||
&self,
|
||||
req: NfsAddRequest,
|
||||
) -> std::result::Result<NfsMount, NfsMountError> {
|
||||
let server = normalize_server(&req.server);
|
||||
let export = req.export.trim().to_string();
|
||||
if server.is_empty() {
|
||||
return Err(NfsMountError::from_raw(
|
||||
"server is required",
|
||||
"",
|
||||
));
|
||||
}
|
||||
if !export.starts_with('/') {
|
||||
return Err(NfsMountError::from_raw(
|
||||
"export path must start with '/'",
|
||||
"",
|
||||
));
|
||||
}
|
||||
if export.contains('\0') || server.contains('\0') {
|
||||
return Err(NfsMountError::from_raw(
|
||||
"server / export must not contain NUL bytes",
|
||||
"",
|
||||
));
|
||||
}
|
||||
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT);
|
||||
|
||||
let id = mount_id(&server, &export);
|
||||
let local_path = self.work_root.join(&id);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&local_path).await {
|
||||
return Err(NfsMountError::from_raw(
|
||||
format!("failed to create local mount point: {e}"),
|
||||
"",
|
||||
));
|
||||
}
|
||||
|
||||
let mount = NfsMount {
|
||||
id: id.clone(),
|
||||
server,
|
||||
export,
|
||||
version: req.version,
|
||||
read_only: req.read_only,
|
||||
port,
|
||||
local_path,
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_hint: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
self.inner.lock().mounts.insert(id.clone(), mount);
|
||||
self.persist_locked();
|
||||
self.try_mount(&id).await.map_err(|e| {
|
||||
// try_mount has already persisted last_error/last_hint. We
|
||||
// refetch them so the API response reflects exactly what the
|
||||
// UI will see when it lists mounts.
|
||||
let m = self.get(&id);
|
||||
NfsMountError {
|
||||
error: m.as_ref().and_then(|m| m.last_error.clone())
|
||||
.unwrap_or_else(|| e.to_string()),
|
||||
stderr: String::new(),
|
||||
hint: m.and_then(|m| m.last_hint),
|
||||
}
|
||||
})?;
|
||||
Ok(self.get(&id).expect("mount just inserted"))
|
||||
}
|
||||
|
||||
/// Unmount and forget a share. Removes any ISOs it contributed from
|
||||
/// the IsoStore and deletes the local mount point. Idempotent.
|
||||
pub async fn remove(&self, id: &str) -> Result<()> {
|
||||
// Best-effort umount; even if it fails (e.g. server unreachable)
|
||||
// we still want to drop the in-memory record.
|
||||
let _ = self.umount_one(id).await;
|
||||
let local_path = {
|
||||
let mut g = self.inner.lock();
|
||||
g.mounts.remove(id).map(|m| m.local_path)
|
||||
};
|
||||
self.persist_locked();
|
||||
self.iso_store.drop_external_source(id);
|
||||
if let Some(p) = local_path {
|
||||
// rmdir only — never recurse, the mount could still be live
|
||||
// on some kernel error path and we don't want to nuke a
|
||||
// remote filesystem.
|
||||
let _ = tokio::fs::remove_dir(&p).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
|
||||
pub async fn rescan(&self, id: &str) -> Result<u32> {
|
||||
let mount = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
|
||||
if !mount.mounted {
|
||||
return Err(Error::Invalid(format!("mount '{id}' is not active")));
|
||||
}
|
||||
let count = self.scan_and_register(&mount).await?;
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.iso_count = count;
|
||||
}
|
||||
self.persist_locked();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Snapshot of every configured mount.
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<NfsMount> {
|
||||
let g = self.inner.lock();
|
||||
let mut v: Vec<_> = g.mounts.values().cloned().collect();
|
||||
v.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
v
|
||||
}
|
||||
|
||||
/// Look up a single mount by id.
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<NfsMount> {
|
||||
self.inner.lock().mounts.get(id).cloned()
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────
|
||||
|
||||
async fn try_mount(&self, id: &str) -> Result<()> {
|
||||
let _g = self.mount_lock.lock().await;
|
||||
|
||||
let m = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
// Already mounted? Skip — `mount` would error on a busy target
|
||||
// and confuse the operator's UI status.
|
||||
if is_mountpoint(&m.local_path).await {
|
||||
self.update_status(id, true, None, None, now);
|
||||
// Even though already mounted, we still want a fresh ISO count.
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// v0.4.64: pre-flight TCP probe. Catches the dominant failure
|
||||
// mode (wrong IP / firewall) before mount.nfs gets a chance to
|
||||
// emit its unhelpful "failed to apply fstab options" message.
|
||||
if let Err((err, hint)) = tcp_probe(&m.server, m.port).await {
|
||||
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
|
||||
self.update_status(id, false, Some(err.clone()), Some(hint), now);
|
||||
return Err(Error::Invalid(err));
|
||||
}
|
||||
|
||||
// First attempt: full option set.
|
||||
let full_opts = mount_options(&m, /*minimal*/ false);
|
||||
let target = format!("{}:{}", m.server, m.export);
|
||||
let attempt = run_mount_nfs(&full_opts, &target, &m.local_path).await;
|
||||
|
||||
let (success, stderr, exit_code) = match attempt {
|
||||
Ok((true, stderr, _)) => (true, stderr, 0),
|
||||
Ok((false, stderr, code)) => (false, stderr, code),
|
||||
Err(e) => {
|
||||
let err = format!("could not exec mount(8): {e}");
|
||||
let hint = Some(
|
||||
"the runtime image is missing /bin/mount or nfs-common — \
|
||||
verify the container hasn't been stripped down"
|
||||
.to_string(),
|
||||
);
|
||||
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
|
||||
self.update_status(id, false, Some(err.clone()), hint, now);
|
||||
return Err(Error::Invalid(err));
|
||||
}
|
||||
};
|
||||
|
||||
if success {
|
||||
tracing::info!(
|
||||
target: "openpxe::nfs",
|
||||
id = %id, server = %m.server, export = %m.export,
|
||||
version = %m.version.label(), port = m.port,
|
||||
"NFS mount succeeded"
|
||||
);
|
||||
self.update_status(id, true, None, None, now);
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Second attempt: if the first attempt failed with the
|
||||
// "failed to apply fstab options" oddity, retry with a minimal
|
||||
// option set. nfs-utils 2.6.x sometimes chokes on the assembled
|
||||
// option string for reasons unrelated to the actual options
|
||||
// being valid; the stripped form bypasses the transformation
|
||||
// edge case.
|
||||
let trigger_retry = looks_like_option_transform_failure(&stderr);
|
||||
let (final_success, final_stderr, final_exit_code) = if trigger_retry {
|
||||
tracing::info!(
|
||||
target: "openpxe::nfs", id = %id,
|
||||
"retrying with minimal options after option-transform failure"
|
||||
);
|
||||
let minimal = mount_options(&m, /*minimal*/ true);
|
||||
match run_mount_nfs(&minimal, &target, &m.local_path).await {
|
||||
Ok((true, s, _)) => (true, s, 0),
|
||||
Ok((false, s, c)) => (false, s, c),
|
||||
Err(e) => (false, format!("could not exec mount(8): {e}"), -1),
|
||||
}
|
||||
} else {
|
||||
(false, stderr, exit_code)
|
||||
};
|
||||
|
||||
if final_success {
|
||||
tracing::info!(
|
||||
target: "openpxe::nfs", id = %id,
|
||||
"NFS mount succeeded on minimal-options retry"
|
||||
);
|
||||
self.update_status(id, true, None, None, now);
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Failure path: persist a clear error and a hint, log both.
|
||||
// `mount(8)` passes mount.nfs's stderr through verbatim, so the
|
||||
// user-visible text reads like "mount.nfs: ..." — we prepend the
|
||||
// exit code so the operator can tell at a glance that the helper
|
||||
// ran but rejected the request, vs the helper not running at all.
|
||||
let err = if final_stderr.is_empty() {
|
||||
format!("mount exit {final_exit_code}")
|
||||
} else {
|
||||
format!("mount exit {final_exit_code}: {}", final_stderr.trim())
|
||||
};
|
||||
let hint = hint_for(&final_stderr);
|
||||
tracing::warn!(
|
||||
target: "openpxe::nfs", id = %id,
|
||||
hint = ?hint, "{err}"
|
||||
);
|
||||
self.update_status(id, false, Some(err.clone()), hint, now);
|
||||
Err(Error::Invalid(err))
|
||||
}
|
||||
|
||||
async fn umount_one(&self, id: &str) -> Result<()> {
|
||||
let _g = self.mount_lock.lock().await;
|
||||
let Some(m) = self.get(id) else { return Ok(()) };
|
||||
if !is_mountpoint(&m.local_path).await {
|
||||
self.update_status(id, false, None, None, OffsetDateTime::now_utc());
|
||||
return Ok(());
|
||||
}
|
||||
// -l = lazy: detach immediately, finish when no process has a
|
||||
// handle. Important if a stale ISO read is still in flight.
|
||||
let out = Command::new("umount")
|
||||
.arg("-l")
|
||||
.arg(&m.local_path)
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
self.update_status(id, false, None, None, OffsetDateTime::now_utc());
|
||||
Ok(())
|
||||
}
|
||||
Ok(o) => {
|
||||
let e = format!(
|
||||
"umount exit {}: {}",
|
||||
o.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
);
|
||||
self.update_status(
|
||||
id,
|
||||
false,
|
||||
Some(e.clone()),
|
||||
None,
|
||||
OffsetDateTime::now_utc(),
|
||||
);
|
||||
Err(Error::Invalid(e))
|
||||
}
|
||||
Err(e) => {
|
||||
let e = format!("could not exec /bin/umount: {e}");
|
||||
self.update_status(
|
||||
id,
|
||||
false,
|
||||
Some(e.clone()),
|
||||
None,
|
||||
OffsetDateTime::now_utc(),
|
||||
);
|
||||
Err(Error::Invalid(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the mount point for `*.iso` files, introspect each one, and
|
||||
/// register it with the IsoStore as an NFS-sourced entry. Returns the
|
||||
/// count of ISOs registered.
|
||||
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
|
||||
// Drop any prior entries from this mount before re-registering, so
|
||||
// a removed file disappears from the store.
|
||||
self.iso_store.drop_external_source(&m.id);
|
||||
|
||||
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
|
||||
let mut count = 0u32;
|
||||
while let Some(entry) = walker.next_entry().await? {
|
||||
let p = entry.path();
|
||||
if p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
!= Some("iso")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let filename = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(f) => f.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let size = tokio::fs::metadata(&p).await?.len();
|
||||
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
|
||||
// it to a blocking thread so the runtime stays responsive on
|
||||
// a slow share.
|
||||
let p_owned = p.clone();
|
||||
let report: IntrospectionReport =
|
||||
tokio::task::spawn_blocking(move || introspect(&p_owned))
|
||||
.await
|
||||
.map_err(|e| Error::Other(e.into()))?;
|
||||
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
|
||||
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
|
||||
let source = IsoSource::Nfs {
|
||||
mount_id: m.id.clone(),
|
||||
relative_path: filename.clone(),
|
||||
};
|
||||
self.iso_store
|
||||
.register_external(id, filename, size, report, boot_entries, source);
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
mounted: bool,
|
||||
err: Option<String>,
|
||||
hint: Option<String>,
|
||||
ts: OffsetDateTime,
|
||||
) {
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.mounted = mounted;
|
||||
m.last_error = err;
|
||||
m.last_hint = hint;
|
||||
m.last_attempt = Some(ts);
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
fn update_iso_count(&self, id: &str, count: u32) {
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.iso_count = count;
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
/// Atomically replace the on-disk JSON with the current state.
|
||||
/// Persistence errors are logged, never propagated — settings live in
|
||||
/// memory authoritatively, matching the SettingsStore policy.
|
||||
fn persist_locked(&self) {
|
||||
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
|
||||
let path = self.state_path.as_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let body = match serde_json::to_vec_pretty(&mounts) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `-o` option list. With `minimal=true` we strip everything
|
||||
/// except the protocol version and ro/rw — used on the retry path when
|
||||
/// the first attempt failed at option transformation, which historically
|
||||
/// indicates one of the auxiliary options confused `nfs_options2string()`.
|
||||
fn mount_options(m: &NfsMount, minimal: bool) -> String {
|
||||
let mut opts = vec![m.version.vers_arg().to_string()];
|
||||
if m.read_only {
|
||||
opts.push("ro".into());
|
||||
} else {
|
||||
opts.push("rw".into());
|
||||
}
|
||||
if minimal {
|
||||
return opts.join(",");
|
||||
}
|
||||
// Explicit TCP. NFSv4.x is TCP-only by spec, but stating it
|
||||
// doesn't hurt and on NFSv3 it's necessary on appliances that
|
||||
// don't bind UDP (which is most modern ones).
|
||||
opts.push("proto=tcp".into());
|
||||
// `nolock` for v3 — many storage appliances disable lockd; we don't
|
||||
// need locking for read-only ISO access anyway. nfs-utils still
|
||||
// tries to contact rpc.statd without it which is a no-op overhead.
|
||||
if matches!(m.version, NfsVersion::V3) {
|
||||
opts.push("nolock".into());
|
||||
}
|
||||
// Non-standard port hint to the kernel.
|
||||
if m.port != DEFAULT_NFS_PORT {
|
||||
opts.push(format!("port={}", m.port));
|
||||
}
|
||||
// Soft mount with a generous timeout — better to surface a hung share
|
||||
// as a user-visible error than to wedge the iPXE client forever on a
|
||||
// dead NFS server.
|
||||
opts.push("soft".into());
|
||||
opts.push("timeo=100".into());
|
||||
opts.push("retrans=3".into());
|
||||
opts.join(",")
|
||||
}
|
||||
|
||||
/// Invoke `mount -t nfs`. Returns `(success, stderr_trimmed,
|
||||
/// exit_code)`. `stderr` is captured separately from `stdout`;
|
||||
/// `mount(8)` passes mount.nfs's stderr through verbatim, so we get the
|
||||
/// same diagnostics ("mount.nfs: ...") whether we invoke `mount.nfs`
|
||||
/// directly or go through the generic wrapper.
|
||||
///
|
||||
/// We deliberately stay on `mount` rather than `mount.nfs` directly
|
||||
/// because `/bin/mount` is in every user's PATH; `mount.nfs` lives in
|
||||
/// `/sbin` (or `/usr/sbin`) and is *not* in the default PATH for the
|
||||
/// non-root `openpxe` user. The generic `mount` binary knows where its
|
||||
/// NFS helper lives and dispatches accordingly.
|
||||
async fn run_mount_nfs(
|
||||
opts: &str,
|
||||
target: &str,
|
||||
local: &Path,
|
||||
) -> std::io::Result<(bool, String, i32)> {
|
||||
let output = Command::new("mount")
|
||||
.arg("-t")
|
||||
.arg("nfs")
|
||||
.arg("-o")
|
||||
.arg(opts)
|
||||
.arg(target)
|
||||
.arg(local)
|
||||
.output()
|
||||
.await?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
Ok((output.status.success(), stderr, code))
|
||||
}
|
||||
|
||||
/// Try to open a TCP connection to `server:port` within `PROBE_TIMEOUT`.
|
||||
/// On failure returns `(error_text, hint_text)` — pre-formatted so the
|
||||
/// caller can persist both.
|
||||
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
|
||||
use tokio::net::TcpStream;
|
||||
let addr = format!("{server}:{port}");
|
||||
let connect = TcpStream::connect(&addr);
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, connect).await {
|
||||
Ok(Ok(_stream)) => Ok(()),
|
||||
Ok(Err(e)) => Err((
|
||||
format!("cannot reach NFS port: {addr}: {e}"),
|
||||
format!(
|
||||
"verify the NFS service is running on {server} and that port {port} is open"
|
||||
),
|
||||
)),
|
||||
Err(_) => Err((
|
||||
format!("cannot reach NFS port: {addr}: timed out after {}s", PROBE_TIMEOUT.as_secs()),
|
||||
format!(
|
||||
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
|
||||
PROBE_TIMEOUT.as_secs()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect mount.nfs's "failed to apply fstab options" / "internal option
|
||||
/// parsing error" path. These messages come from
|
||||
/// `nfs_options2string()` / `nfs_validate_options()` in nfs-utils and
|
||||
/// are emitted *before* the mount(2) syscall, so retrying with a
|
||||
/// stripped option set often succeeds.
|
||||
fn looks_like_option_transform_failure(stderr: &str) -> bool {
|
||||
let s = stderr.to_ascii_lowercase();
|
||||
s.contains("failed to apply fstab options")
|
||||
|| s.contains("internal option parsing error")
|
||||
}
|
||||
|
||||
/// Translate a mount.nfs stderr blob into an operator-friendly hint.
|
||||
/// Returns `None` if we don't have a translation — the caller will fall
|
||||
/// back to surfacing the raw stderr.
|
||||
#[allow(clippy::if_same_then_else)] // ordering matters; keep the patterns explicit
|
||||
fn hint_for(stderr: &str) -> Option<String> {
|
||||
let s = stderr.to_ascii_lowercase();
|
||||
if s.contains("failed to apply fstab options") || s.contains("internal option parsing error") {
|
||||
// The dominant report from the field: mount.nfs failed at the
|
||||
// option-transform layer. Most common root cause is missing
|
||||
// CAP_SYS_ADMIN in the container.
|
||||
Some(
|
||||
"mount.nfs couldn't finalize the mount. Most common cause: the container is \
|
||||
missing CAP_SYS_ADMIN (run with --cap-add=SYS_ADMIN, or use a privileged SCC on \
|
||||
OpenShift). Also check that /etc/mtab exists and the host kernel has NFS client \
|
||||
support."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("operation not permitted") || s.contains("permission denied") {
|
||||
Some(
|
||||
"the container is missing CAP_SYS_ADMIN — mount(2) returns EPERM without it. Re-run \
|
||||
with --cap-add=SYS_ADMIN, or grant the OpenShift pod a privileged SCC."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("access denied by server") {
|
||||
Some(
|
||||
"the server rejected this client. Check the export's allowed-hosts list includes \
|
||||
this OpenPXE host's IP (or 0.0.0.0/0 for testing)."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("no route to host") || s.contains("network is unreachable") {
|
||||
Some("the server is not reachable on this network. Check the IP, subnet, and routes.".into())
|
||||
} else if s.contains("connection refused") {
|
||||
Some(
|
||||
"the NFS service isn't listening on this address/port. Verify NFS is running and \
|
||||
that the export path is correct (e.g. UniFi UNAS Pro exports under \
|
||||
/var/nfs/shared/<name>, not the share name on its own)."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("connection timed out") {
|
||||
Some(
|
||||
"no answer from the server within the connect timeout. Most likely a firewall is \
|
||||
dropping the connection, or the server isn't running NFS on this port."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("no such file or directory")
|
||||
|| s.contains("mount: bad option")
|
||||
|| s.contains("does not exist")
|
||||
{
|
||||
Some(
|
||||
"the export path doesn't exist on the server, or a mount option isn't recognized. \
|
||||
Double-check the export — many NAS appliances bury it under a service root like \
|
||||
/var/nfs/shared/<share>."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("rpc: program not registered") || s.contains("mount system call failed") {
|
||||
Some(
|
||||
"the server didn't respond on the expected RPC programs. NFSv4.1 needs nfsd on TCP \
|
||||
2049; NFSv3 also needs portmap (111) and mountd. If the server only speaks one \
|
||||
version, switch the dropdown to match."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("protocol not supported") || s.contains("invalid argument") {
|
||||
Some(
|
||||
"the server doesn't speak the requested NFS version. Try the other entry in the \
|
||||
Version dropdown."
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a server input: trim, strip a `http(s)://` prefix that the
|
||||
/// operator may have pasted by mistake, and drop a trailing slash. Port
|
||||
/// suffixes (`host:1234`) are preserved so the kernel sees them; the
|
||||
/// explicit `port=` option still wins if the operator set one.
|
||||
fn normalize_server(raw: &str) -> String {
|
||||
let s = raw.trim();
|
||||
let s = s
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| s.strip_prefix("https://"))
|
||||
.or_else(|| s.strip_prefix("nfs://"))
|
||||
.unwrap_or(s);
|
||||
s.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn mount_id(server: &str, export: &str) -> String {
|
||||
let raw = format!("{server}{export}");
|
||||
slugify_str(&raw)
|
||||
}
|
||||
|
||||
/// Detect whether `path` is currently a mount point. We don't have
|
||||
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
|
||||
/// if they differ the dir is a mount.
|
||||
async fn is_mountpoint(path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(m1) = tokio::fs::metadata(path).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(m2) = tokio::fs::metadata(parent).await else {
|
||||
return false;
|
||||
};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
m1.dev() != m2.dev()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_mount(version: NfsVersion, ro: bool, port: u16) -> NfsMount {
|
||||
NfsMount {
|
||||
id: "x".into(),
|
||||
server: "s".into(),
|
||||
export: "/e".into(),
|
||||
version,
|
||||
read_only: ro,
|
||||
port,
|
||||
local_path: PathBuf::from("/tmp/x"),
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_hint: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_arg() {
|
||||
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
|
||||
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_v3_includes_nolock_and_tcp() {
|
||||
let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
|
||||
let opts = mount_options(&m, false);
|
||||
assert!(opts.contains("vers=3"), "got: {opts}");
|
||||
assert!(opts.contains("ro"), "got: {opts}");
|
||||
assert!(opts.contains("nolock"), "got: {opts}");
|
||||
assert!(opts.contains("proto=tcp"), "got: {opts}");
|
||||
assert!(opts.contains("soft"), "got: {opts}");
|
||||
assert!(!opts.contains("port="), "default port shouldn't appear: {opts}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_v41_has_tcp_no_nolock() {
|
||||
let m = make_mount(NfsVersion::V41, false, DEFAULT_NFS_PORT);
|
||||
let opts = mount_options(&m, false);
|
||||
assert!(opts.contains("vers=4.1"), "got: {opts}");
|
||||
assert!(opts.contains("rw"), "got: {opts}");
|
||||
assert!(opts.contains("proto=tcp"), "got: {opts}");
|
||||
assert!(!opts.contains("nolock"), "got: {opts}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_minimal_drops_everything_except_vers_and_mode() {
|
||||
let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
|
||||
let opts = mount_options(&m, true);
|
||||
assert_eq!(opts, "vers=3,ro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_non_default_port_appears() {
|
||||
let m = make_mount(NfsVersion::V41, true, 2050);
|
||||
let opts = mount_options(&m, false);
|
||||
assert!(opts.contains("port=2050"), "got: {opts}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_id_is_stable_and_safe() {
|
||||
let a = mount_id("10.0.0.5", "/srv/isos");
|
||||
let b = mount_id("10.0.0.5", "/srv/isos");
|
||||
assert_eq!(a, b);
|
||||
assert!(!a.contains('/'));
|
||||
assert!(!a.contains('.'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_server_strips_url_schemes_and_slashes() {
|
||||
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
|
||||
assert_eq!(normalize_server("http://10.0.0.5/"), "10.0.0.5");
|
||||
assert_eq!(normalize_server("https://nas.lan//"), "nas.lan");
|
||||
assert_eq!(normalize_server("nfs://192.168.1.51"), "192.168.1.51");
|
||||
assert_eq!(normalize_server("nas.lan:2049"), "nas.lan:2049");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_fstab_options_calls_out_cap_sys_admin() {
|
||||
let h = hint_for("mount.nfs: failed to apply fstab options").unwrap();
|
||||
assert!(
|
||||
h.contains("CAP_SYS_ADMIN"),
|
||||
"expected CAP_SYS_ADMIN guidance, got: {h}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_access_denied_points_at_exports_table() {
|
||||
let h = hint_for("mount.nfs: access denied by server while mounting").unwrap();
|
||||
assert!(
|
||||
h.to_lowercase().contains("allowed-hosts") || h.to_lowercase().contains("export"),
|
||||
"expected exports hint, got: {h}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_connection_refused_mentions_export_path() {
|
||||
let h = hint_for("mount.nfs: Connection refused").unwrap();
|
||||
assert!(
|
||||
h.to_lowercase().contains("export"),
|
||||
"expected export-path hint, got: {h}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_for_unknown_message_is_none() {
|
||||
assert!(hint_for("some completely unrelated text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_option_transform_failure_detects_both_variants() {
|
||||
assert!(looks_like_option_transform_failure(
|
||||
"mount.nfs: failed to apply fstab options"
|
||||
));
|
||||
assert!(looks_like_option_transform_failure(
|
||||
"mount.nfs: internal option parsing error"
|
||||
));
|
||||
assert!(!looks_like_option_transform_failure(
|
||||
"mount.nfs: access denied"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,24 @@ use tokio::io::AsyncWriteExt;
|
||||
/// Where the bytes for an ISO actually live.
|
||||
///
|
||||
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
||||
/// `Nfs` entries point at a file inside a remote share that the
|
||||
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
||||
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
||||
/// `Smb` entries (v0.4.65) point at a file inside a remote SMB share
|
||||
/// that the `SmbShareManager` knows how to stream via Samba's
|
||||
/// userspace `smbclient` CLI. The HTTP handler resolves the share by
|
||||
/// id at request time and pipes `smbclient -c 'get file -'` straight
|
||||
/// into the response body — no kernel mount, no local cache.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum IsoSource {
|
||||
#[default]
|
||||
Local,
|
||||
Nfs {
|
||||
mount_id: String,
|
||||
/// Path relative to the mount point — typically just the filename.
|
||||
/// v0.4.65: kernel-mount NFS is gone (it didn't work on Unraid
|
||||
/// regardless of capabilities — the host kernel needs the nfs
|
||||
/// client modules loaded). SMB via userspace `smbclient` works in
|
||||
/// any container.
|
||||
Smb {
|
||||
share_id: String,
|
||||
/// Filename at the share root. We don't support nested paths
|
||||
/// in v0.4.65; ISOs live at the top of the share.
|
||||
relative_path: String,
|
||||
},
|
||||
}
|
||||
@@ -164,10 +171,6 @@ struct Inner {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IsoStore {
|
||||
iso_dir: Arc<PathBuf>,
|
||||
/// Where NFS mounts land on disk. Set at startup via
|
||||
/// [`IsoStore::set_nfs_root`]; required for resolving any
|
||||
/// `IsoSource::Nfs` entry.
|
||||
nfs_root: Arc<RwLock<Option<PathBuf>>>,
|
||||
inner: Arc<RwLock<Inner>>,
|
||||
}
|
||||
|
||||
@@ -175,17 +178,10 @@ impl IsoStore {
|
||||
pub fn new(iso_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
iso_dir: Arc::new(iso_dir),
|
||||
nfs_root: Arc::new(RwLock::new(None)),
|
||||
inner: Arc::new(RwLock::new(Inner::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the store where NFS mounts live. Without this set,
|
||||
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
|
||||
pub fn set_nfs_root(&self, root: PathBuf) {
|
||||
*self.nfs_root.write() = Some(root);
|
||||
}
|
||||
|
||||
pub async fn ensure_dirs(&self) -> Result<()> {
|
||||
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
|
||||
Ok(())
|
||||
@@ -281,33 +277,32 @@ impl IsoStore {
|
||||
self.inner.read().isos.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Resolve an ISO id to its on-disk path, if any. For local entries
|
||||
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
|
||||
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
|
||||
/// is missing or the source isn't resolvable (e.g. NFS share
|
||||
/// unmounted).
|
||||
/// Resolve an ISO id to its on-disk path, if any. For local
|
||||
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
|
||||
/// ISOs there is no on-disk path — the HTTP handler must stream
|
||||
/// via `SmbShareManager::stream_iso` instead. Returns `None` for
|
||||
/// SMB sources or when the file is missing.
|
||||
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
||||
let meta = self.get(id)?;
|
||||
let path = match &meta.source {
|
||||
IsoSource::Local => self.iso_path(id),
|
||||
IsoSource::Nfs {
|
||||
mount_id,
|
||||
relative_path,
|
||||
} => {
|
||||
let root = self.nfs_root.read().clone()?;
|
||||
root.join(mount_id).join(relative_path)
|
||||
match &meta.source {
|
||||
IsoSource::Local => {
|
||||
let path = self.iso_path(id);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
// SMB sources have no local path — they're streamed via
|
||||
// smbclient subprocess. Callers should check the source
|
||||
// kind first and dispatch accordingly.
|
||||
IsoSource::Smb { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
|
||||
/// for NFS-backed ISOs the operator must remove the file from the
|
||||
/// share or unmount the NFS share entirely.
|
||||
/// Delete an ISO and its sidecar metadata. Only acts on local
|
||||
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
|
||||
/// the file from the share or unregister the share entirely.
|
||||
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||
let meta = self.get(id);
|
||||
let is_local = matches!(
|
||||
@@ -324,10 +319,10 @@ impl IsoStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
|
||||
/// `NfsManager` after walking a freshly-mounted share. We do **not**
|
||||
/// persist a `meta.json` on disk for these — the source of truth is
|
||||
/// the share itself, and the NFS manager re-scans on startup.
|
||||
/// Register an externally-sourced ISO (SMB share, etc.). Used by
|
||||
/// `SmbShareManager` after listing a share. We do **not** persist
|
||||
/// a `meta.json` on disk for these — the source of truth is the
|
||||
/// share itself, and the manager re-scans on startup.
|
||||
pub fn register_external(
|
||||
&self,
|
||||
id: String,
|
||||
@@ -352,13 +347,13 @@ impl IsoStore {
|
||||
self.inner.write().isos.insert(id, meta);
|
||||
}
|
||||
|
||||
/// Drop every entry that belongs to `mount_id`. Used by the NFS
|
||||
/// manager when an operator removes a share, or before re-scanning
|
||||
/// to clean out stale entries.
|
||||
pub fn drop_external_source(&self, mount_id: &str) {
|
||||
/// Drop every entry that belongs to `share_id`. Used by the SMB
|
||||
/// share manager when an operator removes a share, or before
|
||||
/// re-scanning to clean out stale entries.
|
||||
pub fn drop_external_source(&self, share_id: &str) {
|
||||
let mut g = self.inner.write();
|
||||
g.isos.retain(
|
||||
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
|
||||
|_, m| !matches!(&m.source, IsoSource::Smb { share_id: sid, .. } if sid == share_id),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user