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 f6eddd59f8
commit 3cb651be65
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();