//! 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 { 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 = 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 { // 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[..]); } }