1 use std
::collections
::HashMap
;
2 use std
::error
::Error
as StdError
;
7 use serde
::{Deserialize, Serialize}
;
11 use proxmox_schema
::api
;
12 use proxmox_section_config
::SectionConfigData
;
15 use crate::config
::CONFIG
;
16 use matcher
::{MatcherConfig, MATCHER_TYPENAME}
;
30 /// There was an error serializing the config
31 ConfigSerialization(Box
<dyn StdError
+ Send
+ Sync
>),
32 /// There was an error deserializing the config
33 ConfigDeserialization(Box
<dyn StdError
+ Send
+ Sync
>),
34 /// An endpoint failed to send a notification
35 NotifyFailed(String
, Box
<dyn StdError
+ Send
+ Sync
>),
36 /// A target does not exist
37 TargetDoesNotExist(String
),
38 /// Testing one or more notification targets failed
39 TargetTestFailed(Vec
<Box
<dyn StdError
+ Send
+ Sync
>>),
40 /// A filter could not be applied
42 /// The notification's template string could not be rendered
43 RenderError(Box
<dyn StdError
+ Send
+ Sync
>),
44 /// Generic error for anything else
48 impl Display
for Error
{
49 fn fmt(&self, f
: &mut std
::fmt
::Formatter
<'_
>) -> std
::fmt
::Result
{
51 Error
::ConfigSerialization(err
) => {
52 write
!(f
, "could not serialize configuration: {err}")
54 Error
::ConfigDeserialization(err
) => {
55 write
!(f
, "could not deserialize configuration: {err}")
57 Error
::NotifyFailed(endpoint
, err
) => {
58 write
!(f
, "could not notify via endpoint(s): {endpoint}: {err}")
60 Error
::TargetDoesNotExist(target
) => {
61 write
!(f
, "notification target '{target}' does not exist")
63 Error
::TargetTestFailed(errs
) => {
65 writeln
!(f
, "{err}")?
;
70 Error
::FilterFailed(message
) => {
71 write
!(f
, "could not apply filter: {message}")
73 Error
::RenderError(err
) => write
!(f
, "could not render notification template: {err}"),
74 Error
::Generic(message
) => f
.write_str(message
),
79 impl StdError
for Error
{
80 fn source(&self) -> Option
<&(dyn StdError
+ '
static)> {
82 Error
::ConfigSerialization(err
) => Some(&**err
),
83 Error
::ConfigDeserialization(err
) => Some(&**err
),
84 Error
::NotifyFailed(_
, err
) => Some(&**err
),
85 Error
::TargetDoesNotExist(_
) => None
,
86 Error
::TargetTestFailed(errs
) => Some(&*errs
[0]),
87 Error
::FilterFailed(_
) => None
,
88 Error
::RenderError(err
) => Some(&**err
),
89 Error
::Generic(_
) => None
,
95 #[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
96 #[serde(rename_all = "kebab-case")]
97 /// Severity of a notification
99 /// General information
101 /// A noteworthy event
107 /// Unknown severity (e.g. forwarded system mails)
111 impl Display
for Severity
{
112 fn fmt(&self, f
: &mut std
::fmt
::Formatter
) -> std
::result
::Result
<(), std
::fmt
::Error
> {
114 Severity
::Info
=> f
.write_str("info"),
115 Severity
::Notice
=> f
.write_str("notice"),
116 Severity
::Warning
=> f
.write_str("warning"),
117 Severity
::Error
=> f
.write_str("error"),
118 Severity
::Unknown
=> f
.write_str("unknown"),
123 impl FromStr
for Severity
{
125 fn from_str(s
: &str) -> Result
<Self, Error
> {
127 "info" => Ok(Self::Info
),
128 "notice" => Ok(Self::Notice
),
129 "warning" => Ok(Self::Warning
),
130 "error" => Ok(Self::Error
),
131 "unknown" => Ok(Self::Unknown
),
132 _
=> Err(Error
::Generic(format
!("invalid severity {s}"))),
138 #[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
139 #[serde(rename_all = "kebab-case")]
141 /// User-created config entry
143 /// Config entry provided by the system
145 /// Config entry provided by the system, but modified by the user.
149 /// Notification endpoint trait, implemented by all endpoint plugins
151 /// Send a documentation
152 fn send(&self, notification
: &Notification
) -> Result
<(), Error
>;
154 /// The name/identifier for this endpoint
155 fn name(&self) -> &str;
157 /// Check if the endpoint is disabled
158 fn disabled(&self) -> bool
;
161 #[derive(Debug, Clone)]
163 /// Title and body will be rendered as a template
165 /// Template for the notification title.
166 title_template
: String
,
167 /// Template for the notification body.
168 body_template
: String
,
169 /// Data that can be used for template rendering.
172 #[cfg(feature = "mail-forwarder")]
174 /// Raw mail contents
180 /// UID to use when calling sendmail
181 #[allow(dead_code)] // Unused in some feature flag permutations
186 #[derive(Debug, Clone)]
187 pub struct Metadata
{
188 /// Notification severity
190 /// Timestamp of the notification as a UNIX epoch
192 /// Additional fields for additional key-value metadata
193 additional_fields
: HashMap
<String
, String
>,
196 #[derive(Debug, Clone)]
197 /// Notification which can be sent
198 pub struct Notification
{
199 /// Notification content
206 pub fn new_templated
<S
: AsRef
<str>>(
210 template_data
: Value
,
211 fields
: HashMap
<String
, String
>,
216 additional_fields
: fields
,
217 timestamp
: proxmox_time
::epoch_i64(),
219 content
: Content
::Template
{
220 title_template
: title
.as_ref().to_string(),
221 body_template
: body
.as_ref().to_string(),
226 #[cfg(feature = "mail-forwarder")]
227 pub fn new_forwarded_mail(raw_mail
: &[u8], uid
: Option
<u32>) -> Result
<Self, Error
> {
228 let message
= mail_parser
::Message
::parse(raw_mail
)
229 .ok_or_else(|| Error
::Generic("could not parse forwarded email".to_string()))?
;
231 let title
= message
.subject().unwrap_or_default().into();
232 let body
= message
.body_text(0).unwrap_or_default().into();
234 let mut additional_fields
= HashMap
::new();
235 additional_fields
.insert("hostname".into(), proxmox_sys
::nodename().into());
236 additional_fields
.insert("type".into(), "system-mail".into());
239 // Unfortunately we cannot reasonably infer the severity from the
240 // mail contents, so just set it to the highest for now so that
241 // it is not filtered out.
242 content
: Content
::ForwardedMail
{
243 raw
: raw_mail
.into(),
249 severity
: Severity
::Unknown
,
251 timestamp
: proxmox_time
::epoch_i64(),
257 /// Notification configuration
258 #[derive(Debug, Clone)]
260 config
: SectionConfigData
,
261 private_config
: SectionConfigData
,
267 pub fn new(raw_config
: &str, raw_private_config
: &str) -> Result
<Self, Error
> {
268 let (mut config
, digest
) = config
::config(raw_config
)?
;
269 let (private_config
, _
) = config
::private_config(raw_private_config
)?
;
271 let default_config
= context().default_config();
273 let builtin_config
= CONFIG
274 .parse("<builtin>", default_config
)
275 .map_err(|err
| Error
::ConfigDeserialization(err
.into()))?
;
277 for (key
, (builtin_typename
, builtin_value
)) in &builtin_config
.sections
{
278 if let Some((typename
, value
)) = config
.sections
.get_mut(key
) {
279 if builtin_typename
== typename
&& value
== builtin_value
{
280 // Entry is built-in and the config entry section in notifications.cfg
281 // is exactly the same.
282 if let Some(obj
) = value
.as_object_mut() {
283 obj
.insert("origin".to_string(), Value
::String("builtin".into()));
286 "section config entry is not an object. This should not happen"
290 // Entry is built-in, but it has been modified by the user.
291 if let Some(obj
) = value
.as_object_mut() {
293 "origin".to_string(),
294 Value
::String("modified-builtin".into()),
298 "section config entry is not an object. This should not happen"
303 let mut val
= builtin_value
.clone();
305 if let Some(obj
) = val
.as_object_mut() {
306 obj
.insert("origin".to_string(), Value
::String("builtin".into()));
308 log
::error
!("section config entry is not an object. This should not happen");
311 .set_data(key
, builtin_typename
, val
)
312 .map_err(|err
| Error
::ConfigDeserialization(err
.into()))?
;
316 for (_
, (_
, value
)) in config
.sections
.iter_mut() {
317 if let Some(obj
) = value
.as_object_mut() {
318 if obj
.get("origin").is_none() {
319 obj
.insert("origin".to_string(), Value
::String("user-created".into()));
332 pub fn write(&self) -> Result
<(String
, String
), Error
> {
333 let mut c
= self.config
.clone();
334 for (_
, (_
, value
)) in c
.sections
.iter_mut() {
335 // Remove 'origin' parameter, we do not want it in our
337 // TODO: Check if there is a better way for this, maybe a
338 // separate type for API responses?
339 if let Some(obj
) = value
.as_object_mut() {
340 obj
.remove("origin");
342 log
::error
!("section config entry is not an object. This should not happen");
348 config
::write_private(&self.private_config
)?
,
352 /// Returns the SHA256 digest of the configuration.
353 /// The digest is only computed once when the configuration deserialized.
354 pub fn digest(&self) -> &[u8; 32] {
359 /// Notification bus - distributes notifications to all registered endpoints
360 // The reason for the split between `Config` and this struct is to make testing with mocked
361 // endpoints a bit easier.
364 endpoints
: HashMap
<String
, Box
<dyn Endpoint
>>,
365 matchers
: Vec
<MatcherConfig
>,
368 #[allow(unused_macros)]
369 macro_rules
! parse_endpoints_with_private_config
{
370 ($config
:ident
, $public_config
:ty
, $private_config
:ty
, $endpoint_type
:ident
, $type_name
:expr
) => {
371 (|| -> Result
<Vec
<Box
<dyn Endpoint
>>, Error
> {
372 let mut endpoints
= Vec
::<Box
<dyn Endpoint
>>::new();
374 let configs
: Vec
<$public_config
> = $config
376 .convert_to_typed_array($type_name
)
377 .map_err(|err
| Error
::ConfigDeserialization(err
.into()))?
;
379 for config
in configs
{
380 match $config
.private_config
.sections
.get(&config
.name
) {
381 Some((section_type_name
, private_config
)) => {
382 if $type_name
!= section_type_name
{
384 "Could not instantiate endpoint '{name}': \
385 private config has wrong type",
389 let private_config
= <$private_config
>::deserialize(private_config
)
390 .map_err(|err
| Error
::ConfigDeserialization(err
.into()))?
;
392 endpoints
.push(Box
::new($endpoint_type
{
394 private_config
: private_config
.clone(),
398 "Could not instantiate endpoint '{name}': \
399 private config does not exist",
410 #[allow(unused_macros)]
411 macro_rules
! parse_endpoints_without_private_config
{
412 ($config
:ident
, $public_config
:ty
, $endpoint_type
:ident
, $type_name
:expr
) => {
413 (|| -> Result
<Vec
<Box
<dyn Endpoint
>>, Error
> {
414 let mut endpoints
= Vec
::<Box
<dyn Endpoint
>>::new();
416 let configs
: Vec
<$public_config
> = $config
418 .convert_to_typed_array($type_name
)
419 .map_err(|err
| Error
::ConfigDeserialization(err
.into()))?
;
421 for config
in configs
{
422 endpoints
.push(Box
::new($endpoint_type { config }
));
431 /// Instantiate notification bus from a given configuration.
432 pub fn from_config(config
: &Config
) -> Result
<Self, Error
> {
434 let mut endpoints
= HashMap
::new();
436 // Instantiate endpoints
437 #[cfg(feature = "sendmail")]
439 use endpoints
::sendmail
::SENDMAIL_TYPENAME
;
440 use endpoints
::sendmail
::{SendmailConfig, SendmailEndpoint}
;
442 parse_endpoints_without_private_config
!(
449 .map(|e
| (e
.name().into(), e
)),
453 #[cfg(feature = "gotify")]
455 use endpoints
::gotify
::GOTIFY_TYPENAME
;
456 use endpoints
::gotify
::{GotifyConfig, GotifyEndpoint, GotifyPrivateConfig}
;
458 parse_endpoints_with_private_config
!(
466 .map(|e
| (e
.name().into(), e
)),
469 #[cfg(feature = "smtp")]
471 use endpoints
::smtp
::SMTP_TYPENAME
;
472 use endpoints
::smtp
::{SmtpConfig, SmtpEndpoint, SmtpPrivateConfig}
;
474 parse_endpoints_with_private_config
!(
482 .map(|e
| (e
.name().into(), e
)),
486 let matchers
= config
488 .convert_to_typed_array(MATCHER_TYPENAME
)
489 .map_err(|err
| Error
::ConfigDeserialization(err
.into()))?
;
498 pub fn add_endpoint(&mut self, endpoint
: Box
<dyn Endpoint
>) {
499 self.endpoints
.insert(endpoint
.name().to_string(), endpoint
);
503 pub fn add_matcher(&mut self, filter
: MatcherConfig
) {
504 self.matchers
.push(filter
)
507 /// Send a notification. Notification matchers will determine which targets will receive
508 /// the notification.
510 /// Any errors will not be returned but only logged.
511 pub fn send(&self, notification
: &Notification
) {
512 let targets
= matcher
::check_matches(self.matchers
.as_slice(), notification
);
514 for target
in targets
{
515 if let Some(endpoint
) = self.endpoints
.get(target
) {
516 let name
= endpoint
.name();
518 if endpoint
.disabled() {
519 // Skip this target if it is disabled
520 log
::info
!("skipping disabled target '{name}'");
524 match endpoint
.send(notification
) {
526 log
::info
!("notified via target `{name}`");
529 // Only log on errors, do not propagate fail to the caller.
530 log
::error
!("could not notify via target `{name}`: {e}");
534 log
::error
!("could not notify via target '{target}', it does not exist");
539 /// Send a test notification to a target (endpoint or group).
541 /// In contrast to the `send` function, this function will return
542 /// any errors to the caller.
543 pub fn test_target(&self, target
: &str) -> Result
<(), Error
> {
544 let notification
= Notification
{
546 severity
: Severity
::Info
,
547 // TODO: what fields would make sense for test notifications?
548 additional_fields
: Default
::default(),
549 timestamp
: proxmox_time
::epoch_i64(),
551 content
: Content
::Template
{
552 title_template
: "Test notification".into(),
553 body_template
: "This is a test of the notification target '{{ target }}'".into(),
554 data
: json
!({ "target": target }
),
558 if let Some(endpoint
) = self.endpoints
.get(target
) {
559 endpoint
.send(¬ification
)?
;
561 return Err(Error
::TargetDoesNotExist(target
.to_string()));
570 use std
::{cell::RefCell, rc::Rc}
;
574 #[derive(Default, Clone)]
575 struct MockEndpoint
{
577 // Needs to be an Rc so that we can clone MockEndpoint before
578 // passing it to Bus, while still retaining a handle to the Vec
579 messages
: Rc
<RefCell
<Vec
<Notification
>>>,
582 impl Endpoint
for MockEndpoint
{
583 fn send(&self, message
: &Notification
) -> Result
<(), Error
> {
584 self.messages
.borrow_mut().push(message
.clone());
589 fn name(&self) -> &str {
593 fn disabled(&self) -> bool
{
599 fn new(name
: &'
static str) -> Self {
606 fn messages(&self) -> Vec
<Notification
> {
607 self.messages
.borrow().clone()
612 fn test_add_mock_endpoint() -> Result
<(), Error
> {
613 let mock
= MockEndpoint
::new("endpoint");
615 let mut bus
= Bus
::default();
616 bus
.add_endpoint(Box
::new(mock
.clone()));
618 let matcher
= MatcherConfig
{
619 target
: Some(vec
!["endpoint".into()]),
623 bus
.add_matcher(matcher
);
625 // Send directly to endpoint
626 bus
.send(&Notification
::new_templated(
633 let messages
= mock
.messages();
634 assert_eq
!(messages
.len(), 1);
640 fn test_multiple_endpoints_with_different_matchers() -> Result
<(), Error
> {
641 let endpoint1
= MockEndpoint
::new("mock1");
642 let endpoint2
= MockEndpoint
::new("mock2");
644 let mut bus
= Bus
::default();
646 bus
.add_endpoint(Box
::new(endpoint1
.clone()));
647 bus
.add_endpoint(Box
::new(endpoint2
.clone()));
649 bus
.add_matcher(MatcherConfig
{
650 name
: "matcher1".into(),
651 match_severity
: Some(vec
!["warning,error".parse()?
]),
652 target
: Some(vec
!["mock1".into()]),
656 bus
.add_matcher(MatcherConfig
{
657 name
: "matcher2".into(),
658 match_severity
: Some(vec
!["error".parse()?
]),
659 target
: Some(vec
!["mock2".into()]),
663 let send_with_severity
= |severity
| {
664 let notification
= Notification
::new_templated(
672 bus
.send(¬ification
);
675 send_with_severity(Severity
::Info
);
676 assert_eq
!(endpoint1
.messages().len(), 0);
677 assert_eq
!(endpoint2
.messages().len(), 0);
679 send_with_severity(Severity
::Warning
);
680 assert_eq
!(endpoint1
.messages().len(), 1);
681 assert_eq
!(endpoint2
.messages().len(), 0);
683 send_with_severity(Severity
::Error
);
684 assert_eq
!(endpoint1
.messages().len(), 2);
685 assert_eq
!(endpoint2
.messages().len(), 1);