Files
OpenPXE/crates/iso-store/src/smb.rs
T
Miles Ward e3452fe976 v0.3.0 — rebrand: PXEForge → OpenPXE, Gated → Queued Deployment
Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.

## Naming changes

**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
  `bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
  has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
  `/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
  brand string.

**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
  `QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
  so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
  `GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
  flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
  top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.

## Brand assets

The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
  `rainbow-horizon` gradient from openpxe.com (sliding hue rotation
  via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
  louder version of the same disc — used for page-load transitions
  and the imaging-progress widget. Adds a subtle scale pulse and a
  white inner-glow so it has dimensionality on either theme.

## CSS rename

- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"

The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."

## Validation

- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
  - `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
    `/boot/_queue.ipxe`
  - `/api/queue` returns `{count, entries}`
  - `/metrics` emits `openpxe_queue_count` (renamed)
  - `/assets/logo.svg` and `/assets/loader.svg` serve the new
    rainbow brand SVGs
  - `/api/status` reports version `0.3.0`

## Migration notes for operators on v0.2.0

- Container image path changed: pull
  `gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
  Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
  template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
  `gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
  switch to `/api/queue/*`. The JSON envelope key is `entries`
  instead of `gates`.
2026-05-06 14:13:38 -04:00

352 lines
13 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 serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use parking_lot::Mutex;
#[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 = PXEFORGE
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
";
/// 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"));
}
}