Files
punktfunk/crates/punktfunk-core/src/wol.rs
T
enricobuehler 6c4ba77606
windows-host / package (push) Successful in 7m18s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 56s
apple / swift (push) Successful in 1m16s
android / android (push) Successful in 3m40s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m16s
ci / bench (push) Successful in 4m42s
release / apple (push) Successful in 8m37s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
deb / build-publish (push) Successful in 3m45s
apple / screenshots (push) Successful in 5m29s
flatpak / build-publish (push) Successful in 4m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m51s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m26s
fix(wol): clippy + cfg-gate the Windows client module — main compiles again
The Wake-on-LAN batch landed with lints that fail `clippy -D warnings`
(doc continuation, char-array split, io::Error::other, redundant closure)
and an ungated `mod wol;` in the Windows client, which pulls windows-only
crates into the non-Windows stub build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:02:45 +00:00

193 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Wake-on-LAN: magic-packet builder + broadcast sender.
//!
//! Runtime-free by design — a magic packet is one fire-and-forget UDP datagram, so this needs
//! neither the `quic` feature nor an async runtime and links into every client (including the
//! QUIC-less builds). The Rust clients (linux/windows/android) call these `pub fn`s directly;
//! Swift/iOS reach them through the `punktfunk_wake_on_lan` C-ABI wrapper in [`crate::abi`].
//!
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
//! known host MAC we send the 102-byte packet to:
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
//! * the **limited broadcast** `255.255.255.255`, and
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
//!
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
use std::io;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
/// A MAC address (EUI-48), the 6 bytes a magic packet targets.
pub type Mac = [u8; 6];
/// Conventional Wake-on-LAN UDP ports. 9 (discard) is by far the most common; 7 (echo) is a
/// historical alternative some NICs also listen on. Sending to both is free insurance.
const WOL_PORTS: [u16; 2] = [9, 7];
/// Times each packet is re-sent per call. UDP is lossy and this is fire-and-forget; a small
/// burst costs microseconds and materially improves the odds a waking NIC catches one. The
/// caller's connect-retry loop provides the longer-spaced re-attempts.
const BURST: usize = 3;
/// Parse a MAC string — `aa:bb:cc:dd:ee:ff` or `aa-bb-...`, case-insensitive — into 6 bytes.
/// Returns `None` for anything that isn't exactly six hex octets. Shared by the Rust clients
/// (linux/windows) so MAC parsing lives in one place; the Swift/Apple client parses its own.
pub fn parse_mac(s: &str) -> Option<Mac> {
let mut m = [0u8; 6];
let mut n = 0;
for part in s.split([':', '-']) {
if n == 6 {
return None; // too many octets
}
m[n] = u8::from_str_radix(part.trim(), 16).ok()?;
n += 1;
}
(n == 6).then_some(m)
}
/// The 102-byte magic packet for `mac`: 6×`0xFF` followed by the MAC repeated 16 times.
pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
let mut pkt = [0xFFu8; 102];
for i in 0..16 {
let off = 6 + i * 6;
pkt[off..off + 6].copy_from_slice(&mac);
}
pkt
}
/// Broadcast a wake for every MAC in `macs`. `last_known_ip`, when set, is additionally
/// targeted by unicast.
///
/// Returns `Ok` if at least one datagram was sent, so a single unreachable target (e.g. a
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
/// could be opened or nothing could be sent at all.
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
if macs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no MAC addresses",
));
}
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
let mut targets = broadcast_addrs();
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
if let Some(ip) = last_known_ip {
targets.push(ip);
}
targets.sort_unstable();
targets.dedup();
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
// matching NIC via the routing table; the limited broadcast leaves via the default route.
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
sock.set_broadcast(true)?;
let mut sent_any = false;
for _ in 0..BURST {
for mac in macs {
let pkt = build_magic_packet(*mac);
for ip in &targets {
for port in WOL_PORTS {
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
if sock.send_to(&pkt, dst).is_ok() {
sent_any = true;
}
}
}
}
}
if sent_any {
Ok(())
} else {
Err(io::Error::other("no magic packet could be sent"))
}
}
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
fn broadcast_addrs() -> Vec<Ipv4Addr> {
let mut out = Vec::new();
let ifaces = match if_addrs::get_if_addrs() {
Ok(i) => i,
Err(_) => return out,
};
for iface in ifaces {
if iface.is_loopback() {
continue;
}
if let if_addrs::IfAddr::V4(v4) = iface.addr {
let bcast = v4
.broadcast
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
// already add unconditionally.
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
out.push(bcast);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn magic_packet_layout() {
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
let pkt = build_magic_packet(mac);
assert_eq!(pkt.len(), 102);
// 6-byte 0xFF sync stream.
assert_eq!(&pkt[0..6], &[0xFF; 6]);
// MAC repeated exactly 16 times.
for i in 0..16 {
let off = 6 + i * 6;
assert_eq!(&pkt[off..off + 6], &mac, "repetition {i} mismatch");
}
}
#[test]
fn empty_macs_is_error() {
assert!(send_magic_packet(&[], None).is_err());
}
#[test]
fn parse_mac_forms() {
assert_eq!(
parse_mac("aa:bb:cc:dd:ee:ff"),
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
);
assert_eq!(
parse_mac("AA-BB-CC-DD-EE-FF"),
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
);
assert_eq!(parse_mac("01:02:03:04:05:06"), Some([1, 2, 3, 4, 5, 6]));
assert_eq!(parse_mac("aa:bb:cc:dd:ee"), None); // too few
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff:00"), None); // too many
assert_eq!(parse_mac("zz:bb:cc:dd:ee:ff"), None); // non-hex
assert_eq!(parse_mac(""), None);
}
#[test]
fn send_does_not_panic_with_a_mac() {
// Best-effort: binds a real socket and broadcasts on the loopback host. Must not panic
// and, on any machine with a usable network stack, should report success.
let _ = send_magic_packet(&[[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]], None);
}
#[test]
fn broadcast_addrs_never_contains_limited_or_unspecified() {
for b in broadcast_addrs() {
assert_ne!(b, Ipv4Addr::BROADCAST);
assert!(!b.is_unspecified());
}
}
}