Closes the v0.4.x chapter — NFS works end to end. Five additions: ## Wake-on-LAN (Hosts → Bound hosts) - New core::wol module: parse any MAC form, build the 102-byte magic packet, broadcast it. No special capability needed (ephemeral source port; SO_BROADCAST). Sends to the limited broadcast (255.255.255.255) AND the server's own subnet broadcast (computed from advertised IP + detected mask) so it reaches the right VLAN. - POST /api/hosts/:mac/wol — only fires for *bound* MACs (404 otherwise) so it's not an open packet sprayer. - Bound-hosts table grows a "Wake" button with inline Waking…/Sent ✓ state. ## Webhook notifications (Advanced tab) - core::notify: NotifyConfig + NotifyStore (notify.json), one provider at a time — Slack / Discord / Teams (incoming-webhook JSON) or SMTP. SMTP password is persisted but redacted on GET behind a __keep__ sentinel the UI round-trips so the secret never leaves the box. - http-api::notify: delivery — reqwest POST for chat (provider-shaped bodies), lettre for SMTP (rustls, STARTTLS/implicit TLS, no plaintext). 10s timeout; every send is best-effort. - GET/PUT /api/notify, POST /api/notify/test. - Fired fire-and-forget on the canonical "machine is imaging" boot event and on WoL — never blocks the boot path. ## UI: Advanced tab - New nav item. Holds the webhook config card and the API reference block (relocated from the bottom of Settings). ## UI: login/setup logo (FleetDM treatment) - /api/me now returns has_custom_logo + logo_rev (public bootstrap). The login, setup, and connection-error cards render the uploaded logo full-width with the "OpenPXE" wordmark dropped — matching the sidebar. ## About: update check + licenses - "Check for updates" button → GET /api/updates/check queries the Gitea releases API (derived from CARGO_PKG_REPOSITORY) and compares to the running version. Strictly on-demand — no background polling, keeps the air-gapped promise. - License card documents the MIT OR Apache-2.0 dual license with links, plus a note on bundled components (iPXE GPLv2/UBDL, samba, wimtools). Deps: lettre (SMTP, rustls) + reqwest gains the json feature. Both rustls so the static musl binary stays OpenSSL-free. Tests: 179 passing (+notify round-trip/redaction, webhook validation, WoL-unbound-404, WoL packet loopback, version-compare). clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
211 lines
7.7 KiB
Rust
211 lines
7.7 KiB
Rust
//! Wake-on-LAN.
|
|
//!
|
|
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
|
|
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
|
|
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
|
|
//! local segment. The NIC's WoL logic matches the repeated MAC and
|
|
//! powers the board on.
|
|
//!
|
|
//! ## Why this is trivial and safe in our container
|
|
//!
|
|
//! - It's a single UDP datagram to a broadcast address. No privileged
|
|
//! *local* port is needed (we bind an ephemeral source port); the
|
|
//! destination port is conventionally 9 (discard) or 7 (echo), and
|
|
//! nothing actually listens there — the magic is in the payload, not
|
|
//! the port. So WoL works without any extra capability.
|
|
//! - We send to the limited broadcast `255.255.255.255` (stays on the
|
|
//! local link) and, when the caller knows the server's own subnet
|
|
//! broadcast, to that too — directed broadcast reaches the right VLAN
|
|
//! even when the host bridges multiple segments.
|
|
//!
|
|
//! ## Limits
|
|
//!
|
|
//! WoL only crosses L2. If the target is on a different subnet than the
|
|
//! OpenPXE host, the intervening router must be configured to forward
|
|
//! directed broadcasts (most aren't, by design). For the common case —
|
|
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
|
|
//! is enough.
|
|
|
|
use crate::{Error, Result};
|
|
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
|
|
|
|
/// Conventional WoL destination port. 9 (discard) is the de-facto
|
|
/// default; the port is immaterial since the match is on the payload.
|
|
const WOL_PORT: u16 = 9;
|
|
|
|
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
|
|
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
|
|
///
|
|
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
|
|
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
|
|
// Strip every non-hex-digit, then expect exactly 12 hex chars.
|
|
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
|
|
if hex.len() != 12 {
|
|
return Err(Error::Invalid(format!(
|
|
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
|
|
hex.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 6];
|
|
for (i, byte) in out.iter_mut().enumerate() {
|
|
// Each octet is two hex chars; unwrap is safe — we validated
|
|
// the length and that every char is a hex digit above.
|
|
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
|
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Build the 102-byte magic packet for `mac`.
|
|
#[must_use]
|
|
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
|
|
let mut pkt = [0u8; 102];
|
|
// 6 bytes of 0xFF.
|
|
for b in &mut pkt[..6] {
|
|
*b = 0xFF;
|
|
}
|
|
// MAC repeated 16 times.
|
|
for rep in 0..16 {
|
|
let start = 6 + rep * 6;
|
|
pkt[start..start + 6].copy_from_slice(&mac);
|
|
}
|
|
pkt
|
|
}
|
|
|
|
/// Send a Wake-on-LAN magic packet for `mac` to every address in
|
|
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
|
|
/// broadcast). Returns the number of broadcast addresses the packet was
|
|
/// successfully sent to; errors only if the MAC is malformed or the
|
|
/// socket can't be opened at all.
|
|
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
|
|
let parsed = parse_mac(mac)?;
|
|
let packet = magic_packet(parsed);
|
|
|
|
// Always include the limited broadcast even if the caller didn't —
|
|
// it's the one that works with zero network configuration.
|
|
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
|
for b in broadcasts {
|
|
if !targets.contains(b) {
|
|
targets.push(*b);
|
|
}
|
|
}
|
|
|
|
let sent = send_magic(&packet, &targets, WOL_PORT)?;
|
|
tracing::info!(
|
|
target: "openpxe::wol",
|
|
mac = %mac, broadcasts = sent,
|
|
"Wake-on-LAN magic packet sent"
|
|
);
|
|
Ok(sent)
|
|
}
|
|
|
|
/// Open a broadcast-enabled UDP socket and send `packet` to every
|
|
/// `target:port`. Returns how many sends succeeded. Errors if the
|
|
/// socket can't be opened or if *no* target accepted the packet.
|
|
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
|
|
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
|
|
// must be enabled to send to a broadcast address.
|
|
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
|
|
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
|
|
sock.set_broadcast(true)
|
|
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
|
|
|
|
let mut sent = 0usize;
|
|
for &addr in targets {
|
|
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
|
|
Ok(_) => sent += 1,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::wol",
|
|
broadcast = %addr,
|
|
"WoL send failed: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if sent == 0 {
|
|
return Err(Error::Invalid(
|
|
"Wake-on-LAN: no broadcast address accepted the packet".into(),
|
|
));
|
|
}
|
|
Ok(sent)
|
|
}
|
|
|
|
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
|
|
/// Used so the caller can include the server's own subnet broadcast
|
|
/// alongside the limited broadcast.
|
|
#[must_use]
|
|
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
|
|
let ip = u32::from(ip);
|
|
let mask = u32::from(mask);
|
|
Ipv4Addr::from(ip | !mask)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_mac_accepts_common_forms() {
|
|
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
|
|
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
|
|
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
|
|
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
|
|
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_mac_rejects_bad_length() {
|
|
assert!(parse_mac("aa:bb:cc").is_err());
|
|
assert!(parse_mac("").is_err());
|
|
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
|
|
}
|
|
|
|
#[test]
|
|
fn magic_packet_shape() {
|
|
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
assert_eq!(&pkt[..6], &[0xFF; 6]);
|
|
// First MAC repetition.
|
|
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
// Last (16th) repetition ends the packet.
|
|
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
}
|
|
|
|
#[test]
|
|
fn subnet_broadcast_computes() {
|
|
assert_eq!(
|
|
subnet_broadcast(
|
|
Ipv4Addr::new(192, 168, 1, 49),
|
|
Ipv4Addr::new(255, 255, 255, 0)
|
|
),
|
|
Ipv4Addr::new(192, 168, 1, 255)
|
|
);
|
|
assert_eq!(
|
|
subnet_broadcast(
|
|
Ipv4Addr::new(10, 5, 3, 7),
|
|
Ipv4Addr::new(255, 255, 0, 0)
|
|
),
|
|
Ipv4Addr::new(10, 5, 255, 255)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn send_magic_delivers_intact_packet_over_loopback() {
|
|
// Deterministic round-trip that doesn't depend on the sandbox
|
|
// permitting a real L2 broadcast: bind a receiver on loopback
|
|
// and confirm send_magic transmits the exact 102-byte packet.
|
|
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
|
let port = rx.local_addr().unwrap().port();
|
|
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
|
|
|
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
|
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
|
assert_eq!(sent, 1);
|
|
|
|
let mut buf = [0u8; 128];
|
|
let n = rx.recv(&mut buf).unwrap();
|
|
assert_eq!(n, 102, "magic packet should be 102 bytes");
|
|
assert_eq!(&buf[..102], &packet[..]);
|
|
}
|
|
}
|