From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 49C5891C38 for ; Mon, 27 Mar 2023 17:19:52 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id D28EAD3FC for ; Mon, 27 Mar 2023 17:19:24 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Mon, 27 Mar 2023 17:19:21 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 8E61846FFC for ; Mon, 27 Mar 2023 17:19:21 +0200 (CEST) From: Lukas Wagner To: pve-devel@lists.proxmox.com Date: Mon, 27 Mar 2023 17:18:41 +0200 Message-Id: <20230327151857.495565-3-l.wagner@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20230327151857.495565-1-l.wagner@proxmox.com> References: <20230327151857.495565-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.175 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH proxmox 02/18] notification: implement sendmail endpoint X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Mon, 27 Mar 2023 15:19:52 -0000 Add everything needed for a simple endpoint of type 'sendmail', which uses the 'sendmail' binary on the system to send emails to users. The current implementation makes it easy to implement other notification providers with minimal changes to the code. All one has to do is to implement the 'Endpoint' trait and register the plugin in the configuration parser. A notification contains the following data: title, body, severity, and a map of arbitrary metadata fields. The metadata fields will later be useful for filtering notifications. I chose the 'dynamic' metadata map approach over static fields to be more flexible: This allows us to have different kind of metadata for different products, without the proxmox-notification crate being aware of this. 'Type safety' for metadata properties can be enforced in the glue code for any given product. In terms of configuration: The configuration for notification endpoints and filters will live in a new configuration file, `notifications.cfg`. However, since the crate should be product-agnostic, we process the configuration as a raw string. Loading/storing of the configuration file will happen in product-specific code. Signed-off-by: Lukas Wagner --- Cargo.toml | 1 + proxmox-notification/Cargo.toml | 12 ++ proxmox-notification/src/config.rs | 53 ++++++ proxmox-notification/src/endpoints/mod.rs | 1 + .../src/endpoints/sendmail.rs | 71 +++++++ proxmox-notification/src/lib.rs | 178 ++++++++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 proxmox-notification/src/config.rs create mode 100644 proxmox-notification/src/endpoints/mod.rs create mode 100644 proxmox-notification/src/endpoints/sendmail.rs diff --git a/Cargo.toml b/Cargo.toml index 9ccfa1a..d167c73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ proxmox-lang = { version = "1.1", path = "proxmox-lang" } proxmox-rest-server = { version = "0.3.0", path = "proxmox-rest-server" } proxmox-router = { version = "1.3.1", path = "proxmox-router" } proxmox-schema = { version = "1.3.7", path = "proxmox-schema" } +proxmox-section-config = { version = "1.0.2", path = "proxmox-section-config" } proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] } proxmox-sortable-macro = { version = "0.1.2", path = "proxmox-sortable-macro" } proxmox-sys = { version = "0.4.2", path = "proxmox-sys" } diff --git a/proxmox-notification/Cargo.toml b/proxmox-notification/Cargo.toml index 47c85a3..0286c8f 100644 --- a/proxmox-notification/Cargo.toml +++ b/proxmox-notification/Cargo.toml @@ -8,3 +8,15 @@ repository.workspace = true exclude.workspace = true [dependencies] +anyhow.workspace = true +lazy_static.workspace = true +log.workspace = true +openssl.workspace = true +proxmox-http = { workspace = true, features = ["client-sync"]} +proxmox-schema = { workspace = true, features = ["api-macro"]} +proxmox-section-config = { workspace = true } +proxmox-sys.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +handlebars.workspace = true diff --git a/proxmox-notification/src/config.rs b/proxmox-notification/src/config.rs new file mode 100644 index 0000000..58c79d4 --- /dev/null +++ b/proxmox-notification/src/config.rs @@ -0,0 +1,53 @@ +use anyhow::Error; +use lazy_static::lazy_static; +use proxmox_schema::{const_regex, ApiStringFormat, ApiType, ObjectSchema, Schema, StringSchema}; +use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}; + +use crate::endpoints::sendmail::SendmailConfig; +use crate::endpoints::sendmail::SENDMAIL_TYPENAME; + +// Copied from PBS +#[rustfmt::skip] +#[macro_export] +macro_rules! PROXMOX_SAFE_ID_REGEX_STR { () => { r"(?:[A-Za-z0-9_][A-Za-z0-9._\-]*)" }; } + +const_regex! { + + pub PROXMOX_SAFE_ID_REGEX = concat!(r"^", PROXMOX_SAFE_ID_REGEX_STR!(), r"$"); +} +pub const PROXMOX_SAFE_ID_FORMAT: ApiStringFormat = + ApiStringFormat::Pattern(&PROXMOX_SAFE_ID_REGEX); + +pub const BACKEND_NAME_SCHEMA: Schema = StringSchema::new("Notification backend name.") + .format(&PROXMOX_SAFE_ID_FORMAT) + .min_length(3) + .max_length(32) + .schema(); + +lazy_static! { + pub static ref CONFIG: SectionConfig = init(); +} + +fn init() -> SectionConfig { + let mut config = SectionConfig::new(&BACKEND_NAME_SCHEMA); + + const SENDMAIL_SCHEMA: &ObjectSchema = SendmailConfig::API_SCHEMA.unwrap_object_schema(); + + config.register_plugin(SectionConfigPlugin::new( + SENDMAIL_TYPENAME.to_string(), + Some(String::from("name")), + SENDMAIL_SCHEMA, + )); + + config +} + +pub fn config(raw_config: &str) -> Result<(SectionConfigData, [u8; 32]), Error> { + let digest = openssl::sha::sha256(raw_config.as_bytes()); + let data = CONFIG.parse("notifications.cfg", raw_config)?; + Ok((data, digest)) +} + +pub fn write(config: &SectionConfigData) -> Result { + CONFIG.write("notifications.cfg", config) +} diff --git a/proxmox-notification/src/endpoints/mod.rs b/proxmox-notification/src/endpoints/mod.rs new file mode 100644 index 0000000..2d7b9ba --- /dev/null +++ b/proxmox-notification/src/endpoints/mod.rs @@ -0,0 +1 @@ +pub(crate) mod sendmail; diff --git a/proxmox-notification/src/endpoints/sendmail.rs b/proxmox-notification/src/endpoints/sendmail.rs new file mode 100644 index 0000000..2c43ab1 --- /dev/null +++ b/proxmox-notification/src/endpoints/sendmail.rs @@ -0,0 +1,71 @@ +use crate::{Endpoint, Notification}; +use anyhow::Error; + +use proxmox_schema::{api, const_regex, ApiStringFormat, Schema, StringSchema, Updater}; +use serde::{Deserialize, Serialize}; + +// Copied from PBS +const_regex! { + pub SINGLE_LINE_COMMENT_REGEX = r"^[[:^cntrl:]]*$"; +} +const SINGLE_LINE_COMMENT_FORMAT: ApiStringFormat = + ApiStringFormat::Pattern(&SINGLE_LINE_COMMENT_REGEX); +const EMAIL_SCHEMA: Schema = StringSchema::new("E-Mail Address.") + .format(&SINGLE_LINE_COMMENT_FORMAT) + .min_length(2) + .max_length(64) + .schema(); + +pub(crate) const SENDMAIL_TYPENAME: &str = "sendmail"; + +#[api( + properties: { + recipient: { + type: Array, + items: { + schema: EMAIL_SCHEMA, + }, + }, + }, +)] +#[derive(Debug, Serialize, Deserialize, Updater)] +#[serde(rename_all = "kebab-case")] +/// Config for Sendmail notification endpoints +pub struct SendmailConfig { + /// Name of the endpoint + pub name: String, + /// Mail recipients + pub recipient: Vec, + /// `From` address for the mail + #[serde(skip_serializing_if = "Option::is_none")] + pub from_address: Option, + /// Author of the mail + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, +} + +impl Endpoint for SendmailConfig { + fn send(&self, notification: &Notification) -> Result<(), Error> { + let recipients: Vec<&str> = self.recipient.iter().map(String::as_str).collect(); + + // Note: OX has serious problems displaying text mails, + // so we include html as well + let html = format!( + "
\n{}\n
",
+            handlebars::html_escape(¬ification.body)
+        );
+
+        proxmox_sys::email::sendmail(
+            &recipients,
+            ¬ification.title,
+            Some(¬ification.body),
+            Some(&html),
+            self.from_address.as_deref(),
+            self.author.as_deref(),
+        )
+    }
+
+    fn name(&self) -> &str {
+        &self.name
+    }
+}
diff --git a/proxmox-notification/src/lib.rs b/proxmox-notification/src/lib.rs
index e69de29..f076c88 100644
--- a/proxmox-notification/src/lib.rs
+++ b/proxmox-notification/src/lib.rs
@@ -0,0 +1,178 @@
+use std::collections::HashMap;
+
+use anyhow::Error;
+
+use endpoints::sendmail::SendmailConfig;
+use endpoints::sendmail::SENDMAIL_TYPENAME;
+use proxmox_schema::api;
+use proxmox_section_config::SectionConfigData;
+use serde::{Deserialize, Serialize};
+
+mod config;
+mod endpoints;
+
+#[api()]
+#[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
+#[serde(rename_all = "kebab-case")]
+/// Severity of a notification
+pub enum Severity {
+    /// General information
+    Info,
+    /// A noteworthy event
+    Notice,
+    /// Warning
+    Warning,
+    /// Error
+    Error,
+}
+
+/// Notification endpoint trait, implemented by all endpoint plugins
+pub trait Endpoint {
+    /// Send a documention
+    fn send(&self, notification: &Notification) -> Result<(), Error>;
+
+    /// The name/identifier for this endpoint
+    fn name(&self) -> &str;
+}
+
+#[derive(Debug, Clone)]
+/// A sendable notifiction
+pub struct Notification {
+    /// The title of the notification
+    pub title: String,
+    /// Notification text
+    pub body: String,
+    /// Notification severity
+    pub severity: Severity,
+    /// Additional metadata for the notification
+    pub properties: HashMap,
+}
+
+/// Notification configuration
+pub struct Config(SectionConfigData);
+
+impl Clone for Config {
+    fn clone(&self) -> Self {
+        Self(SectionConfigData {
+            sections: self.0.sections.clone(),
+            order: self.0.order.clone(),
+        })
+    }
+}
+
+impl Config {
+    /// Parse raw config
+    pub fn new(raw_config: &str) -> Result {
+        // TODO: save and compare digest? Do we need this here?
+        let (config, _digest) = crate::config::config(raw_config)?;
+
+        Ok(Self(config))
+    }
+
+    /// Serialize config
+    pub fn write(&self) -> Result {
+        config::write(&self.0)
+    }
+
+    /// 'Instantiate' all notification endpoints from their configuration
+    pub fn instantiate(&self) -> Result {
+        let mut endpoints = Vec::new();
+
+        let sendmail_endpoints: Vec =
+            self.0.convert_to_typed_array(SENDMAIL_TYPENAME)?;
+
+        endpoints.extend(
+            sendmail_endpoints
+                .into_iter()
+                .map(Box::new)
+                .map(|e| e as Box),
+        );
+
+        Ok(Bus { endpoints })
+    }
+}
+
+/// Notification bus - distributes notifications to all registered endpoints
+// The reason for the split between `Config` and this struct is to make testing with mocked
+// endpoints a bit easier.
+#[derive(Default)]
+pub struct Bus {
+    endpoints: Vec>,
+}
+
+impl Bus {
+    pub fn add_endpoint(&mut self, endpoint: Box) {
+        self.endpoints.push(endpoint);
+    }
+
+    /// Send a notification to all registered endpoints
+    pub fn send(&self, notification: &Notification) -> Result<(), Error> {
+        log::info!(
+            "sending notification with title '{title}'",
+            title = notification.title
+        );
+
+        for endpoint in &self.endpoints {
+            endpoint.send(notification).unwrap_or_else(|e| {
+                log::error!(
+                    "could not notfiy via endpoint `{name}`: {e}",
+                    name = endpoint.name()
+                )
+            })
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{cell::RefCell, rc::Rc};
+
+    use anyhow::Error;
+
+    use super::*;
+
+    #[derive(Default, Clone)]
+    struct MockEndpoint {
+        messages: Rc>>,
+    }
+
+    impl Endpoint for MockEndpoint {
+        fn send(&self, message: &Notification) -> Result<(), Error> {
+            self.messages.borrow_mut().push(message.clone());
+
+            Ok(())
+        }
+
+        fn name(&self) -> &str {
+            "mock-endpoint"
+        }
+    }
+
+    impl MockEndpoint {
+        fn messages(&self) -> Vec {
+            self.messages.borrow().clone()
+        }
+    }
+
+    #[test]
+    fn test_add_mock_endpoint() -> Result<(), Error> {
+        let mock = MockEndpoint::default();
+
+        let mut bus = Bus::default();
+
+        bus.add_endpoint(Box::new(mock.clone()));
+
+        bus.send(&Notification {
+            title: "Title".into(),
+            body: "Body".into(),
+            severity: Severity::Info,
+            properties: Default::default(),
+        })?;
+        let messages = mock.messages();
+        assert_eq!(messages.len(), 1);
+
+        Ok(())
+    }
+}
-- 
2.30.2