From 6774c4e7a2125ed4301a4ea83c1cc31daf105f1c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 00:34:19 +0200 Subject: [PATCH] feat(wol): support WoWLAN so Wi-Fi hosts wake like wired ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host's arming check asked `ethtool` about every NIC, which is the wrong question for Wi-Fi: the magic-packet trigger lives in nl80211's WoWLAN state, and most wireless drivers print `Wake-on: d` whether or not it is armed. An armed Wi-Fi host was therefore told it was NOT armed, and handed an `ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy (`/sys/class/net//phy80211`) is now asked `iw phy wowlan show` instead, and the warning carries WoWLAN-correct guidance — `iw ... wowlan enable magic-packet`, plus the NetworkManager `802-11-wireless.wake-on-wlan magic` that survives a reconnect. Two fallbacks for when `iw` can't answer (missing binary, driver without the command, or privilege the user-level host service lacks): a POSITIVE ethtool reading counts (brcmfmac & co do report there), a negative one never does, and sysfs `device/power/wakeup` reading `disabled` is conclusive in the negative. The client sender now emits from a socket bound to EACH non-loopback interface's own address rather than leaving the path to the routing table. A station in WoWLAN sleep stays associated and its AP buffers broadcast frames for it until the next DTIM beacon — but only if the datagram reaches the wireless segment at all, and with a VPN or mesh interface holding the default route `255.255.255.255` never did. A failed bind falls back to the routed socket, so no segment is lost. Tests: `iw`/`ethtool` output parsing split from the commands so both are unit- tested on any platform, and a new end-to-end test asserts a real listener receives the 102 magic-packet bytes. Verified on Linux (Ubuntu 26.04, 12 interfaces): `cargo fmt --all --check`, `cargo clippy -p punktfunk-core -p punktfunk-host --all-targets --locked -- -D warnings`, and both wol test sets green. NOT yet exercised against real Wi-Fi hardware — no Wi-Fi Linux box was reachable. --- crates/punktfunk-core/src/wol.rs | 173 +++++++++++++---- crates/punktfunk-host/src/wol.rs | 221 +++++++++++++++++++++- docs-site/content/docs/troubleshooting.md | 9 +- docs-site/content/docs/wake-on-lan.md | 95 +++++++++- 4 files changed, 436 insertions(+), 62 deletions(-) diff --git a/crates/punktfunk-core/src/wol.rs b/crates/punktfunk-core/src/wol.rs index e63642ed..c1848069 100644 --- a/crates/punktfunk-core/src/wol.rs +++ b/crates/punktfunk-core/src/wol.rs @@ -7,14 +7,22 @@ //! //! 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), +//! known host MAC we send the 102-byte packet: +//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own +//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast** +//! `255.255.255.255` — binding the source is what forces the datagram onto that segment +//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and +//! * from an unbound socket to `255.255.255.255` and, when known, 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. +//! +//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes +//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for +//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach +//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The +//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module. use std::io; use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}; @@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] { /// 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<()> { + send_magic_packet_on(macs, last_known_ip, &WOL_PORTS) +} + +/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are +/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed +/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire. +fn send_magic_packet_on( + macs: &[Mac], + last_known_ip: Option, + ports: &[u16], +) -> io::Result<()> { if macs.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "no MAC addresses", )); } + let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect(); - // 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 + // Targets that go out the default route (or wherever the routing table sends them): the + // limited broadcast as a baseline, plus the optional unicast — destination routing picks the + // right NIC for a unicast, so it doesn't need per-interface treatment. + let mut routed: Vec = vec![Ipv4Addr::BROADCAST]; if let Some(ip) = last_known_ip { - targets.push(ip); + routed.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; - } - } - } + + // Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the + // datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route + // only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi + // segment the sleeping WoWLAN station is associated to. + for (local, bcast) in local_v4_segments() { + let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else { + // Bind failed (address just went away, or the OS refuses it) — fall back to the + // routed socket below, which still reaches this segment's directed broadcast. + routed.push(bcast); + continue; + }; + if sock.set_broadcast(true).is_err() { + routed.push(bcast); + continue; } + sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports); + } + + // Routed pass, and the only pass on a machine whose interfaces can't be enumerated. + if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) { + // A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and + // the per-interface sockets above may already have carried the broadcast. + let _ = sock.set_broadcast(true); + routed.sort_unstable(); + routed.dedup(); + sent_any |= blast(&sock, &packets, &routed, ports); + } else if !sent_any { + return Err(io::Error::other("no socket could be opened for the wake")); } if sent_any { @@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option) -> io::R } } -/// 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 { +/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any +/// single datagram made it out — an unroutable target is expected and never fails the wake. +fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool { + let mut sent_any = false; + for _ in 0..BURST { + for pkt in packets { + for ip in targets { + // A degenerate 0.0.0.0 (unconfigured NIC) is not a destination. + if ip.is_unspecified() { + continue; + } + for port in ports { + let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port)); + if sock.send_to(pkt, dst).is_ok() { + sent_any = true; + } + } + } + } + } + sent_any +} + +/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The +/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration +/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires. +fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> { let mut out = Vec::new(); let ifaces = match if_addrs::get_if_addrs() { Ok(i) => i, @@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec { continue; } if let if_addrs::IfAddr::V4(v4) = iface.addr { + if v4.ip.is_unspecified() { + continue; // nothing to bind to + } 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.push((v4.ip, bcast)); } } out @@ -183,10 +235,47 @@ mod tests { } #[test] - fn broadcast_addrs_never_contains_limited_or_unspecified() { - for b in broadcast_addrs() { - assert_ne!(b, Ipv4Addr::BROADCAST); - assert!(!b.is_unspecified()); + fn local_segments_are_bindable_and_have_a_broadcast() { + for (local, bcast) in local_v4_segments() { + // The local address is what we bind the per-interface socket to, so it must be a + // real address — and it must never be the loopback (filtered) or unspecified. + assert!(!local.is_unspecified()); + assert!(!local.is_loopback()); + assert!(!bcast.is_unspecified()); + // Binding to an address the OS just reported must work; a failure here would mean + // the per-interface pass silently degrades to the routed one. + assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok()); } } + + #[test] + fn blast_reports_nothing_sent_for_an_empty_target_list() { + let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback"); + let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])]; + assert!(!blast(&sock, &pkt, &[], &WOL_PORTS)); + // An unconfigured 0.0.0.0 target is skipped rather than sent to. + assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS)); + // Loopback is a real destination — this one must go out. + assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999])); + } + + /// The whole send path, end to end: a real receiver gets a real magic packet with the right + /// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind), + /// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when + /// the AP filters broadcast to sleeping stations. + #[test] + fn send_delivers_the_magic_packet_to_a_listener() { + let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver"); + let port = rx.local_addr().expect("local addr").port(); + rx.set_read_timeout(Some(std::time::Duration::from_secs(5))) + .expect("read timeout"); + + let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; + send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send"); + + let mut buf = [0u8; 256]; + let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive"); + assert_eq!(n, 102); + assert_eq!(buf[..102], build_magic_packet(mac)); + } } diff --git a/crates/punktfunk-host/src/wol.rs b/crates/punktfunk-host/src/wol.rs index 5a10c3b5..ab13dd1e 100644 --- a/crates/punktfunk-host/src/wol.rs +++ b/crates/punktfunk-host/src/wol.rs @@ -1,12 +1,21 @@ -//! Host-side Wake-on-LAN support. +//! Host-side Wake-on-LAN / Wake-on-Wireless-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. +//! asleep and no longer advertising. Wired and Wi-Fi NICs alike: a magic packet is the same +//! packet either way, and an associated station in WoWLAN sleep receives the broadcast the +//! AP buffers for it. //! 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. +//! +//! Wired and wireless are armed through completely different interfaces, so the check follows the +//! NIC: `ethtool ` reports the wired `Wake-on: g` bit, while a Wi-Fi NIC's magic-packet +//! trigger lives in nl80211's WoWLAN state and is read with `iw phy wowlan show`. Asking +//! ethtool about a Wi-Fi NIC is what the previous version did, and it is actively misleading: +//! most wireless drivers print `Wake-on: d` whether or not WoWLAN is armed, so an armed host got +//! warned that it wasn't — with a fix command (`ethtool -s wlan0 wol g`) that its driver rejects. use std::net::IpAddr; @@ -61,8 +70,8 @@ pub fn wake_macs(primary_ip: IpAddr) -> Vec { } /// 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). +/// warn only — never modifies settings. Linux-only (shells out to `iw`/`ethtool`); a no-op +/// elsewhere and silent when it can't tell (tool missing, 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(); @@ -73,6 +82,41 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) { else { return; }; + + // A NIC with an nl80211 phy is wireless: ask nl80211 about WoWLAN, not ethtool about WoL. + if let Some(phy) = wireless_phy(&iface) { + match wowlan_has_magic(phy.as_deref(), &iface) { + Some(true) => tracing::info!( + iface = %iface, + phy = phy.as_deref().unwrap_or("?"), + "Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC" + ), + Some(false) => { + let phy = phy.as_deref().unwrap_or("phy0"); + // A device the kernel won't arm can't wake on anything, so name that separately + // — enabling a WoWLAN trigger alone would not fix it. + let extra = if device_wakeup_enabled(&iface) == Some(false) { + " The kernel also has wake-up switched off for this device \ + (/sys/class/net//device/power/wakeup reads `disabled`), which blocks \ + a network wake by itself." + } else { + "" + }; + tracing::warn!( + iface = %iface, + "Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it \ + from sleep. Enable it with: sudo iw phy {phy} wowlan enable magic-packet \ + (NetworkManager resets that on every re-connect; make it stick with: sudo \ + nmcli connection modify 802-11-wireless.wake-on-wlan magic). \ + The adapter must also stay powered and associated while the host sleeps, and \ + be allowed to wake the machine in BIOS/UEFI.{extra}", + ) + } + None => {} // couldn't determine — stay quiet rather than cry wolf + } + return; + } + match ethtool_wol_has_magic(&iface) { Some(true) => { tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC") @@ -81,7 +125,7 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) { 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.", + PCIe' in BIOS).", ), None => {} // couldn't determine — stay quiet rather than cry wolf } @@ -90,6 +134,80 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) { #[cfg(not(target_os = "linux"))] pub fn warn_if_not_armed(_primary_ip: IpAddr) {} +/// Is `iface` a Wi-Fi NIC, and if so which nl80211 phy backs it? `Some(Some("phy0"))` = wireless +/// and we know the phy (so we can query and name it); `Some(None)` = wireless but the phy name +/// couldn't be read; `None` = wired (or sysfs is unavailable, which reads the same way — the +/// ethtool path then applies, exactly as before). +#[cfg(target_os = "linux")] +fn wireless_phy(iface: &str) -> Option> { + let dir = format!("/sys/class/net/{iface}/phy80211"); + if !std::path::Path::new(&dir).exists() { + return None; + } + let name = std::fs::read_to_string(format!("{dir}/name")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + Some(name) +} + +/// Whether a Wi-Fi NIC is armed for a magic-packet wake. `iw` is authoritative — it reads the +/// live nl80211 WoWLAN state, which is where the trigger actually lives. +/// +/// Two fallbacks for when `iw` can't answer (binary missing, driver without the WoWLAN command, +/// no phy name, or a kernel that wants privilege we don't have — the host runs as a plain user +/// service, so that last one is not hypothetical): +/// * a *positive* ethtool reading counts, a negative one never does — a handful of drivers +/// (brcmfmac and friends, i.e. most Raspberry Pi / SoC Wi-Fi) really do expose the +/// magic-packet bit through ethtool, while the far more common `Wake-on: d` from a wireless +/// driver means nothing at all; +/// * failing that, sysfs `device/power/wakeup` — world-readable, and a `disabled` there is +/// conclusive in the negative direction: the kernel will not arm this device to wake the +/// machine, so whatever WoWLAN triggers the firmware holds can never fire. +#[cfg(target_os = "linux")] +fn wowlan_has_magic(phy: Option<&str>, iface: &str) -> Option { + if let Some(v) = phy.and_then(iw_wowlan_has_magic) { + return Some(v); + } + if let Some(true) = ethtool_wol_has_magic(iface) { + return Some(true); + } + // Only the negative is meaningful: `enabled` says the device may wake the machine, not that a + // magic packet is one of the things that will do it. + match device_wakeup_enabled(iface) { + Some(false) => Some(false), + _ => None, + } +} + +/// sysfs `/sys/class/net//device/power/wakeup` — `enabled`/`disabled`, i.e. whether the +/// kernel will arm this device to wake the system at all. `None` when the attribute isn't there +/// (platform/SDIO devices often have none) or can't be read. +#[cfg(target_os = "linux")] +fn device_wakeup_enabled(iface: &str) -> Option { + let text = + std::fs::read_to_string(format!("/sys/class/net/{iface}/device/power/wakeup")).ok()?; + match text.trim() { + "enabled" => Some(true), + "disabled" => Some(false), + _ => None, + } +} + +/// Ask nl80211 (via `iw phy wowlan show`) whether the magic-packet trigger is enabled. +/// `None` if `iw` is missing or the driver doesn't implement WoWLAN. +#[cfg(target_os = "linux")] +fn iw_wowlan_has_magic(phy: &str) -> Option { + let out = std::process::Command::new("iw") + .args(["phy", phy, "wowlan", "show"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + parse_iw_wowlan(&String::from_utf8_lossy(&out.stdout)) +} + /// 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")] @@ -101,7 +219,13 @@ fn ethtool_wol_has_magic(iface: &str) -> Option { if !out.status.success() { return None; } - let text = String::from_utf8_lossy(&out.stdout); + parse_ethtool_wol(&String::from_utf8_lossy(&out.stdout)) +} + +/// `ethtool ` output → does the *current* Wake-on setting include `g` (MagicPacket)? +/// `None` when the field is absent. Split out from the command so it can be unit-tested on any +/// platform. +fn parse_ethtool_wol(text: &str) -> Option { for line in text.lines() { let t = line.trim(); // The current setting is "Wake-on: "; skip the "Supports Wake-on: ..." capability @@ -112,3 +236,88 @@ fn ethtool_wol_has_magic(iface: &str) -> Option { } None } + +/// `iw phy wowlan show` output → is the magic-packet trigger enabled? The two shapes are +/// +/// ```text +/// WoWLAN is disabled +/// ``` +/// ```text +/// WoWLAN is enabled: +/// * wake up on magic packet +/// * wake up on pattern match, up to 20 patterns of 16 - 128 bytes +/// ``` +/// +/// `* wake up on anything` (the nl80211 `any` trigger) counts too — that NIC wakes on every frame +/// it receives, magic packets included. Enabled with only other triggers reads as NOT armed, +/// which is the honest answer: a magic packet won't wake it. `None` when the output says nothing +/// about WoWLAN at all. Split out from the command so it can be unit-tested on any platform. +fn parse_iw_wowlan(text: &str) -> Option { + let mut seen = false; + let mut magic = false; + for line in text.lines() { + let t = line.trim(); + if let Some(state) = t.strip_prefix("WoWLAN is ") { + seen = true; + if state + .trim() + .trim_end_matches(':') + .eq_ignore_ascii_case("disabled") + { + return Some(false); + } + } else if seen && t.starts_with('*') { + let l = t.to_ascii_lowercase(); + if l.contains("magic packet") || l.contains("anything") { + magic = true; + } + } + } + seen.then_some(magic) +} + +#[cfg(test)] +mod tests { + use super::{parse_ethtool_wol, parse_iw_wowlan}; + + #[test] + fn ethtool_current_setting_not_capability_line() { + let armed = + "Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: g\n\tLink detected: yes\n"; + assert_eq!(parse_ethtool_wol(armed), Some(true)); + // "Supports Wake-on: ...g..." must NOT be read as the current setting. + let off = "Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: d\n"; + assert_eq!(parse_ethtool_wol(off), Some(false)); + assert_eq!( + parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"), + None + ); + } + + #[test] + fn iw_wowlan_states() { + assert_eq!(parse_iw_wowlan("WoWLAN is disabled\n"), Some(false)); + assert_eq!( + parse_iw_wowlan("WoWLAN is enabled:\n * wake up on magic packet\n"), + Some(true) + ); + // Enabled, but not for magic packets — a magic packet will not wake this NIC. + assert_eq!( + parse_iw_wowlan( + "WoWLAN is enabled:\n * wake up on pattern match, up to 20 patterns of 16 - 128 bytes\n" + ), + Some(false) + ); + // The `any` trigger wakes on every received frame, magic packets included. + assert_eq!( + parse_iw_wowlan("WoWLAN is enabled:\n * wake up on anything (device continues operating normally)\n"), + Some(true) + ); + // Nothing to go on — the driver has no WoWLAN command. + assert_eq!(parse_iw_wowlan(""), None); + assert_eq!( + parse_iw_wowlan("Wiphy phy0\n\tmax # scan SSIDs: 20\n"), + None + ); + } +} diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 03d3a655..69ca1c86 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -104,10 +104,11 @@ and capture/display glitches. Clients wake a saved host by themselves — auto-wake is on by default — but only once they have seen it awake, which is how they learn its MAC address, and only if the machine is armed to answer a magic packet. The arming is what's usually missing, and a **Linux** host tells you outright: search the web -console's **Logs** page for `Wake-on-LAN`, and the line either confirms the card is armed or names -the interface and the exact command to arm it. Windows and macOS hosts don't run that check, so go -straight to the BIOS/UEFI and network-card steps in -[Arming the machine](/docs/wake-on-lan#arming-the-machine). +console's **Logs** page for `Wake-on-` — `Wake-on-LAN` for a wired card, `Wake-on-WLAN` for a Wi-Fi +one — and the line either confirms the card is armed or names the interface and the exact command to +arm it. A Wi-Fi card is armed by a different command than a wired one, and the log line gives the +right one. Windows and macOS hosts don't run that check, so go straight to the BIOS/UEFI and +network-card steps in [Arming the machine](/docs/wake-on-lan#arming-the-machine). ## Video is slow to start, or fails across subnets diff --git a/docs-site/content/docs/wake-on-lan.md b/docs-site/content/docs/wake-on-lan.md index 65367143..56e646f5 100644 --- a/docs-site/content/docs/wake-on-lan.md +++ b/docs-site/content/docs/wake-on-lan.md @@ -30,14 +30,35 @@ That ordering is the whole prerequisite: > says so rather than pretending. On every client but the Linux one you can also type the MAC in by > hand; see the table below. -The packet goes to every local interface's subnet broadcast address *and* to `255.255.255.255`, on +The packet goes **out of every one of the client's network interfaces** — from a socket bound to +that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. That -spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it. +spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a +broadcast sent without binding an interface leaves by the default route only, which on a machine +running a VPN or a mesh network is not the LAN the host sleeps on. Neither the advert nor a magic packet is authenticated. That is fine here — a wrong address only makes the wake fail, and the host's certificate fingerprint still gates the actual connection. See [Security](/docs/security). +### Over Wi-Fi + +A host on Wi-Fi wakes from the same packet. The mechanism is **WoWLAN** (Wake on Wireless LAN): +the adapter stays associated to your access point while the machine sleeps, the access point holds +broadcast frames for its sleeping stations and releases them on the next beacon, and the adapter +wakes the machine when one of them is a magic packet. Punktfunk publishes a Wi-Fi card's address +exactly like a wired one, so there is nothing different to do on the client — but the card has to be +armed for it, which is a different switch from the wired one. See +[Linux (Wi-Fi)](#linux-wi-fi) and [Windows](#windows) below. + +Two things can still stop it, and neither is visible from Punktfunk: + +- Some access points and mesh systems drop or rate-limit broadcast traffic to sleeping stations + (often as "multicast enhancement", "broadcast filtering" or IGMP snooping). If wired hosts wake + and a Wi-Fi one never does, that is the first thing to turn off. +- Some laptops and adapters cut power to the Wi-Fi card in deeper sleep states, which drops the + association and with it any chance of a wake. + ## Waking from a client **Auto-wake on connect** is a client setting, and it is **on by default**. You find it in Settings, @@ -135,7 +156,7 @@ whether a machine may be woken off the network is yours to make. ### Check the host log first This is the fastest diagnosis. On **Linux**, the host inspects the card carrying the address it -advertises, each time it starts advertising, and writes one of two lines: +advertises, each time it starts advertising, and writes one line about it. A wired card: ```text Wake-on-LAN armed (magic packet) on host NIC @@ -145,18 +166,29 @@ Wake-on-LAN armed (magic packet) on host NIC Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. ``` +A Wi-Fi card, which is armed through an entirely different mechanism and is asked about separately +(`iw phy … wowlan show`, not `ethtool`): + +```text +Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC +``` + +```text +Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it from sleep. +``` + The warning line goes on to name the interface and the exact command to fix it. The host only -reports; it never changes the card's settings. It stays silent when it cannot tell — `ethtool` -missing, or not enough privilege — rather than guessing, and it says nothing at all when mDNS -adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is published -either. +reports; it never changes the card's settings. It stays silent when it cannot tell — `iw` or +`ethtool` missing, a driver that doesn't answer, or not enough privilege — rather than guessing, and +it says nothing at all when mDNS adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), +because then no address is published either. Read the line on the web console's **Logs** page, or in the journal with `journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck). **Windows and macOS hosts do not run this check**, so there is no log line to look for there. -### Linux +### Linux (wired) Ask the card what it is doing. `Supports Wake-on:` is the capability; `Wake-on:` is the current setting. `g` means magic packet, `d` means disabled. @@ -174,6 +206,42 @@ sudo ethtool -s enp5s0 wol g On many systems that does not survive a reboot. Re-run `ethtool enp5s0` after the next boot to check, and make it permanent through your distribution's network configuration if it reset. +### Linux (Wi-Fi) + +`ethtool` is the wrong tool here — most wireless drivers report `Wake-on: d` whether or not they are +armed, because the trigger lives in the wireless stack instead. Ask `iw`, using the *phy* behind the +interface (`/sys/class/net/wlan0/phy80211/name`, usually `phy0`): + +```bash +iw phy phy0 wowlan show +``` + +`WoWLAN is disabled` means no wake. Armed looks like this, and the `* wake up on magic packet` line +is the one that matters: + +```text +WoWLAN is enabled: + * wake up on magic packet +``` + +Arm it: + +```bash +sudo iw phy phy0 wowlan enable magic-packet +``` + +That setting is per-phy and NetworkManager re-applies its own on every connection, so on a +NetworkManager system make it stick on the connection instead — this survives reboots and +reconnects: + +```bash +sudo nmcli connection modify 802-11-wireless.wake-on-wlan magic +``` + +`iw phy phy0 wowlan show` reporting `command failed: Operation not supported` means the driver has no +WoWLAN support at all; that adapter cannot be woken over Wi-Fi. Check `iw list | grep -A5 "WoWLAN"` +for what the hardware claims to support. + ### Windows Open **Device Manager**, find the network adapter under **Network adapters**, and open its @@ -181,10 +249,17 @@ properties. On the **Power Management** tab, allow the device to wake the comput **Advanced** tab, enable the adapter's magic-packet wake property if it has one. Exact wording depends on the driver. +Wi-Fi adapters use the same two tabs. The **Advanced** property is often called **Wake on Magic +Packet** there too, sometimes **Wake on Wireless LAN**; many Wi-Fi drivers expose neither, and those +cannot be woken over Wi-Fi. `powercfg /devicequery wake_armed` lists every device currently allowed +to wake the machine — if the adapter is not in it, nothing on the network can wake this host. + ## Limits -- **Wired Ethernet is what works.** Waking over Wi-Fi is unreliable and depends entirely on the - adapter and the platform. +- **Wired Ethernet is the sure thing; Wi-Fi works when the adapter supports WoWLAN.** Punktfunk + sends the same packet either way and publishes a Wi-Fi card's address like any other, but whether + a sleeping adapter is still listening is the adapter's and the access point's decision — + see [Over Wi-Fi](#over-wi-fi). - **Connect once while the host is awake**, on the same local network, before you rely on waking it. A host you only ever added by address, on a network where mDNS never reached it, has no learned address — the CLI will tell you so, and the apps will not offer the wake action. Typing the MAC in -- 2.54.0