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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.