public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
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	[thread overview]
Message-ID: <20260609132522.235917-5-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260609132522.235917-1-h.laimer@proxmox.com>

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 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<ZonesRunningConfig>,
     subnets: Option<SubnetsRunningConfig>,
     vnets: Option<VnetsRunningConfig>,
+    microseg: Option<MicrosegRunningConfig>,
+}
+
+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<String>,
+    /// 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<MicrosegId>,
+}
+
+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<MicrosegId>,
+    /// 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<String>,
+}
+
+impl BridgeSection {
+    /// Iterate the node-name list, trimming whitespace and skipping empty entries.
+    pub fn nodes(&self) -> impl Iterator<Item = &str> {
+        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<String, MicrosegEntry>,
+}
+
+impl MicrosegRunningConfig {
+    pub fn ids(&self) -> &HashMap<String, MicrosegEntry> {
+        &self.ids
+    }
+
+    pub fn groups(&self) -> impl Iterator<Item = (&str, &GroupSection)> + '_ {
+        self.ids.iter().filter_map(|(k, v)| match v {
+            MicrosegEntry::Group(g) => Some((k.as_str(), g)),
+            _ => None,
+        })
+    }
+
+    pub fn rules(&self) -> impl Iterator<Item = (&str, &RuleSection)> + '_ {
+        self.ids.iter().filter_map(|(k, v)| match v {
+            MicrosegEntry::Rule(r) => Some((k.as_str(), r)),
+            _ => None,
+        })
+    }
+
+    pub fn assignments(&self) -> impl Iterator<Item = (&str, &AssignmentSection)> + '_ {
+        self.ids.iter().filter_map(|(k, v)| match v {
+            MicrosegEntry::Assignment(a) => Some((k.as_str(), a)),
+            _ => None,
+        })
+    }
+
+    pub fn bridges(&self) -> impl Iterator<Item = (&str, &BridgeSection)> + '_ {
+        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<String, MicrosegEntry>) -> Result<(), anyhow::Error> {
+    let mut group_marks: HashMap<u16, &str> = 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<u16>,
+        #[serde(default)]
+        pub comment: Option<String>,
+        #[serde(default)]
+        pub parent: Option<MicrosegId>,
+    }
+
+    #[derive(Debug, Clone, Deserialize)]
+    pub struct RuleCreate {
+        #[serde(default)]
+        pub src: Option<MicrosegId>,
+        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<String>,
+    }
+
+    #[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<String>,
+        #[serde(default)]
+        pub parent: Option<MicrosegId>,
+        #[serde(default)]
+        pub delete: Vec<GroupDeletableProperty>,
+    }
+
+    #[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<bool>,
+    }
+
+    #[derive(Debug, Clone, Default, Deserialize)]
+    pub struct AssignmentUpdate {
+        #[serde(default)]
+        pub group: Option<MicrosegId>,
+    }
+
+    #[derive(Debug, Clone, Default, Deserialize)]
+    pub struct BridgeUpdate {
+        #[serde(default)]
+        pub nodes: Option<String>,
+        #[serde(default)]
+        pub delete: Vec<BridgeDeletableProperty>,
+    }
+
+    #[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<String, MicrosegEntry>) -> Option<u16> {
+        let used: HashSet<u16> = 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<String, MicrosegEntry>,
+    ) -> Result<MicrosegEntry, Error> {
+        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<u16, Error> {
+                    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<String, MicrosegEntry>,
+        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<String, MicrosegEntry> = 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<MicrosegEntry> = 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<String, MicrosegEntry> =
+            HashMap::from([group("child", 1, Some("ghost"))]);
+        assert!(validate(&unknown).is_err(), "unknown parent must fail");
+
+        let tree: HashMap<String, MicrosegEntry> =
+            HashMap::from([group("parent", 1, None), group("child", 2, Some("parent"))]);
+        validate(&tree).expect("a valid parent tree");
+
+        let cycle: HashMap<String, MicrosegEntry> =
+            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<String, MicrosegEntry> = 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<String, MicrosegEntry> = 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<String, MicrosegEntry> =
+            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





  parent reply	other threads:[~2026-06-09 13:27 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-06-09 13:25 [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ebpf,-ve-rs,-perl-rs} 00/16] sdn: add microsegmentation support Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 01/16] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 02/16] bpf: add bridge subsystem Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 03/16] debian: add packaging and boot-time oneshot unit Hannes Laimer
2026-06-09 13:25 ` Hannes Laimer [this message]
2026-06-09 13:25 ` [PATCH proxmox-perl-rs 05/16] sdn: add microseg config binding Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-cluster 06/16] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 07/16] sdn: microseg: add config and API Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 08/16] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 09/16] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 10/16] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-manager 11/16] ui: sdn: add microsegmentation Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-manager 12/16] network: apply microseg state on reload Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-manager 13/16] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-docs 14/16] sdn: add microsegmentation section Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-docs 15/16] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
2026-06-09 13:25 ` [PATCH ifupdown2 16/16] d/patches: add support for VXLAN-GBP flag Hannes Laimer

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260609132522.235917-5-h.laimer@proxmox.com \
    --to=h.laimer@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal