From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 6E3241FF0C2 for ; Thu, 22 Oct 2026 16:22:50 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 9878F21720; Thu, 22 Oct 2026 16:22:15 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-cluster 04/12] rust: notify: add subscription matching Date: Thu, 22 Oct 2026 16:21:57 +0200 Message-ID: <20261022142205.930536-5-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261022142205.930536-1-h.laimer@proxmox.com> References: <20261022142205.930536-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1792678931908 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.348 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: IADFNEHGAJIM4KGYESFVTZC2CJDNJ5NV X-Message-ID-Hash: IADFNEHGAJIM4KGYESFVTZC2CJDNJ5NV X-MailFrom: h.laimer@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: A subscription is a set of named templates. An event matches when its path or its rename target matches one of them. A rename carries the captures of each side that matches. A connection without root privileges gets no event for a private path, just as IPC and FUSE hide the content from it. Signed-off-by: Hannes Laimer --- src/rust/pmxcfs-notify/src/conn.rs | 140 +++++++++++++++++++++++++++++ src/rust/pmxcfs-notify/src/lib.rs | 4 +- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/rust/pmxcfs-notify/src/conn.rs diff --git a/src/rust/pmxcfs-notify/src/conn.rs b/src/rust/pmxcfs-notify/src/conn.rs new file mode 100644 index 0000000..451504c --- /dev/null +++ b/src/rust/pmxcfs-notify/src/conn.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeMap; + +use crate::protocol::Params; +use crate::template::Template; + +pub(crate) struct Conn { + privileged: bool, + templates: BTreeMap, +} + +impl Conn { + pub(crate) fn new(privileged: bool) -> Self { + Self { + privileged, + templates: BTreeMap::new(), + } + } + + pub(crate) fn matches<'a>(&'a self, path: &'a str, to: Option<&'a str>) -> Option> { + if !self.privileged && (is_private(path) || to.is_some_and(is_private)) { + return None; + } + let mut params = Params::new(); + for (name, template) in &self.templates { + let mut sets = Vec::new(); + if let Some(captures) = template.matches(path) { + sets.push(captures); + } + // equal captures mean the same path, so a rename onto itself matches once + if let Some(to) = to + && let Some(captures) = template.matches(to) + && sets.first() != Some(&captures) + { + sets.push(captures); + } + if !sets.is_empty() { + params.insert(name.as_str(), sets); + } + } + (!params.is_empty()).then_some(params) + } +} + +// Same rule as path_is_private in the daemon. +fn is_private(path: &str) -> bool { + fn priv_component(path: &str) -> bool { + path == "priv" || path.starts_with("priv/") + } + let path = crate::normalize_path(path); + priv_component(path) + || path + .strip_prefix("nodes/") + .and_then(|rest| rest.split_once('/')) + .is_some_and(|(_, below)| priv_component(below)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn subscribed(privileged: bool, specs: &[(&str, &str)]) -> Conn { + let mut conn = Conn::new(privileged); + conn.templates = specs + .iter() + .map(|(name, text)| ((*name).to_owned(), text.parse().unwrap())) + .collect(); + conn + } + + #[test] + fn matches_per_template() { + let conn = subscribed( + true, + &[ + ("guest", "nodes/{node}/qemu-server/{vmid}.conf"), + ("dc", "datacenter.cfg"), + ], + ); + let params = conn.matches("nodes/n1/qemu-server/100.conf", None).unwrap(); + assert_eq!(params.len(), 1); + assert_eq!( + params["guest"], + vec![BTreeMap::from([("node", "n1"), ("vmid", "100")])] + ); + assert_eq!( + conn.matches("datacenter.cfg", None).unwrap()["dc"], + vec![BTreeMap::new()] + ); + assert!(conn.matches("datacenter.cfg.tmp.1", None).is_none()); + assert!(conn.matches("other", None).is_none()); + assert!(Conn::new(true).matches("datacenter.cfg", None).is_none()); + } + + #[test] + fn rename_matches_on_either_side() { + let conn = subscribed(true, &[("guest", "nodes/{node}/qemu-server/{vmid}.conf")]); + let n1 = "nodes/n1/qemu-server/100.conf"; + let n2 = "nodes/n2/qemu-server/100.conf"; + + let params = conn.matches(n1, Some(n2)).unwrap(); + assert_eq!(params["guest"].len(), 2); + assert_eq!(params["guest"][0]["node"], "n1"); + assert_eq!(params["guest"][1]["node"], "n2"); + + let params = conn + .matches("nodes/n2/qemu-server/100.conf.tmp.1", Some(n2)) + .unwrap(); + assert_eq!(params["guest"].len(), 1); + assert_eq!(params["guest"][0]["node"], "n2"); + + assert_eq!(conn.matches(n1, Some(n1)).unwrap()["guest"].len(), 1); + } + + #[test] + fn private_paths_need_privileges() { + let conn = subscribed(false, &[("all", "{path...}")]); + assert!(conn.matches("datacenter.cfg", None).is_some()); + assert!(conn.matches("priv/lock/x", None).is_none()); + assert!(conn.matches("nodes/n1/priv/ssl.key", None).is_none()); + assert!(conn.matches("datacenter.cfg", Some("priv/moved")).is_none()); + assert!(conn.matches("priv/old", Some("datacenter.cfg")).is_none()); + + let conn = subscribed(true, &[("all", "{path...}")]); + assert!(conn.matches("priv/lock/x", None).is_some()); + assert!(conn.matches("priv/old", Some("datacenter.cfg")).is_some()); + } + + #[test] + fn private_rules() { + assert!(is_private("priv")); + assert!(is_private("priv/lock/x")); + assert!(is_private("/priv/tfa.cfg")); + assert!(is_private("nodes/n1/priv")); + assert!(is_private("nodes/n1/priv/ssl.key")); + assert!(!is_private("private")); + assert!(!is_private("nodes/n1/qemu-server/100.conf")); + assert!(!is_private("nodes/priv")); + assert!(!is_private("datacenter.cfg")); + } +} diff --git a/src/rust/pmxcfs-notify/src/lib.rs b/src/rust/pmxcfs-notify/src/lib.rs index d35d3c5..18e7d2f 100644 --- a/src/rust/pmxcfs-notify/src/lib.rs +++ b/src/rust/pmxcfs-notify/src/lib.rs @@ -10,10 +10,12 @@ //! A template is a memdb path whose components may each hold one `{name}` placeholder for all or //! part of that component. The last component may instead be `{name...}` for the rest of the path. //! `nodes/{node}/qemu-server/{vmid}.conf` captures node and vmid, and `{path...}` matches every -//! path. +//! path. Connections admitted by their group id rather than as root never receive events that +//! mention a private path, just as IPC and FUSE hide private files from them. #![allow(dead_code)] +mod conn; mod protocol; mod template; -- 2.47.3