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:
@@ -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