]> git.proxmox.com Git - proxmox-backup.git/blame - src/server/notifications.rs
server: notifications: send acme notifications via notification system
[proxmox-backup.git] / src / server / notifications.rs
CommitLineData
b9e7bcc2 1use anyhow::Error;
57570bda 2use const_format::concatcp;
04f35d0e 3use serde_json::json;
57570bda
LW
4use std::collections::HashMap;
5use std::path::Path;
6use std::time::{Duration, Instant};
b9e7bcc2 7
04f35d0e 8use handlebars::{Handlebars, TemplateError};
57570bda 9use nix::unistd::Uid;
b9e7bcc2 10
6ef1b649 11use proxmox_lang::try_block;
57570bda 12use proxmox_notify::context::pbs::PBS_CONTEXT;
9fa3026a 13use proxmox_schema::ApiType;
ee0ea735 14use proxmox_sys::email::sendmail;
57570bda 15use proxmox_sys::fs::{create_path, CreateOptions};
b9e7bcc2 16
e3619d41 17use pbs_api_types::{
04f35d0e
LW
18 APTUpdateInfo, DataStoreConfig, DatastoreNotify, GarbageCollectionStatus, NotificationMode,
19 Notify, SyncJobConfig, TapeBackupJobSetup, User, Userid, VerificationJobConfig,
b9e7bcc2 20};
04f35d0e
LW
21use proxmox_notify::endpoints::sendmail::{SendmailConfig, SendmailEndpoint};
22use proxmox_notify::{Endpoint, Notification, Severity};
b9e7bcc2 23
57570bda 24const SPOOL_DIR: &str = concatcp!(pbs_buildcfg::PROXMOX_BACKUP_STATE_DIR, "/notifications");
b9e7bcc2 25
8703a68a
DC
26const TAPE_BACKUP_OK_TEMPLATE: &str = r###"
27
28{{#if id ~}}
29Job ID: {{id}}
30{{/if~}}
31Datastore: {{job.store}}
32Tape Pool: {{job.pool}}
33Tape Drive: {{job.drive}}
34
4abd4dbe
DC
35{{#if snapshot-list ~}}
36Snapshots included:
37
38{{#each snapshot-list~}}
39{{this}}
40{{/each~}}
41{{/if}}
42Duration: {{duration}}
36156038
DC
43{{#if used-tapes }}
44Used Tapes:
45{{#each used-tapes~}}
46{{this}}
47{{/each~}}
48{{/if}}
8703a68a
DC
49Tape Backup successful.
50
51
d1d74c43 52Please visit the web interface for further details:
8703a68a
DC
53
54<https://{{fqdn}}:{{port}}/#DataStore-{{job.store}}>
55
56"###;
57
58const TAPE_BACKUP_ERR_TEMPLATE: &str = r###"
59
60{{#if id ~}}
61Job ID: {{id}}
62{{/if~}}
63Datastore: {{job.store}}
64Tape Pool: {{job.pool}}
65Tape Drive: {{job.drive}}
66
4ca3f0c6
DC
67{{#if snapshot-list ~}}
68Snapshots included:
8703a68a 69
4ca3f0c6
DC
70{{#each snapshot-list~}}
71{{this}}
72{{/each~}}
73{{/if}}
36156038
DC
74{{#if used-tapes }}
75Used Tapes:
76{{#each used-tapes~}}
77{{this}}
78{{/each~}}
79{{/if}}
8703a68a
DC
80Tape Backup failed: {{error}}
81
82
d1d74c43 83Please visit the web interface for further details:
8703a68a
DC
84
85<https://{{fqdn}}:{{port}}/#pbsServerAdministration:tasks>
86
87"###;
86d60245 88
ee0ea735 89lazy_static::lazy_static! {
b9e7bcc2
DM
90
91 static ref HANDLEBARS: Handlebars<'static> = {
92 let mut hb = Handlebars::new();
25b4d52d 93 let result: Result<(), TemplateError> = try_block!({
b9e7bcc2 94
25b4d52d 95 hb.set_strict_mode(true);
f24cbee7 96 hb.register_escape_fn(handlebars::no_escape);
b9e7bcc2 97
25b4d52d
DC
98 hb.register_template_string("tape_backup_ok_template", TAPE_BACKUP_OK_TEMPLATE)?;
99 hb.register_template_string("tape_backup_err_template", TAPE_BACKUP_ERR_TEMPLATE)?;
8703a68a 100
25b4d52d
DC
101 Ok(())
102 });
103
104 if let Err(err) = result {
dd06b7f1 105 eprintln!("error during template registration: {err}");
25b4d52d 106 }
86d60245 107
b9e7bcc2
DM
108 hb
109 };
110}
111
57570bda
LW
112/// Initialize the notification system by setting context in proxmox_notify
113pub fn init() -> Result<(), Error> {
114 proxmox_notify::context::set_context(&PBS_CONTEXT);
115 Ok(())
116}
117
118/// Create the directory which will be used to temporarily store notifications
119/// which were sent from an unprivileged process.
120pub fn create_spool_dir() -> Result<(), Error> {
121 let backup_user = pbs_config::backup_user()?;
122 let opts = CreateOptions::new()
123 .owner(backup_user.uid)
124 .group(backup_user.gid);
125
126 create_path(SPOOL_DIR, None, Some(opts))?;
127 Ok(())
128}
129
130async fn send_queued_notifications() -> Result<(), Error> {
131 let mut read_dir = tokio::fs::read_dir(SPOOL_DIR).await?;
132
133 let mut notifications = Vec::new();
134
135 while let Some(entry) = read_dir.next_entry().await? {
136 let path = entry.path();
137
138 if let Some(ext) = path.extension() {
139 if ext == "json" {
140 let p = path.clone();
141
142 let bytes = tokio::fs::read(p).await?;
143 let notification: Notification = serde_json::from_slice(&bytes)?;
144 notifications.push(notification);
145
146 // Currently, there is no retry-mechanism in case of failure...
147 // For retries, we'd have to keep track of which targets succeeded/failed
148 // to send, so we do not retry notifying a target which succeeded before.
149 tokio::fs::remove_file(path).await?;
150 }
151 }
152 }
153
154 // Make sure that we send the oldest notification first
155 notifications.sort_unstable_by_key(|n| n.timestamp());
156
157 let res = tokio::task::spawn_blocking(move || {
158 let config = pbs_config::notifications::config()?;
159 for notification in notifications {
160 if let Err(err) = proxmox_notify::api::common::send(&config, &notification) {
161 log::error!("failed to send notification: {err}");
162 }
163 }
164
165 Ok::<(), Error>(())
166 })
167 .await?;
168
169 if let Err(e) = res {
170 log::error!("could not read notification config: {e}");
171 }
172
173 Ok::<(), Error>(())
174}
175
176/// Worker task to periodically send any queued notifications.
177pub async fn notification_worker() {
178 loop {
179 let delay_target = Instant::now() + Duration::from_secs(5);
180
181 if let Err(err) = send_queued_notifications().await {
182 log::error!("notification worker task error: {err}");
183 }
184
185 tokio::time::sleep_until(tokio::time::Instant::from_std(delay_target)).await;
186 }
187}
188
189fn send_notification(notification: Notification) -> Result<(), Error> {
190 if nix::unistd::ROOT == Uid::current() {
191 let config = pbs_config::notifications::config()?;
192 proxmox_notify::api::common::send(&config, &notification)?;
193 } else {
194 let ser = serde_json::to_vec(&notification)?;
195 let path = Path::new(SPOOL_DIR).join(format!("{id}.json", id = notification.id()));
196
197 let backup_user = pbs_config::backup_user()?;
198 let opts = CreateOptions::new()
199 .owner(backup_user.uid)
200 .group(backup_user.gid);
201 proxmox_sys::fs::replace_file(path, &ser, opts, true)?;
202 log::info!("queued notification (id={id})", id = notification.id())
203 }
204
205 Ok(())
206}
207
04f35d0e
LW
208fn send_sendmail_legacy_notification(notification: Notification, email: &str) -> Result<(), Error> {
209 let endpoint = SendmailEndpoint {
210 config: SendmailConfig {
211 mailto: vec![email.into()],
212 ..Default::default()
213 },
214 };
215
216 endpoint.send(&notification)?;
217
218 Ok(())
219}
220
4abd4dbe
DC
221/// Summary of a successful Tape Job
222#[derive(Default)]
223pub struct TapeBackupJobSummary {
224 /// The list of snaphots backed up
225 pub snapshot_list: Vec<String>,
226 /// The total time of the backup job
227 pub duration: std::time::Duration,
36156038
DC
228 /// The labels of the used tapes of the backup job
229 pub used_tapes: Option<Vec<String>>,
4abd4dbe
DC
230}
231
ee0ea735 232fn send_job_status_mail(email: &str, subject: &str, text: &str) -> Result<(), Error> {
e4665261 233 let (config, _) = crate::config::node::config()?;
ee0ea735 234 let from = config.email_from;
e4665261 235
dd06b7f1
TL
236 // NOTE: some (web)mailers have big problems displaying text mails, so include html as well
237 let escaped_text = handlebars::html_escape(text);
238 let html = format!("<html><body><pre>\n{escaped_text}\n<pre>");
b9e7bcc2 239
25877d05 240 let nodename = proxmox_sys::nodename();
b9e7bcc2 241
dd06b7f1 242 let author = format!("Proxmox Backup Server - {nodename}");
b9e7bcc2
DM
243
244 sendmail(
245 &[email],
9a37bd6c
FG
246 subject,
247 Some(text),
b9e7bcc2 248 Some(&html),
e4665261 249 from.as_deref(),
b9e7bcc2
DM
250 Some(&author),
251 )?;
252
253 Ok(())
254}
255
256pub fn send_gc_status(
b9e7bcc2
DM
257 datastore: &str,
258 status: &GarbageCollectionStatus,
259 result: &Result<(), Error>,
260) -> Result<(), Error> {
3066f564
DM
261 let (fqdn, port) = get_server_url();
262 let mut data = json!({
263 "datastore": datastore,
264 "fqdn": fqdn,
265 "port": port,
266 });
267
04f35d0e 268 let (severity, template) = match result {
b9e7bcc2 269 Ok(()) => {
d6373f35 270 let deduplication_factor = if status.disk_bytes > 0 {
ee0ea735 271 (status.index_data_bytes as f64) / (status.disk_bytes as f64)
d6373f35
DM
272 } else {
273 1.0
274 };
275
3066f564
DM
276 data["status"] = json!(status);
277 data["deduplication-factor"] = format!("{:.2}", deduplication_factor).into();
d6373f35 278
04f35d0e 279 (Severity::Info, "gc-ok")
b9e7bcc2
DM
280 }
281 Err(err) => {
3066f564 282 data["error"] = err.to_string().into();
04f35d0e 283 (Severity::Error, "gc-err")
b9e7bcc2
DM
284 }
285 };
04f35d0e
LW
286 let metadata = HashMap::from([
287 ("datastore".into(), datastore.into()),
288 ("hostname".into(), proxmox_sys::nodename().into()),
289 ("type".into(), "gc".into()),
290 ]);
b9e7bcc2 291
04f35d0e 292 let notification = Notification::from_template(severity, template, data, metadata);
b9e7bcc2 293
04f35d0e
LW
294 let (email, notify, mode) = lookup_datastore_notify_settings(datastore);
295 match mode {
296 NotificationMode::LegacySendmail => {
297 let notify = notify.gc.unwrap_or(Notify::Always);
298
299 if notify == Notify::Never || (result.is_ok() && notify == Notify::Error) {
300 return Ok(());
301 }
302
303 if let Some(email) = email {
304 send_sendmail_legacy_notification(notification, &email)?;
305 }
306 }
307 NotificationMode::NotificationSystem => {
308 send_notification(notification)?;
309 }
310 }
b9e7bcc2
DM
311
312 Ok(())
313}
314
315pub fn send_verify_status(
b9e7bcc2 316 job: VerificationJobConfig,
a4915dfc 317 result: &Result<Vec<String>, Error>,
b9e7bcc2 318) -> Result<(), Error> {
3066f564
DM
319 let (fqdn, port) = get_server_url();
320 let mut data = json!({
321 "job": job,
322 "fqdn": fqdn,
323 "port": port,
324 });
b9e7bcc2 325
2432775c
LW
326 let (template, severity) = match result {
327 Ok(errors) if errors.is_empty() => ("verify-ok", Severity::Info),
a4915dfc 328 Ok(errors) => {
3066f564 329 data["errors"] = json!(errors);
2432775c 330 ("verify-err", Severity::Error)
b9e7bcc2 331 }
a4915dfc 332 Err(_) => {
2432775c 333 // aborted job - do not send any notification
a4915dfc
DM
334 return Ok(());
335 }
b9e7bcc2
DM
336 };
337
2432775c
LW
338 let metadata = HashMap::from([
339 ("job-id".into(), job.id.clone()),
340 ("datastore".into(), job.store.clone()),
341 ("hostname".into(), proxmox_sys::nodename().into()),
342 ("type".into(), "verify".into()),
343 ]);
344
345 let notification = Notification::from_template(severity, template, data, metadata);
346
347 let (email, notify, mode) = lookup_datastore_notify_settings(&job.store);
348 match mode {
349 NotificationMode::LegacySendmail => {
350 let notify = notify.verify.unwrap_or(Notify::Always);
351
352 if notify == Notify::Never || (result.is_ok() && notify == Notify::Error) {
c26c9390
DM
353 return Ok(());
354 }
2432775c
LW
355
356 if let Some(email) = email {
357 send_sendmail_legacy_notification(notification, &email)?;
358 }
359 }
360 NotificationMode::NotificationSystem => {
361 send_notification(notification)?;
c26c9390
DM
362 }
363 }
364
b9e7bcc2
DM
365 Ok(())
366}
367
cf91a072
DC
368pub fn send_prune_status(
369 store: &str,
370 jobname: &str,
371 result: &Result<(), Error>,
372) -> Result<(), Error> {
cf91a072
DC
373 let (fqdn, port) = get_server_url();
374 let mut data = json!({
375 "jobname": jobname,
376 "store": store,
377 "fqdn": fqdn,
378 "port": port,
379 });
380
3ca03c05
LW
381 let (template, severity) = match result {
382 Ok(()) => ("prune-ok", Severity::Info),
cf91a072
DC
383 Err(err) => {
384 data["error"] = err.to_string().into();
3ca03c05 385 ("prune-err", Severity::Error)
cf91a072
DC
386 }
387 };
388
3ca03c05
LW
389 let metadata = HashMap::from([
390 ("job-id".into(), jobname.to_string()),
391 ("datastore".into(), store.into()),
392 ("hostname".into(), proxmox_sys::nodename().into()),
393 ("type".into(), "prune".into()),
394 ]);
cf91a072 395
3ca03c05
LW
396 let notification = Notification::from_template(severity, template, data, metadata);
397
398 let (email, notify, mode) = lookup_datastore_notify_settings(store);
399 match mode {
400 NotificationMode::LegacySendmail => {
401 let notify = notify.prune.unwrap_or(Notify::Error);
402
403 if notify == Notify::Never || (result.is_ok() && notify == Notify::Error) {
404 return Ok(());
405 }
406
407 if let Some(email) = email {
408 send_sendmail_legacy_notification(notification, &email)?;
409 }
410 }
411 NotificationMode::NotificationSystem => {
412 send_notification(notification)?;
413 }
414 }
cf91a072
DC
415
416 Ok(())
417}
418
5b23a707 419pub fn send_sync_status(job: &SyncJobConfig, result: &Result<(), Error>) -> Result<(), Error> {
3066f564
DM
420 let (fqdn, port) = get_server_url();
421 let mut data = json!({
422 "job": job,
423 "fqdn": fqdn,
424 "port": port,
425 });
426
5b23a707
LW
427 let (template, severity) = match result {
428 Ok(()) => ("sync-ok", Severity::Info),
9e733dae 429 Err(err) => {
3066f564 430 data["error"] = err.to_string().into();
5b23a707 431 ("sync-err", Severity::Error)
9e733dae
DM
432 }
433 };
434
5b23a707
LW
435 let metadata = HashMap::from([
436 ("job-id".into(), job.id.clone()),
437 ("datastore".into(), job.store.clone()),
438 ("hostname".into(), proxmox_sys::nodename().into()),
439 ("type".into(), "sync".into()),
440 ]);
4ec73327 441
5b23a707 442 let notification = Notification::from_template(severity, template, data, metadata);
9e733dae 443
5b23a707
LW
444 let (email, notify, mode) = lookup_datastore_notify_settings(&job.store);
445 match mode {
446 NotificationMode::LegacySendmail => {
447 let notify = notify.prune.unwrap_or(Notify::Error);
448
449 if notify == Notify::Never || (result.is_ok() && notify == Notify::Error) {
450 return Ok(());
451 }
452
453 if let Some(email) = email {
454 send_sendmail_legacy_notification(notification, &email)?;
455 }
456 }
457 NotificationMode::NotificationSystem => {
458 send_notification(notification)?;
459 }
460 }
9e733dae
DM
461
462 Ok(())
463}
464
8703a68a
DC
465pub fn send_tape_backup_status(
466 email: &str,
467 id: Option<&str>,
468 job: &TapeBackupJobSetup,
469 result: &Result<(), Error>,
4abd4dbe 470 summary: TapeBackupJobSummary,
8703a68a 471) -> Result<(), Error> {
8703a68a 472 let (fqdn, port) = get_server_url();
15cc41b6 473 let duration: proxmox_time::TimeSpan = summary.duration.into();
8703a68a
DC
474 let mut data = json!({
475 "job": job,
476 "fqdn": fqdn,
477 "port": port,
478 "id": id,
4abd4dbe 479 "snapshot-list": summary.snapshot_list,
36156038 480 "used-tapes": summary.used_tapes,
4abd4dbe 481 "duration": duration.to_string(),
8703a68a
DC
482 });
483
484 let text = match result {
ee0ea735 485 Ok(()) => HANDLEBARS.render("tape_backup_ok_template", &data)?,
8703a68a
DC
486 Err(err) => {
487 data["error"] = err.to_string().into();
488 HANDLEBARS.render("tape_backup_err_template", &data)?
489 }
490 };
491
492 let subject = match (result, id) {
dd06b7f1 493 (Ok(()), Some(id)) => format!("Tape Backup '{id}' datastore '{}' successful", job.store,),
ee0ea735 494 (Ok(()), None) => format!("Tape Backup datastore '{}' successful", job.store,),
dd06b7f1 495 (Err(_), Some(id)) => format!("Tape Backup '{id}' datastore '{}' failed", job.store,),
ee0ea735 496 (Err(_), None) => format!("Tape Backup datastore '{}' failed", job.store,),
8703a68a
DC
497 };
498
499 send_job_status_mail(email, &subject, &text)?;
500
501 Ok(())
502}
503
28926247
DC
504/// Send email to a person to request a manual media change
505pub fn send_load_media_email(
bdce7fa1
DC
506 changer: bool,
507 device: &str,
28926247
DC
508 label_text: &str,
509 to: &str,
510 reason: Option<String>,
511) -> Result<(), Error> {
5574114a
WB
512 use std::fmt::Write as _;
513
bdce7fa1
DC
514 let device_type = if changer { "changer" } else { "drive" };
515
516 let subject = format!("Load Media '{label_text}' request for {device_type} '{device}'");
28926247
DC
517
518 let mut text = String::new();
519
520 if let Some(reason) = reason {
5574114a
WB
521 let _ = write!(
522 text,
bdce7fa1 523 "The {device_type} has the wrong or no tape(s) inserted. Error:\n{reason}\n\n"
5574114a 524 );
28926247
DC
525 }
526
bdce7fa1
DC
527 if changer {
528 text.push_str("Please insert the requested media into the changer.\n\n");
529 let _ = writeln!(text, "Changer: {device}");
530 } else {
531 text.push_str("Please insert the requested media into the backup drive.\n\n");
532 let _ = writeln!(text, "Drive: {device}");
533 }
dd06b7f1 534 let _ = writeln!(text, "Media: {label_text}");
28926247
DC
535
536 send_job_status_mail(to, &subject, &text)
537}
538
3066f564 539fn get_server_url() -> (String, usize) {
3066f564
DM
540 // user will surely request that they can change this
541
25877d05 542 let nodename = proxmox_sys::nodename();
3066f564
DM
543 let mut fqdn = nodename.to_owned();
544
545 if let Ok(resolv_conf) = crate::api2::node::dns::read_etc_resolv_conf() {
546 if let Some(search) = resolv_conf["search"].as_str() {
547 fqdn.push('.');
548 fqdn.push_str(search);
549 }
550 }
551
552 let port = 8007;
553
554 (fqdn, port)
555}
556
ee0ea735 557pub fn send_updates_available(updates: &[&APTUpdateInfo]) -> Result<(), Error> {
823314c7
LW
558 let (fqdn, port) = get_server_url();
559 let hostname = proxmox_sys::nodename().to_string();
86d60245 560
823314c7
LW
561 let data = json!({
562 "fqdn": fqdn,
563 "hostname": &hostname,
564 "port": port,
565 "updates": updates,
566 });
3066f564 567
823314c7
LW
568 let metadata = HashMap::from([
569 ("hostname".into(), hostname),
570 ("type".into(), "package-updates".into()),
571 ]);
86d60245 572
823314c7
LW
573 let notification =
574 Notification::from_template(Severity::Info, "package-updates", data, metadata);
575
576 send_notification(notification)?;
86d60245
TL
577 Ok(())
578}
579
9e8daa1d 580/// send email on certificate renewal failure.
9e8daa1d
SS
581pub fn send_certificate_renewal_mail(result: &Result<(), Error>) -> Result<(), Error> {
582 let error: String = match result {
e1db0670 583 Err(e) => e.to_string(),
9e8daa1d
SS
584 _ => return Ok(()),
585 };
586
1d2069d1 587 let (fqdn, port) = get_server_url();
9e8daa1d 588
1d2069d1
LW
589 let data = json!({
590 "fqdn": fqdn,
591 "port": port,
592 "error": error,
593 });
9e8daa1d 594
1d2069d1
LW
595 let metadata = HashMap::from([
596 ("hostname".into(), proxmox_sys::nodename().into()),
597 ("type".into(), "acme".into()),
598 ]);
9e8daa1d 599
1d2069d1 600 let notification = Notification::from_template(Severity::Info, "acme-err", data, metadata);
9e8daa1d 601
1d2069d1 602 send_notification(notification)?;
9e8daa1d
SS
603 Ok(())
604}
605
b9e7bcc2 606/// Lookup users email address
c9793d47 607pub fn lookup_user_email(userid: &Userid) -> Option<String> {
ba3d7e19 608 if let Ok(user_config) = pbs_config::user::cached_config() {
b9e7bcc2 609 if let Ok(user) = user_config.lookup::<User>("user", userid.as_str()) {
44288184 610 return user.email;
b9e7bcc2
DM
611 }
612 }
613
614 None
615}
616
f47c1d3a 617/// Lookup Datastore notify settings
04f35d0e
LW
618pub fn lookup_datastore_notify_settings(
619 store: &str,
620) -> (Option<String>, DatastoreNotify, NotificationMode) {
f47c1d3a
DM
621 let mut email = None;
622
ee0ea735
TL
623 let notify = DatastoreNotify {
624 gc: None,
625 verify: None,
626 sync: None,
cf91a072 627 prune: None,
ee0ea735 628 };
c26c9390 629
e7d4be9d 630 let (config, _digest) = match pbs_config::datastore::config() {
f47c1d3a 631 Ok(result) => result,
04f35d0e 632 Err(_) => return (email, notify, NotificationMode::default()),
f47c1d3a
DM
633 };
634
635 let config: DataStoreConfig = match config.lookup("datastore", store) {
636 Ok(result) => result,
04f35d0e 637 Err(_) => return (email, notify, NotificationMode::default()),
f47c1d3a
DM
638 };
639
640 email = match config.notify_user {
641 Some(ref userid) => lookup_user_email(userid),
ad54df31 642 None => lookup_user_email(Userid::root_userid()),
f47c1d3a
DM
643 };
644
04f35d0e 645 let notification_mode = config.notification_mode.unwrap_or_default();
17c7b46a 646 let notify_str = config.notify.unwrap_or_default();
c26c9390 647
9fa3026a 648 if let Ok(value) = DatastoreNotify::API_SCHEMA.parse_property_string(&notify_str) {
c26c9390 649 if let Ok(notify) = serde_json::from_value(value) {
04f35d0e 650 return (email, notify, notification_mode);
c26c9390 651 }
f47c1d3a
DM
652 }
653
04f35d0e 654 (email, notify, notification_mode)
b9e7bcc2 655}
25b4d52d
DC
656
657#[test]
658fn test_template_register() {
25b4d52d
DC
659 assert!(HANDLEBARS.has_template("tape_backup_ok_template"));
660 assert!(HANDLEBARS.has_template("tape_backup_err_template"));
25b4d52d 661}