feat(core,host): Wake-on-LAN sender + host MAC advertisement

Add a runtime-free Wake-on-LAN sender in punktfunk-core (per-interface subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated, optional last-known-IP unicast) exposed both as a Rust fn and a punktfunk_wake_on_lan C-ABI (ABI v3), plus a parse_mac helper. The host enumerates its wake-capable NIC MAC(s) and advertises them in a new mDNS `mac` TXT record (routed NIC first), and best-effort detects & warns (never modifies) when the NIC isn't armed for WoL.

MAC delivery is via the unauthenticated mDNS TXT rather than the connection handshake by design: a spoofed MAC only makes a wake fail (the packet is inert; the cert fingerprint still gates the connection), and it avoids threading through the hot connect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 13:37:02 +02:00
parent 097cc6faf4
commit 22c0d92f2e
10 changed files with 450 additions and 5 deletions
+4
View File
@@ -38,6 +38,10 @@ thiserror = "2"
tracing = { version = "0.1", default-features = false, features = ["std"] }
rand = "0.9"
zeroize = "1"
# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a
# magic packet reaches the host's L2 segment on multi-homed clients (VPN/docker/multiple LANs),
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
if-addrs = "0.13"
quinn = { version = "0.11", optional = true }
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
+54
View File
@@ -183,6 +183,60 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
crate::ABI_VERSION
}
/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
///
/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
/// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4
/// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is
/// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on
/// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface.
///
/// Returns `Ok` if at least one datagram was sent. Call off the UI thread.
///
/// # Safety
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
/// must be a NUL-terminated string.
#[no_mangle]
pub unsafe extern "C" fn punktfunk_wake_on_lan(
macs: *const u8,
mac_count: usize,
last_known_ip: *const c_char,
) -> PunktfunkStatus {
guard(|| {
if macs.is_null() {
return PunktfunkStatus::NullPointer;
}
if mac_count == 0 {
return PunktfunkStatus::InvalidArg;
}
let bytes = unsafe { std::slice::from_raw_parts(macs, mac_count * 6) };
let mac_vec: Vec<crate::wol::Mac> = bytes
.chunks_exact(6)
.map(|c| {
let mut m = [0u8; 6];
m.copy_from_slice(c);
m
})
.collect();
let ip = if last_known_ip.is_null() {
None
} else {
match unsafe { CStr::from_ptr(last_known_ip) }
.to_str()
.ok()
.and_then(|s| s.parse::<std::net::Ipv4Addr>().ok())
{
Some(ip) => Some(ip),
None => return PunktfunkStatus::InvalidArg,
}
};
match crate::wol::send_magic_packet(&mac_vec, ip) {
Ok(()) => PunktfunkStatus::Ok,
Err(_) => PunktfunkStatus::Io,
}
})
}
/// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
/// Returns NULL on error.
///
+4 -1
View File
@@ -39,6 +39,7 @@ pub mod quic;
pub mod session;
pub mod stats;
pub mod transport;
pub mod wol;
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
pub use error::{PunktfunkError, PunktfunkStatus, Result};
@@ -50,4 +51,6 @@ pub use stats::Stats;
///
/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
pub const ABI_VERSION: u32 = 2;
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
pub const ABI_VERSION: u32 = 3;
+182
View File
@@ -0,0 +1,182 @@
//! 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(|c| c == ':' || c == '-') {
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::new(io::ErrorKind::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());
}
}
}