feat(core,host): Wake-on-LAN sender + host MAC advertisement

Add a runtime-free Wake-on-LAN sender in punktfunk-core (per-interface subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated, optional last-known-IP unicast) exposed both as a Rust fn and a punktfunk_wake_on_lan C-ABI (ABI v3), plus a parse_mac helper. The host enumerates its wake-capable NIC MAC(s) and advertises them in a new mDNS `mac` TXT record (routed NIC first), and best-effort detects & warns (never modifies) when the NIC isn't armed for WoL.

MAC delivery is via the unauthenticated mDNS TXT rather than the connection handshake by design: a spoofed MAC only makes a wake fail (the packet is inert; the cert fingerprint still gates the connection), and it avoids threading through the hot connect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 13:37:02 +02:00
parent 097cc6faf4
commit 22c0d92f2e
10 changed files with 450 additions and 5 deletions
+4
View File
@@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-log = "0.2"
axum = "0.8"
mdns-sd = "0.20"
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
# `mac_address` reads a NIC's hardware address; `if-addrs` maps the routed IP to its interface name.
mac_address = "1"
if-addrs = "0.13"
tokio = { version = "1", features = ["full"] }
rsa = "0.9"
sha2 = { version = "0.10", features = ["oid"] }
+15
View File
@@ -15,6 +15,9 @@
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
//! - `mac` — the host's wake-capable NIC MAC(s) (comma-separated, routed NIC first), which a client
//! persists so it can Wake-on-LAN this host after it sleeps. Advisory/unauthenticated (a wrong
//! MAC only makes a wake fail). Omitted when none can be read.
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
@@ -63,6 +66,18 @@ pub fn advertise_native(
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
}
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
// silently fails.
crate::wol::warn_if_not_armed(ip);
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?;
daemon
+1
View File
@@ -22,6 +22,7 @@ mod audio;
mod capture;
mod config;
mod discovery;
mod wol;
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
// keeps the `crate::*` module names flat (every existing path is unchanged).
#[cfg(target_os = "linux")]
+105
View File
@@ -0,0 +1,105 @@
//! Host-side Wake-on-LAN support.
//!
//! Two jobs, both best-effort (a failure here never affects streaming):
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
//! asleep and no longer advertising.
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
//! single most common reason WoL silently fails.
use std::net::IpAddr;
/// Upper bound on advertised MACs — keeps the mDNS TXT record small. A host has at most a couple
/// of wake-capable NICs; the routed one is always first.
const MAX_MACS: usize = 4;
/// MAC(s) of the host's wake-capable NIC(s), lowercase `aa:bb:cc:dd:ee:ff`, with the NIC that
/// bears `primary_ip` (the address clients reach us on) FIRST, then other non-loopback NICs as
/// fallbacks. Best-effort — an empty list just means clients can't auto-wake (they fall back to
/// manual MAC entry). Deduped; all-zero MACs skipped; capped at [`MAX_MACS`].
pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
// Interface names in priority order: the one holding `primary_ip` first, then every other
// non-loopback interface that has an IP, de-duplicated by name (an iface has one MAC but may
// appear once per address).
let mut names: Vec<String> = Vec::new();
if let Some(primary) = ifaces.iter().find(|i| i.ip() == primary_ip) {
names.push(primary.name.clone());
}
for i in &ifaces {
if i.is_loopback() {
continue;
}
if !names.contains(&i.name) {
names.push(i.name.clone());
}
}
let mut out: Vec<String> = Vec::new();
for name in names {
let Ok(Some(mac)) = mac_address::mac_address_by_name(&name) else {
continue;
};
let b = mac.bytes();
if b == [0u8; 6] {
continue; // unset / virtual
}
let s = format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
b[0], b[1], b[2], b[3], b[4], b[5]
);
if !out.contains(&s) {
out.push(s);
}
if out.len() >= MAX_MACS {
break;
}
}
out
}
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
#[cfg(target_os = "linux")]
pub fn warn_if_not_armed(primary_ip: IpAddr) {
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
let Some(iface) = ifaces.iter().find(|i| i.ip() == primary_ip).map(|i| i.name.clone()) else {
return;
};
match ethtool_wol_has_magic(&iface) {
Some(true) => tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC"),
Some(false) => tracing::warn!(
iface = %iface,
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
),
None => {} // couldn't determine — stay quiet rather than cry wolf
}
}
#[cfg(not(target_os = "linux"))]
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
#[cfg(target_os = "linux")]
fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
let out = std::process::Command::new("ethtool").arg(iface).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let t = line.trim();
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
// line. `g` = MagicPacket, `d` = disabled.
if let Some(flags) = t.strip_prefix("Wake-on:") {
return Some(flags.trim().contains('g'));
}
}
None
}