* SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support
@ 2026-07-09 9:18 Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
` (26 more replies)
0 siblings, 27 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
This adds support for microsegmentation using eBPF programs attached to
interfaces. Mostly the tap/veth interfaces on the guests directly.
# Overview
Each guest interface is assigned a *set* of groups. Rather than a hierarchy,
group membership is treated as a signature. The agent compiles each interface's
group set into a single per-NIC *identity*, and policies are rules between
groups, matched against those sets with predicates.
A rule is a `src` predicate and a `dst` predicate plus a verdict and a
priority, where a predicate is a match kind over a list of groups:
- any{...} the interface shares at least one of the listed groups
- all{...} the interface has all of the listed groups
- exact{...} the interface's groups are exactly the listed set
For a packet, the `src` predicate is evaluated against the sender's set and the
`dst` predicate against the receiver's. Among the rules that fire the highest
`prio` wins, a conflicting tie (with different verdicts) denies, and if no rule
fires the packet is denied (default-deny). For example, with the groups
web/app/db/prod, specific allows punched through a broad deny:
- any{web} -> any{app} : allow prio 10
- any{app} -> any{db} : allow prio 10
- all{prod} -> all{prod} : allow prio 10 # these three carve out east-west...
- any{web,app,db} -> any{web,app,db} : deny prio 5 # ...through a broad tier-to-tier deny
(the last is technically not needed cause the default is deny, in a larger
context having this explicitly can be necesarry though)
The allows above are one-directional. Rules are stateless and per-packet, there
is no connection tracking. The reply needs its own allow, so for TCP between web
and app both any{web} -> any{app} and any{app} -> any{web} have to be allowed.
Groups land on an interface either directly (a guest matcher: a `vmid`,
optionally a single `iface`), by tag (a tag matcher: every guest carrying the
named tags, e.g. `prod`/`staging`/`web`), or by name (a regex matcher: every
guest whose name matches a regular expression). Assignments are additive, an
interface's set is the union of every assignment that resolves to it, so the
matchers compose.
`untagged` is a reserved built-in group (mark 0) for traffic that arrives
unstamped (gateways, DHCP/ARP, cross-node frames without a tag). It can be
named in a predicate and composed with real groups (e.g. `any{untagged, web}`),
but it is not assignable.
Enforcement happens on the receiving side, cause that's where we for sure know both
- where the packet is from
- and, its destination (us)
# config and API
The config is a single section-config, `sdn/microseg.cfg`. It holds these
object types, keyed by section id:
- group, a numeric `mark` (auto-assigned if omitted) and an optional comment
- rule, a `src`/`dst` predicate pair (`*_match` is any/all/exact over a group list),
a `prio` and `allow`
- guestassignment, binds a guest's NIC(s) (`vmid`, optional `iface`) to a group set
- tagassignment, binds the NICs of every guest matching `tags` (by `tag_match`) to a
group set, expanded against the live guest inventory on render
- regexassignment, same but for every guest whose name matches a regular
expression
- bridge, marks a bridge-facing interface as an SRv6 carrier (no UI yet)
The API exposes endpoints under
`/cluster/sdn/microseg/{group,rule,assignment,bridge}`, guarded by SDN.Audit for reads
and SDN.Allocate for writes. The assignment endpoint discriminates the two matcher
kinds on a `type` property (like the fabric `protocol`).
Rule and tag/regex-assignment ids are derived from their contents (an FNV-1a hash),
guest assignments from `vm{vmid}i{iface}`, groups and bridges take a chosen id. On
commit the config is rendered into `.running-config`, which is what the agent reads.
Rendering resolves each interface's group set to a wire identity and expands the
dynamic matchers against the inventory.
# identity
An identity is not one id per group set but one id per *signature*. Two
interfaces share an id when every rule predicate fires the same way on both, so
no rule can tell them apart. The identities are the classes of that partition,
each named by a 16-bit id, and that id is what rides on the wire.
Editing rules reshapes the partition. A new predicate that separates two sets
which used to look alike *splits* their class in two, and dropping the
predicate that told two classes apart *merges* them back into one. On a split
the subclass that still carries the old representative keeps the id and the
other gets a fresh one, on a merge the surviving class keeps its id and the
fused-away ones retire.
New ids come fresh-first from a counter. A retired id is not reused right away,
it sits in a time quarantine long enough that no packet stamped under its old
meaning can still be in flight. Only once the fresh space is exhausted does
allocation fall back to reusing a retired id whose quarantine has passed, so
config churn alone cannot exhaust the 16-bit id space. Actually running out
would take more than 65534 identities the policies can tell apart. So it
doesn't scale with count of group combinations in use, but with the distinct
semantic meaning they carry.
# skb->mark
Every packet in the kernel lives in a `sk_buff` struct, which has a 32-bit
`mark` field you can read and write while the packet moves through the kernel.
Nothing else in our SDN stack touches `mark` today, so microseg has it to
itself. Worth keeping in mind if that ever changes. For now we only use the
lower 16 bits, to carry the interface's identity (mark 0 is reserved for
unstamped traffic), so up to 65535 identities.
The catch is `mark` only lives as long as the packet stays in the kernel, and
sooner or later it leaves. To carry the identity between hosts we have to put it
on the wire itself. For that we support two carriers:
- VXLAN-GBP, the 16-bit GBP field on the VXLAN encapsulation
- SRv6, the 16-bit Tag field on the SRH
The kernel already moves `mark` in and out of the VXLAN-GBP field for us (found
that out way too late :P). For SRv6 we do it ourselves with a small eBPF program.
# eBPF
Both tagging (setting `mark`) and enforcement happen in an eBPF program
directly attached to the guest's interface. The programs themselves read and
write to/from two maps:
- tap_to_group, the identity to stamp for a given interface
- rules, a map of (src_id, dst_id) -> action, where src is the `mark` the
packet carries and dst is our own interface's identity
Specifically, on ingress we set the mark, and on egress we enforce.
# Implementation
We have an `agent` that reads the running sdn config, and applies that state to
the kernel. The binary is stateless and one-shot, not a long running daemon. It
runs on SDN apply, on tap_plug of a guest interface, and on boot after
pve-sdn-commit but before guests start. Those triggers cover every situation
where we have to touch kernel state.
It compiles the config into what the data plane needs. It folds each
interface's group set into a wire identity (a registry kept stable across
renders so identities agree across hosts, allocated as described above), and
folds the rules into the `(src_id, dst_id)` action map by priority (highest
wins, conflicting ties deny).
It keeps track of what is currently loaded in the kernel with two files under
`/run/proxmox-ebpf`. One keeping track of the bpf program that was compiled
into the binary, and one for keeping track of changes to the data structures
the bpf program accesses. The distinction is useful cause for only a program
change we can swap the currently attached program with the updated one
atomically. In case the data structure (the maps) changed there is no other way
than to tear-down all the current state and repopulate the maps and re-attach
the programs. Swapping out the maps first would have old programs interact with
a new data schema, and swapping the programs first will lead to new programs
accessing an old data schema. Either way, not good, so we wipe the state in
that case and re-build it. So, in case we change the structure of the data our
bpf programs access, there will be a bunch of ms where the configured
segmentation is not enforced. But that is the only scenario where that can
happen.
## aya, aya-ebpf
Aya is a rust lib that helps with working with BPF more easily. It has both a
userspace part (`aya`) and a part (`aya-ebpf`) that compiles to bpf. I chose to
only use the userspace part, and use C for the BPF programs themselves. The
reasons were:
- we don't have `aya-ebpf` packaged
- `aya-ebpf` *requires* nightly toolchain to compile
- the bpf programs are very small, in case this should change at some point we
can reconsider. Also, it doesn't matter what produces the .o file, so this
could be swapped in really easily if we decided to
So we compile the bpf program written in C to a .o targeting bpf using clang
and include it when compiling the agent binary.
## SRv6
Besides VXLAN-GBP, the identity can also ride in the 16-bit Tag field of an SRv6
SRH. The kernel already does this move for VXLAN-GBP but not for SRv6, so this
adds a small eBPF program that writes the identity into the SRH on egress and
reads it back on ingress. The `bridge` object marks the bridge-facing interface
it runs on.
This is only the tag carrier, not an SRv6 transport. Our SDN stack has no SRv6
zone yet, so there is nothing configured to route it over, and there is no UI.
If SDN grows an SRv6 transport later (a small L3, EVPN-lite style option), the
carrier is already in place.
It is packaged as a droppable tail. The carrier bridge section (proxmox-ve-config),
the bridge API (pve-network) and the bridge subsystem (proxmox-ebpf) are each the
last patch of their repo's series, so SRv6 can be deferred without touching the rest.
# enabling VXLAN-GBP
The kernel only moves the mark into the GBP field if the vxlan device was
created with the GBP flag set, and it's create-only, the kernel won't
toggle it on a running device. ifupdown2 had no attribute for this, so
this series includes a small ifupdown2 patch adding `vxlan-gbp`, which
threads the flag through to the netlink/iproute2 create path.
# testing
I have put pre-built packages on sani, these include ifupdown2 with the patch
# building
1. proxmox-ve-rs (install)
2. proxmox-ebpf
3. pve-cluster (install)
4. proxmox-perl-rs (install)
5. pve-network
6. pve-manager
# notes
* everything eBPF related did not change since v1, this v2 only really changes identity semantics
* using GENEVE would also be an option, but with this current approach we gain
a lot of flexibility. Expanding predicate logic, or having not just
allow/deny but also match ports or alike is possible without touching the
underlying identity system. (limiting the per-packet overhead to just 16
bits is also good I'd argue)
* the enforement mechanism is not set in stone, weather this is eBPF or maybe
nftables with a map we populate as shortly discussed with @Stefan. The
current approach plays nicely with both since it conveys identity through
`skb->mark` that nft can access as well
* I did not send the first commit for `proxmox-ebpf` since it contains a
`vmlinux.h` which is rather large but is needed for compiling. The commit is in
my staff repo.
# changelog
v2:
- core model reworked: each interface now holds a *set* of groups compiled
into a per-NIC wire identity, replacing the single-group parent/child tree
- rules gained explicit `src`/`dst` predicates (any/all/exact) and a `prio`,
replacing the implicit tree-distance ordering
- `untagged` is now a nameable built-in group (mark 0)
- assignments are additive: a NIC's set is the union of all that resolve to it
- added tag-matcher and name-regex assignments
- rule and dynamic-assignment ids are now generated
- identity allocation is fresh-first with quarantined reclamation, so churn
cannot exhaust the id space
- split the SRv6 carrier support into droppable tail patches
- updated docs
- updated cover-letter
proxmox-ve-rs:
Hannes Laimer (5):
ve-config: sdn: add microseg signature-identity engine
ve-config: sdn: add microseg config types
ve-config: sdn: microseg: add tag matcher
ve-config: sdn: microseg: add name regex matcher
ve-config: sdn: microseg: add carrier bridge section
proxmox-ve-config/src/sdn/config.rs | 9 +-
.../src/sdn/microseg/identity.rs | 624 +++++
proxmox-ve-config/src/sdn/microseg/mod.rs | 2349 +++++++++++++++++
proxmox-ve-config/src/sdn/mod.rs | 1 +
4 files changed, 2982 insertions(+), 1 deletion(-)
create mode 100644 proxmox-ve-config/src/sdn/microseg/identity.rs
create mode 100644 proxmox-ve-config/src/sdn/microseg/mod.rs
proxmox-ebpf:
Hannes Laimer (3):
agent: add userspace coordinator and stateless policy subsystem
bpf: add bridge subsystem
debian: add packaging and boot-time oneshot unit
Makefile | 66 +++++++
debian/changelog | 5 +
debian/control | 35 ++++
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 +
include/mark.h | 30 +++
src/agent.rs | 99 ++++++++++
src/bridge/bpf/srv6.bpf.c | 76 +++++++
src/bridge/mod.rs | 76 +++++++
src/main.rs | 69 +++++++
src/policy/bpf/tap.bpf.c | 68 +++++++
src/policy/bpf/types.h | 23 +++
src/policy/mod.rs | 268 +++++++++++++++++++++++++
src/policy/types.rs | 45 +++++
src/running_config.rs | 38 ++++
src/state.rs | 149 ++++++++++++++
src/subsystem.rs | 385 ++++++++++++++++++++++++++++++++++++
src/tc.rs | 152 ++++++++++++++
23 files changed, 1675 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
create mode 100644 include/mark.h
create mode 100644 src/agent.rs
create mode 100644 src/bridge/bpf/srv6.bpf.c
create mode 100644 src/bridge/mod.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
pve-cluster:
Hannes Laimer (1):
cfs: add 'sdn/microseg.cfg' to observed files
src/PVE/Cluster.pm | 1 +
1 file changed, 1 insertion(+)
proxmox-perl-rs:
Hannes Laimer (1):
pve-rs: sdn: add microseg config binding
pve-rs/Makefile | 1 +
pve-rs/src/bindings/sdn/microseg.rs | 235 ++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
3 files changed, 237 insertions(+)
create mode 100644 pve-rs/src/bindings/sdn/microseg.rs
ifupdown2:
Hannes Laimer (1):
d/patches: add support for VXLAN-GBP flag
...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
pve-network:
Hannes Laimer (8):
sdn: microseg: add config, API and guest inventory
sdn: dry-run: surface pending microseg changes
sdn: zones: trigger microseg apply on tap_plug
sdn: zones: add vxlan-gbp option to vxlan and evpn zones
evpn: disable vxlan-learning on create if GBP is enabled
sdn: microseg: add tag matcher
sdn: microseg: add name regex matcher
sdn: microseg: add carrier bridge API
src/PVE/API2/Network/SDN.pm | 31 ++
src/PVE/API2/Network/SDN/Makefile | 2 +
src/PVE/API2/Network/SDN/Microseg.pm | 204 +++++++
.../API2/Network/SDN/Microseg/Assignment.pm | 185 +++++++
src/PVE/API2/Network/SDN/Microseg/Bridge.pm | 179 ++++++
src/PVE/API2/Network/SDN/Microseg/Group.pm | 179 ++++++
src/PVE/API2/Network/SDN/Microseg/Makefile | 8 +
src/PVE/API2/Network/SDN/Microseg/Rule.pm | 180 +++++++
src/PVE/Network/SDN.pm | 37 ++
src/PVE/Network/SDN/Makefile | 1 +
src/PVE/Network/SDN/Microseg.pm | 509 ++++++++++++++++++
src/PVE/Network/SDN/Zones.pm | 6 +
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 11 +
src/PVE/Network/SDN/Zones/VxlanPlugin.pm | 9 +
14 files changed, 1541 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
pve-manager:
Hannes Laimer (6):
ui: sdn: add microsegmentation panel
ui: sdn: dry-run: show pending microseg diff
network: apply microseg state on reload
ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn
ui: sdn: microseg: add tag matcher
ui: sdn: microseg: add name regex matcher
PVE/API2/Network.pm | 4 +
www/manager6/Makefile | 9 +
www/manager6/Utils.js | 34 ++
www/manager6/dc/Config.js | 8 +
www/manager6/form/MicrosegGroupSelector.js | 77 +++++
www/manager6/form/MicrosegGuestSelector.js | 83 +++++
www/manager6/sdn/MicrosegView.js | 143 ++++++++
www/manager6/sdn/SdnDiffView.js | 24 ++
www/manager6/sdn/microseg/AssignmentEdit.js | 245 ++++++++++++++
www/manager6/sdn/microseg/Base.js | 137 ++++++++
www/manager6/sdn/microseg/GroupEdit.js | 45 +++
www/manager6/sdn/microseg/PolicyView.js | 181 ++++++++++
www/manager6/sdn/microseg/RuleEdit.js | 104 ++++++
www/manager6/sdn/microseg/Tree.js | 356 ++++++++++++++++++++
www/manager6/sdn/zones/EvpnEdit.js | 8 +
www/manager6/sdn/zones/VxlanEdit.js | 11 +
16 files changed, 1469 insertions(+)
create mode 100644 www/manager6/form/MicrosegGroupSelector.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
create mode 100644 www/manager6/sdn/microseg/Tree.js
pve-docs:
Hannes Laimer (2):
sdn: add microsegmentation section
sdn: add VXLAN-GBP flag to evpn/vxlan zone sections
pvesdn.adoc | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
Summary over all repositories:
64 files changed, 8268 insertions(+), 1 deletions(-)
--
Generated by murpp 0.12.0
^ permalink raw reply [flat|nested] 28+ messages in thread
* [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types Hannes Laimer
` (25 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Each interface carries a set of groups, but the wire has room for a
single 16-bit id. The engine partitions the realized group sets into
equivalence classes that no rule can tell apart and numbers those
classes, so the id space scales with policy-distinguishable sets rather
than every set combination, and all hosts agree on what a number means
because the assignment is rendered once and replicated.
Ids must stay stable while the config changes around them: a number on
the wire has no transactional boundary (it is stamped on one host and
looked up on another), so rebinding it to an unrelated class could give
an in-flight packet the wrong verdict. Allocation is therefore
fresh-first from a counter, splits mint new numbers, merges retire the
losing number, and a retired number only becomes reusable after a
quarantine long enough that no packet stamped under its old meaning can
still exist. Reuse starts only once the fresh space is exhausted, so
exhaustion requires more than 65534 classes live or freshly retired at
the same time, not merely a long-lived, frequently edited cluster.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
.../src/sdn/microseg/identity.rs | 624 ++++++++++++++++++
proxmox-ve-config/src/sdn/microseg/mod.rs | 9 +
proxmox-ve-config/src/sdn/mod.rs | 1 +
3 files changed, 634 insertions(+)
create mode 100644 proxmox-ve-config/src/sdn/microseg/identity.rs
create mode 100644 proxmox-ve-config/src/sdn/microseg/mod.rs
diff --git a/proxmox-ve-config/src/sdn/microseg/identity.rs b/proxmox-ve-config/src/sdn/microseg/identity.rs
new file mode 100644
index 0000000..ab700ae
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/microseg/identity.rs
@@ -0,0 +1,624 @@
+//! Signature-based identity allocation for microseg.
+//!
+//! An *identity* is not one group-set but a class of group-sets that no policy can tell apart. Two
+//! sets share an id when every policy's src and dst predicate fires the same way on both. The class
+//! carries an *id* (stamped on the wire) and a *representative*, the smallest member set in sort
+//! order, so a later split or merge stays deterministic and ids stay stable.
+//!
+//! Allocation is **fresh-first**. Ids come from a counter that only increases, so a number is never
+//! reused while unissued space remains. A number on the wire has no transactional boundary, so
+//! rebinding it to an unrelated class could give an in-flight packet the wrong verdict. Splits
+//! issue fresh ids. Merges retire the fused-away id into a time-based quarantine
+//! ([`RETIRE_QUARANTINE_SECS`]). Exhaustion needs more than 65534 classes live or freshly retired
+//! at once.
+//!
+//! The registry is the only stored state, holding the id assignment that cannot be recomputed. The
+//! partition is always re-derived from the current marks and policies.
+
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+
+use serde::{Deserialize, Serialize};
+
+/// The canonical group-set assigned to an interface, as sorted, deduped base-group marks.
+/// `BTreeSet`'s `Ord` compares the sorted elements in order, which is exactly the representative
+/// order.
+pub type GroupSet = BTreeSet<u16>;
+
+/// Reserved id for unstamped traffic. A frame that arrives without a tag (a gateway, DHCP/ARP, a
+/// cross-node frame with no GBP tag) reads as this id. Never issued to a class, so a real group set
+/// (even one no policy matches) never collides with it and never inherits its `untagged` allows.
+/// Its representative in the rule fold is the built-in `untagged` group.
+pub const UNTAGGED_ID: u16 = 0;
+
+/// How long a retired id stays quarantined before it may be re-issued, in seconds.
+///
+/// Reusing a number is only safe once no packet stamped under its old meaning can still exist. The
+/// generous margin also covers nodes that apply a render late. A node cut off from pmxcfs for
+/// longer than this while still passing traffic can stamp stale ids into a reused number. That is
+/// accepted, a cluster in that state is broken in worse ways.
+pub const RETIRE_QUARANTINE_SECS: u64 = 60 * 60;
+
+/// How a predicate decides whether it fires on an interface's group-set.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PredicateKind {
+ /// Fires when the set shares at least one group with the predicate (intersection non-empty).
+ Any,
+ /// Fires when the set contains every group of the predicate (predicate is a subset of the set).
+ All,
+ /// Fires only when the set equals the predicate exactly.
+ Exact,
+}
+
+/// A predicate over base groups, the src or dst half of a policy. An empty `groups` is degenerate
+/// (`Any` fires on nothing, `All` fires on everything) and is rejected by config validation, not
+/// here.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Predicate {
+ pub kind: PredicateKind,
+ pub groups: GroupSet,
+}
+
+impl Predicate {
+ pub fn new(kind: PredicateKind, groups: impl IntoIterator<Item = u16>) -> Self {
+ Self {
+ kind,
+ groups: groups.into_iter().collect(),
+ }
+ }
+
+ /// Whether this predicate fires on `set`.
+ pub fn fires(&self, set: &GroupSet) -> bool {
+ match self.kind {
+ PredicateKind::Any => self.groups.intersection(set).next().is_some(),
+ PredicateKind::All => self.groups.is_subset(set),
+ PredicateKind::Exact => &self.groups == set,
+ }
+ }
+}
+
+/// A policy adds two bits to every set's signature, whether the set is a valid *source* (`src`
+/// fires) and whether it is a valid *destination* (`dst` fires). The allow/deny verdict is
+/// downstream of identity and lives in the fold, not here (matchability is not the verdict).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Policy {
+ pub src: Predicate,
+ pub dst: Predicate,
+}
+
+/// How a set fires against each policy in order, as `[p0.src, p0.dst, p1.src, ...]`. Only equality
+/// matters, equal signatures are interchangeable in the rule map. The policy order must be fixed
+/// for signatures within one partition to be comparable, so callers pass a canonical order.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct Signature(Vec<bool>);
+
+impl Signature {
+ /// Whether no policy's src or dst predicate fires. Such sets look the same to every policy and
+ /// share a single class, but that class still gets its own id, distinct from the [`UNTAGGED_ID`]
+ /// of unstamped traffic.
+ pub fn is_all_false(&self) -> bool {
+ self.0.iter().all(|fired| !fired)
+ }
+}
+
+/// Compute a set's signature against `policies` (canonical order).
+pub fn signature(set: &GroupSet, policies: &[Policy]) -> Signature {
+ let mut bits = Vec::with_capacity(policies.len() * 2);
+ for policy in policies {
+ bits.push(policy.src.fires(set));
+ bits.push(policy.dst.fires(set));
+ }
+ Signature(bits)
+}
+
+/// Each live id pinned to its representative set, plus the id counter. Only the non-recomputable
+/// part, the id assignment, is kept. The partition is derived from the policies on demand.
+/// Serialized into the running config so every host reads the same id-to-class naming.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Registry {
+ next: u16,
+ /// id to representative set (lex-min member of the class). Serialized with string keys (see
+ /// [`u16_key_serde`]) so the Perl/JSON round trip the running config takes does not choke on
+ /// integer map keys.
+ #[serde(with = "u16_key_serde")]
+ classes: BTreeMap<u16, GroupSet>,
+ /// Recently retired id to unix time of retirement. A number in here is inside the reclamation
+ /// quarantine, no longer live, but packets stamped under its old meaning may still exist. So it
+ /// must not be re-issued yet. Entries older than [`RETIRE_QUARANTINE_SECS`] are dropped by the
+ /// next [`allocate`], turning the number into plain free space.
+ #[serde(
+ default,
+ with = "u16_key_serde",
+ skip_serializing_if = "BTreeMap::is_empty"
+ )]
+ retired: BTreeMap<u16, u64>,
+}
+
+/// Serialize a `u16`-keyed map with string keys. JSON forces object keys to strings, and perlmod
+/// (the running config's Perl round trip) does not parse a string key back into a `u16`, so we
+/// stringify on the way out and parse on the way in. The values are plain numbers and survive the
+/// round trip as-is. serde_json reads either form, so this stays compatible with an existing
+/// running config.
+mod u16_key_serde {
+ use std::collections::BTreeMap;
+
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+ pub fn serialize<S: Serializer, V: Serialize>(
+ map: &BTreeMap<u16, V>,
+ serializer: S,
+ ) -> Result<S::Ok, S::Error> {
+ let stringified: BTreeMap<String, &V> = map
+ .iter()
+ .map(|(id, value)| (id.to_string(), value))
+ .collect();
+ stringified.serialize(serializer)
+ }
+
+ pub fn deserialize<'de, D: Deserializer<'de>, V: Deserialize<'de>>(
+ deserializer: D,
+ ) -> Result<BTreeMap<u16, V>, D::Error> {
+ BTreeMap::<String, V>::deserialize(deserializer)?
+ .into_iter()
+ .map(|(id, value)| {
+ id.parse::<u16>()
+ .map(|id| (id, value))
+ .map_err(serde::de::Error::custom)
+ })
+ .collect()
+ }
+}
+
+impl Default for Registry {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Registry {
+ /// An empty registry. The first issued id is 1 (0 is the untagged id).
+ pub fn new() -> Self {
+ Self {
+ next: 1,
+ classes: BTreeMap::new(),
+ retired: BTreeMap::new(),
+ }
+ }
+
+ /// Reconstruct a registry from its persisted parts (e.g. the running-config).
+ pub fn from_parts(
+ next: u16,
+ classes: BTreeMap<u16, GroupSet>,
+ retired: BTreeMap<u16, u64>,
+ ) -> Self {
+ Self {
+ next,
+ classes,
+ retired,
+ }
+ }
+
+ pub fn next(&self) -> u16 {
+ self.next
+ }
+
+ /// id to representative set for every live (non-retired) class.
+ pub fn classes(&self) -> &BTreeMap<u16, GroupSet> {
+ &self.classes
+ }
+
+ /// Recently retired id to retirement time, still inside the reclamation quarantine.
+ pub fn retired(&self) -> &BTreeMap<u16, u64> {
+ &self.retired
+ }
+
+ /// The id of the class a set belongs to, by signature match. Every realized set has a class,
+ /// including one no policy matches (it shares the all-false class), so this resolves them all.
+ /// Returns `None` only for a set no current class carries, a not-yet-rendered set on a config
+ /// whose render has not landed, so the caller fails closed.
+ pub fn id_of(&self, set: &GroupSet, policies: &[Policy]) -> Option<u16> {
+ let sig = signature(set, policies);
+ self.classes
+ .iter()
+ .find(|(_, rep)| signature(rep, policies) == sig)
+ .map(|(&id, _)| id)
+ }
+}
+
+/// Recompute the registry from the realized sets and policies, fresh-first against `prev`.
+///
+/// Every realized set is partitioned by signature, including the sets no policy matches. They share
+/// the all-false signature and so collapse into a single class with its own id, kept distinct from
+/// the [`UNTAGGED_ID`] of unstamped traffic so an unmatched set never inherits `untagged` allows.
+/// Each class keeps the id of the previous class whose representative still lands in it
+/// (split-stable), and a merge keeps the lex-min representative's id and retires the rest.
+/// Brand-new classes issue from the counter while fresh space remains. Once it is exhausted they
+/// reuse the lowest number that is neither live nor still inside the retirement quarantine (see
+/// [`RETIRE_QUARANTINE_SECS`]). `now` is the render time (unix seconds), the clock the quarantine
+/// runs on. This must be fed the *previous* registry and never a fresh one (that would renumber the
+/// whole cluster).
+pub fn allocate(
+ prev: &Registry,
+ sets: &BTreeSet<GroupSet>,
+ policies: &[Policy],
+ now: u64,
+) -> anyhow::Result<Registry> {
+ let mut by_sig: HashMap<Signature, BTreeSet<GroupSet>> = HashMap::new();
+ for set in sets {
+ by_sig
+ .entry(signature(set, policies))
+ .or_default()
+ .insert(set.clone());
+ }
+
+ // Which previous ids still land in each live class, re-evaluating their representative under
+ // the *current* policies. A prev class whose signature no longer has any realized set is simply
+ // not added, it retires into the quarantine below.
+ let mut claims: HashMap<Signature, Vec<(u16, GroupSet)>> = HashMap::new();
+ for (&id, rep) in &prev.classes {
+ let sig = signature(rep, policies);
+ if by_sig.contains_key(&sig) {
+ claims.entry(sig).or_default().push((id, rep.clone()));
+ }
+ }
+
+ // assign ids in a deterministic order (by representative) so issuing is reproducible
+ let mut order: Vec<(&Signature, &BTreeSet<GroupSet>)> = by_sig.iter().collect();
+ order.sort_by(|a, b| representative(a.1).cmp(representative(b.1)));
+
+ // Every previously live id, plus the previously retired ids whose quarantine has not passed
+ // yet, is reserved. Entries whose quarantine ended are dropped here, turning their numbers into
+ // plain free space
+ let mut retired: BTreeMap<u16, u64> = prev
+ .retired
+ .iter()
+ .filter(|&(_, &at)| now.saturating_sub(at) < RETIRE_QUARANTINE_SECS)
+ .map(|(&id, &at)| (id, at))
+ .collect();
+ let mut reserved: BTreeSet<u16> = prev.classes.keys().copied().collect();
+ reserved.extend(retired.keys().copied());
+
+ let mut next = prev.next;
+ let mut classes = BTreeMap::new();
+ for (sig, members) in order {
+ let rep = representative(members).clone();
+ let id = match claims.get(sig) {
+ // existing class (possibly a merge), keep the claimant with the lex-min representative
+ // and retire the others by not carrying them
+ Some(claimants) => claimants
+ .iter()
+ .min_by(|a, b| a.1.cmp(&b.1))
+ .map(|(id, _)| *id)
+ .expect("a claims entry is non-empty"),
+ // new class, issue fresh from the counter, falling back to reclamation once the fresh
+ // space is exhausted.
+ None => {
+ if next == UNTAGGED_ID {
+ reclaim(&reserved, &classes)?
+ } else {
+ let id = next;
+ next = next.wrapping_add(1); // u16::MAX wraps to 0, fresh space exhausted
+ id
+ }
+ }
+ };
+ classes.insert(id, rep);
+ }
+
+ // everything previously live that was not carried retires now and starts its quarantine
+ for &id in prev.classes.keys() {
+ if !classes.contains_key(&id) {
+ retired.insert(id, now);
+ }
+ }
+
+ Ok(Registry {
+ next,
+ classes,
+ retired,
+ })
+}
+
+/// The lowest reusable number, not the untagged id, not reserved (previously live or still
+/// quarantined), not already assigned in this pass. Only called once the fresh space is exhausted,
+/// so anything else is a retired number whose quarantine has ended.
+fn reclaim(reserved: &BTreeSet<u16>, taken: &BTreeMap<u16, GroupSet>) -> anyhow::Result<u16> {
+ (1..=u16::MAX)
+ .find(|id| !reserved.contains(id) && !taken.contains_key(id))
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "microseg identity id space exhausted \
+ (all 65535 ids are live or recently retired)"
+ )
+ })
+}
+
+/// The lex-min member of a class. `BTreeSet<GroupSet>` iterates its sets in `Ord` (lexicographic)
+/// order, so the first is the representative.
+fn representative(members: &BTreeSet<GroupSet>) -> &GroupSet {
+ members
+ .iter()
+ .next()
+ .expect("a signature class has at least one member")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::PredicateKind::*;
+ use super::*;
+
+ fn set(xs: &[u16]) -> GroupSet {
+ xs.iter().copied().collect()
+ }
+
+ fn pred(kind: PredicateKind, xs: &[u16]) -> Predicate {
+ Predicate::new(kind, xs.iter().copied())
+ }
+
+ fn policy(src: Predicate, dst: Predicate) -> Policy {
+ Policy { src, dst }
+ }
+
+ fn sets(xs: &[&[u16]]) -> BTreeSet<GroupSet> {
+ xs.iter().map(|s| set(s)).collect()
+ }
+
+ #[test]
+ fn fires_semantics() {
+ let s = set(&[2, 3, 4]);
+
+ assert!(pred(Any, &[2]).fires(&s));
+ assert!(pred(Any, &[4, 9]).fires(&s));
+ assert!(!pred(Any, &[9]).fires(&s));
+ assert!(!pred(Any, &[]).fires(&s));
+
+ assert!(pred(All, &[2, 3]).fires(&s));
+ assert!(pred(All, &[2, 3, 4]).fires(&s));
+ assert!(!pred(All, &[2, 9]).fires(&s));
+ assert!(pred(All, &[]).fires(&s));
+
+ assert!(pred(Exact, &[2, 3, 4]).fires(&s));
+ assert!(!pred(Exact, &[2, 3]).fires(&s));
+ assert!(!pred(Exact, &[0]).fires(&set(&[])));
+ assert!(pred(Exact, &[0]).fires(&set(&[0])));
+ }
+
+ #[test]
+ fn equal_signatures_collapse_distinct_signatures_split() {
+ // Only ANY{2} distinguishes, so {2,3} and {2,4} look the same and {5} is separate.
+ let policies = [policy(pred(Any, &[2]), pred(Any, &[9]))];
+ assert_eq!(
+ signature(&set(&[2, 3]), &policies),
+ signature(&set(&[2, 4]), &policies),
+ "no predicate tells (2,3) and (2,4) apart"
+ );
+ assert_ne!(
+ signature(&set(&[2, 3]), &policies),
+ signature(&set(&[5]), &policies),
+ );
+ assert!(signature(&set(&[5]), &policies).is_all_false());
+ }
+
+ #[test]
+ fn fresh_allocation_issues_in_representative_order() {
+ // Two distinct classes. {2,3} sorts before {5} so it issues id 1.
+ let policies = [
+ policy(pred(Any, &[2]), pred(Any, &[9])),
+ policy(pred(Any, &[5]), pred(Any, &[9])),
+ ];
+ let reg = allocate(&Registry::new(), &sets(&[&[2, 3], &[5]]), &policies, 0).unwrap();
+
+ assert_eq!(reg.id_of(&set(&[2, 3]), &policies), Some(1));
+ assert_eq!(reg.id_of(&set(&[5]), &policies), Some(2));
+ assert_eq!(reg.next(), 3);
+ // {7} matches no predicate, here no all-false set was realized, so there is no class for it
+ // and it resolves to nothing
+ assert_eq!(reg.id_of(&set(&[7]), &policies), None);
+ // {9} has a real signature (it matches a dst predicate) but was never realized as a class,
+ // so it resolves to nothing
+ assert_eq!(reg.id_of(&set(&[9]), &policies), None);
+ }
+
+ #[test]
+ fn unmatched_set_gets_its_own_id_not_the_untagged_id() {
+ // A rule references group 2 only. A NIC assigned the unreferenced group 5 has an all-false
+ // signature. It must resolve to its own real id, not the untagged id, so it does not
+ // inherit the allows that an untagged source carries.
+ let policies = [policy(pred(Any, &[0]), pred(Any, &[2]))]; // untagged -> web
+ let reg = allocate(&Registry::new(), &sets(&[&[5]]), &policies, 0).unwrap();
+
+ let id = reg
+ .id_of(&set(&[5]), &policies)
+ .expect("an unmatched realized set still has a class");
+ assert_ne!(
+ id, UNTAGGED_ID,
+ "an unmatched set must not alias onto the untagged id"
+ );
+
+ // two distinct unmatched sets share the one all-false class
+ let reg = allocate(&Registry::new(), &sets(&[&[5], &[6]]), &policies, 0).unwrap();
+ assert_eq!(
+ reg.classes().len(),
+ 1,
+ "all unmatched sets collapse to one class"
+ );
+ assert_eq!(
+ reg.id_of(&set(&[5]), &policies),
+ reg.id_of(&set(&[6]), &policies)
+ );
+
+ // the untagged source still fires the untagged rule, the unmatched set does not
+ assert!(pred(Any, &[0]).fires(&set(&[0])));
+ assert!(!pred(Any, &[0]).fires(&set(&[5])));
+ }
+
+ #[test]
+ fn split_keeps_old_rep_subclass_id_and_issues_the_other() {
+ // Round 1: {2,3} and {2,4} share a class (only ANY{2} distinguishes), rep = {2,3}, id 1.
+ let p1 = [policy(pred(Any, &[2]), pred(Any, &[9]))];
+ let r1 = allocate(&Registry::new(), &sets(&[&[2, 3], &[2, 4]]), &p1, 0).unwrap();
+ assert_eq!(r1.id_of(&set(&[2, 3]), &p1), Some(1));
+ assert_eq!(r1.id_of(&set(&[2, 4]), &p1), Some(1));
+
+ // Round 2: add EXACT{2,3}, which fires only on {2,3}, so the class splits.
+ let p2 = [
+ policy(pred(Any, &[2]), pred(Any, &[9])),
+ policy(pred(Exact, &[2, 3]), pred(Any, &[9])),
+ ];
+ let r2 = allocate(&r1, &sets(&[&[2, 3], &[2, 4]]), &p2, 0).unwrap();
+
+ // {2,3} carries the old representative, so it keeps id 1, and {2,4} issues a fresh id 2.
+ assert_eq!(
+ r2.id_of(&set(&[2, 3]), &p2),
+ Some(1),
+ "old-rep subclass keeps the id"
+ );
+ assert_eq!(
+ r2.id_of(&set(&[2, 4]), &p2),
+ Some(2),
+ "the other subclass issues fresh"
+ );
+ assert_eq!(r2.next(), 3);
+ }
+
+ #[test]
+ fn merge_retires_the_fused_id_and_prefers_fresh() {
+ // Start split: {2,3}=id1, {2,4}=id2 (EXACT{2,3} distinguishes them).
+ let p_split = [
+ policy(pred(Any, &[2]), pred(Any, &[9])),
+ policy(pred(Exact, &[2, 3]), pred(Any, &[9])),
+ ];
+ let r_split = allocate(&Registry::new(), &sets(&[&[2, 3], &[2, 4]]), &p_split, 0).unwrap();
+ assert_eq!(r_split.id_of(&set(&[2, 3]), &p_split), Some(1));
+ assert_eq!(r_split.id_of(&set(&[2, 4]), &p_split), Some(2));
+ assert_eq!(r_split.next(), 3);
+
+ // Drop the distinguisher, so {2,3} and {2,4} merge. The lex-min rep ({2,3}, id 1) survives
+ // and id 2 retires, gone from the live classes, quarantined, and next stays 3, so it is not
+ // reissued while fresh space remains.
+ let p_merge = [policy(pred(Any, &[2]), pred(Any, &[9]))];
+ let r_merge = allocate(&r_split, &sets(&[&[2, 3], &[2, 4]]), &p_merge, 0).unwrap();
+ assert_eq!(r_merge.id_of(&set(&[2, 3]), &p_merge), Some(1));
+ assert_eq!(
+ r_merge.id_of(&set(&[2, 4]), &p_merge),
+ Some(1),
+ "merged into the survivor"
+ );
+ assert!(!r_merge.classes().contains_key(&2), "id 2 is retired");
+ assert_eq!(r_merge.retired().get(&2), Some(&0), "id 2 is quarantined");
+ assert_eq!(r_merge.next(), 3, "the counter never rewinds");
+
+ // A genuinely new class (here {9}, matched as a destination) issues id 3, not the retired 2.
+ let r_next = allocate(&r_merge, &sets(&[&[2, 3], &[2, 4], &[9]]), &p_merge, 0).unwrap();
+ assert_eq!(
+ r_next.id_of(&set(&[9]), &p_merge),
+ Some(3),
+ "new class skips the tombstone"
+ );
+ }
+
+ #[test]
+ fn stable_ids_across_an_unrelated_edit() {
+ // Adding a class must not disturb the id of an existing, unaffected one.
+ let policies = [
+ policy(pred(Any, &[2]), pred(Any, &[9])),
+ policy(pred(Any, &[5]), pred(Any, &[9])),
+ ];
+ let r1 = allocate(&Registry::new(), &sets(&[&[2, 3]]), &policies, 0).unwrap();
+ let id_23 = r1.id_of(&set(&[2, 3]), &policies);
+ let r2 = allocate(&r1, &sets(&[&[2, 3], &[5]]), &policies, 0).unwrap();
+ assert_eq!(
+ r2.id_of(&set(&[2, 3]), &policies),
+ id_23,
+ "untouched class keeps its id"
+ );
+ }
+
+ #[test]
+ fn registry_round_trips_through_string_keyed_json() {
+ // mimics the running config the Perl side reads back, where object keys are strings. A
+ // registry without a retired key (an older running config) reads as an empty quarantine.
+ let reg: Registry = serde_json::from_str(r#"{"next":2,"classes":{"1":[1,2]}}"#)
+ .expect("deserialize string-keyed classes");
+ assert_eq!(reg.next(), 2);
+ assert_eq!(reg.classes().get(&1), Some(&set(&[1, 2])));
+ assert!(reg.retired().is_empty());
+
+ let out = serde_json::to_string(®).expect("serialize");
+ assert!(
+ out.contains("\"classes\":{\"1\":"),
+ "classes uses string keys: {out}"
+ );
+ let back: Registry = serde_json::from_str(&out).expect("re-deserialize");
+ assert_eq!(back, reg);
+
+ // the quarantine round-trips with string keys as well
+ let reg: Registry =
+ serde_json::from_str(r#"{"next":3,"classes":{"1":[1,2]},"retired":{"2":1234}}"#)
+ .expect("deserialize string-keyed retired");
+ assert_eq!(reg.retired().get(&2), Some(&1234));
+ let out = serde_json::to_string(®).expect("serialize");
+ assert!(
+ out.contains("\"retired\":{\"2\":1234}"),
+ "retired uses string keys: {out}"
+ );
+ }
+
+ /// A registry whose fresh space is spent: every id except `free_id` is live, `retired_id` is
+ /// quarantined since `retired_at`, and `free_id` is neither. `next` is 0 (fresh exhausted).
+ fn exhausted_registry(retired_id: u16, retired_at: u64, free_id: u16) -> Registry {
+ let classes: BTreeMap<u16, GroupSet> = (1..=u16::MAX)
+ .filter(|&id| id != retired_id && id != free_id)
+ .map(|id| (id, set(&[id])))
+ .collect();
+ Registry::from_parts(0, classes, BTreeMap::from([(retired_id, retired_at)]))
+ }
+
+ #[test]
+ fn reclaims_a_retired_id_only_after_its_quarantine() {
+ // {2,5} fires both src predicates, no singleton does, so no previous class claims it and
+ // it needs to issue with no fresh space left.
+ let policies = [
+ policy(pred(Any, &[2]), pred(Any, &[9])),
+ policy(pred(Any, &[5]), pred(Any, &[9])),
+ ];
+ let retired_at = 1_000;
+ // 7 is retired, 11 is plain free space (never live, out of every bookkeeping)
+ let prev = exhausted_registry(7, retired_at, 11);
+
+ // one number is free outright, so the first issued id takes it regardless of the quarantine
+ let reg = allocate(&prev, &sets(&[&[2, 5]]), &policies, retired_at + 1).unwrap();
+ assert_eq!(reg.id_of(&set(&[2, 5]), &policies), Some(11));
+
+ // with 11 also live, the only candidate is the quarantined 7, and inside the quarantine
+ // issuing must fail rather than rebind a number packets may still carry
+ let mut prev = exhausted_registry(7, retired_at, 11);
+ prev.classes.insert(11, set(&[11]));
+ let before = retired_at + RETIRE_QUARANTINE_SECS - 1;
+ let err = allocate(&prev, &sets(&[&[2, 5]]), &policies, before).unwrap_err();
+ assert!(err.to_string().contains("exhausted"), "got: {err:#}");
+
+ // once the quarantine has passed, 7 is reclaimed and leaves the bookkeeping
+ let after = retired_at + RETIRE_QUARANTINE_SECS;
+ let reg = allocate(&prev, &sets(&[&[2, 5]]), &policies, after).unwrap();
+ assert_eq!(reg.id_of(&set(&[2, 5]), &policies), Some(7));
+ assert!(!reg.retired().contains_key(&7));
+ }
+
+ #[test]
+ fn prefers_fresh_ids_over_reclaimable_ones() {
+ // id 3 was retired ages ago, yet it issues the fresh 5, since reuse is a last resort, and
+ // the long-expired tombstone drops out of the quarantine bookkeeping.
+ let policies = [policy(pred(Any, &[2]), pred(Any, &[9]))];
+ let prev = Registry::from_parts(5, BTreeMap::new(), BTreeMap::from([(3, 0)]));
+ let reg = allocate(
+ &prev,
+ &sets(&[&[2]]),
+ &policies,
+ RETIRE_QUARANTINE_SECS * 10,
+ )
+ .unwrap();
+ assert_eq!(reg.id_of(&set(&[2]), &policies), Some(5));
+ assert_eq!(reg.next(), 6);
+ assert!(reg.retired().is_empty());
+ }
+}
diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
new file mode 100644
index 0000000..5217cb5
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -0,0 +1,9 @@
+//! Microseg subsystem of the SDN config.
+//!
+//! Microseg is an identity-based stateless firewall. Each managed guest NIC is assigned a *set* of
+//! numeric *groups*. A per-host agent compiles those sets into wire identities (see [`identity`])
+//! and admin-defined policy *rules* into a `(src_id, dst_id) -> allow|deny` map. A rule matches
+//! traffic by a `src` and a `dst` *predicate*, each a match kind (`any`/`all`/`exact`) over a list
+//! of groups.
+
+pub mod identity;
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] 28+ messages in thread
* [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Hannes Laimer
` (24 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
The microseg config as Rust types, shared between the cluster-side
render and the host agent so both sides interpret the same model:
groups with a numeric mark, rules as a src/dst predicate pair
(any/all/exact over groups) with a priority and verdict, and
assignments binding a guest NIC to a set of groups. Group sets are
additive across assignments. Resolution is highest-priority-wins,
conflicting ties deny, no match denies.
Rendering validates the whole config, expands assignments into concrete
per-NIC group sets, allocates wire identities against the previous
registry and stores all of it in the running config, which is the only
thing the agent reads. Rule and assignment ids are derived from their
content, so identical objects collapse and re-creating one is
idempotent.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ve-config/src/sdn/config.rs | 9 +-
proxmox-ve-config/src/sdn/microseg/mod.rs | 1599 +++++++++++++++++++++
2 files changed, 1607 insertions(+), 1 deletion(-)
diff --git a/proxmox-ve-config/src/sdn/config.rs b/proxmox-ve-config/src/sdn/config.rs
index 2f30cf2..fa6f376 100644
--- a/proxmox-ve-config/src/sdn/config.rs
+++ b/proxmox-ve-config/src/sdn/config.rs
@@ -16,7 +16,7 @@ use crate::{
Ipset,
ipset::{IpsetEntry, IpsetName, IpsetScope},
},
- sdn::{SdnNameError, SubnetName, VnetName, ZoneName},
+ sdn::{SdnNameError, SubnetName, VnetName, ZoneName, microseg::MicrosegRunningConfig},
};
#[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/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index 5217cb5..555effa 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -6,4 +6,1603 @@
//! traffic by a `src` and a `dst` *predicate*, each a match kind (`any`/`all`/`exact`) over a list
//! of groups.
+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
+
+use anyhow::{anyhow, bail};
+use const_format::concatcp;
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::{ApiStringFormat, UpdaterType, api, api_string_type, const_regex};
+
pub mod identity;
+
+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()]
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+/// How a rule predicate matches an interface's group set.
+pub enum PredicateMatch {
+ /// Fires when the interface shares at least one of the listed groups (intersection non-empty).
+ #[default]
+ Any,
+ /// Fires when the interface has all of the listed groups (the list is a subset of its groups).
+ All,
+ /// Fires only when the interface's groups equal the listed set exactly.
+ Exact,
+}
+
+impl From<PredicateMatch> for identity::PredicateKind {
+ fn from(value: PredicateMatch) -> Self {
+ match value {
+ PredicateMatch::Any => identity::PredicateKind::Any,
+ PredicateMatch::All => identity::PredicateKind::All,
+ PredicateMatch::Exact => identity::PredicateKind::Exact,
+ }
+ }
+}
+
+#[api(
+ properties: {
+ mark: {
+ type: Integer,
+ minimum: 1,
+ maximum: 65535,
+ description: "Numeric group mark, the building block of the wire identity (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 one element of the group set an interface
+/// is assigned. The agent maps each distinct set to a wire identity. Mark 0 is reserved.
+pub struct GroupSection {
+ pub(crate) id: MicrosegId,
+ pub(crate) mark: u16,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) comment: Option<String>,
+}
+
+impl GroupSection {
+ pub fn mark(&self) -> u16 {
+ self.mark
+ }
+ pub fn comment(&self) -> Option<&str> {
+ self.comment.as_deref()
+ }
+}
+
+#[api(
+ properties: {
+ src: {
+ type: Array,
+ items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT },
+ },
+ dst: {
+ type: Array,
+ items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT },
+ },
+ comment: {
+ type: String,
+ optional: true,
+ max_length: 256,
+ description: "Free-form comment.",
+ },
+ },
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+/// A policy rule. Traffic is permitted when `allow` is true and both predicates fire: the source
+/// interface's group set matches `(src_match, src)` and the destination's matches `(dst_match,
+/// dst)`. With no matching rule the data plane denies.
+pub struct RuleSection {
+ pub(crate) id: MicrosegId,
+ /// Groups the source predicate is evaluated against.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) src: Vec<MicrosegId>,
+ /// How the source predicate matches its groups.
+ #[serde(default)]
+ pub(crate) src_match: PredicateMatch,
+ /// Groups the destination predicate is evaluated against.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) dst: Vec<MicrosegId>,
+ /// How the destination predicate matches its groups.
+ #[serde(default)]
+ pub(crate) dst_match: PredicateMatch,
+ /// Priority. The highest-priority matching rule wins. At a tie, a deny overrides an allow.
+ #[serde(default)]
+ pub(crate) prio: u16,
+ /// Whether traffic matching both predicates is allowed.
+ #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+ pub(crate) allow: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) comment: Option<String>,
+}
+
+impl RuleSection {
+ pub fn src(&self) -> &[MicrosegId] {
+ &self.src
+ }
+ pub fn src_match(&self) -> PredicateMatch {
+ self.src_match
+ }
+ pub fn dst(&self) -> &[MicrosegId] {
+ &self.dst
+ }
+ pub fn dst_match(&self) -> PredicateMatch {
+ self.dst_match
+ }
+ pub fn prio(&self) -> u16 {
+ self.prio
+ }
+ pub fn allow(&self) -> bool {
+ self.allow
+ }
+ pub fn comment(&self) -> Option<&str> {
+ self.comment.as_deref()
+ }
+}
+
+#[api(
+ properties: {
+ vmid: {
+ type: Integer,
+ minimum: 1,
+ maximum: 999999999,
+ description: "Guest (VM or CT) id whose NIC(s) are bound.",
+ },
+ iface: {
+ type: Integer,
+ optional: true,
+ minimum: 0,
+ maximum: 31,
+ description: "The guest's netN interface. If unset, all of the guest's NICs.",
+ },
+ groups: {
+ type: Array,
+ description: "Groups the matching NICs belong to.",
+ items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT },
+ },
+ comment: {
+ type: String,
+ optional: true,
+ max_length: 256,
+ description: "Free-form comment.",
+ },
+ },
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+/// Binds a specific guest's NIC(s) to a set of groups: all of guest `vmid`'s NICs, or just the one
+/// named by `iface` when set. Stored as the `guestassignment` section type. A new matcher kind is a
+/// new sibling section type discriminated by `type`, not a new field here.
+pub struct GuestAssignmentSection {
+ pub(crate) id: MicrosegId,
+ pub(crate) vmid: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) iface: Option<u32>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) groups: Vec<MicrosegId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) comment: Option<String>,
+}
+
+#[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 guest-matcher NIC-to-groups assignment.
+ GuestAssignment(GuestAssignmentSection),
+}
+
+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::GuestAssignment(a) => &a.id,
+ }
+ }
+}
+
+/// A concrete per-NIC group assignment produced by [`render`], stored in the running config and
+/// resolved to a wire id by the agent.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RealizedAssignment {
+ pub vmid: u32,
+ pub iface: u32,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub groups: Vec<MicrosegId>,
+}
+
+/// The guests/NICs a single assignment expands to. Unlike [`RealizedAssignment`] this is not merged
+/// across assignments and carries no groups: it answers "which NICs does *this* matcher cover".
+#[derive(Clone, Debug, Serialize)]
+pub struct AssignmentMember {
+ pub vmid: u32,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub nics: Vec<u32>,
+}
+
+/// Deserialize a `Vec<u32>` whose elements arrive as perl's untyped (often string) scalars, by
+/// running each through the [`deserialize_u32`](proxmox_serde::perl::deserialize_u32) helper.
+fn deserialize_perl_nics<'de, D>(deserializer: D) -> Result<Vec<u32>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ #[derive(Deserialize)]
+ struct Nic(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] u32);
+ Ok(Vec::<Nic>::deserialize(deserializer)?
+ .into_iter()
+ .map(|Nic(index)| index)
+ .collect())
+}
+
+/// One guest in the inventory that [`render`] matches assignments against. Gathered cluster-side
+/// (the only layer with guest data) and passed in, so the engine needs no cluster access.
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct GuestInfo {
+ #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub vmid: u32,
+ /// The present netN interface indices. A matcher with no `iface` covers all of them.
+ #[serde(default, deserialize_with = "deserialize_perl_nics")]
+ pub nics: Vec<u32>,
+}
+
+/// 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>,
+ /// The compiled identity registry (id to representative set), keeping ids stable across renders.
+ #[serde(default)]
+ identities: identity::Registry,
+ /// Concrete per-NIC assignments after expanding selectors against the guest inventory and
+ /// merging with the static assignments.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ realized: Vec<RealizedAssignment>,
+}
+
+impl MicrosegRunningConfig {
+ pub fn ids(&self) -> &HashMap<String, MicrosegEntry> {
+ &self.ids
+ }
+
+ pub fn identities(&self) -> &identity::Registry {
+ &self.identities
+ }
+
+ 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,
+ })
+ }
+
+ /// The rendered concrete per-NIC assignments (static plus selector-expanded) the agent enforces.
+ pub fn realized(&self) -> &[RealizedAssignment] {
+ &self.realized
+ }
+}
+
+/// The reserved name of the built-in *untagged* group (mark 0), the no-group identity. A rule
+/// predicate may name it to match unstamped traffic (gateway / DHCP / ARP replies, cross-node
+/// frames without a GBP tag), and it composes with real groups (e.g. `any{untagged, web}`). It is
+/// not a storable group and cannot be used in an assignment.
+pub const UNTAGGED_GROUP: &str = "untagged";
+
+/// The mark of the [`UNTAGGED_GROUP`], the same number as the untagged wire id.
+pub const UNTAGGED_MARK: u16 = 0;
+
+/// Validation of a microseg config. Group marks are unique and the reserved `untagged` name is not
+/// taken by a real group. Every group named by a rule predicate exists or is the built-in
+/// `untagged`. Every assignment group exists and is not `untagged`. Predicates and assignments name
+/// at least one group. Exact-duplicate rules and assignments cannot arise, because their ids are
+/// derived from their contents, so they collapse to one entry.
+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 {
+ if name == UNTAGGED_GROUP {
+ bail!("'{UNTAGGED_GROUP}' is a reserved group name");
+ }
+ 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
+ );
+ }
+ }
+ }
+
+ let check_group = |kind: &str, name: &str, group: &str| -> Result<(), anyhow::Error> {
+ if !group_names.contains(group) {
+ bail!("{kind} '{name}' references unknown group '{group}'");
+ }
+ Ok(())
+ };
+
+ let check_assignment_groups =
+ |name: &str, groups: &[MicrosegId]| -> Result<(), anyhow::Error> {
+ if groups.is_empty() {
+ bail!("assignment '{name}' is not assigned to any group");
+ }
+ for group in groups {
+ if group.as_ref() == UNTAGGED_GROUP {
+ bail!("assignment '{name}' cannot use the reserved '{UNTAGGED_GROUP}' group");
+ }
+ check_group("assignment", name, group)?;
+ }
+ Ok(())
+ };
+
+ for (name, entry) in entries {
+ match entry {
+ MicrosegEntry::Rule(rule) => {
+ if rule.src.is_empty() {
+ bail!("rule '{name}' has an empty source predicate");
+ }
+ if rule.dst.is_empty() {
+ bail!("rule '{name}' has an empty destination predicate");
+ }
+ for group in rule.src.iter().chain(&rule.dst) {
+ if group.as_ref() == UNTAGGED_GROUP {
+ continue;
+ }
+ check_group("rule", name, group)?;
+ }
+ }
+ MicrosegEntry::GuestAssignment(assignment) => {
+ check_assignment_groups(name, &assignment.groups)?;
+ }
+ MicrosegEntry::Group(_) => {}
+ }
+ }
+
+ Ok(())
+}
+
+/// Map each group name to its numeric mark, including the built-in `untagged` group (mark 0).
+fn group_marks(entries: &HashMap<String, MicrosegEntry>) -> HashMap<&str, u16> {
+ let mut marks: HashMap<&str, u16> = entries
+ .iter()
+ .filter_map(|(name, entry)| match entry {
+ MicrosegEntry::Group(group) => Some((name.as_str(), group.mark)),
+ _ => None,
+ })
+ .collect();
+ marks.insert(UNTAGGED_GROUP, UNTAGGED_MARK);
+ marks
+}
+
+/// Build an engine predicate from a config predicate, resolving group names to marks.
+fn predicate(
+ name_marks: &HashMap<&str, u16>,
+ match_kind: PredicateMatch,
+ groups: &[MicrosegId],
+) -> anyhow::Result<identity::Predicate> {
+ let marks = groups
+ .iter()
+ .map(|group| {
+ name_marks
+ .get(group.as_ref())
+ .copied()
+ .ok_or_else(|| anyhow!("references unknown group '{group}'"))
+ })
+ .collect::<anyhow::Result<Vec<u16>>>()?;
+ Ok(identity::Predicate::new(match_kind.into(), marks))
+}
+
+/// The policy list in canonical order (sorted by rule id), predicates resolved to group marks. The
+/// agent must rebuild this in the same order, so the signatures it computes match the rendered
+/// ones.
+pub fn build_policies(
+ entries: &HashMap<String, MicrosegEntry>,
+) -> anyhow::Result<Vec<identity::Policy>> {
+ let name_marks = group_marks(entries);
+ let mut rules: Vec<(&str, &RuleSection)> = entries
+ .iter()
+ .filter_map(|(id, entry)| match entry {
+ MicrosegEntry::Rule(rule) => Some((id.as_str(), rule)),
+ _ => None,
+ })
+ .collect();
+ rules.sort_by(|a, b| a.0.cmp(b.0));
+ rules
+ .into_iter()
+ .map(|(_, rule)| {
+ Ok(identity::Policy {
+ src: predicate(&name_marks, rule.src_match, &rule.src)?,
+ dst: predicate(&name_marks, rule.dst_match, &rule.dst)?,
+ })
+ })
+ .collect()
+}
+
+fn covered_nics(iface: Option<u32>, nics: &[u32]) -> Vec<u32> {
+ match iface {
+ Some(iface) => nics.iter().copied().filter(|&nic| nic == iface).collect(),
+ None => nics.to_vec(),
+ }
+}
+
+/// Resolve every assignment into the concrete per-NIC group assignments, unioning the groups of
+/// every matcher that names the same NIC. A matcher expands against `inventory` to the guests it
+/// covers, binding its `iface` or, when unset, all of a guest's present NICs. Sorted by (vmid,
+/// iface) for a stable running config.
+pub fn realized_assignments(
+ entries: &HashMap<String, MicrosegEntry>,
+ inventory: &[GuestInfo],
+) -> Vec<RealizedAssignment> {
+ let mut merged: BTreeMap<(u32, u32), BTreeSet<MicrosegId>> = BTreeMap::new();
+ // vmid -> present NICs, so a guest matcher with no iface can cover all of the guest's NICs.
+ let nics_of: HashMap<u32, &[u32]> = inventory
+ .iter()
+ .map(|guest| (guest.vmid, guest.nics.as_slice()))
+ .collect();
+
+ for entry in entries.values() {
+ match entry {
+ MicrosegEntry::GuestAssignment(assignment) => {
+ let nics = nics_of.get(&assignment.vmid).copied().unwrap_or_default();
+ for nic in covered_nics(assignment.iface, nics) {
+ merged
+ .entry((assignment.vmid, nic))
+ .or_default()
+ .extend(assignment.groups.iter().cloned());
+ }
+ }
+ MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {}
+ }
+ }
+
+ merged
+ .into_iter()
+ .map(|((vmid, iface), groups)| RealizedAssignment {
+ vmid,
+ iface,
+ groups: groups.into_iter().collect(),
+ })
+ .collect()
+}
+
+/// Expand each assignment *independently* against `inventory` into the guests/NICs it covers, keyed
+/// by assignment id. Unlike [`realized_assignments`] nothing is merged. Each assignment yields its
+/// own members, one per guest it matches.
+pub fn realized_per_assignment(
+ entries: &HashMap<String, MicrosegEntry>,
+ inventory: &[GuestInfo],
+) -> HashMap<String, Vec<AssignmentMember>> {
+ let nics_of: HashMap<u32, &[u32]> = inventory
+ .iter()
+ .map(|guest| (guest.vmid, guest.nics.as_slice()))
+ .collect();
+
+ entries
+ .iter()
+ .filter_map(|(id, entry)| {
+ let members = match entry {
+ MicrosegEntry::GuestAssignment(assignment) => {
+ let nics = covered_nics(
+ assignment.iface,
+ nics_of.get(&assignment.vmid).copied().unwrap_or_default(),
+ );
+ if nics.is_empty() {
+ Vec::new()
+ } else {
+ vec![AssignmentMember {
+ vmid: assignment.vmid,
+ nics,
+ }]
+ }
+ }
+ MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {
+ return None;
+ }
+ };
+ Some((id.clone(), members))
+ })
+ .collect()
+}
+
+/// A realized identity class, a distinct group-set carried by some realized NIC, paired with the
+/// rules that fire on it. `outbound` where it is a valid source, `inbound` where a valid
+/// destination. Matchability is the engine's own [`identity::Predicate::fires`], so this stays the
+/// single source of truth.
+#[derive(Debug, Clone, Serialize)]
+pub struct IdentityRules {
+ /// The class, its groups sorted.
+ pub groups: Vec<MicrosegId>,
+ /// Ids of the rules whose source predicate fires on this identity.
+ pub outbound: Vec<MicrosegId>,
+ /// Ids of the rules whose destination predicate fires on this identity.
+ pub inbound: Vec<MicrosegId>,
+}
+
+/// For every realized identity class, the rules it fires as source and as destination.
+pub fn rules_by_identity(
+ entries: &HashMap<String, MicrosegEntry>,
+ inventory: &[GuestInfo],
+) -> anyhow::Result<Vec<IdentityRules>> {
+ let name_marks = group_marks(entries);
+
+ let mut rules: Vec<(&MicrosegId, identity::Predicate, identity::Predicate)> = Vec::new();
+ for entry in entries.values() {
+ if let MicrosegEntry::Rule(rule) = entry {
+ let src = predicate(&name_marks, rule.src_match, &rule.src)?;
+ let dst = predicate(&name_marks, rule.dst_match, &rule.dst)?;
+ rules.push((&rule.id, src, dst));
+ }
+ }
+ rules.sort_by(|a, b| a.0.cmp(b.0));
+
+ let mut classes: BTreeSet<BTreeSet<MicrosegId>> = BTreeSet::new();
+ for assignment in realized_assignments(entries, inventory) {
+ classes.insert(assignment.groups.into_iter().collect());
+ }
+
+ Ok(classes
+ .into_iter()
+ .map(|class| {
+ let marks: identity::GroupSet = class
+ .iter()
+ .filter_map(|group| name_marks.get(group.as_ref()).copied())
+ .collect();
+ let mut outbound = Vec::new();
+ let mut inbound = Vec::new();
+ for (id, src, dst) in &rules {
+ if src.fires(&marks) {
+ outbound.push((*id).clone());
+ }
+ if dst.fires(&marks) {
+ inbound.push((*id).clone());
+ }
+ }
+ IdentityRules {
+ groups: class.into_iter().collect(),
+ outbound,
+ inbound,
+ }
+ })
+ .collect())
+}
+
+/// The distinct group-sets (in marks) of the realized assignments, the input the identity allocator
+/// partitions.
+fn realized_group_sets(
+ entries: &HashMap<String, MicrosegEntry>,
+ realized: &[RealizedAssignment],
+) -> anyhow::Result<BTreeSet<identity::GroupSet>> {
+ let name_marks = group_marks(entries);
+ realized
+ .iter()
+ .map(|assignment| {
+ assignment
+ .groups
+ .iter()
+ .map(|group| {
+ name_marks.get(group.as_ref()).copied().ok_or_else(|| {
+ anyhow!("realized assignment references unknown group '{group}'")
+ })
+ })
+ .collect::<anyhow::Result<identity::GroupSet>>()
+ })
+ .collect()
+}
+
+/// Resolve a list of group names to their canonical mark set (sorted, deduped).
+pub fn group_set(
+ entries: &HashMap<String, MicrosegEntry>,
+ groups: &[MicrosegId],
+) -> anyhow::Result<identity::GroupSet> {
+ let name_marks = group_marks(entries);
+ groups
+ .iter()
+ .map(|group| {
+ name_marks
+ .get(group.as_ref())
+ .copied()
+ .ok_or_else(|| anyhow!("references unknown group '{group}'"))
+ })
+ .collect()
+}
+
+/// Render the running config: validate `entries`, expand selectors against `inventory` into the
+/// concrete realized assignments, then allocate wire identities fresh-first against `prev`. Must be
+/// fed the *previous* registry (from the prior running config), never a fresh one, or every host
+/// renumbers and the wire tags stop agreeing. `now` (unix seconds, the render node's clock) drives
+/// the retirement quarantine that gates id reclamation.
+pub fn render(
+ prev: &identity::Registry,
+ entries: HashMap<String, MicrosegEntry>,
+ inventory: &[GuestInfo],
+ now: u64,
+) -> anyhow::Result<MicrosegRunningConfig> {
+ validate(&entries)?;
+ let policies = build_policies(&entries)?;
+ let realized = realized_assignments(&entries, inventory);
+ let sets = realized_group_sets(&entries, &realized)?;
+ let identities = identity::allocate(prev, &sets, &policies, now)?;
+ Ok(MicrosegRunningConfig {
+ ids: entries,
+ identities,
+ realized,
+ })
+}
+
+/// A rule resolved for verdict folding: its predicates in group marks, plus its priority and
+/// verdict. Built once per apply from the running config.
+pub struct ResolvedRule {
+ pub src: identity::Predicate,
+ pub dst: identity::Predicate,
+ pub prio: u16,
+ pub allow: bool,
+}
+
+/// Resolve every rule's predicates to marks for folding.
+pub fn resolved_rules(
+ entries: &HashMap<String, MicrosegEntry>,
+) -> anyhow::Result<Vec<ResolvedRule>> {
+ let name_marks = group_marks(entries);
+ entries
+ .values()
+ .filter_map(|entry| match entry {
+ MicrosegEntry::Rule(rule) => Some(rule),
+ _ => None,
+ })
+ .map(|rule| {
+ Ok(ResolvedRule {
+ src: predicate(&name_marks, rule.src_match, &rule.src)?,
+ dst: predicate(&name_marks, rule.dst_match, &rule.dst)?,
+ prio: rule.prio,
+ allow: rule.allow,
+ })
+ })
+ .collect()
+}
+
+/// Fold the rules into the verdict for one `(src_class, dst_class)` cell, identified by the two
+/// representative sets. Among rules whose `src` predicate fires on `src_rep` and `dst` predicate
+/// fires on `dst_rep`, the highest priority wins. Verdicts tied at the top priority are ANDed, so
+/// a single deny overrides the allows. No matching rule means default-deny.
+pub fn verdict(
+ rules: &[ResolvedRule],
+ src_rep: &identity::GroupSet,
+ dst_rep: &identity::GroupSet,
+) -> bool {
+ let mut best: Option<(u16, bool)> = None;
+ for rule in rules {
+ if rule.src.fires(src_rep) && rule.dst.fires(dst_rep) {
+ best = Some(match best {
+ Some((prio, _)) if rule.prio > prio => (rule.prio, rule.allow),
+ Some((prio, allow)) if rule.prio == prio => (prio, allow && rule.allow),
+ Some(current) => current,
+ None => (rule.prio, rule.allow),
+ });
+ }
+ }
+ best.map_or(false, |(_, allow)| allow)
+}
+
+/// 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::{Error, anyhow, bail};
+ use serde::Deserialize;
+
+ use super::{
+ GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
+ PredicateMatch, RuleSection,
+ };
+
+ #[derive(Debug, Clone, Deserialize)]
+ #[serde(tag = "type", rename_all = "lowercase")]
+ pub enum MicrosegCreate {
+ Group(GroupCreate),
+ Rule(RuleCreate),
+ GuestAssignment(GuestAssignmentCreate),
+ }
+
+ #[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>,
+ }
+
+ #[derive(Debug, Clone, Deserialize)]
+ pub struct RuleCreate {
+ #[serde(default)]
+ pub src: Vec<MicrosegId>,
+ #[serde(default)]
+ pub src_match: PredicateMatch,
+ #[serde(default)]
+ pub dst: Vec<MicrosegId>,
+ #[serde(default)]
+ pub dst_match: PredicateMatch,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u16")]
+ pub prio: Option<u16>,
+ #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+ pub allow: bool,
+ #[serde(default)]
+ pub comment: Option<String>,
+ }
+
+ #[derive(Debug, Clone, Deserialize)]
+ pub struct GuestAssignmentCreate {
+ #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub vmid: u32,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub iface: Option<u32>,
+ #[serde(default)]
+ pub groups: Vec<MicrosegId>,
+ #[serde(default)]
+ pub comment: Option<String>,
+ }
+
+ #[derive(Debug, Clone, Deserialize)]
+ #[serde(tag = "type", rename_all = "lowercase")]
+ pub enum MicrosegUpdate {
+ Group(GroupUpdate),
+ Rule(RuleUpdate),
+ GuestAssignment(AssignmentUpdate),
+ }
+
+ #[derive(Debug, Clone, Default, Deserialize)]
+ pub struct GroupUpdate {
+ #[serde(default)]
+ pub comment: Option<String>,
+ #[serde(default)]
+ pub delete: Vec<GroupDeletableProperty>,
+ }
+
+ #[derive(Debug, Clone, Copy, Deserialize)]
+ #[serde(rename_all = "lowercase")]
+ pub enum GroupDeletableProperty {
+ Comment,
+ }
+
+ #[derive(Debug, Clone, Default, Deserialize)]
+ pub struct RuleUpdate {
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u16")]
+ pub prio: Option<u16>,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+ pub allow: Option<bool>,
+ #[serde(default)]
+ pub comment: Option<String>,
+ #[serde(default)]
+ pub delete: Vec<RuleDeletableProperty>,
+ }
+
+ #[derive(Debug, Clone, Copy, Deserialize)]
+ #[serde(rename_all = "lowercase")]
+ pub enum RuleDeletableProperty {
+ Comment,
+ }
+
+ #[derive(Debug, Clone, Default, Deserialize)]
+ pub struct AssignmentUpdate {
+ #[serde(default)]
+ pub groups: Option<Vec<MicrosegId>>,
+ #[serde(default)]
+ pub comment: Option<String>,
+ #[serde(default)]
+ pub delete: Vec<AssignmentDeletableProperty>,
+ }
+
+ #[derive(Debug, Clone, Copy, Deserialize)]
+ #[serde(rename_all = "lowercase")]
+ pub enum AssignmentDeletableProperty {
+ Comment,
+ }
+
+ /// 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))
+ }
+
+ /// Resolve group names to their marks, sorted and deduped (the canonical set).
+ fn marks_of(
+ names: &[MicrosegId],
+ entries: &HashMap<String, MicrosegEntry>,
+ ) -> Result<Vec<u16>, Error> {
+ let mut marks: Vec<u16> = names
+ .iter()
+ .map(|name| {
+ if name.as_ref() == super::UNTAGGED_GROUP {
+ return Ok(super::UNTAGGED_MARK);
+ }
+ match entries.get(name.as_ref()) {
+ Some(MicrosegEntry::Group(group)) => Ok(group.mark),
+ _ => Err(anyhow!("references group '{name}' which does not exist")),
+ }
+ })
+ .collect::<Result<_, _>>()?;
+ marks.sort_unstable();
+ marks.dedup();
+ Ok(marks)
+ }
+
+ /// A stable, content-derived rule id (FNV-1a over the canonical predicate pair), so identical
+ /// rules collapse to one entry and re-creating one is idempotent.
+ fn rule_id(
+ src_match: PredicateMatch,
+ src_marks: &[u16],
+ dst_match: PredicateMatch,
+ dst_marks: &[u16],
+ ) -> Result<MicrosegId, Error> {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut feed = |byte: u8| {
+ hash ^= byte as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ };
+ feed(src_match as u8);
+ for mark in src_marks {
+ feed((mark >> 8) as u8);
+ feed(*mark as u8);
+ }
+ feed(0xff);
+ feed(dst_match as u8);
+ for mark in dst_marks {
+ feed((mark >> 8) as u8);
+ feed(*mark as u8);
+ }
+ format!("r{hash:016x}").parse().map_err(Error::from)
+ }
+
+ /// Build an entry from a create payload, auto-assigning a free group mark when none is given and
+ /// deriving rule and assignment ids from their contents.
+ 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,
+ })
+ }
+ MicrosegCreate::Rule(rule) => {
+ let src_marks = marks_of(&rule.src, entries).map_err(|e| anyhow!("rule {e}"))?;
+ let dst_marks = marks_of(&rule.dst, entries).map_err(|e| anyhow!("rule {e}"))?;
+ let id = rule_id(rule.src_match, &src_marks, rule.dst_match, &dst_marks)?;
+ MicrosegEntry::Rule(RuleSection {
+ id,
+ src: rule.src,
+ src_match: rule.src_match,
+ dst: rule.dst,
+ dst_match: rule.dst_match,
+ prio: rule.prio.unwrap_or(0),
+ allow: rule.allow,
+ comment: rule.comment,
+ })
+ }
+ MicrosegCreate::GuestAssignment(assignment) => {
+ let id = match assignment.iface {
+ Some(iface) => format!("vm{}i{}", assignment.vmid, iface).parse()?,
+ None => format!("vm{}", assignment.vmid).parse()?,
+ };
+ MicrosegEntry::GuestAssignment(GuestAssignmentSection {
+ id,
+ vmid: assignment.vmid,
+ iface: assignment.iface,
+ groups: assignment.groups,
+ comment: assignment.comment,
+ })
+ }
+ })
+ }
+
+ /// 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;
+ }
+ for property in update.delete {
+ match property {
+ GroupDeletableProperty::Comment => group.comment = None,
+ }
+ }
+ }
+ (MicrosegEntry::Rule(rule), MicrosegUpdate::Rule(update)) => {
+ if let Some(prio) = update.prio {
+ rule.prio = prio;
+ }
+ if let Some(allow) = update.allow {
+ rule.allow = allow;
+ }
+ if update.comment.is_some() {
+ rule.comment = update.comment;
+ }
+ for property in update.delete {
+ match property {
+ RuleDeletableProperty::Comment => rule.comment = None,
+ }
+ }
+ }
+ (
+ MicrosegEntry::GuestAssignment(assignment),
+ MicrosegUpdate::GuestAssignment(update),
+ ) => {
+ if let Some(groups) = update.groups {
+ assignment.groups = groups;
+ }
+ if update.comment.is_some() {
+ assignment.comment = update.comment;
+ }
+ for property in update.delete {
+ match property {
+ AssignmentDeletableProperty::Comment => assignment.comment = None,
+ }
+ }
+ }
+ _ => bail!("update type does not match the existing object's type"),
+ }
+ Ok(())
+ }
+
+ /// Format a predicate (a match kind over a group list) as `any{web, db}`, for diagnostics.
+ fn predicate_str(match_kind: PredicateMatch, groups: &[MicrosegId]) -> String {
+ let kind = match match_kind {
+ PredicateMatch::Any => "any",
+ PredicateMatch::All => "all",
+ PredicateMatch::Exact => "exact",
+ };
+ let list = groups
+ .iter()
+ .map(|g| g.as_ref())
+ .collect::<Vec<_>>()
+ .join(", ");
+ format!("{kind}{{{list}}}")
+ }
+
+ /// A human-readable description of an entry, for diagnostics where its content-derived id is
+ /// meaningless. The object's comment, when set, is appended.
+ fn describe_referrer(entry: &MicrosegEntry) -> String {
+ let (desc, comment) = match entry {
+ MicrosegEntry::Rule(rule) => (
+ format!(
+ "rule {} -> {}",
+ predicate_str(rule.src_match, &rule.src),
+ predicate_str(rule.dst_match, &rule.dst),
+ ),
+ rule.comment.as_deref(),
+ ),
+ MicrosegEntry::GuestAssignment(assignment) => (
+ match assignment.iface {
+ Some(iface) => format!("guest assignment vm{} net{iface}", assignment.vmid),
+ None => format!("guest assignment vm{}", assignment.vmid),
+ },
+ assignment.comment.as_deref(),
+ ),
+ MicrosegEntry::Group(_) => ("object".to_string(), None),
+ };
+ match comment {
+ Some(comment) => format!("{desc} (comment: \"{comment}\")"),
+ None => desc,
+ }
+ }
+
+ /// Human-readable descriptions of every rule or assignment that references group `name`, sorted
+ /// for a stable message, since the referrers' content-derived ids would be meaningless to a user.
+ pub fn group_referenced_by(
+ entries: &HashMap<String, MicrosegEntry>,
+ name: &str,
+ ) -> Vec<String> {
+ let mut referrers: Vec<String> = entries
+ .values()
+ .filter(|entry| match entry {
+ MicrosegEntry::Rule(rule) => {
+ rule.src.iter().any(|g| g.as_ref() == name)
+ || rule.dst.iter().any(|g| g.as_ref() == name)
+ }
+ MicrosegEntry::GuestAssignment(assignment) => {
+ assignment.groups.iter().any(|g| g.as_ref() == name)
+ }
+ MicrosegEntry::Group(_) => false,
+ })
+ .map(describe_referrer)
+ .collect();
+ referrers.sort();
+ referrers
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData};
+
+ use super::*;
+
+ fn id(s: &str) -> MicrosegId {
+ s.parse().expect("valid microseg id")
+ }
+
+ fn ids(names: &[&str]) -> Vec<MicrosegId> {
+ names.iter().map(|n| id(n)).collect()
+ }
+
+ fn group(name: &str, mark: u16) -> (String, MicrosegEntry) {
+ (
+ name.to_string(),
+ MicrosegEntry::Group(GroupSection {
+ id: id(name),
+ mark,
+ comment: None,
+ }),
+ )
+ }
+
+ fn rule(name: &str, src: &[&str], dst: &[&str], allow: bool) -> (String, MicrosegEntry) {
+ (
+ name.to_string(),
+ MicrosegEntry::Rule(RuleSection {
+ id: id(name),
+ src: ids(src),
+ src_match: PredicateMatch::Any,
+ dst: ids(dst),
+ dst_match: PredicateMatch::Any,
+ prio: 0,
+ allow,
+ comment: None,
+ }),
+ )
+ }
+
+ #[test]
+ fn rules_by_identity_honours_match_kinds() {
+ use PredicateMatch::{All, Any, Exact};
+ let mk = |name: &str, sm, src: &[&str], dm, dst: &[&str]| {
+ (
+ name.to_string(),
+ MicrosegEntry::Rule(RuleSection {
+ id: id(name),
+ src: ids(src),
+ src_match: sm,
+ dst: ids(dst),
+ dst_match: dm,
+ prio: 0,
+ allow: true,
+ comment: None,
+ }),
+ )
+ };
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ group("mon", 4),
+ mk("r_any", Any, &["web"], Any, &["db"]),
+ mk("r_all", All, &["web", "db"], Any, &["mon"]),
+ mk("r_exact", Exact, &["web"], Any, &["db"]),
+ assignment("vm100i0", 100, 0, &["web"]),
+ assignment("vm200i0", 200, 0, &["web", "db"]),
+ ]);
+
+ let inv = vec![guest(100, &[], &[0]), guest(200, &[], &[0])];
+ let by_class = rules_by_identity(&entries, &inv).expect("rules_by_identity");
+ let names = |v: &[MicrosegId]| {
+ let mut s: Vec<String> = v.iter().map(|i| i.to_string()).collect();
+ s.sort();
+ s
+ };
+ let find = |groups: &[&str]| {
+ let want: BTreeSet<MicrosegId> = ids(groups).into_iter().collect();
+ by_class
+ .iter()
+ .find(|c| c.groups.iter().cloned().collect::<BTreeSet<_>>() == want)
+ .unwrap_or_else(|| panic!("no class {groups:?}"))
+ };
+
+ // {web}: only the any and exact source predicates fire, nothing names it as a destination.
+ let web = find(&["web"]);
+ assert_eq!(names(&web.outbound), vec!["r_any", "r_exact"]);
+ assert!(web.inbound.is_empty());
+
+ // {web,db}: the all predicate now fires as source, db-destinations fire as inbound.
+ let webdb = find(&["web", "db"]);
+ assert_eq!(names(&webdb.outbound), vec!["r_all", "r_any"]);
+ assert_eq!(names(&webdb.inbound), vec!["r_any", "r_exact"]);
+ }
+
+ #[test]
+ fn section_config_round_trip() {
+ // Build entries, render to the section-config text, parse it back, and assert equality.
+ // This exercises the array (src/dst/groups) and enum (match) serialization without pinning
+ // the exact on-disk syntax.
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 5),
+ group("db", 7),
+ (
+ "r1".to_string(),
+ MicrosegEntry::Rule(RuleSection {
+ id: id("r1"),
+ src: ids(&["web"]),
+ src_match: PredicateMatch::Any,
+ dst: ids(&["db"]),
+ dst_match: PredicateMatch::Exact,
+ prio: 5,
+ allow: true,
+ comment: None,
+ }),
+ ),
+ (
+ "vm100i0".to_string(),
+ MicrosegEntry::GuestAssignment(GuestAssignmentSection {
+ id: id("vm100i0"),
+ vmid: 100,
+ iface: Some(0),
+ groups: ids(&["web", "db"]),
+ comment: None,
+ }),
+ ),
+ ]);
+
+ validate(&entries).expect("config is valid");
+
+ let data: SectionConfigData<MicrosegEntry> = SectionConfigData::from_iter(entries.clone());
+ let raw = MicrosegEntry::write_section_config("microseg.cfg", &data).expect("write");
+ let parsed: HashMap<String, MicrosegEntry> =
+ MicrosegEntry::parse_section_config("microseg.cfg", &raw)
+ .expect("parse")
+ .into_iter()
+ .collect();
+
+ assert_eq!(parsed, entries, "round trip preserves every entry");
+ }
+
+ #[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","src":["web"],"dst":["db"],"allow":1},
+ "r2":{"type":"rule","id":"r2","src":["web"],"dst":["db"],"src_match":"all","allow":0},
+ "r3":{"type":"rule","id":"r3","src":["web"],"dst":["db"],"allow":true},
+ "web":{"type":"group","id":"web","mark":5},
+ "db":{"type":"group","id":"db","mark":7}
+ }}"#;
+ let cfg: MicrosegRunningConfig = serde_json::from_str(json).expect("parse running config");
+ let by_id: HashMap<&str, &RuleSection> = cfg.rules().collect();
+ assert!(by_id["r1"].allow());
+ assert!(!by_id["r2"].allow());
+ assert!(by_id["r3"].allow());
+ // src_match defaults to `any` when absent, and parses the explicit form
+ assert_eq!(by_id["r1"].src_match(), PredicateMatch::Any);
+ assert_eq!(by_id["r2"].src_match(), PredicateMatch::All);
+ }
+
+ #[test]
+ fn create_accepts_perl_string_scalars() {
+ // perlmod hands scalars to serde as strings, so create payloads must accept "0"/"1" for the
+ // allow flag and string forms of numeric fields.
+ let rule: api::MicrosegCreate = serde_json::from_str(
+ r#"{"type":"rule","src":["web"],"src_match":"exact","dst":["db"],"allow":"0"}"#,
+ )
+ .expect("rule create");
+ match rule {
+ api::MicrosegCreate::Rule(rule) => {
+ assert!(!rule.allow);
+ assert_eq!(rule.src_match, PredicateMatch::Exact);
+ }
+ other => panic!("expected rule, got {other:?}"),
+ }
+
+ let assignment: api::MicrosegCreate = serde_json::from_str(
+ r#"{"type":"guestassignment","vmid":"100","iface":"0","groups":["web","db"]}"#,
+ )
+ .expect("assignment create");
+ match assignment {
+ api::MicrosegCreate::GuestAssignment(assignment) => {
+ assert_eq!(assignment.vmid, 100);
+ assert_eq!(assignment.groups.len(), 2);
+ }
+ other => panic!("expected guest assignment, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn validate_rejects_unknown_groups_empty_predicates_and_dup_marks() {
+ let base: HashMap<String, MicrosegEntry> = HashMap::from([group("web", 1), group("db", 2)]);
+
+ let mut ok = base.clone();
+ ok.extend([rule("ok", &["web"], &["db"], true)]);
+ validate(&ok).expect("valid rule");
+
+ let mut unknown = base.clone();
+ unknown.extend([rule("bad", &["web"], &["ghost"], true)]);
+ assert!(validate(&unknown).is_err(), "unknown dst group must fail");
+
+ let mut empty_src = base.clone();
+ empty_src.extend([rule("nosrc", &[], &["db"], true)]);
+ assert!(
+ validate(&empty_src).is_err(),
+ "empty source predicate must fail"
+ );
+
+ let dup_mark: HashMap<String, MicrosegEntry> =
+ HashMap::from([group("ga", 1), group("gb", 1)]);
+ assert!(validate(&dup_mark).is_err(), "duplicate mark must fail");
+ }
+
+ #[test]
+ fn build_entry_derives_stable_ids() {
+ let entries: HashMap<String, MicrosegEntry> =
+ HashMap::from([group("web", 11), group("db", 22)]);
+
+ let make_rule = || {
+ api::build_entry(
+ api::MicrosegCreate::Rule(api::RuleCreate {
+ src: ids(&["web"]),
+ src_match: PredicateMatch::Any,
+ dst: ids(&["db"]),
+ dst_match: PredicateMatch::Exact,
+ prio: None,
+ allow: true,
+ comment: None,
+ }),
+ &entries,
+ )
+ .expect("build rule")
+ };
+ let r1 = make_rule();
+ let r2 = make_rule();
+ assert!(r1.id().as_ref().starts_with('r'));
+ assert_eq!(r1.id(), r2.id(), "same predicate pair derives the same id");
+
+ // a different predicate derives a different id
+ let other = api::build_entry(
+ api::MicrosegCreate::Rule(api::RuleCreate {
+ src: ids(&["db"]),
+ src_match: PredicateMatch::Any,
+ dst: ids(&["web"]),
+ dst_match: PredicateMatch::Exact,
+ prio: None,
+ allow: true,
+ comment: None,
+ }),
+ &entries,
+ )
+ .expect("build other rule");
+ assert_ne!(r1.id(), other.id());
+
+ let assignment = api::build_entry(
+ api::MicrosegCreate::GuestAssignment(api::GuestAssignmentCreate {
+ vmid: 100,
+ iface: Some(0),
+ groups: ids(&["web"]),
+ comment: None,
+ }),
+ &entries,
+ )
+ .expect("build assignment");
+ assert_eq!(assignment.id().to_string(), "vm100i0");
+ }
+
+ fn assignment(id_str: &str, vmid: u32, iface: u32, groups: &[&str]) -> (String, MicrosegEntry) {
+ (
+ id_str.to_string(),
+ MicrosegEntry::GuestAssignment(GuestAssignmentSection {
+ id: id(id_str),
+ vmid,
+ iface: Some(iface),
+ groups: ids(groups),
+ comment: None,
+ }),
+ )
+ }
+
+ fn guest(vmid: u32, _tag_names: &[&str], nics: &[u32]) -> GuestInfo {
+ GuestInfo {
+ vmid,
+ nics: nics.to_vec(),
+ }
+ }
+
+ #[test]
+ fn render_allocates_identities_and_round_trips() {
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ rule("r1", &["web"], &["db"], true),
+ assignment("vm100i0", 100, 0, &["web"]),
+ assignment("vm200i0", 200, 0, &["db"]),
+ ]);
+
+ let inv = vec![guest(100, &[], &[0]), guest(200, &[], &[0])];
+ let running = render(&identity::Registry::new(), entries, &inv, 0).expect("render");
+ // {2} (web) matches the src predicate, {3} (db) the dst predicate -> two classes.
+ assert_eq!(running.identities().classes().len(), 2);
+ assert_eq!(running.identities().next(), 3);
+
+ // the running config (entries + registry) survives a JSON round trip
+ let json = serde_json::to_string(&running).expect("to json");
+ let back: MicrosegRunningConfig = serde_json::from_str(&json).expect("from json");
+ assert_eq!(back, running);
+ }
+
+ #[test]
+ fn unmatched_assignment_is_denied_not_treated_as_untagged() {
+ use PredicateMatch::Any;
+ // An allow for untagged -> web, and a NIC assigned only to `iso`, a group no rule names.
+ // The iso set matches no policy, it must not alias onto the untagged identity and inherit
+ // that allow.
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("iso", 5),
+ rule_full("ru", &["untagged"], Any, &["web"], Any, 0, true),
+ assignment("vm100i0", 100, 0, &["iso"]),
+ ]);
+ let inv = vec![guest(100, &[], &[0])];
+ let running = render(&identity::Registry::new(), entries.clone(), &inv, 0).expect("render");
+ let policies = build_policies(&entries).expect("policies");
+
+ // the iso NIC resolves to a real id, not the untagged id that carries the untagged allows
+ let iso_id = running
+ .identities()
+ .id_of(&marks(&[5]), &policies)
+ .expect("the unmatched iso set still has a class");
+ assert_ne!(iso_id, identity::UNTAGGED_ID);
+
+ // iso is denied to web, while genuinely untagged traffic keeps its allow
+ let rules = resolved_rules(&entries).expect("resolve");
+ assert!(
+ !verdict(&rules, &marks(&[5]), &marks(&[2])),
+ "iso must not reach web"
+ );
+ assert!(
+ verdict(&rules, &marks(&[0]), &marks(&[2])),
+ "untagged still reaches web"
+ );
+ }
+
+ #[test]
+ fn render_is_add_only_across_reruns() {
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ rule("r1", &["web"], &["db"], true),
+ assignment("vm100i0", 100, 0, &["web"]),
+ ]);
+ let inv = vec![guest(100, &[], &[0])];
+ let first = render(&identity::Registry::new(), entries, &inv, 0).expect("first render");
+ let second =
+ render(first.identities(), first.ids().clone(), &inv, 0).expect("second render");
+ assert_eq!(
+ second.identities(),
+ first.identities(),
+ "re-render keeps ids stable"
+ );
+ }
+
+ #[test]
+ fn guest_assignment_to_absent_nic_covers_nothing() {
+ let entries = HashMap::from([group("web", 2), assignment("vm100i3", 100, 3, &["web"])]);
+ // an explicit binding to net3 realizes nothing when the guest has only net0, so the
+ // realized set only ever names NICs that exist
+ let inv = vec![guest(100, &[], &[0])];
+ assert!(realized_assignments(&entries, &inv).is_empty());
+ assert!(realized_per_assignment(&entries, &inv)["vm100i3"].is_empty());
+ }
+
+ fn marks(xs: &[u16]) -> identity::GroupSet {
+ xs.iter().copied().collect()
+ }
+
+ fn rule_full(
+ name: &str,
+ src: &[&str],
+ src_match: PredicateMatch,
+ dst: &[&str],
+ dst_match: PredicateMatch,
+ prio: u16,
+ allow: bool,
+ ) -> (String, MicrosegEntry) {
+ (
+ name.to_string(),
+ MicrosegEntry::Rule(RuleSection {
+ id: id(name),
+ src: ids(src),
+ src_match,
+ dst: ids(dst),
+ dst_match,
+ prio,
+ allow,
+ comment: None,
+ }),
+ )
+ }
+
+ #[test]
+ fn fold_priority_overrides_and_ties_deny() {
+ use PredicateMatch::{Any, Exact};
+ // A broad deny over web/app -> db at prio 0, and a specific allow web -> db at prio 10.
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ group("app", 4),
+ rule_full("broad", &["web", "app"], Any, &["db"], Any, 0, false),
+ rule_full("spec", &["web"], Exact, &["db"], Any, 10, true),
+ ]);
+ let rules = resolved_rules(&entries).expect("resolve");
+
+ // web -> db: both fire, the prio-10 allow wins over the prio-0 deny.
+ assert!(verdict(&rules, &marks(&[2]), &marks(&[3])));
+ // app -> db: only the broad deny fires.
+ assert!(!verdict(&rules, &marks(&[4]), &marks(&[3])));
+ // web -> web: no rule fires -> default deny.
+ assert!(!verdict(&rules, &marks(&[2]), &marks(&[2])));
+
+ // A deny overrides an allow at the same priority: two rules both fire on a web+app interface
+ // going to db at the same priority, one allow one deny.
+ let tied: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ group("app", 4),
+ rule_full("ra", &["web"], Any, &["db"], Any, 5, true),
+ rule_full("rb", &["app"], Any, &["db"], Any, 5, false),
+ ]);
+ let tied_rules = resolved_rules(&tied).expect("resolve");
+ assert!(
+ !verdict(&tied_rules, &marks(&[2, 4]), &marks(&[3])),
+ "allow and deny tied at the top priority deny"
+ );
+ // an interface in only web, to db: only the allow fires.
+ assert!(verdict(&tied_rules, &marks(&[2]), &marks(&[3])));
+ }
+
+ #[test]
+ fn untagged_group_matches_the_untagged_id_and_composes() {
+ use PredicateMatch::Any;
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ group("app", 4),
+ // any{untagged, web} -> db : the untagged source and web both reach db
+ rule_full("ru", &["untagged", "web"], Any, &["db"], Any, 0, true),
+ ]);
+ validate(&entries).expect("a predicate naming 'untagged' is valid");
+ let rules = resolved_rules(&entries).expect("resolve");
+
+ // the untagged identity is the set containing the reserved mark 0
+ assert!(
+ verdict(&rules, &marks(&[0]), &marks(&[3])),
+ "untagged source reaches db"
+ );
+ assert!(
+ verdict(&rules, &marks(&[2]), &marks(&[3])),
+ "web reaches db (same 'any')"
+ );
+ assert!(
+ !verdict(&rules, &marks(&[4]), &marks(&[3])),
+ "app is not in the source predicate"
+ );
+ }
+
+ #[test]
+ fn exact_untagged_matches_only_the_no_group_identity() {
+ use PredicateMatch::{Any, Exact};
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ rule_full("ru", &["untagged"], Exact, &["web"], Any, 0, true),
+ ]);
+ let rules = resolved_rules(&entries).expect("resolve");
+ assert!(
+ verdict(&rules, &marks(&[0]), &marks(&[2])),
+ "untagged -> web allowed"
+ );
+ assert!(
+ !verdict(&rules, &marks(&[2]), &marks(&[2])),
+ "web -> web denied"
+ );
+ assert!(
+ !verdict(&rules, &marks(&[0, 2]), &marks(&[2])),
+ "exact untagged is strict"
+ );
+ }
+
+ #[test]
+ fn validate_reserves_the_untagged_group() {
+ // a real group cannot take the reserved name
+ let reserved: HashMap<String, MicrosegEntry> = HashMap::from([group("untagged", 5)]);
+ assert!(validate(&reserved).is_err(), "'untagged' is reserved");
+
+ // an assignment cannot use the untagged group
+ let bad_assign: HashMap<String, MicrosegEntry> =
+ HashMap::from([group("web", 2), assignment("vm1i0", 1, 0, &["untagged"])]);
+ assert!(
+ validate(&bad_assign).is_err(),
+ "assignments cannot use 'untagged'"
+ );
+
+ // a rule predicate may name it
+ let ok: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ rule_full(
+ "ru",
+ &["untagged"],
+ PredicateMatch::Any,
+ &["web"],
+ PredicateMatch::Any,
+ 0,
+ true,
+ ),
+ ]);
+ validate(&ok).expect("a rule may name 'untagged'");
+ }
+
+ #[test]
+ fn guest_info_reads_perl_string_scalar_nics() {
+ // perlmod hands array elements as untyped (string) scalars, the nics deserializer must read
+ // those, and real JSON numbers, into a Vec<u32>.
+ let from_strings: GuestInfo =
+ serde_json::from_str(r#"{"vmid":"100","tags":["db"],"nics":["0","1","2"]}"#)
+ .expect("parse string-scalar guest info");
+ assert_eq!(from_strings.vmid, 100);
+ assert_eq!(from_strings.nics, vec![0, 1, 2]);
+
+ let from_numbers: GuestInfo =
+ serde_json::from_str(r#"{"vmid":100,"nics":[3,4]}"#).expect("parse numeric guest info");
+ assert_eq!(from_numbers.nics, vec![3, 4]);
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
` (23 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
A second NIC-to-group assignment matcher next to the guest matcher:
bind every guest matching a set of tags (by any/all/exact) to a set of
groups. Expands against the live guest inventory, so a guest tag change
surfaces as a pending change. Its id is derived from the tags, match
kind and optional iface so identical matchers collapse. An empty tag
matcher is rejected on validation.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ve-config/src/sdn/microseg/mod.rs | 467 +++++++++++++++++++++-
1 file changed, 458 insertions(+), 9 deletions(-)
diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index 555effa..fc7d0e2 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -31,6 +31,22 @@ api_string_type! {
pub struct MicrosegId(String);
}
+pub const MICROSEG_TAG_REGEX_STR: &str = r"[a-zA-Z0-9_][a-zA-Z0-9_+.\-]*";
+
+const_regex! {
+ pub MICROSEG_TAG_REGEX = concatcp!(r"^", MICROSEG_TAG_REGEX_STR, r"$");
+}
+
+pub const MICROSEG_TAG_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&MICROSEG_TAG_REGEX);
+
+api_string_type! {
+ /// A guest tag a [`TagAssignmentSection`] matches on. Matched case-sensitively against the
+ /// guest's configured tags.
+ #[api(format: &MICROSEG_TAG_FORMAT)]
+ #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, UpdaterType)]
+ pub struct Tag(String);
+}
+
#[api()]
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@@ -191,7 +207,7 @@ impl RuleSection {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// Binds a specific guest's NIC(s) to a set of groups: all of guest `vmid`'s NICs, or just the one
/// named by `iface` when set. Stored as the `guestassignment` section type. A new matcher kind is a
-/// new sibling section type discriminated by `type`, not a new field here.
+/// new sibling section type (see [`TagAssignmentSection`]), not a new field here.
pub struct GuestAssignmentSection {
pub(crate) id: MicrosegId,
pub(crate) vmid: u32,
@@ -203,6 +219,51 @@ pub struct GuestAssignmentSection {
pub(crate) comment: Option<String>,
}
+#[api(
+ properties: {
+ tags: {
+ type: Array,
+ description: "Guest tags to match on.",
+ items: { type: String, description: "A guest tag.", format: &MICROSEG_TAG_FORMAT },
+ },
+ iface: {
+ type: Integer,
+ optional: true,
+ minimum: 0,
+ maximum: 31,
+ description: "Restrict to the guest's netN interface. If unset, all NICs of each matching guest.",
+ },
+ groups: {
+ type: Array,
+ description: "Groups the matching NICs belong to.",
+ items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT },
+ },
+ comment: {
+ type: String,
+ optional: true,
+ max_length: 256,
+ description: "Free-form comment.",
+ },
+ },
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+/// Binds the NICs of every guest matching `tags` (by `tag_match`) to a set of groups. Expanded
+/// against the live guest inventory at render time, so a guest tag change surfaces as a pending SDN
+/// change and takes effect on the next apply. Stored as the `tagassignment` section type.
+pub struct TagAssignmentSection {
+ pub(crate) id: MicrosegId,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) tags: Vec<Tag>,
+ #[serde(default)]
+ pub(crate) tag_match: PredicateMatch,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) iface: Option<u32>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) groups: Vec<MicrosegId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) comment: Option<String>,
+}
+
#[api(
"id-property": "id",
"id-schema": {
@@ -222,6 +283,8 @@ pub enum MicrosegEntry {
Rule(RuleSection),
/// A guest-matcher NIC-to-groups assignment.
GuestAssignment(GuestAssignmentSection),
+ /// A tag-matcher NIC-to-groups assignment.
+ TagAssignment(TagAssignmentSection),
}
impl MicrosegEntry {
@@ -231,6 +294,7 @@ impl MicrosegEntry {
Self::Group(g) => &g.id,
Self::Rule(r) => &r.id,
Self::GuestAssignment(a) => &a.id,
+ Self::TagAssignment(a) => &a.id,
}
}
}
@@ -274,6 +338,8 @@ where
pub struct GuestInfo {
#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
pub vmid: u32,
+ #[serde(default)]
+ pub tags: Vec<Tag>,
/// The present netN interface indices. A matcher with no `iface` covers all of them.
#[serde(default, deserialize_with = "deserialize_perl_nics")]
pub nics: Vec<u32>,
@@ -395,6 +461,12 @@ pub fn validate(entries: &HashMap<String, MicrosegEntry>) -> Result<(), anyhow::
MicrosegEntry::GuestAssignment(assignment) => {
check_assignment_groups(name, &assignment.groups)?;
}
+ MicrosegEntry::TagAssignment(assignment) => {
+ if assignment.tags.is_empty() {
+ bail!("assignment '{name}' has an empty tag matcher");
+ }
+ check_assignment_groups(name, &assignment.groups)?;
+ }
MicrosegEntry::Group(_) => {}
}
}
@@ -459,6 +531,20 @@ pub fn build_policies(
.collect()
}
+/// Whether the tag set fires on `guest`, per the match kind.
+fn tags_fire(tags: &[Tag], tag_match: PredicateMatch, guest: &GuestInfo) -> bool {
+ let has = |tag: &Tag| guest.tags.iter().any(|t| t == tag);
+ match tag_match {
+ PredicateMatch::Any => tags.iter().any(|t| has(t)),
+ PredicateMatch::All => tags.iter().all(|t| has(t)),
+ PredicateMatch::Exact => {
+ let want: BTreeSet<&Tag> = tags.iter().collect();
+ let have: BTreeSet<&Tag> = guest.tags.iter().collect();
+ want == have
+ }
+ }
+}
+
fn covered_nics(iface: Option<u32>, nics: &[u32]) -> Vec<u32> {
match iface {
Some(iface) => nics.iter().copied().filter(|&nic| nic == iface).collect(),
@@ -475,6 +561,7 @@ pub fn realized_assignments(
inventory: &[GuestInfo],
) -> Vec<RealizedAssignment> {
let mut merged: BTreeMap<(u32, u32), BTreeSet<MicrosegId>> = BTreeMap::new();
+ let mut tag_matchers: Vec<&TagAssignmentSection> = Vec::new();
// vmid -> present NICs, so a guest matcher with no iface can cover all of the guest's NICs.
let nics_of: HashMap<u32, &[u32]> = inventory
.iter()
@@ -492,10 +579,25 @@ pub fn realized_assignments(
.extend(assignment.groups.iter().cloned());
}
}
+ MicrosegEntry::TagAssignment(assignment) => tag_matchers.push(assignment),
MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {}
}
}
+ for guest in inventory {
+ for assignment in &tag_matchers {
+ if !tags_fire(&assignment.tags, assignment.tag_match, guest) {
+ continue;
+ }
+ for nic in covered_nics(assignment.iface, &guest.nics) {
+ merged
+ .entry((guest.vmid, nic))
+ .or_default()
+ .extend(assignment.groups.iter().cloned());
+ }
+ }
+ }
+
merged
.into_iter()
.map(|((vmid, iface), groups)| RealizedAssignment {
@@ -536,6 +638,17 @@ pub fn realized_per_assignment(
}]
}
}
+ MicrosegEntry::TagAssignment(assignment) => inventory
+ .iter()
+ .filter(|guest| tags_fire(&assignment.tags, assignment.tag_match, guest))
+ .filter_map(|guest| {
+ let nics = covered_nics(assignment.iface, &guest.nics);
+ (!nics.is_empty()).then(|| AssignmentMember {
+ vmid: guest.vmid,
+ nics,
+ })
+ })
+ .collect(),
MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {
return None;
}
@@ -734,7 +847,7 @@ pub mod api {
use super::{
GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
- PredicateMatch, RuleSection,
+ PredicateMatch, RuleSection, Tag, TagAssignmentSection,
};
#[derive(Debug, Clone, Deserialize)]
@@ -743,6 +856,7 @@ pub mod api {
Group(GroupCreate),
Rule(RuleCreate),
GuestAssignment(GuestAssignmentCreate),
+ TagAssignment(TagAssignmentCreate),
}
#[derive(Debug, Clone, Deserialize)]
@@ -784,12 +898,27 @@ pub mod api {
pub comment: Option<String>,
}
+ #[derive(Debug, Clone, Deserialize)]
+ pub struct TagAssignmentCreate {
+ #[serde(default)]
+ pub tags: Vec<Tag>,
+ #[serde(default)]
+ pub tag_match: Option<PredicateMatch>,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub iface: Option<u32>,
+ #[serde(default)]
+ pub groups: Vec<MicrosegId>,
+ #[serde(default)]
+ pub comment: Option<String>,
+ }
+
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum MicrosegUpdate {
Group(GroupUpdate),
Rule(RuleUpdate),
GuestAssignment(AssignmentUpdate),
+ TagAssignment(AssignmentUpdate),
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -901,6 +1030,40 @@ pub mod api {
format!("r{hash:016x}").parse().map_err(Error::from)
}
+ /// A stable, content-derived id for a tag matcher (FNV-1a over its sorted tags, match kind and
+ /// optional interface), so identical tag matchers collapse and re-creating one is idempotent.
+ fn tag_assignment_id(
+ tags: &[Tag],
+ tag_match: PredicateMatch,
+ iface: Option<u32>,
+ ) -> Result<MicrosegId, Error> {
+ let mut sorted: Vec<&str> = tags.iter().map(AsRef::as_ref).collect();
+ sorted.sort_unstable();
+ sorted.dedup();
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut feed = |byte: u8| {
+ hash ^= byte as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ };
+ feed(tag_match as u8);
+ match iface {
+ Some(iface) => {
+ feed(1);
+ for byte in iface.to_be_bytes() {
+ feed(byte);
+ }
+ }
+ None => feed(0),
+ }
+ for tag in &sorted {
+ feed(0xff);
+ for byte in tag.as_bytes() {
+ feed(*byte);
+ }
+ }
+ format!("tag{hash:016x}").parse().map_err(Error::from)
+ }
+
/// Build an entry from a create payload, auto-assigning a free group mark when none is given and
/// deriving rule and assignment ids from their contents.
pub fn build_entry(
@@ -948,6 +1111,18 @@ pub mod api {
comment: assignment.comment,
})
}
+ MicrosegCreate::TagAssignment(assignment) => {
+ let tag_match = assignment.tag_match.unwrap_or_default();
+ let id = tag_assignment_id(&assignment.tags, tag_match, assignment.iface)?;
+ MicrosegEntry::TagAssignment(TagAssignmentSection {
+ id,
+ tags: assignment.tags,
+ tag_match,
+ iface: assignment.iface,
+ groups: assignment.groups,
+ comment: assignment.comment,
+ })
+ }
})
}
@@ -997,6 +1172,19 @@ pub mod api {
}
}
}
+ (MicrosegEntry::TagAssignment(assignment), MicrosegUpdate::TagAssignment(update)) => {
+ if let Some(groups) = update.groups {
+ assignment.groups = groups;
+ }
+ if update.comment.is_some() {
+ assignment.comment = update.comment;
+ }
+ for property in update.delete {
+ match property {
+ AssignmentDeletableProperty::Comment => assignment.comment = None,
+ }
+ }
+ }
_ => bail!("update type does not match the existing object's type"),
}
Ok(())
@@ -1036,6 +1224,24 @@ pub mod api {
},
assignment.comment.as_deref(),
),
+ MicrosegEntry::TagAssignment(assignment) => {
+ let tags = assignment
+ .tags
+ .iter()
+ .map(|t| t.as_ref())
+ .collect::<Vec<_>>()
+ .join(", ");
+ let kind = match assignment.tag_match {
+ PredicateMatch::Any => "any",
+ PredicateMatch::All => "all",
+ PredicateMatch::Exact => "exact",
+ };
+ let base = match assignment.iface {
+ Some(iface) => format!("tag assignment {kind}{{{tags}}} on net{iface}"),
+ None => format!("tag assignment {kind}{{{tags}}}"),
+ };
+ (base, assignment.comment.as_deref())
+ }
MicrosegEntry::Group(_) => ("object".to_string(), None),
};
match comment {
@@ -1060,6 +1266,9 @@ pub mod api {
MicrosegEntry::GuestAssignment(assignment) => {
assignment.groups.iter().any(|g| g.as_ref() == name)
}
+ MicrosegEntry::TagAssignment(assignment) => {
+ assignment.groups.iter().any(|g| g.as_ref() == name)
+ }
MicrosegEntry::Group(_) => false,
})
.map(describe_referrer)
@@ -1352,13 +1561,6 @@ mod tests {
)
}
- fn guest(vmid: u32, _tag_names: &[&str], nics: &[u32]) -> GuestInfo {
- GuestInfo {
- vmid,
- nics: nics.to_vec(),
- }
- }
-
#[test]
fn render_allocates_identities_and_round_trips() {
let entries: HashMap<String, MicrosegEntry> = HashMap::from([
@@ -1591,6 +1793,253 @@ mod tests {
validate(&ok).expect("a rule may name 'untagged'");
}
+ fn tag(s: &str) -> Tag {
+ s.parse().expect("valid tag")
+ }
+
+ fn tags(names: &[&str]) -> Vec<Tag> {
+ names.iter().map(|n| tag(n)).collect()
+ }
+
+ fn tag_assignment(
+ name: &str,
+ tag_names: &[&str],
+ tag_match: PredicateMatch,
+ iface: Option<u32>,
+ group_names: &[&str],
+ ) -> (String, MicrosegEntry) {
+ (
+ name.to_string(),
+ MicrosegEntry::TagAssignment(TagAssignmentSection {
+ id: id(name),
+ tags: tags(tag_names),
+ tag_match,
+ iface,
+ groups: ids(group_names),
+ comment: None,
+ }),
+ )
+ }
+
+ fn guest(vmid: u32, tag_names: &[&str], nics: &[u32]) -> GuestInfo {
+ GuestInfo {
+ vmid,
+ tags: tags(tag_names),
+ nics: nics.to_vec(),
+ }
+ }
+
+ fn realized_groups(realized: &[RealizedAssignment]) -> HashMap<(u32, u32), Vec<String>> {
+ realized
+ .iter()
+ .map(|r| {
+ (
+ (r.vmid, r.iface),
+ r.groups.iter().map(|g| g.to_string()).collect(),
+ )
+ })
+ .collect()
+ }
+
+ #[test]
+ fn selector_expands_to_all_nics_and_merges_with_static() {
+ use PredicateMatch::Any;
+ // tags are guest-level, so a selector with no iface covers every NIC of a matching guest.
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ group("mon", 4),
+ assignment("vm100i0", 100, 0, &["web"]),
+ tag_assignment("sel_db", &["db"], Any, None, &["db"]),
+ tag_assignment("sel_mon", &["mon"], Any, None, &["mon"]),
+ ]);
+ // vm100 carries db and mon, and has net0 + net1.
+ let inv = vec![guest(100, &["db", "mon"], &[0, 1])];
+
+ let by_nic = realized_groups(&realized_assignments(&entries, &inv));
+ // net0 unions the static web with both selectors, net1 has only the selectors.
+ assert_eq!(by_nic[&(100, 0)], vec!["db", "mon", "web"]);
+ assert_eq!(by_nic[&(100, 1)], vec!["db", "mon"]);
+ }
+
+ #[test]
+ fn selector_tag_match_any_all_exact() {
+ use PredicateMatch::{All, Any, Exact};
+ let any = HashMap::from([
+ group("gg", 2),
+ tag_assignment("sel", &["a", "b"], Any, None, &["gg"]),
+ ]);
+ let all = HashMap::from([
+ group("gg", 2),
+ tag_assignment("sel", &["a", "b"], All, None, &["gg"]),
+ ]);
+ let exact = HashMap::from([
+ group("gg", 2),
+ tag_assignment("sel", &["a", "b"], Exact, None, &["gg"]),
+ ]);
+
+ let only_a = vec![guest(7, &["a"], &[0])];
+ assert_eq!(
+ realized_assignments(&any, &only_a).len(),
+ 1,
+ "any shares a tag"
+ );
+ assert_eq!(
+ realized_assignments(&all, &only_a).len(),
+ 0,
+ "all needs every tag"
+ );
+
+ let both = vec![guest(7, &["a", "b"], &[0])];
+ assert_eq!(
+ realized_assignments(&all, &both).len(),
+ 1,
+ "all matches with both tags"
+ );
+
+ // exact needs the guest's tags to equal the listed set, no more no less
+ assert_eq!(
+ realized_assignments(&exact, &both).len(),
+ 1,
+ "exact matches the equal set"
+ );
+ let extra = vec![guest(7, &["a", "b", "c"], &[0])];
+ assert_eq!(
+ realized_assignments(&exact, &extra).len(),
+ 0,
+ "exact rejects a superset"
+ );
+ }
+
+ #[test]
+ fn selector_explicit_iface_restricts_to_one_nic() {
+ use PredicateMatch::Any;
+ let entries = HashMap::from([
+ group("web", 2),
+ tag_assignment("sel", &["t"], Any, Some(1), &["web"]),
+ ]);
+ let inv = vec![guest(5, &["t"], &[0, 1, 2])];
+ let realized = realized_assignments(&entries, &inv);
+ assert_eq!(realized.len(), 1);
+ assert_eq!((realized[0].vmid, realized[0].iface), (5, 1));
+ }
+
+ #[test]
+ fn selector_scoped_to_absent_nic_covers_nothing() {
+ use PredicateMatch::Any;
+ let entries = HashMap::from([
+ group("web", 2),
+ tag_assignment("sel", &["t"], Any, Some(3), &["web"]),
+ ]);
+ // the guest matches the tag but has no net3, so the scoped selector realizes no NIC and the
+ // guest must not surface under the assignment
+ let inv = vec![guest(5, &["t"], &[0])];
+ assert!(realized_assignments(&entries, &inv).is_empty());
+ assert!(realized_per_assignment(&entries, &inv)["sel"].is_empty());
+ }
+
+
+ #[test]
+ fn render_with_inventory_allocates_tag_identities_and_round_trips() {
+ use PredicateMatch::Any;
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ group("db", 3),
+ rule("r1", &["web"], &["db"], true),
+ tag_assignment("sel_web", &["frontend"], Any, None, &["web"]),
+ tag_assignment("sel_db", &["backend"], Any, None, &["db"]),
+ ]);
+ let inv = vec![
+ guest(100, &["frontend"], &[0]),
+ guest(200, &["backend"], &[0]),
+ ];
+
+ let running = render(&identity::Registry::new(), entries, &inv, 0).expect("render");
+ // {web} fires the src predicate, {db} the dst predicate -> two classes.
+ assert_eq!(running.identities().classes().len(), 2);
+ // two tagged NICs were realized.
+ assert_eq!(running.realized().len(), 2);
+
+ let json = serde_json::to_string(&running).expect("to json");
+ let back: MicrosegRunningConfig = serde_json::from_str(&json).expect("from json");
+ assert_eq!(
+ back, running,
+ "running config with realized assignments round trips"
+ );
+ }
+
+ #[test]
+ fn validate_rejects_bad_selectors() {
+ use PredicateMatch::Any;
+ let base: HashMap<String, MicrosegEntry> = HashMap::from([group("web", 2)]);
+
+ let mut no_tags = base.clone();
+ no_tags.extend([tag_assignment("s1", &[], Any, None, &["web"])]);
+ assert!(validate(&no_tags).is_err(), "empty tags must fail");
+
+ let mut no_groups = base.clone();
+ no_groups.extend([tag_assignment("s2", &["t"], Any, None, &[])]);
+ assert!(validate(&no_groups).is_err(), "empty groups must fail");
+
+ let mut unknown = base.clone();
+ unknown.extend([tag_assignment("s3", &["t"], Any, None, &["ghost"])]);
+ assert!(validate(&unknown).is_err(), "unknown group must fail");
+ }
+
+ #[test]
+ fn selector_section_config_round_trip() {
+ use PredicateMatch::All;
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 5),
+ group("db", 7),
+ tag_assignment("sel1", &["frontend", "prod"], All, Some(0), &["web", "db"]),
+ ]);
+ validate(&entries).expect("valid");
+
+ let data: SectionConfigData<MicrosegEntry> = SectionConfigData::from_iter(entries.clone());
+ let raw = MicrosegEntry::write_section_config("microseg.cfg", &data).expect("write");
+ let parsed: HashMap<String, MicrosegEntry> =
+ MicrosegEntry::parse_section_config("microseg.cfg", &raw)
+ .expect("parse")
+ .into_iter()
+ .collect();
+
+ assert_eq!(
+ parsed, entries,
+ "selector round trips through section config"
+ );
+ }
+
+ #[test]
+ fn create_tag_assignment_from_perl_scalars() {
+ // perlmod hands scalars as strings: iface "1" must parse and tag_match reads the enum form.
+ let create: api::MicrosegCreate = serde_json::from_str(
+ r#"{"type":"tagassignment","tags":["db","prod"],"tag_match":"all","iface":"1","groups":["web"]}"#,
+ )
+ .expect("assignment create");
+ match &create {
+ api::MicrosegCreate::TagAssignment(assignment) => {
+ assert_eq!(assignment.iface, Some(1));
+ assert_eq!(assignment.tag_match, Some(PredicateMatch::All));
+ assert_eq!(assignment.tags.len(), 2);
+ }
+ other => panic!("expected tag assignment, got {other:?}"),
+ }
+
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([group("web", 2)]);
+ let entry = api::build_entry(create, &entries).expect("build tag assignment");
+ // a tag matcher gets a content-derived id
+ assert!(entry.id().to_string().starts_with("tag"));
+ match entry {
+ MicrosegEntry::TagAssignment(assignment) => {
+ assert_eq!(assignment.tags, tags(&["db", "prod"]));
+ assert_eq!(assignment.tag_match, PredicateMatch::All);
+ assert_eq!(assignment.iface, Some(1));
+ }
+ other => panic!("expected tag assignment, got {other:?}"),
+ }
+ }
+
#[test]
fn guest_info_reads_perl_string_scalar_nics() {
// perlmod hands array elements as untyped (string) scalars, the nics deserializer must read
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (2 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Hannes Laimer
` (22 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
A third NIC-to-group assignment matcher next to the guest and tag
matchers: bind every guest whose name (qemu name / lxc hostname) matches
a regular expression. Like the tag matcher it expands against the live
guest inventory, so a rename surfaces as a pending change. Its id is
derived from the regex and optional iface so identical matchers
collapse. An empty or invalid regex is rejected on validation.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ve-config/src/sdn/microseg/mod.rs | 222 +++++++++++++++++++++-
1 file changed, 216 insertions(+), 6 deletions(-)
diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index fc7d0e2..8522a2c 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -10,6 +10,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use anyhow::{anyhow, bail};
use const_format::concatcp;
+use regex::Regex;
use serde::{Deserialize, Serialize};
use proxmox_schema::{ApiStringFormat, UpdaterType, api, api_string_type, const_regex};
@@ -264,6 +265,49 @@ pub struct TagAssignmentSection {
pub(crate) comment: Option<String>,
}
+#[api(
+ properties: {
+ name_regex: {
+ type: String,
+ max_length: 1024,
+ description: "Regular expression matched against each guest's name.",
+ },
+ iface: {
+ type: Integer,
+ optional: true,
+ minimum: 0,
+ maximum: 31,
+ description: "Restrict to the guest's netN interface. If unset, all NICs of each matching guest.",
+ },
+ groups: {
+ type: Array,
+ description: "Groups the matching NICs belong to.",
+ items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT },
+ },
+ comment: {
+ type: String,
+ optional: true,
+ max_length: 256,
+ description: "Free-form comment.",
+ },
+ },
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+/// Binds the NICs of every guest whose name matches `name_regex` to a set of groups. Expanded
+/// against the live guest inventory at render time, so a guest rename surfaces as a pending SDN
+/// change and takes effect on the next apply. Stored as the `regexassignment` section type.
+pub struct RegexAssignmentSection {
+ pub(crate) id: MicrosegId,
+ #[serde(default)]
+ pub(crate) name_regex: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) iface: Option<u32>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) groups: Vec<MicrosegId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) comment: Option<String>,
+}
+
#[api(
"id-property": "id",
"id-schema": {
@@ -285,6 +329,8 @@ pub enum MicrosegEntry {
GuestAssignment(GuestAssignmentSection),
/// A tag-matcher NIC-to-groups assignment.
TagAssignment(TagAssignmentSection),
+ /// A name-regex-matcher NIC-to-groups assignment.
+ RegexAssignment(RegexAssignmentSection),
}
impl MicrosegEntry {
@@ -295,6 +341,7 @@ impl MicrosegEntry {
Self::Rule(r) => &r.id,
Self::GuestAssignment(a) => &a.id,
Self::TagAssignment(a) => &a.id,
+ Self::RegexAssignment(a) => &a.id,
}
}
}
@@ -340,18 +387,21 @@ pub struct GuestInfo {
pub vmid: u32,
#[serde(default)]
pub tags: Vec<Tag>,
- /// The present netN interface indices. A matcher with no `iface` covers all of them.
+ #[serde(default)]
+ pub name: Option<String>,
+ /// The present netN interface indices.
#[serde(default, deserialize_with = "deserialize_perl_nics")]
pub nics: Vec<u32>,
}
-/// The microseg block of the SDN running config (`running.microseg`), an `ids` map of entries
-/// keyed by section 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>,
- /// The compiled identity registry (id to representative set), keeping ids stable across renders.
+ /// The compiled identity registry (id to representative set), keeping ids stable across
+ /// renders.
#[serde(default)]
identities: identity::Registry,
/// Concrete per-NIC assignments after expanding selectors against the guest inventory and
@@ -467,6 +517,15 @@ pub fn validate(entries: &HashMap<String, MicrosegEntry>) -> Result<(), anyhow::
}
check_assignment_groups(name, &assignment.groups)?;
}
+ MicrosegEntry::RegexAssignment(assignment) => {
+ if assignment.name_regex.is_empty() {
+ bail!("assignment '{name}' has an empty name regex");
+ }
+ if let Err(err) = Regex::new(&assignment.name_regex) {
+ bail!("assignment '{name}' has an invalid name regex: {err}");
+ }
+ check_assignment_groups(name, &assignment.groups)?;
+ }
MicrosegEntry::Group(_) => {}
}
}
@@ -562,7 +621,8 @@ pub fn realized_assignments(
) -> Vec<RealizedAssignment> {
let mut merged: BTreeMap<(u32, u32), BTreeSet<MicrosegId>> = BTreeMap::new();
let mut tag_matchers: Vec<&TagAssignmentSection> = Vec::new();
- // vmid -> present NICs, so a guest matcher with no iface can cover all of the guest's NICs.
+ let mut regex_matchers: Vec<(Regex, &RegexAssignmentSection)> = Vec::new();
+
let nics_of: HashMap<u32, &[u32]> = inventory
.iter()
.map(|guest| (guest.vmid, guest.nics.as_slice()))
@@ -580,6 +640,11 @@ pub fn realized_assignments(
}
}
MicrosegEntry::TagAssignment(assignment) => tag_matchers.push(assignment),
+ MicrosegEntry::RegexAssignment(assignment) => {
+ if let Ok(re) = Regex::new(&assignment.name_regex) {
+ regex_matchers.push((re, assignment));
+ }
+ }
MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {}
}
}
@@ -596,6 +661,17 @@ pub fn realized_assignments(
.extend(assignment.groups.iter().cloned());
}
}
+ for (re, assignment) in ®ex_matchers {
+ if !guest.name.as_deref().is_some_and(|name| re.is_match(name)) {
+ continue;
+ }
+ for nic in covered_nics(assignment.iface, &guest.nics) {
+ merged
+ .entry((guest.vmid, nic))
+ .or_default()
+ .extend(assignment.groups.iter().cloned());
+ }
+ }
}
merged
@@ -649,6 +725,23 @@ pub fn realized_per_assignment(
})
})
.collect(),
+ MicrosegEntry::RegexAssignment(assignment) => {
+ match Regex::new(&assignment.name_regex) {
+ Ok(re) => inventory
+ .iter()
+ .filter(|guest| guest.name.as_deref().is_some_and(|n| re.is_match(n)))
+ .filter_map(|guest| {
+ let nics = covered_nics(assignment.iface, &guest.nics);
+ (!nics.is_empty()).then(|| AssignmentMember {
+ vmid: guest.vmid,
+ nics,
+ })
+ })
+ .collect(),
+ Err(_) => Vec::new(),
+ }
+ }
+
MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {
return None;
}
@@ -847,7 +940,7 @@ pub mod api {
use super::{
GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
- PredicateMatch, RuleSection, Tag, TagAssignmentSection,
+ PredicateMatch, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection,
};
#[derive(Debug, Clone, Deserialize)]
@@ -857,6 +950,7 @@ pub mod api {
Rule(RuleCreate),
GuestAssignment(GuestAssignmentCreate),
TagAssignment(TagAssignmentCreate),
+ RegexAssignment(RegexAssignmentCreate),
}
#[derive(Debug, Clone, Deserialize)]
@@ -912,6 +1006,18 @@ pub mod api {
pub comment: Option<String>,
}
+ #[derive(Debug, Clone, Deserialize)]
+ pub struct RegexAssignmentCreate {
+ #[serde(default)]
+ pub name_regex: String,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub iface: Option<u32>,
+ #[serde(default)]
+ pub groups: Vec<MicrosegId>,
+ #[serde(default)]
+ pub comment: Option<String>,
+ }
+
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum MicrosegUpdate {
@@ -919,6 +1025,7 @@ pub mod api {
Rule(RuleUpdate),
GuestAssignment(AssignmentUpdate),
TagAssignment(AssignmentUpdate),
+ RegexAssignment(AssignmentUpdate),
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -1064,6 +1171,29 @@ pub mod api {
format!("tag{hash:016x}").parse().map_err(Error::from)
}
+ /// A stable, content-derived id for a name-regex matcher (FNV-1a over its regex and optional
+ /// interface), so identical regex matchers collapse and re-creating one is idempotent.
+ fn regex_assignment_id(name_regex: &str, iface: Option<u32>) -> Result<MicrosegId, Error> {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut feed = |byte: u8| {
+ hash ^= byte as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ };
+ match iface {
+ Some(iface) => {
+ feed(1);
+ for byte in iface.to_be_bytes() {
+ feed(byte);
+ }
+ }
+ None => feed(0),
+ }
+ for byte in name_regex.as_bytes() {
+ feed(*byte);
+ }
+ format!("re{hash:016x}").parse().map_err(Error::from)
+ }
+
/// Build an entry from a create payload, auto-assigning a free group mark when none is given and
/// deriving rule and assignment ids from their contents.
pub fn build_entry(
@@ -1123,6 +1253,16 @@ pub mod api {
comment: assignment.comment,
})
}
+ MicrosegCreate::RegexAssignment(assignment) => {
+ let id = regex_assignment_id(&assignment.name_regex, assignment.iface)?;
+ MicrosegEntry::RegexAssignment(RegexAssignmentSection {
+ id,
+ name_regex: assignment.name_regex,
+ iface: assignment.iface,
+ groups: assignment.groups,
+ comment: assignment.comment,
+ })
+ }
})
}
@@ -1185,6 +1325,22 @@ pub mod api {
}
}
}
+ (
+ MicrosegEntry::RegexAssignment(assignment),
+ MicrosegUpdate::RegexAssignment(update),
+ ) => {
+ if let Some(groups) = update.groups {
+ assignment.groups = groups;
+ }
+ if update.comment.is_some() {
+ assignment.comment = update.comment;
+ }
+ for property in update.delete {
+ match property {
+ AssignmentDeletableProperty::Comment => assignment.comment = None,
+ }
+ }
+ }
_ => bail!("update type does not match the existing object's type"),
}
Ok(())
@@ -1242,6 +1398,15 @@ pub mod api {
};
(base, assignment.comment.as_deref())
}
+ MicrosegEntry::RegexAssignment(assignment) => {
+ let base = match assignment.iface {
+ Some(iface) => {
+ format!("regex assignment /{}/ on net{iface}", assignment.name_regex)
+ }
+ None => format!("regex assignment /{}/", assignment.name_regex),
+ };
+ (base, assignment.comment.as_deref())
+ }
MicrosegEntry::Group(_) => ("object".to_string(), None),
};
match comment {
@@ -1269,6 +1434,9 @@ pub mod api {
MicrosegEntry::TagAssignment(assignment) => {
assignment.groups.iter().any(|g| g.as_ref() == name)
}
+ MicrosegEntry::RegexAssignment(assignment) => {
+ assignment.groups.iter().any(|g| g.as_ref() == name)
+ }
MicrosegEntry::Group(_) => false,
})
.map(describe_referrer)
@@ -1825,10 +1993,52 @@ mod tests {
GuestInfo {
vmid,
tags: tags(tag_names),
+ name: None,
+ nics: nics.to_vec(),
+ }
+ }
+
+ fn named_guest(vmid: u32, name: &str, nics: &[u32]) -> GuestInfo {
+ GuestInfo {
+ vmid,
+ tags: vec![],
+ name: Some(name.to_string()),
nics: nics.to_vec(),
}
}
+ #[test]
+ fn regex_assignment_matches_guest_names() {
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ (
+ "rweb".to_string(),
+ MicrosegEntry::RegexAssignment(RegexAssignmentSection {
+ id: id("rweb"),
+ name_regex: "^web-".to_string(),
+ iface: None,
+ groups: ids(&["web"]),
+ comment: None,
+ }),
+ ),
+ ]);
+ let inv = vec![
+ named_guest(1, "web-1", &[0]),
+ named_guest(2, "db-1", &[0]),
+ guest(3, &[], &[0]),
+ ];
+ let by_nic = realized_groups(&realized_assignments(&entries, &inv));
+ assert_eq!(by_nic[&(1, 0)], vec!["web".to_string()]);
+ assert!(
+ !by_nic.contains_key(&(2, 0)),
+ "a non-matching name is excluded"
+ );
+ assert!(
+ !by_nic.contains_key(&(3, 0)),
+ "a guest with no name never matches"
+ );
+ }
+
fn realized_groups(realized: &[RealizedAssignment]) -> HashMap<(u32, u32), Vec<String>> {
realized
.iter()
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (3 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
` (21 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
A bridge section marks a bridge-facing interface as an identity carrier,
optionally restricted to a set of nodes. A carrier is only needed where
the underlay cannot transport the identity itself.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ve-config/src/sdn/microseg/mod.rs | 98 +++++++++++++++++++++--
1 file changed, 90 insertions(+), 8 deletions(-)
diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index 8522a2c..8c38980 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -308,6 +308,36 @@ pub struct RegexAssignmentSection {
pub(crate) comment: Option<String>,
}
+#[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": {
@@ -331,6 +361,8 @@ pub enum MicrosegEntry {
TagAssignment(TagAssignmentSection),
/// A name-regex-matcher NIC-to-groups assignment.
RegexAssignment(RegexAssignmentSection),
+ /// A bridge-facing carrier interface.
+ Bridge(BridgeSection),
}
impl MicrosegEntry {
@@ -342,6 +374,7 @@ impl MicrosegEntry {
Self::GuestAssignment(a) => &a.id,
Self::TagAssignment(a) => &a.id,
Self::RegexAssignment(a) => &a.id,
+ Self::Bridge(b) => &b.id,
}
}
}
@@ -433,6 +466,13 @@ impl MicrosegRunningConfig {
})
}
+ 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,
+ })
+ }
+
/// The rendered concrete per-NIC assignments (static plus selector-expanded) the agent enforces.
pub fn realized(&self) -> &[RealizedAssignment] {
&self.realized
@@ -526,7 +566,7 @@ pub fn validate(entries: &HashMap<String, MicrosegEntry>) -> Result<(), anyhow::
}
check_assignment_groups(name, &assignment.groups)?;
}
- MicrosegEntry::Group(_) => {}
+ MicrosegEntry::Group(_) | MicrosegEntry::Bridge(_) => {}
}
}
@@ -645,7 +685,7 @@ pub fn realized_assignments(
regex_matchers.push((re, assignment));
}
}
- MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {}
+ MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) | MicrosegEntry::Bridge(_) => {}
}
}
@@ -741,8 +781,7 @@ pub fn realized_per_assignment(
Err(_) => Vec::new(),
}
}
-
- MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {
+ MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) | MicrosegEntry::Bridge(_) => {
return None;
}
};
@@ -939,7 +978,7 @@ pub mod api {
use serde::Deserialize;
use super::{
- GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
+ BridgeSection, GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
PredicateMatch, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection,
};
@@ -951,6 +990,7 @@ pub mod api {
GuestAssignment(GuestAssignmentCreate),
TagAssignment(TagAssignmentCreate),
RegexAssignment(RegexAssignmentCreate),
+ Bridge(BridgeCreate),
}
#[derive(Debug, Clone, Deserialize)]
@@ -1018,6 +1058,13 @@ pub mod api {
pub comment: Option<String>,
}
+ #[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 {
@@ -1026,6 +1073,7 @@ pub mod api {
GuestAssignment(AssignmentUpdate),
TagAssignment(AssignmentUpdate),
RegexAssignment(AssignmentUpdate),
+ Bridge(BridgeUpdate),
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -1076,6 +1124,20 @@ pub mod api {
Comment,
}
+ #[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
@@ -1263,6 +1325,10 @@ pub mod api {
comment: assignment.comment,
})
}
+ MicrosegCreate::Bridge(bridge) => MicrosegEntry::Bridge(BridgeSection {
+ id: bridge.id,
+ nodes: bridge.nodes,
+ }),
})
}
@@ -1341,6 +1407,16 @@ pub mod api {
}
}
}
+ (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(())
@@ -1407,7 +1483,7 @@ pub mod api {
};
(base, assignment.comment.as_deref())
}
- MicrosegEntry::Group(_) => ("object".to_string(), None),
+ MicrosegEntry::Group(_) | MicrosegEntry::Bridge(_) => ("object".to_string(), None),
};
match comment {
Some(comment) => format!("{desc} (comment: \"{comment}\")"),
@@ -1437,7 +1513,7 @@ pub mod api {
MicrosegEntry::RegexAssignment(assignment) => {
assignment.groups.iter().any(|g| g.as_ref() == name)
}
- MicrosegEntry::Group(_) => false,
+ MicrosegEntry::Group(_) | MicrosegEntry::Bridge(_) => false,
})
.map(describe_referrer)
.collect();
@@ -1573,6 +1649,13 @@ mod tests {
comment: None,
}),
),
+ (
+ "vmbr0".to_string(),
+ MicrosegEntry::Bridge(BridgeSection {
+ id: id("vmbr0"),
+ nodes: Some("pve1,pve2".to_string()),
+ }),
+ ),
]);
validate(&entries).expect("config is valid");
@@ -2148,7 +2231,6 @@ mod tests {
assert!(realized_per_assignment(&entries, &inv)["sel"].is_empty());
}
-
#[test]
fn render_with_inventory_allocates_tag_identities_and_round_trips() {
use PredicateMatch::Any;
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (4 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem Hannes Laimer
` (20 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
The agent applies the SDN running config to the kernel: BPF programs on
guest NICs stamp each packet with its source identity and enforce the
(src, dst) policy on delivery.
It runs one-shot per event (boot, SDN apply, guest NIC plug) instead of
as a resident daemon: those triggers cover every situation where kernel
state has to change, and programs and maps are pinned in bpffs, so
enforcement persists between invocations. Every run first checks that
the pinned programs match the built binary. A pure code change swaps
the programs atomically with no traffic gap. An incompatible change to
the map layout tears down and rebuilds the state, the only case with a
short enforcement gap.
Enforcement is stateless default-deny on the receiving side: a packet
is dropped unless an allow cell exists for its (source, destination)
identity pair. A guest NIC that should be enforced but cannot be fails
the plug rather than coming up open.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
include/mark.h | 30 +++
src/agent.rs | 69 +++++++
src/main.rs | 68 +++++++
src/policy/bpf/tap.bpf.c | 68 +++++++
src/policy/bpf/types.h | 23 +++
src/policy/mod.rs | 268 +++++++++++++++++++++++++++
src/policy/types.rs | 45 +++++
src/running_config.rs | 38 ++++
src/state.rs | 132 ++++++++++++++
src/subsystem.rs | 385 +++++++++++++++++++++++++++++++++++++++
src/tc.rs | 152 ++++++++++++++++
11 files changed, 1278 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..63f5f14
--- /dev/null
+++ b/src/agent.rs
@@ -0,0 +1,69 @@
+//! Agent orchestrator. Builds the per-host [`DesiredState`] from the SDN running-config and applies
+//! it via the [`policy`](crate::policy) subsystem. 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 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: {} rule cells, {} assignments",
+ state.rules.len(),
+ state.assignments.len(),
+ );
+ self.policy.apply(&state)
+ }
+
+ /// Fast path for a guest NIC that just appeared (a tap_plug).
+ ///
+ /// 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,
+ 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()
+ }
+}
+
+fn build_state() -> anyhow::Result<Option<DesiredState>> {
+ let microseg = running_config::load_microseg()?;
+ match DesiredState::build(µseg) {
+ Ok(s) => Ok(Some(s)),
+ Err(e) => {
+ log::error!("desired state: {e:#}");
+ Ok(None)
+ }
+ }
+}
+
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..305144f
--- /dev/null
+++ b/src/policy/bpf/tap.bpf.c
@@ -0,0 +1,68 @@
+#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
+ // allocate entries on demand, so memory tracks live taps rather than the ceiling
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __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);
+ // allocate entries on demand, so memory tracks live rules rather than the ceiling
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __uint(pinning, LIBBPF_PIN_BY_NAME);
+} rules SEC(".maps");
+
+// Stamp this tap's group into skb->mark. The destination tap's egress reads it as src_group.
+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 is
+// this tap's group, src is skb->mark. Look up (src_group, dst_group) in `rules`.
+// A missing entry is default-deny, and unstamped packets (src_group=0) need an
+// explicit (0, dst_group) allow.
+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..ff0c0ad
--- /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(id) = state
+ .assignments
+ .iter()
+ .find(|a| a.vmid == vmid && a.iface == idx)
+ .map(|a| a.id)
+ 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, id)
+ } 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, id: 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: id, _pad: 0 }, 0)?;
+ }
+ log::info!("apply_guest_iface: {iface} (ifindex {ifindex}) -> id {id}");
+
+ 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, &id) in desired {
+ if live.get(&ifidx) != Some(&id) {
+ tap_to_group.insert(ifidx, GuestGroup { group: id, _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_id,
+ dst_group: r.dst_id,
+ };
+ 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],
+) -> anyhow::Result<HashMap<u32, u16>> {
+ // A failure here must propagate, not fall back to an empty map: a full apply treats the result
+ // as the complete desired set, so an empty one would tear down every tap's enforcement. Failing
+ // the apply keeps the last-good state in place instead.
+ let ifaces = nix::net::if_::if_nameindex().context("if_nameindex")?;
+ let by_name: HashMap<String, u32> = ifaces
+ .iter()
+ .filter_map(|i| i.name().to_str().ok().map(|n| (n.to_owned(), i.index())))
+ .collect();
+
+ 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} id {}",
+ a.vmid,
+ a.iface,
+ a.id,
+ );
+ out.insert(ifidx, a.id);
+ } else {
+ log::trace!("skipping vmid={} iface={}: no local tap", a.vmid, a.iface);
+ }
+ }
+ log::debug!("{} managed taps on this host", out.len());
+ Ok(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..fe48802
--- /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..fc67818
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,132 @@
+//! Resolved desired state derived from the SDN running config.
+//!
+//! The running config carries the compiled identity registry (id to representative set) and the
+//! admin's groups, rules and assignments. This module turns that into what the data plane needs:
+//! the wire id for each managed interface and the folded `(src_id, dst_id) -> allow` rule cells.
+//! The hard, must-be-cluster-consistent work (allocation)
+//! already happened in the render. Here the agent only resolves and folds, reading nothing but the
+//! replicated config.
+
+use anyhow::Context;
+
+use proxmox_ve_config::sdn::microseg::{self, MicrosegRunningConfig};
+
+#[derive(Debug, Default)]
+pub struct DesiredState {
+ /// The permitted cells of the rule map. Absence of a cell is a deny (the data-plane default), so
+ /// only allow cells are carried.
+ pub rules: Vec<ResolvedRule>,
+ /// The wire id each managed guest NIC should stamp.
+ pub assignments: Vec<ResolvedAssignment>,
+}
+
+#[derive(Debug)]
+pub struct ResolvedRule {
+ pub src_id: u16,
+ pub dst_id: u16,
+ pub allow: bool,
+}
+
+#[derive(Debug)]
+pub struct ResolvedAssignment {
+ pub vmid: u32,
+ pub iface: u32,
+ /// The interface's wire id (the identity its group set resolves to).
+ pub id: u16,
+}
+
+impl DesiredState {
+ pub fn build(cfg: &MicrosegRunningConfig) -> anyhow::Result<Self> {
+ let entries = cfg.ids();
+ let registry = cfg.identities();
+ let policies = microseg::build_policies(entries).context("build policies")?;
+ let rules = microseg::resolved_rules(entries).context("resolve rules")?;
+
+ // Resolve each realized assignment (static plus selector-expanded) to its wire id.
+ let mut assignments = Vec::new();
+ for assignment in cfg.realized() {
+ let set = microseg::group_set(entries, &assignment.groups).with_context(|| {
+ format!("assignment vm{} net{}", assignment.vmid, assignment.iface)
+ })?;
+ let wire_id = registry
+ .id_of(&set, &policies)
+ .unwrap_or(microseg::identity::UNTAGGED_ID);
+ assignments.push(ResolvedAssignment {
+ vmid: assignment.vmid,
+ iface: assignment.iface,
+ id: wire_id,
+ });
+ }
+
+ // Fold the rule map over the allocated classes. The destination is always a real, enforced
+ // class. The source side additionally includes the untagged id (id 0, the no-group
+ // identity) so an `untagged -> X` rule admits unstamped traffic: gateway / DHCP / ARP
+ // replies, and cross-node frames that arrive without a GBP tag. An untagged *destination*
+ // would be a no-op (no program enforces there), so it is not iterated, we only emit allow
+ // cells.
+ let classes = registry.classes();
+ // its representative is the built-in untagged mark, so a predicate naming the 'untagged'
+ // group fires on the untagged id.
+ let untagged_rep: microseg::identity::GroupSet =
+ std::iter::once(microseg::UNTAGGED_MARK).collect();
+ let sources = classes
+ .iter()
+ .map(|(&id, rep)| (id, rep))
+ .chain(std::iter::once((
+ microseg::identity::UNTAGGED_ID,
+ &untagged_rep,
+ )));
+ let mut allow_cells = Vec::new();
+ for (src_id, src_rep) in sources {
+ for (&dst_id, dst_rep) in classes {
+ if microseg::verdict(&rules, src_rep, dst_rep) {
+ allow_cells.push(ResolvedRule {
+ src_id,
+ dst_id,
+ allow: true,
+ });
+ }
+ }
+ }
+
+ Ok(Self {
+ rules: allow_cells,
+ assignments,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn build_resolves_ids_and_folds_rules() {
+ // web=2 -> class 1, db=3 -> class 2, one rule web -> db allow.
+ let json = r#"{
+ "ids": {
+ "web": {"type":"group","id":"web","mark":2},
+ "db": {"type":"group","id":"db","mark":3},
+ "r1": {"type":"rule","id":"r1","src":["web"],"dst":["db"],"allow":1},
+ "vm100i0": {"type":"guestassignment","id":"vm100i0","vmid":100,"iface":0,"groups":["web"]}
+ },
+ "identities": {"next":3,"classes":{"1":[2],"2":[3]}},
+ "realized": [{"vmid":100,"iface":0,"groups":["web"]}]
+ }"#;
+ let cfg: MicrosegRunningConfig = serde_json::from_str(json).expect("parse running config");
+ let state = DesiredState::build(&cfg).expect("build state");
+
+ // the web interface resolves to its class id 1
+ let assignment = state
+ .assignments
+ .iter()
+ .find(|a| a.vmid == 100 && a.iface == 0)
+ .expect("assignment present");
+ assert_eq!(assignment.id, 1);
+
+ // the only permitted cell is web(1) -> db(2)
+ assert_eq!(state.rules.len(), 1);
+ let rule = &state.rules[0];
+ assert_eq!((rule.src_id, rule.dst_id, rule.allow), (1, 2, true));
+ }
+}
diff --git a/src/subsystem.rs b/src/subsystem.rs
new file mode 100644
index 0000000..0d7b62c
--- /dev/null
+++ b/src/subsystem.rs
@@ -0,0 +1,385 @@
+//! 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) and [`bridge`](crate::bridge) subsystems each own 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.
+ ///
+ /// 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 verifier runs here and in the
+ /// throwaway [`verify`](Self::verify), nowhere else. Steady-state runs reuse the pins. 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..f8a9e86
--- /dev/null
+++ b/src/tc.rs
@@ -0,0 +1,152 @@
+//! 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};
+
+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] 28+ messages in thread
* [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (5 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit Hannes Laimer
` (19 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Attach programs to bridge-facing carrier interfaces that write the
identity into the IPv6 SRH tag on the way out and read it back into the
packet mark on the way in, so the identity survives an SRv6 underlay
between nodes. VXLAN-GBP needs none of this, the kernel carries the
mark natively. The programs detect SRv6 from the packet and no-op on
everything else, so they attach to any carrier without knowing the zone
type.
Which interfaces are carriers comes from the bridge entries in the
running config, filtered by node. Enforcement itself stays on the guest
NICs, this subsystem only transports the identity between them.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/agent.rs | 48 ++++++++++++++++++++-----
src/bridge/bpf/srv6.bpf.c | 76 +++++++++++++++++++++++++++++++++++++++
src/bridge/mod.rs | 76 +++++++++++++++++++++++++++++++++++++++
src/main.rs | 1 +
src/state.rs | 33 ++++++++++++-----
5 files changed, 217 insertions(+), 17 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 63f5f14..5b299b1 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -1,39 +1,60 @@
//! Agent orchestrator. Builds the per-host [`DesiredState`] from the SDN running-config and applies
-//! it via the [`policy`](crate::policy) subsystem. 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
//! version change), then applies. Maps and links are pinned in bpffs, so they persist across
//! invocations and re-running never interrupts traffic.
-use crate::{policy::PolicySubsystem, running_config, state::DesiredState};
+use anyhow::Context;
+
+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: {} rule cells, {} assignments",
+ "applying: {} rule cells, {} assignments, {} bridges",
state.rules.len(),
state.assignments.len(),
+ state.bridges.len(),
);
- self.policy.apply(&state)
+ 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).
+ /// 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
@@ -52,13 +73,16 @@ impl Agent {
/// Detach everything and drop all pinned/run state, for package removal.
pub fn clear(&self) -> anyhow::Result<()> {
- self.policy.clear()
+ self.policy.clear()?;
+ self.bridge.clear()?;
+ Ok(())
}
}
fn build_state() -> anyhow::Result<Option<DesiredState>> {
let microseg = running_config::load_microseg()?;
- match DesiredState::build(µseg) {
+ let hostname = read_hostname()?;
+ match DesiredState::build(µseg, &hostname) {
Ok(s) => Ok(Some(s)),
Err(e) => {
log::error!("desired state: {e:#}");
@@ -67,3 +91,9 @@ fn build_state() -> anyhow::Result<Option<DesiredState>> {
}
}
+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/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..f34f6c7
--- /dev/null
+++ b/src/bridge/mod.rs
@@ -0,0 +1,76 @@
+//! Bridge-side carrier translation. Attaches BPF programs to bridge-facing interfaces that write
+//! `skb->mark` into the IPv6 SRH tag (the SRv6 on-wire carrier) on egress and read 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 SRv6 from the packet and is a no-op on every other frame, so userspace
+//! attaches it to all bridge carriers without knowing the zone type. A VXLAN-GBP underlay needs no
+//! BPF here: the kernel carries the identity natively (GBP policy-id <-> `skb->mark`) and this
+//! program just no-ops on those frames.
+//!
+//! 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 fc67818..ccc9e0f 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -1,9 +1,9 @@
-//! Resolved desired state derived from the SDN running config.
+//! Resolved desired state derived from the SDN running config plus the local hostname.
//!
//! The running config carries the compiled identity registry (id to representative set) and the
//! admin's groups, rules and assignments. This module turns that into what the data plane needs:
-//! the wire id for each managed interface and the folded `(src_id, dst_id) -> allow` rule cells.
-//! The hard, must-be-cluster-consistent work (allocation)
+//! the wire id for each managed interface, the folded `(src_id, dst_id) -> allow` rule cells, and
+//! the bridge carriers active on this host. The hard, must-be-cluster-consistent work (allocation)
//! already happened in the render. Here the agent only resolves and folds, reading nothing but the
//! replicated config.
@@ -18,6 +18,8 @@ pub struct DesiredState {
pub rules: Vec<ResolvedRule>,
/// The wire id each managed guest NIC should stamp.
pub assignments: Vec<ResolvedAssignment>,
+ /// Bridge carriers active on this host.
+ pub bridges: Vec<ResolvedBridge>,
}
#[derive(Debug)]
@@ -35,8 +37,13 @@ pub struct ResolvedAssignment {
pub id: u16,
}
+#[derive(Debug)]
+pub struct ResolvedBridge {
+ pub interface: String,
+}
+
impl DesiredState {
- pub fn build(cfg: &MicrosegRunningConfig) -> anyhow::Result<Self> {
+ pub fn build(cfg: &MicrosegRunningConfig, this_node: &str) -> anyhow::Result<Self> {
let entries = cfg.ids();
let registry = cfg.identities();
let policies = microseg::build_policies(entries).context("build policies")?;
@@ -62,8 +69,8 @@ impl DesiredState {
// class. The source side additionally includes the untagged id (id 0, the no-group
// identity) so an `untagged -> X` rule admits unstamped traffic: gateway / DHCP / ARP
// replies, and cross-node frames that arrive without a GBP tag. An untagged *destination*
- // would be a no-op (no program enforces there), so it is not iterated, we only emit allow
- // cells.
+ // would be a no-op (no program enforces there), so it is not iterated, and the fold
+ // emits allow cells only.
let classes = registry.classes();
// its representative is the built-in untagged mark, so a predicate naming the 'untagged'
// group fires on the untagged id.
@@ -89,9 +96,19 @@ impl DesiredState {
}
}
+ let mut bridges = Vec::new();
+ for (interface, bridge) in cfg.bridges() {
+ if bridge.applies_to(this_node) {
+ bridges.push(ResolvedBridge {
+ interface: interface.to_string(),
+ });
+ }
+ }
+
Ok(Self {
rules: allow_cells,
assignments,
+ bridges,
})
}
}
@@ -102,7 +119,7 @@ mod tests {
#[test]
fn build_resolves_ids_and_folds_rules() {
- // web=2 -> class 1, db=3 -> class 2, one rule web -> db allow.
+ // web=2 -> class 1, db=3 -> class 2, with one rule web -> db allow.
let json = r#"{
"ids": {
"web": {"type":"group","id":"web","mark":2},
@@ -114,7 +131,7 @@ mod tests {
"realized": [{"vmid":100,"iface":0,"groups":["web"]}]
}"#;
let cfg: MicrosegRunningConfig = serde_json::from_str(json).expect("parse running config");
- let state = DesiredState::build(&cfg).expect("build state");
+ let state = DesiredState::build(&cfg, "node1").expect("build state");
// the web interface resolves to its class id 1
let assignment = state
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (6 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
` (18 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
The oneshot unit re-applies the running config at boot, after the SDN
commit ran and before guests start, so the pinned state lost on reboot
is rebuilt before the first NIC comes up. On package removal all
programs are detached and pinned state is dropped: leaving them
attached would keep enforcing a policy no agent can update anymore.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Makefile | 66 +++++++++++++++++++++++++++++++++++++
debian/changelog | 5 +++
debian/control | 35 ++++++++++++++++++++
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, 197 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..03bd62e
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,35 @@
+Source: proxmox-ebpf
+Section: admin
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-cargo,
+ 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] 28+ messages in thread
* [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (7 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding Hannes Laimer
` (17 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 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] 28+ messages in thread
* [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (8 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag Hannes Laimer
` (16 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 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 | 235 ++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
3 files changed, 237 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..0e8f6d9
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/microseg.rs
@@ -0,0 +1,235 @@
+#[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::identity::Registry;
+ use proxmox_ve_config::sdn::microseg::{
+ self, AssignmentMember, GuestInfo, IdentityRules, MicrosegEntry, MicrosegRunningConfig,
+ RealizedAssignment,
+ };
+
+ /// A SDN Microseg config instance.
+ pub struct PerlMicrosegConfig {
+ 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: Render the running config from this config (the current `microseg.cfg`) plus the
+ /// previous identity registry and the current guest inventory, keeping wire ids stable. Pass the
+ /// previous running config's `identities` (or `undef`/empty on the first apply) so the registry
+ /// is carried forward. Feeding a fresh registry would renumber the whole cluster. `inventory` is
+ /// the list of guests the selectors are expanded against. Returns the new running config (`ids`,
+ /// `identities`, `realized`).
+ #[export]
+ pub fn render(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ previous: Option<Registry>,
+ inventory: Vec<GuestInfo>,
+ ) -> Result<MicrosegRunningConfig, Error> {
+ let entries = this.microseg.lock().unwrap().deref().clone();
+ let previous = previous.unwrap_or_default();
+ // the render node's clock drives the id-reclamation quarantine, and a clock stuck at the
+ // epoch keeps quarantines closed, which is the safe direction
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|since| since.as_secs())
+ .unwrap_or(0);
+ microseg::render(&previous, entries, &inventory, now)
+ }
+
+ /// Method: Expand the selectors in this config against the guest `inventory` into the concrete
+ /// per-NIC assignments (merged with the static assignments), without allocating identities. Used
+ /// to detect selector-driven pending changes: compare the result against the running config's
+ /// stored `realized` list.
+ #[export]
+ pub fn realized(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ inventory: Vec<GuestInfo>,
+ ) -> Result<Vec<RealizedAssignment>, Error> {
+ let entries = this.microseg.lock().unwrap();
+ Ok(microseg::realized_assignments(entries.deref(), &inventory))
+ }
+
+ /// Method: Expand each assignment against the guest `inventory` into the guests/NICs it covers,
+ /// keyed by assignment id.
+ #[export]
+ pub fn realized_members(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ inventory: Vec<GuestInfo>,
+ ) -> Result<HashMap<String, Vec<AssignmentMember>>, Error> {
+ let entries = this.microseg.lock().unwrap();
+ Ok(microseg::realized_per_assignment(entries.deref(), &inventory))
+ }
+
+ /// Method: For each realized identity class (a distinct group-set carried by some NIC), the
+ /// rules that govern it as source (`outbound`) and destination (`inbound`), so callers need not
+ /// re-implement the match semantics.
+ #[export]
+ pub fn rules_by_identity(
+ #[try_from_ref] this: &PerlMicrosegConfig,
+ inventory: Vec<GuestInfo>,
+ ) -> Result<Vec<IdentityRules>, Error> {
+ let entries = this.microseg.lock().unwrap();
+ microseg::rules_by_identity(entries.deref(), &inventory)
+ }
+
+ /// 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, stay stable
+ // across calls even though the config is stored in an unordered HashMap
+ 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(_))) {
+ let referrers = microseg::api::group_referenced_by(&entries, id);
+ if !referrers.is_empty() {
+ bail!("group '{id}' is still referenced by {}", referrers.join(", "));
+ }
+ }
+
+ 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] 28+ messages in thread
* [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (9 preceding siblings ...)
2026-07-09 9:18 ` [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory Hannes Laimer
` (15 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 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] 28+ messages in thread
* [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (10 preceding siblings ...)
2026-07-09 9:18 ` [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes Hannes Laimer
` (14 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
The config lives in sdn/microseg.cfg, owned by Rust the same way the
fabrics config is, and is edited through
/cluster/sdn/microseg/{group,rule,assignment}, readable with SDN.Audit
and writable with SDN.Allocate. The assignment endpoint fronts all
matcher kinds and discriminates them on a type property, so a new
matcher is a new stored section type instead of one type with every
field optional.
On commit the config is rendered into the running config: assignments
are expanded against a cluster-wide guest inventory into per-NIC group
sets and wire identities are allocated, which is what the per-host
agent consumes. Since that expansion depends on guest state, a relevant
guest change surfaces as a pending SDN change even though no config
file changed.
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 | 190 +++++++++
.../API2/Network/SDN/Microseg/Assignment.pm | 185 +++++++++
src/PVE/API2/Network/SDN/Microseg/Group.pm | 179 +++++++++
src/PVE/API2/Network/SDN/Microseg/Makefile | 8 +
src/PVE/API2/Network/SDN/Microseg/Rule.pm | 180 +++++++++
src/PVE/Network/SDN.pm | 37 ++
src/PVE/Network/SDN/Makefile | 1 +
src/PVE/Network/SDN/Microseg.pm | 365 ++++++++++++++++++
10 files changed, 1159 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/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..c88b5f1
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg.pm
@@ -0,0 +1,190 @@
+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::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({
+ 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 => 'all' },
+ ];
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'all',
+ path => 'all',
+ method => 'GET',
+ description => "SDN Microsegmentation Index",
+ 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 => 'object',
+ properties => {
+ objects => {
+ type => 'array',
+ description => 'Every microseg object across all types.',
+ items => {
+ type => "object",
+ properties => {
+ id => { type => 'string', description => 'Object identifier.' },
+ type => {
+ type => 'string',
+ enum => ['group', 'rule', 'guestassignment'],
+ },
+ state => get_standard_option('pve-sdn-config-state'),
+ pending => {
+ type => 'object',
+ optional => 1,
+ description =>
+ 'Changes not yet applied to the running configuration.',
+ },
+ },
+ },
+ },
+ identities => {
+ type => 'array',
+ description =>
+ 'Per realized identity class (a distinct group-set a NIC carries), the'
+ . ' rules that govern it as source (outbound) and destination (inbound).',
+ items => {
+ type => "object",
+ properties => {
+ groups => {
+ type => 'array',
+ items => { type => 'string' },
+ description =>
+ 'The identity class: the sorted group-set a NIC carries.',
+ },
+ outbound => {
+ type => 'array',
+ items => { type => 'string' },
+ description =>
+ 'Ids of the rules whose source predicate fires on this identity.',
+ },
+ inbound => {
+ type => 'array',
+ items => { type => 'string' },
+ description =>
+ 'Ids of the rules whose destination predicate fires on this identity.',
+ },
+ },
+ },
+ },
+ },
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($rpcenv->get_user(), '/sdn', ['SDN.Audit']);
+
+ my $objects = PVE::Network::SDN::Microseg::list_objects($param, undef);
+
+ # Expand each assignment to the guests/NICs it realizes, so the tree can render
+ # group -> assignment -> guest -> nic in one call.
+ my $config = PVE::Network::SDN::Microseg::config();
+ my $inventory = PVE::Network::SDN::Microseg::guest_inventory();
+ my $members = $config->realized_members($inventory);
+
+ # A member can match the current config but not be enforced until applied, so mark NICs
+ # missing from the running config's realized membership as pending.
+ my $running_cfg = PVE::Network::SDN::running_config();
+ my $applied_groups = {};
+ for my $entry (@{ ($running_cfg->{microseg} // {})->{realized} // [] }) {
+ $applied_groups->{"$entry->{vmid}/$entry->{iface}"} =
+ { map { $_ => 1 } @{ $entry->{groups} // [] } };
+ }
+
+ for my $object (@$objects) {
+ my $realized = $members->{ $object->{id} };
+ next if !defined $realized;
+
+ my $groups = ($object->{pending} // {})->{groups} // $object->{groups} // [];
+ for my $member (@$realized) {
+ my @pending_nics;
+ for my $nic (@{ $member->{nics} // [] }) {
+ my $enforced = $applied_groups->{"$member->{vmid}/$nic"} // {};
+ push @pending_nics, $nic if grep { !$enforced->{$_} } @$groups;
+ }
+ $member->{pending} = \@pending_nics if @pending_nics;
+ }
+
+ $object->{realized} = $realized;
+ }
+
+ return {
+ objects => $objects,
+ identities => $config->rules_by_identity($inventory),
+ };
+ },
+});
+
+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..0b64ee9
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Assignment.pm
@@ -0,0 +1,185 @@
+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);
+
+# The assignment endpoint fronts the stored matcher-specific section types (see $ASSIGNMENT_TYPES).
+my $ASSIGNMENT_TYPES = $PVE::Network::SDN::Microseg::ASSIGNMENT_TYPES;
+
+__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'),
+ PVE::Network::SDN::Microseg::assignment_properties(0)->%*,
+ },
+ },
+ 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_TYPES);
+ },
+});
+
+__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',
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ digest => get_standard_option('pve-config-digest'),
+ PVE::Network::SDN::Microseg::assignment_properties(0)->%*,
+ },
+ },
+ code => sub {
+ my ($param) = @_;
+
+ return PVE::Network::SDN::Microseg::get_object($ASSIGNMENT_TYPES,
+ extract_param($param, 'id'));
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create',
+ protected => 1,
+ path => '',
+ method => 'POST',
+ description => "Create a microseg assignment. The id is derived from its matcher.",
+ 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) = @_;
+
+ # The client states the matcher kind in 'type' (the schema gates the matcher-specific
+ # properties to it, see assignment_properties), so the stored type is taken as-is.
+ my $type = extract_param($param, 'type');
+ PVE::Network::SDN::Microseg::create_object($type, $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)->%*,
+ delete => {
+ type => 'array',
+ optional => 1,
+ items => {
+ type => 'string',
+ enum => ['comment'],
+ },
+ },
+ 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_TYPES, $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_TYPES, $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..71dad8a
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Group.pm
@@ -0,0 +1,179 @@
+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'),
+ PVE::Network::SDN::Microseg::group_properties(0)->%*,
+ },
+ },
+ 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',
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ digest => get_standard_option('pve-config-digest'),
+ PVE::Network::SDN::Microseg::group_properties(0)->%*,
+ },
+ },
+ 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'],
+ },
+ },
+ 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..caf850f
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Makefile
@@ -0,0 +1,8 @@
+SOURCES=Group.pm Rule.pm Assignment.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..dc870d5
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Rule.pm
@@ -0,0 +1,180 @@
+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'),
+ PVE::Network::SDN::Microseg::rule_properties(0)->%*,
+ },
+ },
+ 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',
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ digest => get_standard_option('pve-config-digest'),
+ PVE::Network::SDN::Microseg::rule_properties(0)->%*,
+ },
+ },
+ 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"
+ . " predicates, so two rules with the same predicates collapse to one; change an existing"
+ . " rule's priority or action by updating it rather than creating a second rule.",
+ 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)->%*,
+ delete => {
+ type => 'array',
+ optional => 1,
+ items => {
+ type => 'string',
+ enum => ['comment'],
+ },
+ },
+ 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..da8ceab 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,13 @@ sub compile_running_cfg {
my $controllers = { ids => $controllers_cfg->{ids} };
my $subnets = { ids => $subnets_cfg->{ids} };
my $fabrics = { ids => $fabrics_cfg->to_sections() };
+ # render allocates the wire-identity registry add-only, carrying the previous registry
+ # ($cfg still holds the prior running config here) forward, and expands tag selectors against the
+ # current guest inventory. Returns { ids, identities, realized }
+ my $microseg = $microseg_cfg->render(
+ $cfg->{microseg}->{identities},
+ PVE::Network::SDN::Microseg::guest_inventory(),
+ );
my $route_maps = { ids => $route_maps_cfg->to_sections() };
my $prefix_lists = { ids => $prefix_lists_cfg->to_sections() };
@@ -230,6 +239,7 @@ sub compile_running_cfg {
controllers => $controllers,
subnets => $subnets,
fabrics => $fabrics,
+ microseg => $microseg,
'route-maps' => $route_maps,
'prefix-lists' => $prefix_lists,
};
@@ -253,6 +263,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() },
};
@@ -266,9 +277,31 @@ sub has_pending_changes {
}
}
+ # Tag selectors expand against the live guest inventory, so a guest tag change shifts the
+ # rendered membership without touching any .cfg file. Catch that by re-expanding and diffing
+ # against the running config's stored realized assignments.
+ return 1 if microseg_realized_differs($running_cfg);
+
return 0;
}
+# Whether re-expanding the microseg assignments against the current guest inventory would change the
+# realized per-NIC assignments versus what the running config already holds. Tag matchers (and guest
+# matchers with no iface) make the realized set depend on the out-of-band guest inventory, so a guest
+# tag or NIC change surfaces here as a pending change even though no .cfg file changed. When neither
+# the config nor the inventory changed since the last render the two sides are identical, so this
+# never reports a false positive.
+sub microseg_realized_differs {
+ my ($running_cfg) = @_;
+
+ my $microseg_cfg = PVE::Network::SDN::Microseg::config();
+ my $running_realized = $running_cfg->{microseg}->{realized} // [];
+ my $pending_realized = $microseg_cfg->realized(PVE::Network::SDN::Microseg::guest_inventory());
+
+ return to_json($running_realized, { canonical => 1 }) ne
+ to_json($pending_realized, { canonical => 1 });
+}
+
sub generate_lock_token {
my $str;
my $uuid;
@@ -516,6 +549,10 @@ sub encode_value {
|| $key eq 'allowed_ips'
|| $key eq 'secondary-controllers'
|| $key eq 'redistribute'
+ || $key eq 'groups'
+ || $key eq 'tags'
+ || $key eq 'src'
+ || $key eq 'dst'
) {
if (ref($value) eq 'HASH') {
return join(',', sort keys(%$value));
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..4f7398a
--- /dev/null
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -0,0 +1,365 @@
+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);
+
+# The stored section types of the assignment family, one per matcher kind. The API presents them
+# under a single 'assignment' endpoint that discriminates on the 'type' property (storage splits
+# them so each section carries only its matcher's fields). Add a new matcher's stored type here.
+our $ASSIGNMENT_TYPES = ['guestassignment'];
+
+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.",
+ },
+ };
+
+ if (!$update) {
+ $properties->{mark} = {
+ type => 'integer',
+ minimum => 1,
+ maximum => 65535,
+ optional => 1,
+ description => "Numeric group mark, the building block of the wire identity."
+ . " 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).",
+ },
+ prio => {
+ type => 'integer',
+ minimum => 0,
+ maximum => 65535,
+ optional => 1,
+ default => 0,
+ description => "Priority. The highest-priority matching rule wins."
+ . " At a tie, a deny overrides an allow.",
+ },
+ comment => {
+ type => 'string',
+ optional => 1,
+ maxLength => 256,
+ description => "Free-form comment.",
+ },
+ };
+
+ # The src and dst predicates define the rule's identity (its id is derived from them), so they
+ # are create-only. Change them by deleting and recreating the rule.
+ if (!$update) {
+ $properties->{src} = {
+ type => 'array',
+ items => get_standard_option('pve-sdn-microseg-id'),
+ description => "Groups the source predicate is evaluated against. The built-in"
+ . " 'untagged' group matches no-group traffic (e.g. gateway / DHCP / ARP replies)"
+ . " and may be combined with real groups.",
+ };
+ $properties->{src_match} = {
+ type => 'string',
+ enum => ['any', 'all', 'exact'],
+ optional => 1,
+ default => 'any',
+ description => "How the source predicate matches: any (intersects the listed"
+ . " groups), all (is a superset of them), exact (equals them).",
+ };
+ $properties->{dst} = {
+ type => 'array',
+ items => get_standard_option('pve-sdn-microseg-id'),
+ description =>
+ "Groups the destination predicate is evaluated against. May include the"
+ . " built-in 'untagged' group.",
+ };
+ $properties->{dst_match} = {
+ type => 'string',
+ enum => ['any', 'all', 'exact'],
+ optional => 1,
+ default => 'any',
+ description => "How the destination predicate matches: any, all, exact.",
+ };
+ }
+
+ return $properties;
+}
+
+sub assignment_properties {
+ my ($update) = @_;
+
+ my $properties = {
+ groups => {
+ type => 'array',
+ items => get_standard_option('pve-sdn-microseg-id'),
+ optional => $update,
+ description => "Groups the matching NICs belong to.",
+ },
+ comment => {
+ type => 'string',
+ optional => 1,
+ maxLength => 256,
+ description => "Free-form comment.",
+ },
+ };
+
+ # The matcher defines the assignment's identity (its id is derived from it), so it is
+ # create-only. Change it by deleting and recreating. The 'type' property is the matcher-kind
+ # discriminator: it selects the stored section type and gates
+ # the matcher-specific properties to it. An unset iface means all of the guest's NICs.
+ if (!$update) {
+ $properties->{type} = {
+ type => 'string',
+ enum => $ASSIGNMENT_TYPES,
+ description => "The matcher kind: 'guestassignment' binds a specific guest.",
+ };
+ $properties->{vmid} = get_standard_option(
+ 'pve-vmid',
+ {
+ optional => 1,
+ 'type-property' => 'type',
+ 'instance-types' => ['guestassignment'],
+ description => "Guest matcher: the guest whose NIC(s) to bind.",
+ },
+ );
+ $properties->{iface} = {
+ type => 'integer',
+ minimum => 0,
+ maximum => 31,
+ optional => 1,
+ description => "The guest's netN interface, or all of the guest's NICs if unset.",
+ };
+ }
+
+ return $properties;
+}
+
+# The guest inventory assignments are expanded against at render time: every guest and the netN
+# indices it currently has. Pulled cluster-wide from the pmxcfs in-memory index in a single call
+# (>100x faster than parsing each config). All guests are included, since a guest matcher with no
+# iface needs the guest's NIC list too. Returns an arrayref of { vmid, nics => [...] } for the Rust
+# render / realized expansion.
+sub guest_inventory {
+ my $props = [map { "net$_" } 0 .. 31];
+ my $by_vmid = PVE::Cluster::get_guest_config_properties($props);
+
+ my $inventory = [];
+ for my $vmid (sort { $a <=> $b } keys %$by_vmid) {
+ my $guest = $by_vmid->{$vmid};
+
+ my @nics = grep { defined $guest->{"net$_"} } 0 .. 31;
+
+ push @$inventory,
+ {
+ vmid => $vmid + 0,
+ nics => [map { $_ + 0 } @nics],
+ };
+ }
+
+ return $inventory;
+}
+
+# Shared CRUD helpers for the per-type API endpoints. Each takes the object's type, a single type
+# string, or an arrayref of acceptable types for an endpoint that fronts a family of stored types
+# (the assignment matcher kinds). The per-type modules only declare their schema and delegate here.
+
+# Whether an object's stored type satisfies a wanted type: either a single type string or an
+# arrayref of acceptable types.
+sub type_matches {
+ my ($got, $want) = @_;
+ return 0 if !defined $got;
+ return (grep { $_ eq $got } @$want) ? 1 : 0 if ref($want) eq 'ARRAY';
+ return $got eq $want ? 1 : 0;
+}
+
+# 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 object '$id' does not exist" })
+ if !$entry || !type_matches($entry->{type}, $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');
+
+ PVE::Network::SDN::lock_sdn_config(
+ sub {
+ my $config = config();
+ my $entry = $config->get($id);
+ raise_param_exc({ id => "microseg object '$id' does not exist" })
+ if !$entry || !type_matches($entry->{type}, $type);
+
+ PVE::Tools::assert_if_modified($config->digest(), $digest) if $digest;
+
+ # tag the update with the object's concrete stored type so the Rust side selects the
+ # matching update variant
+ $param->{type} = $entry->{type};
+
+ $config->update($id, $param);
+ write_config($config);
+ },
+ "update sdn microseg 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 object '$id' does not exist" })
+ if !$entry || !type_matches($entry->{type}, $type);
+
+ $config->delete($id);
+ write_config($config);
+ },
+ "delete sdn microseg 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 && !type_matches($scfg->{type}, $type);
+ $scfg->{id} = $id;
+ $scfg->{digest} = $digest if defined $digest;
+ push @$res, $scfg;
+ }
+
+ return $res;
+}
+
+1;
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (11 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
` (13 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Microseg has no per-node generated file to diff, so the dry-run renders
a summary of the state (allocated identities, per-NIC memberships,
policies) for both the current and the pending config and returns their
diff. This is where changes with no .cfg edit behind them, like
assignment shifts caused by guest changes, become visible before an
apply.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/API2/Network/SDN.pm | 19 +++++++++++
src/PVE/Network/SDN/Microseg.pm | 60 +++++++++++++++++++++++++++++++++
2 files changed, 79 insertions(+)
diff --git a/src/PVE/API2/Network/SDN.pm b/src/PVE/API2/Network/SDN.pm
index 5a31e6f..5cee5a1 100644
--- a/src/PVE/API2/Network/SDN.pm
+++ b/src/PVE/API2/Network/SDN.pm
@@ -420,6 +420,13 @@ __PACKAGE__->register_method({
description =>
'The difference between the current and pending /etc/network/interfaces.d/sdn configuration.',
},
+ "microseg-diff" => {
+ type => 'string',
+ optional => 1,
+ description =>
+ 'The difference between the current and pending microsegmentation state'
+ . ' (allocated identities and realized per-NIC group membership).',
+ },
},
},
code => sub {
@@ -450,8 +457,20 @@ __PACKAGE__->register_method({
$return_value->{"interfaces-diff"} =
get_diff('/etc/network/interfaces.d/sdn', $interfaces_tmp_filename);
+ my $current_cfg = PVE::Network::SDN::running_config();
+ my $microseg_old = PVE::Network::SDN::Microseg::summary($current_cfg->{microseg});
+ my $microseg_new = PVE::Network::SDN::Microseg::summary($running_cfg->{microseg});
+ my ($microseg_old_filename, $microseg_old_fh) =
+ PVE::File::tempfile_contents($microseg_old, 700);
+ my ($microseg_new_filename, $microseg_new_fh) =
+ PVE::File::tempfile_contents($microseg_new, 700);
+ $return_value->{"microseg-diff"} =
+ get_diff($microseg_old_filename, $microseg_new_filename);
+
close($frr_tmp_fh);
close($interfaces_tmp_fh);
+ close($microseg_old_fh);
+ close($microseg_new_fh);
return $return_value;
},
});
diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm
index 4f7398a..82ee52d 100644
--- a/src/PVE/Network/SDN/Microseg.pm
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -232,6 +232,66 @@ sub guest_inventory {
return $inventory;
}
+# A stable, human-readable summary of a rendered microseg block ({ ids, identities, realized }), for
+# the SDN dry-run diff.
+sub summary {
+ my ($mseg) = @_;
+
+ $mseg //= {};
+ my $ids = $mseg->{ids} // {};
+ my $identities = $mseg->{identities} // {};
+ my $realized = $mseg->{realized} // [];
+
+ my %mark2name;
+ for my $name (keys %$ids) {
+ my $object = $ids->{$name};
+ next if !$object || ($object->{type} // '') ne 'group';
+ $mark2name{ $object->{mark} } = $name if defined $object->{mark};
+ }
+ my $group_names = sub {
+ my ($marks) = @_;
+ return join(',', sort map { $mark2name{$_} // "mark$_" } @{ $marks // [] });
+ };
+
+ my @lines = ('identities:');
+ my $classes = $identities->{classes} // {};
+ for my $cid (sort { $a <=> $b } keys %$classes) {
+ push @lines, sprintf(' %s -> %s', $cid, $group_names->($classes->{$cid}));
+ }
+
+ push @lines, 'assignments:';
+ my @sorted = sort {
+ ($a->{vmid} // 0) <=> ($b->{vmid} // 0) || ($a->{iface} // 0) <=> ($b->{iface} // 0)
+ } @$realized;
+ for my $assignment (@sorted) {
+ push @lines,
+ sprintf(
+ ' vm%s net%s -> %s',
+ $assignment->{vmid} // '?',
+ $assignment->{iface} // '?',
+ join(',', sort @{ $assignment->{groups} // [] }),
+ );
+ }
+
+ push @lines, 'policies:';
+ my @rule_ids = sort grep { ($ids->{$_}->{type} // '') eq 'rule' } keys %$ids;
+ for my $rule_id (@rule_ids) {
+ my $rule = $ids->{$rule_id};
+ push @lines,
+ sprintf(
+ ' %s {%s} -> %s {%s}: %s prio %s',
+ $rule->{src_match} // 'any',
+ join(',', sort @{ $rule->{src} // [] }),
+ $rule->{dst_match} // 'any',
+ join(',', sort @{ $rule->{dst} // [] }),
+ ($rule->{allow} ? 'allow' : 'deny'),
+ $rule->{prio} // 0,
+ );
+ }
+
+ return join("\n", @lines) . "\n";
+}
+
# Shared CRUD helpers for the per-type API endpoints. Each takes the object's type, a single type
# string, or an arrayref of acceptable types for an endpoint that fronts a family of stored types
# (the assignment matcher kinds). The per-type modules only declare their schema and delegate here.
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (12 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
` (12 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Attach enforcement when a guest NIC is plugged, before it is added to
the bridge, so an assigned NIC never passes traffic unenforced. A
failure to enforce fails the plug, and with it the guest start, instead
of bringing the NIC up open.
This adds the two agent invocation helpers to the microseg module: the
per-interface one used here, and a full resync for the network reload
path.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/Network/SDN/Microseg.pm | 33 +++++++++++++++++++++++++++++++++
src/PVE/Network/SDN/Zones.pm | 6 ++++++
2 files changed, 39 insertions(+)
diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm
index 82ee52d..4f135c6 100644
--- a/src/PVE/Network/SDN/Microseg.pm
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -422,4 +422,37 @@ sub list_objects {
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;
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] 28+ messages in thread
* [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (13 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
` (11 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 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] 28+ messages in thread
* [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (14 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher Hannes Laimer
` (10 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 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.
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] 28+ messages in thread
* [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (15 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher Hannes Laimer
` (9 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
A second NIC-to-group assignment matcher next to the guest matcher: a
'tagassignment' type binding every guest matching the given tags, with
tags and tag_match properties gated to it, and the guest inventory
extended to report each guest's tags.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/API2/Network/SDN/Microseg.pm | 2 +-
src/PVE/Network/SDN/Microseg.pm | 42 ++++++++++++++++++++++------
2 files changed, 35 insertions(+), 9 deletions(-)
diff --git a/src/PVE/API2/Network/SDN/Microseg.pm b/src/PVE/API2/Network/SDN/Microseg.pm
index c88b5f1..fc1e4ac 100644
--- a/src/PVE/API2/Network/SDN/Microseg.pm
+++ b/src/PVE/API2/Network/SDN/Microseg.pm
@@ -97,7 +97,7 @@ __PACKAGE__->register_method({
id => { type => 'string', description => 'Object identifier.' },
type => {
type => 'string',
- enum => ['group', 'rule', 'guestassignment'],
+ enum => ['group', 'rule', 'guestassignment', 'tagassignment'],
},
state => get_standard_option('pve-sdn-config-state'),
pending => {
diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm
index 4f135c6..f000ce6 100644
--- a/src/PVE/Network/SDN/Microseg.pm
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -39,7 +39,7 @@ cfs_register_file('sdn/microseg.cfg', \&parse_microseg_config, \&write_microseg_
# The stored section types of the assignment family, one per matcher kind. The API presents them
# under a single 'assignment' endpoint that discriminates on the 'type' property (storage splits
# them so each section carries only its matcher's fields). Add a new matcher's stored type here.
-our $ASSIGNMENT_TYPES = ['guestassignment'];
+our $ASSIGNMENT_TYPES = ['guestassignment', 'tagassignment'];
sub parse_microseg_config {
my ($filename, $raw) = @_;
@@ -184,7 +184,8 @@ sub assignment_properties {
$properties->{type} = {
type => 'string',
enum => $ASSIGNMENT_TYPES,
- description => "The matcher kind: 'guestassignment' binds a specific guest.",
+ description => "The matcher kind: 'guestassignment' binds a specific guest,"
+ . " 'tagassignment' binds every guest carrying the matched tags.",
};
$properties->{vmid} = get_standard_option(
'pve-vmid',
@@ -195,6 +196,27 @@ sub assignment_properties {
description => "Guest matcher: the guest whose NIC(s) to bind.",
},
);
+ $properties->{tags} = {
+ type => 'array',
+ items => {
+ type => 'string',
+ format => 'pve-tag',
+ },
+ optional => 1,
+ 'type-property' => 'type',
+ 'instance-types' => ['tagassignment'],
+ description => "Tag matcher: the guest tags to match.",
+ };
+ $properties->{tag_match} = {
+ type => 'string',
+ enum => ['any', 'all', 'exact'],
+ optional => 1,
+ default => 'any',
+ 'type-property' => 'type',
+ 'instance-types' => ['tagassignment'],
+ description => "How tags match: any (the guest shares a listed tag), all"
+ . " (the guest carries every listed tag), or exact (its tags equal the listed set).",
+ };
$properties->{iface} = {
type => 'integer',
minimum => 0,
@@ -207,24 +229,28 @@ sub assignment_properties {
return $properties;
}
-# The guest inventory assignments are expanded against at render time: every guest and the netN
-# indices it currently has. Pulled cluster-wide from the pmxcfs in-memory index in a single call
-# (>100x faster than parsing each config). All guests are included, since a guest matcher with no
-# iface needs the guest's NIC list too. Returns an arrayref of { vmid, nics => [...] } for the Rust
-# render / realized expansion.
+# The guest inventory assignments are expanded against at render time: every guest, with its tags
+# and the netN indices it currently has. Pulled cluster-wide from the pmxcfs in-memory index in a
+# single call (faster than parsing each config). All guests are included (not just tagged
+# ones), since a guest matcher with no iface needs the guest's NIC list too. Returns an arrayref of
+# { vmid, tags => [...], nics => [...] } for the Rust render / realized expansion.
sub guest_inventory {
- my $props = [map { "net$_" } 0 .. 31];
+ my $props = ['tags', map { "net$_" } 0 .. 31];
my $by_vmid = PVE::Cluster::get_guest_config_properties($props);
my $inventory = [];
for my $vmid (sort { $a <=> $b } keys %$by_vmid) {
my $guest = $by_vmid->{$vmid};
+ my $tags_raw = $guest->{tags} // '';
+ my @tags = grep { length } split(/[;,\s]+/, $tags_raw);
+
my @nics = grep { defined $guest->{"net$_"} } 0 .. 31;
push @$inventory,
{
vmid => $vmid + 0,
+ tags => \@tags,
nics => [map { $_ + 0 } @nics],
};
}
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (16 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API Hannes Laimer
` (8 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Expose the regex matcher through the assignment API: a 'regexassignment'
type with a name_regex property gated to it, and include each guest's
name in the inventory the matcher expands against.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/API2/Network/SDN/Microseg.pm | 8 +++++++-
src/PVE/Network/SDN/Microseg.pm | 23 +++++++++++++++++------
2 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/src/PVE/API2/Network/SDN/Microseg.pm b/src/PVE/API2/Network/SDN/Microseg.pm
index fc1e4ac..85e3194 100644
--- a/src/PVE/API2/Network/SDN/Microseg.pm
+++ b/src/PVE/API2/Network/SDN/Microseg.pm
@@ -97,7 +97,13 @@ __PACKAGE__->register_method({
id => { type => 'string', description => 'Object identifier.' },
type => {
type => 'string',
- enum => ['group', 'rule', 'guestassignment', 'tagassignment'],
+ enum => [
+ 'group',
+ 'rule',
+ 'guestassignment',
+ 'tagassignment',
+ 'regexassignment',
+ ],
},
state => get_standard_option('pve-sdn-config-state'),
pending => {
diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm
index f000ce6..57aa40e 100644
--- a/src/PVE/Network/SDN/Microseg.pm
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -39,7 +39,7 @@ cfs_register_file('sdn/microseg.cfg', \&parse_microseg_config, \&write_microseg_
# The stored section types of the assignment family, one per matcher kind. The API presents them
# under a single 'assignment' endpoint that discriminates on the 'type' property (storage splits
# them so each section carries only its matcher's fields). Add a new matcher's stored type here.
-our $ASSIGNMENT_TYPES = ['guestassignment', 'tagassignment'];
+our $ASSIGNMENT_TYPES = ['guestassignment', 'tagassignment', 'regexassignment'];
sub parse_microseg_config {
my ($filename, $raw) = @_;
@@ -185,7 +185,8 @@ sub assignment_properties {
type => 'string',
enum => $ASSIGNMENT_TYPES,
description => "The matcher kind: 'guestassignment' binds a specific guest,"
- . " 'tagassignment' binds every guest carrying the matched tags.",
+ . " 'tagassignment' binds every guest carrying the matched tags,"
+ . " 'regexassignment' binds every guest whose name matches a regex.",
};
$properties->{vmid} = get_standard_option(
'pve-vmid',
@@ -217,6 +218,15 @@ sub assignment_properties {
description => "How tags match: any (the guest shares a listed tag), all"
. " (the guest carries every listed tag), or exact (its tags equal the listed set).",
};
+ $properties->{name_regex} = {
+ type => 'string',
+ maxLength => 1024,
+ optional => 1,
+ 'type-property' => 'type',
+ 'instance-types' => ['regexassignment'],
+ description =>
+ "Regex matcher: a regular expression matched against each guest's name.",
+ };
$properties->{iface} = {
type => 'integer',
minimum => 0,
@@ -231,11 +241,11 @@ sub assignment_properties {
# The guest inventory assignments are expanded against at render time: every guest, with its tags
# and the netN indices it currently has. Pulled cluster-wide from the pmxcfs in-memory index in a
-# single call (faster than parsing each config). All guests are included (not just tagged
-# ones), since a guest matcher with no iface needs the guest's NIC list too. Returns an arrayref of
-# { vmid, tags => [...], nics => [...] } for the Rust render / realized expansion.
+# single call. All guests are included, since a guest matcher with no iface needs the guest's NIC
+# list too. Returns an arrayref of { vmid, name, tags => [...], nics => [...] } for the Rust
+# render / realized expansion.
sub guest_inventory {
- my $props = ['tags', map { "net$_" } 0 .. 31];
+ my $props = ['tags', 'name', 'hostname', map { "net$_" } 0 .. 31];
my $by_vmid = PVE::Cluster::get_guest_config_properties($props);
my $inventory = [];
@@ -250,6 +260,7 @@ sub guest_inventory {
push @$inventory,
{
vmid => $vmid + 0,
+ name => $guest->{name} // $guest->{hostname},
tags => \@tags,
nics => [map { $_ + 0 } @nics],
};
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (17 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 20/27] ui: sdn: add microsegmentation panel Hannes Laimer
` (7 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
CRUD for carrier bridge entries under /cluster/sdn/microseg/bridge:
they mark bridge-facing interfaces the agent should attach the SRv6
identity-carrier programs to, optionally restricted to a set of nodes.
VXLAN-GBP zones need no carrier entries. SRv6 transport is API-only for
now, without UI support.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/PVE/API2/Network/SDN/Microseg.pm | 8 +
src/PVE/API2/Network/SDN/Microseg/Bridge.pm | 179 ++++++++++++++++++++
src/PVE/API2/Network/SDN/Microseg/Makefile | 2 +-
src/PVE/Network/SDN/Microseg.pm | 14 ++
4 files changed, 202 insertions(+), 1 deletion(-)
create mode 100644 src/PVE/API2/Network/SDN/Microseg/Bridge.pm
diff --git a/src/PVE/API2/Network/SDN/Microseg.pm b/src/PVE/API2/Network/SDN/Microseg.pm
index 85e3194..b346bea 100644
--- a/src/PVE/API2/Network/SDN/Microseg.pm
+++ b/src/PVE/API2/Network/SDN/Microseg.pm
@@ -12,6 +12,7 @@ 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);
@@ -31,6 +32,11 @@ __PACKAGE__->register_method({
path => 'assignment',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Network::SDN::Microseg::Bridge",
+ path => 'bridge',
+});
+
__PACKAGE__->register_method({
name => 'index',
path => '',
@@ -57,6 +63,7 @@ __PACKAGE__->register_method({
{ subdir => 'group' },
{ subdir => 'rule' },
{ subdir => 'assignment' },
+ { subdir => 'bridge' },
{ subdir => 'all' },
];
},
@@ -103,6 +110,7 @@ __PACKAGE__->register_method({
'guestassignment',
'tagassignment',
'regexassignment',
+ 'bridge',
],
},
state => get_standard_option('pve-sdn-config-state'),
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..e56a111
--- /dev/null
+++ b/src/PVE/API2/Network/SDN/Microseg/Bridge.pm
@@ -0,0 +1,179 @@
+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'),
+ PVE::Network::SDN::Microseg::bridge_properties(0)->%*,
+ },
+ },
+ 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',
+ properties => {
+ id => get_standard_option('pve-sdn-microseg-id'),
+ digest => get_standard_option('pve-config-digest'),
+ PVE::Network::SDN::Microseg::bridge_properties(0)->%*,
+ },
+ },
+ 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/Makefile b/src/PVE/API2/Network/SDN/Microseg/Makefile
index caf850f..1dab1d3 100644
--- a/src/PVE/API2/Network/SDN/Microseg/Makefile
+++ b/src/PVE/API2/Network/SDN/Microseg/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Group.pm Rule.pm Assignment.pm
+SOURCES=Group.pm Rule.pm Assignment.pm Bridge.pm
PERL5DIR=${DESTDIR}/usr/share/perl5
diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm
index 57aa40e..2b22cb2 100644
--- a/src/PVE/Network/SDN/Microseg.pm
+++ b/src/PVE/Network/SDN/Microseg.pm
@@ -239,6 +239,20 @@ sub assignment_properties {
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.",
+ },
+ ),
+ };
+}
+
# The guest inventory assignments are expanded against at render time: every guest, with its tags
# and the netN indices it currently has. Pulled cluster-wide from the pmxcfs in-memory index in a
# single call. All guests are included, since a guest matcher with no iface needs the guest's NIC
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-manager v2 20/27] ui: sdn: add microsegmentation panel
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (18 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
` (6 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Add a Microsegmentation panel under Datacenter > SDN. A policy grid of
the rules sits next to a membership tree (group -> assignment -> guest
-> NIC). Selecting a node in the tree filters the grid to the rules that
govern that NIC as source or destination. The backend computes the
matching, so the UI doesn't re-implement predicate semantics.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/manager6/Makefile | 9 +
www/manager6/Utils.js | 24 ++
www/manager6/dc/Config.js | 8 +
www/manager6/form/MicrosegGroupSelector.js | 77 +++++
www/manager6/form/MicrosegGuestSelector.js | 83 +++++
www/manager6/sdn/MicrosegView.js | 143 +++++++++
www/manager6/sdn/microseg/AssignmentEdit.js | 59 ++++
www/manager6/sdn/microseg/Base.js | 134 ++++++++
www/manager6/sdn/microseg/GroupEdit.js | 45 +++
www/manager6/sdn/microseg/PolicyView.js | 181 +++++++++++
www/manager6/sdn/microseg/RuleEdit.js | 104 ++++++
www/manager6/sdn/microseg/Tree.js | 339 ++++++++++++++++++++
12 files changed, 1206 insertions(+)
create mode 100644 www/manager6/form/MicrosegGroupSelector.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
create mode 100644 www/manager6/sdn/microseg/Tree.js
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d4dd3f35..ba0fbc19 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -71,6 +71,8 @@ JSSRC= \
form/SDNControllerSelector.js \
form/SDNZoneSelector.js \
form/SDNVnetSelector.js \
+ form/MicrosegGroupSelector.js \
+ form/MicrosegGuestSelector.js \
form/SDNIpamSelector.js \
form/SDNDnsSelector.js \
form/ScsiHwSelector.js \
@@ -338,6 +340,13 @@ JSSRC= \
sdn/zones/VxlanEdit.js \
sdn/FabricsView.js \
sdn/FabricsContentView.js \
+ sdn/microseg/Base.js \
+ sdn/MicrosegView.js \
+ sdn/microseg/Tree.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 040b5ae0..6d7ac5e3 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -966,6 +966,30 @@ Ext.define('PVE.Utils', {
},
},
+ sdnmicrosegSchema: {
+ group: {
+ name: gettext('Group'),
+ ipanel: 'GroupInputPanel',
+ },
+ rule: {
+ name: gettext('Rule'),
+ ipanel: 'RuleInputPanel',
+ },
+ guestassignment: {
+ name: gettext('Guest Assignment'),
+ ipanel: 'GuestAssignmentInputPanel',
+ urlType: 'assignment',
+ },
+ },
+
+ 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..ca150457
--- /dev/null
+++ b/www/manager6/form/MicrosegGroupSelector.js
@@ -0,0 +1,77 @@
+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',
+ },
+ });
+
+ if (me.includeUntagged) {
+ store.on('load', function (loaded) {
+ if (!loaded.getById('untagged')) {
+ loaded.add({
+ id: 'untagged',
+ type: 'group',
+ mark: 0,
+ comment: gettext('no group / untagged'),
+ });
+ }
+ });
+ }
+
+ 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/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..e0ac5b76
--- /dev/null
+++ b/www/manager6/sdn/MicrosegView.js
@@ -0,0 +1,143 @@
+Ext.define('PVE.sdn.MicrosegGrid', {
+ extend: 'Ext.grid.GridPanel',
+ xtype: 'pveSDNMicrosegGrid',
+
+ border: false,
+
+ initComponent: function () {
+ let me = this;
+
+ if (!me.microsegType) {
+ throw 'no microsegType given';
+ }
+
+ let store = Ext.create('Ext.data.Store', {
+ model: 'pve-sdn-microseg',
+ proxy: {
+ type: 'proxmox',
+ url: `/api2/json/cluster/sdn/microseg/${me.microsegType}`,
+ extraParams: { pending: 1 },
+ },
+ sorters: me.storeSorters ?? 'id',
+ });
+
+ let reload = () => store.load();
+
+ let sm = Ext.create('Ext.selection.RowModel', {});
+
+ let openEdit = (type, microsegId) =>
+ Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: type,
+ microsegId: microsegId,
+ autoShow: true,
+ listeners: { destroy: reload },
+ });
+
+ let run_editor = () => {
+ let rec = sm.getSelection()[0];
+ if (!rec) {
+ return;
+ }
+ openEdit(rec.data.type, rec.data.id);
+ };
+
+ 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/${me.microsegType}/`,
+ callback: reload,
+ });
+
+ let set_button_status = () => {
+ let rec = sm.getSelection()[0];
+ if (!rec || rec.data.state === 'deleted') {
+ edit_btn.disable();
+ remove_btn.disable();
+ }
+ };
+
+ let addButton = me.addMenu
+ ? {
+ text: me.addText || gettext('Add'),
+ menu: {
+ items: me.addMenu.map((type) => ({
+ text: PVE.Utils.format_sdnmicroseg_type(type),
+ handler: () => openEdit(type),
+ })),
+ },
+ }
+ : {
+ text: me.addText || gettext('Add'),
+ handler: () => openEdit(me.microsegType),
+ };
+
+ Ext.apply(me, {
+ store: store,
+ selModel: sm,
+ tbar: [
+ addButton,
+ edit_btn,
+ remove_btn,
+ {
+ xtype: 'proxmoxButton',
+ text: gettext('Reload'),
+ selModel: false,
+ handler: reload,
+ },
+ ],
+ listeners: {
+ itemdblclick: run_editor,
+ selectionchange: set_button_status,
+ activate: reload,
+ },
+ });
+
+ me.callParent();
+ },
+});
+
+Ext.define('PVE.sdn.MicrosegView', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.pveSDNMicroseg',
+
+ onlineHelp: 'pvesdn_config_microseg',
+
+ layout: 'border',
+
+ initComponent: function () {
+ let me = this;
+
+ let policyGrid = Ext.create('PVE.sdn.microseg.PolicyView', {
+ region: 'center',
+ });
+
+ let tree = Ext.create('PVE.sdn.MicrosegTree', {
+ region: 'west',
+ title: gettext('Groups'),
+ width: 400,
+ minWidth: 250,
+ split: true,
+ listeners: {
+ microsegselect: (sel) => policyGrid.setSelection(sel),
+ },
+ });
+
+ Ext.apply(me, {
+ items: [tree, policyGrid],
+ listeners: {
+ activate: () => {
+ tree.reloadTree();
+ policyGrid.getStore().load();
+ },
+ },
+ });
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
new file mode 100644
index 00000000..2a9c5cce
--- /dev/null
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -0,0 +1,59 @@
+Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', {
+ extend: 'PVE.panel.SDNMicrosegBase',
+
+ onlineHelp: 'pvesdn_microseg_assignment',
+
+ autoId: true,
+
+ onGetValues: function (values) {
+ let me = this;
+ if (me.isCreate) {
+ values.type = me.type;
+ }
+ if (values.iface === '' || values.iface === null || values.iface === undefined) {
+ delete values.iface;
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ {
+ xtype: 'pveMicrosegGuestSelector',
+ name: 'vmid',
+ fieldLabel: gettext('Guest'),
+ allowBlank: false,
+ disabled: !me.isCreate,
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iface',
+ fieldLabel: gettext('Network interface'),
+ minValue: 0,
+ maxValue: 31,
+ allowBlank: true,
+ emptyText: gettext('All NICs'),
+ disabled: !me.isCreate,
+ },
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'groups',
+ fieldLabel: gettext('Groups'),
+ multiSelect: true,
+ allowBlank: false,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
new file mode 100644
index 00000000..6e226ce2
--- /dev/null
+++ b/www/manager6/sdn/microseg/Base.js
@@ -0,0 +1,134 @@
+Ext.define('pve-sdn-microseg', {
+ extend: 'Ext.data.Model',
+ fields: [
+ 'id',
+ 'type',
+ 'comment',
+ 'digest',
+ 'state',
+ 'pending',
+ 'mark',
+ 'src',
+ 'src_match',
+ 'dst',
+ 'dst_match',
+ 'prio',
+ 'allow',
+ 'vmid',
+ 'iface',
+ 'groups',
+ 'direction',
+ ],
+ idProperty: 'id',
+});
+
+Ext.define('pve-sdn-microseg-tree', {
+ extend: 'Ext.data.TreeModel',
+ fields: [
+ 'microsegEditType',
+ 'microsegId',
+ 'microsegGroups',
+ 'microsegScope',
+ 'microsegMode',
+ 'microsegOutbound',
+ 'microsegInbound',
+ 'state',
+ 'pending',
+ ],
+});
+
+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',
+ ),
+ });
+ }
+
+ if (!me.isCreate) {
+ me.items.push({ xtype: 'hiddenfield', name: 'digest' });
+ }
+
+ me.callParent();
+ },
+});
+
+Ext.define('PVE.sdn.microseg.BaseEdit', {
+ extend: 'Proxmox.window.Edit',
+
+ width: 400,
+
+ initComponent: function () {
+ let me = this;
+
+ me.isCreate = !me.microsegId;
+
+ let schema = PVE.Utils.sdnmicrosegSchema[me.type];
+ let urlType = schema.urlType || me.type;
+
+ if (me.isCreate) {
+ me.url = '/api2/extjs/cluster/sdn/microseg/' + urlType;
+ me.method = 'POST';
+ } else {
+ me.url = '/api2/extjs/cluster/sdn/microseg/' + urlType + '/' + me.microsegId;
+ me.method = 'PUT';
+ }
+
+ let ipanel = Ext.create(
+ 'PVE.sdn.microseg.' + schema.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..e4f5ed9a
--- /dev/null
+++ b/www/manager6/sdn/microseg/GroupEdit.js
@@ -0,0 +1,45 @@
+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 && !values.mark) {
+ delete values.mark;
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ {
+ 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..e9775ba1
--- /dev/null
+++ b/www/manager6/sdn/microseg/PolicyView.js
@@ -0,0 +1,181 @@
+Ext.define('PVE.sdn.microseg.PolicyView', {
+ extend: 'PVE.sdn.MicrosegGrid',
+ xtype: 'pveSDNMicrosegPolicyView',
+
+ title: gettext('Policies'),
+ microsegType: 'rule',
+ addText: gettext('Add Rule'),
+
+ storeSorters: [
+ { property: 'prio', direction: 'DESC' },
+ { property: 'id', direction: 'ASC' },
+ ],
+
+ setSelection: function (sel) {
+ let me = this;
+
+ let scope = sel && sel.scope;
+ let mode = sel && sel.mode;
+ let title = gettext('Policies');
+ if (scope) {
+ let fmt =
+ mode === 'identity'
+ ? gettext('Policies governing {0}')
+ : gettext('Policies referencing {0}');
+ title = Ext.String.format(fmt, Ext.String.htmlEncode(scope));
+ }
+ me.setTitle(title);
+
+ let col = me.down('#microsegDirection');
+ if (col) {
+ col.setVisible(mode === 'membership' || mode === 'identity');
+ }
+
+ if (mode === 'membership') {
+ let set = {};
+ (sel.groups || []).forEach((g) => {
+ set[g] = true;
+ });
+ me.filterByDirection((groups) => (groups || []).some((g) => set[g]));
+ } else if (mode === 'identity') {
+ let outSet = {};
+ let inSet = {};
+ (sel.outbound || []).forEach((r) => {
+ outSet[r] = true;
+ });
+ (sel.inbound || []).forEach((r) => {
+ inSet[r] = true;
+ });
+ me.filterByRuleId(outSet, inSet);
+ } else {
+ me.getStore().clearFilter();
+ }
+ },
+
+ filterByDirection: function (matches) {
+ let store = this.getStore();
+ store.clearFilter(true);
+ store.filterBy((rec) => {
+ let data = rec.data;
+ let out = matches(data.pending?.src ?? data.src ?? []);
+ let inbound = matches(data.pending?.dst ?? data.dst ?? []);
+ data.direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '';
+ return out || inbound;
+ });
+ },
+
+ filterByRuleId: function (outSet, inSet) {
+ let store = this.getStore();
+ store.clearFilter(true);
+ store.filterBy((rec) => {
+ let out = !!outSet[rec.data.id];
+ let inbound = !!inSet[rec.data.id];
+ rec.data.direction = out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '';
+ return out || inbound;
+ });
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let renderPredicate = (match, groups) => {
+ let kind = match || 'any';
+ let list = (groups || []).join(', ');
+ return Ext.String.htmlEncode(`${kind}(${list})`);
+ };
+
+ let outIcon =
+ '<i class="fa fa-fw fa-sign-out" data-qtip="' +
+ gettext('Outbound: the selection is the source') +
+ '"></i>';
+ let inIcon =
+ '<i class="fa fa-fw fa-sign-in" data-qtip="' +
+ gettext('Inbound: the selection is the destination') +
+ '"></i>';
+ let renderDirection = (value) => {
+ if (value === 'both') {
+ return outIcon + inIcon;
+ }
+ if (value === 'out') {
+ return outIcon;
+ }
+ if (value === 'in') {
+ return inIcon;
+ }
+ return '';
+ };
+
+ let strikeIfDeleted = (rec, html) =>
+ rec.data.state === 'deleted'
+ ? `<span style="text-decoration: line-through;">${html}</span>`
+ : html;
+
+ me.columns = [
+ {
+ text: '',
+ itemId: 'microsegDirection',
+ dataIndex: 'direction',
+ width: 50,
+ resizable: false,
+ hidden: true,
+ renderer: renderDirection,
+ },
+ {
+ text: gettext('Source'),
+ dataIndex: 'src',
+ flex: 2,
+ renderer: (v, m, rec) =>
+ strikeIfDeleted(
+ rec,
+ renderPredicate(
+ rec.data.pending?.src_match ?? rec.data.src_match,
+ rec.data.pending?.src ?? v,
+ ),
+ ),
+ },
+ {
+ text: gettext('Destination'),
+ dataIndex: 'dst',
+ flex: 2,
+ renderer: (v, m, rec) =>
+ strikeIfDeleted(
+ rec,
+ renderPredicate(
+ rec.data.pending?.dst_match ?? rec.data.dst_match,
+ rec.data.pending?.dst ?? v,
+ ),
+ ),
+ },
+ {
+ text: gettext('Action'),
+ dataIndex: 'allow',
+ width: 90,
+ renderer: (v, m, rec) =>
+ strikeIfDeleted(
+ rec,
+ (rec.data.pending?.allow ?? v) ? gettext('Allow') : gettext('Deny'),
+ ),
+ },
+ {
+ text: gettext('Priority'),
+ dataIndex: 'prio',
+ width: 90,
+ renderer: (v, m, rec) => strikeIfDeleted(rec, rec.data.pending?.prio ?? v ?? 0),
+ },
+ {
+ text: gettext('Comment'),
+ dataIndex: 'comment',
+ flex: 1,
+ renderer: (v, m, rec) => PVE.Utils.render_sdn_pending(rec, v ?? '', 'comment'),
+ },
+ {
+ text: gettext('State'),
+ dataIndex: 'state',
+ width: 100,
+ renderer: (v, m, rec) => PVE.Utils.render_sdn_pending_state(rec, v),
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js
new file mode 100644
index 00000000..8b3749e8
--- /dev/null
+++ b/www/manager6/sdn/microseg/RuleEdit.js
@@ -0,0 +1,104 @@
+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 = me.srcSelector.getValue() || [];
+ values.dst = me.dstSelector.getValue() || [];
+ } else {
+ delete values.src;
+ delete values.dst;
+ delete values.src_match;
+ delete values.dst_match;
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let matchItems = [
+ ['any', gettext('Any (intersects)')],
+ ['all', gettext('All (subset of)')],
+ ['exact', gettext('Exact (equals)')],
+ ];
+
+ me.srcSelector = Ext.create({
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'src',
+ fieldLabel: gettext('Source groups'),
+ multiSelect: true,
+ allowBlank: false,
+ includeUntagged: true,
+ disabled: !me.isCreate,
+ });
+
+ me.dstSelector = Ext.create({
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'dst',
+ fieldLabel: gettext('Destination groups'),
+ multiSelect: true,
+ allowBlank: false,
+ includeUntagged: true,
+ disabled: !me.isCreate,
+ });
+
+ me.items = [
+ me.srcSelector,
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'src_match',
+ fieldLabel: gettext('Source match'),
+ value: 'any',
+ disabled: !me.isCreate,
+ comboItems: matchItems,
+ },
+ me.dstSelector,
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'dst_match',
+ fieldLabel: gettext('Destination match'),
+ value: 'any',
+ disabled: !me.isCreate,
+ comboItems: matchItems,
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'prio',
+ fieldLabel: gettext('Priority'),
+ minValue: 0,
+ maxValue: 65535,
+ value: 0,
+ allowBlank: false,
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('The highest priority wins. At a tie, a deny overrides an allow.'),
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'allow',
+ fieldLabel: gettext('Action'),
+ boxLabel: gettext('Allow (unchecked = deny)'),
+ checked: true,
+ uncheckedValue: 0,
+ inputValue: 1,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js
new file mode 100644
index 00000000..9bfa7608
--- /dev/null
+++ b/www/manager6/sdn/microseg/Tree.js
@@ -0,0 +1,339 @@
+Ext.define('PVE.sdn.MicrosegTree', {
+ extend: 'Ext.tree.Panel',
+ xtype: 'pveSDNMicrosegTree',
+
+ rootVisible: false,
+ animate: false,
+ border: false,
+
+ initComponent: function () {
+ let me = this;
+
+ // pending objects carry their staged values under `pending`; fall back to the applied value
+ let pv = (o, k) => o.pending?.[k] ?? o[k];
+
+ let assignmentLabel = (a) => {
+ let ifaceVal = pv(a, 'iface');
+ let iface =
+ ifaceVal === undefined || ifaceVal === null || ifaceVal === ''
+ ? ''
+ : ` net${ifaceVal}`;
+ return `vm${pv(a, 'vmid')}${iface}`;
+ };
+
+ let buildNodes = (objects, identities) => {
+ let groups = (objects || [])
+ .filter((o) => o.type === 'group')
+ .sort((a, b) => a.id.localeCompare(b.id));
+ let assignments = (objects || []).filter(
+ (o) => PVE.Utils.sdnmicrosegSchema[o.type]?.urlType === 'assignment',
+ );
+
+ let nicIdentity = {};
+ assignments.forEach((a) => {
+ let aGroups = pv(a, 'groups') || [];
+ (a.realized || []).forEach((m) => {
+ (m.nics || []).forEach((nic) => {
+ let key = `${m.vmid}/${nic}`;
+ if (!nicIdentity[key]) {
+ nicIdentity[key] = {};
+ }
+ aGroups.forEach((g) => {
+ nicIdentity[key][g] = true;
+ });
+ });
+ });
+ });
+ let identityGroups = (vmid, nic) =>
+ Object.keys(nicIdentity[`${vmid}/${nic}`] || {}).sort();
+ let guestIdentities = (vmid) =>
+ Object.keys(nicIdentity)
+ .filter((key) => key.startsWith(`${vmid}/`))
+ .map((key) => Object.keys(nicIdentity[key]).sort());
+
+ let classRules = {};
+ (identities || []).forEach((cls) => {
+ classRules[(cls.groups || []).slice().sort().join(',')] = cls;
+ });
+ let rulesFor = (identityList) => {
+ let out = {};
+ let inb = {};
+ identityList.forEach((groupSet) => {
+ let cls = classRules[groupSet.join(',')] || {};
+ (cls.outbound || []).forEach((r) => {
+ out[r] = true;
+ });
+ (cls.inbound || []).forEach((r) => {
+ inb[r] = true;
+ });
+ });
+ return { outbound: Object.keys(out), inbound: Object.keys(inb) };
+ };
+
+ let nodes = [
+ {
+ id: 'all',
+ text: gettext('All Policies'),
+ iconCls: 'fa fa-fw fa-list',
+ microsegMode: null,
+ microsegScope: null,
+ leaf: true,
+ },
+ {
+ id: 'untagged',
+ text: 'untagged',
+ iconCls: 'fa fa-fw fa-ban',
+ microsegMode: 'membership',
+ microsegGroups: ['untagged'],
+ microsegScope: 'untagged',
+ leaf: true,
+ },
+ ];
+
+ for (const group of groups) {
+ let groupId = `g/${group.id}`;
+ let assignNodes = assignments
+ .filter((a) => (pv(a, 'groups') || []).includes(group.id))
+ .map((a) => {
+ let aGroups = pv(a, 'groups') || [];
+ let assignId = `${groupId}/a/${a.id}`;
+ let realized = a.realized || [];
+ let anyPending = realized.some((m) => (m.pending || []).length);
+ return {
+ id: assignId,
+ text: assignmentLabel(a),
+ iconCls: 'fa fa-fw fa-link',
+ microsegEditType: a.type,
+ microsegId: a.id,
+ microsegMode: 'membership',
+ microsegGroups: aGroups,
+ microsegScope: assignmentLabel(a),
+ state: a.state || (anyPending ? 'changed' : undefined),
+ pending: a.pending,
+ leaf: !realized.length,
+ children: realized.map((member) => {
+ let pendingNics = member.pending || [];
+ let guestRules = rulesFor(guestIdentities(member.vmid));
+ return {
+ id: `${assignId}/m/${member.vmid}`,
+ text: `vm${member.vmid}`,
+ iconCls: 'fa fa-fw fa-desktop',
+ microsegMode: 'identity',
+ microsegOutbound: guestRules.outbound,
+ microsegInbound: guestRules.inbound,
+ microsegScope: `vm${member.vmid}`,
+ state: pendingNics.length ? 'new' : undefined,
+ leaf: !(member.nics || []).length,
+ children: (member.nics || []).map((nic) => {
+ let ig = identityGroups(member.vmid, nic);
+ let nicRules = rulesFor([ig]);
+ return {
+ id: `${assignId}/m/${member.vmid}/n/${nic}`,
+ text: `net${nic}`,
+ iconCls: 'fa fa-fw fa-plug',
+ microsegMode: 'identity',
+ microsegOutbound: nicRules.outbound,
+ microsegInbound: nicRules.inbound,
+ microsegScope: ig.length
+ ? `vm${member.vmid}/net${nic} {${ig.join(', ')}}`
+ : `vm${member.vmid}/net${nic}`,
+ state: pendingNics.includes(nic) ? 'new' : undefined,
+ leaf: true,
+ };
+ }),
+ };
+ }),
+ };
+ });
+
+ nodes.push({
+ id: groupId,
+ text: group.id,
+ iconCls: 'fa fa-fw fa-object-group',
+ microsegEditType: 'group',
+ microsegId: group.id,
+ microsegMode: 'membership',
+ microsegGroups: [group.id],
+ microsegScope: group.id,
+ state: group.state,
+ pending: group.pending,
+ leaf: !assignNodes.length,
+ children: assignNodes,
+ });
+ }
+ return nodes;
+ };
+
+ let reload = () => {
+ let expanded = [];
+ let oldRoot = me.getRootNode();
+ if (oldRoot) {
+ oldRoot.cascadeBy((node) => {
+ if (!node.isRoot() && node.isExpanded()) {
+ expanded.push(node.getId());
+ }
+ });
+ }
+ let selectedId = me.getSelection()?.[0]?.getId();
+
+ Proxmox.Utils.API2Request({
+ url: '/cluster/sdn/microseg/all?pending=1',
+ method: 'GET',
+ failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+ success: (response) => {
+ let data = response.result.data;
+ me.setRootNode({
+ expanded: true,
+ children: buildNodes(data.objects, data.identities),
+ });
+ let store = me.getStore();
+ for (const id of expanded) {
+ store.getNodeById(id)?.expand();
+ }
+ if (selectedId) {
+ let node = store.getNodeById(selectedId);
+ if (node) {
+ me.setSelection(node);
+ }
+ }
+ },
+ });
+ };
+
+ let openEdit = (type, microsegId) =>
+ Ext.create('PVE.sdn.microseg.BaseEdit', {
+ type: type,
+ microsegId: microsegId,
+ autoShow: true,
+ listeners: { destroy: reload },
+ });
+
+ let selected = () => me.getSelection()?.[0];
+
+ let editSelected = () => {
+ let node = selected();
+ if (!node || !node.data.microsegEditType) {
+ return;
+ }
+ openEdit(node.data.microsegEditType, node.data.microsegId);
+ };
+
+ let removeSelected = () => {
+ let node = selected();
+ if (!node || !node.data.microsegEditType) {
+ return;
+ }
+ let type = node.data.microsegEditType;
+ let id = node.data.microsegId;
+ let schema = PVE.Utils.sdnmicrosegSchema[type];
+ let urlType = (schema && schema.urlType) || type;
+ Ext.Msg.confirm(
+ gettext('Confirm'),
+ Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${id}'`),
+ (btn) => {
+ if (btn !== 'yes') {
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: `/cluster/sdn/microseg/${urlType}/${encodeURIComponent(id)}`,
+ method: 'DELETE',
+ waitMsgTarget: me,
+ failure: (response) =>
+ Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus),
+ callback: reload,
+ });
+ },
+ );
+ };
+
+ let editButton = new Proxmox.button.Button({
+ text: gettext('Edit'),
+ disabled: true,
+ handler: editSelected,
+ });
+
+ let removeButton = new Proxmox.button.Button({
+ text: gettext('Remove'),
+ disabled: true,
+ handler: removeSelected,
+ });
+
+ Ext.apply(me, {
+ store: Ext.create('Ext.data.TreeStore', {
+ model: 'pve-sdn-microseg-tree',
+ root: { expanded: true, children: [] },
+ }),
+ columns: [
+ {
+ xtype: 'treecolumn',
+ text: gettext('Group / Assignment'),
+ dataIndex: 'text',
+ flex: 1,
+ renderer: Ext.String.htmlEncode,
+ },
+ {
+ text: gettext('State'),
+ dataIndex: 'state',
+ width: 100,
+ renderer: (value, meta, rec) => PVE.Utils.render_sdn_pending_state(rec, value),
+ },
+ ],
+ tbar: [
+ {
+ text: gettext('Add Group'),
+ handler: () => openEdit('group'),
+ },
+ {
+ text: gettext('Add Assignment'),
+ menu: {
+ items: Object.keys(PVE.Utils.sdnmicrosegSchema)
+ .filter(
+ (type) =>
+ PVE.Utils.sdnmicrosegSchema[type].urlType === 'assignment',
+ )
+ .map((type) => ({
+ text: PVE.Utils.format_sdnmicroseg_type(type),
+ handler: () => openEdit(type),
+ })),
+ },
+ },
+ '-',
+ editButton,
+ removeButton,
+ {
+ text: gettext('Reload'),
+ handler: reload,
+ },
+ ],
+ listeners: {
+ itemdblclick: editSelected,
+ selectionchange: () => {
+ let node = selected();
+ let editable = !!(
+ node &&
+ node.data.microsegEditType &&
+ node.data.state !== 'deleted'
+ );
+ editButton.setDisabled(!editable);
+ removeButton.setDisabled(!editable);
+ me.fireEvent(
+ 'microsegselect',
+ node
+ ? {
+ scope: node.data.microsegScope,
+ mode: node.data.microsegMode,
+ groups: node.data.microsegGroups,
+ outbound: node.data.microsegOutbound,
+ inbound: node.data.microsegInbound,
+ }
+ : null,
+ );
+ },
+ },
+ });
+
+ me.reloadTree = reload;
+
+ me.callParent();
+ },
+});
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (19 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-manager v2 20/27] ui: sdn: add microsegmentation panel Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 22/27] network: apply microseg state on reload Hannes Laimer
` (5 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Show the microseg diff the apply dry-run now returns next to the
interfaces and frr diffs, so pending identity and membership changes
are visible before applying.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/manager6/sdn/SdnDiffView.js | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/www/manager6/sdn/SdnDiffView.js b/www/manager6/sdn/SdnDiffView.js
index 2bc52049..851c2db8 100644
--- a/www/manager6/sdn/SdnDiffView.js
+++ b/www/manager6/sdn/SdnDiffView.js
@@ -17,6 +17,7 @@ Ext.define('PVE.sdn.SdnDiffView', {
node: undefined,
frr_diff: '',
interfaces_diff: '',
+ microseg_diff: '',
},
},
@@ -44,6 +45,10 @@ Ext.define('PVE.sdn.SdnDiffView', {
'interfaces_diff',
Ext.htmlEncode(diff['interfaces-diff'] ?? gettext('No changes')),
);
+ vm.set(
+ 'microseg_diff',
+ Ext.htmlEncode(diff['microseg-diff'] ?? gettext('No changes')),
+ );
})
.catch(Proxmox.Utils.alertResponseFailure)
.finally(() => {
@@ -102,6 +107,25 @@ Ext.define('PVE.sdn.SdnDiffView', {
},
],
},
+ {
+ xtype: 'panel',
+ title: gettext('Microsegmentation'),
+ flex: 1,
+ scrollable: true,
+ items: [
+ {
+ xtype: 'component',
+ padding: 5,
+ style: {
+ 'white-space': 'pre',
+ 'font-family': 'monospace',
+ },
+ bind: {
+ html: '{microseg_diff}',
+ },
+ },
+ ],
+ },
],
buttons: [
{
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-manager v2 22/27] network: apply microseg state on reload
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (20 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
` (4 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
A network reload recreates SDN devices, dropping BPF state attached to
them, and it is also the per-node step of an SDN apply. Re-sync the
microseg state after ifreload so enforcement follows the freshly
applied running config.
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] 28+ messages in thread
* [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (21 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-manager v2 22/27] network: apply microseg state on reload Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher Hannes Laimer
` (3 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 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] 28+ messages in thread
* [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (22 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher Hannes Laimer
` (2 subsequent siblings)
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Register the tag assignment type with its own input panel (tags
multi-select, tag match, interface, groups, comment) and label it in
the microseg tree. The add-assignment menu and tree filter pick it up
from the schema registry.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/manager6/Utils.js | 5 +
www/manager6/sdn/microseg/AssignmentEdit.js | 113 ++++++++++++++++++++
www/manager6/sdn/microseg/Base.js | 2 +
www/manager6/sdn/microseg/Tree.js | 13 ++-
4 files changed, 132 insertions(+), 1 deletion(-)
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 6d7ac5e3..52022a6a 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -980,6 +980,11 @@ Ext.define('PVE.Utils', {
ipanel: 'GuestAssignmentInputPanel',
urlType: 'assignment',
},
+ tagassignment: {
+ name: gettext('Tag Assignment'),
+ ipanel: 'TagAssignmentInputPanel',
+ urlType: 'assignment',
+ },
},
format_sdnmicroseg_type: function (value, md, record) {
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
index 2a9c5cce..9fca4685 100644
--- a/www/manager6/sdn/microseg/AssignmentEdit.js
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -57,3 +57,116 @@ Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', {
me.callParent();
},
});
+
+Ext.define('PVE.sdn.microseg.TagAssignmentInputPanel', {
+ extend: 'PVE.panel.SDNMicrosegBase',
+
+ onlineHelp: 'pvesdn_microseg_assignment',
+
+ autoId: true,
+
+ onGetValues: function (values) {
+ let me = this;
+ if (me.isCreate) {
+ values.type = me.type;
+ }
+ if (values.iface === '' || values.iface === null || values.iface === undefined) {
+ delete values.iface;
+ }
+ return me.callParent([values]);
+ },
+
+ setValues: function (values) {
+ let me = this;
+ if (Ext.isArray(values.tags) && values.tags.length) {
+ let store = me.tagSelector.getStore();
+ let known = {};
+ store.each((rec) => {
+ known[rec.get('value')] = true;
+ });
+ let missing = values.tags.filter((tag) => !known[tag]).map((tag) => ({ value: tag }));
+ if (missing.length) {
+ store.add(missing);
+ }
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ let tagList = (PVE.UIOptions.tagList ?? []).map((tag) => ({ value: tag }));
+
+ me.tagSelector = Ext.create({
+ xtype: 'proxmoxComboGrid',
+ name: 'tags',
+ fieldLabel: gettext('Tags'),
+ emptyText: gettext('Select guest tags'),
+ editable: false,
+ multiSelect: true,
+ allowBlank: false,
+ disabled: !me.isCreate,
+ valueField: 'value',
+ displayField: 'value',
+ store: {
+ fields: ['value'],
+ data: tagList,
+ sorters: 'value',
+ },
+ listConfig: {
+ userCls: 'proxmox-tags-full',
+ columns: [
+ {
+ dataIndex: 'value',
+ flex: 1,
+ renderer: (value) =>
+ PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
+ },
+ ],
+ },
+ });
+
+ me.items = [
+ me.tagSelector,
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'tag_match',
+ fieldLabel: gettext('Tag match'),
+ value: 'any',
+ disabled: !me.isCreate,
+ comboItems: [
+ ['any', gettext('Any (shares a tag)')],
+ ['all', gettext('All (has every tag)')],
+ ['exact', gettext('Exact (equals)')],
+ ],
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iface',
+ fieldLabel: gettext('Network interface'),
+ minValue: 0,
+ maxValue: 31,
+ allowBlank: true,
+ emptyText: gettext('All NICs'),
+ disabled: !me.isCreate,
+ },
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'groups',
+ fieldLabel: gettext('Groups'),
+ multiSelect: true,
+ allowBlank: false,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
index 6e226ce2..a2c263f9 100644
--- a/www/manager6/sdn/microseg/Base.js
+++ b/www/manager6/sdn/microseg/Base.js
@@ -17,6 +17,8 @@ Ext.define('pve-sdn-microseg', {
'vmid',
'iface',
'groups',
+ 'tags',
+ 'tag_match',
'direction',
],
idProperty: 'id',
diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js
index 9bfa7608..47c13dfa 100644
--- a/www/manager6/sdn/microseg/Tree.js
+++ b/www/manager6/sdn/microseg/Tree.js
@@ -18,9 +18,20 @@ Ext.define('PVE.sdn.MicrosegTree', {
ifaceVal === undefined || ifaceVal === null || ifaceVal === ''
? ''
: ` net${ifaceVal}`;
+ if (a.type === 'tagassignment') {
+ let match = pv(a, 'tag_match') || 'any';
+ return `${match}{${(pv(a, 'tags') || []).join(', ')}}${iface}`;
+ }
return `vm${pv(a, 'vmid')}${iface}`;
};
+ let assignmentIcon = (a) => {
+ if (a.type === 'tagassignment') {
+ return 'fa fa-fw fa-tag';
+ }
+ return 'fa fa-fw fa-link';
+ };
+
let buildNodes = (objects, identities) => {
let groups = (objects || [])
.filter((o) => o.type === 'group')
@@ -102,7 +113,7 @@ Ext.define('PVE.sdn.MicrosegTree', {
return {
id: assignId,
text: assignmentLabel(a),
- iconCls: 'fa fa-fw fa-link',
+ iconCls: assignmentIcon(a),
microsegEditType: a.type,
microsegId: a.id,
microsegMode: 'membership',
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (23 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 26/27] sdn: add microsegmentation section Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Register the regex assignment type with its own input panel (name
regex, interface, groups, comment) and label it in the microseg tree.
The add-assignment menu and tree filter pick it up from the schema
registry automatically.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/manager6/Utils.js | 5 ++
www/manager6/sdn/microseg/AssignmentEdit.js | 73 +++++++++++++++++++++
www/manager6/sdn/microseg/Base.js | 1 +
www/manager6/sdn/microseg/Tree.js | 6 ++
4 files changed, 85 insertions(+)
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 52022a6a..8b483ba1 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -985,6 +985,11 @@ Ext.define('PVE.Utils', {
ipanel: 'TagAssignmentInputPanel',
urlType: 'assignment',
},
+ regexassignment: {
+ name: gettext('Regex Assignment'),
+ ipanel: 'RegexAssignmentInputPanel',
+ urlType: 'assignment',
+ },
},
format_sdnmicroseg_type: function (value, md, record) {
diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js
index 9fca4685..81f8ca9d 100644
--- a/www/manager6/sdn/microseg/AssignmentEdit.js
+++ b/www/manager6/sdn/microseg/AssignmentEdit.js
@@ -58,6 +58,79 @@ Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', {
},
});
+Ext.define('PVE.sdn.microseg.RegexAssignmentInputPanel', {
+ extend: 'PVE.panel.SDNMicrosegBase',
+
+ onlineHelp: 'pvesdn_microseg_assignment',
+
+ autoId: true,
+
+ onGetValues: function (values) {
+ let me = this;
+ if (me.isCreate) {
+ values.type = me.type;
+ }
+ if (values.iface === '' || values.iface === null || values.iface === undefined) {
+ delete values.iface;
+ }
+ return me.callParent([values]);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.items = [
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'name_regex',
+ fieldLabel: gettext('Name regex'),
+ allowBlank: false,
+ maxLength: 1024,
+ emptyText: '^web-',
+ disabled: !me.isCreate,
+ validator: function (value) {
+ if (!value) {
+ return true;
+ }
+ try {
+ new RegExp(value);
+ return true;
+ } catch (err) {
+ return err.message;
+ }
+ },
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iface',
+ fieldLabel: gettext('Network interface'),
+ minValue: 0,
+ maxValue: 31,
+ allowBlank: true,
+ emptyText: gettext('All NICs'),
+ disabled: !me.isCreate,
+ },
+ {
+ xtype: 'pveMicrosegGroupSelector',
+ name: 'groups',
+ fieldLabel: gettext('Groups'),
+ multiSelect: true,
+ allowBlank: false,
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Comment'),
+ allowBlank: true,
+ maxLength: 256,
+ deleteEmpty: !me.isCreate,
+ },
+ ];
+
+ me.callParent();
+ },
+});
+
Ext.define('PVE.sdn.microseg.TagAssignmentInputPanel', {
extend: 'PVE.panel.SDNMicrosegBase',
diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js
index a2c263f9..b1d7b596 100644
--- a/www/manager6/sdn/microseg/Base.js
+++ b/www/manager6/sdn/microseg/Base.js
@@ -19,6 +19,7 @@ Ext.define('pve-sdn-microseg', {
'groups',
'tags',
'tag_match',
+ 'name_regex',
'direction',
],
idProperty: 'id',
diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js
index 47c13dfa..8d473b56 100644
--- a/www/manager6/sdn/microseg/Tree.js
+++ b/www/manager6/sdn/microseg/Tree.js
@@ -22,6 +22,9 @@ Ext.define('PVE.sdn.MicrosegTree', {
let match = pv(a, 'tag_match') || 'any';
return `${match}{${(pv(a, 'tags') || []).join(', ')}}${iface}`;
}
+ if (a.type === 'regexassignment') {
+ return `/${pv(a, 'name_regex')}/${iface}`;
+ }
return `vm${pv(a, 'vmid')}${iface}`;
};
@@ -29,6 +32,9 @@ Ext.define('PVE.sdn.MicrosegTree', {
if (a.type === 'tagassignment') {
return 'fa fa-fw fa-tag';
}
+ if (a.type === 'regexassignment') {
+ return 'fa fa-fw fa-code';
+ }
return 'fa fa-fw fa-link';
};
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-docs v2 26/27] sdn: add microsegmentation section
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (24 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pvesdn.adoc | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 118 insertions(+)
diff --git a/pvesdn.adoc b/pvesdn.adoc
index 3fd3533..bb8a36e 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -443,6 +443,124 @@ 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 a set of *groups*, 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 on a VXLAN-based zone, 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]). An SRv6-based carrier for routed
+setups exists at the API level, without UI support yet. 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. An interface can
+carry several groups at once. Its memberships form a set, the union of every
+assignment that covers it. Rules do not target interfaces directly but match
+this set through their predicates, so combinations of groups (a guest that is
+both `prod` and `web`) can be addressed without defining a group for every
+combination.
+
+Group configuration options:
+
+Name:: An identifier for the group.
+
+Mark:: A unique number from 1 to 65535 identifying the group, auto-assigned if
+ omitted. What is carried on the wire is a compact identity derived from an
+ interface's whole group set, not the individual marks.
+
+Comment:: Optional descriptive comment.
+
+[[pvesdn_microseg_rule]]
+Rules
+~~~~~
+
+A rule permits or denies traffic between a *source* and a *destination*
+predicate, each evaluated against an interface's group set by a match kind. As
+the default is deny, rules are only needed to permit traffic (or to deny a flow
+within a broader allow). Traffic within a single group is not permitted
+implicitly, so add an explicit rule for it.
+
+Rules are stateless and directional. Each packet is checked on its own, the
+source predicate against the sender's groups and the destination predicate
+against the receiver's, with no connection tracking. Return traffic needs its
+own rule. For a TCP connection from `web` to `app`, both a `web` to `app` and
+an `app` to `web` allow are required.
+
+Rule configuration options:
+
+Source / Destination:: The groups each predicate is evaluated against.
+
+Source match / Destination match:: How the predicate matches an interface's
+ groups. *any* fires when the set shares at least one listed group, *all* when
+ it has all of them, and *exact* when the groups equal the listed set.
+
+Priority:: On a conflict the highest-priority matching rule wins. Conflicting
+ verdicts tied at the top priority deny.
+
+Action:: Allow or deny.
+
+A built-in *untagged* group represents the no-group identity, an interface in no
+group, an external host, or a cross-node frame that arrives without a GBP tag.
+List it in a predicate like any other group, on its own (`exact` over
+`untagged`) or combined with real groups (`any` over `untagged, web`), to admit
+such traffic. This is what lets a managed guest exchange ARP / DHCP and reach its
+gateway, which are themselves untagged and would otherwise be dropped. It is
+meaningful as a rule *source*. As a destination it has no effect, since an
+untagged interface has no program to enforce on. The `untagged` group cannot be
+used in an assignment.
+
+[[pvesdn_microseg_assignment]]
+Assignments
+~~~~~~~~~~~
+
+An assignment binds guest network interfaces to a set of groups. It matches a
+specific guest, guests by their tags, or guests whose name matches a regular
+expression. An interface may be placed in several groups (by one assignment
+listing multiple groups, or by several assignments).
+
+The *tag* and *name regex* matchers are dynamic. They are re-evaluated against
+the current guests whenever the SDN configuration is applied, so tagging or
+renaming a guest shows up as a pending SDN change and takes effect on the next
+apply (until then a newly matched guest is treated as it was at the last apply,
+i.e. fail-closed). Both match at the guest level, so unless a specific interface
+is named the assignment covers all of a matching guest's interfaces.
+
+Assignment configuration options:
+
+Match by:: Whether to match a specific *guest*, guests by *tags*, or guests by
+ a *name regex*.
+
+Guest:: (guest matcher) The VM or container.
+
+Tags:: (tag matcher) The guest tags to match.
+
+Tag match:: (tag matcher) Whether a guest must carry *any* of the listed tags
+ (the default), *all* of them, or have *exact*ly the listed set.
+
+Name regex:: (regex matcher) A regular expression matched against each guest's
+ name (VM name or container hostname). It matches anywhere in the name, so anchor
+ it with `^` and `$` to match the whole name.
+
+Network interface:: Optional. The guest's netN interface. If left empty, all of
+ the guest's interfaces are covered.
+
+Groups:: The groups the matching interfaces are placed into.
+
[[pvesdn_config_controllers]]
Controllers
-----------
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
* [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
` (25 preceding siblings ...)
2026-07-09 9:18 ` [PATCH pve-docs v2 26/27] sdn: add microsegmentation section Hannes Laimer
@ 2026-07-09 9:18 ` Hannes Laimer
26 siblings, 0 replies; 28+ messages in thread
From: Hannes Laimer @ 2026-07-09 9:18 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pvesdn.adoc | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/pvesdn.adoc b/pvesdn.adoc
index bb8a36e..8b76af7 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -310,6 +310,14 @@ 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. Every VTEP in the zone must have it
+ enabled, and one without it drops GBP-tagged traffic. Applied at interface
+ creation. Enabling it on an existing zone recreates the zone's VXLAN
+ interfaces on the next apply, briefly interrupting traffic. Affects all VNets
+ in the zone. Optional.
+
[[pvesdn_zone_plugin_evpn]]
EVPN Zones
@@ -371,6 +379,14 @@ 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. Every VTEP in the zone must have it
+ enabled, and one without it drops GBP-tagged traffic. Applied at interface
+ creation. Enabling it on an existing zone recreates the zone's VXLAN
+ interfaces on the next apply, briefly interrupting traffic. Affects all VNets
+ in the zone. Optional.
+
[[pvesdn_config_vnet]]
VNets
--
2.47.3
^ permalink raw reply related [flat|nested] 28+ messages in thread
end of thread, other threads:[~2026-07-09 9:23 UTC | newest]
Thread overview: 28+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-07-09 9:18 ` [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 20/27] ui: sdn: add microsegmentation panel Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 22/27] network: apply microseg state on reload Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 26/27] sdn: add microsegmentation section Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox