From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <f.ebner@proxmox.com>
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 <f.ebner@proxmox.com>
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 <pve-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pve-devel/>
List-Post: <mailto:pve-devel@lists.proxmox.com>
List-Help: <mailto:pve-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=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 <f.ebner@proxmox.com>
---

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 <f.ebner@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+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<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";
+
+/// 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()),
+    }
+}
+
+/// 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);
+    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
+                }
+            }
+        }
+        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<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,
+        }
+    }))
+}
+
+/// 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:")),
+    )
+}
+
+/// 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");
+    }
+
+    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<P: AsRef<Path>>(path: P) -> Option<String> {
+    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





From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <f.ebner@proxmox.com>
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 <f.ebner@proxmox.com>
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: [pbs-devel] [PATCH proxmox-mail-forward 1/3] initial commit
X-BeenThere: pbs-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox Backup Server development discussion
 <pbs-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pbs-devel>, 
 <mailto:pbs-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pbs-devel/>
List-Post: <mailto:pbs-devel@lists.proxmox.com>
List-Help: <mailto:pbs-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel>, 
 <mailto:pbs-devel-request@lists.proxmox.com?subject=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 <f.ebner@proxmox.com>
---

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 <f.ebner@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+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<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";
+
+/// 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()),
+    }
+}
+
+/// 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);
+    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
+                }
+            }
+        }
+        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<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,
+        }
+    }))
+}
+
+/// 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:")),
+    )
+}
+
+/// 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");
+    }
+
+    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<P: AsRef<Path>>(path: P) -> Option<String> {
+    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