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,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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user