Codebase-wide review pass: finish or remove every loose end, take the safe performance wins on the serving hot paths, and refresh the dependency tree for reliability. No behavior changes for working clients; legacy clients get clearer protocol errors. Finalize / cleanup: - Remove mac_allowlist/subnet_allowlist config fields — parsed but never enforced since introduction; the operator wants line-of-sight serving, so the honest fix is deletion, not wiring. - Remove dead ClientRegistry API (get, set_selected_target, always-None selected_target field, never-emitted DhcpRequest/ HttpIsoAsset events). - TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear error instead of silent timeouts (legacy-client friendliness); fold plan_window into cfg(test); drop the unused-constant keep-alive hack. - rustfmt sweep over the six files with accumulated drift. Hot-path optimizations (all behavior-preserving): - Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP and HTTP — was a ~1 MiB heap copy per boot file request. - Cache the composited PXE boot-menu background PNG keyed on the branding logo revision — was ~50-200 ms of image work per booting client; now one compose per logo change. - Run bcrypt verify/hash on the blocking pool (boot password gate, login, setup, credential rotation) so CPU-heavy auth can't stall the workers streaming ISO ranges to imaging machines. - iso_raw: reuse the already-cloned IsoMeta for path resolution instead of a second registry lock + deep clone per range request. - DriverEscalation: amortize the TTL sweep (1-min interval + inline staleness check) instead of an O(map) retain per DHCP packet. - format_mac: one allocation instead of four per datagram. - Introspection haystack sized to min(scan cap, file size) — was guaranteed a 32 MiB realloc on every large-ISO probe. Robustness: - parse_range: malformed Range headers are now ignored per RFC 7233 (200 + full body) instead of answered with a bogus 206. Dependencies: - dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained trust-dns-proto from the tree (hickory-proto), three releases of DHCP option coverage. Compiles + passes the full suite unchanged. - socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom workspace entry removed; requested versions synced to shipped reality. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
209 lines
7.7 KiB
Rust
209 lines
7.7 KiB
Rust
//! Wake-on-LAN.
|
|
//!
|
|
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
|
|
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
|
|
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
|
|
//! local segment. The NIC's WoL logic matches the repeated MAC and
|
|
//! powers the board on.
|
|
//!
|
|
//! ## Why this is trivial and safe in our container
|
|
//!
|
|
//! - It's a single UDP datagram to a broadcast address. No privileged
|
|
//! *local* port is needed (we bind an ephemeral source port); the
|
|
//! destination port is conventionally 9 (discard) or 7 (echo), and
|
|
//! nothing actually listens there — the magic is in the payload, not
|
|
//! the port. So WoL works without any extra capability.
|
|
//! - We send to the limited broadcast `255.255.255.255` (stays on the
|
|
//! local link) and, when the caller knows the server's own subnet
|
|
//! broadcast, to that too — directed broadcast reaches the right VLAN
|
|
//! even when the host bridges multiple segments.
|
|
//!
|
|
//! ## Limits
|
|
//!
|
|
//! WoL only crosses L2. If the target is on a different subnet than the
|
|
//! OpenPXE host, the intervening router must be configured to forward
|
|
//! directed broadcasts (most aren't, by design). For the common case —
|
|
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
|
|
//! is enough.
|
|
|
|
use crate::{Error, Result};
|
|
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
|
|
|
|
/// Conventional WoL destination port. 9 (discard) is the de-facto
|
|
/// default; the port is immaterial since the match is on the payload.
|
|
const WOL_PORT: u16 = 9;
|
|
|
|
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
|
|
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
|
|
///
|
|
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
|
|
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
|
|
// Strip every non-hex-digit, then expect exactly 12 hex chars.
|
|
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
|
|
if hex.len() != 12 {
|
|
return Err(Error::Invalid(format!(
|
|
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
|
|
hex.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 6];
|
|
for (i, byte) in out.iter_mut().enumerate() {
|
|
// Each octet is two hex chars; unwrap is safe — we validated
|
|
// the length and that every char is a hex digit above.
|
|
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
|
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Build the 102-byte magic packet for `mac`.
|
|
#[must_use]
|
|
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
|
|
let mut pkt = [0u8; 102];
|
|
// 6 bytes of 0xFF.
|
|
for b in &mut pkt[..6] {
|
|
*b = 0xFF;
|
|
}
|
|
// MAC repeated 16 times.
|
|
for rep in 0..16 {
|
|
let start = 6 + rep * 6;
|
|
pkt[start..start + 6].copy_from_slice(&mac);
|
|
}
|
|
pkt
|
|
}
|
|
|
|
/// Send a Wake-on-LAN magic packet for `mac` to every address in
|
|
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
|
|
/// broadcast). Returns the number of broadcast addresses the packet was
|
|
/// successfully sent to; errors only if the MAC is malformed or the
|
|
/// socket can't be opened at all.
|
|
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
|
|
let parsed = parse_mac(mac)?;
|
|
let packet = magic_packet(parsed);
|
|
|
|
// Always include the limited broadcast even if the caller didn't —
|
|
// it's the one that works with zero network configuration.
|
|
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
|
for b in broadcasts {
|
|
if !targets.contains(b) {
|
|
targets.push(*b);
|
|
}
|
|
}
|
|
|
|
let sent = send_magic(&packet, &targets, WOL_PORT)?;
|
|
tracing::info!(
|
|
target: "openpxe::wol",
|
|
mac = %mac, broadcasts = sent,
|
|
"Wake-on-LAN magic packet sent"
|
|
);
|
|
Ok(sent)
|
|
}
|
|
|
|
/// Open a broadcast-enabled UDP socket and send `packet` to every
|
|
/// `target:port`. Returns how many sends succeeded. Errors if the
|
|
/// socket can't be opened or if *no* target accepted the packet.
|
|
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
|
|
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
|
|
// must be enabled to send to a broadcast address.
|
|
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
|
|
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
|
|
sock.set_broadcast(true)
|
|
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
|
|
|
|
let mut sent = 0usize;
|
|
for &addr in targets {
|
|
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
|
|
Ok(_) => sent += 1,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::wol",
|
|
broadcast = %addr,
|
|
"WoL send failed: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if sent == 0 {
|
|
return Err(Error::Invalid(
|
|
"Wake-on-LAN: no broadcast address accepted the packet".into(),
|
|
));
|
|
}
|
|
Ok(sent)
|
|
}
|
|
|
|
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
|
|
/// Used so the caller can include the server's own subnet broadcast
|
|
/// alongside the limited broadcast.
|
|
#[must_use]
|
|
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
|
|
let ip = u32::from(ip);
|
|
let mask = u32::from(mask);
|
|
Ipv4Addr::from(ip | !mask)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_mac_accepts_common_forms() {
|
|
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
|
|
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
|
|
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
|
|
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
|
|
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mac_rejects_bad_length() {
|
|
assert!(parse_mac("aa:bb:cc").is_err());
|
|
assert!(parse_mac("").is_err());
|
|
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
|
|
}
|
|
|
|
#[test]
|
|
fn magic_packet_shape() {
|
|
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
assert_eq!(&pkt[..6], &[0xFF; 6]);
|
|
// First MAC repetition.
|
|
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
// Last (16th) repetition ends the packet.
|
|
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
}
|
|
|
|
#[test]
|
|
fn subnet_broadcast_computes() {
|
|
assert_eq!(
|
|
subnet_broadcast(
|
|
Ipv4Addr::new(192, 168, 1, 49),
|
|
Ipv4Addr::new(255, 255, 255, 0)
|
|
),
|
|
Ipv4Addr::new(192, 168, 1, 255)
|
|
);
|
|
assert_eq!(
|
|
subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
|
|
Ipv4Addr::new(10, 5, 255, 255)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn send_magic_delivers_intact_packet_over_loopback() {
|
|
// Deterministic round-trip that doesn't depend on the sandbox
|
|
// permitting a real L2 broadcast: bind a receiver on loopback
|
|
// and confirm send_magic transmits the exact 102-byte packet.
|
|
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
|
let port = rx.local_addr().unwrap().port();
|
|
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
|
.unwrap();
|
|
|
|
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
|
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
|
assert_eq!(sent, 1);
|
|
|
|
let mut buf = [0u8; 128];
|
|
let n = rx.recv(&mut buf).unwrap();
|
|
assert_eq!(n, 102, "magic packet should be 102 bytes");
|
|
assert_eq!(&buf[..102], &packet[..]);
|
|
}
|
|
}
|