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:
Generated
+61
-3
@@ -1952,6 +1952,16 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "if-addrs"
|
name = "if-addrs"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@@ -2195,7 +2205,7 @@ dependencies = [
|
|||||||
"cookie-factory",
|
"cookie-factory",
|
||||||
"libc",
|
"libc",
|
||||||
"libspa-sys",
|
"libspa-sys",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"nom 8.0.0",
|
"nom 8.0.0",
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
@@ -2262,6 +2272,16 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
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]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2285,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"flume",
|
"flume",
|
||||||
"if-addrs",
|
"if-addrs 0.15.0",
|
||||||
"log",
|
"log",
|
||||||
"mio",
|
"mio",
|
||||||
"socket-pktinfo",
|
"socket-pktinfo",
|
||||||
@@ -2383,6 +2403,19 @@ dependencies = [
|
|||||||
"jni-sys 0.3.1",
|
"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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2742,7 +2775,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"libspa",
|
"libspa",
|
||||||
"libspa-sys",
|
"libspa-sys",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pipewire-sys",
|
"pipewire-sys",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -2942,6 +2975,7 @@ dependencies = [
|
|||||||
"criterion",
|
"criterion",
|
||||||
"fec-rs",
|
"fec-rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"if-addrs 0.13.4",
|
||||||
"libc",
|
"libc",
|
||||||
"opus",
|
"opus",
|
||||||
"proptest",
|
"proptest",
|
||||||
@@ -2982,10 +3016,12 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"if-addrs 0.13.4",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
|
"mac_address",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"nvidia-video-codec-sdk",
|
"nvidia-video-codec-sdk",
|
||||||
"openh264",
|
"openh264",
|
||||||
@@ -4765,6 +4801,22 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
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]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -4774,6 +4826,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ thiserror = "2"
|
|||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
zeroize = "1"
|
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 }
|
quinn = { version = "0.11", optional = true }
|
||||||
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
||||||
|
|||||||
@@ -183,6 +183,60 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
|
|||||||
crate::ABI_VERSION
|
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).
|
/// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
||||||
/// Returns NULL on error.
|
/// Returns NULL on error.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub mod quic;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod wol;
|
||||||
|
|
||||||
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
||||||
pub use error::{PunktfunkError, PunktfunkStatus, Result};
|
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);
|
/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||||
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
/// 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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tracing-log = "0.2"
|
tracing-log = "0.2"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
mdns-sd = "0.20"
|
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"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rsa = "0.9"
|
rsa = "0.9"
|
||||||
sha2 = { version = "0.10", features = ["oid"] }
|
sha2 = { version = "0.10", features = ["oid"] }
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
//! - `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.
|
//! 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`).
|
//! 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 anyhow::{Context, Result};
|
||||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||||
@@ -63,6 +66,18 @@ pub fn advertise_native(
|
|||||||
if let Some(mgmt) = mgmt_port {
|
if let Some(mgmt) = mgmt_port {
|
||||||
props.insert("mgmt".into(), mgmt.to_string());
|
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)
|
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||||
.context("build native mDNS ServiceInfo")?;
|
.context("build native mDNS ServiceInfo")?;
|
||||||
daemon
|
daemon
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod audio;
|
|||||||
mod capture;
|
mod capture;
|
||||||
mod config;
|
mod config;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
mod wol;
|
||||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
// 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).
|
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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<String> = 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<String> = 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 <iface>`); 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 <iface>` 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<bool> {
|
||||||
|
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: <flags>"; 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
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
//
|
//
|
||||||
// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||||
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
// 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).
|
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||||
#define PUNKTFUNK_HIDOUT_LED 1
|
#define PUNKTFUNK_HIDOUT_LED 1
|
||||||
@@ -804,6 +806,23 @@ extern "C" {
|
|||||||
// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
|
// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
|
||||||
uint32_t punktfunk_abi_version(void);
|
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).
|
// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
||||||
// Returns NULL on error.
|
// Returns NULL on error.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user