Initial commit: PXEForge Phases 1-4

Container-native PXE boot server in Rust, designed as a clean-room
alternative to iVentoy that never touches the client OS trust store.
This is the first commit of the project; it lands the full output of
Phases 1, 2, 3, and 4 in one shot.

## Phase 1 — protocol stack

- 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store,
  ipxe-assets, webui, pxeforge bin).
- DHCP proxy (RFC 4578): replies with boot info only, never leases —
  sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from
  option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64).
- TFTP server with full OACK negotiation: blksize, tsize, windowsize.
  Without it a 1 MiB iPXE binary takes 2000 packets and unusably long.
- Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE
  re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd.
- HTTP server (axum) with byte-Range ISO streaming and an in-place
  ISO9660 lookup so kernel/initrd are served from inside the ISO
  without ever extracting it to disk.
- Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail
  for >1-2 GiB modern distros). Distro-family detection drives the
  cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine).

## Phase 2 — UX + Windows

- Hierarchical PXE menu (Default / Installers / Tools / Gated
  Deployment) generated from settings — no hand-written .ipxe paths
  surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants
  for some RHEL ISOs.
- Gated Deployment "horse-race" queue: clients join, operator picks
  one ISO, every gate launches simultaneously via tokio::sync::Notify.
- Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd
  into boot.wim so vanilla WinPE net-uses an SMB share and runs
  setup.exe. All Microsoft-signed; no test certs, no testsigning,
  no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP.
- Netbox-style dark UI, fully offline (no CDN, no external fonts).

## Phase 3 — MVP hardening

- TFTP retransmit rewrite with explicit window tracking — UEFI SNP
  clients no longer hang on files that end mid-window. 4 new tests.
- DHCP broadcast-flag honored per RFC 2131 §4.1.
- Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns
  bind-mounts as root then drops to uid 10001 via gosu.
- /healthz + /readyz split from /api/status — readyz fails if no
  iPXE binaries are bundled.
- pxeforge seed --from <path> CLI: same pipeline as web upload (slug,
  sha256, introspection, boot-entry).
- All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple).
- Gate poll retains assignment until operator releases — clients that
  retry on transient network errors reuse the assignment instead of
  falling back to the menu.
- Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no
  NET_RAW.

## Phase 4 — UI restructure + remote storage

- Web UI rebuilt around six tabs inspired by the iVentoy layout:
  Dashboard / Network / Forge Gate / Storage / Terminal / About.
  Old "Monitoring/Content/Configuration" sidebar groups are gone.
- NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or
  NFSv4.1 shares as ISO sources instead of uploading every file
  into the PVC. New IsoSource enum on IsoMeta lets the store resolve
  Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed
  mounts surface in the UI rather than blocking startup.
- Dockerfile gains nfs-common + iproute2; mounting NFS in-container
  also requires CAP_SYS_ADMIN. Documented in docs/architecture.md.
- LogBus + tracing layer in core: 500-line ring buffer + broadcast
  channel feed an SSE endpoint at /api/log/stream.
- Operator terminal at /api/terminal: whitelisted commands (status,
  isos, clients, gate, nfs, smb, log) — deliberately not a shell.
  Output mirrored onto the LogBus so the live tail and the terminal
  pane share one timeline.
- Network tab: read-only nic_name / subnet_mask / gateway probed
  from `ip` at startup; only DNS server is editable. Editing IP/mask
  on a hot UI would silently break PXE for every client mid-boot.
- Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on
  un-bootable ISOs with inline reasons, dashboard "won't boot" panel.

## Tests

56 tests passing across the workspace:
- 16 core (LogBus, gate, settings, arch, client)
- 1 dhcp-proxy (raw option-93 extraction)
- 8 http-api unit (range parsing, terminal split/format)
- 13 http-api integration (gated deployment, range, settings, NFS,
  terminal, log SSE, network endpoint, ui assets, no-external-urls)
- 12 iso-store (introspect, slugify, smb, windows wim, NFS options)
- 6 tftp (RRQ parsing, plan_window edges)

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit cc309da062
67 changed files with 9032 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 { .. }
));
}
}