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
+327
View File
@@ -0,0 +1,327 @@
//! iPXE script generator.
//!
//! ## Menu hierarchy (per Phase 2 spec)
//!
//! ```text
//! Top level:
//! Default
//! > Boot from Local HDD
//! Installers
//! > Linux Installers -> submenu of Linux ISOs
//! > Windows Installers -> submenu of Windows ISOs (gated by Settings::windows_enabled)
//! Tools
//! > Utilities -> memtest, etc. (embedded assets only)
//! > PXEForge Shell -> drop to iPXE shell with branded prompt
//! > Network Card Info -> ifstat / config / route dump
//! Gated Deployment -> join the gate queue
//! ```
//!
//! ## iPXE is entirely backend — users do not see or write iPXE
//!
//! All user-facing knobs live in `Settings`. Script generation translates
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
//! There is intentionally no UI path to upload a custom `.ipxe` script.
use pxeforge_core::{Settings, TimeoutAction};
use pxeforge_iso_store::{BootEntry, BootKind, IsoMeta};
use pxeforge_iso_store::introspect::DistroFamily;
use std::fmt::Write as _;
/// Top-level PXEForge boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares.
#[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action {
TimeoutAction::LocalHdd => "local",
TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress.
TimeoutAction::Stay => "local",
};
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# PXEForge top-level menu - auto-generated, do not edit");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - network boot menu");
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------");
let _ = writeln!(s, "item local Boot from Local HDD");
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >");
} else {
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
}
if settings.windows_enabled && has_family(isos, is_windows_family) {
let _ = writeln!(s, "item windows Windows Installers >");
} else if settings.windows_enabled {
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
} else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
}
let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------");
let _ = writeln!(s, "item tools Tools >");
let _ = writeln!(s, "item --gap -- ---------------------- Gated Deployment ---------------------");
let _ = writeln!(s, "item gate Gated Deployment (join queue)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE");
if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
} else {
let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu");
}
// iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that.
let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} gate && chain {base}/boot/_gate.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
let _ = writeln!(s, "goto menu");
s
}
/// Per-family submenu (Linux or Windows). Each item shows the ISO size
/// in MiB, iVentoy-style (`[ 4376 MB] ubuntu-22.04-desktop-amd64`).
#[must_use]
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
let base = base_url.trim_end_matches('/');
let title = if is_windows { "Windows Installers" } else { "Linux Installers" };
let label = if is_windows { "windows" } else { "linux" };
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - {title}");
let filter: fn(DistroFamily) -> bool =
if is_windows { is_windows_family } else { is_linux_family };
let mut count = 0;
for iso in isos {
if !filter(iso.introspection.family) { continue; }
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let _ = writeln!(
s, "item {}{} [{:>6}] {}",
key,
entry.id,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count == 0 {
let _ = writeln!(s, "item --gap -- (no {label} images uploaded yet)");
}
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
s
}
/// Format a byte count as `NNNN MB` (iVentoy-style — MB not MiB, to match
/// operator expectations from the original tool).
fn fmt_size_mib(bytes: u64) -> String {
let mib = bytes / (1024 * 1024);
format!("{} MB", mib)
}
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
/// iPXE's menu needs the --key prefix as a separate token before the id.
fn hotkey_for_index(i: usize) -> String {
if i < 9 {
format!("--key {} ", i + 1)
} else {
String::new()
}
}
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
#[must_use]
pub fn render_tools_menu(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Tools");
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell PXEForge Shell");
let _ = writeln!(s, "item --key n nic Network Card Info");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key r reboot Reboot Computer");
let _ = writeln!(s, "item --key e firmware Exit and continue BIOS boot");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu");
let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
let _ = writeln!(s, "goto menu");
s
}
/// "Boot from Local HDD". On BIOS, we sanboot the first local drive; on
/// UEFI we `exit` so the firmware moves to the next boot entry (normally
/// the internal disk).
#[must_use]
pub fn render_local_hdd(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || ");
let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry");
let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
/// Utilities submenu. For Phase 2 we bundle memtest86+ as an optional
/// asset (if absent, the item is listed but errors gracefully). No third-
/// party tools are fetched at runtime.
#[must_use]
pub fn render_util(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Utilities");
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || ");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || ");
let _ = writeln!(s, "goto menu");
s
}
/// "PXEForge Shell" — iPXE shell, branded.
#[must_use]
pub fn render_shell(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo PXEForge Shell");
let _ = writeln!(s, "echo 'exit' returns to the main menu");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "shell");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
/// "Network Card Info" — print ifstat + route + config.
#[must_use]
pub fn render_nic_info(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo Network Card Info");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "ifstat");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "route");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo 'Press any key to return to menu'");
let _ = writeln!(s, "prompt --timeout 30000");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
/// Gated Deployment entry point. Joins the queue, then enters a long-poll
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
/// real script comes back).
#[must_use]
pub fn render_gate_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Gated Deployment - join the queue and wait for operator");
let _ = writeln!(s, "echo Joining gate queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read
// the gate id out of the Location-style header by asking the server
// to put it in the response body as a single token.
let _ = writeln!(s, "chain --replace {base}/api/gate/join?mac=${{mac}}");
s
}
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
#[must_use]
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
let base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
match &entry.kind {
BootKind::LinuxKernel { kernel_url, initrd_urls, args } => {
let mut cmdline = args.cmdline.replace("${base-url}", base);
if !settings.extra_kernel_args.trim().is_empty() {
cmdline.push(' ');
cmdline.push_str(settings.extra_kernel_args.trim());
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}");
}
let _ = writeln!(s, "boot || goto failed");
}
BootKind::Wimboot { wimboot_url, files } => {
let _ = writeln!(s, "kernel {base}/{wimboot_url}");
for (tag, url) in files {
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}");
}
let _ = writeln!(s, "boot || goto failed");
}
BootKind::SanBootIso { iso_url } => {
let _ = writeln!(s, "sanboot --no-describe {base}/{iso_url} || goto failed");
}
}
let _ = writeln!(s, ":failed");
let _ = writeln!(s, "echo Boot failed - returning to menu in 5s");
let _ = writeln!(s, "sleep 5");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
fn is_linux_family(f: DistroFamily) -> bool {
matches!(
f,
DistroFamily::DebianUbuntu
| DistroFamily::RhelFedora
| DistroFamily::OpenSuse
| DistroFamily::Arch
| DistroFamily::Alpine
| DistroFamily::Unknown
)
}
fn is_windows_family(f: DistroFamily) -> bool {
matches!(f, DistroFamily::WindowsPe)
}
fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
isos.iter().any(|i| pred(i.introspection.family))
}
fn escape_label(s: &str) -> String {
s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect()
}