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]>
363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
//! 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(_))));
|
|
}
|
|
}
|