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 A0E811FF0C0 for ; Wed, 21 Oct 2026 10:47:18 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 64AA021775; Wed, 21 Oct 2026 10:45:52 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v4 13/20] sdn: dhcp: run IP mapping edits on the guest's node Date: Wed, 21 Oct 2026 10:44:38 +0200 Message-ID: <20261021084445.462195-14-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261021084445.462195-1-h.laimer@proxmox.com> References: <20261021084445.462195-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: 1792572315285 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.212 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) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes 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 Message-ID-Hash: MBDJQ5BG3DVJGEATBYHKGOSKIOHE6BFB X-Message-ID-Hash: MBDJQ5BG3DVJGEATBYHKGOSKIOHE6BFB 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: IP mappings live in the cluster-wide IPAM and can be edited through the API on any node. The DHCP backends hold their copy on each node, dnsmasq in a reservation file and the ebpf responder in a kernel map. An edit only updated the backend of the node it was made on, so a guest on another node kept getting the old mapping. The API proxies an edit through /cluster/sdn/vnets/{vnet}/ips to the node of the guest whose NIC carries the MAC on that vnet, and the backend there is updated along with the record. Only that node answers the guest's DHCP requests, so no other node needs the update. The others catch up when the guest starts on one of them. An edit in a zone without a DHCP backend, or for a MAC no guest has, runs where it was made. When the guest's node cannot be reached the edit fails, as any proxied request does. Signed-off-by: Hannes Laimer --- Notes: v4: * use a proxyto callback to run the edit on the guest's node instead of pushing it there through a node endpoint * look the guest's node up in the NIC list by MAC src/PVE/API2/Network/SDN/Ips.pm | 16 +++++ src/PVE/Network/SDN/Dhcp.pm | 10 +++ src/test/run_test_vnets_blackbox.pl | 96 +++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/src/PVE/API2/Network/SDN/Ips.pm b/src/PVE/API2/Network/SDN/Ips.pm index d7b682dd..e92de511 100644 --- a/src/PVE/API2/Network/SDN/Ips.pm +++ b/src/PVE/API2/Network/SDN/Ips.pm @@ -6,6 +6,7 @@ use warnings; use PVE::Tools qw(extract_param); use PVE::Network::SDN::Vnets; +use PVE::Network::SDN::Zones; use PVE::Network::SDN::Dhcp; use PVE::JSONSchema qw(get_standard_option); @@ -13,12 +14,25 @@ use PVE::RESTHandler; use base qw(PVE::RESTHandler); +# only the guest's node answers its DHCP requests, so the edit runs there +my $proxy_to_guest_node = sub { + my ($rpcenv, $proxyto, $param) = @_; + + my $vnet = PVE::Network::SDN::Vnets::get_vnet($param->{vnet}, 1); + my $zone = $vnet ? PVE::Network::SDN::Zones::get_zone($vnet->{zone}, 1) : undef; + return 'localhost' if !$zone || !$zone->{dhcp}; + + my $node = PVE::Network::SDN::Dhcp::guest_node_by_mac($param->{vnet}, $param->{mac}); + return $node // 'localhost'; +}; + __PACKAGE__->register_method({ name => 'ipdelete', path => '', method => 'DELETE', description => 'Delete IP Mappings in a VNet', protected => 1, + proxyto_callback => $proxy_to_guest_node, permissions => { check => ['perm', '/sdn/zones/{zone}/{vnet}', ['SDN.Allocate']], }, @@ -58,6 +72,7 @@ __PACKAGE__->register_method({ method => 'POST', description => 'Create IP Mapping in a VNet', protected => 1, + proxyto_callback => $proxy_to_guest_node, permissions => { check => ['perm', '/sdn/zones/{zone}/{vnet}', ['SDN.Allocate']], }, @@ -95,6 +110,7 @@ __PACKAGE__->register_method({ method => 'PUT', description => 'Update IP Mapping in a VNet', protected => 1, + proxyto_callback => $proxy_to_guest_node, permissions => { check => ['perm', '/sdn/zones/{zone}/{vnet}', ['SDN.Allocate']], }, diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm index 5a76cfda..32faefe5 100644 --- a/src/PVE/Network/SDN/Dhcp.pm +++ b/src/PVE/Network/SDN/Dhcp.pm @@ -89,6 +89,16 @@ sub guest_nics { return $nics; } +sub guest_node_by_mac { + my ($vnetid, $mac) = @_; + + for my $nic ((guest_nics()->{ lc($mac) } // [])->@*) { + return $nic->{node} if $nic->{bridge} eq $vnetid; + } + + return undef; +} + sub tap_plug { my ($bridge, $iface, $mac) = @_; diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl index 516b13c6..28d58de1 100755 --- a/src/test/run_test_vnets_blackbox.pl +++ b/src/test/run_test_vnets_blackbox.pl @@ -1412,6 +1412,102 @@ sub test_dhcp_backend_needed_per_node { run_test(\&test_dhcp_backend_needed_per_node); +sub test_mapping_edit_target { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + create_zone({ + type => "simple", + dhcp => "dnsmasq", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => "10.0.0.1", + }); + create_zone({ + type => "simple", + ipam => "pve", + zone => "NODHCP", + }); + create_vnet({ + type => "vnet", + zone => "NODHCP", + vnet => "nodhcp", + }); + + # the guest behind a MAC is found by its NIC on the vnet, and the config + # writes the MAC in upper case + $test_state->{vmlist} = { + ids => { + 100 => { type => 'qemu', node => 'other' }, + 101 => { type => 'lxc', node => 'third' }, + }, + }; + $test_state->{guest_nets} = { + 1 => { net0 => "virtio=$mac,bridge=$vnetid" }, + 100 => { + net0 => "virtio=" . uc($mac) . ",bridge=$vnetid", + net1 => "virtio=$mac,bridge=nodhcp", + }, + 101 => { net0 => "name=eth0,bridge=vmbr0,hwaddr=00:11:22:33:44:55" }, + }; + is( + PVE::Network::SDN::Dhcp::guest_node_by_mac($vnetid, $mac), + 'other', + "$test_name: the MAC resolves to the node of its guest, a guest the list lacks is skipped", + ); + is( + PVE::Network::SDN::Dhcp::guest_node_by_mac($vnetid, '00:11:22:33:44:55'), + undef, + "$test_name: a MAC on another bridge does not resolve", + ); + is( + PVE::Network::SDN::Dhcp::guest_node_by_mac($vnetid, '00:11:22:33:44:66'), + undef, + "$test_name: an unknown MAC does not resolve", + ); + + # every mapping edit through the API runs on the guest's node + for my $method (qw(ipcreate ipupdate ipdelete)) { + my $info = PVE::API2::Network::SDN::Ips->map_method_by_name($method); + my $proxyto = $info->{proxyto_callback}; + is( + $proxyto->(undef, undef, { zone => $zoneid, vnet => $vnetid, mac => $mac }), + 'other', + "$test_name: $method runs on the guest's node", + ); + is( + $proxyto->( + undef, + undef, + { zone => $zoneid, vnet => $vnetid, mac => '00:11:22:33:44:66' }, + ), + 'localhost', + "$test_name: $method for a MAC without a guest runs here", + ); + is( + $proxyto->(undef, undef, { zone => 'NODHCP', vnet => 'nodhcp', mac => $mac }), + 'localhost', + "$test_name: $method in a zone without a backend runs here", + ); + } + delete $test_state->{vmlist}; + delete $test_state->{guest_nets}; +} + +run_test(\&test_mapping_edit_target); + sub test_dnsmasq_dual_stack_and_sweep { my $test_name = (split(/::/, (caller(0))[3]))[-1]; my $zoneid = "TESTZONE"; -- 2.47.3