415 lines
14 KiB
Rust
415 lines
14 KiB
Rust
//! SMB share manager. Spawns and supervises `smbd` for serving extracted
|
|
//! Windows install trees on port 445. This is the server side of the
|
|
//! Bootimus-pattern Windows boot: WinPE does `net use Z: \\server\<share>`
|
|
//! and runs Setup from there.
|
|
//!
|
|
//! Lifecycle:
|
|
//!
|
|
//! 1. Web UI toggles `windows_enabled = true` → `SmbManager::start()`.
|
|
//! We write an `smb.conf` that declares one share per extracted
|
|
//! Windows ISO, then `smbd --foreground --no-process-group`.
|
|
//! 2. When a Windows ISO is uploaded, `extract_windows_iso()` unpacks
|
|
//! it under `smb_dir/<iso_id>/` and `SmbManager::reconcile_shares()`
|
|
//! rewrites `smb.conf` and signals smbd to reload (SIGHUP).
|
|
//! 3. When the toggle flips off, `stop()` sends SIGTERM to smbd and
|
|
//! leaves the extracted trees in place (in case the toggle comes
|
|
//! back on).
|
|
//!
|
|
//! Safety posture:
|
|
//! - Guest-mode SMB, read-only (`writable = no`, `guest ok = yes`).
|
|
//! - SMB2 minimum (no SMB1 legacy, not needed for WinPE).
|
|
//! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install
|
|
//! VLAN — guest SMB is not for the general internet.
|
|
//! - smbd runs as the same non-root uid as openpxe (10001).
|
|
//! - If `smbd` isn't on PATH (e.g. lightweight container build without
|
|
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
|
|
//! gap. No panics, no retries, no silent failure.
|
|
|
|
use parking_lot::Mutex;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case", tag = "state")]
|
|
pub enum SmbState {
|
|
/// Windows support is off — smbd not running.
|
|
Disabled,
|
|
/// `smbd` is missing from the image. Operator enabled Windows but the
|
|
/// runtime container didn't include Samba.
|
|
SmbdMissing,
|
|
/// Started and healthy.
|
|
Running { pid: u32, shares: Vec<String> },
|
|
/// Tried to start but smbd exited. Reason is captured for the UI.
|
|
Failed { reason: String },
|
|
}
|
|
|
|
pub struct SmbManager {
|
|
smb_dir: PathBuf,
|
|
conf_path: PathBuf,
|
|
child: Arc<Mutex<Option<Child>>>,
|
|
state: Arc<Mutex<SmbState>>,
|
|
}
|
|
|
|
impl SmbManager {
|
|
pub fn new(smb_dir: PathBuf) -> Self {
|
|
let conf_path = smb_dir.join("smb.conf");
|
|
Self {
|
|
smb_dir,
|
|
conf_path,
|
|
child: Arc::new(Mutex::new(None)),
|
|
state: Arc::new(Mutex::new(SmbState::Disabled)),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> SmbState {
|
|
self.state.lock().clone()
|
|
}
|
|
|
|
/// Enumerate `<smb_dir>/*/` sub-dirs as shares. An extracted Windows
|
|
/// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
|
|
/// the sorted list.
|
|
pub fn discover_shares(&self) -> Vec<String> {
|
|
let Ok(rd) = std::fs::read_dir(&self.smb_dir) else {
|
|
return vec![];
|
|
};
|
|
let mut out: Vec<String> = rd
|
|
.flatten()
|
|
.filter(|e| e.path().is_dir())
|
|
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
|
// Ignore hidden / internal dirs.
|
|
.filter(|n| !n.starts_with('.') && n != "tmp")
|
|
.collect();
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
/// Write out `smb.conf` for the currently-discovered shares. Safe to
|
|
/// call while smbd is running — smbd reloads on SIGHUP.
|
|
pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
|
|
use std::fmt::Write as _;
|
|
std::fs::create_dir_all(&self.smb_dir)?;
|
|
let shares = self.discover_shares();
|
|
let mut conf = String::new();
|
|
conf.push_str(SMB_CONF_GLOBAL);
|
|
for name in &shares {
|
|
let path = self.smb_dir.join(name);
|
|
// Per-share block. `write!` to String never fails — the unwrap
|
|
// is provably unreachable, but expect() makes that explicit.
|
|
write!(
|
|
conf,
|
|
"\n[{name}]\n\
|
|
path = {}\n\
|
|
comment = OpenPXE Windows install media ({name})\n\
|
|
read only = yes\n\
|
|
guest ok = yes\n\
|
|
guest only = yes\n\
|
|
browseable = yes\n\
|
|
available = yes\n",
|
|
path.display(),
|
|
)
|
|
.expect("writing to a String is infallible");
|
|
}
|
|
let tmp = self.conf_path.with_extension("conf.tmp");
|
|
std::fs::write(&tmp, conf)?;
|
|
std::fs::rename(tmp, &self.conf_path)?;
|
|
Ok(shares)
|
|
}
|
|
|
|
/// Start smbd. No-op if already running.
|
|
pub fn start(&self) -> SmbState {
|
|
let mut g = self.child.lock();
|
|
if g.as_ref().is_some_and(|c| c.id() > 0) {
|
|
return self.state.lock().clone();
|
|
}
|
|
if !smbd_present() {
|
|
let s = SmbState::SmbdMissing;
|
|
*self.state.lock() = s.clone();
|
|
return s;
|
|
}
|
|
let shares = match self.write_conf() {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
let s = SmbState::Failed {
|
|
reason: format!("write smb.conf: {e}"),
|
|
};
|
|
*self.state.lock() = s.clone();
|
|
return s;
|
|
}
|
|
};
|
|
let child = Command::new("smbd")
|
|
.args([
|
|
"--foreground",
|
|
"--no-process-group",
|
|
"--configfile",
|
|
self.conf_path.to_str().unwrap_or(""),
|
|
"--log-stdout",
|
|
])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn();
|
|
match child {
|
|
Ok(c) => {
|
|
let pid = c.id();
|
|
*g = Some(c);
|
|
let s = SmbState::Running { pid, shares };
|
|
*self.state.lock() = s.clone();
|
|
tracing::info!(target: "openpxe::smb", pid, shares=?self.state.lock(), "smbd started");
|
|
s
|
|
}
|
|
Err(e) => {
|
|
let s = SmbState::Failed {
|
|
reason: format!("spawn smbd: {e}"),
|
|
};
|
|
*self.state.lock() = s.clone();
|
|
s
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rewrite smb.conf and SIGHUP smbd so it picks up new/removed shares.
|
|
/// No-op if smbd isn't running.
|
|
#[allow(unsafe_code)]
|
|
pub fn reconcile(&self) -> SmbState {
|
|
let mut g = self.child.lock();
|
|
if g.is_none() {
|
|
return self.state.lock().clone();
|
|
}
|
|
let shares = match self.write_conf() {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
let s = SmbState::Failed {
|
|
reason: format!("write smb.conf: {e}"),
|
|
};
|
|
*self.state.lock() = s.clone();
|
|
return s;
|
|
}
|
|
};
|
|
if let Some(c) = g.as_mut() {
|
|
// u32 -> i32 for libc::kill. We never spawn enough children
|
|
// for the pid to overflow i32; cast_signed makes the intent
|
|
// explicit and silences the lint.
|
|
let pid = c.id().cast_signed();
|
|
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
|
|
// from `Child::id` above, the child is alive because we hold the
|
|
// Mutex guard `g`) and a well-defined signal constant. Return
|
|
// value ignored because there's no meaningful recovery if SIGHUP
|
|
// fails — the next reconcile will retry.
|
|
// Rationale for not using a safe wrapper: the only crate that
|
|
// covers this is `nix`, which pulls ~40 transitive deps for a
|
|
// single signal send. One documented unsafe call is the better
|
|
// tradeoff for a container-first project.
|
|
unsafe {
|
|
libc::kill(pid, libc::SIGHUP);
|
|
}
|
|
let s = SmbState::Running {
|
|
pid: pid as u32,
|
|
shares,
|
|
};
|
|
*self.state.lock() = s.clone();
|
|
s
|
|
} else {
|
|
self.state.lock().clone()
|
|
}
|
|
}
|
|
|
|
/// Stop smbd. Safe to call repeatedly.
|
|
pub fn stop(&self) {
|
|
let mut g = self.child.lock();
|
|
if let Some(mut c) = g.take() {
|
|
let _ = c.kill();
|
|
let _ = c.wait();
|
|
}
|
|
*self.state.lock() = SmbState::Disabled;
|
|
}
|
|
}
|
|
|
|
fn smbd_present() -> bool {
|
|
let Ok(paths) = std::env::var("PATH") else {
|
|
return false;
|
|
};
|
|
for dir in std::env::split_paths(&paths) {
|
|
if dir.join("smbd").is_file() {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
const SMB_CONF_GLOBAL: &str = r"[global]
|
|
workgroup = OPENPXE
|
|
server min protocol = SMB2
|
|
smb ports = 445
|
|
log level = 1
|
|
max log size = 1024
|
|
disable netbios = yes
|
|
server role = standalone
|
|
map to guest = Bad User
|
|
guest account = nobody
|
|
# Anchor to container-friendly paths; tdb + log files under the data dir
|
|
# so a read-only rootfs in OpenShift doesn't block Samba.
|
|
lock directory = /tmp
|
|
state directory = /tmp
|
|
cache directory = /tmp
|
|
pid directory = /tmp
|
|
# WinPE reconnect hardening. Windows Setup can reboot mid-install and
|
|
# reconnect from the same IP; stale sessions/oplocks otherwise cause
|
|
# intermittent `net use` failures on the second stage.
|
|
reset on zero vc = yes
|
|
oplocks = no
|
|
kernel oplocks = no
|
|
level2 oplocks = no
|
|
strict locking = no
|
|
deadtime = 1
|
|
";
|
|
|
|
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
|
|
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
|
|
/// falls back to `bsdtar -xf` which also handles UDF on many distros.
|
|
/// Returns the share name (i.e. the slug) on success.
|
|
///
|
|
/// Idempotent: if the target dir already contains `sources/boot.wim`, we
|
|
/// skip extraction. Callers who want a forced re-extract should remove the
|
|
/// dir first.
|
|
pub fn extract_windows_iso(
|
|
iso_path: &Path,
|
|
smb_dir: &Path,
|
|
slug: &str,
|
|
) -> std::io::Result<PathBuf> {
|
|
let target = smb_dir.join(slug);
|
|
if target.join("sources").join("boot.wim").is_file() {
|
|
tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
|
|
return Ok(target);
|
|
}
|
|
std::fs::create_dir_all(&target)?;
|
|
|
|
// Try 7z first.
|
|
if which("7z").is_some() {
|
|
let out = Command::new("7z")
|
|
.args(["x", "-y", "-bd", "-bb0"])
|
|
.arg(format!("-o{}", target.display()))
|
|
.arg(iso_path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.output()?;
|
|
if out.status.success() {
|
|
return Ok(target);
|
|
}
|
|
tracing::warn!(
|
|
target: "openpxe::smb",
|
|
stderr=%String::from_utf8_lossy(&out.stderr),
|
|
"7z extract failed, trying bsdtar"
|
|
);
|
|
}
|
|
// bsdtar fallback.
|
|
if which("bsdtar").is_some() {
|
|
let out = Command::new("bsdtar")
|
|
.args(["-xf"])
|
|
.arg(iso_path)
|
|
.args(["-C"])
|
|
.arg(&target)
|
|
.output()?;
|
|
if out.status.success() {
|
|
return Ok(target);
|
|
}
|
|
return Err(std::io::Error::other(format!(
|
|
"bsdtar failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
)));
|
|
}
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"neither 7z nor bsdtar available for ISO extraction",
|
|
))
|
|
}
|
|
|
|
fn which(cmd: &str) -> Option<PathBuf> {
|
|
let paths = std::env::var_os("PATH")?;
|
|
for dir in std::env::split_paths(&paths) {
|
|
let p = dir.join(cmd);
|
|
if p.is_file() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn disabled_by_default() {
|
|
let dir = tempdir().unwrap();
|
|
let m = SmbManager::new(dir.path().into());
|
|
assert_eq!(m.snapshot(), SmbState::Disabled);
|
|
}
|
|
|
|
#[test]
|
|
fn start_without_smbd_reports_missing() {
|
|
// Drop smbd from PATH for this test.
|
|
let saved = std::env::var_os("PATH");
|
|
std::env::set_var("PATH", "/usr/nowhere-openpxe-test");
|
|
let dir = tempdir().unwrap();
|
|
let m = SmbManager::new(dir.path().into());
|
|
let st = m.start();
|
|
// Restore PATH before asserting so any subsequent failure is legible.
|
|
if let Some(p) = saved {
|
|
std::env::set_var("PATH", p);
|
|
}
|
|
assert_eq!(st, SmbState::SmbdMissing);
|
|
}
|
|
|
|
#[test]
|
|
fn discover_shares_lists_iso_subdirs() {
|
|
let dir = tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("win10-pro")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join("win11")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join(".hidden")).unwrap();
|
|
let m = SmbManager::new(dir.path().into());
|
|
assert_eq!(m.discover_shares(), vec!["win10-pro", "win11"]);
|
|
}
|
|
|
|
#[test]
|
|
fn write_conf_emits_share_blocks() {
|
|
let dir = tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("win10")).unwrap();
|
|
let m = SmbManager::new(dir.path().into());
|
|
let shares = m.write_conf().unwrap();
|
|
assert_eq!(shares, vec!["win10"]);
|
|
let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
|
|
assert!(conf.contains("[global]"));
|
|
assert!(conf.contains("[win10]"));
|
|
assert!(conf.contains("guest ok = yes"));
|
|
assert!(conf.contains("read only = yes"));
|
|
assert!(conf.contains("server min protocol = SMB2"));
|
|
assert!(conf.contains("workgroup = OPENPXE"));
|
|
}
|
|
|
|
#[test]
|
|
fn write_conf_includes_winpe_reconnect_tuning() {
|
|
let dir = tempdir().unwrap();
|
|
let m = SmbManager::new(dir.path().into());
|
|
m.write_conf().unwrap();
|
|
let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
|
|
for expected in [
|
|
"reset on zero vc = yes",
|
|
"oplocks = no",
|
|
"kernel oplocks = no",
|
|
"level2 oplocks = no",
|
|
"strict locking = no",
|
|
"deadtime = 1",
|
|
] {
|
|
assert!(
|
|
conf.contains(expected),
|
|
"missing Windows reconnect Samba option {expected} in:\n{conf}"
|
|
);
|
|
}
|
|
}
|
|
}
|