]> git.proxmox.com Git - proxmox.git/blob - proxmox-notify/src/lib.rs
notify: include 'type' metadata field for forwarded mails
[proxmox.git] / proxmox-notify / src / lib.rs
1 use std::collections::HashMap;
2 use std::error::Error as StdError;
3 use std::fmt::Display;
4 use std::str::FromStr;
5
6 use context::context;
7 use serde::{Deserialize, Serialize};
8 use serde_json::json;
9 use serde_json::Value;
10
11 use proxmox_schema::api;
12 use proxmox_section_config::SectionConfigData;
13
14 pub mod matcher;
15 use crate::config::CONFIG;
16 use matcher::{MatcherConfig, MATCHER_TYPENAME};
17
18 pub mod api;
19 pub mod context;
20 pub mod endpoints;
21 pub mod filter;
22 pub mod group;
23 pub mod renderer;
24 pub mod schema;
25
26 mod config;
27
28 #[derive(Debug)]
29 pub enum Error {
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
41 FilterFailed(String),
42 /// The notification's template string could not be rendered
43 RenderError(Box<dyn StdError + Send + Sync>),
44 /// Generic error for anything else
45 Generic(String),
46 }
47
48 impl Display for Error {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Error::ConfigSerialization(err) => {
52 write!(f, "could not serialize configuration: {err}")
53 }
54 Error::ConfigDeserialization(err) => {
55 write!(f, "could not deserialize configuration: {err}")
56 }
57 Error::NotifyFailed(endpoint, err) => {
58 write!(f, "could not notify via endpoint(s): {endpoint}: {err}")
59 }
60 Error::TargetDoesNotExist(target) => {
61 write!(f, "notification target '{target}' does not exist")
62 }
63 Error::TargetTestFailed(errs) => {
64 for err in errs {
65 writeln!(f, "{err}")?;
66 }
67
68 Ok(())
69 }
70 Error::FilterFailed(message) => {
71 write!(f, "could not apply filter: {message}")
72 }
73 Error::RenderError(err) => write!(f, "could not render notification template: {err}"),
74 Error::Generic(message) => f.write_str(message),
75 }
76 }
77 }
78
79 impl StdError for Error {
80 fn source(&self) -> Option<&(dyn StdError + 'static)> {
81 match self {
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,
90 }
91 }
92 }
93
94 #[api()]
95 #[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
96 #[serde(rename_all = "kebab-case")]
97 /// Severity of a notification
98 pub enum Severity {
99 /// General information
100 Info,
101 /// A noteworthy event
102 Notice,
103 /// Warning
104 Warning,
105 /// Error
106 Error,
107 /// Unknown severity (e.g. forwarded system mails)
108 Unknown,
109 }
110
111 impl Display for Severity {
112 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
113 match self {
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"),
119 }
120 }
121 }
122
123 impl FromStr for Severity {
124 type Err = Error;
125 fn from_str(s: &str) -> Result<Self, Error> {
126 match s {
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}"))),
133 }
134 }
135 }
136
137 #[api()]
138 #[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd)]
139 #[serde(rename_all = "kebab-case")]
140 pub enum Origin {
141 /// User-created config entry
142 UserCreated,
143 /// Config entry provided by the system
144 Builtin,
145 /// Config entry provided by the system, but modified by the user.
146 ModifiedBuiltin,
147 }
148
149 /// Notification endpoint trait, implemented by all endpoint plugins
150 pub trait Endpoint {
151 /// Send a documentation
152 fn send(&self, notification: &Notification) -> Result<(), Error>;
153
154 /// The name/identifier for this endpoint
155 fn name(&self) -> &str;
156
157 /// Check if the endpoint is disabled
158 fn disabled(&self) -> bool;
159 }
160
161 #[derive(Debug, Clone)]
162 pub enum Content {
163 /// Title and body will be rendered as a template
164 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.
170 data: Value,
171 },
172 #[cfg(feature = "mail-forwarder")]
173 ForwardedMail {
174 /// Raw mail contents
175 raw: Vec<u8>,
176 /// Fallback title
177 title: String,
178 /// Fallback body
179 body: String,
180 /// UID to use when calling sendmail
181 #[allow(dead_code)] // Unused in some feature flag permutations
182 uid: Option<u32>,
183 },
184 }
185
186 #[derive(Debug, Clone)]
187 pub struct Metadata {
188 /// Notification severity
189 severity: Severity,
190 /// Timestamp of the notification as a UNIX epoch
191 timestamp: i64,
192 /// Additional fields for additional key-value metadata
193 additional_fields: HashMap<String, String>,
194 }
195
196 #[derive(Debug, Clone)]
197 /// Notification which can be sent
198 pub struct Notification {
199 /// Notification content
200 content: Content,
201 /// Metadata
202 metadata: Metadata,
203 }
204
205 impl Notification {
206 pub fn new_templated<S: AsRef<str>>(
207 severity: Severity,
208 title: S,
209 body: S,
210 template_data: Value,
211 fields: HashMap<String, String>,
212 ) -> Self {
213 Self {
214 metadata: Metadata {
215 severity,
216 additional_fields: fields,
217 timestamp: proxmox_time::epoch_i64(),
218 },
219 content: Content::Template {
220 title_template: title.as_ref().to_string(),
221 body_template: body.as_ref().to_string(),
222 data: template_data,
223 },
224 }
225 }
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()))?;
230
231 let title = message.subject().unwrap_or_default().into();
232 let body = message.body_text(0).unwrap_or_default().into();
233
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());
237
238 Ok(Self {
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(),
244 title,
245 body,
246 uid,
247 },
248 metadata: Metadata {
249 severity: Severity::Unknown,
250 additional_fields,
251 timestamp: proxmox_time::epoch_i64(),
252 },
253 })
254 }
255 }
256
257 /// Notification configuration
258 #[derive(Debug, Clone)]
259 pub struct Config {
260 config: SectionConfigData,
261 private_config: SectionConfigData,
262 digest: [u8; 32],
263 }
264
265 impl Config {
266 /// Parse raw config
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)?;
270
271 let default_config = context().default_config();
272
273 let builtin_config = CONFIG
274 .parse("<builtin>", default_config)
275 .map_err(|err| Error::ConfigDeserialization(err.into()))?;
276
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()));
284 } else {
285 log::error!(
286 "section config entry is not an object. This should not happen"
287 );
288 }
289 } else {
290 // Entry is built-in, but it has been modified by the user.
291 if let Some(obj) = value.as_object_mut() {
292 obj.insert(
293 "origin".to_string(),
294 Value::String("modified-builtin".into()),
295 );
296 } else {
297 log::error!(
298 "section config entry is not an object. This should not happen"
299 );
300 }
301 }
302 } else {
303 let mut val = builtin_value.clone();
304
305 if let Some(obj) = val.as_object_mut() {
306 obj.insert("origin".to_string(), Value::String("builtin".into()));
307 } else {
308 log::error!("section config entry is not an object. This should not happen");
309 }
310 config
311 .set_data(key, builtin_typename, val)
312 .map_err(|err| Error::ConfigDeserialization(err.into()))?;
313 }
314 }
315
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()));
320 }
321 }
322 }
323
324 Ok(Self {
325 config,
326 digest,
327 private_config,
328 })
329 }
330
331 /// Serialize config
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
336 // config fields
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");
341 } else {
342 log::error!("section config entry is not an object. This should not happen");
343 }
344 }
345
346 Ok((
347 config::write(&c)?,
348 config::write_private(&self.private_config)?,
349 ))
350 }
351
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] {
355 &self.digest
356 }
357 }
358
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.
362 #[derive(Default)]
363 pub struct Bus {
364 endpoints: HashMap<String, Box<dyn Endpoint>>,
365 matchers: Vec<MatcherConfig>,
366 }
367
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();
373
374 let configs: Vec<$public_config> = $config
375 .config
376 .convert_to_typed_array($type_name)
377 .map_err(|err| Error::ConfigDeserialization(err.into()))?;
378
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 {
383 log::error!(
384 "Could not instantiate endpoint '{name}': \
385 private config has wrong type",
386 name = config.name
387 );
388 }
389 let private_config = <$private_config>::deserialize(private_config)
390 .map_err(|err| Error::ConfigDeserialization(err.into()))?;
391
392 endpoints.push(Box::new($endpoint_type {
393 config,
394 private_config: private_config.clone(),
395 }));
396 }
397 None => log::error!(
398 "Could not instantiate endpoint '{name}': \
399 private config does not exist",
400 name = config.name
401 ),
402 }
403 }
404
405 Ok(endpoints)
406 })()
407 };
408 }
409
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();
415
416 let configs: Vec<$public_config> = $config
417 .config
418 .convert_to_typed_array($type_name)
419 .map_err(|err| Error::ConfigDeserialization(err.into()))?;
420
421 for config in configs {
422 endpoints.push(Box::new($endpoint_type { config }));
423 }
424
425 Ok(endpoints)
426 })()
427 };
428 }
429
430 impl Bus {
431 /// Instantiate notification bus from a given configuration.
432 pub fn from_config(config: &Config) -> Result<Self, Error> {
433 #[allow(unused_mut)]
434 let mut endpoints = HashMap::new();
435
436 // Instantiate endpoints
437 #[cfg(feature = "sendmail")]
438 {
439 use endpoints::sendmail::SENDMAIL_TYPENAME;
440 use endpoints::sendmail::{SendmailConfig, SendmailEndpoint};
441 endpoints.extend(
442 parse_endpoints_without_private_config!(
443 config,
444 SendmailConfig,
445 SendmailEndpoint,
446 SENDMAIL_TYPENAME
447 )?
448 .into_iter()
449 .map(|e| (e.name().into(), e)),
450 );
451 }
452
453 #[cfg(feature = "gotify")]
454 {
455 use endpoints::gotify::GOTIFY_TYPENAME;
456 use endpoints::gotify::{GotifyConfig, GotifyEndpoint, GotifyPrivateConfig};
457 endpoints.extend(
458 parse_endpoints_with_private_config!(
459 config,
460 GotifyConfig,
461 GotifyPrivateConfig,
462 GotifyEndpoint,
463 GOTIFY_TYPENAME
464 )?
465 .into_iter()
466 .map(|e| (e.name().into(), e)),
467 );
468 }
469 #[cfg(feature = "smtp")]
470 {
471 use endpoints::smtp::SMTP_TYPENAME;
472 use endpoints::smtp::{SmtpConfig, SmtpEndpoint, SmtpPrivateConfig};
473 endpoints.extend(
474 parse_endpoints_with_private_config!(
475 config,
476 SmtpConfig,
477 SmtpPrivateConfig,
478 SmtpEndpoint,
479 SMTP_TYPENAME
480 )?
481 .into_iter()
482 .map(|e| (e.name().into(), e)),
483 );
484 }
485
486 let matchers = config
487 .config
488 .convert_to_typed_array(MATCHER_TYPENAME)
489 .map_err(|err| Error::ConfigDeserialization(err.into()))?;
490
491 Ok(Bus {
492 endpoints,
493 matchers,
494 })
495 }
496
497 #[cfg(test)]
498 pub fn add_endpoint(&mut self, endpoint: Box<dyn Endpoint>) {
499 self.endpoints.insert(endpoint.name().to_string(), endpoint);
500 }
501
502 #[cfg(test)]
503 pub fn add_matcher(&mut self, filter: MatcherConfig) {
504 self.matchers.push(filter)
505 }
506
507 /// Send a notification. Notification matchers will determine which targets will receive
508 /// the notification.
509 ///
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);
513
514 for target in targets {
515 if let Some(endpoint) = self.endpoints.get(target) {
516 let name = endpoint.name();
517
518 if endpoint.disabled() {
519 // Skip this target if it is disabled
520 log::info!("skipping disabled target '{name}'");
521 continue;
522 }
523
524 match endpoint.send(notification) {
525 Ok(_) => {
526 log::info!("notified via target `{name}`");
527 }
528 Err(e) => {
529 // Only log on errors, do not propagate fail to the caller.
530 log::error!("could not notify via target `{name}`: {e}");
531 }
532 }
533 } else {
534 log::error!("could not notify via target '{target}', it does not exist");
535 }
536 }
537 }
538
539 /// Send a test notification to a target (endpoint or group).
540 ///
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 {
545 metadata: Metadata {
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(),
550 },
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 }),
555 },
556 };
557
558 if let Some(endpoint) = self.endpoints.get(target) {
559 endpoint.send(&notification)?;
560 } else {
561 return Err(Error::TargetDoesNotExist(target.to_string()));
562 }
563
564 Ok(())
565 }
566 }
567
568 #[cfg(test)]
569 mod tests {
570 use std::{cell::RefCell, rc::Rc};
571
572 use super::*;
573
574 #[derive(Default, Clone)]
575 struct MockEndpoint {
576 name: &'static str,
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>>>,
580 }
581
582 impl Endpoint for MockEndpoint {
583 fn send(&self, message: &Notification) -> Result<(), Error> {
584 self.messages.borrow_mut().push(message.clone());
585
586 Ok(())
587 }
588
589 fn name(&self) -> &str {
590 self.name
591 }
592
593 fn disabled(&self) -> bool {
594 false
595 }
596 }
597
598 impl MockEndpoint {
599 fn new(name: &'static str) -> Self {
600 Self {
601 name,
602 ..Default::default()
603 }
604 }
605
606 fn messages(&self) -> Vec<Notification> {
607 self.messages.borrow().clone()
608 }
609 }
610
611 #[test]
612 fn test_add_mock_endpoint() -> Result<(), Error> {
613 let mock = MockEndpoint::new("endpoint");
614
615 let mut bus = Bus::default();
616 bus.add_endpoint(Box::new(mock.clone()));
617
618 let matcher = MatcherConfig {
619 target: Some(vec!["endpoint".into()]),
620 ..Default::default()
621 };
622
623 bus.add_matcher(matcher);
624
625 // Send directly to endpoint
626 bus.send(&Notification::new_templated(
627 Severity::Info,
628 "Title",
629 "Body",
630 Default::default(),
631 Default::default(),
632 ));
633 let messages = mock.messages();
634 assert_eq!(messages.len(), 1);
635
636 Ok(())
637 }
638
639 #[test]
640 fn test_multiple_endpoints_with_different_matchers() -> Result<(), Error> {
641 let endpoint1 = MockEndpoint::new("mock1");
642 let endpoint2 = MockEndpoint::new("mock2");
643
644 let mut bus = Bus::default();
645
646 bus.add_endpoint(Box::new(endpoint1.clone()));
647 bus.add_endpoint(Box::new(endpoint2.clone()));
648
649 bus.add_matcher(MatcherConfig {
650 name: "matcher1".into(),
651 match_severity: Some(vec!["warning,error".parse()?]),
652 target: Some(vec!["mock1".into()]),
653 ..Default::default()
654 });
655
656 bus.add_matcher(MatcherConfig {
657 name: "matcher2".into(),
658 match_severity: Some(vec!["error".parse()?]),
659 target: Some(vec!["mock2".into()]),
660 ..Default::default()
661 });
662
663 let send_with_severity = |severity| {
664 let notification = Notification::new_templated(
665 severity,
666 "Title",
667 "Body",
668 Default::default(),
669 Default::default(),
670 );
671
672 bus.send(&notification);
673 };
674
675 send_with_severity(Severity::Info);
676 assert_eq!(endpoint1.messages().len(), 0);
677 assert_eq!(endpoint2.messages().len(), 0);
678
679 send_with_severity(Severity::Warning);
680 assert_eq!(endpoint1.messages().len(), 1);
681 assert_eq!(endpoint2.messages().len(), 0);
682
683 send_with_severity(Severity::Error);
684 assert_eq!(endpoint1.messages().len(), 2);
685 assert_eq!(endpoint2.messages().len(), 1);
686
687 Ok(())
688 }
689 }