v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check
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]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb41288c3
commit
fc99973ac3
@@ -12,9 +12,11 @@ pub mod error;
|
||||
pub mod host_bindings;
|
||||
pub mod log_bus;
|
||||
pub mod metrics;
|
||||
pub mod notify;
|
||||
pub mod queue;
|
||||
pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod wol;
|
||||
|
||||
pub use arch::{ClientArch, FirmwareClass};
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
@@ -27,5 +29,6 @@ pub use error::{Error, Result};
|
||||
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
||||
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
||||
pub use metrics::{HttpRoute, Metrics};
|
||||
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
|
||||
pub use queue::{DeploymentQueue, QueueEntry};
|
||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Webhook / email notification configuration.
|
||||
//!
|
||||
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
|
||||
//! something noteworthy happens (a machine PXE-booted an image, a
|
||||
//! deployment was assigned, a WoL was sent). One active provider at a
|
||||
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
|
||||
//! paste the incoming-webhook URL, done.
|
||||
//!
|
||||
//! This module owns only the *configuration* (validation + persistence
|
||||
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
|
||||
//! chat providers, SMTP for email — lives in the http-api crate, which
|
||||
//! already carries an HTTP client and the SMTP dependency. Keeping the
|
||||
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
|
||||
//! stay pure config stores.
|
||||
//!
|
||||
//! Secrets note: the SMTP password is persisted in `notify.json`
|
||||
//! alongside the rest of the config (0644 like the other state files).
|
||||
//! It is never echoed back through the API — the snapshot used for the
|
||||
//! GET response blanks it (see `Self::redacted`).
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MAX_URL_LEN: usize = 2048;
|
||||
const MAX_FIELD_LEN: usize = 512;
|
||||
|
||||
/// Which notification transport is active.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotifyKind {
|
||||
/// Slack incoming webhook (`{ "text": ... }`).
|
||||
#[default]
|
||||
Slack,
|
||||
/// Discord webhook (`{ "content": ... }`).
|
||||
Discord,
|
||||
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
|
||||
Teams,
|
||||
/// Email via SMTP.
|
||||
Smtp,
|
||||
}
|
||||
|
||||
impl NotifyKind {
|
||||
/// True when this kind drives a chat webhook (POST a JSON body to a
|
||||
/// single URL) rather than SMTP.
|
||||
#[must_use]
|
||||
pub fn is_webhook(self) -> bool {
|
||||
matches!(self, Self::Slack | Self::Discord | Self::Teams)
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator-configurable notification settings. Single provider active
|
||||
/// at a time; the inactive fields are kept so switching providers
|
||||
/// doesn't wipe the other one's values.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NotifyConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub kind: NotifyKind,
|
||||
/// Incoming-webhook URL for Slack / Discord / Teams.
|
||||
#[serde(default)]
|
||||
pub webhook_url: String,
|
||||
|
||||
// ── SMTP fields (used when kind == Smtp) ──
|
||||
#[serde(default)]
|
||||
pub smtp_host: String,
|
||||
#[serde(default = "default_smtp_port")]
|
||||
pub smtp_port: u16,
|
||||
#[serde(default)]
|
||||
pub smtp_username: String,
|
||||
#[serde(default)]
|
||||
pub smtp_password: String,
|
||||
/// `From:` address. Falls back to `smtp_username` when blank.
|
||||
#[serde(default)]
|
||||
pub smtp_from: String,
|
||||
/// `To:` address (single recipient — keep it simple).
|
||||
#[serde(default)]
|
||||
pub smtp_to: String,
|
||||
/// Use implicit TLS (port 465). When false we use STARTTLS on the
|
||||
/// configured port (587 typical). Either way the connection is
|
||||
/// encrypted — we never offer plaintext SMTP.
|
||||
#[serde(default)]
|
||||
pub smtp_implicit_tls: bool,
|
||||
}
|
||||
|
||||
fn default_smtp_port() -> u16 {
|
||||
587
|
||||
}
|
||||
|
||||
impl NotifyConfig {
|
||||
/// True when enabled and the active provider has the fields it
|
||||
/// needs to actually send.
|
||||
#[must_use]
|
||||
pub fn is_usable(&self) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
if self.kind.is_webhook() {
|
||||
!self.webhook_url.trim().is_empty()
|
||||
} else {
|
||||
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy safe to return over the API: the SMTP password is blanked
|
||||
/// (replaced with a non-empty sentinel only when one is set, so the
|
||||
/// UI can show "configured" without leaking it).
|
||||
#[must_use]
|
||||
pub fn redacted(&self) -> NotifyConfig {
|
||||
let mut c = self.clone();
|
||||
if !c.smtp_password.is_empty() {
|
||||
c.smtp_password = SECRET_SENTINEL.to_string();
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned by the API in place of a stored password. When the UI PUTs
|
||||
/// this value back unchanged we keep the existing password rather than
|
||||
/// overwriting it with the sentinel.
|
||||
pub const SECRET_SENTINEL: &str = "__keep__";
|
||||
|
||||
/// In-memory + on-disk notification config registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotifyStore {
|
||||
path: Arc<PathBuf>,
|
||||
inner: Arc<RwLock<NotifyConfig>>,
|
||||
}
|
||||
|
||||
impl NotifyStore {
|
||||
#[must_use]
|
||||
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
||||
let path = work_dir.join("notify.json");
|
||||
let cfg = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "openpxe::notify",
|
||||
"notify.json unreadable ({e}); starting with defaults"
|
||||
);
|
||||
NotifyConfig::default()
|
||||
}),
|
||||
Err(_) => NotifyConfig::default(),
|
||||
};
|
||||
Self {
|
||||
path: Arc::new(path),
|
||||
inner: Arc::new(RwLock::new(cfg)),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> NotifyConfig {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
|
||||
/// is treated as "keep the existing password" so the UI never has to
|
||||
/// round-trip the real secret.
|
||||
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
|
||||
incoming.webhook_url = incoming.webhook_url.trim().to_string();
|
||||
incoming.smtp_host = incoming.smtp_host.trim().to_string();
|
||||
incoming.smtp_username = incoming.smtp_username.trim().to_string();
|
||||
incoming.smtp_from = incoming.smtp_from.trim().to_string();
|
||||
incoming.smtp_to = incoming.smtp_to.trim().to_string();
|
||||
|
||||
// Preserve the stored password when the UI sends the sentinel.
|
||||
if incoming.smtp_password == SECRET_SENTINEL {
|
||||
incoming
|
||||
.smtp_password
|
||||
.clone_from(&self.inner.read().smtp_password);
|
||||
}
|
||||
|
||||
// Length caps.
|
||||
if incoming.webhook_url.len() > MAX_URL_LEN {
|
||||
return Err(Error::Invalid(format!(
|
||||
"webhook URL exceeds {MAX_URL_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
for (name, v) in [
|
||||
("smtp_host", &incoming.smtp_host),
|
||||
("smtp_username", &incoming.smtp_username),
|
||||
("smtp_from", &incoming.smtp_from),
|
||||
("smtp_to", &incoming.smtp_to),
|
||||
] {
|
||||
if v.len() > MAX_FIELD_LEN {
|
||||
return Err(Error::Invalid(format!(
|
||||
"{name} exceeds {MAX_FIELD_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the active provider only when enabling.
|
||||
if incoming.enabled {
|
||||
if incoming.kind.is_webhook() {
|
||||
if incoming.webhook_url.is_empty() {
|
||||
return Err(Error::Invalid(
|
||||
"a webhook URL is required to enable chat notifications".into(),
|
||||
));
|
||||
}
|
||||
if !incoming.webhook_url.starts_with("https://")
|
||||
&& !incoming.webhook_url.starts_with("http://")
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"webhook URL must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if incoming.smtp_host.is_empty() {
|
||||
return Err(Error::Invalid(
|
||||
"SMTP host is required to enable email notifications".into(),
|
||||
));
|
||||
}
|
||||
if incoming.smtp_to.is_empty() {
|
||||
return Err(Error::Invalid(
|
||||
"a recipient (To) is required to enable email notifications".into(),
|
||||
));
|
||||
}
|
||||
if incoming.smtp_port == 0 {
|
||||
return Err(Error::Invalid("SMTP port must be non-zero".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut g = self.inner.write();
|
||||
*g = incoming.clone();
|
||||
}
|
||||
self.persist();
|
||||
tracing::info!(
|
||||
target: "openpxe::notify",
|
||||
enabled = incoming.enabled, kind = ?incoming.kind,
|
||||
"notification configuration updated"
|
||||
);
|
||||
Ok(incoming)
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let snap = self.inner.read().clone();
|
||||
let body = match serde_json::to_vec_pretty(&snap) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn default_disabled_not_usable() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
assert!(!s.snapshot().enabled);
|
||||
assert!(!s.snapshot().is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_requires_url_when_enabled() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
let r = s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Slack,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Slack,
|
||||
webhook_url: "https://hooks.slack.com/services/XXX".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.snapshot().is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_requires_host_and_recipient() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
let r = s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
smtp_port: 587,
|
||||
smtp_to: "[email protected]".into(),
|
||||
smtp_from: "[email protected]".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.snapshot().is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_sentinel_preserves_stored_secret() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
smtp_port: 587,
|
||||
smtp_to: "[email protected]".into(),
|
||||
smtp_password: "s3cret".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
// Redacted snapshot hides the password behind the sentinel.
|
||||
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
|
||||
// PUTting the sentinel back keeps the real password.
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
smtp_port: 587,
|
||||
smtp_to: "[email protected]".into(),
|
||||
smtp_password: SECRET_SENTINEL.into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.snapshot().smtp_password, "s3cret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_scheme_enforced() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
let r = s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Discord,
|
||||
webhook_url: "ftp://example.com/hook".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! 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[..]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user