Name update
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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('.'));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user