Name update

This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit 3517c67831
66 changed files with 9016 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "pxeforge-iso-store"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "ISO upload, storage, introspection, and boot-entry generation for PXEForge"
[lints]
workspace = true
[dependencies]
pxeforge-core.workspace = true
tokio = { workspace = true }
tokio-util = { workspace = true }
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
thiserror.workspace = true
anyhow.workspace = true
sha2.workspace = true
hex.workspace = true
uuid.workspace = true
time.workspace = true
parking_lot.workspace = true
bytes.workspace = true
tempfile = "3.12"
libc = "0.2"
[dev-dependencies]
tempfile = "3.12"
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEntry {
/// Stable id (also the URL slug in generated iPXE scripts).
pub id: String,
/// Display label shown in the iPXE boot menu.
pub title: String,
pub kind: BootKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BootKind {
/// Linux kernel + initrd chainload. Kernel args carry the distro-specific
/// pointer back to the ISO contents served over HTTP.
LinuxKernel {
kernel_url: String,
initrd_urls: Vec<String>,
args: KernelArgs,
},
/// Windows WinPE boot via wimboot shim. `files` maps in-memory tags to
/// HTTP URLs the client fetches. See https://ipxe.org/wimboot .
Wimboot {
wimboot_url: String,
files: Vec<(String, String)>,
},
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
/// default.
SanBootIso { iso_url: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KernelArgs {
/// Raw kernel command line, already distro-adapted. Do not quote — iPXE
/// takes a single space-separated command line.
pub cmdline: String,
}
+155
View File
@@ -0,0 +1,155 @@
//! ISO introspection — identify the distro family and locate kernel/initrd.
//!
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
//! most of the time, without shelling out.
//!
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from.
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DistroFamily {
DebianUbuntu,
RhelFedora,
OpenSuse,
Arch,
Alpine,
WindowsPe,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntrospectionReport {
pub family: DistroFamily,
pub volume_label: Option<String>,
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
pub kernel_path: Option<String>,
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
pub initrd_paths: Vec<String>,
/// True if `sources/boot.wim` present — Windows install media.
pub has_boot_wim: bool,
}
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
/// and return an `Unknown` family so the uploader still sees a record.
pub fn introspect(path: &Path) -> IntrospectionReport {
let mut report = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
let Ok(mut f) = std::fs::File::open(path) else {
tracing::warn!(target: "pxeforge::iso", "cannot open ISO for introspection: {}", path.display());
return report;
};
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
let mut pvd = [0u8; 2048];
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
let label_raw = &pvd[40..72];
let label = String::from_utf8_lossy(label_raw).trim().to_string();
if !label.is_empty() {
report.volume_label = Some(label.clone());
report.family = family_from_label(&label);
}
}
}
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
// This is enough to identify `sources/boot.wim` (Windows) and common
// kernel/initrd paths for the major Linux distros.
let _ = f.seek(SeekFrom::Start(0));
let scan_bytes = 64 * 1024 * 1024;
let mut buf = vec![0u8; 1024 * 1024];
let mut read_total = 0usize;
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0);
if n == 0 { break; }
haystack.extend_from_slice(&buf[..n]);
read_total += n;
}
if contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"SOURCES/BOOT.WIM")
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
{
report.has_boot_wim = true;
if report.family == DistroFamily::Unknown {
report.family = DistroFamily::WindowsPe;
}
}
// Best-effort kernel/initrd path guess from family. These paths are what
// distro ISOs conventionally ship at — we don't verify extraction here;
// that happens in the store after introspection.
let (k, i) = guess_kernel_initrd(report.family);
report.kernel_path = k.map(str::to_string);
report.initrd_paths = i.iter().map(|s| s.to_string()).collect();
report
}
fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase();
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
DistroFamily::DebianUbuntu
} else if l.contains("rhel") || l.contains("centos") || l.contains("fedora")
|| l.contains("rocky") || l.contains("alma")
{
DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") {
DistroFamily::OpenSuse
} else if l.contains("arch") {
DistroFamily::Arch
} else if l.contains("alpine") {
DistroFamily::Alpine
} else if l.contains("windows") || l.contains("winpe") {
DistroFamily::WindowsPe
} else {
DistroFamily::Unknown
}
}
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
match family {
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
DistroFamily::RhelFedora => (Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"]),
DistroFamily::OpenSuse => (Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"]),
DistroFamily::Arch => (Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"]),
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
}
}
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() { return false; }
haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_matching() {
assert_eq!(family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu);
assert_eq!(family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora);
assert_eq!(family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse);
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
}
}
+31
View File
@@ -0,0 +1,31 @@
//! ISO store: uploads, listing, introspection, boot-entry generation.
//!
//! An ISO goes through three states:
//! 1. **Uploading** — bytes streaming to a `.partial` file under `iso_dir`.
//! 2. **Introspecting** — once upload completes, we probe the ISO to detect
//! the distro family and extract kernel/initrd if applicable. Metadata
//! persisted as a sibling `.meta.json` file.
//! 3. **Ready** — listed in the menu, servable over HTTP.
//!
//! Introspection is best-effort. If we can't identify the distro, the ISO is
//! still bootable via a generic `memdisk`/`sanboot` fallback path (not
//! recommended but better than nothing).
//!
//! The `smb` submodule needs exactly one `unsafe` call to `libc::kill` for
//! SIGHUP-based Samba reload — the call is documented inline and every
//! other file in this crate is `#![forbid(unsafe_code)]`-equivalent via
//! the workspace lints.
pub mod entry;
pub mod introspect;
pub mod nfs;
pub mod smb;
pub mod store;
pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use store::{generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle};
pub use windows::{WimPatcher, WinPatchState};
+564
View File
@@ -0,0 +1,564 @@
//! 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 `/bin/mount -t nfs -o vers=...,ro,nolock 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` on the spec and persist anyway
//! so the UI can show a row in red 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.
//!
//! ## 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 parking_lot::Mutex;
use pxeforge_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::process::Command;
/// 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",
}
}
}
/// 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 PXEForge itself never writes.
pub read_only: bool,
/// 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>,
#[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,
}
fn default_version() -> NfsVersion {
NfsVersion::V41
}
fn default_ro() -> bool {
true
}
#[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;
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
target: "pxeforge::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 an error if the spec was invalid.
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
let server = req.server.trim().to_string();
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(Error::Invalid("server is required".into()));
}
if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into()));
}
let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?;
let mount = NfsMount {
id: id.clone(),
server,
export,
version: req.version,
read_only: req.read_only,
local_path,
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked();
self.try_mount(&id).await?;
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, 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(());
}
let opts = mount_options(&m);
let target = format!("{}:{}", m.server, m.export);
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(&opts)
.arg(&target)
.arg(&m.local_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "pxeforge::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
);
self.update_status(id, true, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
Ok(())
}
Ok(out) => {
let err = format!(
"mount exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "pxeforge::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "pxeforge::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), 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, 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, 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()), 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()), 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>, ts: OffsetDateTime) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted;
m.last_error = err;
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: "pxeforge::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: "pxeforge::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "pxeforge::nfs", "rename NFS state: {e}");
}
}
}
fn mount_options(m: &NfsMount) -> String {
let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only {
opts.push("ro".into());
} else {
opts.push("rw".into());
}
// `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway.
if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into());
}
// 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(",")
}
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::*;
#[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() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V3,
read_only: true,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=3"));
assert!(opts.contains("ro"));
assert!(opts.contains("nolock"));
assert!(opts.contains("soft"));
}
#[test]
fn mount_options_v41_no_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V41,
read_only: false,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=4.1"));
assert!(opts.contains("rw"));
assert!(!opts.contains("nolock"));
}
#[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('.'));
}
}
+343
View File
@@ -0,0 +1,343 @@
//! 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 pxeforge (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>> {
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);
conf.push_str(&format!(
"\n[{name}]\n\
path = {}\n\
comment = PXEForge 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(),
));
}
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().map_or(false, |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: "pxeforge::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() {
let pid = c.id() as i32;
// 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: "pxeforge::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: "pxeforge::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::new(
std::io::ErrorKind::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-pxeforge-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"));
}
}
+430
View File
@@ -0,0 +1,430 @@
//! On-disk ISO store with sidecar metadata files.
use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes;
use parking_lot::RwLock;
use pxeforge_core::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
Local,
Nfs {
mount_id: String,
/// Path relative to the mount point — typically just the filename.
relative_path: String,
},
}
impl Default for IsoSource {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoMeta {
/// Stable slug used in URLs (derived from the uploaded filename).
pub id: String,
pub filename: String,
pub size_bytes: u64,
pub sha256_hex: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub uploaded_at: OffsetDateTime,
pub introspection: IntrospectionReport,
/// Boot entries this ISO currently exposes in the PXE menu. Usually one,
/// occasionally two (BIOS + UEFI variant for some RHEL ISOs).
pub boot_entries: Vec<BootEntry>,
/// Source of the bytes — local upload (default) or NFS mount.
/// Old `meta.json` files without this field deserialize as `Local`.
#[serde(default)]
pub source: IsoSource,
}
pub struct UploadHandle {
pub id: String,
pub partial_path: PathBuf,
final_path: PathBuf,
filename: String,
hasher: Sha256,
bytes_written: u64,
file: tokio::fs::File,
}
impl UploadHandle {
pub async fn write_chunk(&mut self, chunk: &Bytes) -> Result<()> {
self.file.write_all(chunk).await?;
self.hasher.update(chunk);
self.bytes_written += chunk.len() as u64;
Ok(())
}
/// Flush, close, and atomically rename to the final path. Returns the
/// final `IsoMeta` including introspection results.
pub async fn finish(mut self, store: &IsoStore) -> Result<IsoMeta> {
self.file.flush().await?;
self.file.sync_all().await?;
drop(self.file);
tokio::fs::rename(&self.partial_path, &self.final_path).await?;
let hash = hex::encode(self.hasher.finalize());
let introspection = {
let p = self.final_path.clone();
tokio::task::spawn_blocking(move || introspect(&p))
.await
.map_err(|e| Error::Other(e.into()))?
};
let boot_entries = generate_boot_entries(&self.id, &self.filename, &introspection);
let meta = IsoMeta {
id: self.id.clone(),
filename: self.filename,
size_bytes: self.bytes_written,
sha256_hex: Some(hash),
uploaded_at: OffsetDateTime::now_utc(),
introspection,
boot_entries,
source: IsoSource::Local,
};
store.persist_meta(&meta).await?;
store.insert(meta.clone());
Ok(meta)
}
pub async fn abort(self) -> Result<()> {
drop(self.file);
let _ = tokio::fs::remove_file(&self.partial_path).await;
Ok(())
}
}
#[derive(Debug, Default)]
struct Inner {
isos: HashMap<String, IsoMeta>,
}
#[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>>,
}
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(())
}
/// Scan the ISO directory on startup and load any sidecar `.meta.json`
/// files. ISOs without metadata are introspected lazily — we don't block
/// startup on potentially many GB of scanning.
pub async fn load_from_disk(&self) -> Result<()> {
self.ensure_dirs().await?;
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
if !p.file_name().and_then(|s| s.to_str()).map_or(false, |n| n.ends_with(".meta.json")) {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
self.insert(meta);
}
}
}
Ok(())
}
fn insert(&self, meta: IsoMeta) {
self.inner.write().isos.insert(meta.id.clone(), meta);
}
async fn persist_meta(&self, meta: &IsoMeta) -> Result<()> {
let path = self.meta_path(&meta.id);
let text = serde_json::to_string_pretty(meta).map_err(|e| Error::Other(e.into()))?;
tokio::fs::write(path, text).await?;
Ok(())
}
fn meta_path(&self, id: &str) -> PathBuf {
self.iso_dir.join(format!("{id}.meta.json"))
}
fn iso_path(&self, id: &str) -> PathBuf {
self.iso_dir.join(format!("{id}.iso"))
}
pub async fn begin_upload(&self, filename: &str) -> Result<UploadHandle> {
self.ensure_dirs().await?;
let id = slugify(filename);
let final_path = self.iso_path(&id);
if final_path.exists() {
return Err(Error::Invalid(format!("iso '{id}' already exists")));
}
let partial_path = self.iso_dir.join(format!("{id}.partial"));
let file = tokio::fs::File::create(&partial_path).await?;
Ok(UploadHandle {
id,
partial_path,
final_path,
filename: filename.to_string(),
hasher: Sha256::new(),
bytes_written: 0,
file,
})
}
/// Readiness probe — is the backing directory reachable? Distinct from
/// "is there content in it", to avoid an empty store failing health.
#[must_use]
pub fn list_ok(&self) -> bool {
std::fs::read_dir(self.iso_dir.as_path()).is_ok()
}
#[must_use]
pub fn list(&self) -> Vec<IsoMeta> {
let g = self.inner.read();
let mut v: Vec<_> = g.isos.values().cloned().collect();
v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<IsoMeta> {
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).
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)
}
};
if path.exists() {
Some(path)
} else {
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.
pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id);
let is_local = matches!(meta.as_ref().map(|m| &m.source), Some(IsoSource::Local) | None);
if is_local {
let iso = self.iso_path(id);
let meta_path = self.meta_path(id);
let _ = tokio::fs::remove_file(&iso).await;
let _ = tokio::fs::remove_file(&meta_path).await;
}
self.inner.write().isos.remove(id);
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.
pub fn register_external(
&self,
id: String,
filename: String,
size_bytes: u64,
introspection: IntrospectionReport,
boot_entries: Vec<BootEntry>,
source: IsoSource,
) {
let meta = IsoMeta {
id: id.clone(),
filename,
size_bytes,
sha256_hex: None,
uploaded_at: OffsetDateTime::now_utc(),
introspection,
boot_entries,
source,
};
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) {
let mut g = self.inner.write();
g.isos.retain(|_, m| {
!matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id)
});
}
}
fn slugify(filename: &str) -> String {
let stem = Path::new(filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("iso");
slugify_str(stem)
}
/// Slugify an arbitrary string to lowercase ASCII alphanumerics, hyphens,
/// and underscores. Public so the NFS manager can mint ids that follow the
/// same rules as upload-time ISO ids.
#[must_use]
pub fn slugify_str(input: &str) -> String {
input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
/// Public wrapper around [`generate_boot_entries`] so the NFS manager can
/// build entries for shares it just scanned, using the same logic as the
/// upload pipeline. Re-exported via the crate root.
#[must_use]
pub fn generate_boot_entries_for(
id: &str,
filename: &str,
r: &IntrospectionReport,
) -> Vec<BootEntry> {
generate_boot_entries(id, filename, r)
}
/// Build `BootEntry`s from the introspection report. URLs are relative —
/// the HTTP layer rewrites them with the public base URL per request.
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
let title = r.volume_label.clone().unwrap_or_else(|| filename.to_string());
match r.family {
DistroFamily::WindowsPe if r.has_boot_wim => {
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
let base = format!("iso/{id}");
vec![BootEntry {
id: format!("{id}-winpe"),
title: format!("{title} (Windows / wimboot)"),
kind: BootKind::Wimboot {
wimboot_url: "ipxe/wimboot".to_string(),
files: vec![
("bootmgr".into(), format!("{base}/bootmgr")),
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
("bcd".into(), format!("{base}/boot/bcd")),
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
("boot.wim".into(), format!("{base}/sources/boot.wim")),
],
},
}]
}
fam if r.kernel_path.is_some() => {
let base = format!("iso/{id}");
let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
let initrd_urls = r.initrd_paths.iter().map(|p| format!("{base}{p}")).collect();
let args = KernelArgs { cmdline: linux_cmdline(fam, id) };
vec![BootEntry {
id: format!("{id}-linux"),
title,
kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args },
}]
}
_ => {
// Last-resort SAN boot. Won't work for large modern ISOs, but
// lets the ISO at least appear in the menu.
vec![BootEntry {
id: format!("{id}-sanboot"),
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso") },
}]
}
}
}
fn linux_cmdline(family: DistroFamily, id: &str) -> String {
// The HTTP layer resolves `${base-url}` at render time.
let iso_url = format!("${{base-url}}/iso/{id}.iso");
match family {
DistroFamily::DebianUbuntu => format!(
"boot=casper netboot=url url={iso_url} ip=dhcp ---"
),
DistroFamily::RhelFedora => format!(
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
),
DistroFamily::OpenSuse => format!(
"install={iso_url} netsetup=dhcp"
),
DistroFamily::Arch => format!(
"archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram"
),
DistroFamily::Alpine => format!(
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
),
_ => "ip=dhcp".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_basic() {
// Upload filenames come from multipart parts (no path components);
// file_stem drops the extension, then non-alphanumerics become `-`.
assert_eq!(slugify("Ubuntu 24.04 Desktop.iso"), "ubuntu-24-04-desktop");
assert_eq!(slugify("Rocky-9.4-x86_64-dvd.iso"), "rocky-9-4-x86_64-dvd");
assert_eq!(slugify("arch.iso"), "arch");
// If a path sneaks in, file_stem strips the directory — OK, not a hazard.
assert_eq!(slugify("/etc/passwd"), "passwd");
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Windows ISO post-processing. Patches `boot.wim` (image index 2, WinPE)
//! with two plain-text files so the client hits our SMB share and runs
//! Windows Setup from there.
//!
//! Credit: the *technique* (not the code) is adapted from Bootimus
//! (Apache-2.0, https://github.com/garybowers/bootimus). We reimplement in
//! Rust and shell out to `wimlib-imagex` at container runtime because
//! there is no maintained pure-Rust wimlib binding.
//!
//! What we inject — and why these are safe:
//!
//! * `Windows/System32/winpeshl.ini`: a plain INI that WinPE reads at
//! startup and uses to launch `startnet.cmd` instead of the default
//! interactive shell. No driver, no executable, no signed code.
//!
//! * `Windows/System32/startnet.cmd`: a batch file that runs `wpeinit`,
//! waits for a DHCP lease, then `net use Z: \\<server>\<share> /user:guest`
//! and invokes `Z:\setup.exe`. Everything the client executes is stock
//! Microsoft-signed WinPE + `setup.exe`. We add zero native code to
//! the client's boot path. The trust store is untouched.
//!
//! What we *do not* inject:
//! * No `.sys` drivers, signed or otherwise.
//! * No `.cer`, no registry hive edits, no `bcdedit` changes.
//! * No `bypass*` Windows 11 tweaks (operators who want those can use an
//! unattend.xml; they will never be injected silently by us).
use std::path::{Path, PathBuf};
use std::process::Command;
/// Public identifier of whether/how Windows patching ran for an ISO.
/// Stored on `IsoMeta` so the UI can show a clear "SMB ready" indicator.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WinPatchState {
/// Not a Windows ISO, nothing to do.
NotApplicable,
/// Windows ISO detected but Windows support is disabled in settings.
DisabledBySettings,
/// wimlib-imagex isn't on PATH — operator needs to install the runtime
/// dependency before Windows ISOs can be patched.
WimlibMissing,
/// Patching succeeded; the ISO's boot.wim was rewritten in-place.
Patched { smb_host: String, smb_share: String },
/// wimlib returned an error.
Failed { reason: String },
}
pub struct WimPatcher {
pub smb_host: String,
pub smb_share: String,
}
impl WimPatcher {
#[must_use]
pub fn new(smb_host: String, smb_share: String) -> Self {
Self { smb_host, smb_share }
}
/// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns
/// a state enum — never panics. Designed to be safely re-runnable; each
/// call rebuilds image 2 from scratch via `wimlib-imagex update`.
pub fn patch(&self, extracted_iso_dir: &Path) -> WinPatchState {
if !wimlib_present() {
return WinPatchState::WimlibMissing;
}
let boot_wim = extracted_iso_dir.join("sources").join("boot.wim");
if !boot_wim.exists() {
// Not a standard Windows install ISO layout.
return WinPatchState::NotApplicable;
}
let work = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => return WinPatchState::Failed { reason: format!("tempdir: {e}") },
};
// Stage the two files we want present at /Windows/System32/.
let staging = work.path().join("stage/Windows/System32");
if let Err(e) = std::fs::create_dir_all(&staging) {
return WinPatchState::Failed { reason: format!("staging mkdir: {e}") };
}
if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) {
return WinPatchState::Failed { reason: format!("write winpeshl.ini: {e}") };
}
let startnet = render_startnet(&self.smb_host, &self.smb_share);
if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) {
return WinPatchState::Failed { reason: format!("write startnet.cmd: {e}") };
}
// Build a wimlib update command file:
// add <stage>/Windows/System32 /Windows/System32
let update_file = work.path().join("update.cmd");
let update_cmd = format!(
"add \"{}\" \"/Windows/System32\"\n",
staging.display()
);
if let Err(e) = std::fs::write(&update_file, update_cmd) {
return WinPatchState::Failed { reason: format!("write update.cmd: {e}") };
}
// Run wimlib-imagex update against image index 2 (WinPE).
let output = Command::new("wimlib-imagex")
.arg("update")
.arg(&boot_wim)
.arg("2")
.arg("--rebuild")
.arg("--command-file")
.arg(&update_file)
.output();
match output {
Ok(o) if o.status.success() => WinPatchState::Patched {
smb_host: self.smb_host.clone(),
smb_share: self.smb_share.clone(),
},
Ok(o) => WinPatchState::Failed {
reason: format!(
"wimlib-imagex update failed (exit {:?}): {}",
o.status.code(),
String::from_utf8_lossy(&o.stderr)
),
},
Err(e) => WinPatchState::Failed { reason: format!("spawn wimlib-imagex: {e}") },
}
}
}
fn wimlib_present() -> bool {
which("wimlib-imagex").is_some()
}
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
}
/// The winpeshl.ini contents. This file tells WinPE "don't run cmd.exe
/// interactively; run startnet.cmd and exit when it returns".
const WINPESHL_INI: &str = "[LaunchApps]\r\n\
\"%SYSTEMROOT%\\system32\\startnet.cmd\"\r\n";
/// Render startnet.cmd. The script:
/// 1. Loads WinPE networking (`wpeinit`) and renews DHCP.
/// 2. Waits until the SMB server is reachable.
/// 3. Maps the install share to Z: as guest.
/// 4. Runs setup.exe from the share.
///
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
/// created on unix hosts.
fn render_startnet(host: &str, share: &str) -> String {
let mut s = String::new();
let host = host.trim();
let share = share.trim_matches('/');
s.push_str("@echo off\r\n");
s.push_str("echo PXEForge WinPE bootstrap\r\n");
s.push_str("wpeinit\r\n");
s.push_str("ipconfig /renew\r\n");
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n"));
s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n"));
s.push_str(":havenet\r\n");
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n"));
s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n"));
s.push_str(":mapped\r\n");
s.push_str("echo Starting Windows Setup\r\n");
s.push_str("Z:\\setup.exe\r\n");
s.push_str("echo Setup exited; dropping to cmd for diagnosis\r\n");
s.push_str("cmd\r\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startnet_has_crlf_and_no_testsigning() {
let s = render_startnet("10.0.0.5", "win11");
assert!(s.contains("\r\n"));
// Hard guard: must never include trust-store or driver-policy mutations.
assert!(!s.to_lowercase().contains("bcdedit"));
assert!(!s.to_lowercase().contains("testsigning"));
assert!(!s.to_lowercase().contains("certutil"));
assert!(s.contains("net use Z:"));
assert!(s.contains("setup.exe"));
}
#[test]
fn patcher_reports_wimlib_missing_gracefully() {
// We don't assume wimlib is present in CI; this checks the missing
// branch is the noisy-but-survivable one we expect.
let patcher = WimPatcher::new("10.0.0.5".into(), "win11".into());
let dir = tempfile::tempdir().unwrap();
// Construct a fake "sources/boot.wim".
std::fs::create_dir_all(dir.path().join("sources")).unwrap();
std::fs::write(dir.path().join("sources/boot.wim"), b"placeholder").unwrap();
let result = patcher.patch(dir.path());
// Depending on whether wimlib is installed on the runner, we get
// either WimlibMissing or Failed(...). Both mean "no silent
// success with trust-store mutation" — that's the invariant.
assert!(matches!(
result,
WinPatchState::WimlibMissing | WinPatchState::Failed { .. }
));
}
}