Wake-on-LAN: support WoWLAN so Wi-Fi hosts wake like wired ones #187

Merged
enricobuehler merged 1 commits from worktree-wowlan-support into main 2026-08-12 22:52:01 +00:00
4 changed files with 436 additions and 62 deletions
+131 -42
View File
@@ -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));
}
}
+215 -6
View File
@@ -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
);
}
}
+5 -4
View File
@@ -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
+85 -10
View File
@@ -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