From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id D6D2A1FF138 for ; Wed, 04 Feb 2026 17:14:00 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 1AE5819E6E; Wed, 4 Feb 2026 17:14:30 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox 2/5] notify: Add state file handling Date: Wed, 4 Feb 2026 17:13:41 +0100 Message-ID: <20260204161354.458814-3-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260204161354.458814-1-a.bied-charreton@proxmox.com> References: <20260204161354.458814-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.145 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: HIVAO3URMPABXZPA3WKW27BV42JQKWCS X-Message-ID-Hash: HIVAO3URMPABXZPA3WKW27BV42JQKWCS X-MailFrom: abied-charreton@jett.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add State struct abstracting state file deserialization, updates and persistence, as well as an EndpointState marker trait stateful endpoints may implement. Also add a state_file_path method to the crate's Context trait, which allows tests to build their own context instead of depending on statics. As far as SMTP endpoints are concerned, file locks are not necessary. Old Microsoft tokens stay valid for 90 days after refreshes [1], and Google tokens' lifetime is just extended at every use [2], so concurrent reads should not be an issue here. [1] https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens#token-lifetime [2] https://stackoverflow.com/questions/8953983/do-google-refresh-tokens-expire Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/Cargo.toml | 1 + proxmox-notify/debian/control | 2 + proxmox-notify/src/context/mod.rs | 2 + proxmox-notify/src/context/pbs.rs | 4 ++ proxmox-notify/src/context/pve.rs | 4 ++ proxmox-notify/src/context/test.rs | 4 ++ proxmox-notify/src/lib.rs | 60 ++++++++++++++++++++++++++++++ 7 files changed, 77 insertions(+) diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index 52493ef7..daa10296 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -40,6 +40,7 @@ proxmox-sendmail = { workspace = true, optional = true } proxmox-sys = { workspace = true, optional = true } proxmox-time.workspace = true proxmox-uuid = { workspace = true, features = ["serde"] } +nix.workspace = true [features] default = ["sendmail", "gotify", "smtp", "webhook"] diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/control index 7770f5ee..76b8a1fa 100644 --- a/proxmox-notify/debian/control +++ b/proxmox-notify/debian/control @@ -11,6 +11,7 @@ Build-Depends-Arch: cargo:native , librust-handlebars-5+default-dev , librust-http-1+default-dev , librust-lettre-0.11+default-dev (>= 0.11.1-~~) , + librust-nix-0.29+default-dev , librust-oauth2-5+default-dev , librust-openssl-0.10+default-dev , librust-percent-encoding-2+default-dev (>= 2.1-~~) , @@ -52,6 +53,7 @@ Depends: librust-anyhow-1+default-dev, librust-const-format-0.2+default-dev, librust-handlebars-5+default-dev, + librust-nix-0.29+default-dev, librust-oauth2-5+default-dev, librust-openssl-0.10+default-dev, librust-proxmox-http-error-1+default-dev, diff --git a/proxmox-notify/src/context/mod.rs b/proxmox-notify/src/context/mod.rs index 8b6e2c43..86130409 100644 --- a/proxmox-notify/src/context/mod.rs +++ b/proxmox-notify/src/context/mod.rs @@ -32,6 +32,8 @@ pub trait Context: Send + Sync + Debug { namespace: Option<&str>, source: TemplateSource, ) -> Result, Error>; + /// Return the state file, or None if no state file exists for this context. + fn state_file_path(&self) -> &'static str; } #[cfg(not(test))] diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/context/pbs.rs index 3e5da59c..67010060 100644 --- a/proxmox-notify/src/context/pbs.rs +++ b/proxmox-notify/src/context/pbs.rs @@ -125,6 +125,10 @@ impl Context for PBSContext { .map_err(|err| Error::Generic(format!("could not load template: {err}")))?; Ok(template_string) } + + fn state_file_path(&self) -> &'static str { + "/etc/proxmox-backup/notifications.state.json" + } } #[cfg(test)] diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/context/pve.rs index a97cce26..0dffbb11 100644 --- a/proxmox-notify/src/context/pve.rs +++ b/proxmox-notify/src/context/pve.rs @@ -74,6 +74,10 @@ impl Context for PVEContext { .map_err(|err| Error::Generic(format!("could not load template: {err}")))?; Ok(template_string) } + + fn state_file_path(&self) -> &'static str { + "/etc/pve/priv/notifications.state.json" + } } pub static PVE_CONTEXT: PVEContext = PVEContext; diff --git a/proxmox-notify/src/context/test.rs b/proxmox-notify/src/context/test.rs index 2c236b4c..e0236b9c 100644 --- a/proxmox-notify/src/context/test.rs +++ b/proxmox-notify/src/context/test.rs @@ -40,4 +40,8 @@ impl Context for TestContext { ) -> Result, Error> { Ok(Some(String::new())) } + + fn state_file_path(&self) -> &'static str { + "/tmp/notifications.state.json" + } } diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs index 1134027c..a40342cc 100644 --- a/proxmox-notify/src/lib.rs +++ b/proxmox-notify/src/lib.rs @@ -6,6 +6,7 @@ use std::fmt::Display; use std::str::FromStr; use context::context; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; use serde_json::Value; @@ -272,6 +273,65 @@ impl Notification { } } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct State { + #[serde(flatten)] + pub sections: HashMap, +} + +impl FromStr for State { + type Err = Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| Error::ConfigDeserialization(e.into())) + } +} + +/// Marker trait to be implemented by the state structs for stateful endpoints. +pub trait EndpointState: Serialize + DeserializeOwned + Default {} + +impl State { + pub fn from_path>(path: P) -> Result { + let contents = proxmox_sys::fs::file_read_string(path) + .map_err(|e| Error::ConfigDeserialization(e.into()))?; + Self::from_str(&contents) + } + + pub fn persist>(&self, path: P) -> Result<(), Error> { + let state_str = + serde_json::to_string_pretty(self).map_err(|e| Error::ConfigSerialization(e.into()))?; + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o600); + let options = proxmox_sys::fs::CreateOptions::new().perm(mode); + + proxmox_sys::fs::replace_file(path, state_str.as_bytes(), options, true) + .map_err(|e| Error::ConfigSerialization(e.into())) + } + + pub fn get(&self, name: &str) -> Result, Error> { + match self.sections.get(name) { + Some(v) => Ok(Some( + S::deserialize(v).map_err(|e| Error::ConfigDeserialization(e.into()))?, + )), + None => Ok(None), + } + } + + pub fn get_or_default(&self, name: &str) -> Result { + Ok(self.get(name)?.unwrap_or_default()) + } + + pub fn set(&mut self, name: &str, state: &S) -> Result<(), Error> { + let v = serde_json::to_value(state).map_err(|e| Error::ConfigSerialization(e.into()))?; + self.sections.insert(name.to_string(), v); + Ok(()) + } + + pub fn remove(&mut self, name: &str) { + self.sections.remove(name); + } +} + /// Notification configuration #[derive(Debug, Clone)] pub struct Config { -- 2.47.3