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
+275
-3
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user