]> git.proxmox.com Git - proxmox-mail-forward.git/blob - src/main.rs
f3d4193db6df3c52e99791b851e4bfd4a2e040da
[proxmox-mail-forward.git] / src / main.rs
1 use std::path::Path;
2 use std::process::Command;
3
4 use anyhow::{bail, format_err, Error};
5 use serde::Deserialize;
6
7 use proxmox_schema::{ObjectSchema, Schema, StringSchema};
8 use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
9 use proxmox_sys::fs;
10
11 const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
12 const PBS_ROOT_USER: &str = "root@pam";
13
14 // FIXME: Switch to the actual schema when possible in terms of dependency.
15 // It's safe to assume that the config was written with the actual schema restrictions, so parsing
16 // it with the less restrictive schema should be enough for the purpose of getting the mail address.
17 const DUMMY_ID_SCHEMA: Schema = StringSchema::new("dummy ID").min_length(3).schema();
18 const DUMMY_EMAIL_SCHEMA: Schema = StringSchema::new("dummy email").schema();
19 const DUMMY_USER_SCHEMA: ObjectSchema = ObjectSchema {
20 description: "minimal PBS user",
21 properties: &[
22 ("userid", false, &DUMMY_ID_SCHEMA),
23 ("email", true, &DUMMY_EMAIL_SCHEMA),
24 ],
25 additional_properties: true,
26 default_key: None,
27 };
28
29 #[derive(Deserialize)]
30 struct DummyPbsUser {
31 pub email: Option<String>,
32 }
33
34 const PVE_USER_CFG_FILENAME: &str = "/etc/pve/user.cfg";
35 const PVE_DATACENTER_CFG_FILENAME: &str = "/etc/pve/datacenter.cfg";
36 const PVE_ROOT_USER: &str = "root@pam";
37
38 /// Convenience helper to get the trimmed contents of an optional &str, mapping blank ones to `None`
39 /// and creating a String from it for returning.
40 fn normalize_for_return(s: Option<&str>) -> Option<String> {
41 match s?.trim() {
42 "" => None,
43 s => Some(s.to_string()),
44 }
45 }
46
47 /// Extract the root user's email address from the PBS user config.
48 fn get_pbs_mail_to(content: &str) -> Option<String> {
49 let mut config = SectionConfig::new(&DUMMY_ID_SCHEMA).allow_unknown_sections(true);
50 let user_plugin = SectionConfigPlugin::new(
51 "user".to_string(),
52 Some("userid".to_string()),
53 &DUMMY_USER_SCHEMA,
54 );
55 config.register_plugin(user_plugin);
56
57 match config.parse(PBS_USER_CFG_FILENAME, content) {
58 Ok(parsed) => {
59 parsed.sections.get(PBS_ROOT_USER)?;
60 match parsed.lookup::<DummyPbsUser>("user", PBS_ROOT_USER) {
61 Ok(user) => normalize_for_return(user.email.as_deref()),
62 Err(err) => {
63 log::error!("unable to parse {} - {}", PBS_USER_CFG_FILENAME, err);
64 None
65 }
66 }
67 }
68 Err(err) => {
69 log::error!("unable to parse {} - {}", PBS_USER_CFG_FILENAME, err);
70 None
71 }
72 }
73 }
74
75 /// Extract the root user's email address from the PVE user config.
76 fn get_pve_mail_to(content: &str) -> Option<String> {
77 normalize_for_return(content.lines().find_map(|line| {
78 let fields: Vec<&str> = line.split(':').collect();
79 #[allow(clippy::get_first)] // to keep expression style consistent
80 match fields.get(0)?.trim() == "user" && fields.get(1)?.trim() == PVE_ROOT_USER {
81 true => fields.get(6).copied(),
82 false => None,
83 }
84 }))
85 }
86
87 /// Extract the From-address configured in the PVE datacenter config.
88 fn get_pve_mail_from(content: &str) -> Option<String> {
89 normalize_for_return(
90 content
91 .lines()
92 .find_map(|line| line.strip_prefix("email_from:")),
93 )
94 }
95
96 /// Executes sendmail as a child process with the specified From/To-addresses, expecting the mail
97 /// contents to be passed via stdin inherited from this program.
98 fn forward_mail(mail_from: String, mail_to: Vec<String>) -> Result<(), Error> {
99 if mail_to.is_empty() {
100 bail!("user 'root@pam' does not have an email address");
101 }
102
103 log::info!("forward mail to <{}>", mail_to.join(","));
104
105 let mut cmd = Command::new("sendmail");
106 cmd.args([
107 "-bm", "-N", "never", // never send DSN (avoid mail loops)
108 "-f", &mail_from, "--",
109 ]);
110 cmd.args(mail_to);
111 cmd.env("PATH", "/sbin:/bin:/usr/sbin:/usr/bin");
112
113 // with status(), child inherits stdin
114 cmd.status()
115 .map_err(|err| format_err!("command {:?} failed - {}", cmd, err))?;
116
117 Ok(())
118 }
119
120 /// Wrapper around `proxmox_sys::fs::file_read_optional_string` which also returns `None` upon error
121 /// after logging it.
122 fn attempt_file_read<P: AsRef<Path>>(path: P) -> Option<String> {
123 match fs::file_read_optional_string(path) {
124 Ok(contents) => contents,
125 Err(err) => {
126 log::error!("{}", err);
127 None
128 }
129 }
130 }
131
132 fn main() {
133 if let Err(err) = syslog::init(
134 syslog::Facility::LOG_DAEMON,
135 log::LevelFilter::Info,
136 Some("proxmox-mail-forward"),
137 ) {
138 eprintln!("unable to inititialize syslog - {}", err);
139 }
140
141 let pbs_user_cfg_content = attempt_file_read(PBS_USER_CFG_FILENAME);
142 let pve_user_cfg_content = attempt_file_read(PVE_USER_CFG_FILENAME);
143 let pve_datacenter_cfg_content = attempt_file_read(PVE_DATACENTER_CFG_FILENAME);
144
145 let real_uid = nix::unistd::getuid();
146 if let Err(err) = nix::unistd::setresuid(real_uid, real_uid, real_uid) {
147 log::error!(
148 "mail forward failed: unable to set effective uid to {}: {}",
149 real_uid,
150 err
151 );
152 return;
153 }
154
155 let pbs_mail_to = pbs_user_cfg_content.and_then(|content| get_pbs_mail_to(&content));
156 let pve_mail_to = pve_user_cfg_content.and_then(|content| get_pve_mail_to(&content));
157 let pve_mail_from = pve_datacenter_cfg_content.and_then(|content| get_pve_mail_from(&content));
158
159 let mail_from = pve_mail_from.unwrap_or_else(|| "root".to_string());
160
161 let mut mail_to = vec![];
162 if let Some(pve_mail_to) = pve_mail_to {
163 mail_to.push(pve_mail_to);
164 }
165 if let Some(pbs_mail_to) = pbs_mail_to {
166 if !mail_to.contains(&pbs_mail_to) {
167 mail_to.push(pbs_mail_to);
168 }
169 }
170
171 if let Err(err) = forward_mail(mail_from, mail_to) {
172 log::error!("mail forward failed: {}", err);
173 }
174 }