diff --git a/Cargo.toml b/Cargo.toml index c68e802..a562f3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "proxmox-mail-forward" version = "0.2.0" authors = [ "Fiona Ebner ", + "Lukas Wagner ", "Proxmox Support Team ", ] edition = "2021" @@ -16,10 +17,7 @@ exclude = [ "debian" ] anyhow = "1.0" log = "0.4.17" nix = "0.26" -serde = { version = "1.0", features = ["derive"] } -#serde_json = "1.0" syslog = "6.0" -proxmox-schema = "1.3" -proxmox-section-config = "1.0.2" proxmox-sys = "0.5" +proxmox-notify = {version = "0.2", features = ["mail-forwarder", "pve-context", "pbs-context"] } diff --git a/src/main.rs b/src/main.rs index f3d4193..e56bc1e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,174 +1,157 @@ +//! 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"; +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"; -// 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, -} - -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"; - -/// 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 { - match s?.trim() { - "" => None, - s => Some(s.to_string()), - } -} - -/// Extract the root user's email address from the PBS user config. -fn get_pbs_mail_to(content: &str) -> Option { - 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::("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 - } - } - } - Err(err) => { - log::error!("unable to parse {} - {}", PBS_USER_CFG_FILENAME, err); - None - } - } -} - -/// Extract the root user's email address from the PVE user config. -fn get_pve_mail_to(content: &str) -> Option { - 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, - } - })) -} - -/// Extract the From-address configured in the PVE datacenter config. -fn get_pve_mail_from(content: &str) -> Option { - normalize_for_return( - content - .lines() - .find_map(|line| line.strip_prefix("email_from:")), - ) -} - -/// 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) -> Result<(), Error> { - if mail_to.is_empty() { - bail!("user 'root@pam' does not have an email address"); - } - - log::info!("forward mail to <{}>", mail_to.join(",")); - - 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"); - - // with status(), child inherits stdin - cmd.status() - .map_err(|err| format_err!("command {:?} failed - {}", cmd, err))?; - - Ok(()) -} +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"; /// Wrapper around `proxmox_sys::fs::file_read_optional_string` which also returns `None` upon error /// after logging it. fn attempt_file_read>(path: P) -> Option { - match fs::file_read_optional_string(path) { + match fs::file_read_optional_string(path.as_ref()) { Ok(contents) => contents, Err(err) => { - log::error!("{}", err); + log::error!("unable to read {path:?}: {err}", path = path.as_ref()); None } } } +/// Read data from stdin, until EOF is encountered. +fn read_stdin() -> Result, Error> { + let mut input = Vec::new(); + let stdin = std::io::stdin(); + let mut handle = stdin.lock(); + + handle.read_to_end(&mut input)?; + Ok(input) +} + +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)?; + + Ok(()) +} + +/// 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(); + + let config = Config::new(&config, &priv_config)?; + + proxmox_notify::context::set_context(&PVE_CONTEXT); + forward_common(mail, &config) +} + +/// 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() { if let Err(err) = syslog::init( syslog::Facility::LOG_DAEMON, 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); + // Read the mail that is to be forwarded from stdin + match read_stdin() { + Ok(mail) => { + let mut has_pve = false; - 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; - } + // 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 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()); - - 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); - } }