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
+470
View File
@@ -0,0 +1,470 @@
//! End-to-end HTTP integration test.
//!
//! Spins up the real axum router against a temp ISO store + settings store,
//! then walks an imaginary iPXE client through: dashboard status → upload
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
//! ISO. Also drives the Gated Deployment flow end-to-end: two clients join,
//! operator assigns, both polls return the chain script with retry fallback.
//!
//! This is the closest we can get to "real PXE client" without QEMU; the
//! TFTP leg is separately unit-tested in `crates/tftp`. Between the two,
//! every HTTP endpoint a real client touches is covered by a test.
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir;
use tower::ServiceExt;
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
/// introspection identifies it as Alpine.
fn fake_alpine_iso() -> Vec<u8> {
let mut buf = vec![0u8; 32 * 2048];
let off = 16 * 2048;
buf[off] = 0x01;
buf[off + 1..off + 6].copy_from_slice(b"CD001");
buf[off + 6] = 0x01;
let label = b"ALPINE-TEST".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
buf
}
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
let boundary = "----PxeForgeTestBoundary1234";
let mut body = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
).as_bytes(),
);
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(bytes);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let ct = format!("multipart/form-data; boundary={boundary}");
(ct, body)
}
async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = res.status();
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
(status, body)
}
async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
(status, body)
}
async fn build_state() -> (AppState, tempfile::TempDir) {
let dir = tempdir().unwrap();
let iso_store = IsoStore::new(dir.path().join("isos"));
iso_store.ensure_dirs().await.unwrap();
let clients = ClientRegistry::new();
let gates = GateQueue::new();
let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let log_bus = LogBus::new(64);
let state = AppState {
iso_store,
clients,
gates,
settings,
smb: None,
nfs,
log_bus,
started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(),
nic_name: "lo".into(),
subnet_mask: "255.0.0.0".into(),
gateway: "127.0.0.1".into(),
};
(state, dir)
}
#[tokio::test]
async fn health_and_ready_endpoints() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/healthz").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b, b"ok\n");
// /readyz should 503 when no iPXE binaries bundled at test time — this
// actually depends on whether CI has fetched them. Accept either.
let (s2, _) = get(&app, "/readyz").await;
assert!(
s2 == StatusCode::OK || s2 == StatusCode::SERVICE_UNAVAILABLE,
"unexpected readyz status: {s2}"
);
}
#[tokio::test]
async fn upload_introspects_and_generates_boot_entry() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED, "upload failed");
// Confirm the ISO shows up in the menu.
let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap();
assert!(menu.contains("Linux Installers"), "menu missing Linux submenu:\n{menu}");
let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux = String::from_utf8(linux).unwrap();
assert!(linux.contains("fake-alpine-linux"), "linux submenu missing entry:\n{linux}");
assert!(linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"),
"size label missing in {linux}");
// Per-entry boot script should include kernel + initrd URLs + boot.
let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
let entry = String::from_utf8(entry).unwrap();
assert!(entry.contains("kernel http://127.0.0.1/iso/fake-alpine/boot/vmlinuz-lts"));
assert!(entry.contains("initrd http://127.0.0.1/iso/fake-alpine/boot/initramfs-lts"));
assert!(entry.contains("boot || goto failed"));
}
#[tokio::test]
async fn iso_range_request_slices_correctly() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
app.clone()
.oneshot(
Request::builder()
.method("POST").uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body)).unwrap()).await.unwrap();
// Range bytes=0x8000-0x8005 should return the PVD signature byte.
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/iso/fake-alpine.iso")
.header(header::RANGE, "bytes=32768-32773")
.body(Body::empty()).unwrap())
.await.unwrap();
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
let slice = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
assert_eq!(slice[0], 0x01); // PVD type
assert_eq!(&slice[1..6], b"CD001");
}
#[tokio::test]
async fn gated_deployment_full_flow() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload an ISO so the target exists.
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
app.clone()
.oneshot(Request::builder().method("POST").uri("/api/isos")
.header("content-type", ct).body(Body::from(body)).unwrap())
.await.unwrap();
// Two clients join.
let (_, join1) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:01").await;
let (_, join2) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:02").await;
let s1 = String::from_utf8(join1).unwrap();
let s2 = String::from_utf8(join2).unwrap();
assert!(s1.contains("Gate Position 1"));
assert!(s2.contains("Gate Position 2"));
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
.unwrap().to_string();
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
.unwrap().to_string();
// Kick off a long-poll for client 1 in the background. Then assign.
let app2 = app.clone();
let poll_future = tokio::spawn(async move {
let uri = format!("/api/gate/poll/{gate1_id}");
get(&app2, &uri).await
});
// Give the poll a moment to register its notify subscription.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Operator assigns.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":["{gate2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/gate/assign", &body).await;
assert_eq!(s, StatusCode::OK);
let assign_json = String::from_utf8(b).unwrap();
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
// Now assign to gate 1 too so the background poll wakes.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#);
post_json(&app, "/api/gate/assign", &body).await;
let (poll_status, poll_body) = poll_future.await.unwrap();
assert_eq!(poll_status, StatusCode::OK);
let poll_s = String::from_utf8(poll_body).unwrap();
assert!(
poll_s.contains("chain http://127.0.0.1/boot/fake-alpine-linux.ipxe"),
"poll response should chain the boot script:\n{poll_s}"
);
// Retry-on-error fallback must be present.
assert!(poll_s.contains("|| chain http://127.0.0.1/api/gate/poll/"),
"retry fallback missing");
// Bad target must be rejected.
let (_, bad) = post_json(&app, "/api/gate/assign",
r#"{"target":"does-not-exist","gate_ids":[]}"#).await;
let bad_s = String::from_utf8(bad).unwrap();
assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}");
}
#[tokio::test]
async fn settings_put_persists_across_reads() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let body = serde_json::json!({
"boot_menu_timeout_secs": 42,
"timeout_action": "local_hdd",
"windows_enabled": false,
"smb_host_override": "",
"extra_kernel_args": "console=ttyS0",
"default_local_hdd": true,
"gate_wait_max_secs": 0
}).to_string();
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (_, g) = get(&app, "/api/settings").await;
let got: serde_json::Value = serde_json::from_slice(&g).unwrap();
assert_eq!(got["boot_menu_timeout_secs"], 42);
assert_eq!(got["timeout_action"], "local_hdd");
assert_eq!(got["extra_kernel_args"], "console=ttyS0");
// And the menu should now use the new timeout.
let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap();
assert!(menu.contains("--timeout 42000"),
"menu should reflect 42s timeout:\n{menu}");
assert!(menu.contains("--default local"),
"menu should default to local:\n{menu}");
}
#[tokio::test]
async fn reboot_and_firmware_exit_in_tools_menu() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools = String::from_utf8(tools).unwrap();
assert!(tools.contains("Reboot Computer"),
"tools menu missing Reboot item:\n{tools}");
assert!(tools.contains("Exit and continue BIOS boot"),
"tools menu missing firmware-exit item:\n{tools}");
assert!(tools.contains("&& reboot"),
"reboot command not wired:\n{tools}");
assert!(tools.contains("&& exit 0"),
"firmware exit command not wired:\n{tools}");
}
#[tokio::test]
async fn ui_assets_served_offline() {
let (state, _dir) = build_state().await;
let app = build_router(state);
for (path, ct) in [
("/", "text/html"),
("/assets/app.js", "application/javascript"),
("/assets/app.css", "text/css"),
("/assets/logo.svg", "image/svg+xml"),
] {
let res = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
let got = res.headers()
.get(header::CONTENT_TYPE).unwrap()
.to_str().unwrap();
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
}
}
#[tokio::test]
async fn no_external_urls_in_generated_ipxe() {
// Sanity check that nothing we serve points off-server.
let (state, _dir) = build_state().await;
let app = build_router(state);
for path in ["/boot.ipxe", "/boot/_tools_menu.ipxe", "/boot/_linux_menu.ipxe",
"/boot/_shell.ipxe", "/boot/_nic.ipxe", "/boot/_local.ipxe"] {
let (_, body) = get(&app, path).await;
let s = String::from_utf8(body).unwrap();
// The only URLs we should emit are relative to our own public_base_url.
for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] {
assert!(!s.contains(url), "{path} references external host {url}:\n{s}");
}
// Confirm URLs are all ours.
for line in s.lines() {
if let Some(idx) = line.find("http://") {
let rest = &line[idx..];
assert!(rest.starts_with("http://127.0.0.1"),
"{path} references non-public-base URL: {line}");
}
}
}
}
// ── Phase 4 integration tests ────────────────────────────────────────────
#[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() {
// Validation must happen before we shell out to /bin/mount —
// otherwise the operator sees opaque kernel errors instead of a
// clear "your export must start with /" hint.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs",
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(msg.contains("export"), "expected validation hint, got: {msg}");
}
#[tokio::test]
async fn nfs_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/nfs").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn terminal_help_and_status_round_trip() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Empty command -> help banner.
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert!(v["output"].as_str().unwrap().contains("PXEForge terminal"));
// status -> contains the version banner.
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let out = v["output"].as_str().unwrap();
assert!(out.starts_with("PXEForge"), "unexpected status output: {out}");
assert!(out.contains("isos:"), "status missing iso line: {out}");
// Unknown command -> ok=false plus help hint.
let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["ok"], false);
assert!(v["output"].as_str().unwrap().contains("unknown command"));
}
#[tokio::test]
async fn log_recent_returns_buffered_lines() {
// The terminal command we issued seeds the log bus, so a follow-up
// /api/log/recent must surface those lines as JSON.
let (state, _dir) = build_state().await;
let app = build_router(state);
let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await;
let (s, b) = get(&app, "/api/log/recent").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let lines = v["lines"].as_array().expect("lines array");
assert!(!lines.is_empty(), "log buffer should have at least one line");
// Every entry should have the canonical timestamp/level/target/message.
for l in lines {
for k in ["timestamp", "level", "target", "message"] {
assert!(l.get(k).is_some(), "missing field {k} in log line: {l}");
}
}
}
#[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// GET starts blank.
let (_, b) = get(&app, "/api/network").await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["dns_server"], "");
assert_eq!(v["nic_name"], "lo");
// PUT updates only the DNS field.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/network")
.header("content-type", "application/json")
.body(Body::from(r#"{"dns_server":"10.0.0.1"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (_, b) = get(&app, "/api/network").await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["dns_server"], "10.0.0.1");
}