Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.
## Naming changes
**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
`bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
`/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
brand string.
**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
`QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
`GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.
## Brand assets
The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
`rainbow-horizon` gradient from openpxe.com (sliding hue rotation
via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
louder version of the same disc — used for page-load transitions
and the imaging-progress widget. Adds a subtle scale pulse and a
white inner-glow so it has dimensionality on either theme.
## CSS rename
- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"
The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."
## Validation
- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
- `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
`/boot/_queue.ipxe`
- `/api/queue` returns `{count, entries}`
- `/metrics` emits `openpxe_queue_count` (renamed)
- `/assets/logo.svg` and `/assets/loader.svg` serve the new
rainbow brand SVGs
- `/api/status` reports version `0.3.0`
## Migration notes for operators on v0.2.0
- Container image path changed: pull
`gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
`gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
switch to `/api/queue/*`. The JSON envelope key is `entries`
instead of `gates`.
249 lines
8.8 KiB
Rust
249 lines
8.8 KiB
Rust
//! 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 openpxe_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>,
|
|
metrics: openpxe_core::Metrics,
|
|
}
|
|
|
|
impl DhcpProxyServer {
|
|
pub fn new(
|
|
bind: IpAddr,
|
|
dhcp_port: u16,
|
|
pxe_port: u16,
|
|
our_ip: Ipv4Addr,
|
|
public_base_url: String,
|
|
clients: Arc<ClientRegistry>,
|
|
metrics: openpxe_core::Metrics,
|
|
) -> Self {
|
|
Self {
|
|
bind,
|
|
dhcp_port,
|
|
pxe_port,
|
|
our_ip,
|
|
public_base_url,
|
|
clients,
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
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: "openpxe::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: "openpxe::dhcp", port=label, "recv error: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await {
|
|
tracing::warn!(target: "openpxe::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) {
|
|
self.metrics.record_dhcp_decline();
|
|
tracing::debug!(
|
|
target: "openpxe::dhcp",
|
|
mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
|
|
);
|
|
return Ok(());
|
|
}
|
|
self.metrics.record_dhcp_reply(arch.as_str());
|
|
|
|
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: "openpxe::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));
|
|
}
|
|
}
|