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 2D58C1FF0AE for ; Tue, 27 Oct 2026 09:56:16 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 8241221665; Tue, 27 Oct 2026 09:55:51 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v3 07/45] ve-config: sdn: microseg: add tag matcher Date: Tue, 27 Oct 2026 09:54:49 +0100 Message-ID: <20261027085527.702738-8-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: 1793091340845 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.334 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: 627PJX2D6QGWJZTBGWLJ5I3J2C7SB4CH X-Message-ID-Hash: 627PJX2D6QGWJZTBGWLJ5I3J2C7SB4CH 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: Bind the NICs of every guest matching a set of tags to a set of groups. The tags are read from the guest inventory whenever NICs are resolved, so a tag change needs no new render of the running config. Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/microseg/api.rs | 70 ++++++++++++++++++++- proxmox-ve-config/src/sdn/microseg/mod.rs | 36 +++++++++-- proxmox-ve-config/src/sdn/microseg/types.rs | 51 ++++++++++++++- 3 files changed, 150 insertions(+), 7 deletions(-) diff --git a/proxmox-ve-config/src/sdn/microseg/api.rs b/proxmox-ve-config/src/sdn/microseg/api.rs index 0c89ba27c21e..4f47c1a3ed80 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, + MicrosegRunningConfig, RuleSection, Tag, TagAssignmentSection, }; use super::{PredicateMatch, assigned_nics, assignments, sorted_rules, validate, validate_entry}; @@ -20,12 +20,14 @@ pub enum MicrosegCreate { Group(GroupSection), Rule(RuleCreate), GuestAssignment(GuestAssignmentCreate), + TagAssignment(TagAssignmentCreate), } impl MicrosegCreate { fn binding(&self) -> Option<&BindingCreate> { match self { Self::GuestAssignment(a) => Some(&a.binding), + Self::TagAssignment(a) => Some(&a.binding), Self::Group(_) | Self::Rule(_) => None, } } @@ -83,12 +85,23 @@ pub struct GuestAssignmentCreate { pub binding: BindingCreate, } +#[derive(Debug, Clone, Deserialize)] +pub struct TagAssignmentCreate { + #[serde(default)] + pub tags: Vec, + #[serde(default, rename = "tag-match")] + pub tag_match: PredicateMatch, + #[serde(flatten)] + pub binding: BindingCreate, +} + #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum MicrosegUpdate { Group(GroupUpdate), Rule(RuleUpdate), GuestAssignment(AssignmentUpdate), + TagAssignment(AssignmentUpdate), } #[derive(Debug, Clone, Deserialize)] @@ -221,6 +234,32 @@ impl Fnv1a { self.text(group); } } + + fn iface(&mut self, iface: Option) { + match iface { + Some(iface) => { + self.byte(1); + self.bytes(&iface.to_be_bytes()); + } + None => self.byte(0), + } + } +} + +/// FNV-1a over the matcher and the interface, so the bound groups can change under the same id. +fn tag_assignment_id( + tags: &[Tag], + tag_match: PredicateMatch, + iface: Option, +) -> Result { + let mut sorted: Vec<&str> = tags.iter().map(AsRef::as_ref).collect(); + sorted.sort_unstable(); + sorted.dedup(); + let mut hash = Fnv1a::new(); + hash.match_kind(tag_match); + hash.iface(iface); + hash.groups(&sorted); + format!("tag{:016x}", hash.0).parse() } fn build_entry( @@ -263,6 +302,19 @@ fn build_entry( binding: assignment.binding.into(), }) } + MicrosegCreate::TagAssignment(assignment) => { + let id = tag_assignment_id( + &assignment.tags, + assignment.tag_match, + assignment.binding.iface, + )?; + MicrosegEntry::TagAssignment(TagAssignmentSection { + id, + tags: assignment.tags, + tag_match: assignment.tag_match, + binding: assignment.binding.into(), + }) + } }) } @@ -416,7 +468,8 @@ pub fn update( } None } - (entry @ MicrosegEntry::GuestAssignment(_), MicrosegUpdate::GuestAssignment(update)) => { + (entry @ MicrosegEntry::GuestAssignment(_), MicrosegUpdate::GuestAssignment(update)) + | (entry @ MicrosegEntry::TagAssignment(_), MicrosegUpdate::TagAssignment(update)) => { apply_assignment_update(entry, update) } _ => bail!("update type does not match the existing object's type"), @@ -469,6 +522,11 @@ fn describe_rule(rule: &RuleSection) -> String { /// A human-readable description of an entry for diagnostics, by what a user would recognize rather /// than by its id. fn describe_referrer(entry: &MicrosegEntry) -> String { + let on_net = |iface: Option| { + iface + .map(|iface| format!(" on net{iface}")) + .unwrap_or_default() + }; let (desc, comment) = match entry { MicrosegEntry::Rule(rule) => (describe_rule(rule), rule.comment.as_deref()), MicrosegEntry::GuestAssignment(assignment) => ( @@ -478,6 +536,14 @@ fn describe_referrer(entry: &MicrosegEntry) -> String { }, assignment.binding.comment.as_deref(), ), + MicrosegEntry::TagAssignment(assignment) => ( + format!( + "tag assignment {}{}", + predicate_str(assignment.tag_match, &assignment.tags), + 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 0a1b1b69225c..0688d4e58180 100644 --- a/proxmox-ve-config/src/sdn/microseg/mod.rs +++ b/proxmox-ve-config/src/sdn/microseg/mod.rs @@ -15,7 +15,7 @@ mod types; pub use identity::PredicateMatch; pub use types::{ Binding, GroupName, GroupSection, GuestAssignmentSection, GuestInfo, MicrosegEntry, MicrosegId, - MicrosegRunningConfig, RuleSection, + MicrosegRunningConfig, RuleSection, Tag, TagAssignmentSection, }; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -31,7 +31,7 @@ pub fn validate(entries: &BTreeMap) -> Result<(), Error> for (name, entry) in entries { let kind = match entry { MicrosegEntry::Rule(_) => "rule", - MicrosegEntry::GuestAssignment(_) => "assignment", + MicrosegEntry::GuestAssignment(_) | MicrosegEntry::TagAssignment(_) => "assignment", MicrosegEntry::Group(_) => "group", }; validate_entry(entries, &format!("{kind} '{name}'"), entry)?; @@ -47,6 +47,14 @@ fn validate_entry( ) -> Result<(), Error> { match entry { MicrosegEntry::Rule(rule) => validate_rule(entries, subject, rule)?, + MicrosegEntry::TagAssignment(assignment) => { + if assignment.tags.is_empty() { + bail!("{subject} has an empty tag matcher"); + } + if assignment.tag_match == PredicateMatch::Every { + bail!("{subject} matches tags with 'every', which only a rule predicate takes"); + } + } MicrosegEntry::GuestAssignment(_) | MicrosegEntry::Group(_) => {} } if let Some(groups) = entry.assignment_groups() { @@ -186,8 +194,24 @@ fn build_policies( .collect()) } -enum Matcher { +fn tags_fire(tags: &[Tag], tag_match: PredicateMatch, guest: &GuestInfo) -> bool { + let has = |tag: &Tag| guest.tags.contains(tag); + match tag_match { + PredicateMatch::Any => tags.iter().any(has), + PredicateMatch::All => tags.iter().all(has), + PredicateMatch::Exact => { + let want: BTreeSet<&Tag> = tags.iter().collect(); + let have: BTreeSet<&Tag> = guest.tags.iter().collect(); + want == have + } + // validation rejects it on an assignment + PredicateMatch::Every => false, + } +} + +enum Matcher<'a> { Guest(u32), + Tags(&'a [Tag], PredicateMatch), } struct Assignment<'a> { @@ -195,13 +219,14 @@ struct Assignment<'a> { order: Option, iface: Option, groups: &'a [MicrosegId], - matcher: Matcher, + matcher: Matcher<'a>, } impl Assignment<'_> { fn covers(&self, guest: &GuestInfo, iface: u32) -> bool { let on_guest = match &self.matcher { Matcher::Guest(vmid) => *vmid == guest.vmid, + Matcher::Tags(tags, tag_match) => tags_fire(tags, *tag_match, guest), }; on_guest && self.iface.is_none_or(|scoped| scoped == iface) } @@ -219,6 +244,9 @@ fn assignments(entries: &BTreeMap) -> Vec> .filter_map(|(id, entry)| { let (binding, matcher) = match entry { MicrosegEntry::GuestAssignment(a) => (&a.binding, Matcher::Guest(a.vmid)), + MicrosegEntry::TagAssignment(a) => { + (&a.binding, Matcher::Tags(&a.tags, a.tag_match)) + } MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => return None, }; Some(Assignment { diff --git a/proxmox-ve-config/src/sdn/microseg/types.rs b/proxmox-ve-config/src/sdn/microseg/types.rs index 5e20ee6ca2df..cf6886267e95 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[0-9a-f]{16})"; +const GENERATED_ID_REGEX_STR: &str = r"(?:vm[0-9]+(?:i[0-9]+)?|(?:r|tag)[0-9a-f]{16})"; const_regex! { GENERATED_ID_REGEX = concatcp!(r"^", GENERATED_ID_REGEX_STR, r"$"); @@ -56,6 +56,21 @@ api_string_type! { pub struct GroupName(String); } +const MICROSEG_TAG_REGEX_STR: &str = r"[a-zA-Z0-9_][a-zA-Z0-9_+.\-]*"; + +const_regex! { + pub MICROSEG_TAG_REGEX = concatcp!(r"^", MICROSEG_TAG_REGEX_STR, r"$"); +} + +pub const MICROSEG_TAG_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&MICROSEG_TAG_REGEX); + +api_string_type! { + /// A guest tag a tag assignment matches on. + #[api(format: &MICROSEG_TAG_FORMAT)] + #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] + pub struct Tag(String); +} + #[api( properties: { comment: { @@ -222,6 +237,33 @@ pub struct GuestAssignmentSection { pub(crate) binding: Binding, } +#[api( + properties: { + tags: { + type: Array, + description: "Guest tags to match on, compared exactly as the guest carries them.", + items: { type: Tag }, + }, + "tag-match": { + type: PredicateMatch, + optional: true, + }, + binding: { flatten: true, type: Binding }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Binds the NICs of every guest matching `tags` (by `tag_match`) to a set of groups. Stored as the +/// `tag-assignment` section type. +pub struct TagAssignmentSection { + pub(crate) id: MicrosegId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) tags: Vec, + #[serde(default, rename = "tag-match")] + pub(crate) tag_match: PredicateMatch, + #[serde(flatten)] + pub(crate) binding: Binding, +} + #[api( "id-property": "id", "id-schema": { @@ -241,6 +283,8 @@ pub enum MicrosegEntry { Rule(RuleSection), /// An assignment by guest id. GuestAssignment(GuestAssignmentSection), + /// An assignment by guest tags. + TagAssignment(TagAssignmentSection), } impl MicrosegEntry { @@ -250,12 +294,14 @@ impl MicrosegEntry { Self::Group(g) => &g.id, Self::Rule(r) => &r.id, Self::GuestAssignment(a) => &a.id, + Self::TagAssignment(a) => &a.id, } } pub(super) fn binding(&self) -> Option<&Binding> { match self { Self::GuestAssignment(a) => Some(&a.binding), + Self::TagAssignment(a) => Some(&a.binding), Self::Group(_) | Self::Rule(_) => None, } } @@ -263,6 +309,7 @@ impl MicrosegEntry { pub(super) fn binding_mut(&mut self) -> Option<&mut Binding> { match self { Self::GuestAssignment(a) => Some(&mut a.binding), + Self::TagAssignment(a) => Some(&mut a.binding), Self::Group(_) | Self::Rule(_) => None, } } @@ -290,6 +337,8 @@ where pub struct GuestInfo { #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] pub vmid: u32, + #[serde(default)] + pub tags: Vec, /// The present netN interface indices. #[serde(default, deserialize_with = "deserialize_perl_nics")] pub nics: Vec, -- 2.47.3