From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 556D61FF0AE for ; Tue, 27 Oct 2026 09:56:29 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id BB15C2168F; Tue, 27 Oct 2026 09:56:11 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v3 08/45] ve-config: sdn: microseg: add name regex matcher Date: Tue, 27 Oct 2026 09:54:50 +0100 Message-ID: <20261027085527.702738-9-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com> References: <20261027085527.702738-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: 1793091341935 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.341 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: QNVSJAQMWJBFZ7OPHW3SCOC5CIA2PQDL X-Message-ID-Hash: QNVSJAQMWJBFZ7OPHW3SCOC5CIA2PQDL 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: Assign groups to the NICs of every guest whose name matches a regular expression. Like guest tags, the names come from the guest inventory and never enter the running config. A rename takes effect the next time the NICs are resolved. The expression has to be ASCII, like the names it is matched against. A query tells whether renaming a guest changes which expressions match its name. It uses the same regex engine as the matcher, so a caller does not have to match the expressions in a dialect of its own. Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/microseg/api.rs | 42 +++++++++++++++- proxmox-ve-config/src/sdn/microseg/mod.rs | 53 ++++++++++++++++++++- proxmox-ve-config/src/sdn/microseg/types.rs | 30 +++++++++++- 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/proxmox-ve-config/src/sdn/microseg/api.rs b/proxmox-ve-config/src/sdn/microseg/api.rs index 4f47c1a3ed80..08b5ee1f4653 100644 --- a/proxmox-ve-config/src/sdn/microseg/api.rs +++ b/proxmox-ve-config/src/sdn/microseg/api.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use super::types::{ Binding, GroupSection, GuestAssignmentSection, GuestInfo, MicrosegEntry, MicrosegId, - MicrosegRunningConfig, RuleSection, Tag, TagAssignmentSection, + MicrosegRunningConfig, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection, }; use super::{PredicateMatch, assigned_nics, assignments, sorted_rules, validate, validate_entry}; @@ -21,6 +21,7 @@ pub enum MicrosegCreate { Rule(RuleCreate), GuestAssignment(GuestAssignmentCreate), TagAssignment(TagAssignmentCreate), + RegexAssignment(RegexAssignmentCreate), } impl MicrosegCreate { @@ -28,6 +29,7 @@ impl MicrosegCreate { match self { Self::GuestAssignment(a) => Some(&a.binding), Self::TagAssignment(a) => Some(&a.binding), + Self::RegexAssignment(a) => Some(&a.binding), Self::Group(_) | Self::Rule(_) => None, } } @@ -95,6 +97,14 @@ pub struct TagAssignmentCreate { pub binding: BindingCreate, } +#[derive(Debug, Clone, Deserialize)] +pub struct RegexAssignmentCreate { + #[serde(default, rename = "name-regex")] + pub name_regex: String, + #[serde(flatten)] + pub binding: BindingCreate, +} + #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum MicrosegUpdate { @@ -102,6 +112,7 @@ pub enum MicrosegUpdate { Rule(RuleUpdate), GuestAssignment(AssignmentUpdate), TagAssignment(AssignmentUpdate), + RegexAssignment(AssignmentUpdate), } #[derive(Debug, Clone, Deserialize)] @@ -262,6 +273,13 @@ fn tag_assignment_id( format!("tag{:016x}", hash.0).parse() } +fn regex_assignment_id(name_regex: &str, iface: Option) -> Result { + let mut hash = Fnv1a::new(); + hash.iface(iface); + hash.text(name_regex); + format!("re{:016x}", hash.0).parse() +} + fn build_entry( payload: MicrosegCreate, entries: &BTreeMap, @@ -315,6 +333,17 @@ fn build_entry( binding: assignment.binding.into(), }) } + MicrosegCreate::RegexAssignment(assignment) => { + // section-config trims every property value on read, so an untrimmed regex would + // come back under an id derived from text the entry no longer holds + let name_regex = assignment.name_regex.trim().to_string(); + let id = regex_assignment_id(&name_regex, assignment.binding.iface)?; + MicrosegEntry::RegexAssignment(RegexAssignmentSection { + id, + name_regex, + binding: assignment.binding.into(), + }) + } }) } @@ -469,7 +498,8 @@ pub fn update( None } (entry @ MicrosegEntry::GuestAssignment(_), MicrosegUpdate::GuestAssignment(update)) - | (entry @ MicrosegEntry::TagAssignment(_), MicrosegUpdate::TagAssignment(update)) => { + | (entry @ MicrosegEntry::TagAssignment(_), MicrosegUpdate::TagAssignment(update)) + | (entry @ MicrosegEntry::RegexAssignment(_), MicrosegUpdate::RegexAssignment(update)) => { apply_assignment_update(entry, update) } _ => bail!("update type does not match the existing object's type"), @@ -544,6 +574,14 @@ fn describe_referrer(entry: &MicrosegEntry) -> String { ), assignment.binding.comment.as_deref(), ), + MicrosegEntry::RegexAssignment(assignment) => ( + format!( + "regex assignment /{}/{}", + assignment.name_regex, + on_net(assignment.binding.iface), + ), + assignment.binding.comment.as_deref(), + ), MicrosegEntry::Group(_) => ("object".to_string(), None), }; match comment { diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs index 0688d4e58180..620f342ad2b0 100644 --- a/proxmox-ve-config/src/sdn/microseg/mod.rs +++ b/proxmox-ve-config/src/sdn/microseg/mod.rs @@ -15,13 +15,14 @@ mod types; pub use identity::PredicateMatch; pub use types::{ Binding, GroupName, GroupSection, GuestAssignmentSection, GuestInfo, MicrosegEntry, MicrosegId, - MicrosegRunningConfig, RuleSection, Tag, TagAssignmentSection, + MicrosegRunningConfig, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection, }; use std::collections::{BTreeMap, BTreeSet, HashMap}; use anyhow::{Context, Error, bail}; use identity::UNTAGGED_GROUP; +use regex::Regex; use serde::Serialize; /// Validate a microseg config before anything renders from it, so resolving group names and @@ -31,7 +32,9 @@ pub fn validate(entries: &BTreeMap) -> Result<(), Error> for (name, entry) in entries { let kind = match entry { MicrosegEntry::Rule(_) => "rule", - MicrosegEntry::GuestAssignment(_) | MicrosegEntry::TagAssignment(_) => "assignment", + MicrosegEntry::GuestAssignment(_) + | MicrosegEntry::TagAssignment(_) + | MicrosegEntry::RegexAssignment(_) => "assignment", MicrosegEntry::Group(_) => "group", }; validate_entry(entries, &format!("{kind} '{name}'"), entry)?; @@ -55,6 +58,17 @@ fn validate_entry( bail!("{subject} matches tags with 'every', which only a rule predicate takes"); } } + MicrosegEntry::RegexAssignment(assignment) => { + if assignment.name_regex.is_empty() { + bail!("{subject} has an empty name regex"); + } + if !assignment.name_regex.is_ascii() { + bail!("{subject} has a name regex with non-ASCII characters"); + } + if let Err(err) = Regex::new(&assignment.name_regex) { + bail!("{subject} has an invalid name regex: {err}"); + } + } MicrosegEntry::GuestAssignment(_) | MicrosegEntry::Group(_) => {} } if let Some(groups) = entry.assignment_groups() { @@ -212,6 +226,9 @@ fn tags_fire(tags: &[Tag], tag_match: PredicateMatch, guest: &GuestInfo) -> bool enum Matcher<'a> { Guest(u32), Tags(&'a [Tag], PredicateMatch), + /// `None` for a regex that does not compile, which validation rejects, so it fires on nothing + /// rather than failing the resolve. + Name(Option), } struct Assignment<'a> { @@ -227,6 +244,12 @@ impl Assignment<'_> { let on_guest = match &self.matcher { Matcher::Guest(vmid) => *vmid == guest.vmid, Matcher::Tags(tags, tag_match) => tags_fire(tags, *tag_match, guest), + Matcher::Name(regex) => regex.as_ref().is_some_and(|regex| { + guest + .name + .as_deref() + .is_some_and(|name| regex.is_match(name)) + }), }; on_guest && self.iface.is_none_or(|scoped| scoped == iface) } @@ -247,6 +270,9 @@ fn assignments(entries: &BTreeMap) -> Vec> MicrosegEntry::TagAssignment(a) => { (&a.binding, Matcher::Tags(&a.tags, a.tag_match)) } + MicrosegEntry::RegexAssignment(a) => { + (&a.binding, Matcher::Name(Regex::new(&a.name_regex).ok())) + } MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => return None, }; Some(Assignment { @@ -379,6 +405,29 @@ pub fn render( }) } +/// Whether a guest renamed from `old` to `new` enters or leaves the names a regex assignment +/// matches, decided by the same expressions that resolve the NICs. +pub fn name_match_changed( + entries: &BTreeMap, + old: Option<&str>, + new: Option<&str>, +) -> bool { + assignments(entries) + .iter() + .any(|assignment| match &assignment.matcher { + Matcher::Name(regex) => { + let matches = |name: Option<&str>| { + regex + .as_ref() + .zip(name) + .is_some_and(|(regex, name)| regex.is_match(name)) + }; + matches(old) != matches(new) + } + _ => false, + }) +} + /// Each assignment's place in the match order, by id. The first has the lowest number. pub fn assignment_order(entries: &BTreeMap) -> HashMap { assignments(entries) diff --git a/proxmox-ve-config/src/sdn/microseg/types.rs b/proxmox-ve-config/src/sdn/microseg/types.rs index cf6886267e95..18367490e076 100644 --- a/proxmox-ve-config/src/sdn/microseg/types.rs +++ b/proxmox-ve-config/src/sdn/microseg/types.rs @@ -27,7 +27,7 @@ api_string_type! { pub struct MicrosegId(String); } -const GENERATED_ID_REGEX_STR: &str = r"(?:vm[0-9]+(?:i[0-9]+)?|(?:r|tag)[0-9a-f]{16})"; +const GENERATED_ID_REGEX_STR: &str = r"(?:vm[0-9]+(?:i[0-9]+)?|(?:r|tag|re)[0-9a-f]{16})"; const_regex! { GENERATED_ID_REGEX = concatcp!(r"^", GENERATED_ID_REGEX_STR, r"$"); @@ -264,6 +264,27 @@ pub struct TagAssignmentSection { pub(crate) binding: Binding, } +#[api( + properties: { + "name-regex": { + type: String, + max_length: 1024, + description: "Regular expression matched against each guest's name, ASCII only. It matches anywhere in the name, so anchor it with '^' and '$' to match the whole name. A guest without a name is never matched.", + }, + binding: { flatten: true, type: Binding }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Binds the NICs of every guest whose name matches `name_regex` to a set of groups. Stored as the +/// `regex-assignment` section type. +pub struct RegexAssignmentSection { + pub(crate) id: MicrosegId, + #[serde(default, rename = "name-regex")] + pub(crate) name_regex: String, + #[serde(flatten)] + pub(crate) binding: Binding, +} + #[api( "id-property": "id", "id-schema": { @@ -285,6 +306,8 @@ pub enum MicrosegEntry { GuestAssignment(GuestAssignmentSection), /// An assignment by guest tags. TagAssignment(TagAssignmentSection), + /// An assignment by guest name regex. + RegexAssignment(RegexAssignmentSection), } impl MicrosegEntry { @@ -295,6 +318,7 @@ impl MicrosegEntry { Self::Rule(r) => &r.id, Self::GuestAssignment(a) => &a.id, Self::TagAssignment(a) => &a.id, + Self::RegexAssignment(a) => &a.id, } } @@ -302,6 +326,7 @@ impl MicrosegEntry { match self { Self::GuestAssignment(a) => Some(&a.binding), Self::TagAssignment(a) => Some(&a.binding), + Self::RegexAssignment(a) => Some(&a.binding), Self::Group(_) | Self::Rule(_) => None, } } @@ -310,6 +335,7 @@ impl MicrosegEntry { match self { Self::GuestAssignment(a) => Some(&mut a.binding), Self::TagAssignment(a) => Some(&mut a.binding), + Self::RegexAssignment(a) => Some(&mut a.binding), Self::Group(_) | Self::Rule(_) => None, } } @@ -339,6 +365,8 @@ pub struct GuestInfo { pub vmid: u32, #[serde(default)] pub tags: Vec, + #[serde(default)] + pub name: Option, /// The present netN interface indices. #[serde(default, deserialize_with = "deserialize_perl_nics")] pub nics: Vec, -- 2.47.3