From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory
Date: Thu, 9 Jul 2026 11:18:37 +0200 [thread overview]
Message-ID: <20260709091852.538885-13-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com>
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
next prev parent reply other threads:[~2026-07-09 9:21 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
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 ` Hannes Laimer [this message]
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
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260709091852.538885-13-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.