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 334221FF09C for ; Mon, 02 Nov 2026 12:07:45 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 02E50217B2; Mon, 02 Nov 2026 12:07:07 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network 09/10] sdn: ipam: give each zone a VRF in NetBox and a section in phpIPAM Date: Mon, 2 Nov 2026 12:06:03 +0100 Message-ID: <20261102110604.915693-10-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261102110604.915693-1-h.laimer@proxmox.com> References: <20261102110604.915693-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: 1793617582925 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.634 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: ZLBPTCHAQKZEXVJLQYVXAM4TZXFDJ7DS X-Message-ID-Hash: ZLBPTCHAQKZEXVJLQYVXAM4TZXFDJ7DS 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 even when another zone holds the same range elsewhere. 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. A failed section lookup stops the operation, only a missing section means the zone has none. Signed-off-by: Hannes Laimer --- src/PVE/Network/SDN/Ipams/NetboxPlugin.pm | 157 +++++++++---- src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm | 163 ++++++++++--- 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 | 221 ++++++++++++++++++ 7 files changed, 478 insertions(+), 71 deletions(-) diff --git a/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm b/src/PVE/Network/SDN/Ipams/NetboxPlugin.pm index 82c9e80b6c6e..ee48a19d5358 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; @@ -78,12 +75,25 @@ sub add_subnet { 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 only the zone's VRF is + # searched, not the global table the prefixes from before sit in + 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"; } - eval { netbox_api_request($plugin_config, "POST", "/ipam/prefixes/", { prefix => $cidr }); }; + $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, vrf => $vrf_id }, + ); + }; if ($@) { return if $noerr; die "error adding subnet to ipam: $@"; @@ -91,7 +101,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 +132,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 +142,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 +157,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 +167,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 +175,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 +188,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 +227,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 +236,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 +244,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 +274,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 +290,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 +304,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 +340,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 +382,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; @@ -393,12 +412,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 ($@) { @@ -435,14 +458,65 @@ 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"; +} + +my sub zone_vrf_name { + my ($zoneid) = @_; + + return "pve-$zoneid"; +} + +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: $@"; @@ -453,13 +527,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 ($@) { @@ -473,9 +548,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: $@"; @@ -486,9 +562,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: $@"; @@ -499,9 +576,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 e1b30e293ab3..efc2b15f6a91 100644 --- a/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm +++ b/src/PVE/Network/SDN/Ipams/PhpIpamPlugin.pm @@ -2,6 +2,8 @@ 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; @@ -47,15 +49,21 @@ 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 only the zone's section is + # searched, not the configured one the subnets from before sit in + 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) }; #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 +93,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 +126,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 +146,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 +173,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 +204,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 +254,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 { @@ -273,6 +281,18 @@ sub del_ip { return 1; } +# 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) = @_; @@ -284,7 +304,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 = eval { 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", @@ -295,17 +323,14 @@ sub get_ips_from_mac { }; 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}); - # } + 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); @@ -338,27 +363,109 @@ sub on_update_hook { #helpers +# 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 get_zone_namespace { + my ($plugin_config, $zoneid, $headers) = @_; + + 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 $err =~ m/^Invalid response from server: 404 /; + 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) = @_; + my ($url, $cidr, $headers, $section) = @_; + return undef if !defined($section); 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 ($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 0d3fed120d3f..4dbc78720163 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,115 @@ 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 range the zone's namespace does not hold is created there, also when a +# subnet from before holds it in the global table or the configured section +{ + 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; }, + ); + PVE::Network::SDN::Ipams::NetboxPlugin->add_subnet( + { url => 'u', token => 't' }, + 'z-10.0.0.0-24', + $subnet, + 1, + ); + is_deeply( + $requests[0], + ['POST', 'u/ipam/prefixes/', { prefix => '10.0.0.0/24', vrf => 2 }], + 'netbox adds a range the global table holds to the zone\'s VRF', + ); + + @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; + }, + ); + PVE::Network::SDN::Ipams::PhpIpamPlugin->add_subnet( + { url => 'u', token => 't', section => 1 }, + 'z-10.0.0.0-24', + $subnet, + 1, + ); + is_deeply( + $requests[0], + ['POST', 'u/subnets/', { subnet => '10.0.0.0', mask => '24', sectionId => 2 }], + 'phpipam adds a range the configured section holds to the zone\'s section', + ); +} + +# 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'); +} + # an address released for a MAC stays when the external IPAM records it # for another MAC { @@ -256,5 +368,114 @@ 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 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