diff --git a/Cargo.lock b/Cargo.lock index c942981..6c9b5e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1952,6 +1952,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "if-addrs" version = "0.15.0" @@ -2195,7 +2205,7 @@ dependencies = [ "cookie-factory", "libc", "libspa-sys", - "nix", + "nix 0.30.1", "nom 8.0.0", "system-deps", ] @@ -2262,6 +2272,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2285,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e" dependencies = [ "fastrand", "flume", - "if-addrs", + "if-addrs 0.15.0", "log", "mio", "socket-pktinfo", @@ -2383,6 +2403,19 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -2742,7 +2775,7 @@ dependencies = [ "libc", "libspa", "libspa-sys", - "nix", + "nix 0.30.1", "once_cell", "pipewire-sys", "thiserror 2.0.18", @@ -2942,6 +2975,7 @@ dependencies = [ "criterion", "fec-rs", "hmac", + "if-addrs 0.13.4", "libc", "opus", "proptest", @@ -2982,10 +3016,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "if-addrs 0.13.4", "khronos-egl", "libc", "libloading", "log", + "mac_address", "mdns-sd", "nvidia-video-codec-sdk", "openh264", @@ -4765,6 +4801,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4774,6 +4826,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/crates/punktfunk-core/Cargo.toml b/crates/punktfunk-core/Cargo.toml index e537099..fa56ce5 100644 --- a/crates/punktfunk-core/Cargo.toml +++ b/crates/punktfunk-core/Cargo.toml @@ -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"] } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index ff3fdfc..111b730 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -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 = 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::().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. /// diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 5cabee2..ec991dc 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -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; diff --git a/crates/punktfunk-core/src/wol.rs b/crates/punktfunk-core/src/wol.rs new file mode 100644 index 0000000..247fd56 --- /dev/null +++ b/crates/punktfunk-core/src/wol.rs @@ -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 { + 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) -> 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 { + 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()); + } + } +} diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index bd0b62d..7160b0d 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-log = "0.2" axum = "0.8" mdns-sd = "0.20" +# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record. +# `mac_address` reads a NIC's hardware address; `if-addrs` maps the routed IP to its interface name. +mac_address = "1" +if-addrs = "0.13" tokio = { version = "1", features = ["full"] } rsa = "0.9" sha2 = { version = "0.10", features = ["oid"] } diff --git a/crates/punktfunk-host/src/discovery.rs b/crates/punktfunk-host/src/discovery.rs index 844a12b..2799dde 100644 --- a/crates/punktfunk-host/src/discovery.rs +++ b/crates/punktfunk-host/src/discovery.rs @@ -15,6 +15,9 @@ //! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's //! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port. //! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`). +//! - `mac` — the host's wake-capable NIC MAC(s) (comma-separated, routed NIC first), which a client +//! persists so it can Wake-on-LAN this host after it sleeps. Advisory/unauthenticated (a wrong +//! MAC only makes a wake fail). Omitted when none can be read. use anyhow::{Context, Result}; use mdns_sd::{ServiceDaemon, ServiceInfo}; @@ -63,6 +66,18 @@ pub fn advertise_native( if let Some(mgmt) = mgmt_port { props.insert("mgmt".into(), mgmt.to_string()); } + // `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC + // first. A client persists these while the host is awake so it can send a Wake-on-LAN magic + // packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like + // the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert + // and the cert fingerprint still gates the actual connection. Omitted when none can be read. + let macs = crate::wol::wake_macs(ip); + if !macs.is_empty() { + props.insert("mac".into(), macs.join(",")); + } + // Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL + // silently fails. + crate::wol::warn_if_not_armed(ip); let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props) .context("build native mDNS ServiceInfo")?; daemon diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 39cac44..bb859fd 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -22,6 +22,7 @@ mod audio; mod capture; mod config; mod discovery; +mod wol; // Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]` // keeps the `crate::*` module names flat (every existing path is unchanged). #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/wol.rs b/crates/punktfunk-host/src/wol.rs new file mode 100644 index 0000000..02ff3c2 --- /dev/null +++ b/crates/punktfunk-host/src/wol.rs @@ -0,0 +1,105 @@ +//! Host-side Wake-on-LAN support. +//! +//! Two jobs, both best-effort (a failure here never affects streaming): +//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them +//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's +//! asleep and no longer advertising. +//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a +//! magic packet. We never change NIC settings (that's the user's call); we just surface the +//! single most common reason WoL silently fails. + +use std::net::IpAddr; + +/// Upper bound on advertised MACs — keeps the mDNS TXT record small. A host has at most a couple +/// of wake-capable NICs; the routed one is always first. +const MAX_MACS: usize = 4; + +/// MAC(s) of the host's wake-capable NIC(s), lowercase `aa:bb:cc:dd:ee:ff`, with the NIC that +/// bears `primary_ip` (the address clients reach us on) FIRST, then other non-loopback NICs as +/// fallbacks. Best-effort — an empty list just means clients can't auto-wake (they fall back to +/// manual MAC entry). Deduped; all-zero MACs skipped; capped at [`MAX_MACS`]. +pub fn wake_macs(primary_ip: IpAddr) -> Vec { + let ifaces = if_addrs::get_if_addrs().unwrap_or_default(); + + // Interface names in priority order: the one holding `primary_ip` first, then every other + // non-loopback interface that has an IP, de-duplicated by name (an iface has one MAC but may + // appear once per address). + let mut names: Vec = Vec::new(); + if let Some(primary) = ifaces.iter().find(|i| i.ip() == primary_ip) { + names.push(primary.name.clone()); + } + for i in &ifaces { + if i.is_loopback() { + continue; + } + if !names.contains(&i.name) { + names.push(i.name.clone()); + } + } + + let mut out: Vec = Vec::new(); + for name in names { + let Ok(Some(mac)) = mac_address::mac_address_by_name(&name) else { + continue; + }; + let b = mac.bytes(); + if b == [0u8; 6] { + continue; // unset / virtual + } + let s = format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + b[0], b[1], b[2], b[3], b[4], b[5] + ); + if !out.contains(&s) { + out.push(s); + } + if out.len() >= MAX_MACS { + break; + } + } + out +} + +/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect & +/// warn only — never modifies settings. Linux-only (reads `ethtool `); a no-op elsewhere +/// and silent when it can't tell (no `ethtool`, insufficient privilege). +#[cfg(target_os = "linux")] +pub fn warn_if_not_armed(primary_ip: IpAddr) { + let ifaces = if_addrs::get_if_addrs().unwrap_or_default(); + let Some(iface) = ifaces.iter().find(|i| i.ip() == primary_ip).map(|i| i.name.clone()) else { + return; + }; + match ethtool_wol_has_magic(&iface) { + Some(true) => tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC"), + Some(false) => tracing::warn!( + iface = %iface, + "Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \ + Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \ + PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.", + ), + None => {} // couldn't determine — stay quiet rather than cry wolf + } +} + +#[cfg(not(target_os = "linux"))] +pub fn warn_if_not_armed(_primary_ip: IpAddr) {} + +/// Parse `ethtool ` for the *current* Wake-on setting and report whether it includes `g` +/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent. +#[cfg(target_os = "linux")] +fn ethtool_wol_has_magic(iface: &str) -> Option { + let out = std::process::Command::new("ethtool").arg(iface).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let t = line.trim(); + // The current setting is "Wake-on: "; skip the "Supports Wake-on: ..." capability + // line. `g` = MagicPacket, `d` = disabled. + if let Some(flags) = t.strip_prefix("Wake-on:") { + return Some(flags.trim().contains('g')); + } + } + None +} diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 04796c2..7f1b76f 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -17,7 +17,9 @@ // // v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities); // added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. -#define ABI_VERSION 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). +#define ABI_VERSION 3 // `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid). #define PUNKTFUNK_HIDOUT_LED 1 @@ -804,6 +806,23 @@ extern "C" { // Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core. uint32_t punktfunk_abi_version(void); +// 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. +PunktfunkStatus punktfunk_wake_on_lan(const uint8_t *macs, + uintptr_t mac_count, + const char *last_known_ip); + // Create a session over a real UDP transport (`local`/`peer` are `host:port` strings). // Returns NULL on error. //