+//! A helper binary that forwards any mail passed via stdin to
+//! proxmox_notify.
+//!
+//! The binary's path is added to /root/.forward, which means that
+//! postfix will invoke it when the local root user receives an email message.
+//! The message is passed via stdin.
+//! The binary is installed with setuid permissions and will thus run as
+//! root (euid ~ root, ruid ~ nobody)
+//!
+//! The forwarding behavior is the following:
+//! - PVE installed: Use PVE's notifications.cfg
+//! - PBS installed: Use PBS's notifications.cfg if present. If not,
+//! use an empty configuration and add a default sendmail target and
+//! a matcher - this is needed because notifications are not yet
+//! integrated in PBS.
+//! - PVE/PBS co-installed: Use PVE's config *and* PBS's config, but if
+//! PBS's config does not exist, a default sendmail target will *not* be
+//! added. We assume that PVE's config contains the desired notification
+//! behavior for system mails.
+//!
+use std::io::Read;
use std::path::Path;
-use std::process::Command;
-use anyhow::{bail, format_err, Error};
-use serde::Deserialize;
+use anyhow::Error;
-use proxmox_schema::{ObjectSchema, Schema, StringSchema};
-use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
+use proxmox_notify::context::pbs::PBS_CONTEXT;
+use proxmox_notify::context::pve::PVE_CONTEXT;
+use proxmox_notify::endpoints::sendmail::SendmailConfig;
+use proxmox_notify::matcher::MatcherConfig;
+use proxmox_notify::Config;
use proxmox_sys::fs;
-const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
-const PBS_ROOT_USER: &str = "root@pam";
-
-// FIXME: Switch to the actual schema when possible in terms of dependency.
-// It's safe to assume that the config was written with the actual schema restrictions, so parsing
-// it with the less restrictive schema should be enough for the purpose of getting the mail address.
-const DUMMY_ID_SCHEMA: Schema = StringSchema::new("dummy ID").min_length(3).schema();
-const DUMMY_EMAIL_SCHEMA: Schema = StringSchema::new("dummy email").schema();
-const DUMMY_USER_SCHEMA: ObjectSchema = ObjectSchema {
- description: "minimal PBS user",
- properties: &[
- ("userid", false, &DUMMY_ID_SCHEMA),
- ("email", true, &DUMMY_EMAIL_SCHEMA),
- ],
- additional_properties: true,
- default_key: None,
-};
-
-#[derive(Deserialize)]
-struct DummyPbsUser {
- pub email: Option<String>,
-}
-
-const PVE_USER_CFG_FILENAME: &str = "/etc/pve/user.cfg";
-const PVE_DATACENTER_CFG_FILENAME: &str = "/etc/pve/datacenter.cfg";
-const PVE_ROOT_USER: &str = "root@pam";
+const PVE_CFG_PATH: &str = "/etc/pve";
+const PVE_PUB_NOTIFICATION_CFG_FILENAME: &str = "/etc/pve/notifications.cfg";
+const PVE_PRIV_NOTIFICATION_CFG_FILENAME: &str = "/etc/pve/priv/notifications.cfg";
-/// Convenience helper to get the trimmed contents of an optional &str, mapping blank ones to `None`
-/// and creating a String from it for returning.
-fn normalize_for_return(s: Option<&str>) -> Option<String> {
- match s?.trim() {
- "" => None,
- s => Some(s.to_string()),
- }
-}
+const PBS_CFG_PATH: &str = "/etc/proxmox-backup";
+const PBS_PUB_NOTIFICATION_CFG_FILENAME: &str = "/etc/proxmox-backup/notifications.cfg";
+const PBS_PRIV_NOTIFICATION_CFG_FILENAME: &str = "/etc/proxmox-backup/notifications-priv.cfg";
-/// Extract the root user's email address from the PBS user config.
-fn get_pbs_mail_to(content: &str) -> Option<String> {
- let mut config = SectionConfig::new(&DUMMY_ID_SCHEMA).allow_unknown_sections(true);
- let user_plugin = SectionConfigPlugin::new(
- "user".to_string(),
- Some("userid".to_string()),
- &DUMMY_USER_SCHEMA,
- );
- config.register_plugin(user_plugin);
-
- match config.parse(PBS_USER_CFG_FILENAME, content) {
- Ok(parsed) => {
- parsed.sections.get(PBS_ROOT_USER)?;
- match parsed.lookup::<DummyPbsUser>("user", PBS_ROOT_USER) {
- Ok(user) => normalize_for_return(user.email.as_deref()),
- Err(err) => {
- log::error!("unable to parse {} - {}", PBS_USER_CFG_FILENAME, err);
- None
- }
- }
- }
+/// Wrapper around `proxmox_sys::fs::file_read_optional_string` which also returns `None` upon error
+/// after logging it.
+fn attempt_file_read<P: AsRef<Path>>(path: P) -> Option<String> {
+ match fs::file_read_optional_string(path.as_ref()) {
+ Ok(contents) => contents,
Err(err) => {
- log::error!("unable to parse {} - {}", PBS_USER_CFG_FILENAME, err);
+ log::error!("unable to read {path:?}: {err}", path = path.as_ref());
None
}
}
}
-/// Extract the root user's email address from the PVE user config.
-fn get_pve_mail_to(content: &str) -> Option<String> {
- normalize_for_return(content.lines().find_map(|line| {
- let fields: Vec<&str> = line.split(':').collect();
- #[allow(clippy::get_first)] // to keep expression style consistent
- match fields.get(0)?.trim() == "user" && fields.get(1)?.trim() == PVE_ROOT_USER {
- true => fields.get(6).copied(),
- false => None,
- }
- }))
-}
+/// Read data from stdin, until EOF is encountered.
+fn read_stdin() -> Result<Vec<u8>, Error> {
+ let mut input = Vec::new();
+ let stdin = std::io::stdin();
+ let mut handle = stdin.lock();
-/// Extract the From-address configured in the PVE datacenter config.
-fn get_pve_mail_from(content: &str) -> Option<String> {
- normalize_for_return(
- content
- .lines()
- .find_map(|line| line.strip_prefix("email_from:")),
- )
+ handle.read_to_end(&mut input)?;
+ Ok(input)
}
-/// Executes sendmail as a child process with the specified From/To-addresses, expecting the mail
-/// contents to be passed via stdin inherited from this program.
-fn forward_mail(mail_from: String, mail_to: Vec<String>) -> Result<(), Error> {
- if mail_to.is_empty() {
- bail!("user 'root@pam' does not have an email address");
- }
+fn forward_common(mail: &[u8], config: &Config) -> Result<(), Error> {
+ let real_uid = nix::unistd::getuid();
+ // The uid is passed so that `sendmail` can be called as the a correct user.
+ // (sendmail will show a warning if called from a setuid process)
+ let notification =
+ proxmox_notify::Notification::new_forwarded_mail(mail, Some(real_uid.as_raw()))?;
+
+ proxmox_notify::api::common::send(config, ¬ification)?;
- log::info!("forward mail to <{}>", mail_to.join(","));
+ Ok(())
+}
- let mut cmd = Command::new("sendmail");
- cmd.args([
- "-bm", "-N", "never", // never send DSN (avoid mail loops)
- "-f", &mail_from, "--",
- ]);
- cmd.args(mail_to);
- cmd.env("PATH", "/sbin:/bin:/usr/sbin:/usr/bin");
+/// Forward a mail to PVE's notification system
+fn forward_for_pve(mail: &[u8]) -> Result<(), Error> {
+ let config = attempt_file_read(PVE_PUB_NOTIFICATION_CFG_FILENAME).unwrap_or_default();
+ let priv_config = attempt_file_read(PVE_PRIV_NOTIFICATION_CFG_FILENAME).unwrap_or_default();
- // with status(), child inherits stdin
- cmd.status()
- .map_err(|err| format_err!("command {:?} failed - {}", cmd, err))?;
+ let config = Config::new(&config, &priv_config)?;
- Ok(())
+ proxmox_notify::context::set_context(&PVE_CONTEXT);
+ forward_common(mail, &config)
}
-/// Wrapper around `proxmox_sys::fs::file_read_optional_string` which also returns `None` upon error
-/// after logging it.
-fn attempt_file_read<P: AsRef<Path>>(path: P) -> Option<String> {
- match fs::file_read_optional_string(path) {
- Ok(contents) => contents,
- Err(err) => {
- log::error!("{}", err);
- None
+/// Forward a mail to PBS's notification system
+fn forward_for_pbs(mail: &[u8], has_pve: bool) -> Result<(), Error> {
+ let config = if Path::new(PBS_PUB_NOTIFICATION_CFG_FILENAME).exists() {
+ let config = attempt_file_read(PBS_PUB_NOTIFICATION_CFG_FILENAME).unwrap_or_default();
+ let priv_config = attempt_file_read(PBS_PRIV_NOTIFICATION_CFG_FILENAME).unwrap_or_default();
+
+ Config::new(&config, &priv_config)?
+ } else {
+ // TODO: This can be removed once PBS has full notification integration
+ let mut config = Config::new("", "")?;
+ if !has_pve {
+ proxmox_notify::api::sendmail::add_endpoint(
+ &mut config,
+ &SendmailConfig {
+ name: "default-target".to_string(),
+ mailto_user: Some(vec!["root@pam".to_string()]),
+ ..Default::default()
+ },
+ )?;
+
+ proxmox_notify::api::matcher::add_matcher(
+ &mut config,
+ &MatcherConfig {
+ name: "default-matcher".to_string(),
+ target: Some(vec!["default-target".to_string()]),
+ ..Default::default()
+ },
+ )?;
}
- }
+ config
+ };
+
+ proxmox_notify::context::set_context(&PBS_CONTEXT);
+ forward_common(mail, &config)?;
+
+ Ok(())
}
fn main() {
log::LevelFilter::Info,
Some("proxmox-mail-forward"),
) {
- eprintln!("unable to inititialize syslog - {}", err);
+ eprintln!("unable to initialize syslog: {err}");
}
- let pbs_user_cfg_content = attempt_file_read(PBS_USER_CFG_FILENAME);
- let pve_user_cfg_content = attempt_file_read(PVE_USER_CFG_FILENAME);
- let pve_datacenter_cfg_content = attempt_file_read(PVE_DATACENTER_CFG_FILENAME);
-
- let real_uid = nix::unistd::getuid();
- if let Err(err) = nix::unistd::setresuid(real_uid, real_uid, real_uid) {
- log::error!(
- "mail forward failed: unable to set effective uid to {}: {}",
- real_uid,
- err
- );
- return;
- }
+ // Read the mail that is to be forwarded from stdin
+ match read_stdin() {
+ Ok(mail) => {
+ let mut has_pve = false;
- let pbs_mail_to = pbs_user_cfg_content.and_then(|content| get_pbs_mail_to(&content));
- let pve_mail_to = pve_user_cfg_content.and_then(|content| get_pve_mail_to(&content));
- let pve_mail_from = pve_datacenter_cfg_content.and_then(|content| get_pve_mail_from(&content));
-
- let mail_from = pve_mail_from.unwrap_or_else(|| "root".to_string());
+ // Assume a PVE installation if /etc/pve exists
+ if Path::new(PVE_CFG_PATH).exists() {
+ has_pve = true;
+ if let Err(err) = forward_for_pve(&mail) {
+ log::error!("could not forward mail for Proxmox VE: {err}");
+ }
+ }
- let mut mail_to = vec![];
- if let Some(pve_mail_to) = pve_mail_to {
- mail_to.push(pve_mail_to);
- }
- if let Some(pbs_mail_to) = pbs_mail_to {
- if !mail_to.contains(&pbs_mail_to) {
- mail_to.push(pbs_mail_to);
+ // Assume a PBS installation if /etc/proxmox-backup exists
+ if Path::new(PBS_CFG_PATH).exists() {
+ if let Err(err) = forward_for_pbs(&mail, has_pve) {
+ log::error!("could not forward mail for Proxmox Backup Server: {err}");
+ }
+ }
+ }
+ Err(err) => {
+ log::error!("could not read mail from STDIN: {err}")
}
- }
-
- if let Err(err) = forward_mail(mail_from, mail_to) {
- log::error!("mail forward failed: {}", err);
}
}