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 1B1411FF0AE for ; Tue, 27 Oct 2026 09:57:36 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 6B9912174B; Tue, 27 Oct 2026 09:56:30 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v3 06/45] ve-config: sdn: microseg: add config types Date: Tue, 27 Oct 2026 09:54:48 +0100 Message-ID: <20261027085527.702738-7-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: 1793091339756 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.665 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) KAM_MAILER 2 Automated Mailer Tag Left in Email 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: ESMAEWP2MVQCHU2QGEFEQFPI6ISP2D5J X-Message-ID-Hash: ESMAEWP2MVQCHU2QGEFEQFPI6ISP2D5J 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: Add the microseg section config together with the operations that change it and its render into the SDN running config. An assignment puts one or all NICs of a guest into a set of groups, and a rule allows or denies traffic between NICs by the groups they are in. Among the matching rules the highest priority decides, and at the same priority a deny wins. Traffic no rule matches is denied. A NIC gets the groups of the first assignment in the match order that covers it, and nothing is merged across assignments. The render allocates an id for the set of every assignment without looking at the guests, so the running config already holds the id of a NIC that appears later. Rule and assignment ids are derived from what they match. An object deleted and created again keeps its id, and the same object cannot be created twice. A group cannot take a name shaped like a derived id, since all entries share one id space. The place of an assignment in the match order is stored as a number with gaps, so moving one usually changes only its own entry. A new assignment goes first unless a position is given. Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/microseg/api.rs | 528 ++++++++++++++ .../src/sdn/microseg/identity.rs | 19 +- proxmox-ve-config/src/sdn/microseg/mod.rs | 669 +++++++++++++++++- proxmox-ve-config/src/sdn/microseg/types.rs | 329 +++++++++ 4 files changed, 1536 insertions(+), 9 deletions(-) create mode 100644 proxmox-ve-config/src/sdn/microseg/api.rs create mode 100644 proxmox-ve-config/src/sdn/microseg/types.rs diff --git a/proxmox-ve-config/src/sdn/microseg/api.rs b/proxmox-ve-config/src/sdn/microseg/api.rs new file mode 100644 index 000000000000..0c89ba27c21e --- /dev/null +++ b/proxmox-ve-config/src/sdn/microseg/api.rs @@ -0,0 +1,528 @@ +//! The API side of the config, its parameter types, the operations that change entries and derive +//! their ids, and the texts it shows. Parameters arrive as an untyped raw hash and deserialize into +//! [`MicrosegCreate`] or [`MicrosegUpdate`]. + +use std::collections::BTreeMap; + +use anyhow::{Error, bail}; +use proxmox_section_config::typed::ApiSectionDataEntry; +use serde::Deserialize; + +use super::types::{ + Binding, GroupSection, GuestAssignmentSection, GuestInfo, MicrosegEntry, MicrosegId, + MicrosegRunningConfig, RuleSection, +}; +use super::{PredicateMatch, assigned_nics, assignments, sorted_rules, validate, validate_entry}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum MicrosegCreate { + Group(GroupSection), + Rule(RuleCreate), + GuestAssignment(GuestAssignmentCreate), +} + +impl MicrosegCreate { + fn binding(&self) -> Option<&BindingCreate> { + match self { + Self::GuestAssignment(a) => Some(&a.binding), + Self::Group(_) | Self::Rule(_) => None, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RuleCreate { + #[serde(default)] + pub src: Vec, + #[serde(default, rename = "src-match")] + pub src_match: PredicateMatch, + #[serde(default)] + pub dst: Vec, + #[serde(default, rename = "dst-match")] + pub dst_match: PredicateMatch, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub bidirectional: bool, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u16")] + pub prio: u16, + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub allow: bool, + #[serde(default)] + pub comment: Option, +} + +/// What a new assignment binds, with the `position` it asks for in the match order. +#[derive(Debug, Clone, Deserialize)] +pub struct BindingCreate { + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub iface: Option, + #[serde(default)] + pub groups: Vec, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub position: Option, + #[serde(default)] + pub comment: Option, +} + +impl From for Binding { + fn from(create: BindingCreate) -> Self { + Binding { + iface: create.iface, + groups: unique_groups(create.groups), + order: None, + comment: create.comment, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GuestAssignmentCreate { + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub vmid: u32, + #[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), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GroupUpdate { + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub delete: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GroupDeletableProperties { + Comment, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RuleUpdate { + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u16")] + pub prio: Option, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub allow: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub delete: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleDeletableProperties { + Comment, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AssignmentUpdate { + #[serde(default)] + pub groups: Option>, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub position: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub delete: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AssignmentDeletableProperties { + Comment, +} + +fn unique_groups(groups: Vec) -> Vec { + let mut unique: Vec = Vec::with_capacity(groups.len()); + for group in groups { + if !unique.contains(&group) { + unique.push(group); + } + } + unique +} + +fn sorted_groups<'a>( + names: &'a [MicrosegId], + entries: &BTreeMap, +) -> Result, Error> { + for name in names { + super::known_group(entries, name.as_ref())?; + } + let mut groups: Vec<&str> = names.iter().map(|name| name.as_ref()).collect(); + groups.sort_unstable(); + groups.dedup(); + Ok(groups) +} + +/// FNV-1a over what the rule matches. Priority, verdict and comment stay out, so they can +/// change under the same id. +fn rule_id( + src_match: PredicateMatch, + src_groups: &[&str], + dst_match: PredicateMatch, + dst_groups: &[&str], + bidirectional: bool, +) -> Result { + let mut hash = Fnv1a::new(); + hash.match_kind(src_match); + hash.groups(src_groups); + hash.match_kind(dst_match); + hash.groups(dst_groups); + hash.flag(bidirectional); + format!("r{:016x}", hash.0).parse() +} + +/// Every input is self-delimiting, so a value cannot shift into the field after it. +struct Fnv1a(u64); + +impl Fnv1a { + fn new() -> Self { + Self(0xcbf2_9ce4_8422_2325) + } + + fn byte(&mut self, byte: u8) { + self.0 ^= u64::from(byte); + self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); + } + + fn bytes(&mut self, bytes: &[u8]) { + for byte in bytes { + self.byte(*byte); + } + } + + fn text(&mut self, text: &str) { + self.bytes(text.as_bytes()); + self.byte(0); + } + + fn flag(&mut self, flag: bool) { + self.byte(u8::from(flag)); + } + + /// By name rather than by discriminant, so reordering the variants keeps the ids. + fn match_kind(&mut self, match_kind: PredicateMatch) { + self.text(match_kind.as_str()); + } + + fn groups(&mut self, groups: &[&str]) { + self.bytes(&(groups.len() as u16).to_be_bytes()); + for group in groups { + self.text(group); + } + } +} + +fn build_entry( + payload: MicrosegCreate, + entries: &BTreeMap, +) -> Result { + Ok(match payload { + MicrosegCreate::Group(group) => MicrosegEntry::Group(group), + MicrosegCreate::Rule(rule) => { + let src_groups = sorted_groups(&rule.src, entries)?; + let dst_groups = sorted_groups(&rule.dst, entries)?; + let id = rule_id( + rule.src_match, + &src_groups, + rule.dst_match, + &dst_groups, + rule.bidirectional, + )?; + MicrosegEntry::Rule(RuleSection { + id, + src: unique_groups(rule.src), + src_match: rule.src_match, + dst: unique_groups(rule.dst), + dst_match: rule.dst_match, + bidirectional: rule.bidirectional, + prio: rule.prio, + allow: rule.allow, + comment: rule.comment, + }) + } + MicrosegCreate::GuestAssignment(assignment) => { + let vmid = assignment.vmid; + let id = match assignment.binding.iface { + Some(iface) => format!("vm{vmid}i{iface}").parse()?, + None => format!("vm{vmid}").parse()?, + }; + MicrosegEntry::GuestAssignment(GuestAssignmentSection { + id, + vmid, + binding: assignment.binding.into(), + }) + } + }) +} + +/// The `order` of the first assignment. +const ORDER_START: u32 = 1_000_000; +/// How far from its neighbor a new first or last assignment goes, which leaves free +/// numbers in front of the list and between two assignments. +const ORDER_GAP: u32 = 1_000; + +/// Move assignment `id` to `position` in the match order, counted without it. Only its own +/// `order` changes while a number is free at that place, otherwise every assignment gets a +/// new one. +fn place_assignment(entries: &mut BTreeMap, id: &str, position: u32) { + let mut others: Vec<(String, Option)> = Vec::new(); + let mut current = None; + for assignment in assignments(entries) { + if assignment.id == id { + current = Some((others.len(), assignment.order)); + } else { + others.push((assignment.id.to_string(), assignment.order)); + } + } + let Some((current, order)) = current else { + return; + }; + let position = (position as usize).min(others.len()); + // a new assignment doesn't have an order yet, so never return early + if position == current && order.is_some() { + return; + } + + let below = position.checked_sub(1).map(|index| others[index].1); + let above = others.get(position).map(|other| other.1); + let free = match (below, above) { + (None, None | Some(None)) => Some(ORDER_START), + (None, Some(Some(above))) => above.checked_sub(ORDER_GAP), + (Some(Some(below)), None | Some(None)) => below.checked_add(ORDER_GAP), + (Some(Some(below)), Some(Some(above))) => { + Some(below + (above - below) / 2).filter(|number| *number > below) + } + // nothing sorts after an assignment without a number + (Some(None), _) => None, + }; + + let mut set_order = |id: &str, number: u32| { + if let Some(binding) = entries.get_mut(id).and_then(MicrosegEntry::binding_mut) { + binding.order = Some(number); + } + }; + match free { + Some(number) => set_order(id, number), + None => { + others.insert(position, (id.to_string(), None)); + // the size limit of the config file keeps the list far shorter than the numbers + // reach + for (index, (id, _)) in others.iter().enumerate() { + set_order(id, ORDER_START + index as u32 * ORDER_GAP); + } + } + } +} + +/// Add the entry a create payload describes and return its id. An assignment goes to the +/// `position` asked for, the top when there is none. +pub fn create( + entries: &mut BTreeMap, + payload: MicrosegCreate, +) -> Result { + let position = payload + .binding() + .map(|binding| binding.position.unwrap_or(0)); + let entry = build_entry(payload, entries)?; + let id = entry.id().to_string(); + + if let Some(existing) = entries.get(&id) { + if matches!(entry, MicrosegEntry::Group(_)) { + bail!("microseg object '{id}' already exists"); + } + // a group cannot be named like a generated id, so the entry under it was generated from the + // same fields + bail!( + "a {} with the same identifying fields already exists as '{id}'", + existing.section_type() + ); + } + + // named by its contents, since the user has not seen the generated id yet + let subject = describe_referrer(&entry); + entries.insert(id.clone(), entry); + validate_entry(entries, &subject, &entries[&id])?; + if let Some(position) = position { + place_assignment(entries, &id, position); + } + validate(entries)?; + + Ok(id) +} + +fn apply_assignment_update(entry: &mut MicrosegEntry, update: AssignmentUpdate) -> Option { + let binding = entry.binding_mut()?; + if let Some(groups) = update.groups { + binding.groups = unique_groups(groups); + } + if update.comment.is_some() { + binding.comment = update.comment; + } + for property in update.delete { + match property { + AssignmentDeletableProperties::Comment => binding.comment = None, + } + } + update.position +} + +/// Apply a partial update to entry `id`, which has to be of the update's type. An assignment +/// given a `position` moves there. +pub fn update( + entries: &mut BTreeMap, + id: &str, + update: MicrosegUpdate, +) -> Result<(), Error> { + let Some(entry) = entries.get_mut(id) else { + bail!("microseg object '{id}' does not exist"); + }; + let position = match (entry, update) { + (MicrosegEntry::Group(group), MicrosegUpdate::Group(update)) => { + if update.comment.is_some() { + group.comment = update.comment; + } + for property in update.delete { + match property { + GroupDeletableProperties::Comment => group.comment = None, + } + } + None + } + (MicrosegEntry::Rule(rule), MicrosegUpdate::Rule(update)) => { + if let Some(prio) = update.prio { + rule.prio = prio; + } + if let Some(allow) = update.allow { + rule.allow = allow; + } + if update.comment.is_some() { + rule.comment = update.comment; + } + for property in update.delete { + match property { + RuleDeletableProperties::Comment => rule.comment = None, + } + } + None + } + (entry @ MicrosegEntry::GuestAssignment(_), MicrosegUpdate::GuestAssignment(update)) => { + apply_assignment_update(entry, update) + } + _ => bail!("update type does not match the existing object's type"), + }; + if let Some(position) = position { + place_assignment(entries, id, position); + } + validate(entries) +} + +/// Remove entry `id`. Returns Err for a group another entry still references. +pub fn delete(entries: &mut BTreeMap, id: &str) -> Result<(), Error> { + let Some(entry) = entries.get(id) else { + bail!("microseg object '{id}' does not exist"); + }; + + if matches!(entry, MicrosegEntry::Group(_)) { + let referrers = group_referenced_by(entries, id); + if !referrers.is_empty() { + bail!( + "group '{id}' is still referenced by {}", + referrers.join(", ") + ); + } + } + + entries.remove(id); + validate(entries) +} + +fn predicate_str(match_kind: PredicateMatch, groups: &[impl AsRef]) -> String { + let kind = match_kind.as_str(); + let list = groups + .iter() + .map(|g| g.as_ref()) + .collect::>() + .join(", "); + format!("{kind}{{{list}}}") +} + +fn describe_rule(rule: &RuleSection) -> String { + let arrow = if rule.bidirectional { "<->" } else { "->" }; + format!( + "rule {} {arrow} {}", + predicate_str(rule.src_match, &rule.src), + predicate_str(rule.dst_match, &rule.dst), + ) +} + +/// 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 (desc, comment) = match entry { + MicrosegEntry::Rule(rule) => (describe_rule(rule), rule.comment.as_deref()), + MicrosegEntry::GuestAssignment(assignment) => ( + match assignment.binding.iface { + Some(iface) => format!("guest assignment vm{} net{iface}", assignment.vmid), + None => format!("guest assignment vm{}", assignment.vmid), + }, + assignment.binding.comment.as_deref(), + ), + MicrosegEntry::Group(_) => ("object".to_string(), None), + }; + match comment { + Some(comment) => format!("{desc} (comment: \"{comment}\")"), + None => desc, + } +} + +/// The microseg state as text in a stable order, so two states can be compared as a diff. +pub fn summary(cfg: &MicrosegRunningConfig, inventory: &[GuestInfo]) -> String { + let mut lines = vec!["identities:".to_string()]; + for &id in cfg.identities().classes().keys() { + lines.push(format!(" {}", cfg.describe_class(id))); + } + lines.push("interfaces:".to_string()); + for nic in assigned_nics(cfg.ids(), inventory) { + let mut groups: Vec<&str> = nic.groups.iter().map(|group| group.as_ref()).collect(); + groups.sort_unstable(); + let groups = groups.join(", "); + lines.push(format!(" vm{} net{} -> {groups}", nic.vmid, nic.iface)); + } + lines.push("rules:".to_string()); + for rule in sorted_rules(cfg.ids()) { + let verdict = if rule.allow { "allow" } else { "deny" }; + let desc = describe_rule(rule); + lines.push(format!(" {desc}: {verdict} prio {}", rule.prio)); + } + lines.join("\n") + "\n" +} + +fn group_referenced_by(entries: &BTreeMap, name: &str) -> Vec { + let mut referrers: Vec = entries + .values() + .filter(|entry| match entry { + MicrosegEntry::Rule(rule) => { + rule.src.iter().any(|g| g.as_ref() == name) + || rule.dst.iter().any(|g| g.as_ref() == name) + } + MicrosegEntry::Group(_) => false, + _ => entry + .assignment_groups() + .is_some_and(|groups| groups.iter().any(|g| g.as_ref() == name)), + }) + .map(describe_referrer) + .collect(); + referrers.sort(); + referrers +} diff --git a/proxmox-ve-config/src/sdn/microseg/identity.rs b/proxmox-ve-config/src/sdn/microseg/identity.rs index 24e91402070e..eb8317076b44 100644 --- a/proxmox-ve-config/src/sdn/microseg/identity.rs +++ b/proxmox-ve-config/src/sdn/microseg/identity.rs @@ -56,9 +56,20 @@ pub enum PredicateMatch { Every, } -/// A predicate over group names, the src or dst half of a policy. -/// Validation refuses an empty `groups` except for `Every`, which lists nothing and never fires -/// on the untagged identity. +impl PredicateMatch { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + Self::All => "all", + Self::Exact => "exact", + Self::Every => "every", + } + } +} + +/// A predicate over group names, the src or dst half of a policy. Validation refuses an +/// empty `groups` except for `Every`, which lists nothing and never fires on the untagged +/// identity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Predicate { pub(crate) kind: PredicateMatch, @@ -157,7 +168,7 @@ impl Registry { } } - pub fn classes(&self) -> &BTreeMap { + pub(crate) fn classes(&self) -> &BTreeMap { &self.classes } diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs index 37c4f92ff0c8..0a1b1b69225c 100644 --- a/proxmox-ve-config/src/sdn/microseg/mod.rs +++ b/proxmox-ve-config/src/sdn/microseg/mod.rs @@ -1,9 +1,668 @@ //! Microseg subsystem of the SDN config. //! -//! Microseg is an identity-based stateless firewall. Each managed guest NIC is assigned a *set* of -//! *groups*. The render allocates a wire identity for each set (see [`identity`]), and every host -//! gets the same ids. Each host looks the identities up and compiles the policy *rules* into a -//! `(src_id, dst_id) -> allow|deny` map. A rule matches traffic by a `src` and a `dst` -//! *predicate*, each a match kind over a list of groups. +//! Microseg is an identity-based stateless firewall. Each managed guest NIC gets the *set* of +//! *groups* of the first *assignment* in admin order that covers it. The render allocates a wire +//! identity for the set of every assignment (see [`identity`]), and the running config carries it +//! to every host, so a NIC can only land in a class that already exists. Each host looks the +//! identities up and compiles the policy *rules* into a `(src_id, dst_id) -> allow|deny` map. A +//! rule matches traffic by a `src` and a `dst` *predicate*, each a match kind over a list of +//! groups. +pub mod api; pub mod identity; +mod types; + +pub use identity::PredicateMatch; +pub use types::{ + Binding, GroupName, GroupSection, GuestAssignmentSection, GuestInfo, MicrosegEntry, MicrosegId, + MicrosegRunningConfig, RuleSection, +}; + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use anyhow::{Context, Error, bail}; +use identity::UNTAGGED_GROUP; +use serde::Serialize; + +/// Validate a microseg config before anything renders from it, so resolving group names and +/// expanding rules cannot fail further down. +pub fn validate(entries: &BTreeMap) -> Result<(), Error> { + // the first invalid entry ends the walk, so walking by id reports the same one every run + for (name, entry) in entries { + let kind = match entry { + MicrosegEntry::Rule(_) => "rule", + MicrosegEntry::GuestAssignment(_) => "assignment", + MicrosegEntry::Group(_) => "group", + }; + validate_entry(entries, &format!("{kind} '{name}'"), entry)?; + } + + Ok(()) +} + +fn validate_entry( + entries: &BTreeMap, + subject: &str, + entry: &MicrosegEntry, +) -> Result<(), Error> { + match entry { + MicrosegEntry::Rule(rule) => validate_rule(entries, subject, rule)?, + MicrosegEntry::GuestAssignment(_) | MicrosegEntry::Group(_) => {} + } + if let Some(groups) = entry.assignment_groups() { + if groups.is_empty() { + bail!("{subject} is not assigned to any group"); + } + for group in groups { + if group.as_ref() == UNTAGGED_GROUP { + bail!("{subject} cannot use the reserved '{UNTAGGED_GROUP}' group"); + } + known_group(entries, group.as_ref()).with_context(|| subject.to_string())?; + } + } + + Ok(()) +} + +fn validate_rule( + entries: &BTreeMap, + subject: &str, + rule: &RuleSection, +) -> Result<(), Error> { + for (side, groups, kind) in [ + ("source", &rule.src, rule.src_match), + ("destination", &rule.dst, rule.dst_match), + ] { + if kind == PredicateMatch::Every && !groups.is_empty() { + bail!("{subject} lists groups in an 'every' {side} predicate, which takes none"); + } + if kind != PredicateMatch::Every && groups.is_empty() { + bail!("{subject} has an empty {side} predicate"); + } + } + let src_untagged = rule.src.iter().any(|g| g.as_ref() == UNTAGGED_GROUP); + let src_has_groups = rule.src.iter().any(|g| g.as_ref() != UNTAGGED_GROUP); + if src_untagged && src_has_groups && rule.src_match != PredicateMatch::Any { + bail!( + "{subject} names '{UNTAGGED_GROUP}' next to real groups in an 'all' or \ + 'exact' source, it is accepted there only on its own" + ); + } + let dst_untagged = rule.dst.iter().any(|g| g.as_ref() == UNTAGGED_GROUP); + let dst_has_groups = rule.dst.iter().any(|g| g.as_ref() != UNTAGGED_GROUP); + if dst_untagged && rule.dst_match != PredicateMatch::Any { + bail!( + "{subject} names '{UNTAGGED_GROUP}' in an 'all' or 'exact' destination, it \ + is accepted only in an 'any' destination and ignored there" + ); + } + if dst_untagged && !dst_has_groups { + bail!( + "{subject} names only '{UNTAGGED_GROUP}' in its destination, no enforced set \ + carries it so the rule can never fire" + ); + } + if rule.bidirectional && (src_untagged || dst_untagged) { + bail!( + "{subject} is bidirectional and names '{UNTAGGED_GROUP}', which does not \ + mean the same in a source as in a destination" + ); + } + for group in rule.src.iter().chain(&rule.dst) { + known_group(entries, group.as_ref()).with_context(|| subject.to_string())?; + } + Ok(()) +} + +fn is_group(entries: &BTreeMap, name: &str) -> bool { + matches!(entries.get(name), Some(MicrosegEntry::Group(_))) +} + +fn known_group(entries: &BTreeMap, name: &str) -> Result<(), Error> { + if name != UNTAGGED_GROUP && !is_group(entries, name) { + bail!("unknown group '{name}'"); + } + Ok(()) +} + +fn group_set<'a>( + entries: &BTreeMap, + groups: impl IntoIterator, +) -> Result { + groups + .into_iter() + .map(|group| { + known_group(entries, group.as_ref())?; + Ok(group.to_string()) + }) + .collect() +} + +fn predicate( + entries: &BTreeMap, + match_kind: PredicateMatch, + groups: &[MicrosegId], +) -> Result { + let set = group_set(entries, groups)?; + Ok(identity::Predicate { + kind: match_kind, + groups: set, + }) +} + +fn rule_entries(entries: &BTreeMap) -> impl Iterator { + entries.values().filter_map(|entry| match entry { + MicrosegEntry::Rule(rule) => Some(rule), + _ => None, + }) +} + +fn sorted_rules(entries: &BTreeMap) -> Vec<&RuleSection> { + rule_entries(entries).collect() +} + +/// Signatures only compare under one policy order, so the order is fixed by id. A bidirectional +/// rule yields one policy, since its mirror would only swap the two signature bits the forward +/// orientation already contributes. +fn sorted_policies( + entries: &BTreeMap, +) -> Result, Error> { + let mut policies = Vec::new(); + for rule in sorted_rules(entries) { + let context = || format!("rule '{}'", rule.id); + let src = predicate(entries, rule.src_match, &rule.src).with_context(context)?; + let dst = predicate(entries, rule.dst_match, &rule.dst).with_context(context)?; + policies.push((rule, identity::Policy { src, dst })); + } + Ok(policies) +} + +fn build_policies( + entries: &BTreeMap, +) -> Result, Error> { + Ok(sorted_policies(entries)? + .into_iter() + .map(|(_, policy)| policy) + .collect()) +} + +enum Matcher { + Guest(u32), +} + +struct Assignment<'a> { + id: &'a str, + order: Option, + iface: Option, + groups: &'a [MicrosegId], + matcher: Matcher, +} + +impl Assignment<'_> { + fn covers(&self, guest: &GuestInfo, iface: u32) -> bool { + let on_guest = match &self.matcher { + Matcher::Guest(vmid) => *vmid == guest.vmid, + }; + on_guest && self.iface.is_none_or(|scoped| scoped == iface) + } +} + +/// What the match order sorts by. A lower `order` comes first, an unset one after every set one, +/// and the id breaks a tie so the walk is stable. +fn match_order(order: Option, id: &str) -> (bool, Option, &str) { + (order.is_none(), order, id) +} + +fn assignments(entries: &BTreeMap) -> Vec> { + let mut assignments: Vec> = entries + .iter() + .filter_map(|(id, entry)| { + let (binding, matcher) = match entry { + MicrosegEntry::GuestAssignment(a) => (&a.binding, Matcher::Guest(a.vmid)), + MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => return None, + }; + Some(Assignment { + id, + order: binding.order, + iface: binding.iface, + groups: &binding.groups, + matcher, + }) + }) + .collect(); + assignments.sort_by_key(|assignment| match_order(assignment.order, assignment.id)); + assignments +} + +/// One NIC of the inventory with the assignment that covers it first and the groups that +/// assignment binds. +#[derive(Debug)] +pub struct AssignedNic<'a> { + pub vmid: u32, + pub iface: u32, + pub assignment: &'a str, + pub groups: &'a [MicrosegId], +} + +/// Every NIC of the inventory some assignment covers, ascending by guest and interface. +pub fn assigned_nics<'a>( + entries: &'a BTreeMap, + inventory: &[GuestInfo], +) -> Vec> { + let assignments = assignments(entries); + let mut nics = Vec::new(); + for guest in inventory { + for &iface in &guest.nics { + if let Some(assignment) = assignments + .iter() + .find(|assignment| assignment.covers(guest, iface)) + { + nics.push(AssignedNic { + vmid: guest.vmid, + iface, + assignment: assignment.id, + groups: assignment.groups, + }); + } + } + } + nics.sort_unstable_by_key(|nic| (nic.vmid, nic.iface)); + nics +} + +/// A group set some assignment names and the rules that fire on it. It evaluates the predicates +/// with the code the allocation uses, so the two cannot disagree. +#[derive(Debug, Serialize)] +pub struct GroupSetRules { + /// The groups, sorted. + pub groups: Vec, + /// Ids of the rules that fire on this group set as a source. + pub outbound: Vec, + /// Ids of the rules that fire on this group set as a destination. + pub inbound: Vec, +} + +pub fn rules_by_group_set( + entries: &BTreeMap, +) -> Result, Error> { + let rules = sorted_policies(entries)?; + + let classes: BTreeSet> = entries + .values() + .filter_map(MicrosegEntry::assignment_groups) + .map(|groups| groups.iter().cloned().collect()) + .collect(); + + classes + .into_iter() + .map(|class| { + let set = group_set(entries, class.iter())?; + let mut outbound = Vec::new(); + let mut inbound = Vec::new(); + for (rule, policy) in &rules { + let src = policy.src.fires(&set); + let dst = policy.dst.fires(&set); + if src || (rule.bidirectional && dst) { + outbound.push(rule.id.clone()); + } + if dst || (rule.bidirectional && src) { + inbound.push(rule.id.clone()); + } + } + Ok(GroupSetRules { + groups: class.into_iter().collect(), + outbound, + inbound, + }) + }) + .collect() +} + +/// The distinct group sets the assignments name, the input the identity allocator partitions. +fn assignment_group_sets( + entries: &BTreeMap, +) -> Result, Error> { + entries + .iter() + .filter_map(|(id, entry)| Some((id, entry.assignment_groups()?))) + .map(|(id, groups)| { + group_set(entries, groups).with_context(|| format!("assignment '{id}'")) + }) + .collect() +} + +/// Render the running config. Identities are allocated for the set of every assignment rather +/// than for what the guests match right now, so a NIC that appears later finds its class in +/// place. `prev` is the previous running config's registry, a fresh one renumbers the whole +/// cluster. `now` is the render node's clock in unix seconds, which the quarantine of retired +/// ids is measured against. +pub fn render( + prev: &identity::Registry, + entries: BTreeMap, + now: u64, +) -> Result { + validate(&entries)?; + let policies = build_policies(&entries)?; + let sets = assignment_group_sets(&entries)?; + let identities = identity::allocate(prev, &sets, &policies, now)?; + Ok(MicrosegRunningConfig { + ids: entries, + identities, + }) +} + +/// 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) + .iter() + .enumerate() + .map(|(place, assignment)| (assignment.id.to_string(), place)) + .collect() +} + +#[derive(Debug)] +pub struct ResolvedRule { + src: identity::Predicate, + dst: identity::Predicate, + prio: u16, + allow: bool, +} + +pub fn resolved_rules( + entries: &BTreeMap, +) -> Result, Error> { + let mut resolved = Vec::new(); + for rule in rule_entries(entries) { + let context = || format!("rule '{}'", rule.id); + let forward = ResolvedRule { + src: predicate(entries, rule.src_match, &rule.src).with_context(context)?, + dst: predicate(entries, rule.dst_match, &rule.dst).with_context(context)?, + prio: rule.prio, + allow: rule.allow, + }; + let mirrored = rule.bidirectional.then(|| ResolvedRule { + src: forward.dst.clone(), + dst: forward.src.clone(), + prio: forward.prio, + allow: forward.allow, + }); + resolved.push(forward); + resolved.extend(mirrored); + } + Ok(resolved) +} + +/// Fold the rules that fire on one `(src_class, dst_class)` pair into its verdict. The highest +/// priority wins, and a single deny among the rules tied there overrides the allows. Without a +/// firing rule the pair denies. +pub fn fold<'a>(firing: impl IntoIterator) -> bool { + let mut best: Option<(u16, bool)> = None; + for rule in firing { + best = Some(match best { + Some((prio, _)) if rule.prio > prio => (rule.prio, rule.allow), + Some((prio, allow)) if rule.prio == prio => (prio, allow && rule.allow), + Some(current) => current, + None => (rule.prio, rule.allow), + }); + } + best.is_some_and(|(_, allow)| allow) +} + +#[cfg(test)] +mod tests { + + use super::*; + + pub(super) fn firing_rules<'a>( + rules: &'a [ResolvedRule], + src_rep: &identity::GroupSet, + dst_rep: &identity::GroupSet, + ) -> Vec<&'a ResolvedRule> { + rules + .iter() + .filter(|rule| rule.src.fires(src_rep) && rule.dst.fires(dst_rep)) + .collect() + } + + fn id(s: &str) -> MicrosegId { + s.parse().expect("valid microseg id") + } + + fn ids(names: &[&str]) -> Vec { + names.iter().map(|n| id(n)).collect() + } + + fn group(name: &str) -> (String, MicrosegEntry) { + ( + name.to_string(), + MicrosegEntry::Group(GroupSection { + id: name.parse().expect("valid group name"), + comment: None, + }), + ) + } + + fn rule(name: &str, src: &[&str], dst: &[&str], allow: bool) -> (String, MicrosegEntry) { + rule_full( + name, + src, + PredicateMatch::Any, + dst, + PredicateMatch::Any, + 0, + allow, + ) + } + + fn with_rule( + (id_str, entry): (String, MicrosegEntry), + modify: impl FnOnce(&mut RuleSection), + ) -> (String, MicrosegEntry) { + let MicrosegEntry::Rule(mut rule) = entry else { + unreachable!() + }; + modify(&mut rule); + (id_str, MicrosegEntry::Rule(rule)) + } + + fn bidirectional(entry: (String, MicrosegEntry)) -> (String, MicrosegEntry) { + with_rule(entry, |rule| rule.bidirectional = true) + } + + fn resolved(entries: &BTreeMap) -> Vec { + resolved_rules(entries).expect("resolve") + } + + #[test] + fn bidirectional_rule_mirrors_the_pair() { + let entries: BTreeMap = BTreeMap::from([ + group("web"), + group("db"), + bidirectional(rule("both", &["web"], &["db"], true)), + ]); + validate(&entries).expect("valid"); + + let rules = resolved(&entries); + assert!(verdict(&rules, &set(&["web"]), &set(&["db"]))); + assert!( + verdict(&rules, &set(&["db"]), &set(&["web"])), + "the mirror decides the reverse pair" + ); + + let one_way: BTreeMap = BTreeMap::from([ + group("web"), + group("db"), + rule("oneway", &["web"], &["db"], true), + ]); + let rules = resolved(&one_way); + assert!(verdict(&rules, &set(&["web"]), &set(&["db"]))); + assert!(!verdict(&rules, &set(&["db"]), &set(&["web"]))); + } + + fn assignment(id_str: &str, vmid: u32, iface: u32, groups: &[&str]) -> (String, MicrosegEntry) { + ( + id_str.to_string(), + MicrosegEntry::GuestAssignment(GuestAssignmentSection { + id: id(id_str), + vmid, + binding: Binding { + iface: Some(iface), + groups: ids(groups), + order: None, + comment: None, + }, + }), + ) + } + + #[test] + fn placing_an_assignment_leaves_the_other_numbers() { + let match_order = + |entries: &BTreeMap| -> Vec<(String, Option)> { + assignments(entries) + .iter() + .map(|assignment| (assignment.id.to_string(), assignment.order)) + .collect() + }; + let mut entries = BTreeMap::from([group("web")]); + for vmid in [100, 101, 102] { + let create = api::MicrosegCreate::GuestAssignment(api::GuestAssignmentCreate { + vmid, + binding: api::BindingCreate { + iface: None, + groups: ids(&["web"]), + position: None, + comment: None, + }, + }); + api::create(&mut entries, create).expect("create"); + } + // a new assignment goes first + let created = match_order(&entries); + let created_ids: Vec<&str> = created.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(created_ids, ["vm102", "vm101", "vm100"]); + + let to_the_middle = api::MicrosegUpdate::GuestAssignment(api::AssignmentUpdate { + position: Some(1), + ..Default::default() + }); + api::update(&mut entries, "vm100", to_the_middle).expect("move"); + let moved = match_order(&entries); + assert_eq!(moved[0], created[0]); + assert_eq!(moved[1].0, "vm100"); + assert_eq!(moved[2], created[1]); + + api::delete(&mut entries, "vm100").expect("delete"); + assert_eq!(match_order(&entries), created[..2]); + } + + #[test] + fn replaced_group_does_not_inherit_the_class_id() { + // each group gets a rule of its own. Without one, web and db would both land in the class + // no rule matches, and that class keeps its id + let first = render( + &identity::Registry::new(), + BTreeMap::from([ + group("web"), + rule("r1", &["web"], &["web"], true), + assignment("vm100i0", 100, 0, &["web"]), + ]), + 0, + ) + .expect("first render"); + let web_id = *first + .identities() + .classes() + .keys() + .next() + .expect("web has a class"); + + let second = render( + first.identities(), + BTreeMap::from([ + group("db"), + rule("r1", &["db"], &["db"], true), + assignment("vm100i0", 100, 0, &["db"]), + ]), + 0, + ) + .expect("second render"); + let classes = second.identities().classes(); + let db_id = classes + .iter() + .find(|(_, rep)| **rep == set(&["db"])) + .map(|(&id, _)| id) + .expect("db has a class"); + assert_ne!(db_id, web_id, "db does not take over the id of web"); + assert!(!classes.contains_key(&web_id), "the id of web is not live"); + } + + fn set(names: &[&str]) -> identity::GroupSet { + names.iter().map(|name| name.to_string()).collect() + } + + fn verdict(rules: &[ResolvedRule], src: &identity::GroupSet, dst: &identity::GroupSet) -> bool { + fold(firing_rules(rules, src, dst)) + } + + fn rule_full( + name: &str, + src: &[&str], + src_match: PredicateMatch, + dst: &[&str], + dst_match: PredicateMatch, + prio: u16, + allow: bool, + ) -> (String, MicrosegEntry) { + ( + name.to_string(), + MicrosegEntry::Rule(RuleSection { + id: id(name), + src: ids(src), + src_match, + dst: ids(dst), + dst_match, + bidirectional: false, + prio, + allow, + comment: None, + }), + ) + } + + #[test] + fn fold_priority_overrides_and_ties_deny() { + use PredicateMatch::{Any, Exact}; + let entries: BTreeMap = BTreeMap::from([ + group("web"), + group("db"), + group("app"), + rule_full("broad", &["web", "app"], Any, &["db"], Any, 0, false), + rule_full("spec", &["web"], Exact, &["db"], Any, 10, true), + ]); + let rules = resolved(&entries); + + // both fire on web -> db, the prio-10 allow wins over the prio-0 deny + assert!(verdict(&rules, &set(&["web"]), &set(&["db"]))); + // only the broad deny fires on app -> db + assert!(!verdict(&rules, &set(&["app"]), &set(&["db"]))); + // no rule fires on web -> web, so the default deny holds + assert!(!verdict(&rules, &set(&["web"]), &set(&["web"]))); + + // A deny overrides an allow at the same priority. + let tied: BTreeMap = BTreeMap::from([ + group("web"), + group("db"), + group("app"), + rule_full("ra", &["web"], Any, &["db"], Any, 5, true), + rule_full("rb", &["app"], Any, &["db"], Any, 5, false), + ]); + let tied_rules = resolved(&tied); + assert!( + !verdict(&tied_rules, &set(&["web", "app"]), &set(&["db"])), + "allow and deny tied at the top priority deny" + ); + // from an interface in web alone only the allow fires + assert!(verdict(&tied_rules, &set(&["web"]), &set(&["db"]))); + } +} diff --git a/proxmox-ve-config/src/sdn/microseg/types.rs b/proxmox-ve-config/src/sdn/microseg/types.rs new file mode 100644 index 000000000000..5e20ee6ca2df --- /dev/null +++ b/proxmox-ve-config/src/sdn/microseg/types.rs @@ -0,0 +1,329 @@ +//! The section types of the config and the running config. + +use std::collections::BTreeMap; + +use anyhow::{Error, bail}; +use const_format::concatcp; +use serde::{Deserialize, Serialize}; + +use proxmox_schema::{ApiStringFormat, ApiType, api, api_string_type, const_regex}; + +use super::PredicateMatch; +use super::identity; +use super::identity::UNTAGGED_GROUP; + +const MICROSEG_ID_REGEX_STR: &str = r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-_]){0,30}[a-zA-Z0-9])"; + +const_regex! { + pub MICROSEG_ID_REGEX = concatcp!(r"^", MICROSEG_ID_REGEX_STR, r"$"); +} + +pub const MICROSEG_ID_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&MICROSEG_ID_REGEX); + +api_string_type! { + /// Identifier of a microseg object, used as the section id and to reference groups. + #[api(format: &MICROSEG_ID_FORMAT)] + #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] + pub struct MicrosegId(String); +} + +const GENERATED_ID_REGEX_STR: &str = r"(?:vm[0-9]+(?:i[0-9]+)?|r[0-9a-f]{16})"; + +const_regex! { + GENERATED_ID_REGEX = concatcp!(r"^", GENERATED_ID_REGEX_STR, r"$"); +} + +// sections are keyed by their id alone, so a group named like a generated id would take the id of +// the rule or assignment that derives it +fn verify_group_name(name: &str) -> Result<(), Error> { + MicrosegId::API_SCHEMA + .unwrap_string_schema() + .check_constraints(name)?; + if name == UNTAGGED_GROUP || GENERATED_ID_REGEX.is_match(name) { + bail!("'{name}' is a reserved group name"); + } + Ok(()) +} + +pub const MICROSEG_GROUP_NAME_FORMAT: ApiStringFormat = + ApiStringFormat::VerifyFn(verify_group_name); + +api_string_type! { + /// Name of a microseg group. + #[api(format: &MICROSEG_GROUP_NAME_FORMAT)] + #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] + #[serde(try_from = "String")] + pub struct GroupName(String); +} + +#[api( + properties: { + comment: { + type: String, + optional: true, + max_length: 256, + description: "Free-form comment.", + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A microseg group. An interface is assigned a set of groups, which resolves to a wire identity. +pub struct GroupSection { + pub(crate) id: GroupName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) comment: Option, +} + +#[api( + properties: { + src: { + type: Array, + items: { type: MicrosegId }, + optional: true, + }, + "src-match": { + type: PredicateMatch, + optional: true, + }, + dst: { + type: Array, + items: { type: MicrosegId }, + optional: true, + }, + "dst-match": { + type: PredicateMatch, + optional: true, + }, + bidirectional: { + type: Boolean, + optional: true, + }, + prio: { + type: Integer, + optional: true, + minimum: 0, + maximum: 65535, + }, + comment: { + type: String, + optional: true, + max_length: 256, + description: "Free-form comment.", + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A policy rule. It fires on traffic from an interface whose group set matches `(src_match, src)` +/// to one whose set matches `(dst_match, dst)`, and decides it with `allow`. A rule ahead of it in +/// the evaluation order decides first. Traffic no rule decides is denied. +pub struct RuleSection { + pub(crate) id: MicrosegId, + /// Groups the source predicate lists. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) src: Vec, + /// How the source predicate matches its groups. + #[serde(default, rename = "src-match")] + pub(crate) src_match: PredicateMatch, + /// Groups the destination predicate lists. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) dst: Vec, + /// How the destination predicate matches its groups. + #[serde(default, rename = "dst-match")] + pub(crate) dst_match: PredicateMatch, + /// Whether the rule also decides the reverse direction, with the predicates swapped. + #[serde( + default, + deserialize_with = "proxmox_serde::perl::deserialize_bool", + skip_serializing_if = "is_false" + )] + pub(crate) bidirectional: bool, + /// Rules of a higher priority win. + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u16")] + pub(crate) prio: u16, + /// Whether traffic matching both predicates is allowed. + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub(crate) allow: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) comment: Option, +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[api( + properties: { + iface: { + type: Integer, + optional: true, + minimum: 0, + maximum: 31, + description: "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: MicrosegId }, + }, + order: { + type: Integer, + optional: true, + minimum: 0, + description: "Place in the match order, the lowest number first. A NIC belongs to the first assignment that covers it.", + }, + comment: { + type: String, + optional: true, + max_length: 256, + description: "Free-form comment.", + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// What an assignment binds, apart from the matcher that picks its guests. +pub struct Binding { + #[serde( + default, + deserialize_with = "proxmox_serde::perl::deserialize_u32", + skip_serializing_if = "Option::is_none" + )] + pub(crate) iface: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) groups: Vec, + #[serde( + default, + deserialize_with = "proxmox_serde::perl::deserialize_u32", + skip_serializing_if = "Option::is_none" + )] + pub(crate) order: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) comment: Option, +} + +#[api( + properties: { + vmid: { + type: Integer, + minimum: 1, + maximum: 999999999, + description: "Guest (VM or CT) id whose NIC(s) are bound.", + }, + binding: { flatten: true, type: Binding }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Binds a specific guest's NIC(s) to a set of groups. Stored as the `guest-assignment` section +/// type. +pub struct GuestAssignmentSection { + pub(crate) id: MicrosegId, + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub(crate) vmid: u32, + #[serde(flatten)] + pub(crate) binding: Binding, +} + +#[api( + "id-property": "id", + "id-schema": { + type: String, + description: "Microseg object identifier.", + format: &MICROSEG_ID_FORMAT, + }, + "type-key": "type", +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case", tag = "type")] +/// One entry in the microseg section config. +pub enum MicrosegEntry { + /// A group. + Group(GroupSection), + /// A rule. + Rule(RuleSection), + /// An assignment by guest id. + GuestAssignment(GuestAssignmentSection), +} + +impl MicrosegEntry { + /// The section id this entry is keyed by. + pub fn id(&self) -> &str { + match self { + Self::Group(g) => &g.id, + Self::Rule(r) => &r.id, + Self::GuestAssignment(a) => &a.id, + } + } + + pub(super) fn binding(&self) -> Option<&Binding> { + match self { + Self::GuestAssignment(a) => Some(&a.binding), + Self::Group(_) | Self::Rule(_) => None, + } + } + + pub(super) fn binding_mut(&mut self) -> Option<&mut Binding> { + match self { + Self::GuestAssignment(a) => Some(&mut a.binding), + Self::Group(_) | Self::Rule(_) => None, + } + } + + pub(super) fn assignment_groups(&self) -> Option<&[MicrosegId]> { + self.binding().map(|binding| binding.groups.as_slice()) + } +} + +/// A Perl caller hands over untyped scalars, so an index may come as a string as well as a number. +fn deserialize_perl_nics<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + struct Nic(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] u32); + Ok(Vec::::deserialize(deserializer)? + .into_iter() + .map(|Nic(index)| index) + .collect()) +} + +/// One guest of the inventory the assignments are resolved against. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct GuestInfo { + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub vmid: u32, + /// 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`), the entries keyed by section +/// id plus what the render derives from them. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MicrosegRunningConfig { + #[serde(default)] + pub(super) ids: BTreeMap, + #[serde(default)] + pub(super) identities: identity::Registry, +} + +impl MicrosegRunningConfig { + pub fn ids(&self) -> &BTreeMap { + &self.ids + } + + pub fn identities(&self) -> &identity::Registry { + &self.identities + } + + pub fn describe_class(&self, id: u16) -> String { + if id == identity::UNTAGGED_ID { + return format!("{id} ({UNTAGGED_GROUP})"); + } + match self.identities.classes().get(&id) { + Some(rep) => { + let groups: Vec<&str> = rep.iter().map(String::as_str).collect(); + format!("{id} ({})", groups.join(", ")) + } + None => id.to_string(), + } + } +} -- 2.47.3