notify: add PVE/PBS context

This commit moves PVEContext from `proxmox-perl-rs` into the
`proxmox-notify` crate, since we now also need to access it from
`promxox-mail-forward`. The context is now hidden behind a feature
flag `pve-context`, ensuring that we only compile it when needed.

This commit adds PBSContext, since we now require it for
`proxmox-mail-forward`. Some of the code for PBSContext comes
from `proxmox-mail-forward`.

This commit also changes the global context from being stored in a
`once_cell` to a regular `Mutex`, since we now need to set/reset
the context in `proxmox-mail-forward`.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
This commit is contained in:
Lukas Wagner 2023-11-14 13:59:19 +01:00 committed by Thomas Lamprecht
parent 5f7ac875f6
commit c1a3505e51
6 changed files with 277 additions and 22 deletions

View File

@ -13,7 +13,6 @@ handlebars = { workspace = true }
lazy_static.workspace = true
log.workspace = true
mail-parser = { workspace = true, optional = true }
once_cell.workspace = true
openssl.workspace = true
proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
proxmox-http-error.workspace = true
@ -32,3 +31,5 @@ default = ["sendmail", "gotify"]
mail-forwarder = ["dep:mail-parser"]
sendmail = ["dep:proxmox-sys"]
gotify = ["dep:proxmox-http"]
pve-context = ["dep:proxmox-sys"]
pbs-context = ["dep:proxmox-sys"]

View File

@ -1,21 +0,0 @@
use std::fmt::Debug;
use once_cell::sync::OnceCell;
pub trait Context: Send + Sync + Debug {
fn lookup_email_for_user(&self, user: &str) -> Option<String>;
fn default_sendmail_author(&self) -> String;
fn default_sendmail_from(&self) -> String;
fn http_proxy_config(&self) -> Option<String>;
}
static CONTEXT: OnceCell<&'static dyn Context> = OnceCell::new();
pub fn set_context(context: &'static dyn Context) {
CONTEXT.set(context).expect("context has already been set");
}
#[allow(unused)] // context is not used if all endpoint features are disabled
pub(crate) fn context() -> &'static dyn Context {
*CONTEXT.get().expect("context has not been yet")
}

View File

@ -0,0 +1,27 @@
use std::path::Path;
pub(crate) fn attempt_file_read<P: AsRef<Path>>(path: P) -> Option<String> {
match proxmox_sys::fs::file_read_optional_string(path) {
Ok(contents) => contents,
Err(err) => {
log::error!("{err}");
None
}
}
}
pub(crate) fn lookup_datacenter_config_key(content: &str, key: &str) -> Option<String> {
let key_prefix = format!("{key}:");
normalize_for_return(
content
.lines()
.find_map(|line| line.strip_prefix(&key_prefix)),
)
}
pub(crate) fn normalize_for_return(s: Option<&str>) -> Option<String> {
match s?.trim() {
"" => None,
s => Some(s.to_string()),
}
}

View File

@ -0,0 +1,36 @@
use std::fmt::Debug;
use std::sync::Mutex;
#[cfg(any(feature = "pve-context", feature = "pbs-context"))]
pub mod common;
#[cfg(feature = "pbs-context")]
pub mod pbs;
#[cfg(feature = "pve-context")]
pub mod pve;
/// Product-specific context
pub trait Context: Send + Sync + Debug {
/// Look up a user's email address from users.cfg
fn lookup_email_for_user(&self, user: &str) -> Option<String>;
/// Default mail author for mail-based targets
fn default_sendmail_author(&self) -> String;
/// Default from address for sendmail-based targets
fn default_sendmail_from(&self) -> String;
/// Proxy configuration for the current node
fn http_proxy_config(&self) -> Option<String>;
}
static CONTEXT: Mutex<Option<&'static dyn Context>> = Mutex::new(None);
/// Set the product-specific context
pub fn set_context(context: &'static dyn Context) {
*CONTEXT.lock().unwrap() = Some(context);
}
/// Get product-specific context.
///
/// Panics if the context has not been set yet.
#[allow(unused)] // context is not used if all endpoint features are disabled
pub(crate) fn context() -> &'static dyn Context {
(*CONTEXT.lock().unwrap()).expect("context for proxmox-notify has not been set yet")
}

View File

@ -0,0 +1,130 @@
use serde::Deserialize;
use proxmox_schema::{ObjectSchema, Schema, StringSchema};
use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
use crate::context::{common, Context};
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
const PBS_NODE_CFG_FILENAME: &str = "/etc/proxmox-backup/node.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<String>,
}
/// Extract the root user's email address from the PBS user config.
fn lookup_mail_address(content: &str, username: &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(username)?;
match parsed.lookup::<DummyPbsUser>("user", username) {
Ok(user) => common::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
}
}
}
#[derive(Debug)]
pub struct PBSContext;
pub static PBS_CONTEXT: PBSContext = PBSContext;
impl Context for PBSContext {
fn lookup_email_for_user(&self, user: &str) -> Option<String> {
let content = common::attempt_file_read(PBS_USER_CFG_FILENAME);
content.and_then(|content| lookup_mail_address(&content, user))
}
fn default_sendmail_author(&self) -> String {
"Proxmox Backup Server".into()
}
fn default_sendmail_from(&self) -> String {
let content = common::attempt_file_read(PBS_NODE_CFG_FILENAME);
content
.and_then(|content| common::lookup_datacenter_config_key(&content, "email-from"))
.unwrap_or_else(|| String::from("root"))
}
fn http_proxy_config(&self) -> Option<String> {
let content = common::attempt_file_read(PBS_NODE_CFG_FILENAME);
content.and_then(|content| common::lookup_datacenter_config_key(&content, "http-proxy"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const USER_CONFIG: &str = "
user: root@pam
email root@example.com
user: test@pbs
enable true
expire 0
";
#[test]
fn test_parse_mail() {
assert_eq!(
lookup_mail_address(USER_CONFIG, "root@pam"),
Some("root@example.com".to_string())
);
assert_eq!(lookup_mail_address(USER_CONFIG, "test@pbs"), None);
}
const NODE_CONFIG: &str = "
default-lang: de
email-from: root@example.com
http-proxy: http://localhost:1234
";
#[test]
fn test_parse_node_config() {
assert_eq!(
common::lookup_datacenter_config_key(NODE_CONFIG, "email-from"),
Some("root@example.com".to_string())
);
assert_eq!(
common::lookup_datacenter_config_key(NODE_CONFIG, "http-proxy"),
Some("http://localhost:1234".to_string())
);
assert_eq!(
common::lookup_datacenter_config_key(NODE_CONFIG, "foo"),
None
);
}
}

View File

@ -0,0 +1,82 @@
use crate::context::{common, Context};
fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
common::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() == user {
true => fields.get(6).copied(),
false => None,
}
}))
}
#[derive(Debug)]
pub struct PVEContext;
impl Context for PVEContext {
fn lookup_email_for_user(&self, user: &str) -> Option<String> {
let content = common::attempt_file_read("/etc/pve/user.cfg");
content.and_then(|content| lookup_mail_address(&content, user))
}
fn default_sendmail_author(&self) -> String {
"Proxmox VE".into()
}
fn default_sendmail_from(&self) -> String {
let content = common::attempt_file_read("/etc/pve/datacenter.cfg");
content
.and_then(|content| common::lookup_datacenter_config_key(&content, "email_from"))
.unwrap_or_else(|| String::from("root"))
}
fn http_proxy_config(&self) -> Option<String> {
let content = common::attempt_file_read("/etc/pve/datacenter.cfg");
content.and_then(|content| common::lookup_datacenter_config_key(&content, "http_proxy"))
}
}
pub static PVE_CONTEXT: PVEContext = PVEContext;
#[cfg(test)]
mod tests {
use super::*;
const USER_CONFIG: &str = "
user:root@pam:1:0:::root@example.com:::
user:test@pve:1:0:::test@example.com:::
user:no-mail@pve:1:0::::::
";
#[test]
fn test_parse_mail() {
assert_eq!(
lookup_mail_address(USER_CONFIG, "root@pam"),
Some("root@example.com".to_string())
);
assert_eq!(
lookup_mail_address(USER_CONFIG, "test@pve"),
Some("test@example.com".to_string())
);
assert_eq!(lookup_mail_address(USER_CONFIG, "no-mail@pve"), None);
}
const DC_CONFIG: &str = "
email_from: user@example.com
http_proxy: http://localhost:1234
keyboard: en-us
";
#[test]
fn test_parse_dc_config() {
assert_eq!(
common::lookup_datacenter_config_key(DC_CONFIG, "email_from"),
Some("user@example.com".to_string())
);
assert_eq!(
common::lookup_datacenter_config_key(DC_CONFIG, "http_proxy"),
Some("http://localhost:1234".to_string())
);
assert_eq!(common::lookup_datacenter_config_key(DC_CONFIG, "foo"), None);
}
}