From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id DD2AE1FF09C for ; Mon, 02 Nov 2026 15:58:12 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 8AE7F21774; Mon, 02 Nov 2026 15:57:20 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v2 09/11] sdn: ipam: give each zone a VRF in NetBox and a section in phpIPAM Date: Mon, 2 Nov 2026 15:56:28 +0100 Message-ID: <20261102145630.1555953-10-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261102145630.1555953-1-h.laimer@proxmox.com> References: <20261102145630.1555953-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: 1793631403749 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.643 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 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: URCUC2F5H5ODHXC6EZSIZIH6Z343LB3D X-Message-ID-Hash: URCUC2F5H5ODHXC6EZSIZIH6Z343LB3D 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: Two zones can use the same subnet, and an external IPAM asked by the address alone cannot tell their records apart. A zone's subnets now go into a VRF in NetBox and into a section under the configured section in phpIPAM, both named pve- and created with the first subnet added to the zone. A subnet being added goes into the zone's VRF or section, also when another zone holds the same range in its own. A range the global table or the configured section still holds is refused, since a copy would hide the records in it. The error says to move that prefix or subnet into the zone's VRF or section, or to remove it. phpIPAM nests sections one level deep, so under a configured section that is a sub-section itself the zones share the configured section as before. Subnets from before stay in the global table or the configured section and are still found there, so nothing has to be moved. The phpIPAM lookup by MAC, unimplemented until now, returns the addresses recorded for the MAC in the vnet's subnets, and its gateway check answers for the subnet it is asked about and for no other. A failed lookup stops the operation, only a not found answer means there is no such section, subnet or record. Signed-off-by: Hannes Laimer --- src/PVE/Network/SDN/Ipams/NetboxPlugin.pm | 164 ++++++--- src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm | 198 +++++++++-- src/test/ipams/netbox/expected.add_ip | 2 +- .../ipams/netbox/expected.add_ip_notgateway | 2 +- src/test/ipams/netbox/expected.add_subnet | 2 +- src/test/ipams/netbox/expected.update_ip | 2 +- src/test/run_test_ipams.pl | 317 ++++++++++++++++++ 7 files changed, 612 insertions(+), 75 deletions(-) diff --git a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm index 84ce4382e0e7..f2d1af55d154 100644 --- a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm +++ b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm @@ -40,18 +40,15 @@ sub netbox_api_request { } sub add_dhcp_range { - my ($config, $dhcp_range, $noerr) = @_; + my ($config, $dhcp_range, $vrf_id, $noerr) = @_; my $result = eval { - netbox_api_request( - $config, - "POST", - "/ipam/ip-ranges/", - { - start_address => $dhcp_range->{'start-address'}, - end_address => $dhcp_range->{'end-address'}, - }, - ); + my $params = { + start_address => $dhcp_range->{'start-address'}, + end_address => $dhcp_range->{'end-address'}, + }; + $params->{vrf} = $vrf_id if defined($vrf_id); + netbox_api_request($config, "POST", "/ipam/ip-ranges/", $params); }; if ($@) { return if $noerr; @@ -72,18 +69,44 @@ sub del_dhcp_range { # Plugin implementation +my sub zone_vrf_name { + my ($zoneid) = @_; + + return "pve-$zoneid"; +} + sub add_subnet { my ($class, $plugin_config, $subnetid, $subnet, $noerr) = @_; my $cidr = $subnet->{cidr}; my $gateway = $subnet->{gateway}; - if (get_prefix_id($plugin_config, $cidr, $noerr)) { + # a subnet being added is new to its zone, so it goes into the zone's VRF, + # and a prefix from before in the global table is not taken over, since + # a copy in the VRF would hide its records + my $vrf_id = get_zone_namespace($plugin_config, $subnet->{zone}, $noerr); + if (defined($vrf_id) && defined(get_prefix_id($plugin_config, $cidr, $vrf_id, $noerr))) { return if $noerr; die "prefix $cidr already exists in netbox"; } + if (defined(get_prefix_id($plugin_config, $cidr, undef, $noerr))) { + return if $noerr; + my $vrf = zone_vrf_name($subnet->{zone}); + die "prefix $cidr exists in the global table of NetBox, move it with its" + . " addresses and ranges into VRF $vrf or remove it first\n"; + } + + $vrf_id //= add_zone_namespace($plugin_config, $subnet->{zone}, $noerr); + return if !defined($vrf_id) && $noerr; - eval { netbox_api_request($plugin_config, "POST", "/ipam/prefixes/", { prefix => $cidr }); }; + eval { + netbox_api_request( + $plugin_config, + "POST", + "/ipam/prefixes/", + { prefix => $cidr, vrf => $vrf_id }, + ); + }; if ($@) { return if $noerr; die "error adding subnet to ipam: $@"; @@ -91,7 +114,7 @@ sub add_subnet { my $dhcp_ranges = PVE::Network::SDN::Subnets::get_dhcp_ranges($subnet); for my $dhcp_range (@$dhcp_ranges) { - add_dhcp_range($plugin_config, $dhcp_range, $noerr); + add_dhcp_range($plugin_config, $dhcp_range, $vrf_id, $noerr); } } @@ -122,6 +145,8 @@ sub update_subnet { my $to_delete_ids = (); + my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); + # delete first so we don't get errors with overlapping ranges for my $dhcp_range (@$old_dhcp_ranges) { my $hash = $hash_range->($dhcp_range); @@ -130,7 +155,7 @@ sub update_subnet { next; } - my $internalid = get_iprange_id($plugin_config, $dhcp_range, $noerr); + my $internalid = get_iprange_id($plugin_config, $dhcp_range, $vrf_id, $noerr); # definedness check, because ID could be 0 if (!defined($internalid)) { @@ -145,7 +170,7 @@ sub update_subnet { for my $dhcp_range (@$new_dhcp_ranges) { my $hash = $hash_range->($dhcp_range); - add_dhcp_range($plugin_config, $dhcp_range, $noerr) + add_dhcp_range($plugin_config, $dhcp_range, $vrf_id, $noerr) if !exists($old_lookup->{$hash}); } } @@ -155,7 +180,7 @@ sub del_subnet { my $cidr = $subnet->{cidr}; - my $internalid = get_prefix_id($plugin_config, $cidr, $noerr); + my ($internalid, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); # definedness check, because ID could be 0 if (!defined($internalid)) { @@ -163,7 +188,7 @@ sub del_subnet { return; } - if (!is_prefix_empty($plugin_config, $cidr, $noerr)) { + if (!is_prefix_empty($plugin_config, $cidr, $vrf_id, $noerr)) { return if $noerr; die "not deleting prefix $cidr because it still contains entries"; } @@ -176,7 +201,7 @@ sub del_subnet { my $dhcp_ranges = PVE::Network::SDN::Subnets::get_dhcp_ranges($subnet); for my $dhcp_range (@$dhcp_ranges) { - my $internalid = get_iprange_id($plugin_config, $dhcp_range, $noerr); + my $internalid = get_iprange_id($plugin_config, $dhcp_range, $vrf_id, $noerr); # definedness check, because ID could be 0 if (!defined($internalid)) { @@ -215,6 +240,8 @@ sub add_ip { $description = "mac:$mac"; } + my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); + eval { my $params = { address => "$ip/$mask", @@ -222,6 +249,7 @@ sub add_ip { }; $params->{dns_name} = $hostname if $hostname; + $params->{vrf} = $vrf_id if defined($vrf_id); netbox_api_request($plugin_config, "POST", "/ipam/ip-addresses/", $params); }; @@ -229,7 +257,7 @@ sub add_ip { if ($@) { if ($is_gateway) { die "error add subnet ip to ipam: ip $ip already exist: $@" - if !is_ip_gateway($plugin_config, $ip, $noerr); + if !is_ip_gateway($plugin_config, $ip, $vrf_id, $noerr); } elsif (!$noerr) { die "error add subnet ip to ipam: ip already exist: $@"; } @@ -259,7 +287,8 @@ sub update_ip { $description = "mac:$mac"; } - my $ip_id = get_ip_id($plugin_config, $ip, $noerr); + my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); + my $ip_id = get_ip_id($plugin_config, $ip, $vrf_id, $noerr); # definedness check, because ID could be 0 if (!defined($ip_id)) { @@ -274,6 +303,7 @@ sub update_ip { }; $params->{dns_name} = $hostname if $hostname; + $params->{vrf} = $vrf_id if defined($vrf_id); netbox_api_request($plugin_config, "PATCH", "/ipam/ip-addresses/$ip_id/", $params); }; @@ -287,7 +317,7 @@ sub add_next_freeip { my $cidr = $subnet->{cidr}; - my $internalid = get_prefix_id($plugin_config, $cidr, $noerr); + my ($internalid) = subnet_prefix($plugin_config, $subnet, $noerr); # definedness check, because ID could be 0 if (!defined($internalid)) { @@ -323,7 +353,8 @@ sub add_next_freeip { sub add_range_next_freeip { my ($class, $plugin_config, $subnet, $range, $data, $noerr) = @_; - my $internalid = get_iprange_id($plugin_config, $range, $noerr); + my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); + my $internalid = get_iprange_id($plugin_config, $range, $vrf_id, $noerr); # definedness check, because ID could be 0 if (!defined($internalid)) { @@ -364,7 +395,8 @@ sub del_ip { return if !$ip; - my $ip_id = get_ip_id($plugin_config, $ip, $noerr); + my (undef, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); + my $ip_id = get_ip_id($plugin_config, $ip, $vrf_id, $noerr); if (!defined($ip_id)) { warn "could not find id for ip $ip"; return; @@ -397,12 +429,16 @@ sub get_ips_from_mac { my $ip4 = undef; my $ip6 = undef; - for my $cidr (sort map { $_->{cidr} } values %{ $subnets // {} }) { + for my $subnet (sort { $a->{cidr} cmp $b->{cidr} } values %{ $subnets // {} }) { + my $cidr = $subnet->{cidr}; + my ($prefix_id, $vrf_id) = subnet_prefix($plugin_config, $subnet, $noerr); + next if !defined($prefix_id); + my $vrf = vrf_filter($vrf_id); my $data = eval { netbox_api_request( $plugin_config, "GET", - "/ipam/ip-addresses/?parent=$cidr&description__ic=$mac", + "/ipam/ip-addresses/?parent=$cidr&$vrf&description__ic=$mac", ); }; if ($@) { @@ -439,14 +475,59 @@ sub on_update_hook { } # helpers -sub get_prefix_id { - my ($config, $cidr, $noerr) = @_; - # we need to supply any IP inside the prefix, without supplying the mask, so - # just take the one from the cidr - my ($ip, undef) = split(/\//, $cidr); +sub vrf_filter { + my ($vrf_id) = @_; + + return defined($vrf_id) ? "vrf_id=$vrf_id" : "vrf_id=null"; +} + +sub get_zone_namespace { + my ($config, $zoneid, $noerr) = @_; + + my $name = zone_vrf_name($zoneid); + my $result = eval { netbox_api_request($config, "GET", "/ipam/vrfs/?name=$name") }; + if ($@) { + return if $noerr; + die "could not obtain ID for VRF $name: $@"; + } + + my $data = @{ $result->{results} }[0]; + return $data->{id}; +} + +sub add_zone_namespace { + my ($config, $zoneid, $noerr) = @_; + + my $name = zone_vrf_name($zoneid); + my $result = eval { netbox_api_request($config, "POST", "/ipam/vrfs/", { name => $name }) }; + if ($@) { + return if $noerr; + die "could not create VRF $name: $@"; + } + + return $result->{id}; +} + +# a prefix from before sits in the global table and keeps its addresses there, +# so a subnet is looked for in the zone's VRF first and in the global table +# after it, with none as the VRF of the global table +sub subnet_prefix { + my ($config, $subnet, $noerr) = @_; + + my $vrf_id = get_zone_namespace($config, $subnet->{zone}, $noerr); + if (defined($vrf_id)) { + my $prefix_id = get_prefix_id($config, $subnet->{cidr}, $vrf_id, $noerr); + return ($prefix_id, $vrf_id) if defined($prefix_id); + } + return (get_prefix_id($config, $subnet->{cidr}, undef, $noerr), undef); +} + +sub get_prefix_id { + my ($config, $cidr, $vrf_id, $noerr) = @_; - my $result = eval { netbox_api_request($config, "GET", "/ipam/prefixes/?q=$ip") }; + my $vrf = vrf_filter($vrf_id); + my $result = eval { netbox_api_request($config, "GET", "/ipam/prefixes/?prefix=$cidr&$vrf") }; if ($@) { return if $noerr; die "could not obtain ID for prefix $cidr: $@"; @@ -457,13 +538,14 @@ sub get_prefix_id { } sub get_iprange_id { - my ($config, $range, $noerr) = @_; + my ($config, $range, $vrf_id, $noerr) = @_; + my $vrf = vrf_filter($vrf_id); my $result = eval { netbox_api_request( $config, "GET", - "/ipam/ip-ranges/?start_address=$range->{'start-address'}&end_address=$range->{'end-address'}", + "/ipam/ip-ranges/?start_address=$range->{'start-address'}&end_address=$range->{'end-address'}&$vrf", ); }; if ($@) { @@ -477,9 +559,10 @@ sub get_iprange_id { } sub get_ip_id { - my ($config, $ip, $noerr) = @_; + my ($config, $ip, $vrf_id, $noerr) = @_; - my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip") }; + my $vrf = vrf_filter($vrf_id); + my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip&$vrf") }; if ($@) { return if $noerr; die "could not obtain ID for IP $ip: $@"; @@ -490,9 +573,10 @@ sub get_ip_id { } sub is_ip_gateway { - my ($config, $ip, $noerr) = @_; + my ($config, $ip, $vrf_id, $noerr) = @_; - my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip") }; + my $vrf = vrf_filter($vrf_id); + my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?q=$ip&$vrf") }; if ($@) { return if $noerr; die "could not obtain ipam entry for address $ip: $@"; @@ -503,9 +587,11 @@ sub is_ip_gateway { } sub is_prefix_empty { - my ($config, $cidr, $noerr) = @_; + my ($config, $cidr, $vrf_id, $noerr) = @_; - my $result = eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?parent=$cidr") }; + my $vrf = vrf_filter($vrf_id); + my $result = + eval { netbox_api_request($config, "GET", "/ipam/ip-addresses/?parent=$cidr&$vrf") }; if ($@) { return if $noerr; die "could not query children for prefix $cidr: $@"; diff --git a/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm b/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm index af33868f32f6..32d961e9efb6 100644 --- a/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm +++ b/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm @@ -2,6 +2,9 @@ package PVE::Network::SDN::Ipams::PhpIpamPlugin; use strict; use warnings; +use Math::BigInt; +use Net::IP; + use PVE::INotify; use PVE::Cluster; use PVE::Tools; @@ -38,6 +41,15 @@ sub options { # Plugin implementation +# a zone's section is a sub-section of the configured one, so SDN writes only +# under the section it was given. phpIPAM wants at least three characters in a +# section name, a zone id may have two +my sub zone_section_name { + my ($zoneid) = @_; + + return "pve-$zoneid"; +} + sub add_subnet { my ($class, $plugin_config, $subnetid, $subnet, $noerr) = @_; @@ -47,15 +59,33 @@ sub add_subnet { my $url = $plugin_config->{url}; my $token = $plugin_config->{token}; - my $section = $plugin_config->{section}; my $fingerprint = $plugin_config->{fingerprint}; my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token]; + # a subnet being added is new to its zone, so it goes into the zone's + # section, and a subnet from before in the configured section is not taken + # over, since a copy in the zone's section would hide its records + my $section = get_zone_namespace($plugin_config, $subnet->{zone}, $headers); + $section //= shared_section($plugin_config, $headers); + #search subnet - my $internalid = eval { get_prefix_id($url, $cidr, $headers) }; + my $internalid = eval { get_prefix_id($url, $cidr, $headers, $section) }; + my $in_shared = defined($section) && $section == $plugin_config->{section}; + if ( + !$internalid + && !$in_shared + && defined(get_prefix_id($url, $cidr, $headers, $plugin_config->{section})) + ) { + return if $noerr; + my $name = zone_section_name($subnet->{zone}); + die "subnet $cidr exists in section $plugin_config->{section} of phpIPAM, move it" + . " into sub-section $name of that section or remove it first\n"; + } #create subnet if (!$internalid) { + $section //= add_zone_namespace($plugin_config, $subnet->{zone}, $headers, $noerr); + return if !defined($section); my $params = { subnet => $network, mask => $mask, @@ -85,7 +115,7 @@ sub del_subnet { my $fingerprint = $plugin_config->{fingerprint}; my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token]; - my $internalid = get_prefix_id($url, $cidr, $headers); + my $internalid = subnet_id($plugin_config, $subnet, $headers); return if !$internalid; return; #fixme: check that prefix is empty exluding gateway, before delete @@ -118,7 +148,7 @@ sub add_ip { my $fingerprint = $plugin_config->{fingerprint}; my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token]; - my $internalid = get_prefix_id($url, $cidr, $headers); + my $internalid = subnet_id($plugin_config, $subnet, $headers); my $params = { ip => $ip, @@ -138,7 +168,7 @@ sub add_ip { if ($@) { if ($is_gateway) { die "error add subnet ip to ipam: ip $ip already exist: $@" - if !is_ip_gateway($url, $ip, $headers) && !$noerr; + if !is_ip_gateway($url, $ip, $headers, $internalid) && !$noerr; } else { die "error add subnet ip to ipam: ip $ip already exist: $@" if !$noerr; } @@ -165,7 +195,7 @@ sub update_ip { my $fingerprint = $plugin_config->{fingerprint}; my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token]; - my $ip_id = get_ip_id($url, $ip, $headers); + my $ip_id = get_ip_id($url, $ip, $headers, subnet_id($plugin_config, $subnet, $headers)); die "can't find ip address in ipam\n" if !$ip_id; my $params = { @@ -196,7 +226,7 @@ sub add_next_freeip { my $fingerprint = $plugin_config->{fingerprint}; my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token]; - my $internalid = get_prefix_id($url, $cidr, $headers); + my $internalid = subnet_id($plugin_config, $subnet, $headers); my $params = { hostname => $hostname, @@ -246,7 +276,7 @@ sub del_ip { my $fingerprint = $plugin_config->{fingerprint}; my $headers = ['Content-Type' => 'application/json; charset=UTF-8', 'Token' => $token]; - my $ip_id = get_ip_id($url, $ip, $headers); + my $ip_id = get_ip_id($url, $ip, $headers, subnet_id($plugin_config, $subnet, $headers)); return if !$ip_id; if ($mac) { my $record = eval { @@ -277,6 +307,24 @@ sub del_ip { return 1; } +# phpIPAM answers a lookup that finds nothing with a 404 +my sub not_found { + my ($err) = @_; + return $err =~ m/^Invalid response from server: 404 /; +} + +# an address comes back in its text form or as the decimal phpIPAM stores +my sub address_of_hit { + my ($hit, $family) = @_; + + my $ip = $hit->{ip} // $hit->{ip_addr}; + return undef if !defined($ip); + return $ip if $ip !~ m/^\d+$/; + my $bin = Net::IP::ip_inttobin(Math::BigInt->new($ip), $family) // return undef; + $ip = Net::IP::ip_bintoip($bin, $family); + return $family == 6 ? Net::IP::ip_compress_address($ip, 6) : $ip; +} + sub get_ips_from_mac { my ($class, $plugin_config, $mac, $zoneid, $subnets) = @_; @@ -288,7 +336,15 @@ sub get_ips_from_mac { my $ip4 = undef; my $ip6 = undef; - my $ips = eval { + # a hit names its subnet, and only the vnet's subnets count + my %wanted; + for my $subnet (values %{ $subnets // {} }) { + my $id = subnet_id($plugin_config, $subnet, $headers) // next; + $wanted{$id} = Net::IP::ip_is_ipv4($subnet->{network}) ? 4 : 6; + } + return if !%wanted; + + my $result = eval { PVE::Network::SDN::api_request( "GET", "$url/addresses/search_mac/$mac", @@ -297,19 +353,19 @@ sub get_ips_from_mac { $fingerprint, ); }; - return if $@; - - #fixme - die "parsing of result not yet implemented"; - - for my $ip (@$ips) { - # if ($ip->{family}->{value} == 4 && !$ip4) { - # ($ip4, undef) = split(/\//, $ip->{address}); - # } - # - # if ($ip->{family}->{value} == 6 && !$ip6) { - # ($ip6, undef) = split(/\//, $ip->{address}); - # } + if (my $err = $@) { + return if not_found($err); + die "could not look up the addresses of $mac: $err"; + } + + for my $hit (@{ $result->{data} // [] }) { + my $family = $wanted{ $hit->{subnetId} // '' } // next; + my $ip = address_of_hit($hit, $family) // next; + if ($family == 4) { + $ip4 //= $ip; + } else { + $ip6 //= $ip; + } } return ($ip4, $ip6); @@ -342,27 +398,105 @@ sub on_update_hook { #helpers -sub get_prefix_id { - my ($url, $cidr, $headers) = @_; +sub get_zone_namespace { + my ($plugin_config, $zoneid, $headers) = @_; - my $result = PVE::Network::SDN::api_request("GET", "$url/subnets/cidr/$cidr", $headers); - my $data = @{ $result->{data} }[0]; - my $internalid = $data->{id}; - return $internalid; + my $name = zone_section_name($zoneid); + my $result = eval { + PVE::Network::SDN::api_request( + "GET", + "$plugin_config->{url}/sections/$name/", + $headers, + undef, + $plugin_config->{fingerprint}, + ); + }; + if (my $err = $@) { + # only a missing section means the zone has none yet, any other + # failure would send the zone's records to the configured section + return if not_found($err); + die "could not look up section $name: $err"; + } + my $section = $result->{data}; + die "section $name is not under section $plugin_config->{section}\n" + if ($section->{masterSection} // 0) != $plugin_config->{section}; + return $section->{id}; +} + +sub add_zone_namespace { + my ($plugin_config, $zoneid, $headers, $noerr) = @_; + + my $name = zone_section_name($zoneid); + my $result = eval { + PVE::Network::SDN::api_request( + "POST", + "$plugin_config->{url}/sections/", + $headers, + { name => $name, masterSection => $plugin_config->{section} }, + $plugin_config->{fingerprint}, + ); + }; + if ($@) { + return if $noerr; + die "could not create section $name: $@"; + } + return $result->{id}; +} + +# phpIPAM nests sections one level deep, so a configured section that is a +# sub-section itself gets no zone sections and every zone's subnets stay in it +sub shared_section { + my ($plugin_config, $headers) = @_; + + my $result = PVE::Network::SDN::api_request( + "GET", + "$plugin_config->{url}/sections/$plugin_config->{section}/", + $headers, + undef, + $plugin_config->{fingerprint}, + ); + return ($result->{data}->{masterSection} // 0) ? $plugin_config->{section} : undef; +} + +# a subnet from before sits in the configured section, so a subnet is looked +# for in the zone's section first and in the configured one after it +sub subnet_id { + my ($plugin_config, $subnet, $headers) = @_; + + my $section = get_zone_namespace($plugin_config, $subnet->{zone}, $headers); + my $url = $plugin_config->{url}; + return get_prefix_id($url, $subnet->{cidr}, $headers, $section) + // get_prefix_id($url, $subnet->{cidr}, $headers, $plugin_config->{section}); +} + +sub get_prefix_id { + my ($url, $cidr, $headers, $section) = @_; + + return undef if !defined($section); + my $result = + eval { PVE::Network::SDN::api_request("GET", "$url/subnets/cidr/$cidr", $headers) }; + if (my $err = $@) { + return undef if not_found($err); + die "could not look up subnet $cidr: $err"; + } + my ($data) = grep { $_->{sectionId} == $section } @{ $result->{data} // [] }; + return $data->{id}; } sub get_ip_id { - my ($url, $ip, $headers) = @_; + my ($url, $ip, $headers, $subnet_id) = @_; my $result = PVE::Network::SDN::api_request("GET", "$url/addresses/search/$ip", $headers); - my $data = @{ $result->{data} }[0]; + my ($data) = + grep { defined($subnet_id) && $_->{subnetId} == $subnet_id } @{ $result->{data} // [] }; my $ip_id = $data->{id}; return $ip_id; } sub is_ip_gateway { - my ($url, $ip, $headers) = @_; + my ($url, $ip, $headers, $subnet_id) = @_; my $result = PVE::Network::SDN::api_request("GET", "$url/addresses/search/$ip", $headers); - my $data = @{ $result->{data} }[0]; + my ($data) = + grep { defined($subnet_id) && $_->{subnetId} == $subnet_id } @{ $result->{data} // [] }; my $is_gateway = $data->{is_gateway}; return $is_gateway; } diff --git a/src/test/ipams/netbox/expected.add_ip b/src/test/ipams/netbox/expected.add_ip index cd46c53f1566..f54f8d05b16e 100644 --- a/src/test/ipams/netbox/expected.add_ip +++ b/src/test/ipams/netbox/expected.add_ip @@ -1,5 +1,5 @@ bless( { - '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname"}', + '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname","vrf":1}', '_headers' => bless( { 'authorization' => 'token 0123456789abcdef0123456789abcdef01234567', 'content-type' => 'application/json; charset=UTF-8' diff --git a/src/test/ipams/netbox/expected.add_ip_notgateway b/src/test/ipams/netbox/expected.add_ip_notgateway index 5ea43cd65296..327efbab8c6b 100644 --- a/src/test/ipams/netbox/expected.add_ip_notgateway +++ b/src/test/ipams/netbox/expected.add_ip_notgateway @@ -1,5 +1,5 @@ bless( { - '_content' => '{"address":"10.0.0.1/24","description":"mac:da:65:8f:18:9b:6f","dns_name":"myhostname"}', + '_content' => '{"address":"10.0.0.1/24","description":"mac:da:65:8f:18:9b:6f","dns_name":"myhostname","vrf":1}', '_headers' => bless( { 'authorization' => 'token 0123456789abcdef0123456789abcdef01234567', 'content-type' => 'application/json; charset=UTF-8' diff --git a/src/test/ipams/netbox/expected.add_subnet b/src/test/ipams/netbox/expected.add_subnet index 6f21265a2a18..7c2785adf9cd 100644 --- a/src/test/ipams/netbox/expected.add_subnet +++ b/src/test/ipams/netbox/expected.add_subnet @@ -1,5 +1,5 @@ bless( { - '_content' => '{"prefix":"10.0.0.0/24"}', + '_content' => '{"prefix":"10.0.0.0/24","vrf":1}', '_headers' => bless( { 'authorization' => 'token 0123456789abcdef0123456789abcdef01234567', 'content-type' => 'application/json; charset=UTF-8' diff --git a/src/test/ipams/netbox/expected.update_ip b/src/test/ipams/netbox/expected.update_ip index d3bc1addbdea..8a6e0a8cbbad 100644 --- a/src/test/ipams/netbox/expected.update_ip +++ b/src/test/ipams/netbox/expected.update_ip @@ -1,5 +1,5 @@ bless( { - '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname"}', + '_content' => '{"address":"10.0.0.1/24","description":"gateway","dns_name":"myhostname","vrf":1}', '_headers' => bless( { 'authorization' => 'token 0123456789abcdef0123456789abcdef01234567', 'content-type' => 'application/json; charset=UTF-8' diff --git a/src/test/run_test_ipams.pl b/src/test/run_test_ipams.pl index f07b4bb4b486..997f48669a85 100755 --- a/src/test/run_test_ipams.pl +++ b/src/test/run_test_ipams.pl @@ -92,6 +92,9 @@ foreach my $path (@plugins) { my $plugin = PVE::Network::SDN::Ipams::Plugin->lookup($plugin_config->{type}); my $sdn_ipam_plugin = Test::MockModule->new($plugin); $sdn_ipam_plugin->mock( + get_zone_namespace => sub { + return 1; + }, get_prefix_id => sub { return 1; }, @@ -219,6 +222,143 @@ foreach my $path (@plugins) { } +# the phpipam MAC lookup, with the addresses as the decimals phpipam hands out +{ + my $plugin = 'PVE::Network::SDN::Ipams::PhpIpamPlugin'; + my $mocked = Test::MockModule->new($plugin); + $mocked->mock(subnet_id => sub { my (undef, $subnet) = @_; return $subnet->{id}; }); + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + return { + data => [ + { ip => 171048980, subnetId => 7 }, + { ip => '10.60.0.20', subnetId => 8 }, + { ip => '55832768119825001489588427028103692289', subnetId => 9 }, + ], + }; + }, + ); + my $subnets = { + 7 => { id => 7, cidr => '10.50.0.0/24', network => '10.50.0.0' }, + 9 => { id => 9, cidr => '2a01::/64', network => '2a01::' }, + }; + my @ips = $plugin->get_ips_from_mac( + { url => 'u', token => 't' }, 'da:65:8f:18:9b:6f', 'z', $subnets, + ); + is_deeply( + \@ips, + ['10.50.0.20', '2a01::1'], + 'phpipam lookup converts decimal addresses by the subnet family', + ); + + # a MAC without addresses is a 404, any other failure fails the lookup + $sdn_module->mock(api_request => sub { die "Invalid response from server: 404 Not Found\n" }); + is_deeply( + [ + $plugin->get_ips_from_mac( + { url => 'u', token => 't' }, + 'da:65:8f:18:9b:6f', + 'z', + $subnets, + ), + ], + [], + 'phpipam lookup answers nothing for a MAC without addresses', + ); + $sdn_module->mock( + api_request => sub { die "Invalid response from server: 503 Service Unavailable\n" }); + eval { + $plugin->get_ips_from_mac( + { url => 'u', token => 't' }, + 'da:65:8f:18:9b:6f', + 'z', + $subnets, + ); + }; + like($@, qr/could not look up the addresses/, 'and dies on any other failure'); +} + +# a range the zone's namespace does not hold is refused while a subnet from +# before holds it in the global table or the configured section, since a copy +# would hide its records, and the error names the way out +{ + my $subnet = { + zone => 'z', + cidr => '10.0.0.0/24', + network => '10.0.0.0', + mask => '24', + }; + my @requests; + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + my ($method, $url, undef, $data) = @_; + push @requests, [$method, $url, $data]; + return {}; + }, + ); + + my $netbox = Test::MockModule->new('PVE::Network::SDN::Ipams::NetboxPlugin'); + $netbox->mock( + get_zone_namespace => sub { return 2; }, + get_prefix_id => + sub { my (undef, undef, $vrf_id) = @_; return defined($vrf_id) ? undef : 5; }, + ); + eval { + PVE::Network::SDN::Ipams::NetboxPlugin->add_subnet( + { url => 'u', token => 't' }, + 'z-10.0.0.0-24', + $subnet, + ); + }; + like($@, qr/move it .* into VRF pve-z/, 'netbox refuses a range the global table holds'); + is(scalar(@requests), 0, 'and creates nothing'); + + @requests = (); + my $phpipam = Test::MockModule->new('PVE::Network::SDN::Ipams::PhpIpamPlugin'); + $phpipam->mock( + get_zone_namespace => sub { return 2; }, + get_prefix_id => sub { + my (undef, undef, undef, $section) = @_; + return ($section // '') eq '2' ? undef : 5; + }, + ); + eval { + PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet( + { url => 'u', token => 't', section => 1 }, + 'z-10.0.0.0-24', + $subnet, + ); + }; + like( + $@, + qr/move it into sub-section pve-z/, + 'phpipam refuses a range the configured section holds', + ); + is(scalar(@requests), 0, 'and creates nothing'); +} + +# the phpipam gateway check answers for the subnet it is asked about, also +# when the address sits in another zone's section as well +{ + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + return { + data => [ + { ip => '10.0.0.1', subnetId => 7, is_gateway => 1 }, + { ip => '10.0.0.1', subnetId => 8, is_gateway => 0 }, + ], + }; + }, + ); + my $is_gateway = \&PVE::Network::SDN::Ipams::PhpIpamPlugin::is_ip_gateway; + ok($is_gateway->('u', '10.0.0.1', [], 7), 'phpipam sees the gateway in its subnet'); + ok(!$is_gateway->('u', '10.0.0.1', [], 8), 'and not in another subnet holding the address'); + ok(!$is_gateway->('u', '10.0.0.1', [], undef), 'and not without a subnet to ask about'); +} + # an address released for a MAC stays when the external IPAM records it # for another MAC { @@ -268,5 +408,182 @@ foreach my $path (@plugins) { } } +# a zone's VRF is found and created under its prefixed name +{ + my @requests; + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + my ($method, $url, undef, $data) = @_; + push @requests, [$method, $url, $data]; + return { results => [{ id => 4 }], id => 4 }; + }, + ); + my $config = { url => 'u', token => 't' }; + PVE::Network::SDN::Ipams::NetboxPlugin::get_zone_namespace($config, 'z'); + PVE::Network::SDN::Ipams::NetboxPlugin::add_zone_namespace($config, 'z'); + is_deeply( + \@requests, + [ + ['GET', 'u/ipam/vrfs/?name=pve-z', undef], + ['POST', 'u/ipam/vrfs/', { name => 'pve-z' }], + ], + 'netbox finds and creates the zone\'s VRF under the name pve-', + ); +} + +# a subnet from before is found in the global table while the zone's VRF holds +# no prefix for its range, and in the VRF once it was moved there +{ + my $moved; + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + my ($method, $url) = @_; + return { results => [{ id => 2 }] } if $url =~ m|/ipam/vrfs/\?name=pve-z$|; + if ($url =~ m|/ipam/prefixes/\?prefix=10\.0\.0\.0/24&vrf_id=(\S+)$|) { + my $vrf = $1; + return { results => [{ id => 5 }] } if $vrf eq '2' && $moved; + return { results => [{ id => 5 }] } if $vrf eq 'null' && !$moved; + return { results => [] }; + } + die "unexpected request $method $url\n"; + }, + ); + my $config = { url => 'u', token => 't' }; + my $subnet = { zone => 'z', cidr => '10.0.0.0/24' }; + is_deeply( + [PVE::Network::SDN::Ipams::NetboxPlugin::subnet_prefix($config, $subnet)], + [5, undef], + 'netbox finds a prefix from before in the global table next to the zone\'s VRF', + ); + $moved = 1; + is_deeply( + [PVE::Network::SDN::Ipams::NetboxPlugin::subnet_prefix($config, $subnet)], + [5, 2], + 'and in the VRF once the prefix was moved there', + ); +} + +# the same for phpipam, where the subnet keeps its id across the move, so the +# requests show which section answered +{ + my $moved; + my @cidr_requests; + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + my ($method, $url) = @_; + return { data => { id => 2, masterSection => 1 } } if $url =~ m|/sections/pve-z/$|; + if ($url =~ m|/subnets/cidr/10\.0\.0\.0/24$|) { + push @cidr_requests, $url; + return { data => [{ id => 5, sectionId => $moved ? 2 : 1 }] }; + } + die "unexpected request $method $url\n"; + }, + ); + my $config = { url => 'u', token => 't', section => 1 }; + my $subnet = { zone => 'z', cidr => '10.0.0.0/24' }; + is( + PVE::Network::SDN::Ipams::PhpIpamPlugin::subnet_id($config, $subnet, []), + 5, + 'phpipam finds a subnet from before in the configured section', + ); + is(scalar(@cidr_requests), 2, 'after looking in the zone\'s section first'); + $moved = 1; + @cidr_requests = (); + is( + PVE::Network::SDN::Ipams::PhpIpamPlugin::subnet_id($config, $subnet, []), + 5, + 'and in the zone\'s section once the subnet was moved there', + ); + is(scalar(@cidr_requests), 1, 'without a second look'); +} + +# a zone without a section is told apart from a section lookup that failed +{ + my $error; + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock(api_request => sub { die $error; }); + my $lookup = \&PVE::Network::SDN::Ipams::PhpIpamPlugin::get_zone_namespace; + my $config = { url => 'u', token => 't' }; + + $error = "Invalid response from server: 404 Not Found\n"; + is(scalar($lookup->($config, 'z', [])), undef, 'phpipam reports a missing section as none'); + $error = "Invalid response from server: 503 Service Unavailable\n"; + eval { $lookup->($config, 'z', []) }; + like($@, qr/could not look up section pve-z/, 'and dies on any other failure'); + + # a section of the zone's name elsewhere in phpIPAM is not the zone's + $sdn_module->mock(api_request => sub { return { data => { id => 7, masterSection => 2 } }; }); + eval { $lookup->({ url => 'u', token => 't', section => 1 }, 'z', []) }; + like($@, qr/not under section 1/, 'a section outside the configured one is refused'); + $sdn_module->mock(api_request => sub { return { data => { id => 7, masterSection => 1 } }; }); + is( + $lookup->({ url => 'u', token => 't', section => 1 }, 'z', []), + 7, + 'and one under it is the zone\'s', + ); +} + +# a zone's section is created under the configured section +{ + my @requests; + my $sdn_module = Test::MockModule->new("PVE::Network::SDN"); + $sdn_module->mock( + api_request => sub { + my ($method, $url, undef, $data) = @_; + push @requests, [$method, $url, $data]; + return { id => 5 }; + }, + ); + my $phpipam = Test::MockModule->new('PVE::Network::SDN::Ipams::PhpIpamPlugin'); + $phpipam->mock( + get_zone_namespace => sub { return undef; }, + get_prefix_id => sub { return undef; }, + ); + PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet( + { url => 'u', token => 't', section => 1 }, + 'z-10.0.0.0/24', + { zone => 'z', cidr => '10.0.0.0/24', network => '10.0.0.0', mask => '24' }, + 1, + ); + is_deeply( + [map { [$_->[0], $_->[1], $_->[2]] } @requests], + [ + ['GET', 'u/sections/1/', undef], + ['POST', 'u/sections/', { name => 'pve-z', masterSection => 1 }], + ['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 5 }], + ], + 'phpipam creates the zone\'s section under the configured one', + ); + + # a configured section that is a sub-section itself gets no zone sections + @requests = (); + $sdn_module->mock( + api_request => sub { + my ($method, $url, undef, $data) = @_; + push @requests, [$method, $url, $data]; + return $url eq 'u/sections/1/' + ? { data => { id => 1, masterSection => 3 } } + : { id => 5 }; + }, + ); + PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet( + { url => 'u', token => 't', section => 1 }, + 'z-10.0.0.0/24', + { zone => 'z', cidr => '10.0.0.0/24', network => '10.0.0.0', mask => '24' }, + 1, + ); + is_deeply( + [map { [$_->[0], $_->[1], $_->[2]] } @requests], + [ + ['GET', 'u/sections/1/', undef], + ['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 1 }], + ], + 'and keeps the subnets in a configured section that is a sub-section itself', + ); +} + done_testing(); -- 2.47.3