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
+187
View File
@@ -0,0 +1,187 @@
//! Runtime-mutable settings, distinct from the static `Config`.
//!
//! Rationale: `Config` holds bind addresses, paths, and other things that
//! can only reasonably change at process start. `Settings` holds everything
//! the web UI can flip at runtime: timeouts, default boot action, Windows
//! feature toggles, etc. Persisted to `<work_dir>/settings.json` so they
//! survive pod restarts without requiring a ConfigMap edit.
//!
//! **Crucial property:** every UI-facing "feature flag" in here maps to a
//! specific iPXE script-generation behavior elsewhere in the codebase. The
//! user never writes iPXE; they toggle a setting and we translate.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// Seconds to wait at the top-level boot menu before falling through
/// to `timeout_action`. Default 600s per the Phase 2 spec.
pub boot_menu_timeout_secs: u32,
/// What happens if the boot-menu timer hits zero with no selection.
pub timeout_action: TimeoutAction,
/// Master enable for Windows ISO support. When off, Windows ISOs are
/// listed in the UI as "Windows — disabled" and not exposed in the
/// PXE menu. Off by default: Windows support requires bundling Samba
/// and wimlib in the runtime image (see deploy/docker/Dockerfile).
pub windows_enabled: bool,
/// SMB share hostname/IP the patched WinPE's startnet.cmd will
/// `net use` against. Empty string = auto-fill with the public IP at
/// script-generation time.
pub smb_host_override: String,
/// Global kernel-args append (added to every Linux entry's cmdline).
/// Useful for things like `console=ttyS0,115200` on serial-only boxes.
/// Do NOT accept raw iPXE script fragments here; this is literal kernel
/// args only.
pub extra_kernel_args: String,
/// If true, the "Default → Boot from Local HDD" menu item is the
/// pre-selected entry (and is what the timeout falls to if
/// `timeout_action = LocalHdd`).
pub default_local_hdd: bool,
/// When a client hits the Gated Deployment item, how long (seconds) to
/// hold it at the gate before giving up and falling back to the menu.
/// 0 = forever.
pub gate_wait_max_secs: u32,
/// Optional DNS server advertised on the Network tab. Purely
/// informational today — PXEForge does not run a DNS server, but
/// operators expect to be able to record what the upstream DNS is.
/// Empty string = unset (UI shows placeholder).
pub dns_server: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TimeoutAction {
/// Sit at the menu forever (no fallthrough).
Stay,
/// Chain the "Boot from Local HDD" entry.
LocalHdd,
/// Put the client into the gate queue, waiting for operator assignment.
#[default]
GatedDeployment,
}
impl Default for Settings {
fn default() -> Self {
Self {
boot_menu_timeout_secs: 600,
timeout_action: TimeoutAction::GatedDeployment,
windows_enabled: false,
smb_host_override: String::new(),
extra_kernel_args: String::new(),
default_local_hdd: true,
gate_wait_max_secs: 0,
dns_server: String::new(),
}
}
}
#[derive(Debug)]
pub struct SettingsStore {
path: PathBuf,
inner: RwLock<Settings>,
}
impl SettingsStore {
/// Load from `work_dir/settings.json`, or create with defaults if the
/// file is missing/corrupt. Never fails — a bad settings file on disk
/// is not a reason to refuse to start.
pub fn load_or_default(work_dir: &Path) -> Arc<Self> {
let path = work_dir.join("settings.json");
let initial = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Settings>(&text) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
target: "pxeforge::settings",
"settings.json present but unreadable ({e}); falling back to defaults"
);
Settings::default()
}
},
Err(_) => Settings::default(),
};
Arc::new(Self { path, inner: RwLock::new(initial) })
}
#[must_use]
pub fn snapshot(&self) -> Settings {
self.inner.read().clone()
}
/// Atomically replace settings and persist. The caller supplies the full
/// `Settings` struct — partial updates happen at the HTTP layer via
/// merge-then-store. Persistence errors are logged but not returned;
/// settings live in memory authoritatively and only SHOULD be on disk.
pub fn replace(&self, new: Settings) {
{
let mut g = self.inner.write();
*g = new;
}
let snap = self.snapshot();
if let Err(e) = self.persist(&snap) {
tracing::warn!(target: "pxeforge::settings", "failed to persist settings: {e}");
}
}
fn persist(&self, s: &Settings) -> std::io::Result<()> {
let tmp = self.path.with_extension("json.tmp");
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_vec_pretty(s)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&tmp, body)?;
std::fs::rename(tmp, &self.path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn defaults_roundtrip() {
let dir = tempdir().unwrap();
let store = SettingsStore::load_or_default(dir.path());
let s = store.snapshot();
assert_eq!(s.boot_menu_timeout_secs, 600);
assert_eq!(s.timeout_action, TimeoutAction::GatedDeployment);
assert!(!s.windows_enabled);
}
#[test]
fn replace_persists() {
let dir = tempdir().unwrap();
let store = SettingsStore::load_or_default(dir.path());
let mut new = store.snapshot();
new.boot_menu_timeout_secs = 30;
new.windows_enabled = true;
store.replace(new);
// Reload from disk.
drop(store);
let reloaded = SettingsStore::load_or_default(dir.path());
let s = reloaded.snapshot();
assert_eq!(s.boot_menu_timeout_secs, 30);
assert!(s.windows_enabled);
}
#[test]
fn corrupt_file_falls_back_to_default() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("settings.json"), b"{ not json }").unwrap();
let store = SettingsStore::load_or_default(dir.path());
assert_eq!(store.snapshot().boot_menu_timeout_secs, 600);
}
}