//! 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\` //! 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//` 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 }, /// 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>>, state: Arc>, } 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 `/*/` sub-dirs as shares. An extracted Windows /// ISO under `smb_dir//` becomes a share named ``. Returns /// the sorted list. pub fn discover_shares(&self) -> Vec { let Ok(rd) = std::fs::read_dir(&self.smb_dir) else { return vec![]; }; let mut out: Vec = 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> { 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//`. 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 { 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 { 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}" ); } } }