Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6774c4e7a2 |
@@ -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<Ipv4Addr>) -> 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<Ipv4Addr>,
|
||||
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<Ipv4Addr> = 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<Ipv4Addr>) -> 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<Ipv4Addr> {
|
||||
/// 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<Ipv4Addr> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <iface>` 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 <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<String> {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 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/<iface>/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 <connection> 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<Option<String>> {
|
||||
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<bool> {
|
||||
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/<iface>/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<bool> {
|
||||
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 <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<bool> {
|
||||
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 <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")]
|
||||
@@ -101,7 +219,13 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
parse_ethtool_wol(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// `ethtool <iface>` 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<bool> {
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
|
||||
@@ -112,3 +236,88 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `iw phy <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<bool> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <connection> 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
|
||||
|
||||
Reference in New Issue
Block a user