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
next prev 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