From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id BE9571FF0E0 for ; Thu, 09 Jul 2026 11:21:29 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2E6E121669; Thu, 09 Jul 2026 11:19:41 +0200 (CEST) From: Hannes Laimer 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 Message-ID: <20260709091852.538885-13-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com> References: <20260709091852.538885-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1783588740137 X-SPAM-LEVEL: Spam detection results: 1 AWL -0.804 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_MAILER 2 Automated Mailer Tag Left in Email POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: IVZDBLYFW2DVVNO6DDN4F672SMYF7TID X-Message-ID-Hash: IVZDBLYFW2DVVNO6DDN4F672SMYF7TID X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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