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]>
139 lines
5.0 KiB
Rust
139 lines
5.0 KiB
Rust
//! 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}"))
|
|
}
|