From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id EF99D1FF146 for ; Tue, 09 Jun 2026 15:27:56 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 645DC13356; Tue, 9 Jun 2026 15:26:47 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs 04/16] ve-config: sdn: add microseg config types Date: Tue, 9 Jun 2026 15:25:10 +0200 Message-ID: <20260609132522.235917-5-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260609132522.235917-1-h.laimer@proxmox.com> References: <20260609132522.235917-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: 1781011483536 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.084 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment 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: J5ADBS4KKNSKJMY4GMP7OBZFULB5YFET X-Message-ID-Hash: J5ADBS4KKNSKJMY4GMP7OBZFULB5YFET 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: Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/config.rs | 9 +- proxmox-ve-config/src/sdn/microseg.rs | 847 ++++++++++++++++++++++++++ proxmox-ve-config/src/sdn/mod.rs | 1 + 3 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 proxmox-ve-config/src/sdn/microseg.rs diff --git a/proxmox-ve-config/src/sdn/config.rs b/proxmox-ve-config/src/sdn/config.rs index afc5175..a562c58 100644 --- a/proxmox-ve-config/src/sdn/config.rs +++ b/proxmox-ve-config/src/sdn/config.rs @@ -16,7 +16,7 @@ use crate::{ ipset::{IpsetEntry, IpsetName, IpsetScope}, Ipset, }, - sdn::{SdnNameError, SubnetName, VnetName, ZoneName}, + sdn::{microseg::MicrosegRunningConfig, SdnNameError, SubnetName, VnetName, ZoneName}, }; #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -214,6 +214,13 @@ pub struct RunningConfig { zones: Option, subnets: Option, vnets: Option, + microseg: Option, +} + +impl RunningConfig { + pub fn microseg(&self) -> Option<&MicrosegRunningConfig> { + self.microseg.as_ref() + } } /// A struct containing the configuration for an SDN subnet diff --git a/proxmox-ve-config/src/sdn/microseg.rs b/proxmox-ve-config/src/sdn/microseg.rs new file mode 100644 index 0000000..8c56246 --- /dev/null +++ b/proxmox-ve-config/src/sdn/microseg.rs @@ -0,0 +1,847 @@ +//! Microseg subsystem of the SDN config. +//! +//! Microseg is an identity-based stateless firewall. Each managed guest NIC is tagged with a +//! numeric *group* (carried in `skb->mark` and on the wire), and admin-defined `(src, dst) -> +//! allow|deny` rules decide whether traffic between groups is permitted. + +use std::collections::{HashMap, HashSet}; + +use anyhow::bail; +use const_format::concatcp; +use serde::{Deserialize, Serialize}; + +use proxmox_schema::{api, api_string_type, const_regex, ApiStringFormat, UpdaterType}; + +pub 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, UpdaterType)] + pub struct MicrosegId(String); +} + +#[api( + properties: { + mark: { + type: Integer, + minimum: 1, + maximum: 65535, + description: "Numeric group mark stamped into skb->mark and carried on the wire (1-65535).", + }, + comment: { + type: String, + optional: true, + max_length: 256, + description: "Free-form comment.", + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A microseg group. Its numeric [`mark`](Self::mark) is stamped into `skb->mark` and carried on +/// the wire. Mark 0 is reserved for unstamped traffic. +pub struct GroupSection { + pub(crate) id: MicrosegId, + pub(crate) mark: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) comment: Option, + /// Parent group this group is nested under. Members of this group count as members of the + /// parent too, so a rule on the parent also applies here. Absent for a top-level group. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) parent: Option, +} + +impl GroupSection { + pub fn mark(&self) -> u16 { + self.mark + } + pub fn comment(&self) -> Option<&str> { + self.comment.as_deref() + } + pub fn parent(&self) -> Option<&str> { + self.parent.as_deref() + } +} + +#[api] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A policy rule. Traffic from the `src` group to the `dst` group is permitted when `allow` is +/// true. An absent `src` matches unstamped traffic (group id 0). With no matching rule the data +/// plane denies. +pub struct RuleSection { + pub(crate) id: MicrosegId, + /// Source group. Absent matches unstamped traffic (group id 0). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) src: Option, + /// Destination group. + pub(crate) dst: MicrosegId, + /// Whether traffic from the src group to the dst group is allowed. + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub(crate) allow: bool, +} + +impl RuleSection { + pub fn src(&self) -> Option<&str> { + self.src.as_deref() + } + pub fn dst(&self) -> &str { + &self.dst + } + pub fn allow(&self) -> bool { + self.allow + } +} + +#[api( + properties: { + vmid: { + type: Integer, + minimum: 1, + maximum: 999999999, + description: "Guest (VM or CT) id.", + }, + iface: { + type: Integer, + minimum: 0, + maximum: 31, + description: "Index N of the guest's netN interface.", + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Binds a guest NIC (the `iface`-th NIC of `vmid`) to a group. +pub struct AssignmentSection { + pub(crate) id: MicrosegId, + pub(crate) vmid: u32, + pub(crate) iface: u32, + /// Group this NIC belongs to. + pub(crate) group: MicrosegId, +} + +impl AssignmentSection { + pub fn vmid(&self) -> u32 { + self.vmid + } + pub fn iface(&self) -> u32 { + self.iface + } + pub fn group(&self) -> &str { + &self.group + } +} + +#[api] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A bridge-facing interface that carries the policy tag across hosts. +pub struct BridgeSection { + pub(crate) id: MicrosegId, + /// Comma-separated list of nodes this bridge applies on (empty or absent means all nodes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) nodes: Option, +} + +impl BridgeSection { + /// Iterate the node-name list, trimming whitespace and skipping empty entries. + pub fn nodes(&self) -> impl Iterator { + self.nodes + .as_deref() + .into_iter() + .flat_map(|s| s.split(',')) + .map(str::trim) + .filter(|n| !n.is_empty()) + } + /// Whether this bridge applies on `node`. Empty / absent `nodes` means all nodes. + pub fn applies_to(&self, node: &str) -> bool { + let mut iter = self.nodes(); + match iter.next() { + None => true, + Some(first) => first == node || iter.any(|n| n == node), + } + } +} + +#[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 = "lowercase", tag = "type")] +/// One entry in the microseg section config, discriminated by the section `type`. +pub enum MicrosegEntry { + /// A group. + Group(GroupSection), + /// A policy rule. + Rule(RuleSection), + /// A NIC-to-group assignment. + Assignment(AssignmentSection), + /// A bridge-facing carrier interface. + Bridge(BridgeSection), +} + +impl MicrosegEntry { + /// The section id this entry is keyed by. + pub fn id(&self) -> &MicrosegId { + match self { + Self::Group(g) => &g.id, + Self::Rule(r) => &r.id, + Self::Assignment(a) => &a.id, + Self::Bridge(b) => &b.id, + } + } +} + +/// The microseg block of the SDN running config (`running.microseg`), an `ids` map of entries +/// keyed by section id. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MicrosegRunningConfig { + #[serde(default)] + ids: HashMap, +} + +impl MicrosegRunningConfig { + pub fn ids(&self) -> &HashMap { + &self.ids + } + + pub fn groups(&self) -> impl Iterator + '_ { + self.ids.iter().filter_map(|(k, v)| match v { + MicrosegEntry::Group(g) => Some((k.as_str(), g)), + _ => None, + }) + } + + pub fn rules(&self) -> impl Iterator + '_ { + self.ids.iter().filter_map(|(k, v)| match v { + MicrosegEntry::Rule(r) => Some((k.as_str(), r)), + _ => None, + }) + } + + pub fn assignments(&self) -> impl Iterator + '_ { + self.ids.iter().filter_map(|(k, v)| match v { + MicrosegEntry::Assignment(a) => Some((k.as_str(), a)), + _ => None, + }) + } + + pub fn bridges(&self) -> impl Iterator + '_ { + self.ids.iter().filter_map(|(k, v)| match v { + MicrosegEntry::Bridge(b) => Some((k.as_str(), b)), + _ => None, + }) + } +} + +/// Validation of a microseg config. Group marks are unique, every rule and assignment references +/// an existing group, no two assignments bind the same guest NIC, and no two rules share the same +/// (src, dst) pair. +pub fn validate(entries: &HashMap) -> Result<(), anyhow::Error> { + let mut group_marks: HashMap = HashMap::new(); + let mut group_names: HashSet<&str> = HashSet::new(); + for (name, entry) in entries { + if let MicrosegEntry::Group(group) = entry { + group_names.insert(name.as_str()); + if let Some(other) = group_marks.insert(group.mark, name.as_str()) { + bail!( + "group mark {} used by both '{other}' and '{name}'", + group.mark + ); + } + } + } + + for (name, entry) in entries { + match entry { + MicrosegEntry::Rule(rule) => { + if let Some(src) = rule.src() { + if !group_names.contains(src) { + bail!("rule '{name}' references unknown src group '{src}'"); + } + } + if !group_names.contains(rule.dst()) { + bail!( + "rule '{name}' references unknown dst group '{}'", + rule.dst() + ); + } + } + MicrosegEntry::Assignment(assignment) => { + if !group_names.contains(assignment.group()) { + bail!( + "assignment '{name}' references unknown group '{}'", + assignment.group() + ); + } + } + MicrosegEntry::Group(group) => { + if let Some(parent) = group.parent() { + if !group_names.contains(parent) { + bail!("group '{name}' references unknown parent group '{parent}'"); + } + } + } + _ => {} + } + } + + let mut bound: HashSet<(u32, u32)> = HashSet::new(); + for (name, entry) in entries { + if let MicrosegEntry::Assignment(assignment) = entry { + if !bound.insert((assignment.vmid, assignment.iface)) { + bail!( + "assignment '{name}': (vmid={}, iface={}) is bound more than once", + assignment.vmid, + assignment.iface + ); + } + } + } + + let mut rule_pairs: HashSet<(Option<&str>, &str)> = HashSet::new(); + for (name, entry) in entries { + if let MicrosegEntry::Rule(rule) = entry { + if !rule_pairs.insert((rule.src(), rule.dst())) { + bail!( + "rule '{name}': a rule for (src={}, dst={}) already exists", + rule.src().unwrap_or("unstamped"), + rule.dst() + ); + } + } + } + + // we don't want cycles in the parent chain + for (name, entry) in entries { + if !matches!(entry, MicrosegEntry::Group(_)) { + continue; + } + let mut seen: HashSet<&str> = HashSet::new(); + let mut cur = name.as_str(); + loop { + if !seen.insert(cur) { + bail!("group '{name}' is part of a parent-group cycle"); + } + match entries.get(cur) { + Some(MicrosegEntry::Group(group)) => match group.parent() { + Some(parent) => cur = parent, + None => break, + }, + _ => break, + } + } + } + + Ok(()) +} + +/// API helper types. The perl API passes its raw parameter hash here and perlmod deserializes it +/// into [`MicrosegCreate`] or [`MicrosegUpdate`]. +pub mod api { + use std::collections::{HashMap, HashSet}; + + use anyhow::{anyhow, bail, Error}; + use serde::Deserialize; + + use super::{ + AssignmentSection, BridgeSection, GroupSection, MicrosegEntry, MicrosegId, RuleSection, + }; + + #[derive(Debug, Clone, Deserialize)] + #[serde(tag = "type", rename_all = "lowercase")] + pub enum MicrosegCreate { + Group(GroupCreate), + Rule(RuleCreate), + Assignment(AssignmentCreate), + Bridge(BridgeCreate), + } + + #[derive(Debug, Clone, Deserialize)] + pub struct GroupCreate { + pub id: MicrosegId, + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u16")] + pub mark: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub parent: Option, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct RuleCreate { + #[serde(default)] + pub src: Option, + pub dst: MicrosegId, + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub allow: bool, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct AssignmentCreate { + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub vmid: u32, + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub iface: u32, + pub group: MicrosegId, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct BridgeCreate { + pub id: MicrosegId, + #[serde(default)] + pub nodes: Option, + } + + #[derive(Debug, Clone, Deserialize)] + #[serde(tag = "type", rename_all = "lowercase")] + pub enum MicrosegUpdate { + Group(GroupUpdate), + Rule(RuleUpdate), + Assignment(AssignmentUpdate), + Bridge(BridgeUpdate), + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct GroupUpdate { + #[serde(default)] + pub comment: Option, + #[serde(default)] + pub parent: Option, + #[serde(default)] + pub delete: Vec, + } + + #[derive(Debug, Clone, Copy, Deserialize)] + #[serde(rename_all = "lowercase")] + pub enum GroupDeletableProperty { + Comment, + Parent, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct RuleUpdate { + #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")] + pub allow: Option, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct AssignmentUpdate { + #[serde(default)] + pub group: Option, + } + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct BridgeUpdate { + #[serde(default)] + pub nodes: Option, + #[serde(default)] + pub delete: Vec, + } + + #[derive(Debug, Clone, Copy, Deserialize)] + #[serde(rename_all = "lowercase")] + pub enum BridgeDeletableProperty { + Nodes, + } + + /// Lowest unused group mark in `1..=65535`. + pub fn next_free_mark(entries: &HashMap) -> Option { + let used: HashSet = entries + .values() + .filter_map(|entry| match entry { + MicrosegEntry::Group(group) => Some(group.mark), + _ => None, + }) + .collect(); + (1..=u16::MAX).find(|mark| !used.contains(mark)) + } + + /// Build an entry from a create payload, auto-assigning a free group mark when none is given. + pub fn build_entry( + payload: MicrosegCreate, + entries: &HashMap, + ) -> Result { + Ok(match payload { + MicrosegCreate::Group(group) => { + let mark = match group.mark { + Some(mark) => mark, + None => next_free_mark(entries) + .ok_or_else(|| anyhow!("no free group mark available"))?, + }; + MicrosegEntry::Group(GroupSection { + id: group.id, + mark, + comment: group.comment, + parent: group.parent, + }) + } + MicrosegCreate::Rule(rule) => { + let group_mark = |name: &str| -> Result { + match entries.get(name) { + Some(MicrosegEntry::Group(group)) => Ok(group.mark), + _ => Err(anyhow!( + "rule references group '{name}' which does not exist" + )), + } + }; + let src_mark = match &rule.src { + Some(src) => group_mark(src)?, + None => 0, + }; + let dst_mark = group_mark(&rule.dst)?; + let id = format!("p{src_mark}-{dst_mark}").parse()?; + MicrosegEntry::Rule(RuleSection { + id, + src: rule.src, + dst: rule.dst, + allow: rule.allow, + }) + } + MicrosegCreate::Assignment(assignment) => { + let id = format!("vm{}i{}", assignment.vmid, assignment.iface).parse()?; + MicrosegEntry::Assignment(AssignmentSection { + id, + vmid: assignment.vmid, + iface: assignment.iface, + group: assignment.group, + }) + } + MicrosegCreate::Bridge(bridge) => MicrosegEntry::Bridge(BridgeSection { + id: bridge.id, + nodes: bridge.nodes, + }), + }) + } + + /// Apply a partial update to an existing entry. The update is tagged by type and carries its + /// own property deletions, so it must match the existing object's type. + pub fn apply_update(entry: &mut MicrosegEntry, update: MicrosegUpdate) -> Result<(), Error> { + match (entry, update) { + (MicrosegEntry::Group(group), MicrosegUpdate::Group(update)) => { + if update.comment.is_some() { + group.comment = update.comment; + } + if update.parent.is_some() { + group.parent = update.parent; + } + for property in update.delete { + match property { + GroupDeletableProperty::Comment => group.comment = None, + GroupDeletableProperty::Parent => group.parent = None, + } + } + } + (MicrosegEntry::Rule(rule), MicrosegUpdate::Rule(update)) => { + if let Some(allow) = update.allow { + rule.allow = allow; + } + } + (MicrosegEntry::Assignment(assignment), MicrosegUpdate::Assignment(update)) => { + if let Some(group) = update.group { + assignment.group = group; + } + } + (MicrosegEntry::Bridge(bridge), MicrosegUpdate::Bridge(update)) => { + if update.nodes.is_some() { + bridge.nodes = update.nodes; + } + for property in update.delete { + match property { + BridgeDeletableProperty::Nodes => bridge.nodes = None, + } + } + } + _ => bail!("update type does not match the existing object's type"), + } + Ok(()) + } + + /// If group `name` is still referenced by a rule or assignment, return that referrer's id, for + /// the delete guard. + pub fn group_referenced_by<'a>( + entries: &'a HashMap, + name: &str, + ) -> Option<&'a str> { + entries.iter().find_map(|(id, entry)| match entry { + MicrosegEntry::Rule(rule) if rule.src() == Some(name) || rule.dst() == name => { + Some(id.as_str()) + } + MicrosegEntry::Assignment(assignment) if assignment.group() == name => { + Some(id.as_str()) + } + MicrosegEntry::Group(group) if group.parent() == Some(name) => Some(id.as_str()), + _ => None, + }) + } +} + +#[cfg(test)] +mod tests { + use std::ops::Deref; + + use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData}; + + use super::*; + + #[test] + fn parse_validate_write_round_trip() { + let raw = "\ +group: webservers +\tmark 5 +\tcomment web tier + +group: db +\tmark 7 +\tparent webservers + +rule: web-to-db +\tsrc webservers +\tdst db +\tallow 1 + +assignment: a100i0 +\tvmid 100 +\tiface 0 +\tgroup webservers + +bridge: vmbr0 +\tnodes pve1,pve2 +"; + let parsed = MicrosegEntry::parse_section_config("microseg.cfg", raw).expect("parse"); + let entries: HashMap = parsed.deref().clone(); + assert_eq!(entries.len(), 5); + + match entries.get("webservers").expect("group present") { + MicrosegEntry::Group(group) => { + assert_eq!(group.mark(), 5); + assert_eq!(group.comment(), Some("web tier")); + } + other => panic!("expected group, got {other:?}"), + } + match entries.get("db").expect("db group present") { + MicrosegEntry::Group(group) => assert_eq!(group.parent(), Some("webservers")), + other => panic!("expected db group, got {other:?}"), + } + match entries.get("web-to-db").expect("rule present") { + MicrosegEntry::Rule(rule) => { + assert_eq!(rule.src(), Some("webservers")); + assert_eq!(rule.dst(), "db"); + assert!(rule.allow()); + } + other => panic!("expected rule, got {other:?}"), + } + match entries.get("vmbr0").expect("bridge present") { + MicrosegEntry::Bridge(bridge) => { + assert!(bridge.applies_to("pve1")); + assert!(!bridge.applies_to("pve3")); + } + other => panic!("expected bridge, got {other:?}"), + } + + validate(&entries).expect("config is valid"); + + let mut dangling = entries.clone(); + dangling.insert( + "dangling".to_string(), + MicrosegEntry::Rule(RuleSection { + id: "dangling".parse().unwrap(), + src: None, + dst: "nonexistent".parse().unwrap(), + allow: true, + }), + ); + assert!( + validate(&dangling).is_err(), + "unknown dst must fail validation" + ); + + let data: SectionConfigData = SectionConfigData::from_iter(entries); + MicrosegEntry::write_section_config("microseg.cfg", &data).expect("write back"); + } + + #[test] + fn agent_reads_numeric_allow_from_running_config() { + // perl to_json renders the rule `allow` flag as 0/1, so the agent's serde_json read of the + // raw running config must accept the numeric form (and still accept a real bool). + let json = r#"{"ids":{ + "r1":{"type":"rule","id":"r1","dst":"web","allow":1}, + "r2":{"type":"rule","id":"r2","dst":"web","allow":0}, + "r3":{"type":"rule","id":"r3","dst":"web","allow":true}, + "web":{"type":"group","id":"web","mark":5} + }}"#; + let cfg: MicrosegRunningConfig = serde_json::from_str(json).expect("parse running config"); + let allow: HashMap<&str, bool> = cfg.rules().map(|(id, rule)| (id, rule.allow())).collect(); + assert_eq!(allow.get("r1"), Some(&true)); + assert_eq!(allow.get("r2"), Some(&false)); + assert_eq!(allow.get("r3"), Some(&true)); + } + + #[test] + fn create_and_update_accept_perl_string_scalars() { + // perlmod hands every scalar to serde as a string, so the create and update payloads must + // accept "0"/"1" for the allow flag and string forms of the numeric fields, the way the + // API delivers them. + let rule: api::MicrosegCreate = + serde_json::from_str(r#"{"type":"rule","src":"web","dst":"db","allow":"0"}"#) + .expect("rule create from string scalars"); + match rule { + api::MicrosegCreate::Rule(rule) => assert!(!rule.allow), + other => panic!("expected rule, got {other:?}"), + } + + let assignment: api::MicrosegCreate = + serde_json::from_str(r#"{"type":"assignment","vmid":"100","iface":"0","group":"web"}"#) + .expect("assignment create from string scalars"); + match assignment { + api::MicrosegCreate::Assignment(assignment) => { + assert_eq!(assignment.vmid, 100); + assert_eq!(assignment.iface, 0); + } + other => panic!("expected assignment, got {other:?}"), + } + + let group: api::MicrosegCreate = + serde_json::from_str(r#"{"type":"group","id":"web","mark":"42"}"#) + .expect("group create with string mark"); + match group { + api::MicrosegCreate::Group(group) => assert_eq!(group.mark, Some(42)), + other => panic!("expected group, got {other:?}"), + } + + let update: api::MicrosegUpdate = serde_json::from_str(r#"{"type":"rule","allow":"1"}"#) + .expect("update from string scalar"); + match update { + api::MicrosegUpdate::Rule(rule) => assert_eq!(rule.allow, Some(true)), + other => panic!("expected rule update, got {other:?}"), + } + } + + #[test] + fn validate_rejects_unknown_and_cyclic_parents() { + let group = |name: &str, mark: u16, parent: Option<&str>| { + ( + name.to_string(), + MicrosegEntry::Group(GroupSection { + id: name.parse().unwrap(), + mark, + comment: None, + parent: parent.map(|p| p.parse().unwrap()), + }), + ) + }; + + let unknown: HashMap = + HashMap::from([group("child", 1, Some("ghost"))]); + assert!(validate(&unknown).is_err(), "unknown parent must fail"); + + let tree: HashMap = + HashMap::from([group("parent", 1, None), group("child", 2, Some("parent"))]); + validate(&tree).expect("a valid parent tree"); + + let cycle: HashMap = + HashMap::from([group("ga", 1, Some("gb")), group("gb", 2, Some("ga"))]); + assert!(validate(&cycle).is_err(), "a parent cycle must fail"); + } + + #[test] + fn validate_rejects_duplicate_src_dst() { + let group = |name: &str, mark: u16| { + ( + name.to_string(), + MicrosegEntry::Group(GroupSection { + id: name.parse().unwrap(), + mark, + comment: None, + parent: None, + }), + ) + }; + let rule = |name: &str, src: Option<&str>, dst: &str| { + ( + name.to_string(), + MicrosegEntry::Rule(RuleSection { + id: name.parse().unwrap(), + src: src.map(|s| s.parse().unwrap()), + dst: dst.parse().unwrap(), + allow: true, + }), + ) + }; + + let dup: HashMap = HashMap::from([ + group("web", 1), + group("db", 2), + rule("r1", Some("web"), "db"), + rule("r2", Some("web"), "db"), + ]); + assert!(validate(&dup).is_err(), "duplicate (src,dst) must fail"); + + let ok: HashMap = HashMap::from([ + group("web", 1), + group("db", 2), + rule("r1", Some("web"), "db"), + rule("r2", None, "db"), + ]); + validate(&ok).expect("distinct (src,dst) is fine"); + } + + #[test] + fn build_entry_derives_rule_and_assignment_ids() { + let group = |name: &str, mark: u16| { + ( + name.to_string(), + MicrosegEntry::Group(GroupSection { + id: name.parse().unwrap(), + mark, + comment: None, + parent: None, + }), + ) + }; + let entries: HashMap = + HashMap::from([group("web", 11), group("db", 22)]); + + let rule = api::build_entry( + api::MicrosegCreate::Rule(api::RuleCreate { + src: Some("web".parse().unwrap()), + dst: "db".parse().unwrap(), + allow: true, + }), + &entries, + ) + .expect("build rule"); + assert_eq!(rule.id().to_string(), "p11-22"); + + let unstamped = api::build_entry( + api::MicrosegCreate::Rule(api::RuleCreate { + src: None, + dst: "db".parse().unwrap(), + allow: false, + }), + &entries, + ) + .expect("build unstamped rule"); + assert_eq!(unstamped.id().to_string(), "p0-22"); + + let assignment = api::build_entry( + api::MicrosegCreate::Assignment(api::AssignmentCreate { + vmid: 100, + iface: 0, + group: "web".parse().unwrap(), + }), + &entries, + ) + .expect("build assignment"); + assert_eq!(assignment.id().to_string(), "vm100i0"); + } +} diff --git a/proxmox-ve-config/src/sdn/mod.rs b/proxmox-ve-config/src/sdn/mod.rs index 2133396..f647e87 100644 --- a/proxmox-ve-config/src/sdn/mod.rs +++ b/proxmox-ve-config/src/sdn/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod fabric; pub mod ipam; +pub mod microseg; pub mod prefix_list; pub mod route_map; pub mod wireguard; -- 2.47.3