* [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support
@ 2026-09-22 10:09 Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 01/38] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
` (2 more replies)
0 siblings, 3 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-22 10:09 UTC (permalink / raw)
To: pve-devel
Guests on a vnet are separated only by IP addressing and the guest
firewall. Its address rules have to follow every move and renumbering.
No identity crosses nodes with a frame, and tags and names carry no
policy.
This series adds a group-based policy with a datacenter panel over
/etc/pve/sdn/microseg.cfg and /cluster/sdn/microseg. A rule has a
priority and a verdict and matches a source and a destination predicate
over groups, any, all or exact. It can be narrowed to a protocol and
ports, an ICMP type or a firewall macro. Assignments bind guest
interfaces to groups by guest, tag or name regex, and a subgroup member
is in every group above it. VXLAN and EVPN zones gain a vxlan-gbp option
that carries the identity across nodes. The host stamps it where a frame
enters the bridge, so a guest cannot forge its own.
Default is deny. Rules are stateless and directional, so a reply needs
its own allow, or the rule is marked both ways, which is exactly that
pair with the ports swapped. The highest priority wins, at a tie the
more specific all and exact predicates decide, and deny wins among
equals. Identities stay stable across config changes, and a retired one
is reused only after a quarantine. A full apply swaps a node's policy in
one transaction, and a guest interface is enforced before it is bridged.
A running config a node cannot read fails every guest start there, one
it cannot parse or build fails the start of guests with assigned
interfaces, as does an interface headed for an Open vSwitch port without
a firewall bridge. One already on such a port is named in the node's
network reload task and stays unenforced until the firewall option is
set. A guest matched after the last apply stays unmanaged until the next
one.
proxmox-ve-config carries the types, the identity engine and the desired
state, the policy in a form no enforcing mechanism depends on.
pve-cluster adds the observed file, pve-access-control the ACL path.
pve-network holds the config, the API, the zone option and the plug
trigger. proxmox-microseg, a library crate in the proxmox-firewall
workspace, renders the desired state into a bridge family nftables
table, and pve-rs binds it next to the config, so Perl applies it on an
SDN apply and on a NIC plug. pve-manager adds the panel and applies on
network reload. pve-docs adds the section. Connection tracking, rule
logging, the SRv6 carrier and an eBPF plane are left out on purpose.
libpve-network-perl loads only with the pve-cluster of this series and
needs its libpve-rs-perl, or every guest start on the node fails at the
plug. It gains dependencies on nftables and libpve-guest-common-perl.
The API package needs the libpve-access-control of this series, or no
ACL can be granted on /sdn/microseg and its endpoints only see what /sdn
passes down. pve-manager builds only with the pve-docs of this series,
whose anchors its help buttons name, and at runtime needs the new
libpve-network-perl, since its network reload applies the microseg
policy through it. pve-rs builds against the proxmox-microseg crate,
which pulls in proxmox-nftables. pve-rs and the crate both need a
proxmox-ve-config with the new module, and so does the proxmox-firewall
source package that carries the crate. None of these floors is bumped
here. The ifupdown2 vxlan-gbp attribute and four proxmox-nftables
patches (building without the config extension, mark-keyed verdict
maps, shared protocol rendering, crate packaging) are sent separately.
# changelog
v3:
- data plane moved from eBPF programs to an nftables bridge-family
table, proxmox-ebpf is gone and the data plane is a library crate in
the proxmox-firewall workspace that pve-rs binds, without an agent
binary or a boot-time unit since the first plug builds the table, the
desired state it renders lives in proxmox-ve-config, and
libpve-network-perl depends on nftables
- rules can be narrowed to a protocol, ports, an ICMP type or a
firewall macro, the protocol and port rendering is shared with the
firewall
- a rule can be marked both ways, which applies it with the predicates
and the ports swapped as well, under one id, so the reply rule of a
stateless pair no longer has to be written by hand
- groups can form a hierarchy, membership in a subgroup implies the
groups above it, and for all and exact predicates specificity breaks
priority ties so a subgroup rule outranks its parent's
- a plug fails when the running config cannot be read, or cannot be
parsed or built and the NIC is assigned, or an assigned NIC would
become an Open vSwitch port with no firewall bridge, an unreachable
pmxcfs fails the plug instead of reading as an empty config, and a
realized set without an identity fails the apply instead of passing
as untagged
- the microseg endpoints check their privileges on a new /sdn/microseg
ACL path, added by a pve-access-control patch in the series
- the overview endpoint reports identity id space usage, shown in the
tree header
- the pve-cluster patch also registers the file with the version array
pmxcfs keeps for observed files
- validation rejects mark 0 for a group, `untagged` in an all or exact
destination, and `untagged` next to real groups in an all or exact
source
- an assignment naming an interface index is realized whether or not
the guest has that interface yet, and tag matcher tags are
normalized like guest tags
- the running config stores the realized assignments per group set
with per-guest interface lists, so the file stays small at scale
- the network reload warns about an assigned interface that is already
up on an Open vSwitch port without a firewall bridge, since only a
plug can refuse one
- two-column rule editor reusing the firewall's selectors, the tree
shows subgroups
- the SRv6 carrier is out of the series, it belongs with an SRv6 zone
- the ifupdown2 `vxlan-gbp` attribute and the proxmox-nftables
additions with the crate packaging are sent as standalone patches,
the zone option stays in the series and the evpn vxlan-learning fix
is folded into it
- docs updated, cover letter rewritten
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 (8):
ve-config: firewall: add a constructor for sctp matches
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: narrow rules by protocol and ports
ve-config: sdn: microseg: add group hierarchy
ve-config: sdn: microseg: add the desired state
.../src/firewall/types/rule_match.rs | 4 +
.../src/sdn/microseg/identity.rs | 625 +++
proxmox-ve-config/src/sdn/microseg/mod.rs | 3819 +++++++++++++++++
proxmox-ve-config/src/sdn/microseg/state.rs | 316 ++
proxmox-ve-config/src/sdn/mod.rs | 1 +
5 files changed, 4765 insertions(+)
create mode 100644 proxmox-ve-config/src/sdn/microseg/identity.rs
create mode 100644 proxmox-ve-config/src/sdn/microseg/mod.rs
create mode 100644 proxmox-ve-config/src/sdn/microseg/state.rs
proxmox-firewall:
Hannes Laimer (5):
nftables: build without the config extension
nftables: add support for mark-keyed verdict maps
nftables: move protocol match rendering into the lib
nftables: package the crate for debcargo
microseg: add the SDN microsegmentation data plane
.gitignore | 4 +
Cargo.toml | 4 +
Makefile | 12 +-
build.sh | 46 ++
proxmox-firewall/src/rule.rs | 166 +---
proxmox-microseg/Cargo.toml | 22 +
proxmox-microseg/debian/changelog | 6 +
proxmox-microseg/debian/control | 46 ++
proxmox-microseg/debian/copyright | 18 +
proxmox-microseg/debian/debcargo.toml | 7 +
proxmox-microseg/debian/source/format | 1 +
proxmox-microseg/src/dataplane.rs | 12 +
proxmox-microseg/src/lib.rs | 41 +
proxmox-microseg/src/nft.rs | 458 +++++++++++
proxmox-microseg/src/nic.rs | 54 ++
proxmox-microseg/src/running_config.rs | 28 +
..._tests__empty_state_removes_the_table.snap | 24 +
..._tests__full_apply_replaces_the_table.snap | 726 ++++++++++++++++++
...t__tests__nic_apply_adds_its_elements.snap | 46 ++
proxmox-nftables/Cargo.toml | 7 +-
proxmox-nftables/debian/changelog | 6 +
proxmox-nftables/debian/control | 60 ++
proxmox-nftables/debian/copyright | 18 +
proxmox-nftables/debian/debcargo.toml | 7 +
proxmox-nftables/debian/source/format | 1 +
proxmox-nftables/src/expression.rs | 3 +-
proxmox-nftables/src/lib.rs | 2 +
proxmox-nftables/src/protocol.rs | 70 ++
proxmox-nftables/src/statement.rs | 10 +
proxmox-nftables/src/types.rs | 1 +
30 files changed, 1748 insertions(+), 158 deletions(-)
create mode 100755 build.sh
create mode 100644 proxmox-microseg/Cargo.toml
create mode 100644 proxmox-microseg/debian/changelog
create mode 100644 proxmox-microseg/debian/control
create mode 100644 proxmox-microseg/debian/copyright
create mode 100644 proxmox-microseg/debian/debcargo.toml
create mode 100644 proxmox-microseg/debian/source/format
create mode 100644 proxmox-microseg/src/dataplane.rs
create mode 100644 proxmox-microseg/src/lib.rs
create mode 100644 proxmox-microseg/src/nft.rs
create mode 100644 proxmox-microseg/src/nic.rs
create mode 100644 proxmox-microseg/src/running_config.rs
create mode 100644 proxmox-microseg/src/snapshots/proxmox_microseg__nft__tests__empty_state_removes_the_table.snap
create mode 100644 proxmox-microseg/src/snapshots/proxmox_microseg__nft__tests__full_apply_replaces_the_table.snap
create mode 100644 proxmox-microseg/src/snapshots/proxmox_microseg__nft__tests__nic_apply_adds_its_elements.snap
create mode 100644 proxmox-nftables/debian/changelog
create mode 100644 proxmox-nftables/debian/control
create mode 100644 proxmox-nftables/debian/copyright
create mode 100644 proxmox-nftables/debian/debcargo.toml
create mode 100644 proxmox-nftables/debian/source/format
create mode 100644 proxmox-nftables/src/protocol.rs
pve-cluster:
Hannes Laimer (1):
cfs: add 'sdn/microseg.cfg' to observed files
src/PVE/Cluster.pm | 1 +
src/pmxcfs/status.c | 1 +
2 files changed, 2 insertions(+)
pve-access-control:
Hannes Laimer (1):
permissions: add ACL path for SDN microsegmentation
src/PVE/AccessControl.pm | 1 +
1 file changed, 1 insertion(+)
proxmox-perl-rs:
Hannes Laimer (2):
pve-rs: sdn: add microseg config binding
pve-rs: sdn: microseg: bind the data plane
pve-rs/Cargo.toml | 2 +
pve-rs/Makefile | 1 +
pve-rs/debian/control | 1 +
pve-rs/src/bindings/sdn/microseg.rs | 278 ++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
5 files changed, 283 insertions(+)
create mode 100644 pve-rs/src/bindings/sdn/microseg.rs
pve-network:
Hannes Laimer (8):
sdn: zones: add vxlan-gbp option to vxlan and evpn zones
sdn: microseg: add config, API and guest inventory
sdn: dry-run: show pending microseg changes
sdn: zones: trigger microseg apply on tap_plug
sdn: microseg: add tag matcher
sdn: microseg: add name regex matcher
sdn: microseg: expose protocol, port and macro rule options
sdn: microseg: expose the group parent
debian/control | 2 +
src/PVE/API2/Network/SDN.pm | 38 +-
src/PVE/API2/Network/SDN/Makefile | 2 +
src/PVE/API2/Network/SDN/Microseg.pm | 277 +++++++
.../API2/Network/SDN/Microseg/Assignment.pm | 192 +++++
src/PVE/API2/Network/SDN/Microseg/Group.pm | 191 +++++
src/PVE/API2/Network/SDN/Microseg/Makefile | 10 +
src/PVE/API2/Network/SDN/Microseg/Rule.pm | 194 +++++
src/PVE/API2/Network/SDN/Zones.pm | 8 +
src/PVE/Network/SDN.pm | 36 +
src/PVE/Network/SDN/Makefile | 1 +
src/PVE/Network/SDN/Microseg.pm | 774 ++++++++++++++++++
src/PVE/Network/SDN/Zones.pm | 5 +
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 5 +
src/PVE/Network/SDN/Zones/VxlanPlugin.pm | 10 +
.../zones/evpn/gbp/expected_controller_config | 77 ++
.../zones/evpn/gbp/expected_sdn_interfaces | 45 +
src/test/zones/evpn/gbp/interfaces | 7 +
src/test/zones/evpn/gbp/sdn_config | 26 +
.../zones/vxlan/gbp/expected_sdn_interfaces | 16 +
src/test/zones/vxlan/gbp/interfaces | 7 +
src/test/zones/vxlan/gbp/sdn_config | 11 +
22 files changed, 1932 insertions(+), 2 deletions(-)
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
create mode 100644 src/test/zones/evpn/gbp/expected_controller_config
create mode 100644 src/test/zones/evpn/gbp/expected_sdn_interfaces
create mode 100644 src/test/zones/evpn/gbp/interfaces
create mode 100644 src/test/zones/evpn/gbp/sdn_config
create mode 100644 src/test/zones/vxlan/gbp/expected_sdn_interfaces
create mode 100644 src/test/zones/vxlan/gbp/interfaces
create mode 100644 src/test/zones/vxlan/gbp/sdn_config
pve-manager:
Hannes Laimer (9):
network: apply microseg state on reload
ui: firewall: register the ICMP type stores by id
ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn
ui: sdn: add microsegmentation panel
ui: sdn: dry-run: show pending microseg diff
ui: sdn: microseg: add tag matcher
ui: sdn: microseg: add name regex matcher
ui: sdn: microseg: add narrowing to the rule form
ui: sdn: microseg: show and edit the group hierarchy
PVE/API2/Network.pm | 4 +
www/css/ext6-pve.css | 8 +
www/manager6/Makefile | 8 +
www/manager6/Utils.js | 34 ++
www/manager6/data/PermPathStore.js | 1 +
www/manager6/dc/Config.js | 8 +
www/manager6/form/MicrosegGroupSelector.js | 95 ++++
www/manager6/grid/FirewallRules.js | 2 +
www/manager6/sdn/MicrosegView.js | 43 ++
www/manager6/sdn/SdnDiffView.js | 24 +
www/manager6/sdn/microseg/AssignmentEdit.js | 238 ++++++++++
www/manager6/sdn/microseg/Base.js | 123 ++++++
www/manager6/sdn/microseg/GroupEdit.js | 47 ++
www/manager6/sdn/microseg/PolicyView.js | 401 +++++++++++++++++
www/manager6/sdn/microseg/RuleEdit.js | 285 ++++++++++++
www/manager6/sdn/microseg/Tree.js | 460 ++++++++++++++++++++
www/manager6/sdn/zones/EvpnEdit.js | 8 +
www/manager6/sdn/zones/VxlanEdit.js | 11 +
18 files changed, 1800 insertions(+)
create mode 100644 www/manager6/form/MicrosegGroupSelector.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 (4):
sdn: add VXLAN-GBP flag to evpn/vxlan zone sections
sdn: add microsegmentation section
sdn: microseg: document narrowed rules
sdn: microseg: document group hierarchy and the specificity tie-break
pvesdn.adoc | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 264 insertions(+)
Summary over all repositories:
84 files changed, 10795 insertions(+), 160 deletions(-)
--
Generated by murpp 0.12.0
^ permalink raw reply [flat|nested] 4+ messages in thread
* [PATCH proxmox-ve-rs v3 01/38] ve-config: firewall: add a constructor for sctp matches
2026-09-22 10:09 [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
@ 2026-09-22 10:09 ` Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 02/38] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-09-22 10:13 ` [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
2 siblings, 0 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-22 10:09 UTC (permalink / raw)
To: pve-devel
The tcp and udp matches can already be built from a port pair, while
an sctp match is only reachable by parsing rule options. Add the
missing constructor so a match can be assembled from parts for every
protocol that carries ports.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ve-config/src/firewall/types/rule_match.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/proxmox-ve-config/src/firewall/types/rule_match.rs b/proxmox-ve-config/src/firewall/types/rule_match.rs
index 8e142f7..8e9c0c6 100644
--- a/proxmox-ve-config/src/firewall/types/rule_match.rs
+++ b/proxmox-ve-config/src/firewall/types/rule_match.rs
@@ -437,6 +437,10 @@ pub struct Sctp {
}
impl Sctp {
+ pub fn new(ports: Ports) -> Self {
+ Self { ports }
+ }
+
fn from_options(options: &RuleOptions) -> Result<Self, Error> {
Ok(Self {
ports: Ports::from_options(options)?,
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread
* [PATCH proxmox-ve-rs v3 02/38] ve-config: sdn: add microseg signature-identity engine
2026-09-22 10:09 [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 01/38] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
@ 2026-09-22 10:09 ` Hannes Laimer
2026-09-22 10:13 ` [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
2 siblings, 0 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-22 10:09 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. The id space therefore scales with the sets the policy can
distinguish, not with every group combination. All hosts agree on what
a number means, since 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 and merges retire
the losing number. A retired number becomes reusable only after a
quarantine long enough that no packet stamped under its old meaning
can still exist. Reuse starts only once the counter has issued every
number, which a long-lived cluster can reach, and from then on numbers
whose quarantine has passed are handed out. Exhaustion, the failure,
needs all numbers live or in quarantine at once.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
.../src/sdn/microseg/identity.rs | 625 ++++++++++++++++++
proxmox-ve-config/src/sdn/microseg/mod.rs | 9 +
proxmox-ve-config/src/sdn/mod.rs | 1 +
3 files changed, 635 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..d8b7ae8
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/microseg/identity.rs
@@ -0,0 +1,625 @@
+//! 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* that is stamped on the wire. It also carries a *representative*, the smallest
+//! member set in sort order. That keeps a later split or merge deterministic and ids 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`). The counter never rewinds, so fresh-first ends once it has issued
+//! every number over the cluster's lifetime. Allocation then reclaims the lowest number whose
+//! quarantine has passed. Exhaustion needs all 65535 numbers live or quarantined 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(crate) 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(crate) 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.
+ Any,
+ /// Fires when the set contains every group of the predicate.
+ 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.
+#[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(crate) struct Signature(Vec<bool>);
+
+/// Compute a set's signature against `policies` (canonical order).
+pub(crate) 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
+ /// [`int_key_serde`]) so the Perl/JSON round trip the running config takes does not choke on
+ /// integer map keys.
+ #[serde(with = "int_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 = "int_key_serde",
+ skip_serializing_if = "BTreeMap::is_empty"
+ )]
+ retired: BTreeMap<u16, u64>,
+}
+
+/// Serialize an integer-keyed map with string keys. JSON object keys are strings and the Perl round
+/// trip through perlmod hands them back as strings, so keys are stringified on the way out and
+/// parsed on the way in.
+pub(super) mod int_key_serde {
+ use std::collections::BTreeMap;
+ use std::fmt::Display;
+ use std::str::FromStr;
+
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+ pub fn serialize<S: Serializer, K: Display, V: Serialize>(
+ map: &BTreeMap<K, V>,
+ serializer: S,
+ ) -> Result<S::Ok, S::Error> {
+ serializer.collect_map(map.iter().map(|(key, value)| (key.to_string(), value)))
+ }
+
+ pub fn deserialize<'de, D: Deserializer<'de>, K: FromStr + Ord, V: Deserialize<'de>>(
+ deserializer: D,
+ ) -> Result<BTreeMap<K, V>, D::Error>
+ where
+ K::Err: Display,
+ {
+ BTreeMap::<String, V>::deserialize(deserializer)?
+ .into_iter()
+ .map(|(key, value)| {
+ key.parse::<K>()
+ .map(|key| (key, 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.
+ #[cfg(test)]
+ pub fn from_parts(
+ next: u16,
+ classes: BTreeMap<u16, GroupSet>,
+ retired: BTreeMap<u16, u64>,
+ ) -> Self {
+ Self {
+ next,
+ classes,
+ retired,
+ }
+ }
+
+ #[cfg(test)]
+ 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.
+ #[cfg(test)]
+ 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, which a rendered running config
+ /// never produces.
+ #[cfg(test)]
+ pub(crate) 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 collapse into a single class with its own id. That id is 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);
+ }
+
+ // 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 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));
+ }
+ }
+
+ // 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. Reusable means not the untagged id, not reserved (previously live
+/// or still quarantined) and 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. A `BTreeSet` iterates its members in `Ord` (lexicographic) order,
+/// so the first is the representative.
+fn representative<'a>(members: &BTreeSet<&'a GroupSet>) -> &'a GroupSet {
+ members
+ .iter()
+ .next()
+ .copied()
+ .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_eq!(
+ signature(&set(&[5]), &policies),
+ Signature(vec![false, false]),
+ "no predicate fires on (5)"
+ );
+ }
+
+ #[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 and quarantined. The counter stays at 3,
+ // so 2 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. 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. 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..bf57ae3
--- /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*. Those sets are allocated wire identities (see [`identity`]) once cluster-side
+//! at render and replicated. Each host only looks an identity up and compiles 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] 4+ messages in thread
* Re: [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support
2026-09-22 10:09 [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 01/38] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 02/38] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
@ 2026-09-22 10:13 ` Hannes Laimer
2 siblings, 0 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-22 10:13 UTC (permalink / raw)
To: pve-devel
did not intend to send these, please ignore.
sorry for the noise
On 2026-09-22 12:10, Hannes Laimer wrote:
> Guests on a vnet are separated only by IP addressing and the guest
...
>
^ permalink raw reply [flat|nested] 4+ messages in thread
end of thread, other threads:[~2026-09-22 10:13 UTC | newest]
Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-22 10:09 [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 01/38] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
2026-09-22 10:09 ` [PATCH proxmox-ve-rs v3 02/38] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-09-22 10:13 ` [PATCH access-control/cluster/docs/manager/network/proxmox{-ve-rs,-firewall,-perl-rs} v3 00/38] sdn: add microsegmentation support Hannes Laimer
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox