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 58C161FF0DF for ; Thu, 05 Nov 2026 12:18:31 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id A7E6121950; Thu, 05 Nov 2026 12:15:47 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v4 25/45] sdn: microseg: add tag matcher Date: Thu, 5 Nov 2026 12:13:04 +0100 Message-ID: <20261105111324.706922-26-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105111324.706922-1-h.laimer@proxmox.com> References: <20261105111324.706922-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: 1793877234321 X-SPAM-LEVEL: Spam detection results: 0 AWL -2.216 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) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_DBL_SPAM 5 Contains a spam URL listed in the Spamhaus DBL blocklist [sdn.pm] Message-ID-Hash: 3X7XMX7TKW65EHLAHBJYBVZ3XU7GQWK4 X-Message-ID-Hash: 3X7XMX7TKW65EHLAHBJYBVZ3XU7GQWK4 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: A tag matcher binds every guest with the given tags to a set of groups, guests created later included. A tag the matchers name moves a guest between groups, an SDN change like putting a NIC on a vnet. So a new permission check requires SDN.Use on /sdn/microseg when a guest gains or loses such a tag. Signed-off-by: Hannes Laimer --- debian/control | 1 + src/PVE/API2/Network/SDN/Microseg/Base.pm | 7 ++- src/PVE/Network/SDN.pm | 1 + src/PVE/Network/SDN/Microseg.pm | 59 ++++++++++++++++++----- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/debian/control b/debian/control index 268f40186b6b..c8842ab3d37d 100644 --- a/debian/control +++ b/debian/control @@ -42,6 +42,7 @@ Description: Proxmox VE's SDN (Software Defined Network) stack Package: libpve-network-api-perl Architecture: all Depends: libpve-common-perl (>= 9.0.2), + libpve-guest-common-perl, pve-firewall (>= 5.1.0~), pve-cluster (>= 9.0.1), libpve-network-perl (= ${binary:Version}), diff --git a/src/PVE/API2/Network/SDN/Microseg/Base.pm b/src/PVE/API2/Network/SDN/Microseg/Base.pm index dde88d10c7b1..58ec75c6fb12 100644 --- a/src/PVE/API2/Network/SDN/Microseg/Base.pm +++ b/src/PVE/API2/Network/SDN/Microseg/Base.pm @@ -5,6 +5,7 @@ use warnings; use PVE::Cluster; use PVE::Exception qw(raise_param_exc); +use PVE::GuestHelpers; use PVE::JSONSchema qw(get_standard_option); use PVE::Tools qw(extract_param); @@ -85,7 +86,7 @@ my sub assert_non_empty_lists { my ($param) = @_; my $errors = {}; - for my $property (qw(src dst groups)) { + for my $property (qw(src dst groups tags)) { my $value = $param->{$property}; $errors->{$property} = "must not be empty" if $value && !@$value; } @@ -108,6 +109,10 @@ my sub create_object { my $lock_token = extract_param($param, 'lock-token'); $param->{type} = $type; + if ($param->{tags}) { + $param->{tags} = PVE::GuestHelpers::get_unique_tags($param->{tags}, 1); + } + PVE::Cluster::check_cfs_quorum(); mkdir("/etc/pve/sdn"); diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm index 0996de05794b..210553de685f 100644 --- a/src/PVE/Network/SDN.pm +++ b/src/PVE/Network/SDN.pm @@ -661,6 +661,7 @@ sub encode_value { || $key eq 'secondary-controllers' || $key eq 'redistribute' || $key eq 'groups' + || $key eq 'tags' || $key eq 'src' || $key eq 'dst' ) { diff --git a/src/PVE/Network/SDN/Microseg.pm b/src/PVE/Network/SDN/Microseg.pm index 9cfaf6b746ff..4b322dede8be 100644 --- a/src/PVE/Network/SDN/Microseg.pm +++ b/src/PVE/Network/SDN/Microseg.pm @@ -35,7 +35,7 @@ PVE::JSONSchema::register_standard_option( cfs_register_file('sdn/microseg.cfg', \&parse_microseg_config, \&write_microseg_config); -our $ASSIGNMENT_TYPES = ['guest-assignment']; +our $ASSIGNMENT_TYPES = ['guest-assignment', 'tag-assignment']; sub parse_microseg_config { my ($filename, $raw) = @_; @@ -223,7 +223,8 @@ sub assignment_properties { $properties->{type} = { type => 'string', enum => $ASSIGNMENT_TYPES, - description => "The matcher kind. 'guest-assignment' binds a specific guest.", + description => "The matcher kind. 'guest-assignment' binds a specific guest," + . " 'tag-assignment' every guest carrying the matched tags.", }; $properties->{vmid} = get_standard_option( 'pve-vmid', @@ -233,6 +234,27 @@ sub assignment_properties { description => "The guest whose NICs to bind.", }, ); + $properties->{tags} = { + type => 'array', + items => { + type => 'string', + format => 'pve-tag', + }, + 'type-property' => 'type', + 'instance-types' => ['tag-assignment'], + description => "The guest tags to match. They are normalized like the tags on a" + . " guest, so case only matters with a case-sensitive datacenter tag style.", + }; + $properties->{'tag-match'} = { + type => 'string', + enum => [@MATCH_KINDS], + optional => 1, + default => 'any', + 'type-property' => 'type', + 'instance-types' => ['tag-assignment'], + description => "How tags match. 'any' shares a listed tag, 'all' carries every" + . " listed tag, 'exact' equals the listed set.", + }; $properties->{iface} = { type => 'integer', minimum => 0, @@ -268,10 +290,21 @@ sub optional_properties { return { map { $_ => { $properties->{$_}->%*, optional => 1 } } keys $properties->%* }; } +sub check_tag_access { + my ($rpcenv, $authuser, $old, $new) = @_; + + my $old_tags = [PVE::Tools::split_list($old // '')]; + my $new_tags = [PVE::Tools::split_list($new // '')]; + my $policy = running_policy(PVE::Network::SDN::running_config()); + return if !$policy->tag_match_changed($old_tags, $new_tags); + $rpcenv->check($authuser, '/sdn/microseg', ['SDN.Use']); +} + sub guest_inventory { my (%opts) = @_; - my @props = map { "net$_" } 0 .. $MAX_NIC_INDEX; + # the data plane takes a guest's NICs from the node, so an apply leaves them out + my @props = ('tags', $opts{skip_nics} ? () : map { "net$_" } 0 .. $MAX_NIC_INDEX); my $by_vmid = PVE::Cluster::get_guest_config_properties(\@props, $opts{vmid}); @@ -279,11 +312,14 @@ sub guest_inventory { for my $vmid (sort { $a <=> $b } keys %$by_vmid) { my $guest = $by_vmid->{$vmid}; + my @tags = PVE::Tools::split_list($guest->{tags}); + my @nics = grep { defined $guest->{"net$_"} } 0 .. $MAX_NIC_INDEX; push @$inventory, { vmid => $vmid, + tags => \@tags, nics => \@nics, }; } @@ -311,13 +347,13 @@ my sub parse_guest_nic { return $iface =~ m/^(?:tap|veth)(\d+)i(\d+)$/; } -# the guest as the inventory lists it, with nothing but its vmid when it has no NICs +# the guest as the inventory lists it, with nothing but its vmid when it has no tags my sub guest_info { my ($vmid) = @_; - my ($guest) = guest_inventory(vmid => $vmid)->@*; + my ($guest) = guest_inventory(vmid => $vmid, skip_nics => 1)->@*; - return $guest // { vmid => int($vmid), nics => [] }; + return $guest // { vmid => int($vmid), tags => [], nics => [] }; } # an unmounted pmxcfs reads like a config that was never committed, and enforcement would be dropped @@ -358,11 +394,12 @@ sub apply_all { eval { my $lock = PVE::RS::SDN::Microseg::ExclusiveLock->new(); my $raw = read_running_config(); - $lock->apply($raw, guest_inventory()) - or !is_configured() - or log_warn("microseg: the running config carries no microseg block, as written" - . " before the upgrade or by a node without microsegmentation support, leaving" - . " enforcement unchanged"); + if (!$lock->apply($raw, guest_inventory(skip_nics => 1))) { + log_warn("microseg: the running config carries no microseg block, as written" + . " before the upgrade or by a node without microsegmentation support, leaving" + . " enforcement unchanged") + if is_configured(); + } }; die "microseg: failed to apply running config: $@" if $@; } -- 2.47.3