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 v3 13/45] microseg: add a crate that applies the policy to a node's guest NICs
Date: Tue, 27 Oct 2026 09:54:55 +0100	[thread overview]
Message-ID: <20261027085527.702738-14-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com>

The crate hands the microseg policy of the SDN running config to the
policy subsystem of proxmox-ebpf. A full pass applies it to every
guest NIC on the node, a plug to a single NIC that is about to come
up. A validation needs only the config and tells whether the data
plane can hold it.

Only the microseg block of the running config is parsed. A plug runs
for every guest NIC, so a zone or DHCP type that this node's
proxmox-ve-config does not know yet must not keep guests from
starting. The SDN apply on such a node fails either way.

A guest's NICs come from the interfaces on the node and not from its
config, since the config names a hot-plugged NIC only after the
hotplug.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 Cargo.toml                             |   2 +
 proxmox-microseg/Cargo.toml            |  20 +++
 proxmox-microseg/debian/changelog      |   5 +
 proxmox-microseg/debian/control        |  46 +++++
 proxmox-microseg/debian/copyright      |  18 ++
 proxmox-microseg/debian/debcargo.toml  |   7 +
 proxmox-microseg/debian/source/format  |   1 +
 proxmox-microseg/src/ebpf.rs           | 235 +++++++++++++++++++++++++
 proxmox-microseg/src/lib.rs            |  66 +++++++
 proxmox-microseg/src/nic.rs            | 120 +++++++++++++
 proxmox-microseg/src/running_config.rs |  26 +++
 11 files changed, 546 insertions(+)
 create mode 100644 proxmox-microseg/Cargo.toml
 create mode 100644 proxmox-microseg/debian/changelog
 create mode 100644 proxmox-microseg/debian/control
 create mode 100644 proxmox-microseg/debian/copyright
 create mode 100644 proxmox-microseg/debian/debcargo.toml
 create mode 100644 proxmox-microseg/debian/source/format
 create mode 100644 proxmox-microseg/src/ebpf.rs
 create mode 100644 proxmox-microseg/src/lib.rs
 create mode 100644 proxmox-microseg/src/nic.rs
 create mode 100644 proxmox-microseg/src/running_config.rs

diff --git a/Cargo.toml b/Cargo.toml
index 4655b178b47a..d38e48897216 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,7 @@ members = [
     "proxmox-ve-config",
     "proxmox-frr",
     "proxmox-sdn-types",
+    "proxmox-microseg",
 ]
 exclude = [
     "build",
@@ -29,3 +30,4 @@ proxmox-network-types = { version = "1.1.2" }
 proxmox-schema = { version = "5" }
 proxmox-sdn-types = { version = "0.2", path = "proxmox-sdn-types" }
 proxmox-serde = { version = "1.0.0" }
+proxmox-ve-config = { version = "0.10", path = "proxmox-ve-config" }
diff --git a/proxmox-microseg/Cargo.toml b/proxmox-microseg/Cargo.toml
new file mode 100644
index 000000000000..067b0f593990
--- /dev/null
+++ b/proxmox-microseg/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "proxmox-microseg"
+description = "Proxmox VE SDN microsegmentation data plane"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+# proxmox-ebpf needs it
+rust-version = "1.88"
+
+[dependencies]
+anyhow = { workspace = true }
+log = "0.4"
+serde = { workspace = true, features = [ "derive" ] }
+serde_json = "1"
+
+proxmox-ebpf = { version = "0.1", features = [ "policy" ] }
+proxmox-ve-config = { workspace = true }
diff --git a/proxmox-microseg/debian/changelog b/proxmox-microseg/debian/changelog
new file mode 100644
index 000000000000..e8f8ee14dcf9
--- /dev/null
+++ b/proxmox-microseg/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-microseg (0.1.0) trixie; urgency=medium
+
+  * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com>  Tue, 22 Sep 2026 16:02:32 +0200
diff --git a/proxmox-microseg/debian/control b/proxmox-microseg/debian/control
new file mode 100644
index 000000000000..35487025c2b7
--- /dev/null
+++ b/proxmox-microseg/debian/control
@@ -0,0 +1,46 @@
+Source: rust-proxmox-microseg
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.88) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-log-0.4+default-dev <!nocheck>,
+ librust-proxmox-ebpf-0.1+default-dev <!nocheck>,
+ librust-proxmox-ebpf-0.1+policy-dev <!nocheck>,
+ librust-proxmox-ve-config-0.10+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
+ librust-serde-json-1+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/proxmox-ve-rs.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox-ve-rs.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-microseg
+
+Package: librust-proxmox-microseg-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-anyhow-1+default-dev,
+ librust-log-0.4+default-dev,
+ librust-proxmox-ebpf-0.1+default-dev,
+ librust-proxmox-ebpf-0.1+policy-dev,
+ librust-proxmox-ve-config-0.10+default-dev,
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev,
+ librust-serde-json-1+default-dev
+Provides:
+ librust-proxmox-microseg+default-dev (= ${binary:Version}),
+ librust-proxmox-microseg-0-dev (= ${binary:Version}),
+ librust-proxmox-microseg-0+default-dev (= ${binary:Version}),
+ librust-proxmox-microseg-0.1-dev (= ${binary:Version}),
+ librust-proxmox-microseg-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-microseg-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-microseg-0.1.0+default-dev (= ${binary:Version})
+Description: Proxmox VE SDN microsegmentation data plane - Rust source code
+ Source code for Debianized Rust crate "proxmox-microseg"
diff --git a/proxmox-microseg/debian/copyright b/proxmox-microseg/debian/copyright
new file mode 100644
index 000000000000..77952eba571e
--- /dev/null
+++ b/proxmox-microseg/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2019 - 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/proxmox-microseg/debian/debcargo.toml b/proxmox-microseg/debian/debcargo.toml
new file mode 100644
index 000000000000..87a787e6d03e
--- /dev/null
+++ b/proxmox-microseg/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox-ve-rs.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox-ve-rs.git"
diff --git a/proxmox-microseg/debian/source/format b/proxmox-microseg/debian/source/format
new file mode 100644
index 000000000000..89ae9db8f88b
--- /dev/null
+++ b/proxmox-microseg/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/proxmox-microseg/src/ebpf.rs b/proxmox-microseg/src/ebpf.rs
new file mode 100644
index 000000000000..02ea16d5a2ef
--- /dev/null
+++ b/proxmox-microseg/src/ebpf.rs
@@ -0,0 +1,235 @@
+use anyhow::{Context, bail};
+use proxmox_ebpf::policy::{self, Assignment, CHECK_MAX, CheckEntry, Family, Pair, Pass};
+use proxmox_ve_config::firewall::types::port::{PortEntry, PortList};
+use proxmox_ve_config::firewall::types::rule_match::{
+    IcmpCode, IcmpType, Icmpv6Code, Icmpv6Type, Ports, Protocol,
+};
+use proxmox_ve_config::sdn::microseg::MicrosegRunningConfig;
+use proxmox_ve_config::sdn::microseg::state::{self, DesiredState};
+
+use crate::nic::{self, LocalNics};
+
+pub use proxmox_ebpf::policy::{ExclusiveLock, PAIRS_MAX, SharedLock, lock_exclusive, lock_shared};
+
+fn port_bounds(list: Option<&PortList>) -> Vec<Option<(u16, u16)>> {
+    let Some(list) = list else {
+        // without a port list every port matches
+        return vec![None];
+    };
+    list.iter()
+        .map(|entry| {
+            Some(match entry {
+                PortEntry::Port(port) => (*port, *port),
+                PortEntry::Range(from, to) => (*from, *to),
+            })
+        })
+        .collect()
+}
+
+/// One entry per combination of source and destination range, since an entry carries a single range
+/// of each and both have to match.
+fn port_entries(ip_proto: u8, ports: &Ports, allow: bool) -> Vec<CheckEntry> {
+    let dports = port_bounds(ports.dport());
+    let mut entries = Vec::new();
+    for sport in port_bounds(ports.sport()) {
+        for &dport in &dports {
+            entries.push(CheckEntry {
+                ip_proto: Some(ip_proto),
+                sport,
+                dport,
+                allow,
+                ..Default::default()
+            });
+        }
+    }
+    entries
+}
+
+fn protocol_entries(protocol: &Protocol, allow: bool) -> anyhow::Result<Vec<CheckEntry>> {
+    let Some(ip_proto) = protocol.number() else {
+        bail!("protocol {protocol:?} was not resolved to a number");
+    };
+    Ok(match protocol {
+        Protocol::Tcp(tcp) => port_entries(ip_proto, tcp.ports(), allow),
+        Protocol::Udp(udp) => port_entries(ip_proto, udp.ports(), allow),
+        Protocol::Sctp(sctp) => port_entries(ip_proto, sctp.ports(), allow),
+        Protocol::Dccp(ports) | Protocol::UdpLite(ports) => port_entries(ip_proto, ports, allow),
+        // an icmp match covers IPv4 frames and an icmpv6 match IPv6 frames, which the protocol
+        // number alone does not say
+        Protocol::Icmp(icmp) => vec![CheckEntry {
+            ip_proto: Some(ip_proto),
+            family: Family::V4,
+            icmp_type: icmp.ty().and_then(IcmpType::number),
+            icmp_code: icmp.code().map(IcmpCode::number),
+            allow,
+            ..Default::default()
+        }],
+        Protocol::Icmpv6(icmpv6) => vec![CheckEntry {
+            ip_proto: Some(ip_proto),
+            family: Family::V6,
+            icmp_type: icmpv6.ty().and_then(Icmpv6Type::number),
+            icmp_code: icmpv6.code().map(Icmpv6Code::number),
+            allow,
+            ..Default::default()
+        }],
+        Protocol::Named(_) | Protocol::Numeric(_) => vec![CheckEntry {
+            ip_proto: Some(ip_proto),
+            allow,
+            ..Default::default()
+        }],
+    })
+}
+
+/// Pairs share checks, so translate each check once up front instead of once per pair.
+fn check_entries(state: &DesiredState) -> anyhow::Result<Vec<Vec<CheckEntry>>> {
+    let mut checks = Vec::with_capacity(state.checks.len());
+    for check in &state.checks {
+        let mut entries = Vec::new();
+        for protocol in &check.protocols {
+            entries.extend(protocol_entries(protocol, check.allow)?);
+        }
+        checks.push(entries);
+    }
+    Ok(checks)
+}
+
+fn pair_entries(
+    checks: &[Vec<CheckEntry>],
+    pair: &state::Pair,
+    cfg: &MicrosegRunningConfig,
+) -> anyhow::Result<Vec<CheckEntry>> {
+    let mut entries = Vec::new();
+    for &check in &pair.checks {
+        entries.extend_from_slice(&checks[check]);
+    }
+    // proxmox-ebpf refuses such a pair as well, but cannot name the classes by their groups
+    if entries.len() > CHECK_MAX {
+        bail!(
+            "pair {} -> {} needs {} checks, a pair holds at most {CHECK_MAX}",
+            cfg.describe_class(pair.src_id),
+            cfg.describe_class(pair.dst_id),
+            entries.len(),
+        );
+    }
+    Ok(entries)
+}
+
+fn pairs(state: &DesiredState, cfg: &MicrosegRunningConfig) -> anyhow::Result<Vec<Pair>> {
+    let checks = check_entries(state)?;
+    let mut pairs = Vec::with_capacity(state.pairs.len());
+    for pair in &state.pairs {
+        pairs.push(Pair {
+            src_id: pair.src_id,
+            dst_id: pair.dst_id,
+            checks: pair_entries(&checks, pair, cfg)?,
+            allow: pair.allow,
+        });
+    }
+    Ok(pairs)
+}
+
+pub struct FullPass {
+    configured: bool,
+    assignments: Vec<Assignment>,
+    pairs: Vec<Pair>,
+}
+
+pub fn full_pass(
+    nics: &LocalNics,
+    state: &DesiredState,
+    cfg: &MicrosegRunningConfig,
+) -> anyhow::Result<FullPass> {
+    let assignments = state
+        .nics
+        .iter()
+        .map(|resolved| {
+            let Some(name) = nics.name(resolved.vmid, resolved.iface) else {
+                bail!(
+                    "NIC vm{} net{} has no interface on this node",
+                    resolved.vmid,
+                    resolved.iface
+                );
+            };
+            Ok(Assignment {
+                iface: name.to_owned(),
+                id: resolved.id,
+                firewall_bridge: nic::behind_firewall_bridge(name),
+            })
+        })
+        .collect::<anyhow::Result<Vec<_>>>()?;
+    Ok(FullPass {
+        configured: state.configured,
+        assignments,
+        pairs: pairs(state, cfg)?,
+    })
+}
+
+pub fn check(state: &DesiredState, cfg: &MicrosegRunningConfig) -> anyhow::Result<()> {
+    policy::check(&pairs(state, cfg)?).context("the data plane cannot take the microseg policy")
+}
+
+pub fn apply(lock: &ExclusiveLock, pass: &FullPass) -> anyhow::Result<()> {
+    let applied = lock.apply(pass.configured, &pass.assignments, &pass.pairs)?;
+    if applied != Pass::Applied {
+        return Ok(());
+    }
+    let level = if pass.assignments.is_empty() {
+        log::Level::Debug
+    } else {
+        log::Level::Info
+    };
+    log::log!(
+        level,
+        "microseg: applied {} assigned NICs on this node, {} pairs",
+        pass.assignments.len(),
+        pass.pairs.len()
+    );
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use proxmox_ve_config::firewall::types::rule_match::Tcp;
+    use proxmox_ve_config::sdn::microseg::state::Check;
+
+    use super::*;
+
+    fn ports(list: &str) -> PortList {
+        list.parse().expect("valid port list")
+    }
+
+    /// The entries depend on nothing but the one check.
+    fn state(protocols: Vec<Protocol>) -> DesiredState {
+        DesiredState {
+            configured: true,
+            pairs: vec![state::Pair {
+                src_id: 1,
+                dst_id: 2,
+                checks: vec![0],
+                allow: false,
+            }],
+            checks: vec![Check {
+                protocols,
+                allow: true,
+            }],
+            nics: Vec::new(),
+        }
+    }
+
+    #[test]
+    fn check_rejects_what_the_apply_would() {
+        let elements: Vec<String> = (1..=CHECK_MAX + 1).map(|port| port.to_string()).collect();
+        let tcp = Tcp::new(Ports::new(None, ports(&elements.join(","))));
+        let over = state(vec![Protocol::Tcp(tcp)]);
+        let cfg: MicrosegRunningConfig = serde_json::from_str(
+            r#"{"ids":{},"identities":{"next":3,"classes":{"1":["web"],"2":["db"]}}}"#,
+        )
+        .expect("parse running config");
+        let error = check(&over, &cfg).expect_err("past the bound");
+        let message = format!("{error:#}");
+        assert!(message.contains("pair 1 (web) -> 2 (db)"), "{message}");
+
+        let tcp = Tcp::new(Ports::new(None, ports("22")));
+        check(&state(vec![Protocol::Tcp(tcp)]), &cfg).expect("an ordinary pair");
+    }
+}
diff --git a/proxmox-microseg/src/lib.rs b/proxmox-microseg/src/lib.rs
new file mode 100644
index 000000000000..4646d70982d1
--- /dev/null
+++ b/proxmox-microseg/src/lib.rs
@@ -0,0 +1,66 @@
+mod ebpf;
+mod nic;
+mod running_config;
+
+use anyhow::{Context, bail};
+use proxmox_ve_config::sdn::microseg::state::{DesiredState, resolve_iface};
+use proxmox_ve_config::sdn::microseg::{GuestInfo, MicrosegRunningConfig};
+
+pub use crate::ebpf::{ExclusiveLock, SharedLock, lock_exclusive, lock_shared};
+pub use crate::running_config::parse as parse_running_config;
+
+fn full_pass(
+    cfg: &MicrosegRunningConfig,
+    inventory: &[GuestInfo],
+) -> anyhow::Result<ebpf::FullPass> {
+    let nics = nic::LocalNics::list()?;
+    let state = DesiredState::build(cfg, &nics.guests(inventory), ebpf::PAIRS_MAX)
+        .context("desired state")?;
+    ebpf::full_pass(&nics, &state, cfg)
+}
+
+pub fn apply(
+    lock: &ExclusiveLock,
+    cfg: &MicrosegRunningConfig,
+    inventory: &[GuestInfo],
+) -> anyhow::Result<()> {
+    ebpf::apply(lock, &full_pass(cfg, inventory)?)
+}
+
+/// Fails for a config the data plane could not install. The pairs alone decide that, and they
+/// cover every allocated class whatever the guests match, so no inventory is needed.
+pub fn check(cfg: &MicrosegRunningConfig) -> anyhow::Result<()> {
+    let state = DesiredState::build(cfg, &[], ebpf::PAIRS_MAX).context("desired state")?;
+    ebpf::check(&state, cfg)
+}
+
+/// Enforce the policy on a guest NIC that is about to come up. An assigned NIC gets its class. A
+/// NIC no assignment covers loses what an earlier plug or pass placed for its index, so one that
+/// comes up again in place drops its old identity. A failure in either case is an error rather
+/// than a skip, since the NIC would come up unenforced or as a class it left. `guest` is the
+/// plugged guest as the inventory lists it. `firewall_bridge` says the NIC is about to be put
+/// behind a firewall bridge.
+pub fn apply_iface(
+    lock: &SharedLock,
+    cfg: &MicrosegRunningConfig,
+    guest: &GuestInfo,
+    ifname: &str,
+    firewall_bridge: bool,
+) -> anyhow::Result<()> {
+    let Some((vmid, iface)) = nic::parse(ifname) else {
+        log::warn!("microseg: {ifname} is not a guest NIC name, skipping");
+        return Ok(());
+    };
+    if vmid != guest.vmid {
+        bail!(
+            "{ifname} is a NIC of guest {vmid}, not of guest {}",
+            guest.vmid
+        );
+    }
+    let Some(id) = resolve_iface(cfg, guest, iface)? else {
+        log::debug!("microseg: {ifname} is unassigned");
+        lock.release_iface(ifname)?;
+        return Ok(());
+    };
+    lock.apply_iface(ifname, id, firewall_bridge)
+}
diff --git a/proxmox-microseg/src/nic.rs b/proxmox-microseg/src/nic.rs
new file mode 100644
index 000000000000..6a0b974b0f49
--- /dev/null
+++ b/proxmox-microseg/src/nic.rs
@@ -0,0 +1,120 @@
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+
+use anyhow::Context;
+use proxmox_ve_config::sdn::microseg::GuestInfo;
+
+/// The guest and the NIC index a host interface name stands for.
+pub fn parse(name: &str) -> Option<(u32, u32)> {
+    let rest = name
+        .strip_prefix("tap")
+        .or_else(|| name.strip_prefix("veth"))?;
+    let (vmid, iface) = rest.split_once('i')?;
+    // the integer parser alone would also take a sign
+    let number = |digits: &str| -> Option<u32> {
+        digits
+            .bytes()
+            .all(|byte| byte.is_ascii_digit())
+            .then(|| digits.parse().ok())
+            .flatten()
+    };
+    Some((number(vmid)?, number(iface)?))
+}
+
+/// A guest without a name or tags may be missing from the inventory. An assignment by VMID covers
+/// it all the same, so it is known by its VMID alone.
+fn guest(known: Option<&GuestInfo>, vmid: u32) -> GuestInfo {
+    known.cloned().unwrap_or(GuestInfo {
+        vmid,
+        ..Default::default()
+    })
+}
+
+pub fn behind_firewall_bridge(name: &str) -> bool {
+    let Some((vmid, iface)) = parse(name) else {
+        return false;
+    };
+    std::fs::read_link(format!("/sys/class/net/{name}/master")).is_ok_and(|bridge| {
+        bridge
+            .file_name()
+            .is_some_and(|master| master.to_string_lossy() == format!("fwbr{vmid}i{iface}"))
+    })
+}
+
+/// The node says which NICs a guest has, since its config does not name a hot-plugged NIC until the
+/// plug is through.
+pub struct LocalNics(BTreeMap<(u32, u32), String>);
+
+impl LocalNics {
+    pub fn list() -> anyhow::Result<Self> {
+        // a failure must not read as a node without guest NICs, a full pass would then take
+        // enforcement off every one of them
+        let names = std::fs::read_dir("/sys/class/net")
+            .and_then(|entries| {
+                entries
+                    .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned()))
+                    .collect::<std::io::Result<Vec<_>>>()
+            })
+            .context("list the interfaces of this node")?;
+        Ok(Self::from_names(names))
+    }
+
+    fn from_names(names: impl IntoIterator<Item = String>) -> Self {
+        let nics = names
+            .into_iter()
+            .filter_map(|name| Some((parse(&name)?, name)));
+        Self(nics.collect())
+    }
+
+    pub fn name(&self, vmid: u32, iface: u32) -> Option<&str> {
+        self.0.get(&(vmid, iface)).map(String::as_str)
+    }
+
+    pub fn guests(&self, inventory: &[GuestInfo]) -> Vec<GuestInfo> {
+        let mut nics: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
+        for &(vmid, iface) in self.0.keys() {
+            nics.entry(vmid).or_default().insert(iface);
+        }
+        let known: HashMap<u32, &GuestInfo> = inventory.iter().map(|g| (g.vmid, g)).collect();
+        nics.into_iter()
+            .map(|(vmid, nics)| GuestInfo {
+                nics: nics.into_iter().collect(),
+                ..guest(known.get(&vmid).copied(), vmid)
+            })
+            .collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_guest_has_the_nics_the_node_has() {
+        let inventory = [
+            GuestInfo {
+                vmid: 100,
+                name: Some("web".to_string()),
+                nics: vec![0, 5],
+                ..Default::default()
+            },
+            GuestInfo {
+                vmid: 300,
+                nics: vec![0],
+                ..Default::default()
+            },
+        ];
+        let names = ["lo", "tap100i1", "fwln100i0", "veth200i0", "tap100i0"];
+        let nics = LocalNics::from_names(names.map(str::to_string));
+        let guests = nics.guests(&inventory);
+        assert_eq!(nics.name(200, 0), Some("veth200i0"));
+
+        // net1 is not in the config of vm100 yet and net5 is not plugged, vm300 runs elsewhere
+        assert_eq!(guests.len(), 2);
+        assert_eq!(guests[0].vmid, 100);
+        assert_eq!(guests[0].name.as_deref(), Some("web"));
+        assert_eq!(guests[0].nics, [0, 1]);
+        assert_eq!(guests[1].vmid, 200);
+        assert_eq!(guests[1].name, None);
+        assert_eq!(guests[1].nics, [0]);
+    }
+}
diff --git a/proxmox-microseg/src/running_config.rs b/proxmox-microseg/src/running_config.rs
new file mode 100644
index 000000000000..dbf8160de196
--- /dev/null
+++ b/proxmox-microseg/src/running_config.rs
@@ -0,0 +1,26 @@
+use proxmox_ve_config::sdn::microseg::MicrosegRunningConfig;
+use serde::Deserialize;
+
+#[derive(Deserialize)]
+struct Sections {
+    microseg: Option<MicrosegRunningConfig>,
+}
+
+/// `None` for a running config without a microseg block. Every apply writes the block, even an
+/// empty one, so only a node without microseg support leaves it out. Such a document says nothing
+/// about the policy, it does not declare an empty one.
+pub fn parse(raw: &str) -> anyhow::Result<Option<MicrosegRunningConfig>> {
+    let sections: Sections = serde_json::from_str(raw)?;
+    Ok(sections.microseg)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_missing_block_is_absent() {
+        let cfg = parse(r#"{"zones": {"ids": {}}}"#).expect("parse running config");
+        assert_eq!(cfg, None);
+    }
+}
-- 
2.47.3





  parent reply	other threads:[~2026-10-27  8:59 UTC|newest]

Thread overview: 46+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-27  8:54 [PATCH access-control/cluster/container/docs/manager/network/proxmox{-ebpf,-ve-rs,-perl-rs}/qemu-server v3 00/45] sdn: add microsegmentation support Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 01/45] build: keep the build directory out of the BPF objects Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 02/45] policy: add the enforcement subsystem for guest interfaces Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 03/45] policy: add a check for non-first fragments Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 04/45] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 05/45] ve-config: sdn: microseg: add identity allocation for group sets Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 06/45] ve-config: sdn: microseg: add config types Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 07/45] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 08/45] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 09/45] ve-config: sdn: microseg: narrow rules by protocol and ports Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 10/45] ve-config: sdn: microseg: add group hierarchy Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 11/45] ve-config: firewall: expose protocol, icmp type and code numbers Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 12/45] ve-config: sdn: microseg: add the desired state Hannes Laimer
2026-10-27  8:54 ` Hannes Laimer [this message]
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 14/45] microseg: add a rule option that matches non-first fragments Hannes Laimer
2026-10-27  8:54 ` [PATCH pve-cluster v3 15/45] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-10-27  8:54 ` [PATCH pve-access-control v3 16/45] permissions: add ACL path for SDN microsegmentation Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-perl-rs v3 17/45] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 18/45] pve-rs: sdn: microseg: bind the data plane Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 19/45] pve-rs: sdn: microseg: check the data plane limits on render Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 20/45] pve-rs: debian: fire the pve-microseg-update trigger on upgrade Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 21/45] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 22/45] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 23/45] sdn: dry-run: show pending microseg changes Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 24/45] sdn: microseg: enforce the policy on guest NICs Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 25/45] sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 26/45] sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 27/45] sdn: microseg: add an update for a running NIC Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 28/45] sdn: microseg: expose protocol, port and macro rule options Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 29/45] sdn: microseg: allow nesting groups under a parent Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 30/45] sdn: microseg: expose the fragments rule option Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 31/45] network: apply the microseg policy on reload Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 32/45] pve-sdn-commit: apply the microseg policy at boot Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 33/45] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 34/45] ui: sdn: add microsegmentation panel Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 35/45] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 36/45] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 37/45] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 38/45] ui: sdn: microseg: add narrowing to the rule form Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 39/45] ui: sdn: microseg: show and edit the group hierarchy Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 40/45] ui: sdn: microseg: add fragments option to the rule form Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 41/45] ui: sdn: microseg: add a guest tab with its groups and rules Hannes Laimer
2026-10-27  8:55 ` [PATCH qemu-server v3 42/45] network: update the microseg enforcement on a tag or name change Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-container v3 43/45] lxc: update the microseg enforcement on a tag or hostname change Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-docs v3 44/45] sdn: zones: document the VXLAN-GBP option Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-docs v3 45/45] sdn: add microsegmentation section 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=20261027085527.702738-14-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