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
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "pxeforge-dhcp-proxy"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "DHCP proxy (RFC 4578) for PXEForge — serves boot info, does not lease IPs"
[lints]
workspace = true
[dependencies]
pxeforge-core.workspace = true
tokio.workspace = true
socket2.workspace = true
dhcproto.workspace = true
tracing.workspace = true
thiserror.workspace = true
anyhow.workspace = true
bytes.workspace = true
+23
View File
@@ -0,0 +1,23 @@
//! DHCP proxy (RFC 4578 "PXE Boot Server Discovery").
//!
//! Listens on UDP/67 (broadcast) and UDP/4011 (PXE boot server). Never
//! assigns IPs — only returns boot parameters (siaddr, option 66 TFTP server,
//! option 67 boot filename, and the mandatory option 60 "PXEClient" echo).
//!
//! Key decisions (see architecture memory for rationale):
//! - Single code path handles both 67 and 4011; distinguished by port.
//! - We set `SO_REUSEADDR` + `SO_BROADCAST` and enable `IP_PKTINFO` so we can
//! (a) learn the destination interface for multi-homed pods and (b) reply
//! back through the correct interface. This lets us run behind host-network
//! in OpenShift without needing `SO_BINDTODEVICE` (which requires NET_RAW).
//! - Classification is: option 77 user-class `iPXE` → serve HTTP script URL;
//! option 60 starts `HTTPClient` → serve HTTP URL directly (UEFI HTTP boot);
//! otherwise → TFTP + arch-specific iPXE binary.
//! - We MUST echo `option 60 = "PXEClient"` (or `"HTTPClient"`) in replies or
//! clients silently drop them.
#![forbid(unsafe_code)]
pub mod reply;
pub mod server;
pub use server::DhcpProxyServer;
+122
View File
@@ -0,0 +1,122 @@
//! Build DHCP proxy replies.
//!
//! Proxy replies look like a normal DHCPOFFER/ACK except:
//! - `yiaddr` (your IP) is 0 — we don't lease.
//! - `siaddr` (server IP) is us — the client will TFTP from here.
//! - option 60 (vendor class) MUST be echoed as `PXEClient` or clients drop.
//! - option 66 (TFTP server name) points at us.
//! - option 67 (bootfile name) is per-architecture iPXE binary on first
//! pass, or the HTTP URL of the boot script once iPXE has chained.
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
use pxeforge_core::{ClientArch, FirmwareClass};
use std::net::Ipv4Addr;
/// Where the reply directs the client next.
#[derive(Debug, Clone)]
pub enum BootDirective {
/// Serve an iPXE binary over TFTP (first-stage chainload).
TftpIpxe { filename: String },
/// Serve an iPXE boot script directly over HTTP. Used when the client is
/// iPXE itself (option 77 = "iPXE") or UEFI HTTP boot (option 60 starts
/// with "HTTPClient").
HttpScript { url: String },
/// Refuse to respond (architecture we don't have a binary for, or
/// not-a-PXE-client). Caller should skip sending anything.
Ignore,
}
pub struct ReplyContext<'a> {
pub request: &'a Message,
pub our_ip: Ipv4Addr,
pub arch: ClientArch,
pub class: FirmwareClass,
/// Public base URL (scheme://host[:port]) used in HTTP directives.
pub public_base_url: &'a str,
}
/// Decide what to do for an incoming request. Pure function — easy to unit
/// test. Does NOT send anything.
#[must_use]
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
match ctx.class {
FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
url: format!("{}/boot.ipxe", ctx.public_base_url.trim_end_matches('/')),
},
FirmwareClass::HttpClient => {
// UEFI HTTP boot: client wants an http:// URL in option 67
// pointing at an EFI executable. We serve ipxe.efi over HTTP;
// it'll then do the same script-fetch the iPXE path does.
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
BootDirective::HttpScript {
url: format!("{}/ipxe/{}", ctx.public_base_url.trim_end_matches('/'), name),
}
}
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
Some(name) => BootDirective::TftpIpxe { filename: name.to_string() },
None => BootDirective::Ignore,
},
FirmwareClass::Other => BootDirective::Ignore,
}
}
/// Build the outgoing DHCPOFFER (or ACK, matching request type) for a
/// directive. Caller is responsible for sending the bytes on the wire.
pub fn build_reply(ctx: &ReplyContext<'_>, directive: &BootDirective) -> Option<Message> {
let reply_type = match request_message_type(ctx.request)? {
MessageType::Discover => MessageType::Offer,
MessageType::Request | MessageType::Inform => MessageType::Ack,
_ => return None,
};
let mut msg = Message::default();
msg.set_opcode(Opcode::BootReply)
.set_htype(ctx.request.htype())
.set_hops(0)
.set_xid(ctx.request.xid())
.set_secs(0)
.set_flags(ctx.request.flags())
.set_ciaddr(Ipv4Addr::UNSPECIFIED)
.set_yiaddr(Ipv4Addr::UNSPECIFIED) // proxy does not lease
.set_siaddr(ctx.our_ip)
.set_giaddr(ctx.request.giaddr())
.set_chaddr(ctx.request.chaddr());
// Set the BOOTP `file` field for legacy PXE stacks before we take the
// options borrow (the two borrows can't overlap).
if let BootDirective::TftpIpxe { filename } = directive {
msg.set_fname_str(filename);
}
let class_echo: &[u8] = match ctx.class {
FirmwareClass::HttpClient => b"HTTPClient",
_ => b"PXEClient",
};
let opts = msg.opts_mut();
opts.insert(DhcpOption::MessageType(reply_type));
opts.insert(DhcpOption::ServerIdentifier(ctx.our_ip));
// Echo the vendor class — REQUIRED by spec for the client to accept.
opts.insert(DhcpOption::ClassIdentifier(class_echo.to_vec()));
match directive {
BootDirective::TftpIpxe { filename } => {
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes()));
opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec()));
}
BootDirective::HttpScript { url } => {
opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec()));
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes()));
}
BootDirective::Ignore => return None,
}
opts.insert(DhcpOption::End);
Some(msg)
}
fn request_message_type(m: &Message) -> Option<MessageType> {
m.opts().get(OptionCode::MessageType).and_then(|o| match o {
DhcpOption::MessageType(t) => Some(*t),
_ => None,
})
}
+236
View File
@@ -0,0 +1,236 @@
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
//! second socket) and dispatches each datagram through the pure reply logic.
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use pxeforge_core::{
ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use tokio::net::UdpSocket;
pub struct DhcpProxyServer {
bind: IpAddr,
dhcp_port: u16,
pxe_port: u16,
our_ip: Ipv4Addr,
public_base_url: String,
clients: Arc<ClientRegistry>,
}
impl DhcpProxyServer {
pub fn new(
bind: IpAddr,
dhcp_port: u16,
pxe_port: u16,
our_ip: Ipv4Addr,
public_base_url: String,
clients: Arc<ClientRegistry>,
) -> Self {
Self { bind, dhcp_port, pxe_port, our_ip, public_base_url, clients }
}
pub async fn run(self) -> anyhow::Result<()> {
let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?;
let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?;
tracing::info!(
target: "pxeforge::dhcp",
"DHCP proxy listening on {}:{} and :{}",
self.bind, self.dhcp_port, self.pxe_port
);
let ctx = Arc::new(self);
let c1 = ctx.clone();
let c2 = ctx.clone();
let a = tokio::spawn(async move { c1.serve_loop(dhcp_sock, "67").await });
let b = tokio::spawn(async move { c2.serve_loop(pxe_sock, "4011").await });
let _ = tokio::try_join!(a, b)?;
Ok(())
}
async fn serve_loop(&self, sock: UdpSocket, label: &'static str) -> anyhow::Result<()> {
let mut buf = vec![0u8; 4096];
loop {
let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "pxeforge::dhcp", port=label, "recv error: {e}");
continue;
}
};
if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await {
tracing::warn!(target: "pxeforge::dhcp", port=label, "handle error: {e}");
}
}
}
async fn handle_datagram(
&self,
sock: &UdpSocket,
data: &[u8],
from: SocketAddr,
label: &'static str,
) -> anyhow::Result<()> {
let request = Message::decode(&mut Decoder::new(data))?;
let vendor_class = request.opts().get(OptionCode::ClassIdentifier).and_then(|o| {
if let DhcpOption::ClassIdentifier(v) = o { Some(v.as_slice()) } else { None }
});
let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| {
if let DhcpOption::UserClass(v) = o { Some(v.as_slice()) } else { None }
});
let class = FirmwareClass::classify(vendor_class, user_class);
if matches!(class, FirmwareClass::Other) {
// Not a PXE client (e.g. a regular DHCP DISCOVER from a phone).
// Silently ignore — we are a proxy, we only speak to PXE clients.
return Ok(());
}
// dhcproto types option 93 as an enum that drops unknown codes;
// re-parse from the raw wire bytes so firmware quirks like 0x0009
// come through intact.
let raw_arch = extract_raw_arch(data).unwrap_or(0);
let arch = ClientArch::from_option_93(raw_arch);
let chaddr = request.chaddr();
let mac = format_mac(chaddr);
self.clients.record(
&mac,
None,
Some(arch),
match label {
"4011" => ClientEvent::PxeBootServerRequest,
_ => ClientEvent::DhcpDiscover,
},
);
let ctx = ReplyContext {
request: &request,
our_ip: self.our_ip,
arch,
class,
public_base_url: &self.public_base_url,
};
let directive = decide(&ctx);
if matches!(directive, BootDirective::Ignore) {
tracing::debug!(
target: "pxeforge::dhcp",
mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
);
return Ok(());
}
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); };
let mut out = Vec::with_capacity(512);
reply.encode(&mut Encoder::new(&mut out))?;
let dest = reply_destination(&request, from);
sock.send_to(&out, dest).await?;
tracing::info!(
target: "pxeforge::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
"PXE reply sent"
);
Ok(())
}
}
/// Choose where to send the reply. DHCP semantics (RFC 2131 §4.1):
/// 1. If the request came via a relay agent (`giaddr` != 0), reply to
/// that agent on port 67. The relay will forward to the client.
/// 2. If the client already has an IP (`ciaddr`), unicast there on :68.
/// 3. If the broadcast flag is set in the BOOTP flags (bit 15), the
/// client cannot receive unicast frames yet — we MUST broadcast.
/// 4. Otherwise, per the spec we MAY unicast to `chaddr` if we ARP-inject,
/// but since we don't craft raw frames (proxy mode, no NET_RAW), we
/// fall back to broadcast which every client accepts.
/// 5. Special case for the PXE Boot Server port 4011: reply to the
/// source address/port exactly — this is a unicast query and the
/// client expects a unicast answer there.
fn reply_destination(request: &Message, from: SocketAddr) -> SocketAddr {
// (1) relayed request
let giaddr = request.giaddr();
if giaddr != Ipv4Addr::UNSPECIFIED {
return SocketAddr::V4(SocketAddrV4::new(giaddr, 67));
}
// (5) PXE Boot Server discovery is unicast
if from.port() == 4011 {
return from;
}
// (2) client has an IP and has NOT requested broadcast-only
let ciaddr = request.ciaddr();
let bflag = request.flags().broadcast();
if ciaddr != Ipv4Addr::UNSPECIFIED && !bflag {
return SocketAddr::V4(SocketAddrV4::new(ciaddr, 68));
}
// (3, 4) broadcast to 255.255.255.255:68
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::BROADCAST, 68))
}
fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocket> {
let domain = match bind {
IpAddr::V4(_) => Domain::IPV4,
IpAddr::V6(_) => Domain::IPV6,
};
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
sock.set_reuse_address(true)?;
#[cfg(unix)]
sock.set_reuse_port(true)?;
if broadcast {
sock.set_broadcast(true)?;
}
sock.set_nonblocking(true)?;
let addr: SocketAddr = SocketAddr::new(bind, port);
sock.bind(&addr.into())?;
let std_sock: std::net::UdpSocket = sock.into();
Ok(UdpSocket::from_std(std_sock)?)
}
fn format_mac(chaddr: &[u8]) -> String {
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
take.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(":")
}
/// Walk raw DHCP options looking for option 93 (Client System Architecture)
/// and return the first 2-byte big-endian value. This bypasses dhcproto's
/// typed decoding because some firmwares emit values outside the IANA table
/// that the typed decoder may drop.
fn extract_raw_arch(packet: &[u8]) -> Option<u16> {
// DHCPv4 fixed header is 240 bytes including the 4-byte magic cookie.
// Options start at offset 240.
let opts = packet.get(240..)?;
let mut i = 0;
while i < opts.len() {
let code = opts[i];
if code == 0xff { return None; } // END
if code == 0x00 { i += 1; continue; } // PAD
i += 1;
if i >= opts.len() { return None; }
let len = opts[i] as usize;
i += 1;
if code == 93 && len >= 2 && i + 2 <= opts.len() {
return Some(u16::from_be_bytes([opts[i], opts[i + 1]]));
}
i += len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_arch_from_raw_options() {
// Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END.
let mut pkt = vec![0u8; 240];
pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie
pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER
pkt.extend_from_slice(&[93, 2, 0x00, 0x07]);
pkt.push(0xff);
assert_eq!(extract_raw_arch(&pkt), Some(0x0007));
}
}