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
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>
193 lines
7.3 KiB
Rust
193 lines
7.3 KiB
Rust
//! 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());
|
||
}
|
||
}
|
||
}
|