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