Name update
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