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
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "pxeforge"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Container-native PXE boot server — a lightweight Rust clone of iVentoy"
[lints]
workspace = true
[[bin]]
name = "pxeforge"
path = "src/main.rs"
[dependencies]
pxeforge-core.workspace = true
pxeforge-dhcp-proxy.workspace = true
pxeforge-tftp.workspace = true
pxeforge-http-api.workspace = true
pxeforge-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true
tokio.workspace = true
axum.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
toml.workspace = true
bytes.workspace = true
time.workspace = true
+372
View File
@@ -0,0 +1,372 @@
//! PXEForge entry point. Wires the three protocol servers (DHCP proxy,
//! TFTP, HTTP) to the shared ISO store and client registry, then runs
//! them concurrently.
use clap::{Parser, Subcommand};
use pxeforge_core::{ClientRegistry, Config, DhcpMode, GateQueue, LogBus, LogBusLayer, SettingsStore};
use pxeforge_dhcp_proxy::DhcpProxyServer;
use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use pxeforge_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)]
#[command(name = "pxeforge", about = "Container-native PXE boot server", version)]
struct Cli {
/// Path to a TOML config file. All fields have sensible defaults and can
/// also be overridden with env vars (PXEFORGE_*).
#[arg(long, env = "PXEFORGE_CONFIG")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Import every `.iso` from a host directory into the ISO store, running
/// the same introspection + boot-entry generation pass the web upload
/// does. Useful for pre-populating the store before starting the server
/// (e.g. in a CI setup or after copying ISOs to a fresh PVC).
///
/// Example:
/// docker run --rm \
/// -v /my/isos:/seed:ro \
/// -v pxeforge-data:/var/lib/pxeforge/isos \
/// pxeforge:0.1.0 seed --from /seed
Seed {
/// Source directory containing one or more `.iso` files.
#[arg(long)]
from: PathBuf,
/// Don't actually import — print what would happen.
#[arg(long)]
dry_run: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// The LogBus has to exist before we install tracing layers, since one
// of those layers fans out into it. The web UI's Terminal tab
// subscribes to this bus over SSE.
let log_bus = LogBus::new(500);
init_tracing(log_bus.clone());
let cli = Cli::parse();
let mut config = match &cli.config {
Some(p) if p.exists() => Config::from_toml_file(p)?,
_ => Config::default(),
};
config.apply_env();
// Dispatch subcommands before bringing up the server.
if let Some(cmd) = cli.command {
return run_command(cmd, config).await;
}
pxeforge_ipxe_assets::log_availability();
let our_ip = match config.server.public_ip {
Some(ip) => {
tracing::info!("using configured public IP: {ip}");
ip
}
None => match detect_primary_ipv4() {
Some(ip) => {
tracing::info!("auto-detected public IPv4: {ip}");
ip
}
None => {
// Without a reachable IP, every generated iPXE URL would
// be unreachable from real clients. Exit with a clear
// message instead of serving a broken deployment.
anyhow::bail!(
"could not detect a non-loopback IPv4 address for this host. \
Set PXEFORGE_PUBLIC_IP=<your-ip> (e.g. `-e PXEFORGE_PUBLIC_IP=10.0.0.5` \
in docker, or the env block in OpenShift Deployment) to advertise \
a specific IP to PXE clients."
);
}
},
};
let public_base_url = format!("http://{our_ip}");
let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?;
let clients = ClientRegistry::new();
let gates = GateQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir);
// Build the SMB manager unconditionally — it starts/stops on the
// Windows toggle, not at process start. If the `smb_dir` isn't
// writable (e.g. read-only filesystem), the manager will surface that
// as `SmbState::Failed` when the operator flips the toggle.
let smb = Arc::new(SmbManager::new(config.paths.smb_dir.clone()));
if settings.snapshot().windows_enabled {
let _ = smb.start();
}
// NFS manager. The mount root has to be set on the IsoStore *before*
// we replay any persisted mounts, otherwise an in-memory IsoMeta
// pointing at an NFS source can't resolve to a path.
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
if let Err(e) = nfs.load_and_remount().await {
tracing::warn!(target: "pxeforge::nfs", "could not reload NFS mounts: {e}");
}
// Sniff network details for the Network tab. None of these are
// required for PXE to work — they're informational, surfaced in the
// UI so an operator doesn't have to drop to a shell to find their
// own gateway.
let net = detect_network_info(our_ip);
tracing::info!(
target: "pxeforge::net",
nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway,
"network info"
);
let state = AppState {
iso_store: iso_store.clone(),
clients: clients.clone(),
settings: settings.clone(),
gates: gates.clone(),
smb: Some(smb.clone()),
nfs: nfs.clone(),
log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(),
nic_name: net.nic_name,
subnet_mask: net.subnet_mask,
gateway: net.gateway,
};
let http_addr = SocketAddr::new(config.server.http_bind, config.server.http_port);
let router = build_router(state);
let http_task = tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await?;
tracing::info!(target: "pxeforge::http", "HTTP listening on {http_addr}");
axum::serve(listener, router).await?;
Ok::<_, anyhow::Error>(())
});
let tftp = TftpServer::new(config.server.tftp_bind, config.server.tftp_port, clients.clone());
let tftp_task = tokio::spawn(tftp.run());
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
DhcpMode::Proxy => {
let s = DhcpProxyServer::new(
config.network.dhcp_bind,
config.network.dhcp_port,
config.network.pxe_port,
our_ip,
public_base_url.clone(),
clients.clone(),
);
tokio::spawn(s.run())
}
DhcpMode::Disabled => {
tracing::info!(target: "pxeforge::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
tokio::spawn(async { futures_forever().await })
}
};
tokio::select! {
r = http_task => { tracing::error!("http task exited: {:?}", r); r??; }
r = tftp_task => { tracing::error!("tftp task exited: {:?}", r); r??; }
r = dhcp_task => { tracing::error!("dhcp task exited: {:?}", r); r??; }
}
Ok(())
}
async fn futures_forever() -> anyhow::Result<()> {
std::future::pending::<()>().await;
Ok(())
}
async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
match cmd {
Command::Seed { from, dry_run } => seed_from_dir(&from, &config, dry_run).await,
}
}
/// Walk `src` for `*.iso`, stream each file through the normal upload path.
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
/// is identical to a web upload — same slug rules, same introspection, same
/// sha256.
async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) -> anyhow::Result<()> {
let store = IsoStore::new(config.paths.iso_dir.clone());
store.load_from_disk().await?;
let mut entries = tokio::fs::read_dir(src).await?;
let mut imported = 0u32;
let mut skipped = 0u32;
while let Some(entry) = entries.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 = p
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
.to_string();
println!(" {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len());
if dry_run { continue; }
let mut handle = match store.begin_upload(&filename).await {
Ok(h) => h,
Err(pxeforge_core::Error::Invalid(e)) => {
eprintln!(" skip: {e}");
skipped += 1;
continue;
}
Err(e) => return Err(e.into()),
};
let mut file = tokio::fs::File::open(&p).await?;
let mut buf = vec![0u8; 1024 * 1024];
loop {
let n = file.read(&mut buf).await?;
if n == 0 { break; }
let chunk: bytes::Bytes = buf[..n].to_vec().into();
handle.write_chunk(&chunk).await?;
}
let meta = handle.finish(&store).await?;
println!(" -> id={} family={:?}", meta.id, meta.introspection.family);
imported += 1;
}
println!("\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" });
Ok(())
}
/// Pick the first non-loopback IPv4 address on this host. Returns `None` if
/// detection fails — callers should fail startup rather than silently using
/// a loopback address (which would give every PXE client an unreachable
/// `http://127.0.0.1/...`). Users in multi-homed setups should set
/// `PXEFORGE_PUBLIC_IP` explicitly.
fn detect_primary_ipv4() -> Option<Ipv4Addr> {
// First try: route to the public internet. `UdpSocket::connect` to a
// well-known external address causes the OS to populate `local_addr`
// with the source IP it would use — this is the standard "which of my
// interfaces is the internet-facing one" idiom.
if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") {
if sock.connect("8.8.8.8:80").is_ok() {
if let Ok(std::net::SocketAddr::V4(addr)) = sock.local_addr() {
let v4 = *addr.ip();
if !v4.is_loopback() && !v4.is_unspecified() {
return Some(v4);
}
}
}
}
// Fallback: hostname resolution.
if let Ok(hostname) = hostname() {
if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&format!("{hostname}:0")) {
for a in addrs {
if let std::net::IpAddr::V4(v4) = a.ip() {
if !v4.is_loopback() && !v4.is_unspecified() {
return Some(v4);
}
}
}
}
}
None
}
fn hostname() -> std::io::Result<String> {
// Tiny shim: read /proc/sys/kernel/hostname on Linux, fall back to `uname -n` via env.
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
return Ok(h.trim().to_string());
}
std::env::var("HOSTNAME").map_err(|_| std::io::Error::new(
std::io::ErrorKind::NotFound, "no hostname",
))
}
fn init_tracing(bus: Arc<LogBus>) {
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
let filter = EnvFilter::try_from_env("PXEFORGE_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,pxeforge=debug"));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_target(true))
.with(LogBusLayer::new(bus))
.init();
}
#[derive(Debug, Default)]
struct NetworkInfo {
nic_name: String,
subnet_mask: String,
gateway: String,
}
/// Best-effort population of the Network tab's read-only fields. We shell
/// out to standard Linux tools (`ip route`) instead of pulling in a
/// netlink crate — these calls run once at startup, so the cost of
/// spawning a process is negligible. Empty strings are perfectly fine
/// fallbacks; the UI shows a `?` placeholder.
fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
use std::process::Command;
let mut info = NetworkInfo::default();
// `ip -o -f inet addr show` lists every interface with its
// `inet a.b.c.d/mask`. We match the line that mentions our IP.
if let Ok(out) = Command::new("ip").args(["-o", "-f", "inet", "addr", "show"]).output() {
if let Ok(text) = String::from_utf8(out.stdout) {
for line in text.lines() {
if !line.contains(&our_ip.to_string()) {
continue;
}
// Format: "2: enp1s0 inet 10.0.0.5/24 brd ..."
let mut parts = line.split_whitespace();
let _idx = parts.next();
if let Some(name) = parts.next() {
info.nic_name = name.trim_end_matches(':').to_string();
}
if let Some(addr) = line.split_whitespace().find(|p| p.contains('/')) {
if let Some((_, prefix_str)) = addr.split_once('/') {
if let Ok(prefix) = prefix_str.parse::<u8>() {
info.subnet_mask = prefix_to_dotted(prefix);
}
}
}
break;
}
}
}
// `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..."
if let Ok(out) = Command::new("ip").args(["route", "show", "default"]).output() {
if let Ok(text) = String::from_utf8(out.stdout) {
if let Some(line) = text.lines().next() {
let mut parts = line.split_whitespace();
while let Some(p) = parts.next() {
if p == "via" {
if let Some(gw) = parts.next() {
info.gateway = gw.to_string();
}
break;
}
}
}
}
}
info
}
fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) };
format!(
"{}.{}.{}.{}",
(mask >> 24) & 0xff,
(mask >> 16) & 0xff,
(mask >> 8) & 0xff,
mask & 0xff
)
}