//! 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 `/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, inner: Arc>, } 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::(&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 { 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: "ops@example.com".into(), smtp_from: "openpxe@example.com".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: "ops@example.com".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: "ops@example.com".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(_)))); } }