From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id F19B61FF13B for ; Wed, 08 Apr 2026 11:46:28 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 95CE16EB8; Wed, 8 Apr 2026 11:47:04 +0200 (CEST) From: Arthur Bied-Charreton To: pdm-devel@lists.proxmox.com Subject: [proxmox-datacenter-manager] Add notifications backend Date: Wed, 8 Apr 2026 11:40:21 +0200 Message-ID: <20260408094625.726246-1-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.091 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 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: TB3NOVG62CAC45YVEU6RY4T4SD4ZNHMO X-Message-ID-Hash: TB3NOVG62CAC45YVEU6RY4T4SD4ZNHMO X-MailFrom: abied-charreton@jett.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Ideally, the whole router should be in proxmox-notify. However, after discussing this quite extensively off-list with Lukas, we came to the conclusion that doing so would require a large refactor that is especially tricky given the fact that PVE would still need a different entry point into proxmox-notify than PBS and PDM, since it defines its router on the Perl side. For this reason, factoring the routing logic out into proxmox-notify is offset to a future where PVE also has a Rust-based router, and for now it is just copied over from PBS (commit: fbf8d1b2) with the adaptations described below. The `proxmox_notify::context::Context` trait implementation is added to PDM directly instead of creating a pdm.rs module in proxmox-notify to avoid adding even more product-specific logic to it. It uses `proxmox-access-control`, which is pulled in by PDM anyway, for getting the user config instead of reading the file directly. The `matcher-fields` and `matcher-field-values` endpoints currently return empty vecs, they will be updated in a future commit introducing actual notifications. The same applies for the notification templates. The endpoints are made unprotected and the configuration files read/writable by `www-data`, like for `remotes.cfg`. Signed-off-by: Arthur Bied-Charreton --- Cargo.toml | 1 + Makefile | 3 +- debian/proxmox-datacenter-manager.install | 2 + defines.mk | 1 + lib/pdm-config/Cargo.toml | 1 + lib/pdm-config/src/lib.rs | 1 + lib/pdm-config/src/notifications.rs | 39 ++++ server/Cargo.toml | 1 + server/src/api/config/mod.rs | 2 + server/src/api/config/notifications/gotify.rs | 185 +++++++++++++++++ .../src/api/config/notifications/matchers.rs | 165 ++++++++++++++++ server/src/api/config/notifications/mod.rs | 102 ++++++++++ .../src/api/config/notifications/sendmail.rs | 173 ++++++++++++++++ server/src/api/config/notifications/smtp.rs | 186 ++++++++++++++++++ .../src/api/config/notifications/targets.rs | 61 ++++++ .../src/api/config/notifications/webhook.rs | 170 ++++++++++++++++ server/src/context.rs | 2 + server/src/lib.rs | 1 + server/src/notifications/mod.rs | 115 +++++++++++ templates/Makefile | 14 ++ templates/default/test-body.txt.hbs | 1 + templates/default/test-subject.txt.hbs | 1 + 22 files changed, 1226 insertions(+), 1 deletion(-) create mode 100644 lib/pdm-config/src/notifications.rs create mode 100644 server/src/api/config/notifications/gotify.rs create mode 100644 server/src/api/config/notifications/matchers.rs create mode 100644 server/src/api/config/notifications/mod.rs create mode 100644 server/src/api/config/notifications/sendmail.rs create mode 100644 server/src/api/config/notifications/smtp.rs create mode 100644 server/src/api/config/notifications/targets.rs create mode 100644 server/src/api/config/notifications/webhook.rs create mode 100644 server/src/notifications/mod.rs create mode 100644 templates/Makefile create mode 100644 templates/default/test-body.txt.hbs create mode 100644 templates/default/test-subject.txt.hbs diff --git a/Cargo.toml b/Cargo.toml index ec2aa3d..918e831 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ proxmox-ldap = { version = "1.1", features = ["sync"] } proxmox-lang = "1.1" proxmox-log = "1" proxmox-login = "1.0.2" +proxmox-notify = "1" proxmox-rest-server = "1" # some use "cli", some use "cli" and "server", pbs-config uses nothing proxmox-router = { version = "3.0.0", default-features = false } diff --git a/Makefile b/Makefile index 8e1cd13..48327c9 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,7 @@ install: $(COMPILED_BINS) $(SHELL_COMPLETION_FILES) install -m644 $(COMPLETION_DIR)/$(i) $(DESTDIR)$(ZSHCOMPDIR)/ ;) make -C services install $(MAKE) -C docs install + $(MAKE) -C templates install $(COMPILED_BINS) $(COMPILEDIR)/docgen &: $(CARGO) build $(CARGO_BUILD_ARGS) @@ -99,7 +100,7 @@ cargo-build: $(BUILDDIR): rm -rf $@ $@.tmp mkdir $@.tmp - cp -a debian/ server/ services/ cli/ lib/ docs/ ui/ defines.mk Makefile Cargo.toml $@.tmp + cp -a debian/ server/ services/ cli/ lib/ docs/ ui/ templates/ defines.mk Makefile Cargo.toml $@.tmp echo "git clone git://git.proxmox.com/git/$(PACKAGE).git\\ngit checkout $$(git rev-parse HEAD)" \ > $@.tmp/debian/SOURCE mv $@.tmp $@ diff --git a/debian/proxmox-datacenter-manager.install b/debian/proxmox-datacenter-manager.install index fad3f4a..fb2b1f1 100644 --- a/debian/proxmox-datacenter-manager.install +++ b/debian/proxmox-datacenter-manager.install @@ -20,3 +20,5 @@ usr/share/man/man5/remotes.cfg.5 usr/share/man/man5/views.cfg.5 usr/share/zsh/vendor-completions/_pdmAtoB usr/share/zsh/vendor-completions/_proxmox-datacenter-manager-admin +usr/share/proxmox-datacenter-manager/templates/default/test-body.txt.hbs +usr/share/proxmox-datacenter-manager/templates/default/test-subject.txt.hbs diff --git a/defines.mk b/defines.mk index 1b9c1a8..111923f 100644 --- a/defines.mk +++ b/defines.mk @@ -3,6 +3,7 @@ BINDIR = $(PREFIX)/bin SBINDIR = $(PREFIX)/sbin LIBDIR = $(PREFIX)/lib LIBEXECDIR = $(PREFIX)/libexec +DATAROOTDIR = $(PREFIX)/share BASHCOMPDIR = $(PREFIX)/share/bash-completion/completions ZSHCOMPDIR = $(PREFIX)/share/zsh/vendor-completions MAN1DIR = $(PREFIX)/share/man/man1 diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml index d39c2ad..b6f3739 100644 --- a/lib/pdm-config/Cargo.toml +++ b/lib/pdm-config/Cargo.toml @@ -23,5 +23,6 @@ proxmox-shared-memory.workspace = true proxmox-simple-config.workspace = true proxmox-sys = { workspace = true, features = [ "acl", "crypt", "timer" ] } proxmox-acme-api.workspace = true +proxmox-notify.workspace = true pdm-api-types.workspace = true pdm-buildcfg.workspace = true diff --git a/lib/pdm-config/src/lib.rs b/lib/pdm-config/src/lib.rs index 4c49054..03dc247 100644 --- a/lib/pdm-config/src/lib.rs +++ b/lib/pdm-config/src/lib.rs @@ -5,6 +5,7 @@ pub use pdm_buildcfg::{BACKUP_GROUP_NAME, BACKUP_USER_NAME}; pub mod certificate_config; pub mod domains; pub mod node; +pub mod notifications; pub mod remotes; pub mod setup; pub mod views; diff --git a/lib/pdm-config/src/notifications.rs b/lib/pdm-config/src/notifications.rs new file mode 100644 index 0000000..fd6ec79 --- /dev/null +++ b/lib/pdm-config/src/notifications.rs @@ -0,0 +1,39 @@ +use anyhow::Error; + +use proxmox_notify::Config; + +use pdm_buildcfg::configdir; +use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard}; +use proxmox_sys::fs::file_read_optional_string; + +/// Configuration file location for notification targets/matchers. +pub const NOTIFICATION_CONFIG_PATH: &str = configdir!("/notifications.cfg"); + +/// Private configuration file location for secrets - only readable by `root`. +pub const NOTIFICATION_PRIV_CONFIG_PATH: &str = configdir!("/notifications-priv.cfg"); + +/// Lockfile to prevent concurrent write access. +pub const NOTIFICATION_LOCK_FILE: &str = configdir!("/.notifications.lck"); + +/// Get exclusive lock for `notifications.cfg`. +pub fn lock_config() -> Result { + open_api_lockfile(NOTIFICATION_LOCK_FILE, None, true) +} + +/// Load notification config. +pub fn config() -> Result { + let content = file_read_optional_string(NOTIFICATION_CONFIG_PATH)?.unwrap_or_default(); + + let priv_content = + file_read_optional_string(NOTIFICATION_PRIV_CONFIG_PATH)?.unwrap_or_default(); + + Ok(Config::new(&content, &priv_content)?) +} + +/// Save notification config. +pub fn save_config(config: Config) -> Result<(), Error> { + let (cfg, priv_cfg) = config.write()?; + replace_config(NOTIFICATION_CONFIG_PATH, cfg.as_bytes())?; + replace_config(NOTIFICATION_PRIV_CONFIG_PATH, priv_cfg.as_bytes())?; + Ok(()) +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 6969549..a4f7bbd 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -46,6 +46,7 @@ proxmox-lang.workspace = true proxmox-ldap.workspace = true proxmox-log.workspace = true proxmox-login.workspace = true +proxmox-notify.workspace = true proxmox-openid.workspace = true proxmox-rest-server = { workspace = true, features = [ "templates" ] } proxmox-router = { workspace = true, features = [ "cli", "server"] } diff --git a/server/src/api/config/mod.rs b/server/src/api/config/mod.rs index 8f646c1..a465219 100644 --- a/server/src/api/config/mod.rs +++ b/server/src/api/config/mod.rs @@ -6,6 +6,7 @@ pub mod access; pub mod acme; pub mod certificate; pub mod notes; +pub mod notifications; pub mod views; #[sortable] @@ -14,6 +15,7 @@ const SUBDIRS: SubdirMap = &sorted!([ ("acme", &acme::ROUTER), ("certificate", &certificate::ROUTER), ("notes", ¬es::ROUTER), + ("notifications", ¬ifications::ROUTER), ("views", &views::ROUTER) ]); diff --git a/server/src/api/config/notifications/gotify.rs b/server/src/api/config/notifications/gotify.rs new file mode 100644 index 0000000..a281245 --- /dev/null +++ b/server/src/api/config/notifications/gotify.rs @@ -0,0 +1,185 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox_notify::endpoints::gotify::{ + DeleteableGotifyProperty, GotifyConfig, GotifyConfigUpdater, GotifyPrivateConfig, + GotifyPrivateConfigUpdater, +}; +use proxmox_notify::schema::ENTITY_NAME_SCHEMA; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pbs_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of gotify endpoints.", + type: Array, + items: { type: GotifyConfig }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all gotify endpoints. +pub fn list_endpoints( + _param: Value, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let config = pdm_config::notifications::config()?; + + let endpoints = proxmox_notify::api::gotify::get_endpoints(&config)?; + + Ok(endpoints) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + returns: { type: GotifyConfig }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// Get a gotify endpoint. +pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result { + let config = pdm_config::notifications::config()?; + let endpoint = proxmox_notify::api::gotify::get_endpoint(&config, &name)?; + + rpcenv["digest"] = hex::encode(config.digest()).into(); + + Ok(endpoint) +} + +#[api( + input: { + properties: { + endpoint: { + type: GotifyConfig, + flatten: true, + }, + token: { + description: "Authentication token", + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Add a new gotify endpoint. +pub fn add_endpoint( + endpoint: GotifyConfig, + token: String, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let private_endpoint_config = GotifyPrivateConfig { + name: endpoint.name.clone(), + token, + }; + + proxmox_notify::api::gotify::add_endpoint(&mut config, endpoint, private_endpoint_config)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + }, + updater: { + type: GotifyConfigUpdater, + flatten: true, + }, + token: { + description: "Authentication token", + optional: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeleteableGotifyProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Update gotify endpoint. +pub fn update_endpoint( + name: String, + updater: GotifyConfigUpdater, + token: Option, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let digest = digest.map(hex::decode).transpose()?; + + proxmox_notify::api::gotify::update_endpoint( + &mut config, + &name, + updater, + GotifyPrivateConfigUpdater { token }, + delete.as_deref(), + digest.as_deref(), + )?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Delete gotify endpoint. +pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + proxmox_notify::api::gotify::delete_gotify_endpoint(&mut config, &name)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_GET_ENDPOINT) + .put(&API_METHOD_UPDATE_ENDPOINT) + .delete(&API_METHOD_DELETE_ENDPOINT); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_ENDPOINTS) + .post(&API_METHOD_ADD_ENDPOINT) + .match_all("name", &ITEM_ROUTER); diff --git a/server/src/api/config/notifications/matchers.rs b/server/src/api/config/notifications/matchers.rs new file mode 100644 index 0000000..9918305 --- /dev/null +++ b/server/src/api/config/notifications/matchers.rs @@ -0,0 +1,165 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox_notify::matcher::{DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater}; +use proxmox_notify::schema::ENTITY_NAME_SCHEMA; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of matchers.", + type: Array, + items: { type: MatcherConfig }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all notification matchers. +pub fn list_matchers( + _param: Value, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let config = pdm_config::notifications::config()?; + + let matchers = proxmox_notify::api::matcher::get_matchers(&config)?; + + Ok(matchers) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + returns: { type: MatcherConfig }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// Get a notification matcher. +pub fn get_matcher(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result { + let config = pdm_config::notifications::config()?; + let matcher = proxmox_notify::api::matcher::get_matcher(&config, &name)?; + + rpcenv["digest"] = hex::encode(config.digest()).into(); + + Ok(matcher) +} + +#[api( + input: { + properties: { + matcher: { + type: MatcherConfig, + flatten: true, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Add a new notification matcher. +pub fn add_matcher(matcher: MatcherConfig, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + + proxmox_notify::api::matcher::add_matcher(&mut config, matcher)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + }, + updater: { + type: MatcherConfigUpdater, + flatten: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeleteableMatcherProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Update notification matcher. +pub fn update_matcher( + name: String, + updater: MatcherConfigUpdater, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let digest = digest.map(hex::decode).transpose()?; + + proxmox_notify::api::matcher::update_matcher( + &mut config, + &name, + updater, + delete.as_deref(), + digest.as_deref(), + )?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Delete notification matcher. +pub fn delete_matcher(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + proxmox_notify::api::matcher::delete_matcher(&mut config, &name)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_GET_MATCHER) + .put(&API_METHOD_UPDATE_MATCHER) + .delete(&API_METHOD_DELETE_MATCHER); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_MATCHERS) + .post(&API_METHOD_ADD_MATCHER) + .match_all("name", &ITEM_ROUTER); diff --git a/server/src/api/config/notifications/mod.rs b/server/src/api/config/notifications/mod.rs new file mode 100644 index 0000000..5730116 --- /dev/null +++ b/server/src/api/config/notifications/mod.rs @@ -0,0 +1,102 @@ +use anyhow::Error; +use pdm_api_types::PRIV_SYS_AUDIT; +use proxmox_router::{ + list_subdirs_api_method, ApiMethod, Permission, Router, RpcEnvironment, SubdirMap, +}; +use proxmox_schema::api; +use proxmox_sortable_macro::sortable; +use serde::Serialize; +use serde_json::Value; + +pub mod gotify; +pub mod matchers; +pub mod sendmail; +pub mod smtp; +pub mod targets; +pub mod webhook; + +#[sortable] +const SUBDIRS: SubdirMap = &sorted!([ + ("endpoints", &ENDPOINT_ROUTER), + ("matcher-fields", &FIELD_ROUTER), + ("matcher-field-values", &VALUE_ROUTER), + ("targets", &targets::ROUTER), + ("matchers", &matchers::ROUTER), +]); + +pub const ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(SUBDIRS)) + .subdirs(SUBDIRS); + +#[sortable] +const ENDPOINT_SUBDIRS: SubdirMap = &sorted!([ + ("gotify", &gotify::ROUTER), + ("sendmail", &sendmail::ROUTER), + ("smtp", &smtp::ROUTER), + ("webhook", &webhook::ROUTER), +]); + +const ENDPOINT_ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(ENDPOINT_SUBDIRS)) + .subdirs(ENDPOINT_SUBDIRS); + +const FIELD_ROUTER: Router = Router::new().get(&API_METHOD_GET_FIELDS); +const VALUE_ROUTER: Router = Router::new().get(&API_METHOD_GET_VALUES); + +#[api] +#[derive(Serialize)] +/// A matchable field +pub struct MatchableField { + /// Name of the field + name: String, +} + +#[api] +#[derive(Serialize)] +/// A matchable metadata field value +pub struct MatchableValue { + /// Field this value belongs to. + field: String, + /// Notification metadata value known by the system. + value: String, + /// Additional comment for this value. + comment: Option, +} + +#[api( + input: { + properties: {} + }, + returns: { + description: "List of known metadata fields.", + type: Array, + items: {type: MatchableField}, + }, + access: {permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false)}, +)] +/// Get all known metadata fields. +pub fn get_fields() -> Result, Error> { + Ok(vec![]) +} + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of known metadata field values.", + type: Array, + items: { type: MatchableValue }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all known, matchable metadata field values +pub fn get_values( + _param: Value, + _info: &ApiMethod, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + Ok(vec![]) +} diff --git a/server/src/api/config/notifications/sendmail.rs b/server/src/api/config/notifications/sendmail.rs new file mode 100644 index 0000000..66c9656 --- /dev/null +++ b/server/src/api/config/notifications/sendmail.rs @@ -0,0 +1,173 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox_notify::endpoints::sendmail::{ + DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater, +}; +use proxmox_notify::schema::ENTITY_NAME_SCHEMA; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of sendmail endpoints.", + type: Array, + items: { type: SendmailConfig }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all sendmail endpoints. +pub fn list_endpoints( + _param: Value, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let config = pdm_config::notifications::config()?; + + let endpoints = proxmox_notify::api::sendmail::get_endpoints(&config)?; + + Ok(endpoints) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + returns: { type: SendmailConfig }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// Get a sendmail endpoint. +pub fn get_endpoint( + name: String, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let config = pdm_config::notifications::config()?; + let endpoint = proxmox_notify::api::sendmail::get_endpoint(&config, &name)?; + + rpcenv["digest"] = hex::encode(config.digest()).into(); + + Ok(endpoint) +} + +#[api( + input: { + properties: { + endpoint: { + type: SendmailConfig, + flatten: true, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Add a new sendmail endpoint. +pub fn add_endpoint( + endpoint: SendmailConfig, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + + proxmox_notify::api::sendmail::add_endpoint(&mut config, endpoint)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + }, + updater: { + type: SendmailConfigUpdater, + flatten: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeleteableSendmailProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Update sendmail endpoint. +pub fn update_endpoint( + name: String, + updater: SendmailConfigUpdater, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let digest = digest.map(hex::decode).transpose()?; + + proxmox_notify::api::sendmail::update_endpoint( + &mut config, + &name, + updater, + delete.as_deref(), + digest.as_deref(), + )?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Delete sendmail endpoint. +pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + proxmox_notify::api::sendmail::delete_endpoint(&mut config, &name)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_GET_ENDPOINT) + .put(&API_METHOD_UPDATE_ENDPOINT) + .delete(&API_METHOD_DELETE_ENDPOINT); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_ENDPOINTS) + .post(&API_METHOD_ADD_ENDPOINT) + .match_all("name", &ITEM_ROUTER); diff --git a/server/src/api/config/notifications/smtp.rs b/server/src/api/config/notifications/smtp.rs new file mode 100644 index 0000000..4a48256 --- /dev/null +++ b/server/src/api/config/notifications/smtp.rs @@ -0,0 +1,186 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox_notify::endpoints::smtp::{ + DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig, + SmtpPrivateConfigUpdater, +}; +use proxmox_notify::schema::ENTITY_NAME_SCHEMA; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of smtp endpoints.", + type: Array, + items: { type: SmtpConfig }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all smtp endpoints. +pub fn list_endpoints( + _param: Value, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let config = pdm_config::notifications::config()?; + + let endpoints = proxmox_notify::api::smtp::get_endpoints(&config)?; + + Ok(endpoints) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + returns: { type: SmtpConfig }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// Get a smtp endpoint. +pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result { + let config = pdm_config::notifications::config()?; + let endpoint = proxmox_notify::api::smtp::get_endpoint(&config, &name)?; + + rpcenv["digest"] = hex::encode(config.digest()).into(); + + Ok(endpoint) +} + +#[api( + input: { + properties: { + endpoint: { + type: SmtpConfig, + flatten: true, + }, + password: { + optional: true, + description: "SMTP authentication password" + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Add a new smtp endpoint. +pub fn add_endpoint( + endpoint: SmtpConfig, + password: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let private_endpoint_config = SmtpPrivateConfig { + name: endpoint.name.clone(), + password, + }; + + proxmox_notify::api::smtp::add_endpoint(&mut config, endpoint, private_endpoint_config)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + }, + updater: { + type: SmtpConfigUpdater, + flatten: true, + }, + password: { + description: "SMTP authentication password", + optional: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeleteableSmtpProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Update smtp endpoint. +pub fn update_endpoint( + name: String, + updater: SmtpConfigUpdater, + password: Option, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let digest = digest.map(hex::decode).transpose()?; + + proxmox_notify::api::smtp::update_endpoint( + &mut config, + &name, + updater, + SmtpPrivateConfigUpdater { password }, + delete.as_deref(), + digest.as_deref(), + )?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Delete smtp endpoint. +pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + proxmox_notify::api::smtp::delete_endpoint(&mut config, &name)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_GET_ENDPOINT) + .put(&API_METHOD_UPDATE_ENDPOINT) + .delete(&API_METHOD_DELETE_ENDPOINT); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_ENDPOINTS) + .post(&API_METHOD_ADD_ENDPOINT) + .match_all("name", &ITEM_ROUTER); diff --git a/server/src/api/config/notifications/targets.rs b/server/src/api/config/notifications/targets.rs new file mode 100644 index 0000000..b2655fb --- /dev/null +++ b/server/src/api/config/notifications/targets.rs @@ -0,0 +1,61 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox_notify::api::Target; +use proxmox_notify::schema::ENTITY_NAME_SCHEMA; +use proxmox_router::{list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap}; +use proxmox_schema::api; +use proxmox_sortable_macro::sortable; + +use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of all entities which can be used as notification targets.", + type: Array, + items: { type: Target }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all notification targets. +pub fn list_targets(_param: Value, _rpcenv: &mut dyn RpcEnvironment) -> Result, Error> { + let config = pdm_config::notifications::config()?; + let targets = proxmox_notify::api::get_targets(&config)?; + + Ok(targets) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + }, + } + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Test a given notification target. +pub fn test_target(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let config = pdm_config::notifications::config()?; + proxmox_notify::api::common::test_target(&config, &name)?; + Ok(()) +} + +#[sortable] +const SUBDIRS: SubdirMap = &sorted!([("test", &TEST_ROUTER),]); +const TEST_ROUTER: Router = Router::new().post(&API_METHOD_TEST_TARGET); +const ITEM_ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(SUBDIRS)) + .subdirs(SUBDIRS); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_TARGETS) + .match_all("name", &ITEM_ROUTER); diff --git a/server/src/api/config/notifications/webhook.rs b/server/src/api/config/notifications/webhook.rs new file mode 100644 index 0000000..751086f --- /dev/null +++ b/server/src/api/config/notifications/webhook.rs @@ -0,0 +1,170 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox_notify::endpoints::webhook::{ + DeleteableWebhookProperty, WebhookConfig, WebhookConfigUpdater, +}; +use proxmox_notify::schema::ENTITY_NAME_SCHEMA; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of webhook endpoints.", + type: Array, + items: { type: WebhookConfig }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// List all webhook endpoints. +pub fn list_endpoints( + _param: Value, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let config = pdm_config::notifications::config()?; + + let endpoints = proxmox_notify::api::webhook::get_endpoints(&config)?; + + Ok(endpoints) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + returns: { type: WebhookConfig }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false), + }, +)] +/// Get a webhook endpoint. +pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result { + let config = pdm_config::notifications::config()?; + let endpoint = proxmox_notify::api::webhook::get_endpoint(&config, &name)?; + + rpcenv["digest"] = hex::encode(config.digest()).into(); + + Ok(endpoint) +} + +#[api( + input: { + properties: { + endpoint: { + type: WebhookConfig, + flatten: true, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Add a new webhook endpoint. +pub fn add_endpoint( + endpoint: WebhookConfig, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + + proxmox_notify::api::webhook::add_endpoint(&mut config, endpoint)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + }, + updater: { + type: WebhookConfigUpdater, + flatten: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeleteableWebhookProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Update webhook endpoint. +pub fn update_endpoint( + name: String, + updater: WebhookConfigUpdater, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + let digest = digest.map(hex::decode).transpose()?; + + proxmox_notify::api::webhook::update_endpoint( + &mut config, + &name, + updater, + delete.as_deref(), + digest.as_deref(), + )?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +#[api( + input: { + properties: { + name: { + schema: ENTITY_NAME_SCHEMA, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false), + }, +)] +/// Delete webhook endpoint. +pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let _lock = pdm_config::notifications::lock_config()?; + let mut config = pdm_config::notifications::config()?; + proxmox_notify::api::webhook::delete_endpoint(&mut config, &name)?; + + pdm_config::notifications::save_config(config)?; + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_GET_ENDPOINT) + .put(&API_METHOD_UPDATE_ENDPOINT) + .delete(&API_METHOD_DELETE_ENDPOINT); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_ENDPOINTS) + .post(&API_METHOD_ADD_ENDPOINT) + .match_all("name", &ITEM_ROUTER); diff --git a/server/src/context.rs b/server/src/context.rs index c5da0af..acff3a8 100644 --- a/server/src/context.rs +++ b/server/src/context.rs @@ -38,5 +38,7 @@ pub fn init() -> Result<(), Error> { default_remote_setup(); } + crate::notifications::init(); + Ok(()) } diff --git a/server/src/lib.rs b/server/src/lib.rs index 5ed10d6..9723bc4 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,6 +7,7 @@ pub mod context; pub mod env; pub mod jobstate; pub mod metric_collection; +pub mod notifications; pub mod parallel_fetcher; pub mod remote_cache; pub mod remote_tasks; diff --git a/server/src/notifications/mod.rs b/server/src/notifications/mod.rs new file mode 100644 index 0000000..e38584b --- /dev/null +++ b/server/src/notifications/mod.rs @@ -0,0 +1,115 @@ +use std::path::Path; + +use proxmox_access_control::types::User; +use proxmox_notify::context::Context; +use proxmox_notify::renderer::TemplateSource; +use proxmox_notify::Error; +use tracing::error; + +use pdm_buildcfg::configdir; + +const PDM_NODE_CFG_FILENAME: &str = configdir!("/node.cfg"); + +const DEFAULT_CONFIG: &str = "\ +sendmail: mail-to-root + comment Send mails to root@pam's email address + mailto-user root@pam + + +matcher: default-matcher + mode all + target mail-to-root + comment Route all notifications to mail-to-root +"; + +fn attempt_file_read>(path: P) -> Option { + match proxmox_sys::fs::file_read_optional_string(path) { + Ok(contents) => contents, + Err(err) => { + error!("{err}"); + None + } + } +} + +fn lookup_datacenter_config_key(content: &str, key: &str) -> Option { + let key_prefix = format!("{key}:"); + let value = content + .lines() + .find_map(|line| line.strip_prefix(&key_prefix))? + .trim(); + + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +#[derive(Debug)] +struct PDMContext; + +static PDM_CONTEXT: PDMContext = PDMContext; + +impl Context for PDMContext { + fn lookup_email_for_user(&self, user: &str) -> Option { + let (config, _digest) = match proxmox_access_control::user::config() { + Ok(c) => c, + Err(err) => { + error!("failed to read user config: {err}"); + return None; + } + }; + + match config.lookup::("user", user) { + Ok(user) => user.email, + Err(_) => None, + } + } + + fn default_sendmail_author(&self) -> String { + format!("Proxmox Datacenter Manager - {}", proxmox_sys::nodename()) + } + + fn default_sendmail_from(&self) -> String { + let content = attempt_file_read(PDM_NODE_CFG_FILENAME); + content + .and_then(|content| lookup_datacenter_config_key(&content, "email-from")) + .unwrap_or_else(|| String::from("root")) + } + + fn http_proxy_config(&self) -> Option { + let content = attempt_file_read(PDM_NODE_CFG_FILENAME); + content.and_then(|content| lookup_datacenter_config_key(&content, "http-proxy")) + } + + fn default_config(&self) -> &'static str { + DEFAULT_CONFIG + } + + fn lookup_template( + &self, + filename: &str, + namespace: Option<&str>, + source: TemplateSource, + ) -> Result, Error> { + let base = match source { + TemplateSource::Vendor => "/usr/share/proxmox-datacenter-manager/templates", + TemplateSource::Override => configdir!("/notification-templates"), + }; + + let path = Path::new(base) + .join(namespace.unwrap_or("default")) + .join(filename); + + error!("{path:?}"); + + proxmox_sys::fs::file_read_optional_string(path) + .map_err(|err| Error::Generic(format!("could not load template: {err}"))) + } +} + +/// Initialize `proxmox-notify` by registering the PDM context. +pub fn init() { + proxmox_notify::context::set_context(&PDM_CONTEXT); +} diff --git a/templates/Makefile b/templates/Makefile new file mode 100644 index 0000000..294fa7b --- /dev/null +++ b/templates/Makefile @@ -0,0 +1,14 @@ +include ../defines.mk + +NOTIFICATION_TEMPLATES= \ + default/test-body.txt.hbs \ + default/test-subject.txt.hbs \ + +all: + +clean: + +install: + install -dm755 $(DESTDIR)$(DATAROOTDIR)/proxmox-datacenter-manager/templates/default + $(foreach i,$(NOTIFICATION_TEMPLATES), \ + install -m644 $(i) $(DESTDIR)$(DATAROOTDIR)/proxmox-datacenter-manager/templates/$(i) ;) diff --git a/templates/default/test-body.txt.hbs b/templates/default/test-body.txt.hbs new file mode 100644 index 0000000..2445443 --- /dev/null +++ b/templates/default/test-body.txt.hbs @@ -0,0 +1 @@ +This is a test of the notification target '{{target}}'. diff --git a/templates/default/test-subject.txt.hbs b/templates/default/test-subject.txt.hbs new file mode 100644 index 0000000..cb8e132 --- /dev/null +++ b/templates/default/test-subject.txt.hbs @@ -0,0 +1 @@ +Test notification -- 2.47.3