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 DCEB81FF0E0 for ; Thu, 09 Jul 2026 11:20:14 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id DB4322153D; Thu, 09 Jul 2026 11:19:37 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Date: Thu, 9 Jul 2026 11:18:28 +0200 Message-ID: <20260709091852.538885-4-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: 1783588730231 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.375 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: SB35UTTNATR3L7I2IBORCEKDPOO45XJD X-Message-ID-Hash: SB35UTTNATR3L7I2IBORCEKDPOO45XJD 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 second NIC-to-group assignment matcher next to the guest matcher: bind every guest matching a set of tags (by any/all/exact) to a set of groups. Expands against the live guest inventory, so a guest tag change surfaces as a pending change. Its id is derived from the tags, match kind and optional iface so identical matchers collapse. An empty tag matcher is rejected on validation. Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/microseg/mod.rs | 467 +++++++++++++++++++++- 1 file changed, 458 insertions(+), 9 deletions(-) diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs index 555effa..fc7d0e2 100644 --- a/proxmox-ve-config/src/sdn/microseg/mod.rs +++ b/proxmox-ve-config/src/sdn/microseg/mod.rs @@ -31,6 +31,22 @@ api_string_type! { pub struct MicrosegId(String); } +pub 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 [`TagAssignmentSection`] matches on. Matched case-sensitively against the + /// guest's configured tags. + #[api(format: &MICROSEG_TAG_FORMAT)] + #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, UpdaterType)] + pub struct Tag(String); +} + #[api()] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -191,7 +207,7 @@ impl RuleSection { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// Binds a specific guest's NIC(s) to a set of groups: all of guest `vmid`'s NICs, or just the one /// named by `iface` when set. Stored as the `guestassignment` section type. A new matcher kind is a -/// new sibling section type discriminated by `type`, not a new field here. +/// new sibling section type (see [`TagAssignmentSection`]), not a new field here. pub struct GuestAssignmentSection { pub(crate) id: MicrosegId, pub(crate) vmid: u32, @@ -203,6 +219,51 @@ pub struct GuestAssignmentSection { pub(crate) comment: Option, } +#[api( + properties: { + tags: { + type: Array, + description: "Guest tags to match on.", + items: { type: String, description: "A guest tag.", format: &MICROSEG_TAG_FORMAT }, + }, + 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 matching `tags` (by `tag_match`) to a set of groups. Expanded +/// against the live guest inventory at render time, so a guest tag change surfaces as a pending SDN +/// change and takes effect on the next apply. Stored as the `tagassignment` section type. +pub struct TagAssignmentSection { + pub(crate) id: MicrosegId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) tags: Vec, + #[serde(default)] + pub(crate) tag_match: PredicateMatch, + #[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": { @@ -222,6 +283,8 @@ pub enum MicrosegEntry { Rule(RuleSection), /// A guest-matcher NIC-to-groups assignment. GuestAssignment(GuestAssignmentSection), + /// A tag-matcher NIC-to-groups assignment. + TagAssignment(TagAssignmentSection), } impl MicrosegEntry { @@ -231,6 +294,7 @@ impl MicrosegEntry { Self::Group(g) => &g.id, Self::Rule(r) => &r.id, Self::GuestAssignment(a) => &a.id, + Self::TagAssignment(a) => &a.id, } } } @@ -274,6 +338,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. A matcher with no `iface` covers all of them. #[serde(default, deserialize_with = "deserialize_perl_nics")] pub nics: Vec, @@ -395,6 +461,12 @@ pub fn validate(entries: &HashMap) -> Result<(), anyhow:: MicrosegEntry::GuestAssignment(assignment) => { check_assignment_groups(name, &assignment.groups)?; } + MicrosegEntry::TagAssignment(assignment) => { + if assignment.tags.is_empty() { + bail!("assignment '{name}' has an empty tag matcher"); + } + check_assignment_groups(name, &assignment.groups)?; + } MicrosegEntry::Group(_) => {} } } @@ -459,6 +531,20 @@ pub fn build_policies( .collect() } +/// Whether the tag set fires on `guest`, per the match kind. +fn tags_fire(tags: &[Tag], tag_match: PredicateMatch, guest: &GuestInfo) -> bool { + let has = |tag: &Tag| guest.tags.iter().any(|t| t == tag); + match tag_match { + PredicateMatch::Any => tags.iter().any(|t| has(t)), + PredicateMatch::All => tags.iter().all(|t| has(t)), + PredicateMatch::Exact => { + let want: BTreeSet<&Tag> = tags.iter().collect(); + let have: BTreeSet<&Tag> = guest.tags.iter().collect(); + want == have + } + } +} + fn covered_nics(iface: Option, nics: &[u32]) -> Vec { match iface { Some(iface) => nics.iter().copied().filter(|&nic| nic == iface).collect(), @@ -475,6 +561,7 @@ pub fn realized_assignments( inventory: &[GuestInfo], ) -> 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 nics_of: HashMap = inventory .iter() @@ -492,10 +579,25 @@ pub fn realized_assignments( .extend(assignment.groups.iter().cloned()); } } + MicrosegEntry::TagAssignment(assignment) => tag_matchers.push(assignment), MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {} } } + for guest in inventory { + for assignment in &tag_matchers { + if !tags_fire(&assignment.tags, assignment.tag_match, guest) { + continue; + } + for nic in covered_nics(assignment.iface, &guest.nics) { + merged + .entry((guest.vmid, nic)) + .or_default() + .extend(assignment.groups.iter().cloned()); + } + } + } + merged .into_iter() .map(|((vmid, iface), groups)| RealizedAssignment { @@ -536,6 +638,17 @@ pub fn realized_per_assignment( }] } } + MicrosegEntry::TagAssignment(assignment) => inventory + .iter() + .filter(|guest| tags_fire(&assignment.tags, assignment.tag_match, guest)) + .filter_map(|guest| { + let nics = covered_nics(assignment.iface, &guest.nics); + (!nics.is_empty()).then(|| AssignmentMember { + vmid: guest.vmid, + nics, + }) + }) + .collect(), MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => { return None; } @@ -734,7 +847,7 @@ pub mod api { use super::{ GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId, - PredicateMatch, RuleSection, + PredicateMatch, RuleSection, Tag, TagAssignmentSection, }; #[derive(Debug, Clone, Deserialize)] @@ -743,6 +856,7 @@ pub mod api { Group(GroupCreate), Rule(RuleCreate), GuestAssignment(GuestAssignmentCreate), + TagAssignment(TagAssignmentCreate), } #[derive(Debug, Clone, Deserialize)] @@ -784,12 +898,27 @@ pub mod api { pub comment: Option, } + #[derive(Debug, Clone, Deserialize)] + pub struct TagAssignmentCreate { + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub tag_match: Option, + #[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 { Group(GroupUpdate), Rule(RuleUpdate), GuestAssignment(AssignmentUpdate), + TagAssignment(AssignmentUpdate), } #[derive(Debug, Clone, Default, Deserialize)] @@ -901,6 +1030,40 @@ pub mod api { format!("r{hash:016x}").parse().map_err(Error::from) } + /// A stable, content-derived id for a tag matcher (FNV-1a over its sorted tags, match kind and + /// optional interface), so identical tag matchers collapse and re-creating one is idempotent. + 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: u64 = 0xcbf2_9ce4_8422_2325; + let mut feed = |byte: u8| { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + }; + feed(tag_match as u8); + match iface { + Some(iface) => { + feed(1); + for byte in iface.to_be_bytes() { + feed(byte); + } + } + None => feed(0), + } + for tag in &sorted { + feed(0xff); + for byte in tag.as_bytes() { + feed(*byte); + } + } + format!("tag{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( @@ -948,6 +1111,18 @@ pub mod api { comment: assignment.comment, }) } + MicrosegCreate::TagAssignment(assignment) => { + let tag_match = assignment.tag_match.unwrap_or_default(); + let id = tag_assignment_id(&assignment.tags, tag_match, assignment.iface)?; + MicrosegEntry::TagAssignment(TagAssignmentSection { + id, + tags: assignment.tags, + tag_match, + iface: assignment.iface, + groups: assignment.groups, + comment: assignment.comment, + }) + } }) } @@ -997,6 +1172,19 @@ pub mod api { } } } + (MicrosegEntry::TagAssignment(assignment), MicrosegUpdate::TagAssignment(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(()) @@ -1036,6 +1224,24 @@ pub mod api { }, assignment.comment.as_deref(), ), + MicrosegEntry::TagAssignment(assignment) => { + let tags = assignment + .tags + .iter() + .map(|t| t.as_ref()) + .collect::>() + .join(", "); + let kind = match assignment.tag_match { + PredicateMatch::Any => "any", + PredicateMatch::All => "all", + PredicateMatch::Exact => "exact", + }; + let base = match assignment.iface { + Some(iface) => format!("tag assignment {kind}{{{tags}}} on net{iface}"), + None => format!("tag assignment {kind}{{{tags}}}"), + }; + (base, assignment.comment.as_deref()) + } MicrosegEntry::Group(_) => ("object".to_string(), None), }; match comment { @@ -1060,6 +1266,9 @@ pub mod api { MicrosegEntry::GuestAssignment(assignment) => { assignment.groups.iter().any(|g| g.as_ref() == name) } + MicrosegEntry::TagAssignment(assignment) => { + assignment.groups.iter().any(|g| g.as_ref() == name) + } MicrosegEntry::Group(_) => false, }) .map(describe_referrer) @@ -1352,13 +1561,6 @@ mod tests { ) } - fn guest(vmid: u32, _tag_names: &[&str], nics: &[u32]) -> GuestInfo { - GuestInfo { - vmid, - nics: nics.to_vec(), - } - } - #[test] fn render_allocates_identities_and_round_trips() { let entries: HashMap = HashMap::from([ @@ -1591,6 +1793,253 @@ mod tests { validate(&ok).expect("a rule may name 'untagged'"); } + fn tag(s: &str) -> Tag { + s.parse().expect("valid tag") + } + + fn tags(names: &[&str]) -> Vec { + names.iter().map(|n| tag(n)).collect() + } + + fn tag_assignment( + name: &str, + tag_names: &[&str], + tag_match: PredicateMatch, + iface: Option, + group_names: &[&str], + ) -> (String, MicrosegEntry) { + ( + name.to_string(), + MicrosegEntry::TagAssignment(TagAssignmentSection { + id: id(name), + tags: tags(tag_names), + tag_match, + iface, + groups: ids(group_names), + comment: None, + }), + ) + } + + fn guest(vmid: u32, tag_names: &[&str], nics: &[u32]) -> GuestInfo { + GuestInfo { + vmid, + tags: tags(tag_names), + nics: nics.to_vec(), + } + } + + fn realized_groups(realized: &[RealizedAssignment]) -> HashMap<(u32, u32), Vec> { + realized + .iter() + .map(|r| { + ( + (r.vmid, r.iface), + r.groups.iter().map(|g| g.to_string()).collect(), + ) + }) + .collect() + } + + #[test] + fn selector_expands_to_all_nics_and_merges_with_static() { + use PredicateMatch::Any; + // tags are guest-level, so a selector with no iface covers every NIC of a matching guest. + let entries: HashMap = HashMap::from([ + group("web", 2), + group("db", 3), + group("mon", 4), + assignment("vm100i0", 100, 0, &["web"]), + tag_assignment("sel_db", &["db"], Any, None, &["db"]), + tag_assignment("sel_mon", &["mon"], Any, None, &["mon"]), + ]); + // vm100 carries db and mon, and has net0 + net1. + let inv = vec![guest(100, &["db", "mon"], &[0, 1])]; + + let by_nic = realized_groups(&realized_assignments(&entries, &inv)); + // net0 unions the static web with both selectors, net1 has only the selectors. + assert_eq!(by_nic[&(100, 0)], vec!["db", "mon", "web"]); + assert_eq!(by_nic[&(100, 1)], vec!["db", "mon"]); + } + + #[test] + fn selector_tag_match_any_all_exact() { + use PredicateMatch::{All, Any, Exact}; + let any = HashMap::from([ + group("gg", 2), + tag_assignment("sel", &["a", "b"], Any, None, &["gg"]), + ]); + let all = HashMap::from([ + group("gg", 2), + tag_assignment("sel", &["a", "b"], All, None, &["gg"]), + ]); + let exact = HashMap::from([ + group("gg", 2), + tag_assignment("sel", &["a", "b"], Exact, None, &["gg"]), + ]); + + let only_a = vec![guest(7, &["a"], &[0])]; + assert_eq!( + realized_assignments(&any, &only_a).len(), + 1, + "any shares a tag" + ); + assert_eq!( + realized_assignments(&all, &only_a).len(), + 0, + "all needs every tag" + ); + + let both = vec![guest(7, &["a", "b"], &[0])]; + assert_eq!( + realized_assignments(&all, &both).len(), + 1, + "all matches with both tags" + ); + + // exact needs the guest's tags to equal the listed set, no more no less + assert_eq!( + realized_assignments(&exact, &both).len(), + 1, + "exact matches the equal set" + ); + let extra = vec![guest(7, &["a", "b", "c"], &[0])]; + assert_eq!( + realized_assignments(&exact, &extra).len(), + 0, + "exact rejects a superset" + ); + } + + #[test] + fn selector_explicit_iface_restricts_to_one_nic() { + use PredicateMatch::Any; + let entries = HashMap::from([ + group("web", 2), + tag_assignment("sel", &["t"], Any, Some(1), &["web"]), + ]); + let inv = vec![guest(5, &["t"], &[0, 1, 2])]; + let realized = realized_assignments(&entries, &inv); + assert_eq!(realized.len(), 1); + assert_eq!((realized[0].vmid, realized[0].iface), (5, 1)); + } + + #[test] + fn selector_scoped_to_absent_nic_covers_nothing() { + use PredicateMatch::Any; + let entries = HashMap::from([ + group("web", 2), + tag_assignment("sel", &["t"], Any, Some(3), &["web"]), + ]); + // the guest matches the tag but has no net3, so the scoped selector realizes no NIC and the + // guest must not surface under the assignment + let inv = vec![guest(5, &["t"], &[0])]; + assert!(realized_assignments(&entries, &inv).is_empty()); + assert!(realized_per_assignment(&entries, &inv)["sel"].is_empty()); + } + + + #[test] + fn render_with_inventory_allocates_tag_identities_and_round_trips() { + use PredicateMatch::Any; + let entries: HashMap = HashMap::from([ + group("web", 2), + group("db", 3), + rule("r1", &["web"], &["db"], true), + tag_assignment("sel_web", &["frontend"], Any, None, &["web"]), + tag_assignment("sel_db", &["backend"], Any, None, &["db"]), + ]); + let inv = vec![ + guest(100, &["frontend"], &[0]), + guest(200, &["backend"], &[0]), + ]; + + let running = render(&identity::Registry::new(), entries, &inv, 0).expect("render"); + // {web} fires the src predicate, {db} the dst predicate -> two classes. + assert_eq!(running.identities().classes().len(), 2); + // two tagged NICs were realized. + assert_eq!(running.realized().len(), 2); + + let json = serde_json::to_string(&running).expect("to json"); + let back: MicrosegRunningConfig = serde_json::from_str(&json).expect("from json"); + assert_eq!( + back, running, + "running config with realized assignments round trips" + ); + } + + #[test] + fn validate_rejects_bad_selectors() { + use PredicateMatch::Any; + let base: HashMap = HashMap::from([group("web", 2)]); + + let mut no_tags = base.clone(); + no_tags.extend([tag_assignment("s1", &[], Any, None, &["web"])]); + assert!(validate(&no_tags).is_err(), "empty tags must fail"); + + let mut no_groups = base.clone(); + no_groups.extend([tag_assignment("s2", &["t"], Any, None, &[])]); + assert!(validate(&no_groups).is_err(), "empty groups must fail"); + + let mut unknown = base.clone(); + unknown.extend([tag_assignment("s3", &["t"], Any, None, &["ghost"])]); + assert!(validate(&unknown).is_err(), "unknown group must fail"); + } + + #[test] + fn selector_section_config_round_trip() { + use PredicateMatch::All; + let entries: HashMap = HashMap::from([ + group("web", 5), + group("db", 7), + tag_assignment("sel1", &["frontend", "prod"], All, Some(0), &["web", "db"]), + ]); + validate(&entries).expect("valid"); + + let data: SectionConfigData = SectionConfigData::from_iter(entries.clone()); + let raw = MicrosegEntry::write_section_config("microseg.cfg", &data).expect("write"); + let parsed: HashMap = + MicrosegEntry::parse_section_config("microseg.cfg", &raw) + .expect("parse") + .into_iter() + .collect(); + + assert_eq!( + parsed, entries, + "selector round trips through section config" + ); + } + + #[test] + fn create_tag_assignment_from_perl_scalars() { + // perlmod hands scalars as strings: iface "1" must parse and tag_match reads the enum form. + let create: api::MicrosegCreate = serde_json::from_str( + r#"{"type":"tagassignment","tags":["db","prod"],"tag_match":"all","iface":"1","groups":["web"]}"#, + ) + .expect("assignment create"); + match &create { + api::MicrosegCreate::TagAssignment(assignment) => { + assert_eq!(assignment.iface, Some(1)); + assert_eq!(assignment.tag_match, Some(PredicateMatch::All)); + assert_eq!(assignment.tags.len(), 2); + } + other => panic!("expected tag assignment, got {other:?}"), + } + + let entries: HashMap = HashMap::from([group("web", 2)]); + let entry = api::build_entry(create, &entries).expect("build tag assignment"); + // a tag matcher gets a content-derived id + assert!(entry.id().to_string().starts_with("tag")); + match entry { + MicrosegEntry::TagAssignment(assignment) => { + assert_eq!(assignment.tags, tags(&["db", "prod"])); + assert_eq!(assignment.tag_match, PredicateMatch::All); + assert_eq!(assignment.iface, Some(1)); + } + other => panic!("expected tag assignment, got {other:?}"), + } + } + #[test] fn guest_info_reads_perl_string_scalar_nics() { // perlmod hands array elements as untyped (string) scalars, the nics deserializer must read -- 2.47.3