From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id A359F1FF187 for ; Mon, 22 Sep 2025 11:20:03 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 2C0EF16200; Mon, 22 Sep 2025 11:20:28 +0200 (CEST) To: pve-devel@lists.proxmox.com Date: Mon, 22 Sep 2025 11:19:39 +0200 In-Reply-To: <20250922091942.4715-1-lou.lecrivain@orange.fr> References: <20250922091942.4715-1-lou.lecrivain@orange.fr> MIME-Version: 1.0 Message-ID: List-Id: Proxmox VE development discussion List-Post: From: Lou Lecrivain via pve-devel Precedence: list Cc: lou.lecrivain@orange.fr X-Mailman-Version: 2.1.29 X-BeenThere: pve-devel@lists.proxmox.com List-Subscribe: , List-Unsubscribe: , List-Archive: Reply-To: Proxmox VE development discussion List-Help: Subject: [pve-devel] [PATCH pve-network v6 1/2] ipam: add Nautobot plugin Content-Type: multipart/mixed; boundary="===============6959285863668259540==" Errors-To: pve-devel-bounces@lists.proxmox.com Sender: "pve-devel" --===============6959285863668259540== Content-Type: message/rfc822 Content-Disposition: inline Return-Path: X-Original-To: pve-devel@lists.proxmox.com Delivered-To: pve-devel@lists.proxmox.com Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id E7B84D361F for ; Mon, 22 Sep 2025 11:20:25 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A480415EE2 for ; Mon, 22 Sep 2025 11:19:55 +0200 (CEST) Received: from smtp.smtpout.orange.fr (smtp-73.smtpout.orange.fr [80.12.242.73]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Mon, 22 Sep 2025 11:19:53 +0200 (CEST) Received: from localhost ([176.139.8.107]) by smtp.orange.fr with ESMTPA id 0ci1vNeMH28xr0ci1vu8WO; Mon, 22 Sep 2025 11:19:46 +0200 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=orange.fr; s=t20230301; t=1758532786; bh=Qa9nMMCuTD6SwMfJ94aTp9p6YHbpY5XTcOwEDdARInU=; h=From:To:Subject:Date:Message-ID:MIME-Version; b=YvWm5wuyJP7YqSQSKFtpm6CR0t2PEazQmlsLSofilhzZ7LMNYSTX9vFCmFcKm+a/W ZIysPzQtEQj3TWSxNOPvsUQ816c+L2veuHQGbf54gquOkdWBb8/ZwDMbSlxrEru6ro u0Jh9UmvcGJ63F9hNnxdcDl0VS/JT1jQUnCyLN2uBYoa4Rr2gVIQEpV8ognTFfli18 xy1TNeD5Xn3QxXLFNL+bOHZuJQYXKI4dHyi5tssBM3nR5WtNwAXJHgRMNgLbKXq3sC OH5QmjOVEA8BUo/RWKBj7QRFmoJKyVRYqpZ3wKK9qRyY4g8jauU0FYNuHigkjmhjs4 kO7uA4DtNFTNQ== X-ME-Helo: localhost X-ME-Auth: bG91LmxlY3JpdmFpbkBvcmFuZ2UuZnI= X-ME-Date: Mon, 22 Sep 2025 11:19:46 +0200 X-ME-IP: 176.139.8.107 From: lou.lecrivain@orange.fr To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v6 1/2] ipam: add Nautobot plugin Date: Mon, 22 Sep 2025 11:19:39 +0200 Message-ID: <20250922091942.4715-2-lou.lecrivain@orange.fr> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20250922091942.4715-1-lou.lecrivain@orange.fr> References: <20250922091942.4715-1-lou.lecrivain@orange.fr> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.379 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature DKIM_VALID_AU -0.1 Message has a valid DKIM or DK signature from author's domain DKIM_VALID_EF -0.1 Message has a valid DKIM or DK signature from envelope-from domain DMARC_PASS -0.1 DMARC pass policy FREEMAIL_FROM 0.001 Sender email is commonly abused enduser mail provider KAM_MAILER 2 Automated Mailer Tag Left in Email RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust RCVD_IN_MSPIKE_H5 -1 Excellent reputation (+5) RCVD_IN_MSPIKE_WL -0.01 Mailspike good senders SPF_HELO_PASS -0.001 SPF: HELO matches SPF record SPF_PASS -0.001 SPF: sender matches SPF record From: Lou Lecrivain Nautobot is a network source of truth [0] and as such this plugin offers the possibility to automatically enter IP addresses and subnets (prefixes in Nautobot jargon) into Nautobot as soon as they are created in Proxmox VE. Limitations: * Only Nautobot versions from v2.4.14 and onwards are supported, previous versions didn't had support for IP range [1]. * The IPAM plugin currently does not recognize whether VMs/CTs are active/online and initially sets the status to Active but when the VMs/CTs are switched off the status is not deactivated. * Nautobot has the possibility to map DNS names to IP addresses. However, since we have no standardized way to set the DNS names in Proxmox VE (for containers the container name in Proxmox VE is set as the host name in the container, but this is not possible for VMs) we refrain from setting a DNS name at the moment. * Nautobot does not have a field in IP address objects for a MAC address or to mark them as gateway, so we write this in the comment. [0] https://networktocode.com/nautobot/ [1] https://github.com/nautobot/nautobot/releases/tag/v2.4.14 Co-authored-by: lou lecrivain Signed-off-by: Hannes Duerr Signed-off-by: Lou Lecrivain --- src/PVE/API2/Network/SDN/Ipams.pm | 1 + src/PVE/Network/SDN/Ipams.pm | 3 + src/PVE/Network/SDN/Ipams/Makefile | 2 +- src/PVE/Network/SDN/Ipams/NautobotPlugin.pm | 460 ++++++++++++++++++++ 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 src/PVE/Network/SDN/Ipams/NautobotPlugin.pm diff --git a/src/PVE/API2/Network/SDN/Ipams.pm b/src/PVE/API2/Network/SDN/Ipams.pm index 9955817..4e76bce 100644 --- a/src/PVE/API2/Network/SDN/Ipams.pm +++ b/src/PVE/API2/Network/SDN/Ipams.pm @@ -12,6 +12,7 @@ use PVE::Network::SDN::Ipams::Plugin; use PVE::Network::SDN::Ipams::PVEPlugin; use PVE::Network::SDN::Ipams::PhpIpamPlugin; use PVE::Network::SDN::Ipams::NetboxPlugin; +use PVE::Network::SDN::Ipams::NautobotPlugin; use PVE::Network::SDN::Dhcp; use PVE::Network::SDN::Vnets; use PVE::Network::SDN::Zones; diff --git a/src/PVE/Network/SDN/Ipams.pm b/src/PVE/Network/SDN/Ipams.pm index 00aa20c..ee36ab5 100644 --- a/src/PVE/Network/SDN/Ipams.pm +++ b/src/PVE/Network/SDN/Ipams.pm @@ -12,11 +12,14 @@ use PVE::Network; use PVE::Network::SDN::Ipams::PVEPlugin; use PVE::Network::SDN::Ipams::NetboxPlugin; +use PVE::Network::SDN::Ipams::NautobotPlugin; use PVE::Network::SDN::Ipams::PhpIpamPlugin; use PVE::Network::SDN::Ipams::Plugin; + PVE::Network::SDN::Ipams::PVEPlugin->register(); PVE::Network::SDN::Ipams::NetboxPlugin->register(); +PVE::Network::SDN::Ipams::NautobotPlugin->register(); PVE::Network::SDN::Ipams::PhpIpamPlugin->register(); PVE::Network::SDN::Ipams::Plugin->init(); diff --git a/src/PVE/Network/SDN/Ipams/Makefile b/src/PVE/Network/SDN/Ipams/Makefile index 4e7d65f..75e5b9a 100644 --- a/src/PVE/Network/SDN/Ipams/Makefile +++ b/src/PVE/Network/SDN/Ipams/Makefile @@ -1,4 +1,4 @@ -SOURCES=Plugin.pm PhpIpamPlugin.pm NetboxPlugin.pm PVEPlugin.pm +SOURCES=Plugin.pm PhpIpamPlugin.pm NetboxPlugin.pm PVEPlugin.pm NautobotPlugin.pm PERL5DIR=${DESTDIR}/usr/share/perl5 diff --git a/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm new file mode 100644 index 0000000..447bfaa --- /dev/null +++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm @@ -0,0 +1,460 @@ +package PVE::Network::SDN::Ipams::NautobotPlugin; + +use strict; +use warnings; +use PVE::INotify; +use PVE::Cluster; +use PVE::Tools; +use NetAddr::IP; +use Net::Subnet qw(subnet_matcher); +use version 0.77; + +use base('PVE::Network::SDN::Ipams::Plugin'); + +our $MINIMAL_NAUTOBOT_VERSION = version->declare('v2.4.14'); + +sub type { + return 'nautobot'; +} + +sub properties { + return { + namespace => { + type => 'string', + }, + }; +} + +sub options { + return { + url => { optional => 0 }, + token => { optional => 0 }, + namespace => { optional => 0 }, + fingerprint => { optional => 1 }, + }; +} + +sub default_ip_status { + return 'Active'; +} + +sub nautobot_api_request { + my ($config, $method, $path, $params) = @_; + + return PVE::Network::SDN::api_request( + $method, + "$config->{url}${path}", + [ + 'Content-Type' => 'application/json; charset=UTF-8', + 'Authorization' => "token $config->{token}", + 'Accept' => "application/json", + ], + $params, + $config->{fingerprint}, + ); +} + +sub add_subnet { + my ($class, $config, undef, $subnet, $noerr) = @_; + + my $cidr = $subnet->{cidr}; + my $namespace = $config->{namespace}; + + my $internalid = get_prefix_id($config, $cidr, $noerr); + if ($internalid) { + return if $noerr; + die "could not add the subnet $subnet because it already exists in nautobot\n"; + } + + my $params = { + prefix => $cidr, + namespace => $namespace, + status => default_ip_status(), + }; + + eval { nautobot_api_request($config, "POST", "/ipam/prefixes/", $params); }; + if ($@) { + return if $noerr; + die "error adding the subnet $subnet to nautobot $@\n"; + } +} + +sub update_subnet { + my ($class, $plugin_config, $subnetid, $subnet, $old_subnet, $noerr) = @_; + # dhcp ranges are not supported in nautobot so we don't have to update them +} + +sub del_subnet { + my ($class, $config, $subnetid, $subnet, $noerr) = @_; + + my $cidr = $subnet->{cidr}; + + my $internalid = get_prefix_id($config, $cidr, $noerr); + if (!$internalid) { + warn("could not find delete the subnet $cidr because it does not exist in nautobot\n"); + return; + } + + if (!subnet_is_deletable($config, $subnetid, $subnet, $internalid, $noerr)) { + return if $noerr; + die "could not delete the subnet $cidr, it still contains ip addresses!\n"; + } + + # delete associated gateway IP addresses + $class->empty_subnet($config, $subnetid, $subnet, $internalid, $noerr); + + eval { nautobot_api_request($config, "DELETE", "/ipam/prefixes/$internalid/"); }; + if ($@) { + return if $noerr; + die "error deleting subnet from nautobot: $@\n"; + } + return 1; +} + +sub add_ip { + my ($class, $config, undef, $subnet, $ip, $hostname, $mac, undef, $is_gateway, $noerr) = @_; + + my $mask = $subnet->{mask}; + my $namespace = $config->{namespace}; + + my $description = undef; + if ($is_gateway) { + $description = 'gateway'; + } elsif ($mac) { + $description = "mac:$mac"; + } + + my $params = { + address => "$ip/$mask", + type => "dhcp", + description => $description, + namespace => $namespace, + status => default_ip_status(), + }; + + eval { nautobot_api_request($config, "POST", "/ipam/ip-addresses/", $params); }; + + if ($@) { + if ($is_gateway) { + die "error add subnet ip to ipam: ip $ip already exist: $@" + if !is_ip_gateway($config, $ip, $noerr); + } elsif (!$noerr) { + die "error add subnet ip to ipam: ip already exist: $@"; + } + } +} + +sub add_next_freeip { + my ($class, $config, undef, $subnet, $hostname, $mac, undef, $noerr) = @_; + + my $cidr = $subnet->{cidr}; + my $namespace = $config->{namespace}; + + my $internalid = get_prefix_id($config, $cidr, $noerr); + if (!defined($internalid)) { + return if $noerr; + die "could not find prefix $cidr in nautobot\n"; + } + + my $description = undef; + $description = "mac:$mac" if $mac; + + my $params = { + type => "dhcp", + description => $description, + namespace => $namespace, + status => default_ip_status(), + }; + + my $response = eval { + return nautobot_api_request( + $config, "POST", "/ipam/prefixes/$internalid/available-ips/", $params, + ); + }; + if ($@ || !$response) { + return if $noerr; + die "could not allocate ip in subnet $cidr: $@\n"; + } + + my $ip = NetAddr::IP->new($response->{address}); + + return $ip->addr; +} + +sub add_range_next_freeip { + my ($class, $config, $subnet, $range, $data, $noerr) = @_; + + my $cidr = $subnet->{cidr}; + my $namespace = $config->{namespace}; + + my $internalid = get_prefix_id($config, $cidr, $noerr); + if (!defined($internalid)) { + return if $noerr; + die "could not find prefix $cidr in nautobot\n"; + } + + my $description = undef; + $description = "mac:$data->{mac}" if $data->{mac}; + + my $params = { + type => "dhcp", + description => $description, + namespace => $namespace, + status => default_ip_status(), + }; + + my $range_start = $range->{'start-address'}; + my $range_end = $range->{'end-address'}; + + my $response = eval { + return nautobot_api_request( + $config, + "POST", + "/ipam/prefixes/$internalid/available-ips/" + . "?range_start=$range_start&range_end=$range_end", + $params, + ); + }; + if ($@ || !$response) { + return if $noerr; + die "could not allocate ip in subnet $cidr: $@\n"; + } + + my $ip = NetAddr::IP->new($response->{address}); + + return $ip->addr; +} + +sub update_ip { + my ($class, $config, $subnetid, $subnet, $ip, $hostname, $mac, undef, $is_gateway, $noerr) = @_; + + my $mask = $subnet->{mask}; + my $namespace = $config->{namespace}; + + my $description = undef; + if ($is_gateway) { + $description = 'gateway'; + } elsif ($mac) { + $description = "mac:$mac"; + } + + my $params = { + address => "$ip/$mask", + type => "dhcp", + description => $description, + namespace => $namespace, + status => default_ip_status(), + }; + + my $ip_id = get_ip_id($config, $ip, $noerr); + if (!defined($ip_id)) { + return if $noerr; + die "could not find the ip $ip in nautobot\n"; + } + + eval { nautobot_api_request($config, "PATCH", "/ipam/ip-addresses/$ip_id/", $params); }; + if ($@) { + return if $noerr; + die "error updating ip $ip: $@"; + } +} + +sub del_ip { + my ($class, $config, undef, undef, $ip, $noerr) = @_; + + return if !$ip; + + my $ip_id = get_ip_id($config, $ip, $noerr); + if (!defined($ip_id)) { + warn("could not find the ip $ip in nautobot\n"); + return; + } + + eval { nautobot_api_request($config, "DELETE", "/ipam/ip-addresses/$ip_id/"); }; + if ($@) { + return if $noerr; + die "error deleting ip $ip : $@\n"; + } + + return 1; +} + +sub empty_subnet { + my ($class, $config, $subnetid, $subnet, $subnetuuid, $noerr) = @_; + + my $namespace = $config->{namespace}; + + my $response = eval { + return nautobot_api_request( + $config, + "GET", + "/ipam/ip-addresses/?namespace=$namespace&parent=$subnetuuid", + ); + }; + if ($@) { + return if $noerr; + die "could not find the subnet $subnet in nautobot: $@\n"; + } + + for my $ip (@{ $response->{results} }) { + del_ip($class, $config, undef, undef, $ip->{host}, $noerr); + } + + return 1; +} + +sub subnet_is_deletable { + my ($config, $subnetid, $subnet, $subnetuuid, $noerr) = @_; + + my $namespace = $config->{namespace}; + + my $response = eval { + return nautobot_api_request( + $config, + "GET", + "/ipam/ip-addresses/?namespace=$namespace&parent=$subnetuuid", + ); + }; + if ($@) { + return if $noerr; + die "error querying prefix $subnet: $@\n"; + } + my $n_ips = scalar $response->{results}->@*; + + # least costly check operation 1st + return 1 if ($n_ips == 0); + + for my $ip (values $response->{results}->@*) { + if (!is_ip_gateway($config, $ip->{host}, $noerr)) { + # some remaining IP is not a gateway so we can't delete the subnet + return 0; + } + } + #all remaining IPs are gateways + return 1; +} + +sub verify_api { + my ($class, $config) = @_; + + my $namespace = $config->{namespace}; + + # check if the namespace and the status "Active" exist + eval { + my $status = nautobot_api_request($config, "GET", "/status/"); + my $current_version = version->parse($status->{'nautobot-version'}); + $current_version >= $MINIMAL_NAUTOBOT_VERSION + || die "version $current_version is below minimal required Nautobot version " + . "$MINIMAL_NAUTOBOT_VERSION"; + + get_namespace_id($config, $namespace) // die "namespace $namespace does not exist"; + get_status_id($config, default_ip_status()) + // die "the status " . default_ip_status() . " does not exist"; + + }; + if ($@) { + die "could not use nautobot api: $@\n"; + } +} + +sub get_ips_from_mac { + my ($class, $config, $mac, $zone) = @_; + + my $ip4 = undef; + my $ip6 = undef; + + my $data = eval { nautobot_api_request($config, "GET", "/ipam/ip-addresses/?q=$mac"); }; + if ($@) { + die "could not query ip address entry for mac $mac: $@"; + } + + for my $ip (@{ $data->{results} }) { + if ($ip->{ip_version} == 4 && !$ip4) { + ($ip4, undef) = split(/\//, $ip->{address}); + } + + if ($ip->{ip_version} == 6 && !$ip6) { + ($ip6, undef) = split(/\//, $ip->{address}); + } + } + + return ($ip4, $ip6); +} + +sub on_update_hook { + my ($class, $config) = @_; + + PVE::Network::SDN::Ipams::NautobotPlugin::verify_api($class, $config); +} + +sub get_ip_id { + my ($config, $ip, $noerr) = @_; + + my $result = + eval { return nautobot_api_request($config, "GET", "/ipam/ip-addresses/?address=$ip"); }; + if ($@) { + return if $noerr; + die "error while querying for ip $ip id: $@\n"; + } + + my $data = @{ $result->{results} }[0]; + return $data->{id}; +} + +sub get_prefix_id { + my ($config, $cidr, $noerr) = @_; + + my $result = + eval { return nautobot_api_request($config, "GET", "/ipam/prefixes/?prefix=$cidr"); }; + if ($@) { + return if $noerr; + die "error while querying for cidr $cidr prefix id: $@\n"; + } + + my $data = @{ $result->{results} }[0]; + return $data->{id}; +} + +sub get_namespace_id { + my ($config, $namespace, $noerr) = @_; + + my $result = + eval { return nautobot_api_request($config, "GET", "/ipam/namespaces/?name=$namespace"); }; + if ($@) { + return if $noerr; + die "error while querying for namespace $namespace id: $@\n"; + } + + my $data = @{ $result->{results} }[0]; + return $data->{id}; +} + +sub get_status_id { + my ($config, $status, $noerr) = @_; + + my $result = + eval { return nautobot_api_request($config, "GET", "/extras/statuses/?name=$status"); }; + if ($@) { + return if $noerr; + die "error while querying for status $status id: $@\n"; + } + + my $data = @{ $result->{results} }[0]; + return $data->{id}; +} + +sub is_ip_gateway { + my ($config, $ip, $noerr) = @_; + + my $result = + eval { return nautobot_api_request($config, "GET", "/ipam/ip-addresses/?address=$ip"); }; + if ($@) { + return if $noerr; + die "error while checking if $ip is a gateway: $@\n"; + } + + my $data = @{ $result->{results} }[0]; + return $data->{description} eq 'gateway'; +} + +1; -- 2.47.3 --===============6959285863668259540== Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Content-Disposition: inline _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel --===============6959285863668259540==--