* [PATCH proxmox-ebpf 01/16] agent: add userspace coordinator and stateless policy subsystem
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 02/16] bpf: add bridge subsystem Hannes Laimer
` (14 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
The userspace agent reads the desired state from the SDN running-config and
runs one-shot per event
- at boot: full pass
- at sdn apply: full pass
- at tap_plug: only additive, for the interface in question
It updates the pinned kernel state (the BPF maps and the attached
programs) to match. A full pass covers the whole host. It writes the
per-interface group mappings and the policy rules, and attaches or
detaches programs so the right interfaces are covered. The tap_plug path
is narrow. It sets the group mapping for the one new interface and
attaches its program, leaving the policy rules and other interfaces
untouched.
The programs are loaded and verified once, then pinned in bpffs and
reused on later runs. A run reloads only when the embedded BPF object or
its map schema changed, so the common path attaches links and syncs maps
against what is already in the kernel without going through the verifier.
The agent manages multiple BPF programs that do not share state, here
called `subsystems`. The first, `policy`, filters guest traffic by group
with a stateless BPF tap program backed by pinned maps.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
include/mark.h | 30 +++
src/agent.rs | 85 +++++++++
src/main.rs | 68 +++++++
src/policy/bpf/tap.bpf.c | 66 +++++++
src/policy/bpf/types.h | 23 +++
src/policy/mod.rs | 268 +++++++++++++++++++++++++++
src/policy/types.rs | 45 +++++
src/running_config.rs | 38 ++++
src/state.rs | 286 +++++++++++++++++++++++++++++
src/subsystem.rs | 383 +++++++++++++++++++++++++++++++++++++++
src/tc.rs | 151 +++++++++++++++
11 files changed, 1443 insertions(+)
create mode 100644 include/mark.h
create mode 100644 src/agent.rs
create mode 100644 src/main.rs
create mode 100644 src/policy/bpf/tap.bpf.c
create mode 100644 src/policy/bpf/types.h
create mode 100644 src/policy/mod.rs
create mode 100644 src/policy/types.rs
create mode 100644 src/running_config.rs
create mode 100644 src/state.rs
create mode 100644 src/subsystem.rs
create mode 100644 src/tc.rs
diff --git a/include/mark.h b/include/mark.h
new file mode 100644
index 0000000..e3c0577
--- /dev/null
+++ b/include/mark.h
@@ -0,0 +1,30 @@
+#ifndef PROXMOX_EBPF_MARK_H
+#define PROXMOX_EBPF_MARK_H
+
+// Include after vmlinux.h and <bpf/bpf_helpers.h>, which provide struct
+// __sk_buff, the __uNN types, __always_inline, and barrier_var().
+//
+// skb->mark is shared with the rest of the host (firewall fwmark, routing,
+// ...). Microseg owns only the low 16 bits, which hold the group id and are
+// also what travels on the wire. The helpers below touch only that range, so
+// any bits other subsystems set are preserved.
+#define MICROSEG_MARK_MASK 0xffffu
+
+// Replace microseg's bits in skb->mark with the group, leaving the rest untouched.
+static __always_inline void microseg_mark_set(struct __sk_buff *skb, __u16 group) {
+ __u32 mark = skb->mark;
+ mark = (mark & ~MICROSEG_MARK_MASK) | ((__u32)group & MICROSEG_MARK_MASK);
+ // skb->mark is BPF context, which the verifier only allows at its full
+ // 32-bit width. Without the barrier clang narrows the write to a 16-bit
+ // store of the low half, a valid little-endian read-modify-write that the
+ // verifier then rejects as an invalid context access.
+ barrier_var(mark);
+ skb->mark = mark;
+}
+
+// The group currently stamped in skb->mark.
+static __always_inline __u16 microseg_mark_get(struct __sk_buff *skb) {
+ return (__u16)(skb->mark & MICROSEG_MARK_MASK);
+}
+
+#endif
diff --git a/src/agent.rs b/src/agent.rs
new file mode 100644
index 0000000..ae8eb66
--- /dev/null
+++ b/src/agent.rs
@@ -0,0 +1,85 @@
+//! Agent orchestrator. Builds the per-host [`DesiredState`] from the SDN running-config and applies
+//! it through the [`policy`](crate::policy) subsystem. A full pass ([`apply`](Agent::apply)) covers
+//! the whole host, the tap_plug path
+//! ([`apply_guest_iface_policy`](Agent::apply_guest_iface_policy)) programs a single interface.
+//!
+//! The binary runs one-shot per event (boot, an SDN apply, a tap_plug), not as a resident daemon.
+//! Every invocation first makes sure the programs are loaded (installing them on the first run or a
+//! version change), then applies. Maps and links are pinned in bpffs, so they persist across
+//! invocations and re-running never interrupts traffic.
+
+use anyhow::Context;
+
+use crate::{policy::PolicySubsystem, running_config, state::DesiredState};
+
+pub struct Agent {
+ policy: PolicySubsystem,
+}
+
+impl Agent {
+ pub fn new() -> Self {
+ Self {
+ policy: PolicySubsystem::new(),
+ }
+ }
+
+ /// Full pass over the host.
+ pub fn apply(&mut self) -> anyhow::Result<()> {
+ let Some(state) = build_state()? else {
+ return Ok(());
+ };
+ log::debug!(
+ "applying: {} groups, {} rules, {} assignments",
+ state.groups.len(),
+ state.rules.len(),
+ state.assignments.len(),
+ );
+ if log::log_enabled!(log::Level::Trace) {
+ for (id, g) in &state.groups {
+ log::trace!("group {id} = '{}'", g.name);
+ }
+ }
+ if let Err(e) = self.policy.apply(&state) {
+ log::error!("policy: apply: {e:#}");
+ }
+ Ok(())
+ }
+
+ /// Fast path for a guest NIC that just appeared (a tap_plug). Programs just that interface.
+ pub fn apply_guest_iface_policy(&mut self, iface: &str) -> anyhow::Result<()> {
+ let state = match build_state() {
+ Ok(Some(state)) => state,
+ Ok(None) => return Ok(()),
+ Err(e) => {
+ log::error!("apply {iface}: load running config: {e:#}");
+ return Ok(());
+ }
+ };
+ self.policy.apply_guest_iface(&state, iface)
+ }
+
+ /// Detach everything and drop all pinned/run state, for package removal.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ self.policy.clear()?;
+ Ok(())
+ }
+}
+
+fn build_state() -> anyhow::Result<Option<DesiredState>> {
+ let microseg = running_config::load_microseg()?;
+ let hostname = read_hostname()?;
+ match DesiredState::build(µseg, &hostname) {
+ Ok(s) => Ok(Some(s)),
+ Err(e) => {
+ log::error!("desired state: {e:#}");
+ Ok(None)
+ }
+ }
+}
+
+fn read_hostname() -> anyhow::Result<String> {
+ let hostname = nix::unistd::gethostname().context("gethostname")?;
+ hostname
+ .into_string()
+ .map_err(|os| anyhow::anyhow!("non-utf8 hostname: {os:?}"))
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..6b3c16c
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,68 @@
+mod agent;
+mod policy;
+mod running_config;
+mod state;
+mod subsystem;
+mod tc;
+
+use agent::Agent;
+use anyhow::{anyhow, bail};
+use proxmox_log::{LevelFilter, Logger};
+
+const HELP: &str = "\
+Usage: proxmox-ebpf <command> [<interface>]
+
+Commands:
+ apply [<iface>] apply the SDN running-config to BPF state. With <iface>,
+ only that interface.
+ clear detach all programs and drop pinned state (used on removal)
+ help show this help
+";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Command {
+ Apply,
+ Clear,
+ Help,
+}
+
+impl std::str::FromStr for Command {
+ type Err = anyhow::Error;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Ok(match s {
+ "apply" => Command::Apply,
+ "clear" => Command::Clear,
+ "help" => Command::Help,
+ other => bail!("{other:?} is not a valid command"),
+ })
+ }
+}
+
+fn init_logger() -> anyhow::Result<()> {
+ Logger::from_env("PROXMOX_EBPF_LOG", LevelFilter::INFO)
+ .stderr_pve()
+ .init()?;
+ Ok(())
+}
+
+fn main() -> anyhow::Result<()> {
+ let mut args = pico_args::Arguments::from_env();
+ let cmd: Command = args
+ .subcommand()?
+ .ok_or_else(|| anyhow!("no command specified\n\n{HELP}"))?
+ .parse()?;
+
+ init_logger()?;
+
+ match cmd {
+ Command::Help => {
+ println!("{HELP}");
+ Ok(())
+ }
+ Command::Apply => match args.opt_free_from_str::<String>()? {
+ None => Agent::new().apply(),
+ Some(iface) => Agent::new().apply_guest_iface_policy(&iface),
+ },
+ Command::Clear => Agent::new().clear(),
+ }
+}
diff --git a/src/policy/bpf/tap.bpf.c b/src/policy/bpf/tap.bpf.c
new file mode 100644
index 0000000..b9a1b51
--- /dev/null
+++ b/src/policy/bpf/tap.bpf.c
@@ -0,0 +1,66 @@
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include "mark.h"
+#include "types.h"
+#include "bpf_debug.h"
+
+char LICENSE[] SEC("license") = "GPL";
+
+#define TC_ACT_OK 0
+#define TC_ACT_SHOT 2
+
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __type(key, __u32); // ifindex
+ __type(value, struct guest_group);
+ __uint(max_entries, 65560); // max # of tap interfaces, ~6 MB
+ __uint(pinning, LIBBPF_PIN_BY_NAME);
+} tap_to_group SEC(".maps");
+
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __type(key, struct rule_key);
+ __type(value, struct rule_value);
+ __uint(max_entries, 1048576);
+ __uint(pinning, LIBBPF_PIN_BY_NAME);
+} rules SEC(".maps");
+
+// Place group id in lower 16 bits of sk(i)b(idi)->mark.
+SEC("classifier")
+int tc_tap_ingress(struct __sk_buff *skb) {
+ __u32 ifidx = skb->ifindex;
+ struct guest_group *g = bpf_map_lookup_elem(&tap_to_group, &ifidx);
+ if (!g) return TC_ACT_OK;
+ DBG("mark: src_grp=%u ifidx=%u", g->group, ifidx);
+ microseg_mark_set(skb, g->group);
+ return TC_ACT_OK;
+}
+
+
+// Enforce on egress of the destination tap, where both ends are known, dst
+// from this tap and src from skb->mark.
+//
+// - look up (src_group, dst_group) in `rules`, if no entry default-deny
+// - for unstamped packets (src_group=0) to be allowed a explicity
+// (0,dst_group)->1 is required
+SEC("classifier")
+int tc_tap_egress(struct __sk_buff *skb) {
+ __u32 ifidx = skb->ifindex;
+ __u16 src_group = microseg_mark_get(skb);
+
+ struct guest_group *g = bpf_map_lookup_elem(&tap_to_group, &ifidx);
+ if (!g) return TC_ACT_OK;
+
+ struct rule_key k = { .src_group = src_group, .dst_group = g->group };
+ struct rule_value *r = bpf_map_lookup_elem(&rules, &k);
+ if (!r) {
+ DBG("deny (no rule): src_grp=%u dst_grp=%u", src_group, g->group);
+ return TC_ACT_SHOT;
+ }
+ if (!r->allow) {
+ DBG("deny (explicit): src_grp=%u dst_grp=%u", src_group, g->group);
+ return TC_ACT_SHOT;
+ }
+ return TC_ACT_OK;
+}
+
diff --git a/src/policy/bpf/types.h b/src/policy/bpf/types.h
new file mode 100644
index 0000000..67a86a9
--- /dev/null
+++ b/src/policy/bpf/types.h
@@ -0,0 +1,23 @@
+#ifndef PROXMOX_EBPF_POLICY_TYPES_H
+#define PROXMOX_EBPF_POLICY_TYPES_H
+
+// Keep in sync with ../types.rs.
+// __u8/__u16/__u32 are provided by vmlinux.h
+
+struct guest_group {
+ __u16 group;
+ __u16 _pad;
+};
+
+struct rule_key {
+ __u16 src_group;
+ __u16 dst_group;
+};
+
+struct rule_value {
+ __u8 allow;
+ __u8 _pad[3];
+};
+
+#endif
+
diff --git a/src/policy/mod.rs b/src/policy/mod.rs
new file mode 100644
index 0000000..9b3eae8
--- /dev/null
+++ b/src/policy/mod.rs
@@ -0,0 +1,268 @@
+//! Tap-side enforcement. Attaches BPF programs to per-VM tap/veth interfaces that (a) stamp
+//! `skb->mark` with the source group on ingress, and (b) drop packets on egress if no `(src_group,
+//! dst_group) -> allow` rule exists for the pair. Driven by the [`DesiredState`] derived from
+//! `/etc/pve/sdn/.running-config`, resolved per host via `veth{vmid}i{iface}` /
+//! `tap{vmid}i{iface}`.
+//!
+//! Two maps hold the state, `tap_to_group` (ifindex -> group) and `rules` ((src, dst) -> allow).
+
+mod types;
+
+use std::collections::{HashMap, HashSet};
+
+use anyhow::Context;
+use aya::include_bytes_aligned;
+
+use self::types::*;
+use crate::state::{DesiredState, ResolvedAssignment, ResolvedRule};
+use crate::subsystem::TcPrograms;
+use crate::tc::Direction;
+
+const NAME: &str = "policy";
+
+const POLICY_OBJ: &[u8] = include_bytes_aligned!(concat!(env!("OUT_DIR"), "/tap.bpf.o"));
+const POLICY_FINGERPRINT: u64 = TcPrograms::obj_fingerprint(POLICY_OBJ);
+
+// BUMP THIS when a semantically-incompatible change to a map definition in tap.bpf.c is made
+const SCHEMA_VERSION: u32 = 1;
+
+fn program_name(dir: Direction) -> &'static str {
+ match dir {
+ Direction::Ingress => "tc_tap_ingress",
+ Direction::Egress => "tc_tap_egress",
+ }
+}
+
+pub struct PolicySubsystem {
+ programs: TcPrograms,
+}
+
+impl PolicySubsystem {
+ pub fn new() -> Self {
+ Self {
+ programs: TcPrograms::new(
+ NAME,
+ POLICY_OBJ,
+ POLICY_FINGERPRINT,
+ program_name,
+ Some(SCHEMA_VERSION),
+ ),
+ }
+ }
+
+ /// Full pass over the host (boot, after an SDN apply). Holds the apply lock exclusively, so its
+ /// enumerate/detach/attach cannot interleave with a concurrent single-interface apply.
+ pub fn apply(&mut self, state: &DesiredState) -> anyhow::Result<()> {
+ let _lock = self.programs.lock_exclusive()?;
+ self.programs.ensure_loaded()?;
+ self.apply_full(state)
+ }
+
+ /// Detach all tap programs and drop pinned/run state, for package removal.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ self.programs.clear()
+ }
+
+ /// Program a single guest NIC that just appeared (a tap_plug).
+ ///
+ /// An unassigned NIC has nothing to enforce and returns `Ok`. For an assigned NIC a failure to
+ /// install enforcement propagates, so the agent exits non-zero and the plug does not bring the
+ /// NIC up unenforced.
+ pub fn apply_guest_iface(&mut self, state: &DesiredState, iface: &str) -> anyhow::Result<()> {
+ let Some((vmid, idx)) = parse_tap_name(iface) else {
+ log::warn!("apply_guest_iface: '{iface}' is not a guest NIC name, skipping");
+ return Ok(());
+ };
+
+ let Some(group) = state
+ .assignments
+ .iter()
+ .find(|a| a.vmid == vmid && a.iface == idx)
+ .map(|a| a.group)
+ else {
+ log::debug!(
+ "apply_guest_iface: {iface} (vmid={vmid} iface={idx}) is unassigned, nothing to attach"
+ );
+ return Ok(());
+ };
+
+ // If the programs are already loaded, take the lock shared so concurrent guest plugs don't
+ // serialize. A needed (re)install rebuilds shared state, so take it exclusively and do a
+ // full apply, which covers this NIC too.
+ if self.programs.is_current() {
+ let _lock = self.programs.lock_shared()?;
+ self.apply_one(iface, group)
+ } else {
+ let _lock = self.programs.lock_exclusive()?;
+ self.programs.ensure_loaded()?;
+ self.apply_full(state)
+ }
+ .with_context(|| format!("enforcement for assigned NIC {iface}"))
+ }
+
+ fn apply_full(&mut self, state: &DesiredState) -> anyhow::Result<()> {
+ log::trace!("policy full apply");
+ let assignments = resolve_local_assignments(&state.assignments);
+ self.sync_tap_to_group(&assignments)?;
+ let desired: HashSet<u32> = assignments.keys().copied().collect();
+ self.programs.reconcile(&desired)?;
+ self.sync_rules(&state.rules)?;
+ Ok(())
+ }
+
+ /// Additive single-interface programming. Sets this interface's `tap_to_group` entry and
+ /// attaches its links, propagating failure so the plug does not bring the NIC up unenforced.
+ /// The global `rules` map and the other interfaces are not touched.
+ fn apply_one(&mut self, iface: &str, group: u16) -> anyhow::Result<()> {
+ let Ok(ifindex) = nix::net::if_::if_nametoindex(iface) else {
+ log::info!("apply_guest_iface: {iface} is gone, nothing to do");
+ return Ok(());
+ };
+
+ {
+ let mut tap_to_group = self.programs.hash_map::<u32, GuestGroup>("tap_to_group")?;
+ tap_to_group.insert(ifindex, GuestGroup { group, _pad: 0 }, 0)?;
+ }
+ log::info!("apply_guest_iface: {iface} (ifindex {ifindex}) -> group {group}");
+
+ self.programs.attach_iface(ifindex)?;
+ Ok(())
+ }
+
+ fn sync_tap_to_group(&mut self, desired: &HashMap<u32, u16>) -> anyhow::Result<()> {
+ let mut tap_to_group = self.programs.hash_map::<u32, GuestGroup>("tap_to_group")?;
+ let live: HashMap<u32, u16> = tap_to_group
+ .iter()
+ .filter_map(|r| r.ok().map(|(k, v)| (k, v.group)))
+ .collect();
+ let mut written = 0usize;
+ for (&ifidx, &group) in desired {
+ if live.get(&ifidx) != Some(&group) {
+ tap_to_group.insert(ifidx, GuestGroup { group, _pad: 0 }, 0)?;
+ written += 1;
+ }
+ }
+ let mut removed = 0usize;
+ for &ifidx in live.keys().filter(|i| !desired.contains_key(i)) {
+ log::debug!("drop: group mapping for {ifidx}");
+ let _ = tap_to_group.remove(&ifidx);
+ removed += 1;
+ }
+ if written + removed > 0 {
+ log::info!("tap_to_group: {written} written, {removed} removed");
+ }
+ Ok(())
+ }
+
+ fn sync_rules(&mut self, rules: &[ResolvedRule]) -> anyhow::Result<()> {
+ let mut rules_map = self.programs.hash_map::<RuleKey, RuleValue>("rules")?;
+ let live: HashMap<RuleKey, u8> = rules_map
+ .iter()
+ .filter_map(|r| r.ok().map(|(k, v)| (k, v.allow)))
+ .collect();
+ let mut desired_keys = HashSet::new();
+ let mut written = 0usize;
+ for r in rules {
+ let k = RuleKey {
+ src_group: r.src_group,
+ dst_group: r.dst_group,
+ };
+ desired_keys.insert(k);
+ let want_allow = r.allow as u8;
+ if live.get(&k) != Some(&want_allow) {
+ rules_map.insert(
+ k,
+ RuleValue {
+ allow: want_allow,
+ _pad: [0; 3],
+ },
+ 0,
+ )?;
+ written += 1;
+ log::trace!(
+ "rule: write src={} dst={} allow={}",
+ k.src_group,
+ k.dst_group,
+ r.allow
+ );
+ }
+ }
+ let stale: Vec<_> = live
+ .keys()
+ .filter(|k| !desired_keys.contains(k))
+ .copied()
+ .collect();
+ for k in &stale {
+ let _ = rules_map.remove(k);
+ }
+ if written + stale.len() > 0 {
+ log::info!("rules: {written} written, {} removed", stale.len());
+ } else {
+ log::debug!("rules: {} entries, no changes", desired_keys.len());
+ }
+ Ok(())
+ }
+}
+
+/// Parse a guest NIC name (`tap<vmid>i<idx>` or `veth<vmid>i<idx>`) into its (vmid, iface) pair.
+fn parse_tap_name(name: &str) -> Option<(u32, u32)> {
+ let rest = name
+ .strip_prefix("tap")
+ .or_else(|| name.strip_prefix("veth"))?;
+ let (vmid, idx) = rest.split_once('i')?;
+ Some((vmid.parse().ok()?, idx.parse().ok()?))
+}
+
+fn resolve_local_assignments(assignments: &[ResolvedAssignment]) -> HashMap<u32, u16> {
+ let by_name: HashMap<String, u32> = match nix::net::if_::if_nameindex() {
+ Ok(ifaces) => ifaces
+ .iter()
+ .filter_map(|i| i.name().to_str().ok().map(|n| (n.to_owned(), i.index())))
+ .collect(),
+ Err(e) => {
+ log::error!("if_nameindex: {e:#}");
+ return HashMap::new();
+ }
+ };
+
+ let mut out = HashMap::new();
+ for a in assignments {
+ let veth = format!("veth{}i{}", a.vmid, a.iface);
+ let tap = format!("tap{}i{}", a.vmid, a.iface);
+ if let Some(&ifidx) = by_name.get(&veth).or_else(|| by_name.get(&tap)) {
+ log::debug!(
+ "resolved vmid={} iface={} -> ifindex {ifidx} group {}",
+ a.vmid,
+ a.iface,
+ a.group,
+ );
+ out.insert(ifidx, a.group);
+ } else {
+ log::trace!("skipping vmid={} iface={}: no local tap", a.vmid, a.iface);
+ }
+ }
+ log::debug!("{} managed taps on this host", out.len());
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_tap_name_handles_qemu_and_lxc() {
+ assert_eq!(parse_tap_name("tap101i0"), Some((101, 0)));
+ assert_eq!(parse_tap_name("veth200i3"), Some((200, 3)));
+ assert_eq!(parse_tap_name("tap1000i12"), Some((1000, 12)));
+ }
+
+ #[test]
+ fn parse_tap_name_rejects_other_interfaces() {
+ assert_eq!(parse_tap_name("vmbr0"), None);
+ assert_eq!(parse_tap_name("fwln101i0"), None);
+ assert_eq!(parse_tap_name("veth200i-3"), None);
+ assert_eq!(parse_tap_name("eth0"), None);
+ assert_eq!(parse_tap_name("tap101"), None);
+ assert_eq!(parse_tap_name("tapfooibar"), None);
+ }
+}
diff --git a/src/policy/types.rs b/src/policy/types.rs
new file mode 100644
index 0000000..174aecc
--- /dev/null
+++ b/src/policy/types.rs
@@ -0,0 +1,45 @@
+//! Keep in sync with `bpf/types.h`.
+
+#[repr(C)]
+#[derive(Copy, Clone)]
+pub struct GuestGroup {
+ pub group: u16,
+ pub _pad: u16,
+}
+
+#[repr(C)]
+#[derive(Copy, Clone, Hash, PartialEq, Eq)]
+pub struct RuleKey {
+ pub src_group: u16,
+ pub dst_group: u16,
+}
+
+#[repr(C)]
+#[derive(Copy, Clone)]
+pub struct RuleValue {
+ pub allow: u8,
+ pub _pad: [u8; 3],
+}
+
+unsafe impl aya::Pod for GuestGroup {}
+unsafe impl aya::Pod for RuleKey {}
+unsafe impl aya::Pod for RuleValue {}
+
+#[cfg(test)]
+mod layout {
+ use super::*;
+ use core::mem::{offset_of, size_of};
+
+ #[test]
+ fn sizes() {
+ assert_eq!(size_of::<GuestGroup>(), 4);
+ assert_eq!(size_of::<RuleKey>(), 4);
+ assert_eq!(size_of::<RuleValue>(), 4);
+ }
+
+ #[test]
+ fn offsets() {
+ assert_eq!(offset_of!(RuleKey, dst_group), 2);
+ assert_eq!(offset_of!(GuestGroup, _pad), 2);
+ }
+}
diff --git a/src/running_config.rs b/src/running_config.rs
new file mode 100644
index 0000000..a2003de
--- /dev/null
+++ b/src/running_config.rs
@@ -0,0 +1,38 @@
+//! Loader for `/etc/pve/sdn/.running-config`, the JSON snapshot perl writes on every SDN commit.
+//! The deserialize types live in [`proxmox_ve_config::sdn`]; this module is just the agent-side
+//! I/O wrapper around them.
+
+use std::path::Path;
+
+use anyhow::Context;
+use proxmox_ve_config::sdn::config::RunningConfig;
+use proxmox_ve_config::sdn::microseg::MicrosegRunningConfig;
+
+pub const PATH: &str = "/etc/pve/sdn/.running-config";
+
+/// Read and parse the full SDN running config.
+///
+/// Returns `Ok(None)` if the file does not exist yet. This is the legitimate state of a node where
+/// SDN has never been committed. Any other I/O or parse error propagates.
+pub fn load() -> anyhow::Result<Option<RunningConfig>> {
+ load_from(Path::new(PATH))
+}
+
+/// Read and project just the microseg block, returning `default()` if the file or the `microseg`
+/// key is absent. Most agent code only cares about microseg, so this is the convenient entry
+/// point.
+pub fn load_microseg() -> anyhow::Result<MicrosegRunningConfig> {
+ Ok(load()?
+ .and_then(|c| c.microseg().cloned())
+ .unwrap_or_default())
+}
+
+fn load_from(path: &Path) -> anyhow::Result<Option<RunningConfig>> {
+ let raw = match std::fs::read_to_string(path) {
+ Ok(s) => s,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
+ };
+ let cfg = serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
+ Ok(Some(cfg))
+}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..879562b
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,286 @@
+//! Resolved desired state derived from the SDN running config plus the local hostname.
+//!
+//! [`MicrosegRunningConfig`] is admin-facing, groups are referenced by name, rules and assignments
+//! name them. This module does the one-time name -> id resolution so subsystems see a straight-line
+//! view of "what should be applied on this host".
+
+use std::collections::{HashMap, HashSet};
+
+use anyhow::{Context, bail};
+
+use proxmox_ve_config::sdn::microseg::MicrosegRunningConfig;
+
+#[derive(Debug, Default)]
+pub struct DesiredState {
+ pub groups: HashMap<u16, GroupInfo>,
+ pub rules: Vec<ResolvedRule>,
+ pub assignments: Vec<ResolvedAssignment>,
+}
+
+#[derive(Debug)]
+pub struct GroupInfo {
+ pub name: String,
+}
+
+#[derive(Debug)]
+pub struct ResolvedRule {
+ /// 0 if the source was unstamped (rule.src was None)
+ pub src_group: u16,
+ pub dst_group: u16,
+ pub allow: bool,
+}
+
+#[derive(Debug)]
+pub struct ResolvedAssignment {
+ pub vmid: u32,
+ pub iface: u32,
+ pub group: u16,
+}
+
+impl DesiredState {
+ pub fn build(cfg: &MicrosegRunningConfig, _this_node: &str) -> anyhow::Result<Self> {
+ let mut name_to_id: HashMap<&str, u16> = HashMap::new();
+ let mut parent_of: HashMap<&str, &str> = HashMap::new();
+ let mut groups: HashMap<u16, GroupInfo> = HashMap::new();
+ for (name, g) in cfg.groups() {
+ if name_to_id.insert(name, g.mark()).is_some() {
+ bail!("duplicate group name '{name}'");
+ }
+ if let Some(parent) = g.parent() {
+ parent_of.insert(name, parent);
+ }
+ if groups
+ .insert(
+ g.mark(),
+ GroupInfo {
+ name: name.to_string(),
+ },
+ )
+ .is_some()
+ {
+ bail!("duplicate group mark {} (name '{name}')", g.mark());
+ }
+ }
+
+ let resolve = |name: &str, ctx: &str| -> anyhow::Result<u16> {
+ name_to_id
+ .get(name)
+ .copied()
+ .with_context(|| format!("{ctx} references unknown group '{name}'"))
+ };
+
+ // Flatten the hierarchy into concrete (src, dst) pairs here so the data plane is a plain
+ // lookup -- a rule on a group covers every group nested under it. On overlap the most
+ // specific wins: destination closest in the tree, then source. Valid config never ties
+ // (single parent, no duplicates), so equal-distance can't really happen, still kept a
+ // branch for it (fallback is AND over the ties).
+ let descendants = group_descendants(&name_to_id, &parent_of);
+ let unstamped = [(0u16, 0u16)];
+ let mut verdicts: HashMap<(u16, u16), ((u16, u16), bool)> = HashMap::new();
+ for (id, r) in cfg.rules() {
+ let src: &[(u16, u16)] = match r.src() {
+ None => &unstamped[..],
+ Some(s) => descendants
+ .get(s)
+ .with_context(|| format!("rule '{id}' src references unknown group '{s}'"))?
+ .as_slice(),
+ };
+ let dst = descendants.get(r.dst()).with_context(|| {
+ format!("rule '{id}' dst references unknown group '{}'", r.dst())
+ })?;
+ let allow = r.allow();
+ for &(src_group, src_dist) in src {
+ for &(dst_group, dst_dist) in dst {
+ let specificity = (dst_dist, src_dist);
+ verdicts
+ .entry((src_group, dst_group))
+ .and_modify(|best| {
+ if specificity < best.0 {
+ *best = (specificity, allow);
+ } else if specificity == best.0 {
+ best.1 &= allow;
+ }
+ })
+ .or_insert((specificity, allow));
+ }
+ }
+ }
+ let rules = verdicts
+ .into_iter()
+ .map(|((src_group, dst_group), (_, allow))| ResolvedRule {
+ src_group,
+ dst_group,
+ allow,
+ })
+ .collect();
+
+ let mut assignments = Vec::new();
+ for (id, a) in cfg.assignments() {
+ let group = resolve(a.group(), &format!("assignment '{id}' group"))?;
+ assignments.push(ResolvedAssignment {
+ vmid: a.vmid(),
+ iface: a.iface(),
+ group,
+ });
+ }
+
+ Ok(Self {
+ groups,
+ rules,
+ assignments,
+ })
+ }
+}
+
+/// Map each group name to every group nested under it as `(mark, distance)` pairs, where
+/// distance is the number of steps from that descendant up to this group (0 for the group itself).
+/// A rule on a group expands across these, and the most specific rule wins per pair. Guards against
+/// cycles and unknown parents so a corrupt running-config can't hang the agent -- defense in depth,
+/// since the write path already rejects both.
+fn group_descendants(
+ name_to_id: &HashMap<&str, u16>,
+ parent_of: &HashMap<&str, &str>,
+) -> HashMap<String, Vec<(u16, u16)>> {
+ let mut out: HashMap<String, Vec<(u16, u16)>> = HashMap::new();
+ for (&name, &id) in name_to_id {
+ let mut cur = name;
+ let mut dist = 0u16;
+ let mut seen: HashSet<&str> = HashSet::new();
+ loop {
+ if !seen.insert(cur) {
+ break;
+ }
+ out.entry(cur.to_string()).or_default().push((id, dist));
+ match parent_of.get(cur) {
+ Some(&parent) if name_to_id.contains_key(parent) => {
+ cur = parent;
+ dist += 1;
+ }
+ _ => break,
+ }
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cfg(json: &str) -> MicrosegRunningConfig {
+ serde_json::from_str(json).expect("parse running config")
+ }
+
+ #[test]
+ fn nested_group_inherits_parent_rule() {
+ // web is nested under app, so a rule to app also covers web with no rule of its own.
+ let state = DesiredState::build(
+ &cfg(r#"{"ids":{
+ "app":{"type":"group","id":"app","mark":10},
+ "web":{"type":"group","id":"web","mark":11,"parent":"app"},
+ "mon":{"type":"group","id":"mon","mark":20},
+ "r1":{"type":"rule","id":"r1","src":"mon","dst":"app","allow":1}
+ }}"#),
+ "node1",
+ )
+ .expect("build desired state");
+
+ let verdict = |src: u16, dst: u16| {
+ state
+ .rules
+ .iter()
+ .find(|r| r.src_group == src && r.dst_group == dst)
+ .map(|r| r.allow)
+ };
+
+ assert_eq!(verdict(20, 10), Some(true)); // mon to app
+ assert_eq!(verdict(20, 11), Some(true)); // mon to web, inherited from app
+ assert_eq!(state.rules.len(), 2);
+ }
+
+ #[test]
+ fn nearest_rule_in_the_tree_wins() {
+ // A more specific rule on a child overrides the parent rule in either direction, a deny
+ // under a broad allow and an allow under a broad deny.
+ let state = DesiredState::build(
+ &cfg(r#"{"ids":{
+ "app":{"type":"group","id":"app","mark":10},
+ "web":{"type":"group","id":"web","mark":11,"parent":"app"},
+ "db":{"type":"group","id":"db","mark":20},
+ "dbprimary":{"type":"group","id":"dbprimary","mark":21,"parent":"db"},
+ "ext":{"type":"group","id":"ext","mark":30},
+ "r1":{"type":"rule","id":"r1","src":"ext","dst":"app","allow":1},
+ "r2":{"type":"rule","id":"r2","src":"ext","dst":"web","allow":0},
+ "r3":{"type":"rule","id":"r3","src":"ext","dst":"db","allow":0},
+ "r4":{"type":"rule","id":"r4","src":"ext","dst":"dbprimary","allow":1}
+ }}"#),
+ "node1",
+ )
+ .expect("build desired state");
+
+ let verdict = |src: u16, dst: u16| {
+ state
+ .rules
+ .iter()
+ .find(|r| r.src_group == src && r.dst_group == dst)
+ .map(|r| r.allow)
+ };
+
+ assert_eq!(verdict(30, 10), Some(true)); // ext to app, broad allow
+ assert_eq!(verdict(30, 11), Some(false)); // ext to web, specific deny overrides
+ assert_eq!(verdict(30, 20), Some(false)); // ext to db, broad deny
+ assert_eq!(verdict(30, 21), Some(true)); // ext to dbprimary, specific allow overrides
+ }
+
+ #[test]
+ fn destination_specificity_is_primary() {
+ // web is under app, dbprimary under db. For web -> dbprimary two rules overlap: web -> db
+ // names the source exactly but the destination only via its parent, app -> dbprimary names
+ // the destination exactly. The destination is the primary axis, so app -> dbprimary wins
+ // and its action (allow) stands.
+ let state = DesiredState::build(
+ &cfg(r#"{"ids":{
+ "app":{"type":"group","id":"app","mark":10},
+ "web":{"type":"group","id":"web","mark":11,"parent":"app"},
+ "db":{"type":"group","id":"db","mark":20},
+ "dbprimary":{"type":"group","id":"dbprimary","mark":21,"parent":"db"},
+ "r1":{"type":"rule","id":"r1","src":"web","dst":"db","allow":0},
+ "r2":{"type":"rule","id":"r2","src":"app","dst":"dbprimary","allow":1}
+ }}"#),
+ "node1",
+ )
+ .expect("build desired state");
+
+ let allow = state
+ .rules
+ .iter()
+ .find(|r| r.src_group == 11 && r.dst_group == 21)
+ .map(|r| r.allow);
+ assert_eq!(allow, Some(true));
+ }
+
+ #[test]
+ fn source_specificity_breaks_destination_ties() {
+ // app -> svc and web -> svc both name the destination exactly, so the destination distance
+ // ties and the more specific source decides: web -> svc names the source exactly and wins
+ // over app -> svc, which only names the source's parent.
+ let state = DesiredState::build(
+ &cfg(r#"{"ids":{
+ "app":{"type":"group","id":"app","mark":10},
+ "web":{"type":"group","id":"web","mark":11,"parent":"app"},
+ "svc":{"type":"group","id":"svc","mark":30},
+ "r1":{"type":"rule","id":"r1","src":"app","dst":"svc","allow":0},
+ "r2":{"type":"rule","id":"r2","src":"web","dst":"svc","allow":1}
+ }}"#),
+ "node1",
+ )
+ .expect("build desired state");
+
+ let allow = state
+ .rules
+ .iter()
+ .find(|r| r.src_group == 11 && r.dst_group == 30)
+ .map(|r| r.allow);
+ assert_eq!(allow, Some(true));
+ }
+}
diff --git a/src/subsystem.rs b/src/subsystem.rs
new file mode 100644
index 0000000..7b944b4
--- /dev/null
+++ b/src/subsystem.rs
@@ -0,0 +1,383 @@
+//! The shared subsystem mechanism. An ingress and egress tc program with their maps and links,
+//! pinned under `/sys/fs/bpf/proxmox-ebpf/<name>/`. The loaded BPF stays in the kernel between
+//! invocations, so [`TcPrograms::ensure_loaded`] loads and verifies only on the first run and on a
+//! version change. Everything else attaches links and syncs maps against what is already there.
+//! The [`policy`](crate::policy) subsystem owns a [`TcPrograms`].
+
+use std::{collections::HashSet, fs::File, io::ErrorKind, path::PathBuf};
+
+use anyhow::Context;
+use aya::{EbpfLoader, programs::SchedClassifier};
+use nix::fcntl::{Flock, FlockArg};
+
+use crate::tc::{self, DIRECTIONS, Direction};
+
+pub const VERIFY_ROOT: &str = "/sys/fs/bpf/proxmox-ebpf-test";
+
+const PIN_ROOT: &str = "/sys/fs/bpf/proxmox-ebpf";
+const RUN_ROOT: &str = "/run/proxmox-ebpf";
+
+fn pin_root_for(name: &str) -> PathBuf {
+ PathBuf::from(PIN_ROOT).join(name)
+}
+
+// Small persisted state under /run/proxmox-ebpf/<name>/<key>, used to decide on each run whether
+// to tear down (schema changed) and whether to refresh existing links (the object changed).
+fn read_state(name: &str, key: &str) -> Option<u64> {
+ std::fs::read_to_string(PathBuf::from(RUN_ROOT).join(name).join(key))
+ .ok()
+ .and_then(|s| s.trim().parse().ok())
+}
+
+fn write_state(name: &str, key: &str, value: u64) -> anyhow::Result<()> {
+ let path = PathBuf::from(RUN_ROOT).join(name).join(key);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(path, format!("{value}\n"))?;
+ Ok(())
+}
+
+/// The pinned tc programs for one subsystem.
+///
+/// Holds only the immutable description. The loaded BPF lives in the kernel, pinned, and is
+/// reached back through those pins, so normal operation runs no verifier.
+pub struct TcPrograms {
+ name: &'static str,
+ obj: &'static [u8],
+ fingerprint: u64,
+ prog_name: fn(Direction) -> &'static str,
+ schema_version: Option<u32>,
+}
+
+impl TcPrograms {
+ pub fn new(
+ name: &'static str,
+ obj: &'static [u8],
+ fingerprint: u64,
+ prog_name: fn(Direction) -> &'static str,
+ schema_version: Option<u32>,
+ ) -> Self {
+ Self {
+ name,
+ obj,
+ fingerprint,
+ prog_name,
+ schema_version,
+ }
+ }
+
+ fn pin_root(&self) -> PathBuf {
+ pin_root_for(self.name)
+ }
+ fn links_dir(&self) -> PathBuf {
+ self.pin_root().join("links")
+ }
+ fn link_pin_path(&self, ifindex: u32, dir: Direction) -> PathBuf {
+ self.links_dir().join(tc::pin_filename(ifindex, dir))
+ }
+ fn prog_dir(&self) -> PathBuf {
+ self.pin_root().join("prog")
+ }
+ fn prog_pin_path(&self, dir: Direction) -> PathBuf {
+ self.prog_dir().join(dir.as_str())
+ }
+
+ /// Open a pinned BPF hash map by name, for the owning subsystem to sync. Valid once
+ /// [`ensure_loaded`](Self::ensure_loaded) has run, which is every caller's first step.
+ pub fn hash_map<K: aya::Pod, V: aya::Pod>(
+ &self,
+ name: &str,
+ ) -> anyhow::Result<aya::maps::HashMap<aya::maps::MapData, K, V>> {
+ let map = aya::maps::MapData::from_pin(self.pin_root().join(name))
+ .with_context(|| format!("{}: open pinned map {name}", self.name))?;
+ Ok(aya::maps::HashMap::try_from(aya::maps::Map::HashMap(map))?)
+ }
+
+ /// Open one direction's pinned program. A plain `BPF_OBJ_GET`, no verifier, since the program
+ /// was checked once when [`ensure_loaded`](Self::ensure_loaded) installed it.
+ fn program(&self, dir: Direction) -> anyhow::Result<SchedClassifier> {
+ SchedClassifier::from_pin(self.prog_pin_path(dir))
+ .with_context(|| format!("{}: open pinned program {}", self.name, dir.as_str()))
+ }
+
+ /// FNV-1a over the embedded object. A `const fn`, so each subsystem folds it into a `const` at
+ /// compile time and the per-invocation path never rehashes a constant.
+ pub const fn obj_fingerprint(obj: &[u8]) -> u64 {
+ let mut hash = 0xcbf29ce484222325u64;
+ let mut i = 0;
+ while i < obj.len() {
+ hash ^= obj[i] as u64;
+ hash = hash.wrapping_mul(0x100000001b3);
+ i += 1;
+ }
+ hash
+ }
+
+ /// The per-subsystem apply lock under `/run`, one file taken in two modes. A full apply (and
+ /// any install/teardown) takes it [exclusively](Self::lock_exclusive) so its enumerate, detach
+ /// and attach run as one unit. An additive single-interface apply takes it
+ /// [shared](Self::lock_shared) so guest plugs run concurrently and only block while a full
+ /// apply holds it. The kernel drops the lock if the process dies.
+ fn lock(&self, arg: FlockArg) -> anyhow::Result<Flock<File>> {
+ let dir = PathBuf::from(RUN_ROOT).join(self.name);
+ std::fs::create_dir_all(&dir)?;
+ let file = File::create(dir.join("lock"))?;
+ Flock::lock(file, arg).map_err(|(_, e)| anyhow::Error::new(e))
+ }
+
+ /// Take the apply lock in shared mode, for an additive single-interface apply.
+ pub fn lock_shared(&self) -> anyhow::Result<Flock<File>> {
+ self.lock(FlockArg::LockShared)
+ }
+
+ /// Take the apply lock exclusively, for a full apply or an install/teardown.
+ pub fn lock_exclusive(&self) -> anyhow::Result<Flock<File>> {
+ self.lock(FlockArg::LockExclusive)
+ }
+
+ /// Make sure the programs are loaded and pinned. The load runs only when there is no current
+ /// pin, when the schema version changed (a rebuild), or when the object changed (a refresh).
+ /// Otherwise the programs and maps pinned by an earlier run are reused untouched. Returns
+ /// whether a (re)install happened, so the caller follows with a full apply.
+ ///
+ /// Takes no lock of its own: the caller holds the apply lock exclusively whenever an install
+ /// may be needed (it gates on [`is_current`](Self::is_current) first), so the install path
+ /// here is already serialized. A subsystem with no maps passes no schema version and so never
+ /// verifies or tears down.
+ pub fn ensure_loaded(&self) -> anyhow::Result<bool> {
+ if self.is_current() {
+ return Ok(false);
+ }
+
+ // rebuild when the pinned programs are of an unknown or different schema: a different
+ // recorded schema_version, or none recorded while programs are still pinned (the /run
+ // markers were lost but the bpffs pins survived, e.g. across a service restart) -- the
+ // loaded map layout is then unknown, so tear down rather than bind new code to old maps.
+ let schema_changed = self.programs_pinned()
+ && match self.schema_version {
+ Some(v) => read_state(self.name, "schema_version") != Some(v as u64),
+ None => false,
+ };
+ let code_changed = read_state(self.name, "fingerprint") != Some(self.fingerprint);
+
+ if schema_changed {
+ log::warn!("{}: schema changed or unknown, rebuilding", self.name);
+ // verify the new code loads against throw-away state before tearing the old down, so a
+ // verifier rejection can't leave us with the old state wiped and nothing to replace it
+ self.verify().with_context(|| {
+ format!(
+ "{}: new BPF code does not load against fresh state",
+ self.name
+ )
+ })?;
+ self.tear_down().context("tear_down")?;
+ }
+
+ self.load_and_pin().context("load_and_pin")?;
+
+ // refresh links pinned by a previous run onto the new code. no-op on a fresh install or
+ // right after a teardown, where there are no links yet
+ if code_changed {
+ self.swap_existing_links();
+ }
+
+ if let Some(v) = self.schema_version
+ && let Err(e) = write_state(self.name, "schema_version", v as u64)
+ {
+ log::warn!("{}: failed to persist schema_version: {e:#}", self.name);
+ }
+ if let Err(e) = write_state(self.name, "fingerprint", self.fingerprint) {
+ log::warn!("{}: failed to persist fingerprint: {e:#}", self.name);
+ }
+ Ok(true)
+ }
+
+ /// True when the pinned programs already match this build, the `/run` fingerprint and schema
+ /// version agree with the embedded object and both programs are pinned. Lock-free; callers use
+ /// it to decide whether a run needs the exclusive lock (an install) or only the shared one.
+ pub fn is_current(&self) -> bool {
+ let schema_changed = match self.schema_version {
+ Some(v) => read_state(self.name, "schema_version").is_some_and(|s| s != v as u64),
+ None => false,
+ };
+ let code_changed = read_state(self.name, "fingerprint") != Some(self.fingerprint);
+ !schema_changed && !code_changed && self.programs_pinned()
+ }
+
+ fn programs_pinned(&self) -> bool {
+ DIRECTIONS
+ .iter()
+ .all(|&dir| self.prog_pin_path(dir).exists())
+ }
+
+ fn verify(&self) -> anyhow::Result<()> {
+ tc::verify(&[self.obj], &DIRECTIONS.map(self.prog_name))
+ }
+
+ fn tear_down(&self) -> anyhow::Result<()> {
+ match std::fs::remove_dir_all(self.pin_root()) {
+ Ok(()) => {}
+ Err(e) if e.kind() == ErrorKind::NotFound => {}
+ Err(e) => return Err(e.into()),
+ }
+ std::fs::create_dir_all(self.links_dir())?;
+ Ok(())
+ }
+
+ /// Load and verify the object, then pin its programs. The only place the verifier runs. The
+ /// `bpf` handle is dropped at the end, the pinned programs and maps stay resident in the
+ /// kernel.
+ fn load_and_pin(&self) -> anyhow::Result<()> {
+ std::fs::create_dir_all(self.links_dir())?;
+ std::fs::create_dir_all(self.prog_dir())?;
+ let mut bpf = EbpfLoader::new()
+ .map_pin_path(self.pin_root())
+ .load(self.obj)?;
+ for dir in DIRECTIONS {
+ let prog: &mut SchedClassifier =
+ bpf.program_mut((self.prog_name)(dir)).unwrap().try_into()?;
+ prog.load()?;
+ let path = self.prog_pin_path(dir);
+ // refreshed program is a new kernel object but the old pin file still exists, so
+ // remove it before re-pinning or pin() hits EEXIST
+ match std::fs::remove_file(&path) {
+ Ok(()) => {}
+ Err(e) if e.kind() == ErrorKind::NotFound => {}
+ Err(e) => return Err(e).context("remove stale program pin"),
+ }
+ prog.pin(&path)?;
+ }
+ Ok(())
+ }
+
+ fn live_links(&self) -> anyhow::Result<Vec<(u32, Direction)>> {
+ tc::read_pinned_links(&self.links_dir())
+ }
+
+ fn swap_existing_links(&self) {
+ let live = match self.live_links() {
+ Ok(l) => l,
+ Err(e) => {
+ log::error!("{}: read pinned links: {e:#}", self.name);
+ return;
+ }
+ };
+ for (ifindex, dir) in live {
+ if let Err(e) = self.swap_pinned_link(ifindex, dir) {
+ log::error!("{}: swap link {ifindex}-{}: {e:#}", self.name, dir.as_str());
+ }
+ }
+ }
+
+ /// Make the attached set match `desired`. Detach interfaces no longer wanted, attach the ones
+ /// missing. Refreshing existing links onto new code is done by
+ /// [`ensure_loaded`](Self::ensure_loaded). Runs under the caller's exclusive apply lock, so the
+ /// live set it samples cannot change under it.
+ ///
+ /// Returns an error if any interface failed to attach (after attempting all of them): a NIC
+ /// left without its program would pass traffic unenforced, so that surfaces as a failed apply
+ /// rather than a silent fail-open. A failed detach only leaves over-enforcement and is logged.
+ pub fn reconcile(&self, desired: &HashSet<u32>) -> anyhow::Result<()> {
+ let live: HashSet<(u32, Direction)> = self.live_links()?.into_iter().collect();
+
+ for &(ifidx, dir) in &live {
+ if desired.contains(&ifidx) {
+ continue;
+ }
+ log::debug!("{}: detach {ifidx}-{}", self.name, dir.as_str());
+ if let Err(e) = tc::detach_pinned_link(&self.link_pin_path(ifidx, dir)) {
+ log::error!("{}: detach {ifidx}-{}: {e:#}", self.name, dir.as_str());
+ } else {
+ log::info!("{}: detached {ifidx}-{}", self.name, dir.as_str());
+ }
+ }
+
+ let mut failed = 0usize;
+ for &ifidx in desired {
+ for dir in DIRECTIONS {
+ if live.contains(&(ifidx, dir)) {
+ continue;
+ }
+ if let Err(e) = self.attach_and_pin(ifidx, dir) {
+ log::error!("{}: attach {ifidx}-{}: {e:#}", self.name, dir.as_str());
+ failed += 1;
+ }
+ }
+ }
+ if failed > 0 {
+ anyhow::bail!("{}: {failed} interface(s) failed to attach", self.name);
+ }
+ Ok(())
+ }
+
+ /// Attach the programs to a single interface, additively, without touching others.
+ ///
+ /// If a pin already exists, swap the program in place with no traffic gap. The swap only
+ /// succeeds while the link is still on the live netdev at this ifindex; a recycled ifindex
+ /// leaves a defunct pin, so the swap fails and we reclaim it and attach fresh. A failed attach
+ /// propagates so the caller can refuse to bring the NIC up unenforced.
+ pub fn attach_iface(&self, ifindex: u32) -> anyhow::Result<()> {
+ for dir in DIRECTIONS {
+ let path = self.link_pin_path(ifindex, dir);
+ if path.exists() {
+ match self.swap_pinned_link(ifindex, dir) {
+ Ok(()) => continue,
+ Err(e) => {
+ log::debug!(
+ "{}: {ifindex}-{} swap failed ({e:#}), rebuilding",
+ self.name,
+ dir.as_str()
+ );
+ if let Err(e) = tc::detach_pinned_link(&path) {
+ log::warn!(
+ "{}: reclaim {ifindex}-{}: unpin stale link: {e:#}",
+ self.name,
+ dir.as_str()
+ );
+ let _ = std::fs::remove_file(&path);
+ }
+ }
+ }
+ }
+ self.attach_and_pin(ifindex, dir)
+ .with_context(|| format!("{}: attach {ifindex}-{}", self.name, dir.as_str()))?;
+ }
+ Ok(())
+ }
+
+ fn swap_pinned_link(&self, ifindex: u32, dir: Direction) -> anyhow::Result<()> {
+ let path = self.link_pin_path(ifindex, dir);
+ let mut prog = self.program(dir)?;
+ tc::swap_pinned_link(&mut prog, &path)?;
+ Ok(())
+ }
+
+ fn attach_and_pin(&self, ifindex: u32, dir: Direction) -> anyhow::Result<()> {
+ let path = self.link_pin_path(ifindex, dir);
+ let mut prog = self.program(dir)?;
+ tc::attach_and_pin(&mut prog, ifindex, dir, &path)?;
+ Ok(())
+ }
+
+ /// Detach every pinned link and drop all pinned and `/run` state for this subsystem, under the
+ /// exclusive lock. For package removal: leaving links attached would keep interfaces enforcing
+ /// the last-applied policy with no agent left to update them. Best-effort past the lock -- logs
+ /// and continues so one failure does not strand the rest.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ let _lock = self.lock_exclusive()?;
+ for (ifindex, dir) in self.live_links().unwrap_or_default() {
+ if let Err(e) = tc::detach_pinned_link(&self.link_pin_path(ifindex, dir)) {
+ log::warn!("{}: detach {ifindex}-{}: {e:#}", self.name, dir.as_str());
+ }
+ }
+ for path in [self.pin_root(), PathBuf::from(RUN_ROOT).join(self.name)] {
+ if let Err(e) = std::fs::remove_dir_all(&path)
+ && e.kind() != ErrorKind::NotFound
+ {
+ log::warn!("{}: remove {}: {e:#}", self.name, path.display());
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/src/tc.rs b/src/tc.rs
new file mode 100644
index 0000000..d89304d
--- /dev/null
+++ b/src/tc.rs
@@ -0,0 +1,151 @@
+//! TC link plumbing for the [`policy`](crate::policy) subsystem. A `Direction` enum,
+//! attach/swap/detach free functions, and a uniform pin-filename layout `{ifindex}-{direction}`.
+
+use std::{io::ErrorKind, path::Path, str::FromStr};
+
+use anyhow::Context;
+use aya::{
+ EbpfLoader,
+ programs::{
+ SchedClassifier, TcAttachType,
+ links::{FdLink, PinnedLink},
+ tc,
+ },
+};
+
+use crate::subsystem::VERIFY_ROOT;
+
+#[derive(Copy, Clone, Hash, PartialEq, Eq, Debug)]
+pub enum Direction {
+ Ingress,
+ Egress,
+}
+
+pub const DIRECTIONS: [Direction; 2] = [Direction::Ingress, Direction::Egress];
+
+impl Direction {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Ingress => "ingress",
+ Self::Egress => "egress",
+ }
+ }
+ pub fn aya_type(self) -> TcAttachType {
+ match self {
+ Self::Ingress => TcAttachType::Ingress,
+ Self::Egress => TcAttachType::Egress,
+ }
+ }
+}
+
+impl FromStr for Direction {
+ type Err = ();
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "ingress" => Ok(Self::Ingress),
+ "egress" => Ok(Self::Egress),
+ _ => Err(()),
+ }
+ }
+}
+
+/// RAII handle for the shared verify root. Constructor wipes any stale contents and creates the
+/// dir. `Drop` removes it. Cleanup happens even if the verify body panics or returns Err.
+struct VerifyRoot;
+
+impl VerifyRoot {
+ fn new() -> anyhow::Result<Self> {
+ let _ = std::fs::remove_dir_all(VERIFY_ROOT);
+ std::fs::create_dir_all(VERIFY_ROOT)
+ .with_context(|| format!("create verify root {VERIFY_ROOT}"))?;
+ Ok(Self)
+ }
+ fn path(&self) -> &Path {
+ Path::new(VERIFY_ROOT)
+ }
+}
+
+impl Drop for VerifyRoot {
+ fn drop(&mut self) {
+ if let Err(e) = std::fs::remove_dir_all(VERIFY_ROOT) {
+ log::warn!("failed to clean up verify root {VERIFY_ROOT}: {e:#}");
+ }
+ }
+}
+
+/// Loads every named program in each object against a throwaway pin root, catching verifier
+/// regressions before any real state is touched.
+pub fn verify(objects: &[&[u8]], program_names: &[&str]) -> anyhow::Result<()> {
+ let root = VerifyRoot::new()?;
+ for &obj in objects {
+ let mut bpf = EbpfLoader::new().map_pin_path(root.path()).load(obj)?;
+ for &name in program_names {
+ let p: &mut SchedClassifier = bpf.program_mut(name).unwrap().try_into()?;
+ p.load()?;
+ }
+ }
+ Ok(())
+}
+
+pub fn pin_filename(ifindex: u32, dir: Direction) -> String {
+ format!("{ifindex}-{}", dir.as_str())
+}
+
+/// Ensure clsact qdisc on the iface, attach `prog` in `dir`, pin the link.
+pub fn attach_and_pin(
+ prog: &mut SchedClassifier,
+ ifindex: u32,
+ dir: Direction,
+ pin_path: &Path,
+) -> anyhow::Result<()> {
+ let name = nix::net::if_::if_indextoname(ifindex)?;
+ let name = name.to_str()?;
+ let _ = tc::qdisc_add_clsact(name);
+ let link_id = prog.attach(name, dir.aya_type())?;
+ let link = prog.take_link(link_id)?;
+ let fd_link: FdLink = link.try_into()?;
+ fd_link.pin(pin_path)?;
+ Ok(())
+}
+
+/// Rebind a pinned link to `prog` via LINK_UPDATE. Atomic, traffic sees no detach/reattach gap.
+pub fn swap_pinned_link(prog: &mut SchedClassifier, pin_path: &Path) -> anyhow::Result<()> {
+ let pinned = PinnedLink::from_pin(pin_path)?;
+ let fd_link: FdLink = pinned.into();
+ let link = fd_link.try_into()?;
+ let new_id = prog.attach_to_link(link)?;
+ // take the handle out of aya's internal tracking, we have the pin
+ let _ = prog.take_link(new_id)?;
+ Ok(())
+}
+
+pub fn detach_pinned_link(pin_path: &Path) -> anyhow::Result<()> {
+ let pinned = PinnedLink::from_pin(pin_path)?;
+ let _fd_link = pinned.unpin()?;
+ Ok(())
+}
+
+/// Read and parse every pin file in `links_dir` as `{ifindex}-{direction}`. Unrecognized names are
+/// skipped with a warning.
+pub fn read_pinned_links(links_dir: &Path) -> anyhow::Result<Vec<(u32, Direction)>> {
+ let mut out = Vec::new();
+ let dir = match std::fs::read_dir(links_dir) {
+ Ok(d) => d,
+ Err(e) if e.kind() == ErrorKind::NotFound => return Ok(out),
+ Err(e) => return Err(e.into()),
+ };
+ for entry in dir {
+ let entry = entry?;
+ let name = entry.file_name();
+ let name = name.to_string_lossy();
+ let Some((ifidx_str, dir_str)) = name.split_once('-') else {
+ log::warn!("unrecognized pin file in links dir: {name}");
+ continue;
+ };
+ match (ifidx_str.parse::<u32>(), dir_str.parse::<Direction>()) {
+ (Ok(ifidx), Ok(d)) => out.push((ifidx, d)),
+ _ => log::warn!("unrecognized pin file in links dir: {name}"),
+ }
+ }
+ Ok(out)
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH proxmox-ebpf 02/16] bpf: add bridge subsystem
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ebpf 03/16] debian: add packaging and boot-time oneshot unit Hannes Laimer
` (13 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Carry the per-packet identity across the underlay so the receiving host
sees the same group as the sending host and can enforce policy on it.
SRv6 and VXLAN-GBP are the two carriers that fit, but the kernel already
handles the skb->mark <-> md->gbp mapping, so we only implement SRv6 here.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/agent.rs | 38 +++++++++++++++-----
src/bridge/bpf/srv6.bpf.c | 76 +++++++++++++++++++++++++++++++++++++++
src/bridge/mod.rs | 75 ++++++++++++++++++++++++++++++++++++++
src/main.rs | 1 +
src/state.rs | 25 ++++++++++---
src/subsystem.rs | 3 +-
src/tc.rs | 5 +--
7 files changed, 207 insertions(+), 16 deletions(-)
create mode 100644 src/bridge/bpf/srv6.bpf.c
create mode 100644 src/bridge/mod.rs
diff --git a/src/agent.rs b/src/agent.rs
index ae8eb66..34aede5 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -1,7 +1,7 @@
//! Agent orchestrator. Builds the per-host [`DesiredState`] from the SDN running-config and applies
-//! it through the [`policy`](crate::policy) subsystem. A full pass ([`apply`](Agent::apply)) covers
-//! the whole host, the tap_plug path
-//! ([`apply_guest_iface_policy`](Agent::apply_guest_iface_policy)) programs a single interface.
+//! it. A full pass ([`apply`](Agent::apply)) covers both subsystems ([`policy`](crate::policy) and
+//! [`bridge`](crate::bridge)). The tap_plug path ([`apply_guest_iface_policy`](Agent::apply_guest_iface_policy))
+//! is policy only and programs a single interface.
//!
//! The binary runs one-shot per event (boot, an SDN apply, a tap_plug), not as a resident daemon.
//! Every invocation first makes sure the programs are loaded (installing them on the first run or a
@@ -10,42 +10,61 @@
use anyhow::Context;
-use crate::{policy::PolicySubsystem, running_config, state::DesiredState};
+use crate::{
+ bridge::BridgeSubsystem, policy::PolicySubsystem, running_config, state::DesiredState,
+};
pub struct Agent {
policy: PolicySubsystem,
+ bridge: BridgeSubsystem,
}
impl Agent {
pub fn new() -> Self {
Self {
policy: PolicySubsystem::new(),
+ bridge: BridgeSubsystem::new(),
}
}
- /// Full pass over the host.
+ /// Full pass over the host, both subsystems.
pub fn apply(&mut self) -> anyhow::Result<()> {
let Some(state) = build_state()? else {
return Ok(());
};
log::debug!(
- "applying: {} groups, {} rules, {} assignments",
+ "applying: {} groups, {} rules, {} assignments, {} bridges",
state.groups.len(),
state.rules.len(),
state.assignments.len(),
+ state.bridges.len(),
);
if log::log_enabled!(log::Level::Trace) {
for (id, g) in &state.groups {
log::trace!("group {id} = '{}'", g.name);
}
}
- if let Err(e) = self.policy.apply(&state) {
- log::error!("policy: apply: {e:#}");
+ let policy = self.policy.apply(&state);
+ let bridge = self.bridge.apply(&state);
+ let mut failed = false;
+ for (name, result) in [("policy", policy), ("bridge", bridge)] {
+ if let Err(e) = result {
+ log::error!("{name} subsystem: {e:#}");
+ failed = true;
+ }
+ }
+ if failed {
+ anyhow::bail!("one or more subsystems failed to apply");
}
Ok(())
}
- /// Fast path for a guest NIC that just appeared (a tap_plug). Programs just that interface.
+ /// Policy-only fast path for a guest NIC that just appeared (a tap_plug). Bridge is untouched,
+ /// a guest NIC is never a bridge-facing interface.
+ ///
+ /// Forwards only a genuine enforcement failure for an assigned NIC, so the agent exits non-zero
+ /// and the caller does not bring the NIC up unenforced. A running config we cannot read or
+ /// build is logged and treated as success, so one bad config does not block every guest start.
pub fn apply_guest_iface_policy(&mut self, iface: &str) -> anyhow::Result<()> {
let state = match build_state() {
Ok(Some(state)) => state,
@@ -61,6 +80,7 @@ impl Agent {
/// Detach everything and drop all pinned/run state, for package removal.
pub fn clear(&self) -> anyhow::Result<()> {
self.policy.clear()?;
+ self.bridge.clear()?;
Ok(())
}
}
diff --git a/src/bridge/bpf/srv6.bpf.c b/src/bridge/bpf/srv6.bpf.c
new file mode 100644
index 0000000..8610418
--- /dev/null
+++ b/src/bridge/bpf/srv6.bpf.c
@@ -0,0 +1,76 @@
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_endian.h>
+#include "mark.h"
+#include "bpf_debug.h"
+
+char LICENSE[] SEC("license") = "GPL";
+
+#define TC_ACT_OK 0
+
+#define ETH_HLEN 14
+#define IPV6_HLEN 40
+
+#define ETH_P_IPV6 0x86dd
+#define IPPROTO_ROUTING 43
+#define SRH_TYPE 4
+
+// SRH layout (IPv6 Routing Header type 4), starting from byte 0 of the header:
+// 0: next_hdr
+// 1: hdr_ext_len
+// 2: routing_type (== SRH_TYPE)
+// 3: segments_left
+// 4: last_entry
+// 5: flags
+// 6..7: tag <-- our carrier
+#define SRH_TAG_OFF (ETH_HLEN + IPV6_HLEN + 6)
+
+static __always_inline int is_srv6(struct __sk_buff *skb) {
+ void *data = (void *)(long)skb->data;
+ void *data_end = (void *)(long)skb->data_end;
+
+ if (data + ETH_HLEN + IPV6_HLEN + 8 > data_end) return 0;
+
+ struct ethhdr *eth = data;
+ if (eth->h_proto != bpf_htons(ETH_P_IPV6)) return 0;
+
+ struct ipv6hdr *ip6 = (void *)(eth + 1);
+ if (ip6->nexthdr != IPPROTO_ROUTING) return 0;
+
+ __u8 *rh = (__u8 *)(ip6 + 1);
+ if (rh[2] != SRH_TYPE) return 0;
+
+ return 1;
+}
+
+SEC("classifier")
+int tc_bridge_egress(struct __sk_buff *skb) {
+ if (!is_srv6(skb)) {
+ DBG("bridge_out(%u): skip, no SRv6", skb->ifindex);
+ return TC_ACT_OK;
+ }
+ __u16 group = microseg_mark_get(skb);
+ DBG("bridge_out(%u): putting %u onto SRH tag", skb->ifindex, group);
+ __u16 tag = bpf_htons(group);
+ bpf_skb_store_bytes(skb, SRH_TAG_OFF, &tag, sizeof(tag), 0);
+ return TC_ACT_OK;
+}
+
+SEC("classifier")
+int tc_bridge_ingress(struct __sk_buff *skb) {
+ if (!is_srv6(skb)) {
+ DBG("bridge_in(%u): skip, no SRv6", skb->ifindex);
+ return TC_ACT_OK;
+ }
+
+ __u16 tag = 0;
+ if (bpf_skb_load_bytes(skb, SRH_TAG_OFF, &tag, sizeof(tag)) < 0) {
+ DBG("bridge_in(%u): skip, no tag", skb->ifindex);
+ return TC_ACT_OK;
+ }
+
+ __u16 group = bpf_ntohs(tag);
+ microseg_mark_set(skb, group);
+ DBG("bridge_in(%u): pulled %u out of SRH", skb->ifindex, group);
+ return TC_ACT_OK;
+}
diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs
new file mode 100644
index 0000000..bdc8e2a
--- /dev/null
+++ b/src/bridge/mod.rs
@@ -0,0 +1,75 @@
+//! Bridge-side carrier translation. Attaches BPF programs to bridge-facing interfaces that stamp
+//! `skb->mark` onto an on-wire policy carrier on egress (e.g. the SRv6 SRH tag) and lift it back
+//! into `skb->mark` on ingress. Driven by the bridge entries in the [`DesiredState`], which the
+//! agent has already filtered to interfaces that apply on this host.
+//!
+//! The program detects the carrier from the packet itself, so the userspace side is
+//! protocol-agnostic. It attaches the one program everywhere and lets the data plane decide per
+//! packet.
+//!
+//! Pairs with the [`policy`](crate::policy) subsystem. Policy decides which group `skb->mark`
+//! carries on the local tap, bridge moves that value onto and off the wire so it survives across
+//! hosts.
+
+use std::collections::HashSet;
+
+use aya::include_bytes_aligned;
+
+use crate::state::{DesiredState, ResolvedBridge};
+use crate::subsystem::TcPrograms;
+use crate::tc::Direction;
+
+const NAME: &str = "bridge";
+
+const BRIDGE_OBJ: &[u8] = include_bytes_aligned!(concat!(env!("OUT_DIR"), "/srv6.bpf.o"));
+const BRIDGE_FINGERPRINT: u64 = TcPrograms::obj_fingerprint(BRIDGE_OBJ);
+
+fn program_name(dir: Direction) -> &'static str {
+ match dir {
+ Direction::Ingress => "tc_bridge_ingress",
+ Direction::Egress => "tc_bridge_egress",
+ }
+}
+
+pub struct BridgeSubsystem {
+ programs: TcPrograms,
+}
+
+impl BridgeSubsystem {
+ pub fn new() -> Self {
+ Self {
+ programs: TcPrograms::new(NAME, BRIDGE_OBJ, BRIDGE_FINGERPRINT, program_name, None),
+ }
+ }
+
+ pub fn apply(&mut self, state: &DesiredState) -> anyhow::Result<()> {
+ log::trace!("bridge apply starting");
+ let _lock = self.programs.lock_exclusive()?;
+ self.programs.ensure_loaded()?;
+ self.programs
+ .reconcile(&resolve_local_bridges(&state.bridges))?;
+ log::trace!("bridge apply done");
+ Ok(())
+ }
+
+ /// Detach all bridge programs and drop pinned/run state, for package removal.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ self.programs.clear()
+ }
+}
+
+fn resolve_local_bridges(bridges: &[ResolvedBridge]) -> HashSet<u32> {
+ let mut out = HashSet::new();
+ for b in bridges {
+ match nix::net::if_::if_nametoindex(b.interface.as_str()) {
+ Ok(ifidx) => {
+ log::debug!("resolved bridge iface {} -> ifindex {}", b.interface, ifidx);
+ out.insert(ifidx);
+ }
+ Err(_) => {
+ log::debug!("bridge: skipping {}, no such interface", b.interface);
+ }
+ }
+ }
+ out
+}
diff --git a/src/main.rs b/src/main.rs
index 6b3c16c..0334fcb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
mod agent;
+mod bridge;
mod policy;
mod running_config;
mod state;
diff --git a/src/state.rs b/src/state.rs
index 879562b..085a747 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -1,8 +1,9 @@
//! Resolved desired state derived from the SDN running config plus the local hostname.
//!
-//! [`MicrosegRunningConfig`] is admin-facing, groups are referenced by name, rules and assignments
-//! name them. This module does the one-time name -> id resolution so subsystems see a straight-line
-//! view of "what should be applied on this host".
+//! In [`MicrosegRunningConfig`] groups are referenced by name, rules and assignments name them,
+//! bridges carry the cluster-wide `nodes` filter. This module does the one-time name -> id
+//! resolution and host filtering so subsystems see a straight-line view of "what should be applied
+//! on this host".
use std::collections::{HashMap, HashSet};
@@ -15,6 +16,7 @@ pub struct DesiredState {
pub groups: HashMap<u16, GroupInfo>,
pub rules: Vec<ResolvedRule>,
pub assignments: Vec<ResolvedAssignment>,
+ pub bridges: Vec<ResolvedBridge>,
}
#[derive(Debug)]
@@ -37,8 +39,13 @@ pub struct ResolvedAssignment {
pub group: u16,
}
+#[derive(Debug)]
+pub struct ResolvedBridge {
+ pub interface: String,
+}
+
impl DesiredState {
- pub fn build(cfg: &MicrosegRunningConfig, _this_node: &str) -> anyhow::Result<Self> {
+ pub fn build(cfg: &MicrosegRunningConfig, this_node: &str) -> anyhow::Result<Self> {
let mut name_to_id: HashMap<&str, u16> = HashMap::new();
let mut parent_of: HashMap<&str, &str> = HashMap::new();
let mut groups: HashMap<u16, GroupInfo> = HashMap::new();
@@ -124,10 +131,20 @@ impl DesiredState {
});
}
+ let mut bridges = Vec::new();
+ for (interface, b) in cfg.bridges() {
+ if b.applies_to(this_node) {
+ bridges.push(ResolvedBridge {
+ interface: interface.to_string(),
+ });
+ }
+ }
+
Ok(Self {
groups,
rules,
assignments,
+ bridges,
})
}
}
diff --git a/src/subsystem.rs b/src/subsystem.rs
index 7b944b4..1a1ab71 100644
--- a/src/subsystem.rs
+++ b/src/subsystem.rs
@@ -2,7 +2,8 @@
//! pinned under `/sys/fs/bpf/proxmox-ebpf/<name>/`. The loaded BPF stays in the kernel between
//! invocations, so [`TcPrograms::ensure_loaded`] loads and verifies only on the first run and on a
//! version change. Everything else attaches links and syncs maps against what is already there.
-//! The [`policy`](crate::policy) subsystem owns a [`TcPrograms`].
+//! The [`policy`](crate::policy) and [`bridge`](crate::bridge) subsystems each own a
+//! [`TcPrograms`].
use std::{collections::HashSet, fs::File, io::ErrorKind, path::PathBuf};
diff --git a/src/tc.rs b/src/tc.rs
index d89304d..f8a9e86 100644
--- a/src/tc.rs
+++ b/src/tc.rs
@@ -1,5 +1,6 @@
-//! TC link plumbing for the [`policy`](crate::policy) subsystem. A `Direction` enum,
-//! attach/swap/detach free functions, and a uniform pin-filename layout `{ifindex}-{direction}`.
+//! TC link plumbing shared by the [`policy`](crate::policy) and [`bridge`](crate::bridge)
+//! subsystems. A `Direction` enum, attach/swap/detach free functions, and a uniform pin-filename
+//! layout `{ifindex}-{direction}`.
use std::{io::ErrorKind, path::Path, str::FromStr};
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH proxmox-ebpf 03/16] debian: add packaging and boot-time oneshot unit
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH proxmox-ve-rs 04/16] ve-config: sdn: add microseg config types Hannes Laimer
` (12 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Package the agent and ship a systemd unit that runs `apply` once at boot,
bringing BPF state in line with the SDN running-config.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Makefile | 66 +++++++++++++++++++++++++++++++++++++
debian/changelog | 5 +++
debian/control | 34 +++++++++++++++++++
debian/copyright | 18 ++++++++++
debian/proxmox-ebpf.install | 1 +
debian/proxmox-ebpf.postrm | 11 +++++++
debian/proxmox-ebpf.prerm | 12 +++++++
debian/proxmox-ebpf.service | 15 +++++++++
debian/rules | 33 +++++++++++++++++++
debian/source/format | 1 +
10 files changed, 196 insertions(+)
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/proxmox-ebpf.install
create mode 100755 debian/proxmox-ebpf.postrm
create mode 100755 debian/proxmox-ebpf.prerm
create mode 100644 debian/proxmox-ebpf.service
create mode 100755 debian/rules
create mode 100644 debian/source/format
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..bf3d7e7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,66 @@
+include /usr/share/dpkg/architecture.mk
+include /usr/share/dpkg/pkg-info.mk
+
+PACKAGE := proxmox-ebpf
+BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+CARGO ?= cargo
+
+ifeq ($(BUILD_MODE), release)
+CARGO_BUILD_ARGS += --release
+endif
+
+DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+DBG_DEB=$(PACKAGE)-dbgsym_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+
+all: cargo-build
+
+.PHONY: cargo-build
+cargo-build:
+ $(CARGO) build $(CARGO_BUILD_ARGS)
+
+.PHONY: test
+test:
+ $(CARGO) test $(CARGO_BUILD_ARGS)
+
+.PHONY: check
+check: test
+
+$(BUILDDIR): src include debian Cargo.toml build.rs
+ rm -rf $(BUILDDIR) $(BUILDDIR).tmp
+ mkdir $(BUILDDIR).tmp
+ cp -a -t $(BUILDDIR).tmp $^ Makefile
+ mv $(BUILDDIR).tmp $(BUILDDIR)
+
+.PHONY: deb
+deb: $(DEB)
+$(DEB) $(DBG_DEB) &: $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+ lintian $(DEB)
+
+.PHONY: dsc
+dsc:
+ $(MAKE) clean
+ $(MAKE) $(DSC)
+ lintian $(DSC)
+
+$(DSC): $(BUILDDIR)
+ cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+ sbuild $(DSC)
+
+.PHONY: upload
+upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
+upload: $(DEB) $(DBG_DEB)
+ tar -cf - $(DEB) $(DBG_DEB) | ssh -X repoman@repo.proxmox.com upload --product pve --dist $(UPLOAD_DIST)
+
+.PHONY: dinstall
+dinstall:
+ $(MAKE) deb
+ sudo -k dpkg -i $(DEB)
+
+clean:
+ $(CARGO) clean
+ rm -rf ./$(BUILDDIR)
+ rm -f -- *.deb *.dsc *.tar.?z *.buildinfo *.build *.changes
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..310d2cd
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+proxmox-ebpf (0.1.0) trixie; urgency=medium
+
+ * initial packaging.
+
+ -- Proxmox Support Team <support@proxmox.com> Mon, 18 May 2026 11:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..680da4f
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,34 @@
+Source: proxmox-ebpf
+Section: admin
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ clang,
+ llvm,
+ libbpf-dev,
+ linux-libc-dev,
+ cargo:native,
+ rustc:native,
+ libstd-rust-dev,
+ librust-anyhow-1+default-dev,
+ librust-aya-0.13+default-dev,
+ librust-log-0.4+default-dev,
+ librust-pico-args-0.5+default-dev,
+ librust-proxmox-log-1+default-dev,
+ librust-proxmox-ve-config-0.10+default-dev,
+ librust-nix-0.29+default-dev,
+ librust-nix-0.29+hostname-dev,
+ librust-nix-0.29+net-dev,
+ librust-serde-json-1+default-dev,
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.6.2
+Homepage: https://www.proxmox.com
+Rules-Requires-Root: no
+
+Package: proxmox-ebpf
+Architecture: any
+Depends: pve-cluster (>= 9.0.1),
+ libpve-network-perl,
+ ${shlibs:Depends},
+ ${misc:Depends},
+Description: eBPF-based microsegmentation agent for Proxmox VE
+ Filters traffic between guests by identity.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..01138fa
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 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/debian/proxmox-ebpf.install b/debian/proxmox-ebpf.install
new file mode 100644
index 0000000..7fbce7e
--- /dev/null
+++ b/debian/proxmox-ebpf.install
@@ -0,0 +1 @@
+target/x86_64-unknown-linux-gnu/release/proxmox-ebpf usr/libexec/proxmox
diff --git a/debian/proxmox-ebpf.postrm b/debian/proxmox-ebpf.postrm
new file mode 100755
index 0000000..2a7d9a5
--- /dev/null
+++ b/debian/proxmox-ebpf.postrm
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -e
+
+case "$1" in
+ remove|purge)
+ rm -rf /sys/fs/bpf/proxmox-ebpf /sys/fs/bpf/proxmox-ebpf-test || true
+ rm -rf /run/proxmox-ebpf || true
+ ;;
+esac
+
+#DEBHELPER#
diff --git a/debian/proxmox-ebpf.prerm b/debian/proxmox-ebpf.prerm
new file mode 100755
index 0000000..cce50f0
--- /dev/null
+++ b/debian/proxmox-ebpf.prerm
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -e
+
+case "$1" in
+ remove)
+ if [ -x /usr/libexec/proxmox/proxmox-ebpf ]; then
+ /usr/libexec/proxmox/proxmox-ebpf clear || true
+ fi
+ ;;
+esac
+
+#DEBHELPER#
diff --git a/debian/proxmox-ebpf.service b/debian/proxmox-ebpf.service
new file mode 100644
index 0000000..b78259e
--- /dev/null
+++ b/debian/proxmox-ebpf.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Proxmox VE eBPF microsegmentation boot reconcile
+Wants=pve-cluster.service network-online.target
+After=pve-cluster.service network-online.target pve-sdn-commit.service
+Before=pve-guests.service
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/usr/libexec/proxmox/proxmox-ebpf apply
+RuntimeDirectory=proxmox-ebpf
+RuntimeDirectoryPreserve=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..db2a8c9
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,33 @@
+#!/usr/bin/make -f
+# See debhelper(7) (uncomment to enable)
+# output every command that modifies files on the build system.
+DH_VERBOSE = 1
+
+include /usr/share/dpkg/pkg-info.mk
+include /usr/share/rustc/architecture.mk
+
+export BUILD_MODE=release
+
+export CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
+export DEB_HOST_RUST_TYPE DEB_HOST_GNU_TYPE
+
+export CARGO=/usr/share/cargo/bin/cargo
+export CARGO_HOME = $(CURDIR)/debian/cargo_home
+
+export DEB_CARGO_CRATE=proxmox-ebpf_$(DEB_VERSION_UPSTREAM)
+export DEB_CARGO_PACKAGE=proxmox-ebpf
+
+%:
+ dh $@
+
+override_dh_auto_configure:
+ @perl -ne 'if (/^version\s*=\s*"(\d+(?:\.\d+)+)"/) { my $$v_cargo = $$1; my $$v_deb = "$(DEB_VERSION_UPSTREAM)"; \
+ die "ERROR: d/changelog <-> Cargo.toml version mismatch: $$v_cargo != $$v_deb\n" if $$v_cargo ne $$v_deb; exit(0); }' Cargo.toml
+ $(CARGO) prepare-debian $(CURDIR)/debian/cargo_registry --link-from-system
+ dh_auto_configure
+
+override_dh_missing:
+ dh_missing --fail-missing
+
+override_dh_installsystemd:
+ dh_installsystemd proxmox-ebpf.service
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH proxmox-ve-rs 04/16] ve-config: sdn: add microseg config types
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
` (2 preceding siblings ...)
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
2026-06-09 13:25 ` [PATCH proxmox-perl-rs 05/16] sdn: add microseg config binding Hannes Laimer
` (11 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
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
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH proxmox-perl-rs 05/16] sdn: add microseg config binding
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
` (3 preceding siblings ...)
2026-06-09 13:25 ` [PATCH proxmox-ve-rs 04/16] ve-config: sdn: add microseg config types Hannes Laimer
@ 2026-06-09 13:25 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-cluster 06/16] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
` (10 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pve-rs/Makefile | 1 +
pve-rs/src/bindings/sdn/microseg.rs | 172 ++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
3 files changed, 174 insertions(+)
create mode 100644 pve-rs/src/bindings/sdn/microseg.rs
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..fc199ae 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -33,6 +33,7 @@ PERLMOD_PACKAGES := \
PVE::RS::ResourceScheduling::Static \
PVE::RS::ResourceScheduling::Dynamic \
PVE::RS::SDN::Fabrics \
+ PVE::RS::SDN::Microseg \
PVE::RS::SDN::PrefixLists \
PVE::RS::SDN::RouteMaps \
PVE::RS::SDN::WireGuard::PrivateKeys \
diff --git a/pve-rs/src/bindings/sdn/microseg.rs b/pve-rs/src/bindings/sdn/microseg.rs
new file mode 100644
index 0000000..fab11b0
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/microseg.rs
@@ -0,0 +1,172 @@
+#[perlmod::package(name = "PVE::RS::SDN::Microseg", lib = "pve_rs")]
+pub mod pve_rs_sdn_microseg {
+ //! The `PVE::RS::SDN::Microseg` package.
+ //!
+ //! This provides the configuration for SDN microseg, as well as helper methods for reading
+ //! / writing the configuration.
+
+ use std::collections::{BTreeMap, HashMap};
+ use std::ops::Deref;
+ use std::sync::Mutex;
+
+ use anyhow::{Error, anyhow, bail};
+ use openssl::hash::{MessageDigest, hash};
+
+ use perlmod::Value;
+
+ use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData};
+ use proxmox_ve_config::sdn::microseg::api::{MicrosegCreate, MicrosegUpdate};
+ use proxmox_ve_config::sdn::microseg::{self, MicrosegEntry};
+
+ /// A SDN Microseg config instance.
+ pub struct PerlMicrosegConfig {
+ /// The microseg config instance
+ pub microseg: Mutex<HashMap<String, MicrosegEntry>>,
+ }
+
+ perlmod::declare_magic!(Box<PerlMicrosegConfig> : &PerlMicrosegConfig as "PVE::RS::SDN::Microseg::Config");
+
+ /// Class method: Parse the raw configuration from `/etc/pve/sdn/microseg.cfg`.
+ #[export]
+ pub fn config(#[raw] class: Value, raw_config: &[u8]) -> Result<perlmod::Value, Error> {
+ let raw_config = std::str::from_utf8(raw_config)?;
+ let config = MicrosegEntry::parse_section_config("microseg.cfg", raw_config)?;
+
+ microseg::validate(config.deref())?;
+
+ Ok(
+ perlmod::instantiate_magic!(&class, MAGIC => Box::new(PerlMicrosegConfig {
+ microseg: Mutex::new(config.deref().clone()),
+ })),
+ )
+ }
+
+ /// Class method: Parse the configuration from `/etc/pve/sdn/.running-config`.
+ #[export]
+ pub fn running_config(
+ #[raw] class: Value,
+ entries: HashMap<String, MicrosegEntry>,
+ ) -> Result<perlmod::Value, Error> {
+ microseg::validate(&entries)?;
+
+ Ok(
+ perlmod::instantiate_magic!(&class, MAGIC => Box::new(PerlMicrosegConfig {
+ microseg: Mutex::new(entries),
+ })),
+ )
+ }
+
+ /// Method: Used for writing the running configuration.
+ #[export]
+ pub fn to_sections(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ ) -> Result<HashMap<String, MicrosegEntry>, Error> {
+ let config = this.microseg.lock().unwrap();
+ microseg::validate(config.deref())?;
+ Ok(config.deref().clone())
+ }
+
+ /// Method: Convert the configuration into the section config string.
+ ///
+ /// Used for writing `/etc/pve/sdn/microseg.cfg`
+ #[export]
+ pub fn to_raw(#[try_from_ref] this: &PerlMicrosegConfig) -> Result<String, Error> {
+ let config = this.microseg.lock().unwrap();
+ microseg::validate(config.deref())?;
+ // write sections in id order so the output, and the digest taken over it, are stable
+ // across calls; the config is stored in a HashMap whose iteration order is not
+ let ordered: BTreeMap<String, MicrosegEntry> = config.deref().clone().into_iter().collect();
+ let data: SectionConfigData<MicrosegEntry> = SectionConfigData::from_iter(ordered);
+
+ MicrosegEntry::write_section_config("microseg.cfg", &data)
+ }
+
+ /// Method: Generate a digest for the whole configuration.
+ #[export]
+ pub fn digest(#[try_from_ref] this: &PerlMicrosegConfig) -> Result<String, Error> {
+ let raw = to_raw(this)?;
+ let digest = hash(MessageDigest::sha256(), raw.as_bytes())?;
+
+ Ok(hex::encode(digest))
+ }
+
+ /// Method: Returns all microseg objects, keyed by id.
+ #[export]
+ pub fn list(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ ) -> Result<HashMap<String, MicrosegEntry>, Error> {
+ Ok(this.microseg.lock().unwrap().deref().clone())
+ }
+
+ /// Method: Returns a single microseg object.
+ #[export]
+ pub fn get(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ id: &str,
+ ) -> Result<Option<MicrosegEntry>, Error> {
+ Ok(this.microseg.lock().unwrap().get(id).cloned())
+ }
+
+ /// Method: Create a new microseg object. A group with no mark gets the lowest free one.
+ #[export]
+ pub fn create(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ create: MicrosegCreate,
+ ) -> Result<(), Error> {
+ let mut entries = this.microseg.lock().unwrap();
+
+ let entry = microseg::api::build_entry(create, &entries)?;
+ let id = entry.id().to_string();
+
+ if entries.contains_key(&id) {
+ bail!("microseg object '{id}' already exists");
+ }
+
+ entries.insert(id, entry);
+ microseg::validate(&entries)?;
+
+ Ok(())
+ }
+
+ /// Method: Update an existing microseg object.
+ #[export]
+ pub fn update(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ id: &str,
+ update: MicrosegUpdate,
+ ) -> Result<(), Error> {
+ let mut entries = this.microseg.lock().unwrap();
+
+ let mut entry = entries
+ .get(id)
+ .cloned()
+ .ok_or_else(|| anyhow!("microseg object '{id}' does not exist"))?;
+
+ microseg::api::apply_update(&mut entry, update)?;
+ entries.insert(id.to_string(), entry);
+ microseg::validate(&entries)?;
+
+ Ok(())
+ }
+
+ /// Method: Delete a microseg object. A group still referenced by a rule or assignment cannot
+ /// be removed.
+ #[export]
+ pub fn delete(#[try_from_ref] this: &PerlMicrosegConfig, id: &str) -> Result<(), Error> {
+ let mut entries = this.microseg.lock().unwrap();
+
+ if !entries.contains_key(id) {
+ bail!("microseg object '{id}' does not exist");
+ }
+
+ if matches!(entries.get(id), Some(MicrosegEntry::Group(_))) {
+ if let Some(referrer) = microseg::api::group_referenced_by(&entries, id) {
+ bail!("group '{id}' is still referenced by '{referrer}'");
+ }
+ }
+
+ entries.remove(id);
+
+ Ok(())
+ }
+}
diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index dcae046..1d4c23f 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -1,4 +1,5 @@
pub(crate) mod fabrics;
+pub(crate) mod microseg;
pub(crate) mod prefix_lists;
pub(crate) mod route_maps;
pub(crate) mod wireguard;
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-cluster 06/16] cfs: add 'sdn/microseg.cfg' to observed files
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
` (4 preceding siblings ...)
2026-06-09 13:25 ` [PATCH proxmox-perl-rs 05/16] sdn: add microseg config binding Hannes Laimer
@ 2026-06-09 13:25 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 07/16] sdn: microseg: add config and API Hannes Laimer
` (9 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/Cluster.pm | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index 034b78c..cef0b1a 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -84,6 +84,7 @@ my $observed = {
'sdn/mac-cache.json' => 1,
'sdn/dns.cfg' => 1,
'sdn/fabrics.cfg' => 1,
+ 'sdn/microseg.cfg' => 1,
'sdn/route-maps.cfg' => 1,
'sdn/prefix-lists.cfg' => 1,
'sdn/.running-config' => 1,
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-network 07/16] sdn: microseg: add config and API
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
` (5 preceding siblings ...)
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 08/16] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
` (8 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/API2/Network/SDN.pm | 12 +
src/PVE/API2/Network/SDN/Makefile | 2 +
src/PVE/API2/Network/SDN/Microseg.pm | 126 +++++++
.../API2/Network/SDN/Microseg/Assignment.pm | 163 +++++++++
src/PVE/API2/Network/SDN/Microseg/Bridge.pm | 171 ++++++++++
src/PVE/API2/Network/SDN/Microseg/Group.pm | 171 ++++++++++
src/PVE/API2/Network/SDN/Microseg/Makefile | 8 +
src/PVE/API2/Network/SDN/Microseg/Rule.pm | 163 +++++++++
src/PVE/Network/SDN.pm | 5 +
src/PVE/Network/SDN/Makefile | 1 +
src/PVE/Network/SDN/Microseg.pm | 316 ++++++++++++++++++
11 files changed, 1138 insertions(+)
create mode 100644 src/PVE/API2/Network/SDN/Microseg.pm
create mode 100644 src/PVE/API2/Network/SDN/Microseg/Assignment.pm
create mode 100644 src/PVE/API2/Network/SDN/Microseg/Bridge.pm
create mode 100644 src/PVE/API2/Network/SDN/Microseg/Group.pm
create mode 100644 src/PVE/API2/Network/SDN/Microseg/Makefile
create mode 100644 src/PVE/API2/Network/SDN/Microseg/Rule.pm
create mode 100644 src/PVE/Network/SDN/Microseg.pm
diff --git a/src/PVE/API2/Network/SDN.pm b/src/PVE/API2/Network/SDN.pm
index e3c8d9d..5a31e6f 100644
--- a/src/PVE/API2/Network/SDN.pm
+++ b/src/PVE/API2/Network/SDN.pm
@@ -22,6 +22,7 @@ use PVE::API2::Network::SDN::Zones;
use PVE::API2::Network::SDN::Ipams;
use PVE::API2::Network::SDN::Dns;
use PVE::API2::Network::SDN::Fabrics;
+use PVE::API2::Network::SDN::Microseg;
use PVE::API2::Network::SDN::PrefixLists;
use PVE::API2::Network::SDN::RouteMaps;
@@ -57,6 +58,11 @@ __PACKAGE__->register_method({
path => 'fabrics',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Network::SDN::Microseg",
+ path => 'microseg',
+});
+
__PACKAGE__->register_method({
subclass => "PVE::API2::Network::SDN::PrefixLists",
path => 'prefix-lists',
@@ -99,6 +105,7 @@ __PACKAGE__->register_method({
{ id => 'ipams' },
{ id => 'dns' },
{ id => 'fabrics' },
+ { id => 'microseg' },
{ id => 'prefix-lists' },
{ id => 'route-maps' },
];
@@ -271,6 +278,11 @@ __PACKAGE__->register_method({
PVE::RS::SDN::PrefixLists->running_config($prefix_list_config);
PVE::Network::SDN::PrefixLists::write_config($parsed_prefix_list_config);
+ my $microseg_config = $running_config->{microseg}->{ids} // {};
+ my $parsed_microseg_config =
+ PVE::RS::SDN::Microseg->running_config($microseg_config);
+ PVE::Network::SDN::Microseg::write_config($parsed_microseg_config);
+
PVE::Network::SDN::delete_global_lock() if $lock_token && $release_lock;
};
diff --git a/src/PVE/API2/Network/SDN/Makefile b/src/PVE/API2/Network/SDN/Makefile
index 6b91f8c..3ae11b0 100644
--- a/src/PVE/API2/Network/SDN/Makefile
+++ b/src/PVE/API2/Network/SDN/Makefile
@@ -6,6 +6,7 @@ SOURCES=Vnets.pm\
Dns.pm\
Ips.pm\
Fabrics.pm\
+ Microseg.pm\
PrefixLists.pm\
RouteMaps.pm
@@ -18,4 +19,5 @@ install:
make -C Nodes install
make -C RouteMaps install
make -C PrefixLists install
+ make -C Microseg install
diff --git a/src/PVE/API2/Network/SDN/Microseg.pm b/src/PVE/API2/Network/SDN/Microseg.pm
new file mode 100644
index 0000000..4c21593
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg.pm
@@ -0,0 +1,126 @@
+package PVE::API2::Network::SDN::Microseg;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RPCEnvironment;
+
+use PVE::Network::SDN;
+use PVE::Network::SDN::Microseg;
+
+use PVE::API2::Network::SDN::Microseg::Group;
+use PVE::API2::Network::SDN::Microseg::Rule;
+use PVE::API2::Network::SDN::Microseg::Assignment;
+use PVE::API2::Network::SDN::Microseg::Bridge;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Network::SDN::Microseg::Group",
+ path => 'group',
+});
+
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Network::SDN::Microseg::Rule",
+ path => 'rule',
+});
+
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Network::SDN::Microseg::Assignment",
+ path => 'assignment',
+});
+
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Network::SDN::Microseg::Bridge",
+ path => 'bridge',
+});
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => "Microseg index.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ properties => {},
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {
+ subdir => { type => 'string' },
+ },
+ },
+ links => [{ rel => 'child', href => "{subdir}" }],
+ },
+ code => sub {
+ return [
+ { subdir => 'group' },
+ { subdir => 'rule' },
+ { subdir => 'assignment' },
+ { subdir => 'bridge' },
+ { subdir => 'all' },
+ ];
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'all',
+ path => 'all',
+ method => 'GET',
+ description => "List every microseg object across all types, for the tree view.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ running => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the running (committed) config.",
+ },
+ pending => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the pending config with change markers.",
+ },
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {
+ id => { type => 'string', description => 'Object identifier.' },
+ type => {
+ type => 'string',
+ enum => ['group', 'rule', 'assignment', 'bridge'],
+ },
+ state => get_standard_option('pve-sdn-config-state'),
+ pending => {
+ type => 'object',
+ optional => 1,
+ description =>
+ 'Changes that have not yet been applied to the running configuration.',
+ },
+ },
+ },
+ links => [{ rel => 'child', href => "{id}" }],
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($rpcenv->get_user(), '/sdn', ['SDN.Audit']);
+
+ return PVE::Network::SDN::Microseg::list_objects($param, undef);
+ },
+});
+
+1;
diff --git a/src/PVE/API2/Network/SDN/Microseg/Assignment.pm b/src/PVE/API2/Network/SDN/Microseg/Assignment.pm
new file mode 100644
index 0000000..440e0e4
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Assignment.pm
@@ -0,0 +1,163 @@
+package PVE::API2::Network::SDN::Microseg::Assignment;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RPCEnvironment;
+use PVE::Tools qw(extract_param);
+
+use PVE::Network::SDN::Microseg;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => "List microseg NIC-to-group assignments.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ running => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the running (committed) config.",
+ },
+ pending => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the pending config with change markers.",
+ },
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ links => [{ rel => 'child', href => "{id}" }],
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($rpcenv->get_user(), '/sdn', ['SDN.Audit']);
+
+ return PVE::Network::SDN::Microseg::list_objects($param, 'assignment');
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'read',
+ path => '{id}',
+ method => 'GET',
+ description => "Read one microseg assignment.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ returns => { type => 'object' },
+ code => sub {
+ my ($param) = @_;
+
+ return PVE::Network::SDN::Microseg::get_object('assignment',
+ extract_param($param, 'id'));
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create',
+ protected => 1,
+ path => '',
+ method => 'POST',
+ description => "Create a microseg assignment. The id is derived from the vmid and iface.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ PVE::Network::SDN::Microseg::assignment_properties(0)->%*,
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ PVE::Network::SDN::Microseg::create_object('assignment', $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'update',
+ protected => 1,
+ path => '{id}',
+ method => 'PUT',
+ description => "Update a microseg assignment.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ PVE::Network::SDN::Microseg::assignment_properties(1)->%*,
+ digest => get_standard_option('pve-config-digest'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::update_object('assignment', $id, $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'delete',
+ protected => 1,
+ path => '{id}',
+ method => 'DELETE',
+ description => "Delete a microseg assignment.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::delete_object('assignment', $id, $param);
+
+ return undef;
+ },
+});
+
+1;
diff --git a/src/PVE/API2/Network/SDN/Microseg/Bridge.pm b/src/PVE/API2/Network/SDN/Microseg/Bridge.pm
new file mode 100644
index 0000000..216cde4
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Bridge.pm
@@ -0,0 +1,171 @@
+package PVE::API2::Network::SDN::Microseg::Bridge;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RPCEnvironment;
+use PVE::Tools qw(extract_param);
+
+use PVE::Network::SDN::Microseg;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => "List microseg carrier bridges.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ running => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the running (committed) config.",
+ },
+ pending => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the pending config with change markers.",
+ },
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ links => [{ rel => 'child', href => "{id}" }],
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($rpcenv->get_user(), '/sdn', ['SDN.Audit']);
+
+ return PVE::Network::SDN::Microseg::list_objects($param, 'bridge');
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'read',
+ path => '{id}',
+ method => 'GET',
+ description => "Read one microseg carrier bridge.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ returns => { type => 'object' },
+ code => sub {
+ my ($param) = @_;
+
+ return PVE::Network::SDN::Microseg::get_object('bridge', extract_param($param, 'id'));
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create',
+ protected => 1,
+ path => '',
+ method => 'POST',
+ description => "Create a microseg carrier bridge.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ PVE::Network::SDN::Microseg::bridge_properties(0)->%*,
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ PVE::Network::SDN::Microseg::create_object('bridge', $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'update',
+ protected => 1,
+ path => '{id}',
+ method => 'PUT',
+ description => "Update a microseg carrier bridge.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ PVE::Network::SDN::Microseg::bridge_properties(1)->%*,
+ delete => {
+ type => 'array',
+ optional => 1,
+ items => {
+ type => 'string',
+ enum => ['nodes'],
+ },
+ },
+ digest => get_standard_option('pve-config-digest'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::update_object('bridge', $id, $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'delete',
+ protected => 1,
+ path => '{id}',
+ method => 'DELETE',
+ description => "Delete a microseg carrier bridge.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::delete_object('bridge', $id, $param);
+
+ return undef;
+ },
+});
+
+1;
diff --git a/src/PVE/API2/Network/SDN/Microseg/Group.pm b/src/PVE/API2/Network/SDN/Microseg/Group.pm
new file mode 100644
index 0000000..0cb4e74
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Group.pm
@@ -0,0 +1,171 @@
+package PVE::API2::Network::SDN::Microseg::Group;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RPCEnvironment;
+use PVE::Tools qw(extract_param);
+
+use PVE::Network::SDN::Microseg;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => "List microseg groups.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ running => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the running (committed) config.",
+ },
+ pending => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the pending config with change markers.",
+ },
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ links => [{ rel => 'child', href => "{id}" }],
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($rpcenv->get_user(), '/sdn', ['SDN.Audit']);
+
+ return PVE::Network::SDN::Microseg::list_objects($param, 'group');
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'read',
+ path => '{id}',
+ method => 'GET',
+ description => "Read one microseg group.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ returns => { type => 'object' },
+ code => sub {
+ my ($param) = @_;
+
+ return PVE::Network::SDN::Microseg::get_object('group', extract_param($param, 'id'));
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create',
+ protected => 1,
+ path => '',
+ method => 'POST',
+ description => "Create a microseg group.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ PVE::Network::SDN::Microseg::group_properties(0)->%*,
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ PVE::Network::SDN::Microseg::create_object('group', $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'update',
+ protected => 1,
+ path => '{id}',
+ method => 'PUT',
+ description => "Update a microseg group.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ PVE::Network::SDN::Microseg::group_properties(1)->%*,
+ delete => {
+ type => 'array',
+ optional => 1,
+ items => {
+ type => 'string',
+ enum => ['comment', 'parent'],
+ },
+ },
+ digest => get_standard_option('pve-config-digest'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::update_object('group', $id, $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'delete',
+ protected => 1,
+ path => '{id}',
+ method => 'DELETE',
+ description => "Delete a microseg group.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::delete_object('group', $id, $param);
+
+ return undef;
+ },
+});
+
+1;
diff --git a/src/PVE/API2/Network/SDN/Microseg/Makefile b/src/PVE/API2/Network/SDN/Microseg/Makefile
new file mode 100644
index 0000000..1dab1d3
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Makefile
@@ -0,0 +1,8 @@
+SOURCES=Group.pm Rule.pm Assignment.pm Bridge.pm
+
+
+PERL5DIR=${DESTDIR}/usr/share/perl5
+
+.PHONY: install
+install:
+ for i in ${SOURCES}; do install -D -m 0644 $$i ${PERL5DIR}/PVE/API2/Network/SDN/Microseg/$$i; done
diff --git a/src/PVE/API2/Network/SDN/Microseg/Rule.pm b/src/PVE/API2/Network/SDN/Microseg/Rule.pm
new file mode 100644
index 0000000..b06e338
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Rule.pm
@@ -0,0 +1,163 @@
+package PVE::API2::Network::SDN::Microseg::Rule;
+
+use strict;
+use warnings;
+
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RPCEnvironment;
+use PVE::Tools qw(extract_param);
+
+use PVE::Network::SDN::Microseg;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => "List microseg policy rules.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ running => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the running (committed) config.",
+ },
+ pending => {
+ type => 'boolean',
+ optional => 1,
+ description => "Display the pending config with change markers.",
+ },
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ links => [{ rel => 'child', href => "{id}" }],
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($rpcenv->get_user(), '/sdn', ['SDN.Audit']);
+
+ return PVE::Network::SDN::Microseg::list_objects($param, 'rule');
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'read',
+ path => '{id}',
+ method => 'GET',
+ description => "Read one microseg policy rule.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ },
+ },
+ returns => { type => 'object' },
+ code => sub {
+ my ($param) = @_;
+
+ return PVE::Network::SDN::Microseg::get_object('rule', extract_param($param, 'id'));
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create',
+ protected => 1,
+ path => '',
+ method => 'POST',
+ description =>
+ "Create a microseg policy rule. The id is derived from the src and dst marks.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ PVE::Network::SDN::Microseg::rule_properties(0)->%*,
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ PVE::Network::SDN::Microseg::create_object('rule', $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'update',
+ protected => 1,
+ path => '{id}',
+ method => 'PUT',
+ description => "Update a microseg policy rule.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ PVE::Network::SDN::Microseg::rule_properties(1)->%*,
+ digest => get_standard_option('pve-config-digest'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::update_object('rule', $id, $param);
+
+ return undef;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'delete',
+ protected => 1,
+ path => '{id}',
+ method => 'DELETE',
+ description => "Delete a microseg policy rule.",
+ permissions => {
+ check => ['perm', '/sdn', ['SDN.Allocate']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ 'lock-token' => get_standard_option('pve-sdn-lock-token'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub {
+ my ($param) = @_;
+
+ my $id = extract_param($param, 'id');
+ PVE::Network::SDN::Microseg::delete_object('rule', $id, $param);
+
+ return undef;
+ },
+});
+
+1;
diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index 33a3cf3..4521465 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -25,6 +25,7 @@ use PVE::Network::SDN::Subnets;
use PVE::Network::SDN::Dhcp;
use PVE::Network::SDN::Frr;
use PVE::Network::SDN::Fabrics;
+use PVE::Network::SDN::Microseg;
use PVE::Network::SDN::RouteMaps;
use PVE::Network::SDN::PrefixLists;
@@ -212,6 +213,7 @@ sub compile_running_cfg {
my $controllers_cfg = PVE::Network::SDN::Controllers::config();
my $subnets_cfg = PVE::Network::SDN::Subnets::config();
my $fabrics_cfg = PVE::Network::SDN::Fabrics::config();
+ my $microseg_cfg = PVE::Network::SDN::Microseg::config();
my $route_maps_cfg = PVE::Network::SDN::RouteMaps::config();
my $prefix_lists_cfg = PVE::Network::SDN::PrefixLists::config();
@@ -220,6 +222,7 @@ sub compile_running_cfg {
my $controllers = { ids => $controllers_cfg->{ids} };
my $subnets = { ids => $subnets_cfg->{ids} };
my $fabrics = { ids => $fabrics_cfg->to_sections() };
+ my $microseg = { ids => $microseg_cfg->to_sections() };
my $route_maps = { ids => $route_maps_cfg->to_sections() };
my $prefix_lists = { ids => $prefix_lists_cfg->to_sections() };
@@ -230,6 +233,7 @@ sub compile_running_cfg {
controllers => $controllers,
subnets => $subnets,
fabrics => $fabrics,
+ microseg => $microseg,
'route-maps' => $route_maps,
'prefix-lists' => $prefix_lists,
};
@@ -253,6 +257,7 @@ sub has_pending_changes {
subnets => PVE::Network::SDN::Subnets::config(),
controllers => PVE::Network::SDN::Controllers::config(),
fabrics => { ids => PVE::Network::SDN::Fabrics::config()->to_sections() },
+ microseg => { ids => PVE::Network::SDN::Microseg::config()->to_sections() },
'route-maps' => { ids => PVE::Network::SDN::RouteMaps::config()->to_sections() },
'prefix-lists' => { ids => PVE::Network::SDN::PrefixLists::config()->to_sections() },
};
diff --git a/src/PVE/Network/SDN/Makefile b/src/PVE/Network/SDN/Makefile
index d0b4bce..3042bab 100644
--- a/src/PVE/Network/SDN/Makefile
+++ b/src/PVE/Network/SDN/Makefile
@@ -9,6 +9,7 @@ SOURCES=Vnets.pm\
Dhcp.pm\
Fabrics.pm\
Frr.pm\
+ Microseg.pm\
PrefixLists.pm\
RouteMaps.pm\
WireGuard.pm
diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm
new file mode 100644
index 0000000..83f6a67
--- /dev/null
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -0,0 +1,316 @@
+package PVE::Network::SDN::Microseg;
+
+use strict;
+use warnings;
+
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file);
+use PVE::Exception qw(raise_param_exc);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::Tools;
+
+use PVE::Network::SDN;
+use PVE::RS::SDN::Microseg;
+
+PVE::JSONSchema::register_format(
+ 'pve-sdn-microseg-id',
+ sub {
+ my ($id, $noerr) = @_;
+ if ($id !~ m/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,30}[a-zA-Z0-9]$/) {
+ return undef if $noerr;
+ die "microseg id '$id' contains illegal characters or is too long\n";
+ }
+ return $id;
+ },
+);
+
+PVE::JSONSchema::register_standard_option(
+ 'pve-sdn-microseg-id',
+ {
+ description => "The microseg object identifier.",
+ type => 'string',
+ format => 'pve-sdn-microseg-id',
+ minLength => 2,
+ maxLength => 32,
+ },
+);
+
+cfs_register_file('sdn/microseg.cfg', \&parse_microseg_config, \&write_microseg_config);
+
+sub parse_microseg_config {
+ my ($filename, $raw) = @_;
+ return $raw // '';
+}
+
+sub write_microseg_config {
+ my ($filename, $config) = @_;
+ return $config // '';
+}
+
+sub config {
+ my ($running) = @_;
+
+ if ($running) {
+ my $running_config = PVE::Network::SDN::running_config();
+ my $microseg_config = $running_config->{microseg}->{ids} // {};
+ return PVE::RS::SDN::Microseg->running_config($microseg_config);
+ }
+
+ my $microseg_config = cfs_read_file("sdn/microseg.cfg");
+ return PVE::RS::SDN::Microseg->config($microseg_config);
+}
+
+sub write_config {
+ my ($config) = @_;
+ cfs_write_file("sdn/microseg.cfg", $config->to_raw(), 1);
+}
+
+sub group_properties {
+ my ($update) = @_;
+
+ my $properties = {
+ comment => {
+ type => 'string',
+ optional => 1,
+ maxLength => 256,
+ description => "Free-form comment.",
+ },
+ parent => get_standard_option(
+ 'pve-sdn-microseg-id',
+ {
+ optional => 1,
+ description => "Parent group this group is nested under.",
+ },
+ ),
+ };
+
+ if (!$update) {
+ $properties->{mark} = {
+ type => 'integer',
+ minimum => 1,
+ maximum => 65535,
+ optional => 1,
+ description => "Numeric mark stamped into skb->mark and carried on the wire."
+ . " Auto-assigned if omitted.",
+ };
+ }
+
+ return $properties;
+}
+
+sub rule_properties {
+ my ($update) = @_;
+
+ my $properties = {
+ allow => {
+ type => 'boolean',
+ optional => $update,
+ description => "0 = deny, 1 = allow. No matching rule = deny (stateless).",
+ },
+ };
+
+ # src and dst define the rule's identity (its id is derived from their marks), so they are
+ # create-only. Change them by deleting and recreating the rule.
+ if (!$update) {
+ $properties->{src} = get_standard_option(
+ 'pve-sdn-microseg-id',
+ {
+ optional => 1,
+ description => "Source group. Omit to match unstamped traffic (mark 0).",
+ },
+ );
+ $properties->{dst} = get_standard_option(
+ 'pve-sdn-microseg-id', { description => "Destination group." },
+ );
+ }
+
+ return $properties;
+}
+
+sub assignment_properties {
+ my ($update) = @_;
+
+ my $properties = {
+ group => get_standard_option(
+ 'pve-sdn-microseg-id',
+ { optional => $update, description => "Group this NIC belongs to." },
+ ),
+ };
+
+ if (!$update) {
+ $properties->{vmid} = get_standard_option('pve-vmid');
+ $properties->{iface} = {
+ type => 'integer',
+ minimum => 0,
+ maximum => 31,
+ description => "Index N of the guest's netN interface.",
+ };
+ }
+
+ return $properties;
+}
+
+sub bridge_properties {
+ my ($update) = @_;
+
+ return {
+ nodes => get_standard_option(
+ 'pve-node-list',
+ {
+ optional => 1,
+ description => "Nodes this bridge applies on. Empty means all nodes.",
+ },
+ ),
+ };
+}
+
+# Shared CRUD helpers for the per-type API endpoints. Each takes the object's type and the API
+# parameter hash, so the per-type modules only declare their schema and delegate here.
+
+# Read one object, asserting it exists and is of the expected type, stamped with id and digest.
+sub get_object {
+ my ($type, $id) = @_;
+
+ my $config = config();
+ my $entry = $config->get($id);
+ raise_param_exc({ id => "microseg $type '$id' does not exist" })
+ if !$entry || $entry->{type} ne $type;
+
+ $entry->{id} = $id;
+ $entry->{digest} = $config->digest();
+
+ return $entry;
+}
+
+sub create_object {
+ my ($type, $param) = @_;
+
+ my $lock_token = PVE::Tools::extract_param($param, 'lock-token');
+ $param->{type} = $type;
+
+ PVE::Cluster::check_cfs_quorum();
+ mkdir("/etc/pve/sdn");
+
+ PVE::Network::SDN::lock_sdn_config(
+ sub {
+ my $config = config();
+ $config->create($param);
+ write_config($config);
+ },
+ "create sdn microseg $type failed",
+ $lock_token,
+ );
+}
+
+sub update_object {
+ my ($type, $id, $param) = @_;
+
+ my $digest = PVE::Tools::extract_param($param, 'digest');
+ my $lock_token = PVE::Tools::extract_param($param, 'lock-token');
+ $param->{type} = $type;
+
+ PVE::Network::SDN::lock_sdn_config(
+ sub {
+ my $config = config();
+ my $entry = $config->get($id);
+ raise_param_exc({ id => "microseg $type '$id' does not exist" })
+ if !$entry || $entry->{type} ne $type;
+
+ PVE::Tools::assert_if_modified($config->digest(), $digest) if $digest;
+
+ $config->update($id, $param);
+ write_config($config);
+ },
+ "update sdn microseg $type failed",
+ $lock_token,
+ );
+}
+
+sub delete_object {
+ my ($type, $id, $param) = @_;
+
+ my $lock_token = PVE::Tools::extract_param($param, 'lock-token');
+
+ PVE::Network::SDN::lock_sdn_config(
+ sub {
+ my $config = config();
+ my $entry = $config->get($id);
+ raise_param_exc({ id => "microseg $type '$id' does not exist" })
+ if !$entry || $entry->{type} ne $type;
+
+ $config->delete($id);
+ write_config($config);
+ },
+ "delete sdn microseg $type failed",
+ $lock_token,
+ );
+}
+
+# Shared lister for the index endpoints: returns the objects as an arrayref, each stamped with its
+# id (and digest), honoring the pending / running view flags and an optional type filter.
+sub list_objects {
+ my ($param, $type) = @_;
+
+ my $ids;
+ my $digest;
+ if ($param->{pending}) {
+ my $running_cfg = PVE::Network::SDN::running_config();
+ my $config = config();
+ my $sections = { ids => $config->to_sections() };
+ my $pending = PVE::Network::SDN::pending_config($running_cfg, $sections, 'microseg');
+ $ids = $pending->{ids};
+ $digest = $config->digest();
+ } elsif ($param->{running}) {
+ my $running_cfg = PVE::Network::SDN::running_config();
+ $ids = $running_cfg->{microseg}->{ids} // {};
+ } else {
+ my $config = config();
+ $ids = $config->to_sections();
+ $digest = $config->digest();
+ }
+
+ my $res = [];
+ for my $id (sort keys %$ids) {
+ my $scfg = $ids->{$id};
+ next if defined $type && $scfg->{type} ne $type;
+ $scfg->{id} = $id;
+ $scfg->{digest} = $digest if defined $digest;
+ push @$res, $scfg;
+ }
+
+ return $res;
+}
+
+my $MICROSEG_AGENT = '/usr/libexec/proxmox/proxmox-ebpf';
+
+sub apply_interface {
+ my ($iface) = @_;
+
+ return if !-x $MICROSEG_AGENT;
+
+ # An assigned NIC that cannot be enforced must not come up, so a non-zero exit fails the plug
+ # and the task that triggered it. Capture the agent's output and put it in the error so that
+ # task shows why, instead of a bare exit code.
+ my $output = '';
+ my $collect = sub { $output .= "$_[0]\n" };
+ eval {
+ PVE::Tools::run_command(
+ [$MICROSEG_AGENT, 'apply', $iface],
+ outfunc => $collect,
+ errfunc => $collect,
+ );
+ };
+ if (my $err = $@) {
+ chomp $output;
+ die "microseg: refusing to bring up '$iface' unenforced\n"
+ . ($output ne '' ? "$output\n" : "$err");
+ }
+}
+
+sub apply_all {
+ return if !-x $MICROSEG_AGENT;
+
+ eval { PVE::Tools::run_command([$MICROSEG_AGENT, 'apply']); };
+ warn "microseg: failed to apply running config: $@" if $@;
+}
+
+1;
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-network 08/16] sdn: zones: trigger microseg apply on tap_plug
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
` (6 preceding siblings ...)
2026-06-09 13:25 ` [PATCH pve-network 07/16] sdn: microseg: add config and API Hannes Laimer
@ 2026-06-09 13:25 ` 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
` (7 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/Network/SDN/Zones.pm | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/PVE/Network/SDN/Zones.pm b/src/PVE/Network/SDN/Zones.pm
index 4c1468c..1af5e5c 100644
--- a/src/PVE/Network/SDN/Zones.pm
+++ b/src/PVE/Network/SDN/Zones.pm
@@ -10,6 +10,7 @@ use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file);
use PVE::Network;
use PVE::Network::SDN::Vnets;
+use PVE::Network::SDN::Microseg;
use PVE::Network::SDN::Zones::VlanPlugin;
use PVE::Network::SDN::Zones::QinQPlugin;
use PVE::Network::SDN::Zones::VxlanPlugin;
@@ -332,6 +333,8 @@ sub tap_plug {
$opts->{learning} = 0
if $interfaces_config->{ifaces}->{$bridge}
&& $interfaces_config->{ifaces}->{$bridge}->{'bridge-disable-mac-learning'};
+ # attach enforcement before bridging, so the NIC never passes traffic unenforced
+ PVE::Network::SDN::Microseg::apply_interface($iface);
PVE::Network::tap_plug($iface, $bridge, $tag, $firewall, $trunks, $rate, $opts);
return;
}
@@ -343,6 +346,9 @@ sub tap_plug {
if $plugin_config->{nodes} && !defined($plugin_config->{nodes}->{$nodename});
my $plugin = PVE::Network::SDN::Zones::Plugin->lookup($plugin_config->{type});
+
+ # attach enforcement before bridging, so the NIC never passes traffic unenforced
+ PVE::Network::SDN::Microseg::apply_interface($iface);
$plugin->tap_plug($plugin_config, $vnet, $tag, $iface, $bridge, $firewall, $trunks, $rate);
}
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-network 09/16] sdn: zones: add vxlan-gbp option to vxlan and evpn zones
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
` (7 preceding siblings ...)
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-network 10/16] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
` (6 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Add an opt-in per-zone vxlan-gbp flag that creates the zone's vxlan
interfaces with VXLAN-GBP, so the source group rides the GBP field
across hosts and microsegmentation can enforce on the receiving node.
For evpn it covers both the per-vnet device and the l3vni. Off by
default, and every VTEP in the zone must have it enabled or it drops
the GBP-tagged traffic.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 3 +++
src/PVE/Network/SDN/Zones/VxlanPlugin.pm | 9 +++++++++
2 files changed, 12 insertions(+)
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index dfbd7e9..d8ce733 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -118,6 +118,7 @@ sub options {
'bridge-disable-mac-learning' => { optional => 1 },
'rt-import' => { optional => 1 },
'vxlan-port' => { optional => 1 },
+ 'vxlan-gbp' => { optional => 1 },
mtu => { optional => 1 },
mac => { optional => 1 },
dns => { optional => 1 },
@@ -223,6 +224,7 @@ sub generate_sdn_config {
push @iface_config, "vxlan-id $tag";
push @iface_config, "vxlan-local-tunnelip $ifaceip" if $ifaceip;
push @iface_config, "vxlan-port $vxlanport" if $vxlanport;
+ push @iface_config, "vxlan-gbp on" if $plugin_config->{'vxlan-gbp'};
push @iface_config, "bridge-learning off";
push @iface_config, "bridge-arp-nd-suppress on"
if !$plugin_config->{'disable-arp-nd-suppression'};
@@ -319,6 +321,7 @@ sub generate_sdn_config {
push @iface_config, "vxlan-id $vrfvxlan";
push @iface_config, "vxlan-local-tunnelip $ifaceip" if $ifaceip;
push @iface_config, "vxlan-port $vxlanport" if $vxlanport;
+ push @iface_config, "vxlan-gbp on" if $plugin_config->{'vxlan-gbp'};
push @iface_config, "bridge-learning off";
push @iface_config, "bridge-arp-nd-suppress on"
if !$plugin_config->{'disable-arp-nd-suppression'};
diff --git a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
index a408261..167f470 100644
--- a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
@@ -44,6 +44,13 @@ sub properties {
type => 'string',
format => 'pve-sdn-fabric-id',
},
+ 'vxlan-gbp' => {
+ description => "Enable VXLAN Group Based Policy (GBP) on the zone's VXLAN"
+ . " interfaces. Carries the source group across hosts for"
+ . " microsegmentation; every VTEP in the zone must have it enabled.",
+ type => 'boolean',
+ optional => 1,
+ },
};
}
@@ -58,6 +65,7 @@ sub options {
dnszone => { optional => 1 },
ipam => { optional => 1 },
fabric => { optional => 1 },
+ 'vxlan-gbp' => { optional => 1 },
};
}
@@ -132,6 +140,7 @@ sub generate_sdn_config {
push @iface_config, "vxlan_remoteip $address";
}
push @iface_config, "vxlan-port $vxlanport" if $vxlanport;
+ push @iface_config, "vxlan-gbp on" if $plugin_config->{'vxlan-gbp'};
push @iface_config, "mtu $mtu" if $mtu;
push(@{ $config->{$vxlan_iface} }, @iface_config) if !$config->{$vxlan_iface};
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-network 10/16] evpn: disable vxlan-learning on create if GBP is enabled
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
` (8 preceding siblings ...)
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-manager 11/16] ui: sdn: add microsegmentation Hannes Laimer
` (5 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
The kernel recomputes a vxlan interface's reserved_bits on every
changelink. When GBP is enabled, those bits must leave the group
policy id and GBP flag unreserved, otherwise vxlan_rcv classifies
incoming GBP frames as malformed and drops them.
When bridge-learning is off, ifupdown2 syncs vxlan-learning to match
by issuing a separate changelink after create. That changelink omits
vxlan-gbp, so the kernel resets reserved_bits to the default and GBP
frames start getting dropped.
Set vxlan-learning off already at create when GBP is enabled, so the
interface matches the desired state up front and ifupdown2's later
learning sync has nothing to change. This avoids the gbp-less
changelink that would reset reserved_bits.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index d8ce733..7f10c8a 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -225,6 +225,10 @@ sub generate_sdn_config {
push @iface_config, "vxlan-local-tunnelip $ifaceip" if $ifaceip;
push @iface_config, "vxlan-port $vxlanport" if $vxlanport;
push @iface_config, "vxlan-gbp on" if $plugin_config->{'vxlan-gbp'};
+ # keep vxlan-learning off already at create, matching bridge-learning, otherwise
+ # ifupdown2's later learning sync sends a gbp-less changelink and the kernel resets
+ # reserved_bits to the default, which makes it drop GBP frames
+ push @iface_config, "vxlan-learning off" if $plugin_config->{'vxlan-gbp'};
push @iface_config, "bridge-learning off";
push @iface_config, "bridge-arp-nd-suppress on"
if !$plugin_config->{'disable-arp-nd-suppression'};
@@ -322,6 +326,10 @@ sub generate_sdn_config {
push @iface_config, "vxlan-local-tunnelip $ifaceip" if $ifaceip;
push @iface_config, "vxlan-port $vxlanport" if $vxlanport;
push @iface_config, "vxlan-gbp on" if $plugin_config->{'vxlan-gbp'};
+ # keep vxlan-learning off already at create, matching bridge-learning, otherwise
+ # ifupdown2's later learning sync sends a gbp-less changelink and the kernel resets
+ # reserved_bits to the default, which makes it drop GBP frames
+ push @iface_config, "vxlan-learning off" if $plugin_config->{'vxlan-gbp'};
push @iface_config, "bridge-learning off";
push @iface_config, "bridge-arp-nd-suppress on"
if !$plugin_config->{'disable-arp-nd-suppression'};
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-manager 11/16] ui: sdn: add microsegmentation
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
` (9 preceding siblings ...)
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 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-manager 12/16] network: apply microseg state on reload Hannes Laimer
` (4 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/manager6/Makefile | 9 +
www/manager6/Utils.js | 23 +
www/manager6/dc/Config.js | 8 +
www/manager6/form/MicrosegGroupSelector.js | 64 +++
www/manager6/form/MicrosegGuestNicSelector.js | 107 +++++
www/manager6/form/MicrosegGuestSelector.js | 83 ++++
www/manager6/sdn/MicrosegView.js | 408 ++++++++++++++++++
www/manager6/sdn/microseg/AssignmentEdit.js | 63 +++
www/manager6/sdn/microseg/Base.js | 88 ++++
www/manager6/sdn/microseg/GroupEdit.js | 61 +++
www/manager6/sdn/microseg/PolicyView.js | 221 ++++++++++
www/manager6/sdn/microseg/RuleEdit.js | 49 +++
12 files changed, 1184 insertions(+)
create mode 100644 www/manager6/form/MicrosegGroupSelector.js
create mode 100644 www/manager6/form/MicrosegGuestNicSelector.js
create mode 100644 www/manager6/form/MicrosegGuestSelector.js
create mode 100644 www/manager6/sdn/MicrosegView.js
create mode 100644 www/manager6/sdn/microseg/AssignmentEdit.js
create mode 100644 www/manager6/sdn/microseg/Base.js
create mode 100644 www/manager6/sdn/microseg/GroupEdit.js
create mode 100644 www/manager6/sdn/microseg/PolicyView.js
create mode 100644 www/manager6/sdn/microseg/RuleEdit.js
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d4dd3f35..f300a6a4 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -71,6 +71,9 @@ JSSRC= \
form/SDNControllerSelector.js \
form/SDNZoneSelector.js \
form/SDNVnetSelector.js \
+ form/MicrosegGroupSelector.js \
+ form/MicrosegGuestSelector.js \
+ form/MicrosegGuestNicSelector.js \
form/SDNIpamSelector.js \
form/SDNDnsSelector.js \
form/ScsiHwSelector.js \
@@ -338,6 +341,12 @@ JSSRC= \
sdn/zones/VxlanEdit.js \
sdn/FabricsView.js \
sdn/FabricsContentView.js \
+ sdn/MicrosegView.js \
+ sdn/microseg/Base.js \
+ sdn/microseg/GroupEdit.js \
+ sdn/microseg/RuleEdit.js \
+ sdn/microseg/AssignmentEdit.js \
+ sdn/microseg/PolicyView.js \
sdn/fabrics/Common.js \
sdn/fabrics/InterfacePanel.js \
sdn/fabrics/NodeEdit.js \
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 2ed4e65d..2ac5de06 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -966,6 +966,29 @@ Ext.define('PVE.Utils', {
},
},
+ sdnmicrosegSchema: {
+ group: {
+ name: gettext('Group'),
+ ipanel: 'PVE.sdn.microseg.GroupInputPanel',
+ },
+ rule: {
+ name: gettext('Rule'),
+ ipanel: 'PVE.sdn.microseg.RuleInputPanel',
+ },
+ assignment: {
+ name: gettext('Assignment'),
+ ipanel: 'PVE.sdn.microseg.AssignmentInputPanel',
+ },
+ },
+
+ format_sdnmicroseg_type: function (value, md, record) {
+ var schema = PVE.Utils.sdnmicrosegSchema[value];
+ if (schema) {
+ return schema.name;
+ }
+ return Proxmox.Utils.unknownText;
+ },
+
sdnipamSchema: {
ipam: {
name: 'ipam',
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index e1706636..e473f937 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -309,6 +309,14 @@ Ext.define('PVE.dc.Config', {
iconCls: 'fa fa-road',
itemId: 'sdnfabrics',
},
+ {
+ xtype: 'pveSDNMicroseg',
+ groups: ['sdn'],
+ title: gettext('Microseg'),
+ hidden: true,
+ iconCls: 'fa fa-tags',
+ itemId: 'sdnmicroseg',
+ },
{
xtype: 'pveSDNRouteMaps',
groups: ['sdn'],
diff --git a/www/manager6/form/MicrosegGroupSelector.js b/www/manager6/form/MicrosegGroupSelector.js
new file mode 100644
index 00000000..4954d2e3
--- /dev/null
+++ b/www/manager6/form/MicrosegGroupSelector.js
@@ -0,0 +1,64 @@
+Ext.define(
+ 'PVE.form.MicrosegGroupSelector',
+ {
+ extend: 'Proxmox.form.ComboGrid',
+ alias: ['widget.pveMicrosegGroupSelector'],
+
+ valueField: 'id',
+ displayField: 'id',
+
+ initComponent: function () {
+ let me = this;
+
+ let store = new Ext.data.Store({
+ model: 'pve-sdn-microseg-group',
+ sorters: {
+ property: 'id',
+ direction: 'ASC',
+ },
+ });
+
+ Ext.apply(me, {
+ store: store,
+ autoSelect: false,
+ listConfig: {
+ columns: [
+ {
+ header: gettext('Group'),
+ sortable: true,
+ dataIndex: 'id',
+ flex: 1,
+ },
+ {
+ header: gettext('Mark'),
+ sortable: true,
+ dataIndex: 'mark',
+ width: 70,
+ },
+ {
+ header: gettext('Comment'),
+ dataIndex: 'comment',
+ flex: 2,
+ renderer: Ext.String.htmlEncode,
+ },
+ ],
+ },
+ });
+
+ me.callParent();
+
+ store.load();
+ },
+ },
+ function () {
+ Ext.define('pve-sdn-microseg-group', {
+ extend: 'Ext.data.Model',
+ fields: ['id', 'type', 'mark', 'comment'],
+ proxy: {
+ type: 'proxmox',
+ url: '/api2/json/cluster/sdn/microseg/group',
+ },
+ idProperty: 'id',
+ });
+ },
+);
diff --git a/www/manager6/form/MicrosegGuestNicSelector.js b/www/manager6/form/MicrosegGuestNicSelector.js
new file mode 100644
index 00000000..da8dc953
--- /dev/null
+++ b/www/manager6/form/MicrosegGuestNicSelector.js
@@ -0,0 +1,107 @@
+Ext.define('PVE.form.MicrosegGuestNicSelector', {
+ extend: 'Proxmox.form.ComboGrid',
+ alias: ['widget.pveMicrosegGuestNicSelector'],
+
+ valueField: 'iface',
+ displayField: 'name',
+ queryMode: 'local',
+ disabled: true,
+ emptyText: gettext('Select a guest first'),
+
+ currentGuest: undefined,
+
+ initComponent: function () {
+ let me = this;
+
+ me.store = Ext.create('Ext.data.Store', {
+ fields: ['iface', 'name', 'detail'],
+ data: [],
+ });
+
+ Ext.apply(me, {
+ autoSelect: false,
+ listConfig: {
+ columns: [
+ {
+ header: gettext('Interface'),
+ dataIndex: 'name',
+ width: 90,
+ },
+ {
+ header: gettext('Configuration'),
+ dataIndex: 'detail',
+ flex: 1,
+ renderer: Ext.String.htmlEncode,
+ },
+ ],
+ },
+ });
+
+ me.callParent();
+ },
+
+ setValue: function (value) {
+ let me = this;
+ let store = me.getStore();
+ if (
+ value !== undefined &&
+ value !== null &&
+ store &&
+ store.findBy((r) => String(r.get('iface')) === String(value)) === -1
+ ) {
+ me.pendingValue = value;
+ }
+ return me.callParent([value]);
+ },
+
+ setGuest: function (vmid, node, type) {
+ let me = this;
+
+ vmid = vmid !== undefined && vmid !== null && vmid !== '' ? String(vmid) : undefined;
+
+ if (!vmid || !node) {
+ return;
+ }
+
+ if (me.currentGuest === vmid) {
+ return;
+ }
+
+ if (me.currentGuest !== undefined && me.currentGuest !== vmid) {
+ me.setValue(undefined);
+ }
+ me.currentGuest = vmid;
+
+ let path = type === 'lxc' ? 'lxc' : 'qemu';
+ Proxmox.Utils.API2Request({
+ url: `/nodes/${node}/${path}/${vmid}/config`,
+ method: 'GET',
+ success: function (response) {
+ let conf = response.result.data ?? {};
+ let nics = [];
+ Ext.Object.each(conf, function (key, val) {
+ let m = key.match(/^net(\d+)$/);
+ if (m) {
+ nics.push({
+ iface: parseInt(m[1], 10),
+ name: key,
+ detail: Ext.isString(val) ? val : '',
+ });
+ }
+ });
+ nics.sort((a, b) => a.iface - b.iface);
+ me.getStore().loadData(nics);
+ if (me.pendingValue !== undefined) {
+ let v = me.pendingValue;
+ me.pendingValue = undefined;
+ me.setValue(v);
+ }
+ },
+ failure: function (response) {
+ me.getStore().removeAll();
+ me.currentGuest = undefined;
+ Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+ },
+ });
+ },
+});
diff --git a/www/manager6/form/MicrosegGuestSelector.js b/www/manager6/form/MicrosegGuestSelector.js
new file mode 100644
index 00000000..19c4bcc9
--- /dev/null
+++ b/www/manager6/form/MicrosegGuestSelector.js
@@ -0,0 +1,83 @@
+Ext.define(
+ 'PVE.form.MicrosegGuestSelector',
+ {
+ extend: 'Proxmox.form.ComboGrid',
+ alias: ['widget.pveMicrosegGuestSelector'],
+
+ valueField: 'vmid',
+ displayField: 'display',
+
+ initComponent: function () {
+ let me = this;
+
+ let store = new Ext.data.Store({
+ model: 'pve-microseg-guest',
+ sorters: {
+ property: 'vmid',
+ direction: 'ASC',
+ },
+ });
+
+ Ext.apply(me, {
+ store: store,
+ autoSelect: false,
+ listConfig: {
+ columns: [
+ {
+ header: 'VMID',
+ sortable: true,
+ dataIndex: 'vmid',
+ width: 80,
+ },
+ {
+ header: gettext('Name'),
+ sortable: true,
+ dataIndex: 'name',
+ flex: 1,
+ renderer: Ext.String.htmlEncode,
+ },
+ {
+ header: gettext('Type'),
+ dataIndex: 'type',
+ width: 70,
+ },
+ {
+ header: gettext('Node'),
+ dataIndex: 'node',
+ width: 100,
+ },
+ ],
+ },
+ });
+
+ me.callParent();
+
+ store.load();
+ },
+ },
+ function () {
+ Ext.define('pve-microseg-guest', {
+ extend: 'Ext.data.Model',
+ fields: [
+ 'vmid',
+ 'name',
+ 'node',
+ 'type',
+ 'status',
+ {
+ name: 'display',
+ convert: function (value, rec) {
+ let name = rec.get('name');
+ let vmid = rec.get('vmid');
+ return name ? `${vmid} (${name})` : `${vmid}`;
+ },
+ },
+ ],
+ proxy: {
+ type: 'proxmox',
+ url: '/api2/json/cluster/resources?type=vm',
+ },
+ idProperty: 'vmid',
+ });
+ },
+);
diff --git a/www/manager6/sdn/MicrosegView.js b/www/manager6/sdn/MicrosegView.js
new file mode 100644
index 00000000..d9948fb9
--- /dev/null
+++ b/www/manager6/sdn/MicrosegView.js
@@ -0,0 +1,408 @@
+Ext.define('pve-sdn-microseg', {
+ extend: 'Ext.data.Model',
+ fields: [
+ 'id',
+ 'type',
+ 'comment',
+ 'digest',
+ 'state',
+ 'pending',
+ 'mark',
+ 'parent',
+ 'src',
+ 'dst',
+ 'allow',
+ 'vmid',
+ 'iface',
+ 'group',
+ ],
+ idProperty: 'id',
+});
+
+Ext.define('PVE.sdn.MicrosegTreeModel', {
+ extend: 'Ext.data.TreeModel',
+ idProperty: 'tree_id',
+});
+
+Ext.define('PVE.sdn.MicrosegTree', {
+ extend: 'Ext.tree.Panel',
+ xtype: 'pveSDNMicrosegTree',
+
+ title: gettext('Groups'),
+ emptyText: gettext('No microseg groups configured.'),
+
+ rootVisible: false,
+ animate: false,
+
+ detailPanel: undefined,
+
+ store: {
+ sorters: ['tree_id'],
+ model: 'PVE.sdn.MicrosegTreeModel',
+ },
+
+ columns: [
+ {
+ xtype: 'treecolumn',
+ text: gettext('Name'),
+ dataIndex: 'id',
+ flex: 2,
+ renderer: function (value, metaData, rec) {
+ if (rec.data.type !== 'assignment') {
+ return PVE.Utils.render_sdn_pending(rec, value, 'id', 1);
+ }
+
+ let pending = rec.data.pending || {};
+ let vmid = pending.vmid ?? rec.data.vmid;
+ let iface = pending.iface ?? rec.data.iface;
+ let guest = Ext.String.htmlEncode(`${vmid}`);
+ let nic = Ext.String.htmlEncode(`net${iface}`);
+ let label = `${guest} <span style="opacity: 0.6;">${nic}</span>`;
+ if (rec.data.state === 'deleted') {
+ return `<span style="text-decoration: line-through;">${label}</span>`;
+ }
+ return label;
+ },
+ },
+ {
+ text: gettext('Mark'),
+ dataIndex: 'mark',
+ width: 100,
+ renderer: (value, metaData, rec) =>
+ rec.data.type === 'group' ? PVE.Utils.render_sdn_pending(rec, value, 'mark') : '',
+ },
+ {
+ text: gettext('Comment'),
+ dataIndex: 'comment',
+ flex: 1,
+ renderer: (value, metaData, rec) =>
+ rec.data.type === 'group'
+ ? PVE.Utils.render_sdn_pending(rec, value, 'comment')
+ : '',
+ },
+ {
+ text: gettext('Action'),
+ xtype: 'actioncolumn',
+ width: 110,
+ items: [
+ {
+ tooltip: gettext('Add Subgroup'),
+ handler: 'addSubgroupAction',
+ getClass: (_v, _m, { data }) =>
+ data.type === 'group' && data.state !== 'deleted'
+ ? 'fa fa-sitemap'
+ : 'pmx-hidden',
+ isActionDisabled: (_v, _r, _c, _i, { data }) => data.type !== 'group',
+ },
+ {
+ tooltip: gettext('Add Assignment'),
+ handler: 'addAssignmentAction',
+ getClass: (_v, _m, { data }) =>
+ data.type === 'group' && data.state !== 'deleted'
+ ? 'fa fa-plus-circle'
+ : 'pmx-hidden',
+ isActionDisabled: (_v, _r, _c, _i, { data }) => data.type !== 'group',
+ },
+ {
+ tooltip: gettext('Edit'),
+ handler: 'editAction',
+ getClass: (_v, _m, { data }) =>
+ data.type && data.state !== 'deleted' ? 'fa fa-pencil fa-fw' : 'pmx-hidden',
+ isActionDisabled: (_v, _r, _c, _i, { data }) =>
+ !data.type || data.state === 'deleted',
+ },
+ {
+ tooltip: gettext('Delete'),
+ handler: 'deleteAction',
+ getClass: (_v, _m, { data }) =>
+ data.type && data.state !== 'deleted'
+ ? 'fa critical fa-trash-o'
+ : 'pmx-hidden',
+ isActionDisabled: (_v, _r, _c, _i, { data }) =>
+ !data.type || data.state === 'deleted',
+ },
+ ],
+ },
+ {
+ text: gettext('State'),
+ width: 100,
+ dataIndex: 'state',
+ renderer: (value, metaData, rec) => PVE.Utils.render_sdn_pending_state(rec, value),
+ },
+ ],
+
+ tbar: [
+ {
+ text: gettext('Add Group'),
+ handler: 'addGroup',
+ },
+ {
+ text: gettext('Add Assignment'),
+ itemId: 'addAssignmentBtn',
+ disabled: true,
+ handler: 'addAssignmentTbar',
+ },
+ {
+ xtype: 'proxmoxButton',
+ text: gettext('Reload'),
+ handler: 'reload',
+ },
+ ],
+
+ listeners: {
+ selectionchange: 'onSelectionChange',
+ itemdblclick: 'onItemDblClick',
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ contextChain: function () {
+ let rec = this.getView().getSelection()?.[0];
+ if (!rec) {
+ return [];
+ }
+ let node = rec.data.type === 'assignment' ? rec.parentNode : rec;
+ let chain = [];
+ while (node && node.data && node.data.type === 'group') {
+ chain.push(node.data.id);
+ node = node.parentNode;
+ }
+ return chain;
+ },
+
+ reload: function () {
+ let me = this;
+ let view = me.getView();
+ let previous = view.getSelection()?.[0]?.data?.tree_id;
+
+ Proxmox.Utils.API2Request({
+ url: '/cluster/sdn/microseg/all?pending=1',
+ method: 'GET',
+ waitMsgTarget: view,
+ failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+ success: function (response) {
+ let entries = response.result.data;
+
+ let groups = {};
+ for (const entry of entries) {
+ if (entry.type !== 'group') {
+ continue;
+ }
+ groups[entry.id] = {
+ type: 'group',
+ expanded: true,
+ iconCls: 'fa fa-tag x-fa-treepanel',
+ tree_id: entry.id,
+ children: [],
+ ...entry,
+ ...entry.pending,
+ };
+ }
+
+ let parentOf = {};
+ for (const [name, node] of Object.entries(groups)) {
+ let parent = node.parent;
+ parentOf[name] =
+ parent && parent !== 'deleted' && groups[parent] ? parent : undefined;
+ }
+ let acyclic = (name) => {
+ let seen = {};
+ let cur = name;
+ while (cur !== undefined) {
+ if (seen[cur]) {
+ return false;
+ }
+ seen[cur] = true;
+ cur = parentOf[cur];
+ }
+ return true;
+ };
+
+ let roots = [];
+ for (const [name, node] of Object.entries(groups)) {
+ let parent = parentOf[name];
+ if (parent && acyclic(name)) {
+ groups[parent].children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ for (const entry of entries) {
+ if (entry.type !== 'assignment') {
+ continue;
+ }
+ let member = {
+ type: 'assignment',
+ leaf: true,
+ tree_id: entry.id,
+ ...entry,
+ ...entry.pending,
+ };
+ let resources = PVE.data.ResourceStore;
+ let idx = resources.findBy(
+ (r) => String(r.get('vmid')) === String(member.vmid),
+ );
+ let guest = idx >= 0 ? resources.getAt(idx) : undefined;
+ member.iconCls =
+ (guest?.get('type') === 'lxc' ? 'fa fa-cube' : 'fa fa-desktop') +
+ ' x-fa-treepanel';
+ let parent = groups[member.group];
+ if (parent) {
+ parent.children.push(member);
+ } else {
+ console.warn(
+ `microseg assignment ${member.id} references unknown group ${member.group}`,
+ );
+ }
+ }
+
+ view.setRootNode({ expanded: true, children: roots });
+
+ let node = previous && view.getStore().getNodeById(previous);
+ if (node) {
+ view.setSelection(node);
+ }
+ me.onSelectionChange();
+ },
+ });
+ },
+
+ onSelectionChange: function () {
+ let me = this;
+ let view = me.getView();
+ let chain = me.contextChain();
+ view.down('#addAssignmentBtn')?.setDisabled(chain.length === 0);
+ view.detailPanel?.setGroups(chain);
+ },
+
+ onItemDblClick: function (_view, rec) {
+ this.editAction(null, null, null, null, null, rec);
+ },
+
+ addGroup: function () {
+ this.openGroupAdd();
+ },
+
+ addSubgroupAction: function (_t, _r, _c, _i, _e, rec) {
+ if (rec.data.type === 'group') {
+ this.openGroupAdd(rec.data.id);
+ }
+ },
+
+ openGroupAdd: function (presetParent) {
+ let me = this;
+ Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: 'group',
+ autoShow: true,
+ panelConfig: presetParent ? { presetParent } : undefined,
+ listeners: { destroy: () => me.reload() },
+ });
+ },
+
+ addAssignmentTbar: function () {
+ let chain = this.contextChain();
+ if (chain.length) {
+ this.openAssignmentAdd(chain[0]);
+ }
+ },
+
+ addAssignmentAction: function (_t, _r, _c, _i, _e, rec) {
+ if (rec.data.type === 'group') {
+ this.openAssignmentAdd(rec.data.id);
+ }
+ },
+
+ openAssignmentAdd: function (group) {
+ let me = this;
+ Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: 'assignment',
+ autoShow: true,
+ panelConfig: { presetGroup: group },
+ listeners: { destroy: () => me.reload() },
+ });
+ },
+
+ editAction: function (_t, _r, _c, _i, _e, rec) {
+ let me = this;
+ Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: rec.data.type === 'group' ? 'group' : 'assignment',
+ microsegId: rec.data.id,
+ autoShow: true,
+ listeners: { destroy: () => me.reload() },
+ });
+ },
+
+ deleteAction: function (_t, _r, _c, _i, _e, rec) {
+ let me = this;
+ let name = rec.data.id;
+ let message =
+ rec.data.type === 'group'
+ ? Ext.String.format(
+ gettext('Are you sure you want to remove the group "{0}"?'),
+ name,
+ )
+ : Ext.String.format(
+ gettext('Are you sure you want to remove the assignment "{0}"?'),
+ name,
+ );
+
+ Ext.Msg.show({
+ title: gettext('Confirm'),
+ icon: Ext.Msg.WARNING,
+ message: message,
+ buttons: Ext.Msg.YESNO,
+ defaultFocus: 'no',
+ callback: function (btn) {
+ if (btn !== 'yes') {
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: `/cluster/sdn/microseg/${rec.data.type}/${encodeURIComponent(name)}`,
+ method: 'DELETE',
+ waitMsgTarget: me.getView(),
+ failure: (response) =>
+ Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+ callback: () => me.reload(),
+ });
+ },
+ });
+ },
+ },
+});
+
+Ext.define('PVE.sdn.MicrosegView', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.pveSDNMicroseg',
+
+ onlineHelp: 'pvesdn_config_microseg',
+
+ layout: 'border',
+
+ initComponent: function () {
+ let me = this;
+
+ let policies = Ext.createWidget('pveSDNMicrosegPolicyView', {
+ region: 'center',
+ border: false,
+ });
+
+ let tree = Ext.createWidget('pveSDNMicrosegTree', {
+ region: 'west',
+ width: '45%',
+ split: true,
+ border: false,
+ detailPanel: policies,
+ });
+
+ Ext.apply(me, {
+ items: [tree, policies],
+ listeners: {
+ activate: () => tree.getController().reload(),
+ },
+ });
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
new file mode 100644
index 00000000..b75c8a1c
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -0,0 +1,63 @@
+Ext.define('PVE.sdn.microseg.AssignmentInputPanel', {
+ extend: 'PVE.panel.SDNMicrosegBase',
+
+ onlineHelp: 'pvesdn_microseg_assignment',
+
+ autoId: true,
+
+ syncNicSelector: function () {
+ let me = this;
+
+ let vmid = me.guestSelector.getValue();
+ let rec;
+ if (vmid !== undefined && vmid !== null && vmid !== '') {
+ let store = me.guestSelector.getStore();
+ let idx = store.findBy((r) => String(r.get('vmid')) === String(vmid));
+ rec = idx >= 0 ? store.getAt(idx) : undefined;
+ }
+
+ if (rec) {
+ me.nicSelector.setGuest(rec.get('vmid'), rec.get('node'), rec.get('type'));
+ } else {
+ me.nicSelector.setGuest();
+ }
+ me.nicSelector.setDisabled(!me.isCreate || !rec);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.guestSelector = Ext.create({
+ xtype: 'pveMicrosegGuestSelector',
+ name: 'vmid',
+ fieldLabel: gettext('Guest'),
+ allowBlank: false,
+ disabled: !me.isCreate,
+ listeners: {
+ change: () => me.syncNicSelector(),
+ },
+ });
+ me.guestSelector.getStore().on('load', () => me.syncNicSelector());
+
+ me.nicSelector = Ext.create({
+ xtype: 'pveMicrosegGuestNicSelector',
+ name: 'iface',
+ fieldLabel: gettext('Network interface'),
+ allowBlank: false,
+ });
+
+ me.items = [
+ me.guestSelector,
+ me.nicSelector,
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'group',
+ fieldLabel: gettext('Group'),
+ allowBlank: false,
+ value: me.presetGroup,
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
new file mode 100644
index 00000000..e604a1f2
--- /dev/null
+++ b/www/manager6/sdn/microseg/Base.js
@@ -0,0 +1,88 @@
+Ext.define('PVE.panel.SDNMicrosegBase', {
+ extend: 'Proxmox.panel.InputPanel',
+
+ type: '',
+
+ autoId: false,
+ idLabel: 'ID',
+
+ onGetValues: function (values) {
+ let me = this;
+
+ if (!me.isCreate) {
+ delete values.id;
+ }
+
+ return values;
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = me.items ?? [];
+ if (!me.autoId) {
+ me.items.unshift({
+ xtype: me.isCreate ? 'textfield' : 'displayfield',
+ name: 'id',
+ value: me.microsegId || '',
+ fieldLabel: me.idLabel,
+ allowBlank: false,
+ regex: /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,30}[a-zA-Z0-9]$/,
+ regexText: gettext(
+ 'must be 2-32 chars, start/end alphanumeric, may contain dashes and underscores',
+ ),
+ });
+ }
+
+ me.callParent();
+ },
+});
+
+Ext.define('PVE.sdn.microseg.BaseEdit', {
+ extend: 'Proxmox.window.Edit',
+
+ width: 400,
+
+ initComponent: function () {
+ let me = this;
+
+ me.isCreate = !me.microsegId;
+
+ if (me.isCreate) {
+ me.url = '/api2/extjs/cluster/sdn/microseg/' + me.type;
+ me.method = 'POST';
+ } else {
+ me.url = '/api2/extjs/cluster/sdn/microseg/' + me.type + '/' + me.microsegId;
+ me.method = 'PUT';
+ }
+
+ let ipanel = Ext.create(
+ PVE.Utils.sdnmicrosegSchema[me.type].ipanel,
+ Ext.apply(
+ {
+ type: me.type,
+ isCreate: me.isCreate,
+ microsegId: me.microsegId,
+ },
+ me.panelConfig || {},
+ ),
+ );
+
+ Ext.apply(me, {
+ subject: PVE.Utils.format_sdnmicroseg_type(me.type),
+ isAdd: true,
+ items: [ipanel],
+ });
+
+ me.callParent();
+
+ if (!me.isCreate) {
+ me.load({
+ success: function (response) {
+ let values = response.result.data;
+ ipanel.setValues(values);
+ },
+ });
+ }
+ },
+});
diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js
new file mode 100644
index 00000000..6d4f27ab
--- /dev/null
+++ b/www/manager6/sdn/microseg/GroupEdit.js
@@ -0,0 +1,61 @@
+Ext.define('PVE.sdn.microseg.GroupInputPanel', {
+ extend: 'PVE.panel.SDNMicrosegBase',
+
+ onlineHelp: 'pvesdn_microseg_group',
+
+ idLabel: gettext('Name'),
+
+ onGetValues: function (values) {
+ let me = this;
+ if (me.isCreate) {
+ if (!values.mark) {
+ delete values.mark;
+ }
+ if (!values.parent) {
+ delete values.parent;
+ }
+ } else if (!values.parent) {
+ delete values.parent;
+ values.delete = values.delete ? [].concat(values.delete, 'parent') : ['parent'];
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'parent',
+ fieldLabel: gettext('Parent group'),
+ emptyText: gettext('none (top-level group)'),
+ allowBlank: true,
+ value: me.presetParent,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.advancedItems = [
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'mark',
+ fieldLabel: gettext('Mark'),
+ minValue: 1,
+ maxValue: 65535,
+ allowBlank: true,
+ disabled: !me.isCreate,
+ emptyText: gettext('auto'),
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/PolicyView.js b/www/manager6/sdn/microseg/PolicyView.js
new file mode 100644
index 00000000..0bd39a12
--- /dev/null
+++ b/www/manager6/sdn/microseg/PolicyView.js
@@ -0,0 +1,221 @@
+Ext.define('PVE.sdn.microseg.PolicyView', {
+ extend: 'Ext.grid.GridPanel',
+ alias: 'widget.pveSDNMicrosegPolicyView',
+
+ title: gettext('Policies'),
+
+ chain: undefined,
+ ownGroup: undefined,
+ chainSet: undefined,
+
+ setGroups: function (chain) {
+ let me = this;
+
+ me.chain = chain || [];
+ me.ownGroup = me.chain[0];
+ me.chainSet = {};
+ for (const name of me.chain) {
+ me.chainSet[name] = true;
+ }
+
+ me.store.removeAll();
+
+ if (me.ownGroup === undefined || me.ownGroup === null) {
+ me.setTitle(gettext('Policies'));
+ me.addBtn.disable();
+ return;
+ }
+
+ me.setTitle(Ext.String.format(gettext('Policies for {0}'), me.ownGroup));
+ me.addBtn.enable();
+ me.store.load();
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let eff = (rec, key) => {
+ let pending = rec.data.pending?.[key];
+ if (pending === undefined || pending === null || pending === 'deleted') {
+ return rec.data[key];
+ }
+ return pending;
+ };
+
+ let matched = (rec) => {
+ let src = eff(rec, 'src');
+ let dst = eff(rec, 'dst');
+ if (src === me.ownGroup || dst === me.ownGroup) {
+ return me.ownGroup;
+ }
+ return (me.chain || []).find((name) => name === src || name === dst);
+ };
+
+ let isInherited = (rec) => {
+ let group = matched(rec);
+ return group !== undefined && group !== me.ownGroup;
+ };
+
+ let store = new Ext.data.Store({
+ model: 'pve-sdn-microseg',
+ proxy: {
+ type: 'proxmox',
+ url: '/api2/json/cluster/sdn/microseg/rule?pending=1',
+ },
+ filters: [
+ {
+ filterFn: (rec) => {
+ if (!me.chainSet) {
+ return false;
+ }
+ return Boolean(
+ me.chainSet[eff(rec, 'src')] || me.chainSet[eff(rec, 'dst')],
+ );
+ },
+ },
+ ],
+ sorters: { property: 'id', direction: 'ASC' },
+ });
+
+ let reload = () => {
+ if (me.ownGroup !== undefined && me.ownGroup !== null) {
+ store.load();
+ }
+ };
+
+ let sm = Ext.create('Ext.selection.RowModel', {});
+
+ let run_editor = function () {
+ let rec = sm.getSelection()[0];
+ if (!rec || isInherited(rec)) {
+ return;
+ }
+ let win = Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: 'rule',
+ microsegId: rec.data.id,
+ autoShow: true,
+ });
+ win.on('destroy', reload);
+ };
+
+ me.addBtn = new Proxmox.button.Button({
+ text: gettext('Add'),
+ disabled: true,
+ handler: function () {
+ let win = Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: 'rule',
+ autoShow: true,
+ panelConfig: { presetDst: me.ownGroup },
+ });
+ win.on('destroy', reload);
+ },
+ });
+
+ let edit_btn = new Proxmox.button.Button({
+ text: gettext('Edit'),
+ disabled: true,
+ selModel: sm,
+ handler: run_editor,
+ });
+
+ let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
+ selModel: sm,
+ baseurl: '/cluster/sdn/microseg/rule/',
+ callback: reload,
+ });
+
+ let set_button_status = function () {
+ let rec = sm.getSelection()[0];
+ if (!rec || rec.data.state === 'deleted' || isInherited(rec)) {
+ edit_btn.disable();
+ remove_btn.disable();
+ }
+ };
+
+ Ext.apply(me, {
+ store: store,
+ selModel: sm,
+ viewConfig: {
+ trackOver: false,
+ getRowClass: (rec) => (isInherited(rec) ? 'proxmox-disabled-row' : ''),
+ },
+ tbar: [me.addBtn, remove_btn, edit_btn],
+ columns: [
+ {
+ header: '',
+ width: 44,
+ align: 'center',
+ renderer: (value, md, rec) => {
+ let group = matched(rec);
+ let inbound = eff(rec, 'dst') === group;
+ let outbound = eff(rec, 'src') === group;
+ let dir;
+ if (inbound && outbound) {
+ dir = { icon: 'fa-exchange', tip: gettext('Internal') };
+ } else if (inbound) {
+ dir = { icon: 'fa-sign-in', tip: gettext('Inbound') };
+ } else if (outbound) {
+ dir = { icon: 'fa-sign-out', tip: gettext('Outbound') };
+ } else {
+ return '';
+ }
+ return `<i class="fa fa-fw ${dir.icon}" data-qtip="${Ext.String.htmlEncode(dir.tip)}"></i>`;
+ },
+ },
+ {
+ header: gettext('Source'),
+ flex: 1,
+ dataIndex: 'src',
+ renderer: (value, md, rec) => {
+ if (!eff(rec, 'src')) {
+ return '<i>' + gettext('unstamped') + '</i>';
+ }
+ return PVE.Utils.render_sdn_pending(rec, value, 'src');
+ },
+ },
+ {
+ header: gettext('Destination'),
+ flex: 1,
+ dataIndex: 'dst',
+ renderer: (value, md, rec) => PVE.Utils.render_sdn_pending(rec, value, 'dst'),
+ },
+ {
+ header: gettext('Action'),
+ width: 100,
+ dataIndex: 'allow',
+ renderer: (value, md, rec) =>
+ Number(eff(rec, 'allow'))
+ ? '<i class="fa fa-check good"></i> ' + gettext('allow')
+ : '<i class="fa fa-ban critical"></i> ' + gettext('deny'),
+ },
+ {
+ header: gettext('Inherited'),
+ width: 150,
+ renderer: (value, md, rec) => {
+ let group = matched(rec);
+ if (!group || group === me.ownGroup) {
+ return '';
+ }
+ return (
+ '<span style="color: gray;"><i>' +
+ Ext.String.format(gettext('from {0}'), Ext.String.htmlEncode(group)) +
+ '</i></span>'
+ );
+ },
+ },
+ {
+ header: gettext('State'),
+ width: 100,
+ dataIndex: 'state',
+ renderer: (value, md, rec) => PVE.Utils.render_sdn_pending_state(rec, value),
+ },
+ ],
+ listeners: {
+ itemdblclick: run_editor,
+ selectionchange: set_button_status,
+ },
+ });
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js
new file mode 100644
index 00000000..4e0a1342
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleEdit.js
@@ -0,0 +1,49 @@
+Ext.define('PVE.sdn.microseg.RuleInputPanel', {
+ extend: 'PVE.panel.SDNMicrosegBase',
+
+ onlineHelp: 'pvesdn_microseg_rule',
+
+ autoId: true,
+
+ onGetValues: function (values) {
+ let me = this;
+ if (me.isCreate && !values.src) {
+ delete values.src;
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'src',
+ fieldLabel: gettext('Source group'),
+ emptyText: gettext('leave empty for unstamped traffic'),
+ allowBlank: true,
+ disabled: !me.isCreate,
+ },
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'dst',
+ fieldLabel: gettext('Destination group'),
+ allowBlank: false,
+ disabled: !me.isCreate,
+ value: me.presetDst,
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'allow',
+ fieldLabel: gettext('Action'),
+ boxLabel: gettext('Allow (unchecked = deny)'),
+ checked: true,
+ uncheckedValue: 0,
+ inputValue: 1,
+ },
+ ];
+
+ me.callParent();
+ },
+});
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-manager 12/16] network: apply microseg state on reload
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
` (10 preceding siblings ...)
2026-06-09 13:25 ` [PATCH pve-manager 11/16] ui: sdn: add microsegmentation Hannes Laimer
@ 2026-06-09 13:25 ` 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
` (3 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
PVE/API2/Network.pm | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/PVE/API2/Network.pm b/PVE/API2/Network.pm
index c5863ca7..ebc4a851 100644
--- a/PVE/API2/Network.pm
+++ b/PVE/API2/Network.pm
@@ -944,6 +944,10 @@ __PACKAGE__->register_method({
# reload if there are FRR entities in the SDN config.
PVE::Network::SDN::generate_frr_config(1);
}
+
+ # sync microsegmentation BPF state with the freshly applied SDN
+ # running config (no-op if the proxmox-ebpf agent is absent)
+ PVE::Network::SDN::Microseg::apply_all() if $have_sdn;
};
return $rpcenv->fork_worker('srvreload', 'networking', $authuser, $worker);
},
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-manager 13/16] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn
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
` (11 preceding siblings ...)
2026-06-09 13:25 ` [PATCH pve-manager 12/16] network: apply microseg state on reload Hannes Laimer
@ 2026-06-09 13:25 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH pve-docs 14/16] sdn: add microsegmentation section Hannes Laimer
` (2 subsequent siblings)
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Expose the per-zone vxlan-gbp option as an opt-in checkbox in the VXLAN
and EVPN zone edit panels.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/manager6/sdn/zones/EvpnEdit.js | 8 ++++++++
www/manager6/sdn/zones/VxlanEdit.js | 11 +++++++++++
2 files changed, 19 insertions(+)
diff --git a/www/manager6/sdn/zones/EvpnEdit.js b/www/manager6/sdn/zones/EvpnEdit.js
index 7229fc14..e6545d96 100644
--- a/www/manager6/sdn/zones/EvpnEdit.js
+++ b/www/manager6/sdn/zones/EvpnEdit.js
@@ -100,6 +100,14 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', {
skipEmptyText: true,
deleteEmpty: !me.isCreate,
},
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'vxlan-gbp',
+ uncheckedValue: null,
+ checked: false,
+ fieldLabel: gettext('VXLAN-GBP'),
+ deleteEmpty: !me.isCreate,
+ },
];
me.callParent();
diff --git a/www/manager6/sdn/zones/VxlanEdit.js b/www/manager6/sdn/zones/VxlanEdit.js
index 4b53c2ff..df02b41d 100644
--- a/www/manager6/sdn/zones/VxlanEdit.js
+++ b/www/manager6/sdn/zones/VxlanEdit.js
@@ -78,6 +78,17 @@ Ext.define('PVE.sdn.zones.VxlanInputPanel', {
},
];
+ me.advancedItems = [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'vxlan-gbp',
+ uncheckedValue: null,
+ checked: false,
+ fieldLabel: gettext('VXLAN-GBP'),
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
me.callParent();
},
});
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-docs 14/16] sdn: add microsegmentation section
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
` (12 preceding siblings ...)
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 ` 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
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pvesdn.adoc | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
diff --git a/pvesdn.adoc b/pvesdn.adoc
index a09a443..09ec087 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -443,6 +443,83 @@ DNS Zone Prefix:: Add a prefix to the domain registration, like
<hostname>.prefix.<domain> Optional.
+[[pvesdn_config_microseg]]
+Microsegmentation
+-----------------
+
+Microsegmentation enforces an allow/deny policy between groups of guests at the
+guest network interface, independent of IP addressing. Each interface can be
+assigned to a *group*, and *rules* between groups decide which traffic is
+allowed. Enforcement happens in the kernel via eBPF programs attached to the
+guest interfaces, on the receiving side.
+
+The default is deny: without a matching rule, traffic between groups is
+dropped, so every allowed flow needs an explicit rule.
+
+To carry the group identity between nodes, the underlying VXLAN must have Group
+Based Policy enabled via the `VXLAN-GBP` option on the zone (see
+xref:pvesdn_zone_plugin_vxlan[VXLAN Zones] and
+xref:pvesdn_zone_plugin_evpn[EVPN Zones]). Traffic that stays on a single
+node needs no extra configuration. A guest cannot forge its own group, as the
+host stamps it at the interface; the underlay is trusted, much like a VLAN tag.
+
+[[pvesdn_microseg_group]]
+Groups
+~~~~~~
+
+A group is a label applied to one or more guest interfaces. Groups can be
+nested: a group may have a single parent, and a rule on a group also applies to
+every group below it. When several rules match, the most specific one wins -
+the rule naming the destination group closest in the tree, then the source
+group.
+
+Group configuration options:
+
+Name:: An identifier for the group.
+
+Mark:: A unique numeric tag from 1 to 65535, carried on the wire to identify the
+ group.
+
+Parent:: Optional parent group, whose rules this group inherits and can refine.
+
+Comment:: Optional descriptive comment.
+
+[[pvesdn_microseg_rule]]
+Rules
+~~~~~
+
+A rule maps a `(source group, destination group)` pair to *allow* or *deny*. As
+the default is deny, rules are only needed to permit traffic, or to deny a flow
+within a broader allow inherited from a parent. Traffic within a single group is
+not permitted implicitly; add an explicit rule from a group to itself for that.
+
+Rule configuration options:
+
+Source group:: Where the traffic comes from. Leave empty to match unstamped
+ traffic, that is, traffic from interfaces not in any group.
+
+Destination group:: Where the traffic is destined.
+
+Action:: Allow or deny.
+
+NOTE: An interface with a group assigned drops unstamped traffic unless a rule
+with an empty source for that group explicitly allows it.
+
+[[pvesdn_microseg_assignment]]
+Assignments
+~~~~~~~~~~~
+
+An assignment places a specific guest network interface into a group. Each
+interface can belong to at most one group.
+
+Assignment configuration options:
+
+Guest:: The VM or container.
+
+Network interface:: The interface of that guest to place in the group.
+
+Group:: The group to assign it to.
+
[[pvesdn_config_controllers]]
Controllers
-----------
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH pve-docs 15/16] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections
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
` (13 preceding siblings ...)
2026-06-09 13:25 ` [PATCH pve-docs 14/16] sdn: add microsegmentation section Hannes Laimer
@ 2026-06-09 13:25 ` Hannes Laimer
2026-06-09 13:25 ` [PATCH ifupdown2 16/16] d/patches: add support for VXLAN-GBP flag Hannes Laimer
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pvesdn.adoc | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/pvesdn.adoc b/pvesdn.adoc
index 09ec087..91481d6 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -310,6 +310,11 @@ SDN Fabric:: Instead of manually defining all the peers, use a
MTU:: Because VXLAN encapsulation uses 50 bytes, the MTU needs to be 50 bytes
lower than the outgoing physical interface.
+VXLAN-GBP:: Enable VXLAN Group Based Policy so the
+ xref:pvesdn_config_microseg[microsegmentation] group is carried on the wire and
+ policy can be enforced across nodes. Applied at interface creation; affects all
+ VNets in the zone. Optional.
+
[[pvesdn_zone_plugin_evpn]]
EVPN Zones
@@ -371,6 +376,11 @@ MTU:: Because VXLAN encapsulation uses 50 bytes, the MTU needs to be 50 bytes
less than the maximal MTU of the outgoing physical interface. Optional,
defaults to 1450.
+VXLAN-GBP:: Enable VXLAN Group Based Policy so the
+ xref:pvesdn_config_microseg[microsegmentation] group is carried on the wire and
+ policy can be enforced across nodes. Applied at interface creation; affects all
+ VNets in the zone. Optional.
+
[[pvesdn_config_vnet]]
VNets
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread* [PATCH ifupdown2 16/16] d/patches: add support for VXLAN-GBP flag
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
` (14 preceding siblings ...)
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 ` Hannes Laimer
15 siblings, 0 replies; 17+ messages in thread
From: Hannes Laimer @ 2026-06-09 13:25 UTC (permalink / raw)
To: pve-devel
Add a vxlan-gbp attribute, threading IFLA_VXLAN_GBP through netlink and
the iproute2 create paths, to enable VXLAN Group Based Policy (the group
policy extension) on a VXLAN interface.
The flag is create-only, so it is only set on create, with a warning
when a running device diverges. As it is encoded by presence, ifquery
matches a configured "off" against an absent flag.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
...addons-vxlan-add-vxlan-gbp-attribute.patch | 228 ++++++++++++++++++
debian/patches/series | 1 +
2 files changed, 229 insertions(+)
create mode 100644 debian/patches/pve/0016-addons-vxlan-add-vxlan-gbp-attribute.patch
diff --git a/debian/patches/pve/0016-addons-vxlan-add-vxlan-gbp-attribute.patch b/debian/patches/pve/0016-addons-vxlan-add-vxlan-gbp-attribute.patch
new file mode 100644
index 0000000..6c3680a
--- /dev/null
+++ b/debian/patches/pve/0016-addons-vxlan-add-vxlan-gbp-attribute.patch
@@ -0,0 +1,228 @@
+From 653270c53d5091a5b3d02d1692cb4dd59d1a0d5f Mon Sep 17 00:00:00 2001
+From: Hannes Laimer <h.laimer@proxmox.com>
+Date: Wed, 20 May 2026 17:56:00 +0200
+Subject: [PATCH] addons: vxlan: add vxlan-gbp attribute
+
+Add a vxlan-gbp attribute, threading IFLA_VXLAN_GBP through netlink and
+the iproute2 create paths. The flag is create-only, so it is only set on
+create, with a warning when a running device diverges. Being encoded by
+presence, ifquery matches a configured "off" against an absent flag.
+
+Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
+---
+ ifupdown2/addons/vxlan.py | 57 +++++++++++++++++++++++++++++++--
+ ifupdown2/lib/iproute2.py | 14 ++++++--
+ ifupdown2/nlmanager/nlpacket.py | 20 ++++++++++++
+ 3 files changed, 86 insertions(+), 5 deletions(-)
+
+diff --git a/ifupdown2/addons/vxlan.py b/ifupdown2/addons/vxlan.py
+index bb1a6ca..93200fb 100644
+--- a/ifupdown2/addons/vxlan.py
++++ b/ifupdown2/addons/vxlan.py
+@@ -145,7 +145,15 @@ class vxlan(Vxlan, moduleBase):
+ "help": "L3 VxLAN interface (vni list and range are supported)",
+ "validvals": ["<number>"],
+ "example": ["vxlan-vni 42"]
+- }
++ },
++ "vxlan-gbp": {
++ "help": "enable VXLAN Group Based Policy (group policy extension);"
++ " can only be set at interface creation time and cannot be"
++ " toggled on a running vxlan device",
++ "validvals": ["yes", "no", "on", "off"],
++ "default": "no",
++ "example": ["vxlan-gbp yes"],
++ },
+ }
+ }
+
+@@ -908,6 +916,28 @@ class vxlan(Vxlan, moduleBase):
+ self.logger.info("%s: set vxlan-learning %s" % (ifaceobj.name, "on" if vxlan_learning else "off"))
+ user_request_vxlan_info_data[Link.IFLA_VXLAN_LEARNING] = vxlan_learning
+
++ def __config_vxlan_gbp(self, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data):
++ vxlan_gbp_str = ifaceobj.get_attr_value_first('vxlan-gbp')
++ if not vxlan_gbp_str:
++ return
++
++ vxlan_gbp = utils.get_boolean_from_string(vxlan_gbp_str)
++ cached_gbp = bool(cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_GBP))
++
++ if link_exists and vxlan_gbp != cached_gbp:
++ # IFLA_VXLAN_GBP is a NLA_FLAG and the kernel only honors it at
++ # device creation - changing it on a running vxlan is rejected.
++ self.logger.warning(
++ "%s: vxlan-gbp can only be set at vxlan device creation;"
++ " recreate the interface to change it (current: %s, requested: %s)"
++ % (ifaceobj.name, "on" if cached_gbp else "off", "on" if vxlan_gbp else "off")
++ )
++ return
++
++ if vxlan_gbp and not link_exists:
++ self.logger.info("%s: set vxlan-gbp on" % ifaceobj.name)
++ user_request_vxlan_info_data[Link.IFLA_VXLAN_GBP] = True
++
+ def __config_vxlan_udp_csum(self, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data):
+ vxlan_udp_csum = ifaceobj.get_attr_value_first('vxlan-udp-csum')
+
+@@ -1111,6 +1141,7 @@ class vxlan(Vxlan, moduleBase):
+ self.__config_vxlan_id(ifname, ifaceobj, vxlan_id_str, user_request_vxlan_info_data, cached_vxlan_ifla_info_data)
+
+ self.__config_vxlan_learning(ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data)
++ self.__config_vxlan_gbp(ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data)
+ self.__config_vxlan_ageing(ifname, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data)
+ self.__config_vxlan_port(ifname, ifaceobj, link_exists, user_request_vxlan_info_data, cached_vxlan_ifla_info_data)
+ vxlan_ttl = self.__config_vxlan_ttl(ifname, ifaceobj, user_request_vxlan_info_data, cached_vxlan_ifla_info_data)
+@@ -1182,7 +1213,8 @@ class vxlan(Vxlan, moduleBase):
+ user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT),
+ vxlan_vnifilter,
+ vxlan_ttl,
+- local.version if local else 4
++ local.version if local else 4,
++ gbp=bool(user_request_vxlan_info_data.get(Link.IFLA_VXLAN_GBP))
+ )
+ elif ifaceobj.link_privflags & ifaceLinkPrivFlags.L3VXI:
+ self.iproute2.link_add_l3vxi(
+@@ -1193,7 +1225,8 @@ class vxlan(Vxlan, moduleBase):
+ vxlan_physdev,
+ user_request_vxlan_info_data.get(Link.IFLA_VXLAN_PORT),
+ vxlan_ttl,
+- local.version if local else 4
++ local.version if local else 4,
++ gbp=bool(user_request_vxlan_info_data.get(Link.IFLA_VXLAN_GBP))
+ )
+ else:
+ try:
+@@ -1514,6 +1547,23 @@ class vxlan(Vxlan, moduleBase):
+ else:
+ ifaceobjcurr.update_config_with_status(vxlan_attr_str, cached_vxlan_attr_value or 'None', 1)
+
++ #
++ # vxlan-gbp
++ #
++ # IFLA_VXLAN_GBP is a NLA_FLAG: the attribute is present when on and
++ # absent when off, so a missing cached value must compare equal to
++ # "off". The generic loop above assumes a stored value and can't
++ # express that, hence the dedicated check here.
++ #
++ vxlan_gbp_str = ifaceobj.get_attr_value_first('vxlan-gbp')
++ if vxlan_gbp_str:
++ vxlan_gbp = utils.get_boolean_from_string(vxlan_gbp_str)
++ cached_gbp = bool(cached_vxlan_ifla_info_data.get(Link.IFLA_VXLAN_GBP))
++ if vxlan_gbp == cached_gbp:
++ ifaceobjcurr.update_config_with_status('vxlan-gbp', vxlan_gbp_str, 0)
++ else:
++ ifaceobjcurr.update_config_with_status('vxlan-gbp', 'on' if cached_gbp else 'off', 1)
++
+ #
+ # vxlan-local-tunnelip
+ #
+@@ -1722,6 +1772,7 @@ class vxlan(Vxlan, moduleBase):
+ ('vxlan-ageing', Link.IFLA_VXLAN_AGEING, str),
+ ('vxlan-learning', Link.IFLA_VXLAN_LEARNING, lambda value: 'on' if value else 'off'),
+ ('vxlan-udp-csum', Link.IFLA_VXLAN_UDP_CSUM, lambda value: 'on' if value else 'off'),
++ ('vxlan-gbp', Link.IFLA_VXLAN_GBP, lambda value: 'on' if value else 'off'),
+ ('vxlan-local-tunnelip', Link.IFLA_VXLAN_LOCAL, str),
+ ('vxlan-local-tunnelip', Link.IFLA_VXLAN_LOCAL6, str),
+ ):
+diff --git a/ifupdown2/lib/iproute2.py b/ifupdown2/lib/iproute2.py
+index 15b581e..c5268ed 100644
+--- a/ifupdown2/lib/iproute2.py
++++ b/ifupdown2/lib/iproute2.py
+@@ -280,7 +280,7 @@ class IPRoute2(Cache, Requirements):
+
+ ###
+
+- def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, vnifilter="off", ttl=None, ipversion=4):
++ def link_add_single_vxlan(self, link_exists, ifname, ip, group, physdev, port, vnifilter="off", ttl=None, ipversion=4, gbp=False):
+ cmd = []
+
+ if ipversion == 6:
+@@ -303,6 +303,11 @@ class IPRoute2(Cache, Requirements):
+ if vnifilter and utils.get_boolean_from_string(vnifilter):
+ cmd.append("vnifilter")
+
++ # GBP, like vnifilter, is a create-only flag and cannot be
++ # toggled on a running device.
++ if gbp:
++ cmd.append("gbp")
++
+ if ip:
+ cmd.append("local %s" % ip)
+
+@@ -321,7 +326,7 @@ class IPRoute2(Cache, Requirements):
+ self.__execute_or_batch(utils.ip_cmd, " ".join(cmd))
+ self.__update_cache_after_link_creation(ifname, "vxlan")
+
+- def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ttl=None, ipversion=4):
++ def link_add_l3vxi(self, link_exists, ifname, ip, group, physdev, port, ttl=None, ipversion=4, gbp=False):
+ self.logger.info("creating l3vxi device: %s" % ifname)
+
+ cmd = []
+@@ -343,6 +348,11 @@ class IPRoute2(Cache, Requirements):
+ # So we are only setting this attribute on vxlan creation
+ cmd.append("link add dev %s type vxlan external vnifilter" % ifname)
+
++ # GBP, like vnifilter, is a create-only flag and cannot be
++ # toggled on a running device.
++ if gbp:
++ cmd.append("gbp")
++
+ if ip:
+ cmd.append("local %s" % ip)
+
+diff --git a/ifupdown2/nlmanager/nlpacket.py b/ifupdown2/nlmanager/nlpacket.py
+index c3b0b67..dab36d2 100644
+--- a/ifupdown2/nlmanager/nlpacket.py
++++ b/ifupdown2/nlmanager/nlpacket.py
+@@ -1043,6 +1043,11 @@ class Attribute(object):
+ return obj(self.value)
+ return self.value
+
++ @staticmethod
++ def decode_flag_attribute(data, _=None):
++ # NLA_FLAG: presence means True
++ return True
++
+ @staticmethod
+ def decode_one_byte_attribute(data, _=None):
+ # we don't need to use the unpack function because bytes are a list of ints
+@@ -1146,6 +1151,15 @@ class Attribute(object):
+ sub_attr_pack_layout.append("Bxxx")
+ sub_attr_payload.append(info_data_value)
+
++ @staticmethod
++ def encode_flag_attribute(sub_attr_pack_layout, sub_attr_payload, info_data_type, info_data_value):
++ # NLA_FLAG: presence of the attribute encodes truth, zero payload.
++ if not info_data_value:
++ return
++ sub_attr_pack_layout.append("HH")
++ sub_attr_payload.append(4) # length: header only
++ sub_attr_payload.append(info_data_type)
++
+ @staticmethod
+ def encode_bond_xmit_hash_policy_attribute(sub_attr_pack_layout, sub_attr_payload, info_data_type, info_data_value):
+ return Attribute.encode_one_byte_attribute(
+@@ -2337,6 +2351,9 @@ class AttributeIFLA_LINKINFO(Attribute):
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_REMCSUM_RX: Attribute.decode_one_byte_attribute,
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_REPLICATION_TYPE: Attribute.decode_one_byte_attribute,
+
++ # flag attributes (zero-length) ################################
++ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_GBP: Attribute.decode_flag_attribute,
++
+ # 2 bytes network byte order attributes ########################
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_PORT: Attribute.decode_two_bytes_network_byte_order_attribute,
+
+@@ -2678,6 +2695,9 @@ class AttributeIFLA_LINKINFO(Attribute):
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_REMCSUM_RX: Attribute.encode_one_byte_attribute,
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_REPLICATION_TYPE: Attribute.encode_one_byte_attribute,
+
++ # flag attributes (zero-length) ################################
++ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_GBP: Attribute.encode_flag_attribute,
++
+ # 4 bytes attributes ###########################################
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_ID: Attribute.encode_four_bytes_attribute,
+ NetlinkPacket_IFLA_LINKINFO_Attributes.IFLA_VXLAN_LINK: Attribute.encode_four_bytes_attribute,
+--
+2.47.3
+
diff --git a/debian/patches/series b/debian/patches/series
index 2865533..ee2cb46 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -16,3 +16,4 @@ upstream/0001-use-raw-strings-for-regex-to-fix-backslash-interpret.patch
upstream/0002-vxlan-add-support-for-IPv6-vxlan-local-tunnelip.patch
pve/0014-nlmanager-read-ipv6-devconf-disable_ipv6-attribute-t.patch
pve/0015-revert-addons-bond-warn-if-sub-interface-is-detected-on-bond-slave.patch
+pve/0016-addons-vxlan-add-vxlan-gbp-attribute.patch
--
2.47.3
^ permalink raw reply related [flat|nested] 17+ messages in thread