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:
Miles Ward
2026-05-29 14:34:20 -04:00
co-authored by Claude Opus 4.8
parent 1eb41288c3
commit fc99973ac3
16 changed files with 1817 additions and 61 deletions
+275 -3
View File
@@ -31,8 +31,8 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
MAX_LOGO_BYTES,
ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
@@ -159,9 +159,19 @@ pub fn build_router(state: AppState) -> Router {
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
// limited broadcast + the server's own subnet broadcast.
.route("/api/hosts/:mac/wol", post(api_hosts_wol))
// Rolling "host log" of boot events: what image actually
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
// v0.5.0: notification config (Advanced tab) + a "send test"
// probe. GET redacts the SMTP password.
.route("/api/notify", get(api_notify_get).put(api_notify_put))
.route("/api/notify/test", post(api_notify_test))
// v0.5.0: About-tab update check — queries the Gitea releases
// API and compares against the running version.
.route("/api/updates/check", get(api_updates_check))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
@@ -613,11 +623,22 @@ async fn boot_sub(
.filter(|m| !m.is_empty());
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: mac_normalized,
mac: mac_normalized.clone(),
ip: peer_ip,
target_id: entry.id.clone(),
target_title: format!("{} — {}", iso.filename, entry.title),
});
// v0.5.0: fire-and-forget notification on the
// canonical "a machine is imaging" moment.
let who = mac_normalized
.clone()
.or_else(|| peer_ip.map(|ip| ip.to_string()))
.unwrap_or_else(|| "an unknown client".into());
spawn_notify(
&state,
"PXE boot started",
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
);
return text_plain(render_entry(entry, &settings, base));
}
}
@@ -1247,10 +1268,25 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
{"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol",
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
{"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
],
},
{
"name": "Notifications & updates",
"endpoints": [
{"method": "GET", "path": "/api/notify",
"summary": "Current notification config (SMTP password redacted)."},
{"method": "PUT", "path": "/api/notify",
"summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
{"method": "POST", "path": "/api/notify/test",
"summary": "Send a test notification using the saved config."},
{"method": "GET", "path": "/api/updates/check",
"summary": "Compare the running version against the latest Gitea release."},
],
},
{
"name": "Operator console",
"endpoints": [
@@ -2022,6 +2058,220 @@ async fn api_hosts_remove(
}
}
/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
/// broadcast (255.255.255.255) and the server's own subnet broadcast
/// (computed from the advertised IP + detected mask), which covers the
/// common "same VLAN as OpenPXE" case with zero network config. We only
/// wake MACs that are actually bound — keeps this from being an open
/// "spray packets at any MAC" endpoint.
async fn api_hosts_wol(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
) -> Response {
if state.hosts.lookup(&mac).is_none() {
return (
StatusCode::NOT_FOUND,
"no host binding for that MAC — bind it first",
)
.into_response();
}
// Compute the server's subnet broadcast from the advertised IP +
// detected mask so the packet reaches the right VLAN even if the
// limited broadcast is filtered. Best-effort: skip if either won't
// parse.
let server_ip = state
.public_base_url
.strip_prefix("http://")
.unwrap_or(&state.public_base_url)
.split(':')
.next()
.unwrap_or("")
.parse::<std::net::Ipv4Addr>();
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
let mut broadcasts = Vec::new();
if let (Ok(ip), Ok(m)) = (server_ip, mask) {
broadcasts.push(wol::subnet_broadcast(ip, m));
}
// The send is a blocking std UDP call; push it off the async
// executor.
let mac_owned = mac.clone();
let result =
tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
match result {
Ok(Ok(n)) => {
// Fire-and-forget notification — nice "someone woke a box"
// signal, never blocks the response.
spawn_notify(
&state,
"Wake-on-LAN sent",
&format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
);
Json(json!({ "ok": true, "broadcasts": n })).into_response()
}
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}")).into_response(),
}
}
// ─── Notifications (v0.5.0) ───────────────────────────────────────────────
async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
// Redact the SMTP password before it leaves the process.
Json(state.notify.snapshot().redacted())
}
async fn api_notify_put(
State(state): State<AppState>,
Json(cfg): Json<NotifyConfig>,
) -> Response {
match state.notify.replace(cfg) {
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
/// Send a test notification using the *currently saved* config (not the
/// request body) so the operator validates exactly what's persisted.
async fn api_notify_test(State(state): State<AppState>) -> Response {
let cfg = state.notify.snapshot();
match crate::notify::send(
&cfg,
"OpenPXE test notification",
"If you're reading this, OpenPXE notifications are wired up correctly. \
This is a test from the Advanced settings tab.",
)
.await
{
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
}
}
// ─── Update check (v0.5.0) ────────────────────────────────────────────────
/// Query the project's Gitea releases API for the latest published tag
/// and compare it to the running version. This is the only outbound
/// call OpenPXE makes that isn't operator-initiated data movement, and
/// it's strictly on-demand (the About tab's "Check for updates" button)
/// — never a background poll, keeping the air-gapped promise intact.
async fn api_updates_check() -> Response {
let current = env!("CARGO_PKG_VERSION");
let Some(api) = gitea_releases_api_url() else {
return Json(json!({
"current": current,
"error": "repository URL not configured at build time",
}))
.into_response();
};
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
.build()
{
Ok(c) => c,
Err(e) => {
return Json(json!({ "current": current, "error": format!("client: {e}") }))
.into_response();
}
};
match client.get(&api).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
let latest_tag = body
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let html_url = body
.get("html_url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let update_available = version_is_newer(
latest_tag.trim_start_matches('v'),
current,
);
Json(json!({
"current": current,
"latest": latest_tag,
"update_available": update_available,
"html_url": html_url,
}))
.into_response()
}
Ok(resp) => Json(json!({
"current": current,
"error": format!("releases API returned HTTP {}", resp.status()),
}))
.into_response(),
Err(e) => Json(json!({
"current": current,
"error": format!("could not reach the releases API: {e}"),
}))
.into_response(),
}
}
/// Derive the Gitea `releases/latest` API URL from the compile-time
/// repository URL (`https://host/owner/repo`).
fn gitea_releases_api_url() -> Option<String> {
let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
let rest = repo
.strip_prefix("https://")
.or_else(|| repo.strip_prefix("http://"))?;
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
let host = parts.next()?;
let owner = parts.next()?;
let name = parts.next()?;
if host.is_empty() || owner.is_empty() || name.is_empty() {
return None;
}
Some(format!(
"https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
))
}
/// Compare two dotted numeric versions; `true` when `latest` is strictly
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
/// a garbage tag never falsely reports an update.
fn version_is_newer(latest: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u64> {
v.split('.')
.map(|p| p.chars().take_while(char::is_ascii_digit).collect::<String>())
.map(|s| s.parse::<u64>().unwrap_or(0))
.collect()
}
let (l, c) = (parts(latest), parts(current));
for i in 0..l.len().max(c.len()) {
let lv = l.get(i).copied().unwrap_or(0);
let cv = c.get(i).copied().unwrap_or(0);
if lv != cv {
return lv > cv;
}
}
false
}
/// Fire a notification on a detached task. Never blocks the caller and
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
let cfg = state.notify.snapshot();
if !cfg.is_usable() {
return;
}
let subject = subject.to_string();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
}
});
}
// ─── Boot event log ───────────────────────────────────────────────────────
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
@@ -2081,6 +2331,28 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
mod tests {
use super::*;
#[test]
fn version_newer_detects_updates() {
assert!(version_is_newer("0.5.1", "0.5.0"));
assert!(version_is_newer("0.6.0", "0.5.9"));
assert!(version_is_newer("1.0.0", "0.9.9"));
assert!(!version_is_newer("0.5.0", "0.5.0"));
assert!(!version_is_newer("0.4.69", "0.5.0"));
// A garbage / empty tag must never falsely report an update.
assert!(!version_is_newer("", "0.5.0"));
assert!(!version_is_newer("not-a-version", "0.5.0"));
}
#[test]
fn gitea_api_url_derives_from_repo() {
// CARGO_PKG_REPOSITORY is set from the workspace manifest.
let url = gitea_releases_api_url();
if let Some(u) = url {
assert!(u.contains("/api/v1/repos/"), "got: {u}");
assert!(u.ends_with("/releases/latest"), "got: {u}");
}
}
#[test]
fn range_full() {
let (s, e, p) = parse_range(None, 1000).unwrap();
+12
View File
@@ -303,12 +303,20 @@ pub async fn api_logout(
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
// v0.5.0: include branding bootstrap so the pre-auth login/setup
// screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_logo();
let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() {
return (
StatusCode::OK,
Json(json!({
"setup_required": true,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response();
@@ -323,6 +331,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
@@ -331,6 +341,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
Json(json!({
"setup_required": false,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
+1
View File
@@ -18,6 +18,7 @@ pub mod auth;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
pub mod notify;
pub mod state;
pub mod terminal;
pub mod uploads;
+138
View File
@@ -0,0 +1,138 @@
//! Notification *delivery* — the network half of the notify feature.
//!
//! `openpxe_core::notify` owns the config + persistence; this module
//! turns a `NotifyConfig` + a message into an actual delivery:
//!
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
//! body to the operator's incoming-webhook URL (via `reqwest`).
//! - SMTP → a TLS email via `lettre`.
//!
//! Every send is best-effort and time-bounded: a flaky webhook must
//! never wedge a PXE boot. Callers fire these from a detached task.
use openpxe_core::{NotifyConfig, NotifyKind};
use std::time::Duration;
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
/// task forever.
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
/// Deliver `body` (with an optional `subject`, used as the email
/// subject / chat bold-line) using the active provider in `cfg`.
/// Returns `Ok(())` on success, or a human-readable error suitable for
/// surfacing in the "Send test" response.
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
if !cfg.is_usable() {
return Err("notifications are not enabled / fully configured".into());
}
match cfg.kind {
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
send_webhook(cfg, subject, body).await
}
NotifyKind::Smtp => send_email(cfg, subject, body).await,
}
}
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
// Each chat platform wants a different JSON shape for an incoming
// webhook. Keep the bodies minimal and plain-text-ish so they
// render cleanly everywhere.
let combined = if subject.is_empty() {
body.to_string()
} else {
format!("*{subject}*\n{body}")
};
let payload = match cfg.kind {
NotifyKind::Slack => serde_json::json!({ "text": combined }),
NotifyKind::Discord => serde_json::json!({ "content": combined }),
NotifyKind::Teams => serde_json::json!({
// Legacy MessageCard — the format every Teams "Incoming
// Webhook" connector still accepts.
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
"title": subject,
"text": body,
}),
NotifyKind::Smtp => unreachable!("smtp handled separately"),
};
let client = reqwest::Client::builder()
.timeout(SEND_TIMEOUT)
.build()
.map_err(|e| format!("could not build HTTP client: {e}"))?;
let resp = client
.post(&cfg.webhook_url)
.json(&payload)
.send()
.await
.map_err(|e| format!("webhook POST failed: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let snippet = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
Err(format!("webhook returned HTTP {status}: {snippet}"))
}
}
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from = if cfg.smtp_from.trim().is_empty() {
cfg.smtp_username.trim()
} else {
cfg.smtp_from.trim()
};
if from.is_empty() {
return Err("SMTP requires a From address (or a username to fall back to)".into());
}
let email = Message::builder()
.from(
from.parse()
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
)
.to(cfg
.smtp_to
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
let mut builder = if cfg.smtp_implicit_tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
}
.port(cfg.smtp_port)
.timeout(Some(SEND_TIMEOUT));
// Auth is optional — some internal relays accept unauthenticated
// mail from trusted hosts. Only attach credentials when a username
// is set.
if !cfg.smtp_username.trim().is_empty() {
builder = builder.credentials(Credentials::new(
cfg.smtp_username.trim().to_string(),
cfg.smtp_password.clone(),
));
}
let mailer = builder.build();
mailer
.send(email)
.await
.map(|_| ())
.map_err(|e| format!("SMTP send failed: {e}"))
}
+5 -1
View File
@@ -2,7 +2,7 @@ use crate::uploads::UploadSessions;
use crate::auth::SessionStore;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore,
Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use std::sync::Arc;
@@ -37,6 +37,10 @@ pub struct AppState {
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
/// flow ships in a later release.
pub sso: SsoStore,
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
/// SMTP). Drives the fire-and-forget pings on boot events and powers
/// the Advanced tab's config + "Send test" button.
pub notify: NotifyStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,