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 967D81FF0E0 for ; Thu, 09 Jul 2026 11:19:36 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 66CB22147D; Thu, 09 Jul 2026 11:19:36 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Date: Thu, 9 Jul 2026 11:18:29 +0200 Message-ID: <20260709091852.538885-5-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com> References: <20260709091852.538885-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: 1783588731358 X-SPAM-LEVEL: Spam detection results: 1 AWL 1.500 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) 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: TU4BX43RET7HZO3JUXFKAH2S576F5NE4 X-Message-ID-Hash: TU4BX43RET7HZO3JUXFKAH2S576F5NE4 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 third NIC-to-group assignment matcher next to the guest and tag matchers: bind every guest whose name (qemu name / lxc hostname) matches a regular expression. Like the tag matcher it expands against the live guest inventory, so a rename surfaces as a pending change. Its id is derived from the regex and optional iface so identical matchers collapse. An empty or invalid regex is rejected on validation. Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/microseg/mod.rs | 222 +++++++++++++++++++++- 1 file changed, 216 insertions(+), 6 deletions(-) diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs index fc7d0e2..8522a2c 100644 --- a/proxmox-ve-config/src/sdn/microseg/mod.rs +++ b/proxmox-ve-config/src/sdn/microseg/mod.rs @@ -10,6 +10,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use anyhow::{anyhow, bail}; use const_format::concatcp; +use regex::Regex; use serde::{Deserialize, Serialize}; use proxmox_schema::{ApiStringFormat, UpdaterType, api, api_string_type, const_regex}; @@ -264,6 +265,49 @@ pub struct TagAssignmentSection { pub(crate) comment: Option, } +#[api( + properties: { + name_regex: { + type: String, + max_length: 1024, + description: "Regular expression matched against each guest's name.", + }, + iface: { + type: Integer, + optional: true, + minimum: 0, + maximum: 31, + description: "Restrict to the guest's netN interface. If unset, all NICs of each matching guest.", + }, + groups: { + type: Array, + description: "Groups the matching NICs belong to.", + items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT }, + }, + comment: { + type: String, + optional: true, + max_length: 256, + description: "Free-form comment.", + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Binds the NICs of every guest whose name matches `name_regex` to a set of groups. Expanded +/// against the live guest inventory at render time, so a guest rename surfaces as a pending SDN +/// change and takes effect on the next apply. Stored as the `regexassignment` section type. +pub struct RegexAssignmentSection { + pub(crate) id: MicrosegId, + #[serde(default)] + pub(crate) name_regex: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) iface: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) groups: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) comment: Option, +} + #[api( "id-property": "id", "id-schema": { @@ -285,6 +329,8 @@ pub enum MicrosegEntry { GuestAssignment(GuestAssignmentSection), /// A tag-matcher NIC-to-groups assignment. TagAssignment(TagAssignmentSection), + /// A name-regex-matcher NIC-to-groups assignment. + RegexAssignment(RegexAssignmentSection), } impl MicrosegEntry { @@ -295,6 +341,7 @@ impl MicrosegEntry { Self::Rule(r) => &r.id, Self::GuestAssignment(a) => &a.id, Self::TagAssignment(a) => &a.id, + Self::RegexAssignment(a) => &a.id, } } } @@ -340,18 +387,21 @@ pub struct GuestInfo { pub vmid: u32, #[serde(default)] pub tags: Vec, - /// The present netN interface indices. A matcher with no `iface` covers all of them. + #[serde(default)] + pub name: Option, + /// The present netN interface indices. #[serde(default, deserialize_with = "deserialize_perl_nics")] pub nics: Vec, } -/// The microseg block of the SDN running config (`running.microseg`), an `ids` map of entries -/// keyed by section id. +/// The microseg block of the SDN running config (`running.microseg`), an `ids` map of entries keyed +/// by section id. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct MicrosegRunningConfig { #[serde(default)] ids: HashMap, - /// The compiled identity registry (id to representative set), keeping ids stable across renders. + /// The compiled identity registry (id to representative set), keeping ids stable across + /// renders. #[serde(default)] identities: identity::Registry, /// Concrete per-NIC assignments after expanding selectors against the guest inventory and @@ -467,6 +517,15 @@ pub fn validate(entries: &HashMap) -> Result<(), anyhow:: } check_assignment_groups(name, &assignment.groups)?; } + MicrosegEntry::RegexAssignment(assignment) => { + if assignment.name_regex.is_empty() { + bail!("assignment '{name}' has an empty name regex"); + } + if let Err(err) = Regex::new(&assignment.name_regex) { + bail!("assignment '{name}' has an invalid name regex: {err}"); + } + check_assignment_groups(name, &assignment.groups)?; + } MicrosegEntry::Group(_) => {} } } @@ -562,7 +621,8 @@ pub fn realized_assignments( ) -> Vec { let mut merged: BTreeMap<(u32, u32), BTreeSet> = BTreeMap::new(); let mut tag_matchers: Vec<&TagAssignmentSection> = Vec::new(); - // vmid -> present NICs, so a guest matcher with no iface can cover all of the guest's NICs. + let mut regex_matchers: Vec<(Regex, &RegexAssignmentSection)> = Vec::new(); + let nics_of: HashMap = inventory .iter() .map(|guest| (guest.vmid, guest.nics.as_slice())) @@ -580,6 +640,11 @@ pub fn realized_assignments( } } MicrosegEntry::TagAssignment(assignment) => tag_matchers.push(assignment), + MicrosegEntry::RegexAssignment(assignment) => { + if let Ok(re) = Regex::new(&assignment.name_regex) { + regex_matchers.push((re, assignment)); + } + } MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {} } } @@ -596,6 +661,17 @@ pub fn realized_assignments( .extend(assignment.groups.iter().cloned()); } } + for (re, assignment) in ®ex_matchers { + if !guest.name.as_deref().is_some_and(|name| re.is_match(name)) { + continue; + } + for nic in covered_nics(assignment.iface, &guest.nics) { + merged + .entry((guest.vmid, nic)) + .or_default() + .extend(assignment.groups.iter().cloned()); + } + } } merged @@ -649,6 +725,23 @@ pub fn realized_per_assignment( }) }) .collect(), + MicrosegEntry::RegexAssignment(assignment) => { + match Regex::new(&assignment.name_regex) { + Ok(re) => inventory + .iter() + .filter(|guest| guest.name.as_deref().is_some_and(|n| re.is_match(n))) + .filter_map(|guest| { + let nics = covered_nics(assignment.iface, &guest.nics); + (!nics.is_empty()).then(|| AssignmentMember { + vmid: guest.vmid, + nics, + }) + }) + .collect(), + Err(_) => Vec::new(), + } + } + MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => { return None; } @@ -847,7 +940,7 @@ pub mod api { use super::{ GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId, - PredicateMatch, RuleSection, Tag, TagAssignmentSection, + PredicateMatch, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection, }; #[derive(Debug, Clone, Deserialize)] @@ -857,6 +950,7 @@ pub mod api { Rule(RuleCreate), GuestAssignment(GuestAssignmentCreate), TagAssignment(TagAssignmentCreate), + RegexAssignment(RegexAssignmentCreate), } #[derive(Debug, Clone, Deserialize)] @@ -912,6 +1006,18 @@ pub mod api { pub comment: Option, } + #[derive(Debug, Clone, Deserialize)] + pub struct RegexAssignmentCreate { + #[serde(default)] + pub name_regex: String, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub iface: Option, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub comment: Option, + } + #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum MicrosegUpdate { @@ -919,6 +1025,7 @@ pub mod api { Rule(RuleUpdate), GuestAssignment(AssignmentUpdate), TagAssignment(AssignmentUpdate), + RegexAssignment(AssignmentUpdate), } #[derive(Debug, Clone, Default, Deserialize)] @@ -1064,6 +1171,29 @@ pub mod api { format!("tag{hash:016x}").parse().map_err(Error::from) } + /// A stable, content-derived id for a name-regex matcher (FNV-1a over its regex and optional + /// interface), so identical regex matchers collapse and re-creating one is idempotent. + fn regex_assignment_id(name_regex: &str, iface: Option) -> Result { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + let mut feed = |byte: u8| { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + }; + match iface { + Some(iface) => { + feed(1); + for byte in iface.to_be_bytes() { + feed(byte); + } + } + None => feed(0), + } + for byte in name_regex.as_bytes() { + feed(*byte); + } + format!("re{hash:016x}").parse().map_err(Error::from) + } + /// Build an entry from a create payload, auto-assigning a free group mark when none is given and /// deriving rule and assignment ids from their contents. pub fn build_entry( @@ -1123,6 +1253,16 @@ pub mod api { comment: assignment.comment, }) } + MicrosegCreate::RegexAssignment(assignment) => { + let id = regex_assignment_id(&assignment.name_regex, assignment.iface)?; + MicrosegEntry::RegexAssignment(RegexAssignmentSection { + id, + name_regex: assignment.name_regex, + iface: assignment.iface, + groups: assignment.groups, + comment: assignment.comment, + }) + } }) } @@ -1185,6 +1325,22 @@ pub mod api { } } } + ( + MicrosegEntry::RegexAssignment(assignment), + MicrosegUpdate::RegexAssignment(update), + ) => { + if let Some(groups) = update.groups { + assignment.groups = groups; + } + if update.comment.is_some() { + assignment.comment = update.comment; + } + for property in update.delete { + match property { + AssignmentDeletableProperty::Comment => assignment.comment = None, + } + } + } _ => bail!("update type does not match the existing object's type"), } Ok(()) @@ -1242,6 +1398,15 @@ pub mod api { }; (base, assignment.comment.as_deref()) } + MicrosegEntry::RegexAssignment(assignment) => { + let base = match assignment.iface { + Some(iface) => { + format!("regex assignment /{}/ on net{iface}", assignment.name_regex) + } + None => format!("regex assignment /{}/", assignment.name_regex), + }; + (base, assignment.comment.as_deref()) + } MicrosegEntry::Group(_) => ("object".to_string(), None), }; match comment { @@ -1269,6 +1434,9 @@ pub mod api { MicrosegEntry::TagAssignment(assignment) => { assignment.groups.iter().any(|g| g.as_ref() == name) } + MicrosegEntry::RegexAssignment(assignment) => { + assignment.groups.iter().any(|g| g.as_ref() == name) + } MicrosegEntry::Group(_) => false, }) .map(describe_referrer) @@ -1825,10 +1993,52 @@ mod tests { GuestInfo { vmid, tags: tags(tag_names), + name: None, + nics: nics.to_vec(), + } + } + + fn named_guest(vmid: u32, name: &str, nics: &[u32]) -> GuestInfo { + GuestInfo { + vmid, + tags: vec![], + name: Some(name.to_string()), nics: nics.to_vec(), } } + #[test] + fn regex_assignment_matches_guest_names() { + let entries: HashMap = HashMap::from([ + group("web", 2), + ( + "rweb".to_string(), + MicrosegEntry::RegexAssignment(RegexAssignmentSection { + id: id("rweb"), + name_regex: "^web-".to_string(), + iface: None, + groups: ids(&["web"]), + comment: None, + }), + ), + ]); + let inv = vec![ + named_guest(1, "web-1", &[0]), + named_guest(2, "db-1", &[0]), + guest(3, &[], &[0]), + ]; + let by_nic = realized_groups(&realized_assignments(&entries, &inv)); + assert_eq!(by_nic[&(1, 0)], vec!["web".to_string()]); + assert!( + !by_nic.contains_key(&(2, 0)), + "a non-matching name is excluded" + ); + assert!( + !by_nic.contains_key(&(3, 0)), + "a guest with no name never matches" + ); + } + fn realized_groups(realized: &[RealizedAssignment]) -> HashMap<(u32, u32), Vec> { realized .iter() -- 2.47.3