From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 539438ADCC; Fri, 21 Oct 2022 15:03:35 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 234D522456; Fri, 21 Oct 2022 15:03:04 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS; Fri, 21 Oct 2022 15:02:58 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id CC6CB44B22; Fri, 21 Oct 2022 15:02:57 +0200 (CEST) From: Fiona Ebner To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Date: Fri, 21 Oct 2022 15:02:45 +0200 Message-Id: <20221021130252.176316-3-f.ebner@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20221021130252.176316-1-f.ebner@proxmox.com> References: <20221021130252.176316-1-f.ebner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.028 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [main.rs, proxmox.com] Subject: [pve-devel] [PATCH proxmox-mail-forward 1/3] initial commit X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 21 Oct 2022 13:03:35 -0000 It is intended to replace the current pvemailforward binary+script in PVE and also be used in PBS. The implemenation is largely based on the pvemailforward script to try and keep behavior mostly the same in PVE. To read the config in PBS, the binary would need to belong to backup:backup with setuid and setgid bits (proxmox-backup is 700 owned by backup:backup and user.cfg is 640 owned by root:backup). To read the configs in PVE the setgid bit for www-data would need to be set. To avoid this issue, the helper will be a root-owned setuid binary and set the effective UID to the real UID, after reading in the config files. Signed-off-by: Fiona Ebner --- Dependency bump for proxmox-section-config needed! .cargo/config | 5 ++ .gitignore | 2 + Cargo.toml | 25 ++++++++ rustfmt.toml | 1 + src/main.rs | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 .cargo/config create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 rustfmt.toml create mode 100644 src/main.rs diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 0000000..3b5b6e4 --- /dev/null +++ b/.cargo/config @@ -0,0 +1,5 @@ +[source] +[source.debian-packages] +directory = "/usr/share/cargo/registry" +[source.crates-io] +replace-with = "debian-packages" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e7caa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +target/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4dd0681 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "proxmox-mail-forward" +version = "0.1.0" +authors = [ + "Fiona Ebner ", + "Proxmox Support Team ", +] +edition = "2021" +license = "AGPL-3" +description = "Proxmox mail forward helper" +homepage = "https://www.proxmox.com" + +exclude = [ "debian" ] + +[dependencies] +anyhow = "1.0" +log = "0.4.17" +nix = "0.24" +serde = { version = "1.0", features = ["derive"] } +#serde_json = "1.0" +syslog = "4.0" + +proxmox-schema = "1.3.4" +proxmox-section-config = "1.0" +proxmox-sys = "0.4" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3a26366 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2021" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e0cacb2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,174 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, format_err, Error}; +use serde::Deserialize; + +use proxmox_schema::{ObjectSchema, Schema, StringSchema}; +use proxmox_section_config::{SectionConfig, SectionConfigPlugin}; +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, +} + +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); + 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(()) +} + +/// 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) { + Ok(contents) => contents, + Err(err) => { + log::error!("{}", err); + None + } + } +} + +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); + } + + 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::seteuid(real_uid) { + log::error!( + "mail forward failed: unable to set effective uid to {}: {}", + real_uid, + err + ); + return; + } + + 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); + } + } + + if let Err(err) = forward_mail(mail_from, mail_to) { + log::error!("mail forward failed: {}", err); + } +} -- 2.30.2