public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
@ 2025-05-06  9:00 Hannes Duerr
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-network v4 2/2] ipam: add test cases for nautobot plugin Hannes Duerr
                   ` (4 more replies)
  0 siblings, 5 replies; 9+ messages in thread
From: Hannes Duerr @ 2025-05-06  9:00 UTC (permalink / raw)
  To: pve-devel

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:
* 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 does not support ranges that can be searched for free ip
  addresses, so we map as many pool prefixes that we search until we
  have searched the entire range for free ip addresses
* 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/

Co-authored-by: lou lecrivain <lou.lecrivain@wdz.de>
Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
---

Notes:
    Changes from v3 -> v4:
    * Merge the check for empty prefixes/subnets into this commit
    * Create nautobot_api_request to unify API calls
    * add update_subnet function
    * fix add_range_next_freeip
    * fix and unify error handling
    * Fix perl style

 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 | 513 ++++++++++++++++++++
 4 files changed, 518 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 27ead02..8074512 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 c689b8f..2ecb75e 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..2897025
--- /dev/null
+++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
@@ -0,0 +1,513 @@
+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 base('PVE::Network::SDN::Ipams::Plugin');
+
+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 = NetAddr::IP->new($subnet->{cidr});
+    my $namespace = $config->{namespace};
+
+    # Nautobot does not support IP ranges only prefixes.
+    # We therefore divide the range into smaller pool-prefixes,
+    # each containing 256 addresses, and search for available IPs in them
+    my $prefix_size = $cidr->version == 4 ? 24 : 120;
+    my $increment = 256;
+    my $found_ip = undef;
+
+    my $start_range = NetAddr::IP->new($range->{'start-address'}, $prefix_size);
+    my $end_range = NetAddr::IP->new($range->{'end-address'}, $prefix_size);
+    my $matcher = subnet_matcher($end_range->cidr);
+    my $current_ip = $start_range;
+
+    while (1) {
+	my $current_cidr = $current_ip->addr . "/$prefix_size";
+
+	my $params = {
+	    prefix => $current_cidr,
+	    namespace => $namespace,
+	    status => default_ip_status(),
+	    type => "pool"
+	};
+
+	# create temporary pool prefix
+	my $temp_prefix = eval {
+	    return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
+	};
+
+	# skip if it is not possible to create it
+	next if $@;
+
+	my $temp_prefix_id = $temp_prefix->{id};
+
+	# Fetch available IPs from the temporary pool and find a matching IP
+	my $result = eval {
+	    return nautobot_api_request(
+		$config,
+		"GET",
+		"/ipam/prefixes/$temp_prefix_id/available-ips/?limit=$increment"
+	    );
+	};
+
+	# search list for IPs in actual range
+	if (!$@) {
+	    foreach my $entry (@$result) {
+		my $ip = NetAddr::IP->new($entry->{address});
+		# comparison is only possible because they are in the same subnet
+		if ($start_range <= $ip && $ip <= $end_range) {
+		    $found_ip = $ip->addr;
+		    last;
+		}
+	    }
+	}
+
+	# Delete temporary prefix pool
+	eval {
+	    nautobot_api_request($config, "DELETE", "/ipam/prefixes/$temp_prefix_id/");
+	};
+
+	last if $found_ip;
+
+	# we searched the last pool prefix
+	last if $matcher->($current_ip->addr);
+
+	$current_ip = $current_ip->plus($increment);
+    }
+
+    if (!$found_ip) {
+	return if $noerr;
+	die "could not allocate ip in the range " . $start_range->addr . " - "
+	    . $end_range->addr . ": $@\n";
+    }
+
+    $class->add_ip($config, undef, $subnet, $found_ip, $data->{hostname}, $data->{mac}, undef, 0, $noerr);
+
+    return $found_ip;
+}
+
+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 {
+	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.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [pve-devel] [PATCH pve-network v4 2/2] ipam: add test cases for nautobot plugin
  2025-05-06  9:00 [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin Hannes Duerr
@ 2025-05-06  9:00 ` Hannes Duerr
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-docs v4 1/1] add documentation for nautobot ipam plugin Hannes Duerr
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 9+ messages in thread
From: Hannes Duerr @ 2025-05-06  9:00 UTC (permalink / raw)
  To: pve-devel

Co-authored-by: Lou Lecrivain <lou.lecrivain@wdz.de>
Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
---
 src/test/ipams/nautobot/expected.add_ip       | 11 +++++++++
 .../ipams/nautobot/expected.add_ip_notgateway | 11 +++++++++
 .../ipams/nautobot/expected.add_next_freeip   | 11 +++++++++
 src/test/ipams/nautobot/expected.add_subnet   | 11 +++++++++
 src/test/ipams/nautobot/expected.del_ip       | 11 +++++++++
 src/test/ipams/nautobot/expected.update_ip    | 11 +++++++++
 src/test/ipams/nautobot/ipam_config           | 24 +++++++++++++++++++
 src/test/ipams/nautobot/sdn_config            | 20 ++++++++++++++++
 src/test/ipams/netbox/ipam_config             |  8 ++++++-
 src/test/ipams/phpipam/ipam_config            |  8 ++++++-
 10 files changed, 124 insertions(+), 2 deletions(-)
 create mode 100644 src/test/ipams/nautobot/expected.add_ip
 create mode 100644 src/test/ipams/nautobot/expected.add_ip_notgateway
 create mode 100644 src/test/ipams/nautobot/expected.add_next_freeip
 create mode 100644 src/test/ipams/nautobot/expected.add_subnet
 create mode 100644 src/test/ipams/nautobot/expected.del_ip
 create mode 100644 src/test/ipams/nautobot/expected.update_ip
 create mode 100644 src/test/ipams/nautobot/ipam_config
 create mode 100644 src/test/ipams/nautobot/sdn_config

diff --git a/src/test/ipams/nautobot/expected.add_ip b/src/test/ipams/nautobot/expected.add_ip
new file mode 100644
index 0000000..bec1f3a
--- /dev/null
+++ b/src/test/ipams/nautobot/expected.add_ip
@@ -0,0 +1,11 @@
+bless( {
+                  '_content' => '{"address":"10.0.0.1/24","description":"gateway","namespace":"TestNamespace","status":"Active","type":"dhcp"}',
+                  '_headers' => bless( {
+                                         'accept' => 'application/json',
+                                         'authorization' => 'token FAKETESTTOKEN',
+                                         'content-type' => 'application/json; charset=UTF-8',
+                                       }, 'HTTP::Headers' ),
+                  '_max_body_size' => undef,
+                  '_method' => 'POST',
+                  '_uri' => bless( do{\(my $o = 'http://localhost:8080/api/ipam/ip-addresses/')}, 'URI::http' )
+                }, 'HTTP::Request' );
diff --git a/src/test/ipams/nautobot/expected.add_ip_notgateway b/src/test/ipams/nautobot/expected.add_ip_notgateway
new file mode 100644
index 0000000..bae92e6
--- /dev/null
+++ b/src/test/ipams/nautobot/expected.add_ip_notgateway
@@ -0,0 +1,11 @@
+bless( {
+                  '_content' => '{"address":"10.0.0.1/24","description":"mac:da:65:8f:18:9b:6f","namespace":"TestNamespace","status":"Active","type":"dhcp"}',
+                  '_headers' => bless( {
+                                         'accept' => 'application/json',
+                                         'authorization' => 'token FAKETESTTOKEN',
+                                         'content-type' => 'application/json; charset=UTF-8',
+                                       }, 'HTTP::Headers' ),
+                  '_max_body_size' => undef,
+                  '_method' => 'POST',
+                  '_uri' => bless( do{\(my $o = 'http://localhost:8080/api/ipam/ip-addresses/')}, 'URI::http' )
+                }, 'HTTP::Request' );
diff --git a/src/test/ipams/nautobot/expected.add_next_freeip b/src/test/ipams/nautobot/expected.add_next_freeip
new file mode 100644
index 0000000..0181719
--- /dev/null
+++ b/src/test/ipams/nautobot/expected.add_next_freeip
@@ -0,0 +1,11 @@
+bless( {
+                  '_content' => '{"description":"mac:da:65:8f:18:9b:6f","namespace":"TestNamespace","status":"Active","type":"dhcp"}',
+                  '_headers' => bless( {
+                                         'accept' => 'application/json',
+                                         'authorization' => 'token FAKETESTTOKEN',
+                                         'content-type' => 'application/json; charset=UTF-8',
+                                       }, 'HTTP::Headers' ),
+                  '_max_body_size' => undef,
+                  '_method' => 'POST',
+                  '_uri' => bless( do{\(my $o = 'http://localhost:8080/api/ipam/prefixes/1/available-ips/')}, 'URI::http' )
+                }, 'HTTP::Request' );
diff --git a/src/test/ipams/nautobot/expected.add_subnet b/src/test/ipams/nautobot/expected.add_subnet
new file mode 100644
index 0000000..78f911d
--- /dev/null
+++ b/src/test/ipams/nautobot/expected.add_subnet
@@ -0,0 +1,11 @@
+bless({
+	'_content' => '{"namespace":"TestNamespace","prefix":"10.0.0.0/24","status":"Active"}',
+	'_headers' => bless({
+		   'accept' => 'application/json',
+		   'authorization' => 'token FAKETESTTOKEN',
+		   'content-type' => 'application/json; charset=UTF-8',
+	}, 'HTTP::Headers'),
+	'_max_body_size' => undef,
+	'_method' => 'POST',
+	'_uri' => bless(do{\(my $o = 'http://localhost:8080/api/ipam/prefixes/')}, 'URI::http'),
+}, 'HTTP::Request');
diff --git a/src/test/ipams/nautobot/expected.del_ip b/src/test/ipams/nautobot/expected.del_ip
new file mode 100644
index 0000000..eaa133e
--- /dev/null
+++ b/src/test/ipams/nautobot/expected.del_ip
@@ -0,0 +1,11 @@
+bless( {
+                  '_content' => '',
+                  '_headers' => bless( {
+                                         'accept' => 'application/json',
+                                         'authorization' => 'token FAKETESTTOKEN',
+                                         'content-type' => 'application/json; charset=UTF-8',
+                                       }, 'HTTP::Headers' ),
+                  '_max_body_size' => undef,
+                  '_method' => 'DELETE',
+                  '_uri' => bless( do{\(my $o = 'http://localhost:8080/api/ipam/ip-addresses/1/')}, 'URI::http' )
+                }, 'HTTP::Request' );
diff --git a/src/test/ipams/nautobot/expected.update_ip b/src/test/ipams/nautobot/expected.update_ip
new file mode 100644
index 0000000..be6df47
--- /dev/null
+++ b/src/test/ipams/nautobot/expected.update_ip
@@ -0,0 +1,11 @@
+bless( {
+                  '_content' => '{"address":"10.0.0.1/24","description":"gateway","namespace":"TestNamespace","status":"Active","type":"dhcp"}',
+                  '_headers' => bless( {
+                                         'accept' => 'application/json',
+                                         'authorization' => 'token FAKETESTTOKEN',
+                                         'content-type' => 'application/json; charset=UTF-8',
+                                       }, 'HTTP::Headers' ),
+                  '_max_body_size' => undef,
+                  '_method' => 'PATCH',
+                  '_uri' => bless( do{\(my $o = 'http://localhost:8080/api/ipam/ip-addresses/1/')}, 'URI::http' )
+                }, 'HTTP::Request' );
diff --git a/src/test/ipams/nautobot/ipam_config b/src/test/ipams/nautobot/ipam_config
new file mode 100644
index 0000000..014d6b1
--- /dev/null
+++ b/src/test/ipams/nautobot/ipam_config
@@ -0,0 +1,24 @@
+{
+          'ids' => {
+                     'phpipam' => {
+                                    'url' => 'https://localhost/api/apiadmin',
+                                    'type' => 'phpipam',
+                                    'section' => 1,
+                                    'token' => 'JPHkPSLB4O_XL-GQz4qtEFmNpx-99Htw'
+                                  },
+                     'pve' => {
+                                'type' => 'pve'
+                              },
+                     'netbox' => {
+                                   'token' => '0123456789abcdef0123456789abcdef01234567',
+                                   'type' => 'netbox',
+                                   'url' => 'http://localhost:8000/api'
+                                 },
+		      'nautobot' => {
+				    'url' => 'http://localhost:8080/api',
+				    'type' => 'nautobot',
+				    'token' => 'FAKETESTTOKEN',
+				    'namespace' => 'TestNamespace'
+		      }
+                   },
+}
diff --git a/src/test/ipams/nautobot/sdn_config b/src/test/ipams/nautobot/sdn_config
new file mode 100644
index 0000000..784cd95
--- /dev/null
+++ b/src/test/ipams/nautobot/sdn_config
@@ -0,0 +1,20 @@
+{
+  version => 1,
+  vnets   => {
+               ids => {
+                        myvnet => { type => "vnet", zone => "myzone" },
+                      },
+             },
+
+  zones   => {
+               ids => { myzone => { ipam => "nautobot" } },
+             },
+
+  subnets => {
+              ids => { 'myzone-10.0.0.0-24' => {
+                                                        'type' => 'subnet',
+                                                        'vnet' => 'myvnet',
+                                                  }
+                     }
+             }
+}
diff --git a/src/test/ipams/netbox/ipam_config b/src/test/ipams/netbox/ipam_config
index a33be30..6a6fe44 100644
--- a/src/test/ipams/netbox/ipam_config
+++ b/src/test/ipams/netbox/ipam_config
@@ -13,6 +13,12 @@
                                    'token' => '0123456789abcdef0123456789abcdef01234567',
                                    'type' => 'netbox',
                                    'url' => 'http://localhost:8000/api'
-                                 }
+                                 },
+                     'nautobot' => {
+                                   'url' => 'http://localhost:8080/api',
+                                   'type' => 'nautobot',
+                                   'token' => 'FAKETESTTOKEN',
+                                   'namespace' => 'TestNamespace'
+                                   }
                    },
 }
diff --git a/src/test/ipams/phpipam/ipam_config b/src/test/ipams/phpipam/ipam_config
index a33be30..014d6b1 100644
--- a/src/test/ipams/phpipam/ipam_config
+++ b/src/test/ipams/phpipam/ipam_config
@@ -13,6 +13,12 @@
                                    'token' => '0123456789abcdef0123456789abcdef01234567',
                                    'type' => 'netbox',
                                    'url' => 'http://localhost:8000/api'
-                                 }
+                                 },
+		      'nautobot' => {
+				    'url' => 'http://localhost:8080/api',
+				    'type' => 'nautobot',
+				    'token' => 'FAKETESTTOKEN',
+				    'namespace' => 'TestNamespace'
+		      }
                    },
 }
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [pve-devel] [PATCH pve-docs v4 1/1] add documentation for nautobot ipam plugin
  2025-05-06  9:00 [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin Hannes Duerr
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-network v4 2/2] ipam: add test cases for nautobot plugin Hannes Duerr
@ 2025-05-06  9:00 ` Hannes Duerr
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-manager v4 1/1] ipam: add UI dialog " Hannes Duerr
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 9+ messages in thread
From: Hannes Duerr @ 2025-05-06  9:00 UTC (permalink / raw)
  To: pve-devel; +Cc: Jonatan Crystall

Co-authored-by: Jonatan Crystall <jonatan.crystall@gwdg.de>
Co-authored-by: Lou Lecrivain <lou.lecrivain@wdz.de>
Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
---
 pvesdn.adoc | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/pvesdn.adoc b/pvesdn.adoc
index 5e58cc3..2967ec2 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -563,6 +563,25 @@ URL:: The NetBox REST API endpoint: `http://yournetbox.domain.com/api`
 
 Token:: An API access token
 
+[[pvesdn_ipam_plugin_nautobot]]
+Nautobot IPAM Plugin
+~~~~~~~~~~~~~~~~~~
+
+link:https://github.com/nautobot/nautobot[Nautobot] is an open-source IP
+Address Management (IPAM) and datacenter infrastructure management (DCIM) tool.
+
+To integrate Nautobot with {pve} SDN, create an API token in Nautobot as described
+here:
+https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/users/token/
+
+The Nautobot configuration properties are:
+
+URL:: The Nautobot REST API endpoint: `http://yournautobot.domain.com/api`
+
+Token:: An API access token
+
+Namespace:: Nautobot IP Namespace 
+
 
 [[pvesdn_ipam_plugin_phpipam]]
 phpIPAM Plugin
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [pve-devel] [PATCH pve-manager v4 1/1] ipam: add UI dialog for nautobot ipam plugin
  2025-05-06  9:00 [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin Hannes Duerr
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-network v4 2/2] ipam: add test cases for nautobot plugin Hannes Duerr
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-docs v4 1/1] add documentation for nautobot ipam plugin Hannes Duerr
@ 2025-05-06  9:00 ` Hannes Duerr
  2025-05-07  9:17 ` [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions Lou Lecrivain via pve-devel
       [not found] ` <FR2PPF45D006270C594040B87F6E418E7548588A@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
  4 siblings, 0 replies; 9+ messages in thread
From: Hannes Duerr @ 2025-05-06  9:00 UTC (permalink / raw)
  To: pve-devel; +Cc: Jonatan Crystall

Co-authored-by: Jonatan Crystall <jonatan.crystall@gwdg.de>
Co-authored-by: Lou Lecrivain <lou.lecrivain@wdz.de>
Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
---
 www/manager6/Makefile                  |  1 +
 www/manager6/Utils.js                  |  5 +++
 www/manager6/sdn/ipams/NautobotEdit.js | 62 ++++++++++++++++++++++++++
 3 files changed, 68 insertions(+)
 create mode 100644 www/manager6/sdn/ipams/NautobotEdit.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index fdf0e816..e873e6c5 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -296,6 +296,7 @@ JSSRC= 							\
 	sdn/IpamView.js					\
 	sdn/ipams/Base.js				\
 	sdn/ipams/NetboxEdit.js				\
+	sdn/ipams/NautobotEdit.js			\
 	sdn/ipams/PVEIpamEdit.js			\
 	sdn/ipams/PhpIpamEdit.js			\
 	sdn/DnsView.js					\
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 1f6778cd..a95c21ce 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -942,6 +942,11 @@ Ext.define('PVE.Utils', {
 	    ipanel: 'PhpIpamInputPanel',
 	    faIcon: 'th',
 	},
+	nautobot: {
+	    name: 'Nautobot',
+	    ipanel: 'NautobotInputPanel',
+	    faIcon: 'th',
+	},
     },
 
     sdndnsSchema: {
diff --git a/www/manager6/sdn/ipams/NautobotEdit.js b/www/manager6/sdn/ipams/NautobotEdit.js
new file mode 100644
index 00000000..46162d98
--- /dev/null
+++ b/www/manager6/sdn/ipams/NautobotEdit.js
@@ -0,0 +1,62 @@
+Ext.define('PVE.sdn.ipams.NautobotInputPanel', {
+    extend: 'PVE.panel.SDNIpamBase',
+
+    onlineHelp: 'pvesdn_ipam_plugin_nautobot',
+
+    onGetValues: function(values) {
+        var me = this;
+
+	if (me.isCreate) {
+	    values.type = me.type;
+	} else {
+	    delete values.ipam;
+	}
+
+	return values;
+    },
+
+    initComponent: function() {
+	var me = this;
+
+	me.column1 = [
+	    {
+		xtype: me.isCreate ? 'textfield' : 'displayfield',
+		name: 'ipam',
+		maxLength: 10,
+		value: me.zone || '',
+		fieldLabel: 'ID',
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'token',
+		fieldLabel: gettext('Token'),
+		allowBlank: false,
+	    },
+	];
+	me.column2 = [
+	    {
+		xtype: 'textfield',
+		name: 'url',
+		fieldLabel: gettext('URL'),
+		allowBlank: false,
+	    },
+	    {
+		xtype: 'textfield',
+		name: 'namespace',
+		fieldLabel: gettext('Namespace'),
+		allowBlank: false,
+	    },
+	];
+	me.columnB = [
+	    {
+		xtype: 'pmxFingerprintField',
+		name: 'fingerprint',
+		value: me.isCreate ? null : undefined,
+		deleteEmpty: !me.isCreate,
+	    },
+	];
+
+	me.callParent();
+    },
+});
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
  2025-05-06  9:00 [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin Hannes Duerr
                   ` (2 preceding siblings ...)
  2025-05-06  9:00 ` [pve-devel] [PATCH pve-manager v4 1/1] ipam: add UI dialog " Hannes Duerr
@ 2025-05-07  9:17 ` Lou Lecrivain via pve-devel
       [not found] ` <FR2PPF45D006270C594040B87F6E418E7548588A@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
  4 siblings, 0 replies; 9+ messages in thread
From: Lou Lecrivain via pve-devel @ 2025-05-07  9:17 UTC (permalink / raw)
  To: h.duerr, pve-devel; +Cc: Lou.Lecrivain

[-- Attachment #1: Type: message/rfc822, Size: 35635 bytes --]

From: <Lou.Lecrivain@wdz.de>
To: <h.duerr@proxmox.com>, <pve-devel@lists.proxmox.com>
Subject: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
Date: Wed, 7 May 2025 09:17:22 +0000
Message-ID: <FR2PPF45D006270C594040B87F6E418E7548588A@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>

Hannes,

Thank you for helping with getting the Netbox plugin in good shape, it's appreciated, really :)

I just have two questions regarding the lack of range support in Nautobot:
- Was there an issue with the original workaround code? I find this one way more complex,
   but also I guess I could've missed some corner case(s).
- What happens if we use a prefix with a CIDR greater than /24 in IPv4 or /120 in IPv6?
   Does not being able to slice it means that ranges with small prefixes such as these
   would be forbidden/not working?

BR
Lou
________________________________________
De : Hannes Duerr <h.duerr@proxmox.com>
Envoyé : mardi 6 mai 2025 11:00
À : pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
Cc : Hannes Duerr <h.duerr@proxmox.com>; Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>
Objet : [!!ACHTUNG extern!!] - [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
 
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:
* 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 does not support ranges that can be searched for free ip
  addresses, so we map as many pool prefixes that we search until we
  have searched the entire range for free ip addresses
* 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/

Co-authored-by: lou lecrivain <lou.lecrivain@wdz.de>
Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
---

Notes:
    Changes from v3 -> v4:
    * Merge the check for empty prefixes/subnets into this commit
    * Create nautobot_api_request to unify API calls
    * add update_subnet function
    * fix add_range_next_freeip
    * fix and unify error handling
    * Fix perl style

 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 | 513 ++++++++++++++++++++
 4 files changed, 518 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 27ead02..8074512 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 c689b8f..2ecb75e 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..2897025
--- /dev/null
+++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
@@ -0,0 +1,513 @@
+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 base('PVE::Network::SDN::Ipams::Plugin');
+
+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 = NetAddr::IP->new($subnet->{cidr});
+    my $namespace = $config->{namespace};
+
+    # Nautobot does not support IP ranges only prefixes.
+    # We therefore divide the range into smaller pool-prefixes,
+    # each containing 256 addresses, and search for available IPs in them
+    my $prefix_size = $cidr->version == 4 ? 24 : 120;
+    my $increment = 256;
+    my $found_ip = undef;
+
+    my $start_range = NetAddr::IP->new($range->{'start-address'}, $prefix_size);
+    my $end_range = NetAddr::IP->new($range->{'end-address'}, $prefix_size);
+    my $matcher = subnet_matcher($end_range->cidr);
+    my $current_ip = $start_range;
+
+    while (1) {
+       my $current_cidr = $current_ip->addr . "/$prefix_size";
+
+       my $params = {
+           prefix => $current_cidr,
+           namespace => $namespace,
+           status => default_ip_status(),
+           type => "pool"
+       };
+
+       # create temporary pool prefix
+       my $temp_prefix = eval {
+           return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
+       };
+
+       # skip if it is not possible to create it
+       next if $@;
+
+       my $temp_prefix_id = $temp_prefix->{id};
+
+       # Fetch available IPs from the temporary pool and find a matching IP
+       my $result = eval {
+           return nautobot_api_request(
+               $config,
+               "GET",
+               "/ipam/prefixes/$temp_prefix_id/available-ips/?limit=$increment"
+           );
+       };
+
+       # search list for IPs in actual range
+       if (!$@) {
+           foreach my $entry (@$result) {
+               my $ip = NetAddr::IP->new($entry->{address});
+               # comparison is only possible because they are in the same subnet
+               if ($start_range <= $ip && $ip <= $end_range) {
+                   $found_ip = $ip->addr;
+                   last;
+               }
+           }
+       }
+
+       # Delete temporary prefix pool
+       eval {
+           nautobot_api_request($config, "DELETE", "/ipam/prefixes/$temp_prefix_id/");
+       };
+
+       last if $found_ip;
+
+       # we searched the last pool prefix
+       last if $matcher->($current_ip->addr);
+
+       $current_ip = $current_ip->plus($increment);
+    }
+
+    if (!$found_ip) {
+       return if $noerr;
+       die "could not allocate ip in the range " . $start_range->addr . " - "
+           . $end_range->addr . ": $@\n";
+    }
+
+    $class->add_ip($config, undef, $subnet, $found_ip, $data->{hostname}, $data->{mac}, undef, 0, $noerr);
+
+    return $found_ip;
+}
+
+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 {
+       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.39.5



[-- Attachment #2: Type: text/plain, Size: 160 bytes --]

_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [pve-devel] SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
       [not found] ` <FR2PPF45D006270C594040B87F6E418E7548588A@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
@ 2025-05-09  8:46   ` Hannes Duerr
  2025-05-09  9:34     ` [pve-devel] [!!ACHTUNG extern!!] - " Lou Lecrivain via pve-devel
       [not found]     ` <FR2PPF45D0062706987369A6C23E8E8DF74858AA@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
  0 siblings, 2 replies; 9+ messages in thread
From: Hannes Duerr @ 2025-05-09  8:46 UTC (permalink / raw)
  To: Lou.Lecrivain, pve-devel


On 5/7/25 11:17, Lou.Lecrivain@wdz.de wrote:
> Hannes,
>
> Thank you for helping with getting the Netbox plugin in good shape, it's appreciated, really :)
>
> I just have two questions regarding the lack of range support in Nautobot:
> - Was there an issue with the original workaround code? I find this one way more complex,
>     but also I guess I could've missed some corner case(s).
Yes, there was the problem that the old version would possibly output
invalid free IP addresses if the IP range did not start directly at
the beginning of the prefix. Assuming you have 192.168.0.0/16 as
prefix, all IP addresses are still free and you want free ip addresses
in the range 192.168.6.1/16 - 192.168.7.255/16 then the old
implementation would still have received the addresses
192.168.0.[0-50]/24 from the API. This could have been remedied by an
offset parameter, but this is not implemented for the API endpoint.
Increasing the `limit` is also not an option, as there is a maximum
limit of 1000 and you can still run into problems.
I have therefore opted for the current implementation.

> - What happens if we use a prefix with a CIDR greater than /24 in IPv4 or /120 in IPv6?
>     Does not being able to slice it means that ranges with small prefixes such as these
>     would be forbidden/not working?
No, all prefixes should work. For smaller prefixes such as /26, we
still create a /24 pool and search it for free ips, but then check
again whether the ip is actually in the desired range in /26.

>
> BR
> Lou
> ________________________________________
> De : Hannes Duerr<h.duerr@proxmox.com>
> Envoyé : mardi 6 mai 2025 11:00
> À :pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
> Cc : Hannes Duerr<h.duerr@proxmox.com>; Lecrivain, Lou (WDZ)<Lou.Lecrivain@wdz.de>
> Objet : [!!ACHTUNG extern!!] - [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
>   
> 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:
> * 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 does not support ranges that can be searched for free ip
>    addresses, so we map as many pool prefixes that we search until we
>    have searched the entire range for free ip addresses
> * 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/
>
> Co-authored-by: lou lecrivain<lou.lecrivain@wdz.de>
> Signed-off-by: Hannes Duerr<h.duerr@proxmox.com>
> ---
>
> Notes:
>      Changes from v3 -> v4:
>      * Merge the check for empty prefixes/subnets into this commit
>      * Create nautobot_api_request to unify API calls
>      * add update_subnet function
>      * fix add_range_next_freeip
>      * fix and unify error handling
>      * Fix perl style
>
>   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 | 513 ++++++++++++++++++++
>   4 files changed, 518 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 27ead02..8074512 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 c689b8f..2ecb75e 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..2897025
> --- /dev/null
> +++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> @@ -0,0 +1,513 @@
> +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 base('PVE::Network::SDN::Ipams::Plugin');
> +
> +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 = NetAddr::IP->new($subnet->{cidr});
> +    my $namespace = $config->{namespace};
> +
> +    # Nautobot does not support IP ranges only prefixes.
> +    # We therefore divide the range into smaller pool-prefixes,
> +    # each containing 256 addresses, and search for available IPs in them
> +    my $prefix_size = $cidr->version == 4 ? 24 : 120;
> +    my $increment = 256;
> +    my $found_ip = undef;
> +
> +    my $start_range = NetAddr::IP->new($range->{'start-address'}, $prefix_size);
> +    my $end_range = NetAddr::IP->new($range->{'end-address'}, $prefix_size);
> +    my $matcher = subnet_matcher($end_range->cidr);
> +    my $current_ip = $start_range;
> +
> +    while (1) {
> +       my $current_cidr = $current_ip->addr . "/$prefix_size";
> +
> +       my $params = {
> +           prefix => $current_cidr,
> +           namespace => $namespace,
> +           status => default_ip_status(),
> +           type => "pool"
> +       };
> +
> +       # create temporary pool prefix
> +       my $temp_prefix = eval {
> +           return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
> +       };
> +
> +       # skip if it is not possible to create it
> +       next if $@;
> +
> +       my $temp_prefix_id = $temp_prefix->{id};
> +
> +       # Fetch available IPs from the temporary pool and find a matching IP
> +       my $result = eval {
> +           return nautobot_api_request(
> +               $config,
> +               "GET",
> +               "/ipam/prefixes/$temp_prefix_id/available-ips/?limit=$increment"
> +           );
> +       };
> +
> +       # search list for IPs in actual range
> +       if (!$@) {
> +           foreach my $entry (@$result) {
> +               my $ip = NetAddr::IP->new($entry->{address});
> +               # comparison is only possible because they are in the same subnet
> +               if ($start_range <= $ip && $ip <= $end_range) {
> +                   $found_ip = $ip->addr;
> +                   last;
> +               }
> +           }
> +       }
> +
> +       # Delete temporary prefix pool
> +       eval {
> +           nautobot_api_request($config, "DELETE", "/ipam/prefixes/$temp_prefix_id/");
> +       };
> +
> +       last if $found_ip;
> +
> +       # we searched the last pool prefix
> +       last if $matcher->($current_ip->addr);
> +
> +       $current_ip = $current_ip->plus($increment);
> +    }
> +
> +    if (!$found_ip) {
> +       return if $noerr;
> +       die "could not allocate ip in the range " . $start_range->addr . " - "
> +           . $end_range->addr . ": $@\n";
> +    }
> +
> +    $class->add_ip($config, undef, $subnet, $found_ip, $data->{hostname}, $data->{mac}, undef, 0, $noerr);
> +
> +    return $found_ip;
> +}
> +
> +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 {
> +       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.39.5
>
>
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [pve-devel] [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
  2025-05-09  8:46   ` [pve-devel] SPAM: " Hannes Duerr
@ 2025-05-09  9:34     ` Lou Lecrivain via pve-devel
       [not found]     ` <FR2PPF45D0062706987369A6C23E8E8DF74858AA@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
  1 sibling, 0 replies; 9+ messages in thread
From: Lou Lecrivain via pve-devel @ 2025-05-09  9:34 UTC (permalink / raw)
  To: h.duerr, pve-devel; +Cc: Lou.Lecrivain, jonatan.crystall

[-- Attachment #1: Type: message/rfc822, Size: 38107 bytes --]

From: <Lou.Lecrivain@wdz.de>
To: <h.duerr@proxmox.com>, <pve-devel@lists.proxmox.com>
Cc: <jonatan.crystall@gwdg.de>
Subject: RE: [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
Date: Fri, 9 May 2025 09:34:56 +0000
Message-ID: <FR2PPF45D0062706987369A6C23E8E8DF74858AA@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>

Re,

Ok, thanks for clarifying. Indeed the offset parameter is ineffective (I have looked at the Nautobot code this morning). Tbqh I don't really like having to create then delete an object in Nautobot for this, but I guess there's no other way, given the limitations we're faced with.

Alternatively as a middle ground (and I think this would be much quicker than implementing IP ranges in Nautobot), we could implement the offset parameter in Nautobot and change the original workaround code to fit this, and then require a minimal Nautobot version (for ranges or the whole plugin). But IMO this doesn't mean we can't merge this. We could change it afterwards.

I have no other remarks. Thanks again for your work.

BR
Lou
________________________________________
De : Hannes Duerr <h.duerr@proxmox.com>
Envoyé : vendredi 9 mai 2025 10:46
À : Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>; pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
Objet : [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
 

On 5/7/25 11:17, Lou.Lecrivain@wdz.de wrote:
Hannes,

Thank you for helping with getting the Netbox plugin in good shape, it's appreciated, really :)

I just have two questions regarding the lack of range support in Nautobot:
- Was there an issue with the original workaround code? I find this one way more complex,
   but also I guess I could've missed some corner case(s).
Yes, there was the problem that the old version would possibly output
invalid free IP addresses if the IP range did not start directly at
the beginning of the prefix. Assuming you have 192.168.0.0/16 as
prefix, all IP addresses are still free and you want free ip addresses
in the range 192.168.6.1/16 - 192.168.7.255/16 then the old
implementation would still have received the addresses
192.168.0.[0-50]/24 from the API. This could have been remedied by an
offset parameter, but this is not implemented for the API endpoint.
Increasing the `limit` is also not an option, as there is a maximum
limit of 1000 and you can still run into problems.
I have therefore opted for the current implementation.


- What happens if we use a prefix with a CIDR greater than /24 in IPv4 or /120 in IPv6?
   Does not being able to slice it means that ranges with small prefixes such as these
   would be forbidden/not working?
No, all prefixes should work. For smaller prefixes such as /26, we
still create a /24 pool and search it for free ips, but then check
again whether the ip is actually in the desired range in /26.

BR
Lou
________________________________________
De : Hannes Duerr <h.duerr@proxmox.com>
Envoyé : mardi 6 mai 2025 11:00
À : pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
Cc : Hannes Duerr <h.duerr@proxmox.com>; Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>
Objet : [!!ACHTUNG extern!!] - [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
 
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:
* 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 does not support ranges that can be searched for free ip
  addresses, so we map as many pool prefixes that we search until we
  have searched the entire range for free ip addresses
* 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/

Co-authored-by: lou lecrivain <lou.lecrivain@wdz.de>
Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
---

Notes:
    Changes from v3 -> v4:
    * Merge the check for empty prefixes/subnets into this commit
    * Create nautobot_api_request to unify API calls
    * add update_subnet function
    * fix add_range_next_freeip
    * fix and unify error handling
    * Fix perl style

 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 | 513 ++++++++++++++++++++
 4 files changed, 518 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 27ead02..8074512 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 c689b8f..2ecb75e 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..2897025
--- /dev/null
+++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
@@ -0,0 +1,513 @@
+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 base('PVE::Network::SDN::Ipams::Plugin');
+
+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 = NetAddr::IP->new($subnet->{cidr});
+    my $namespace = $config->{namespace};
+
+    # Nautobot does not support IP ranges only prefixes.
+    # We therefore divide the range into smaller pool-prefixes,
+    # each containing 256 addresses, and search for available IPs in them
+    my $prefix_size = $cidr->version == 4 ? 24 : 120;
+    my $increment = 256;
+    my $found_ip = undef;
+
+    my $start_range = NetAddr::IP->new($range->{'start-address'}, $prefix_size);
+    my $end_range = NetAddr::IP->new($range->{'end-address'}, $prefix_size);
+    my $matcher = subnet_matcher($end_range->cidr);
+    my $current_ip = $start_range;
+
+    while (1) {
+       my $current_cidr = $current_ip->addr . "/$prefix_size";
+
+       my $params = {
+           prefix => $current_cidr,
+           namespace => $namespace,
+           status => default_ip_status(),
+           type => "pool"
+       };
+
+       # create temporary pool prefix
+       my $temp_prefix = eval {
+           return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
+       };
+
+       # skip if it is not possible to create it
+       next if $@;
+
+       my $temp_prefix_id = $temp_prefix->{id};
+
+       # Fetch available IPs from the temporary pool and find a matching IP
+       my $result = eval {
+           return nautobot_api_request(
+               $config,
+               "GET",
+               "/ipam/prefixes/$temp_prefix_id/available-ips/?limit=$increment"
+           );
+       };
+
+       # search list for IPs in actual range
+       if (!$@) {
+           foreach my $entry (@$result) {
+               my $ip = NetAddr::IP->new($entry->{address});
+               # comparison is only possible because they are in the same subnet
+               if ($start_range <= $ip && $ip <= $end_range) {
+                   $found_ip = $ip->addr;
+                   last;
+               }
+           }
+       }
+
+       # Delete temporary prefix pool
+       eval {
+           nautobot_api_request($config, "DELETE", "/ipam/prefixes/$temp_prefix_id/");
+       };
+
+       last if $found_ip;
+
+       # we searched the last pool prefix
+       last if $matcher->($current_ip->addr);
+
+       $current_ip = $current_ip->plus($increment);
+    }
+
+    if (!$found_ip) {
+       return if $noerr;
+       die "could not allocate ip in the range " . $start_range->addr . " - "
+           . $end_range->addr . ": $@\n";
+    }
+
+    $class->add_ip($config, undef, $subnet, $found_ip, $data->{hostname}, $data->{mac}, undef, 0, $noerr);
+
+    return $found_ip;
+}
+
+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 {
+       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.39.5




[-- Attachment #2: Type: text/plain, Size: 160 bytes --]

_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [pve-devel] SPAM: RE: [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
       [not found]     ` <FR2PPF45D0062706987369A6C23E8E8DF74858AA@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
@ 2025-05-12  8:38       ` Hannes Duerr
  2025-05-14 14:00         ` [pve-devel] [!!ACHTUNG extern!!] - " Lou Lecrivain via pve-devel
  0 siblings, 1 reply; 9+ messages in thread
From: Hannes Duerr @ 2025-05-12  8:38 UTC (permalink / raw)
  To: Lou.Lecrivain, pve-devel; +Cc: jonatan.crystall


On 5/9/25 11:34, Lou.Lecrivain@wdz.de wrote:
> Re,
>
> Ok, thanks for clarifying. Indeed the offset parameter is ineffective (I have looked at the Nautobot code this morning). Tbqh I don't really like having to create then delete an object in Nautobot for this, but I guess there's no other way, given the limitations we're faced with.
>
> Alternatively as a middle ground (and I think this would be much quicker than implementing IP ranges in Nautobot), we could implement the offset parameter in Nautobot and change the original workaround code to fit this, and then require a minimal Nautobot version (for ranges or the whole plugin). But IMO this doesn't mean we can't merge this. We could change it afterwards.
yes I understand that it's not necessarily the nicest solution, but
intuitively I preferred to implement it this way and have no API
version restrictions than to implement the offset parameter. But I
wouldn't have any hard feelings about adapting the code if Nautobot
supports the offset parameter and people prefer it that way.
Have you already tested the new implementation?
> I have no other remarks. Thanks again for your work.
>
> BR
> Lou
> ________________________________________
> De : Hannes Duerr <h.duerr@proxmox.com>
> Envoyé : vendredi 9 mai 2025 10:46
> À : Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>; pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
> Objet : [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
>   
>
> On 5/7/25 11:17, Lou.Lecrivain@wdz.de wrote:
> Hannes,
>
> Thank you for helping with getting the Netbox plugin in good shape, it's appreciated, really :)
>
> I just have two questions regarding the lack of range support in Nautobot:
> - Was there an issue with the original workaround code? I find this one way more complex,
>     but also I guess I could've missed some corner case(s).
> Yes, there was the problem that the old version would possibly output
> invalid free IP addresses if the IP range did not start directly at
> the beginning of the prefix. Assuming you have 192.168.0.0/16 as
> prefix, all IP addresses are still free and you want free ip addresses
> in the range 192.168.6.1/16 - 192.168.7.255/16 then the old
> implementation would still have received the addresses
> 192.168.0.[0-50]/24 from the API. This could have been remedied by an
> offset parameter, but this is not implemented for the API endpoint.
> Increasing the `limit` is also not an option, as there is a maximum
> limit of 1000 and you can still run into problems.
> I have therefore opted for the current implementation.
>
>
> - What happens if we use a prefix with a CIDR greater than /24 in IPv4 or /120 in IPv6?
>     Does not being able to slice it means that ranges with small prefixes such as these
>     would be forbidden/not working?
> No, all prefixes should work. For smaller prefixes such as /26, we
> still create a /24 pool and search it for free ips, but then check
> again whether the ip is actually in the desired range in /26.
>
> BR
> Lou
> ________________________________________
> De : Hannes Duerr <h.duerr@proxmox.com>
> Envoyé : mardi 6 mai 2025 11:00
> À : pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
> Cc : Hannes Duerr <h.duerr@proxmox.com>; Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>
> Objet : [!!ACHTUNG extern!!] - [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
>   
> 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:
> * 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 does not support ranges that can be searched for free ip
>    addresses, so we map as many pool prefixes that we search until we
>    have searched the entire range for free ip addresses
> * 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/
>
> Co-authored-by: lou lecrivain <lou.lecrivain@wdz.de>
> Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
> ---
>
> Notes:
>      Changes from v3 -> v4:
>      * Merge the check for empty prefixes/subnets into this commit
>      * Create nautobot_api_request to unify API calls
>      * add update_subnet function
>      * fix add_range_next_freeip
>      * fix and unify error handling
>      * Fix perl style
>
>   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 | 513 ++++++++++++++++++++
>   4 files changed, 518 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 27ead02..8074512 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 c689b8f..2ecb75e 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..2897025
> --- /dev/null
> +++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> @@ -0,0 +1,513 @@
> +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 base('PVE::Network::SDN::Ipams::Plugin');
> +
> +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 = NetAddr::IP->new($subnet->{cidr});
> +    my $namespace = $config->{namespace};
> +
> +    # Nautobot does not support IP ranges only prefixes.
> +    # We therefore divide the range into smaller pool-prefixes,
> +    # each containing 256 addresses, and search for available IPs in them
> +    my $prefix_size = $cidr->version == 4 ? 24 : 120;
> +    my $increment = 256;
> +    my $found_ip = undef;
> +
> +    my $start_range = NetAddr::IP->new($range->{'start-address'}, $prefix_size);
> +    my $end_range = NetAddr::IP->new($range->{'end-address'}, $prefix_size);
> +    my $matcher = subnet_matcher($end_range->cidr);
> +    my $current_ip = $start_range;
> +
> +    while (1) {
> +       my $current_cidr = $current_ip->addr . "/$prefix_size";
> +
> +       my $params = {
> +           prefix => $current_cidr,
> +           namespace => $namespace,
> +           status => default_ip_status(),
> +           type => "pool"
> +       };
> +
> +       # create temporary pool prefix
> +       my $temp_prefix = eval {
> +           return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
> +       };
> +
> +       # skip if it is not possible to create it
> +       next if $@;
> +
> +       my $temp_prefix_id = $temp_prefix->{id};
> +
> +       # Fetch available IPs from the temporary pool and find a matching IP
> +       my $result = eval {
> +           return nautobot_api_request(
> +               $config,
> +               "GET",
> +               "/ipam/prefixes/$temp_prefix_id/available-ips/?limit=$increment"
> +           );
> +       };
> +
> +       # search list for IPs in actual range
> +       if (!$@) {
> +           foreach my $entry (@$result) {
> +               my $ip = NetAddr::IP->new($entry->{address});
> +               # comparison is only possible because they are in the same subnet
> +               if ($start_range <= $ip && $ip <= $end_range) {
> +                   $found_ip = $ip->addr;
> +                   last;
> +               }
> +           }
> +       }
> +
> +       # Delete temporary prefix pool
> +       eval {
> +           nautobot_api_request($config, "DELETE", "/ipam/prefixes/$temp_prefix_id/");
> +       };
> +
> +       last if $found_ip;
> +
> +       # we searched the last pool prefix
> +       last if $matcher->($current_ip->addr);
> +
> +       $current_ip = $current_ip->plus($increment);
> +    }
> +
> +    if (!$found_ip) {
> +       return if $noerr;
> +       die "could not allocate ip in the range " . $start_range->addr . " - "
> +           . $end_range->addr . ": $@\n";
> +    }
> +
> +    $class->add_ip($config, undef, $subnet, $found_ip, $data->{hostname}, $data->{mac}, undef, 0, $noerr);
> +
> +    return $found_ip;
> +}
> +
> +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 {
> +       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.39.5
>
>
>


_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [pve-devel] [!!ACHTUNG extern!!] - Re: SPAM: RE: [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
  2025-05-12  8:38       ` [pve-devel] SPAM: " Hannes Duerr
@ 2025-05-14 14:00         ` Lou Lecrivain via pve-devel
  0 siblings, 0 replies; 9+ messages in thread
From: Lou Lecrivain via pve-devel @ 2025-05-14 14:00 UTC (permalink / raw)
  To: h.duerr, pve-devel; +Cc: Lou.Lecrivain, jonatan.crystall

[-- Attachment #1: Type: message/rfc822, Size: 41612 bytes --]

From: <Lou.Lecrivain@wdz.de>
To: <h.duerr@proxmox.com>, <pve-devel@lists.proxmox.com>
Cc: <jonatan.crystall@gwdg.de>
Subject: RE: [!!ACHTUNG extern!!] - Re: SPAM: RE: [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
Date: Wed, 14 May 2025 14:00:25 +0000
Message-ID: <FR2PPF45D00627026643A8C54C7A090EC6F8591A@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>

Re Hannes,

I took some time today to test the new implementation, but I believe there are still some issues with it.

Here's what I did:
1. Create IPAM entry with Nautobot plugin
2. Create a zone of type simple with Nautobot as an IPAM
3. Create a VNet in said zone, with a /28 IPv4 subnet and two DHCP ranges within it

Afterwards I created a /24 IPv4 subnet for which the /28 is in it (so, what the plugin
would have created itself).

This results in a 403 from there, because we cannot create the same prefix *twice*:
+       # create temporary pool prefix
+       my $temp_prefix = eval {
+           return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
+       };
+
+       # skip if it is not possible to create it
+       next if $@;
And then the plugin goes into an infinite loop, doing the same request again and again.

I don't think this failure mode is necessarily linked to the prefix size or anything.

BR
________________________________________
De : Hannes Duerr <h.duerr@proxmox.com>
Envoyé : lundi 12 mai 2025 10:38
À : Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>; pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
Cc : jonatan.crystall@gwdg.de <jonatan.crystall@gwdg.de>
Objet : [!!ACHTUNG extern!!] - Re: SPAM: RE: [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
 

On 5/9/25 11:34, Lou.Lecrivain@wdz.de wrote:
> Re,
>
> Ok, thanks for clarifying. Indeed the offset parameter is ineffective (I have looked at the Nautobot code this morning). Tbqh I don't really like having to create then delete an object in Nautobot for this, but I guess there's no other way, given the limitations we're faced with.
>
> Alternatively as a middle ground (and I think this would be much quicker than implementing IP ranges in Nautobot), we could implement the offset parameter in Nautobot and change the original workaround code to fit this, and then require a minimal Nautobot version (for ranges or the whole plugin). But IMO this doesn't mean we can't merge this. We could change it afterwards.
yes I understand that it's not necessarily the nicest solution, but
intuitively I preferred to implement it this way and have no API
version restrictions than to implement the offset parameter. But I
wouldn't have any hard feelings about adapting the code if Nautobot
supports the offset parameter and people prefer it that way.
Have you already tested the new implementation?
> I have no other remarks. Thanks again for your work.
>
> BR
> Lou
> ________________________________________
> De : Hannes Duerr <h.duerr@proxmox.com>
> Envoyé : vendredi 9 mai 2025 10:46
> À : Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>; pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
> Objet : [!!ACHTUNG extern!!] - Re: SPAM: [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions
>  
>
> On 5/7/25 11:17, Lou.Lecrivain@wdz.de wrote:
> Hannes,
>
> Thank you for helping with getting the Netbox plugin in good shape, it's appreciated, really :)
>
> I just have two questions regarding the lack of range support in Nautobot:
> - Was there an issue with the original workaround code? I find this one way more complex,
>     but also I guess I could've missed some corner case(s).
> Yes, there was the problem that the old version would possibly output
> invalid free IP addresses if the IP range did not start directly at
> the beginning of the prefix. Assuming you have 192.168.0.0/16 as
> prefix, all IP addresses are still free and you want free ip addresses
> in the range 192.168.6.1/16 - 192.168.7.255/16 then the old
> implementation would still have received the addresses
> 192.168.0.[0-50]/24 from the API. This could have been remedied by an
> offset parameter, but this is not implemented for the API endpoint.
> Increasing the `limit` is also not an option, as there is a maximum
> limit of 1000 and you can still run into problems.
> I have therefore opted for the current implementation.
>
>
> - What happens if we use a prefix with a CIDR greater than /24 in IPv4 or /120 in IPv6?
>     Does not being able to slice it means that ranges with small prefixes such as these
>     would be forbidden/not working?
> No, all prefixes should work. For smaller prefixes such as /26, we
> still create a /24 pool and search it for free ips, but then check
> again whether the ip is actually in the desired range in /26.
>
> BR
> Lou
> ________________________________________
> De : Hannes Duerr <h.duerr@proxmox.com>
> Envoyé : mardi 6 mai 2025 11:00
> À : pve-devel@lists.proxmox.com <pve-devel@lists.proxmox.com>
> Cc : Hannes Duerr <h.duerr@proxmox.com>; Lecrivain, Lou (WDZ) <Lou.Lecrivain@wdz.de>
> Objet : [!!ACHTUNG extern!!] - [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
>  
> 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:
> * 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 does not support ranges that can be searched for free ip
>    addresses, so we map as many pool prefixes that we search until we
>    have searched the entire range for free ip addresses
> * 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/
>
> Co-authored-by: lou lecrivain <lou.lecrivain@wdz.de>
> Signed-off-by: Hannes Duerr <h.duerr@proxmox.com>
> ---
>
> Notes:
>      Changes from v3 -> v4:
>      * Merge the check for empty prefixes/subnets into this commit
>      * Create nautobot_api_request to unify API calls
>      * add update_subnet function
>      * fix add_range_next_freeip
>      * fix and unify error handling
>      * Fix perl style
>
>   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 | 513 ++++++++++++++++++++
>   4 files changed, 518 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 27ead02..8074512 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 c689b8f..2ecb75e 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..2897025
> --- /dev/null
> +++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> @@ -0,0 +1,513 @@
> +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 base('PVE::Network::SDN::Ipams::Plugin');
> +
> +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 = NetAddr::IP->new($subnet->{cidr});
> +    my $namespace = $config->{namespace};
> +
> +    # Nautobot does not support IP ranges only prefixes.
> +    # We therefore divide the range into smaller pool-prefixes,
> +    # each containing 256 addresses, and search for available IPs in them
> +    my $prefix_size = $cidr->version == 4 ? 24 : 120;
> +    my $increment = 256;
> +    my $found_ip = undef;
> +
> +    my $start_range = NetAddr::IP->new($range->{'start-address'}, $prefix_size);
> +    my $end_range = NetAddr::IP->new($range->{'end-address'}, $prefix_size);
> +    my $matcher = subnet_matcher($end_range->cidr);
> +    my $current_ip = $start_range;
> +
> +    while (1) {
> +       my $current_cidr = $current_ip->addr . "/$prefix_size";
> +
> +       my $params = {
> +           prefix => $current_cidr,
> +           namespace => $namespace,
> +           status => default_ip_status(),
> +           type => "pool"
> +       };
> +
> +       # create temporary pool prefix
> +       my $temp_prefix = eval {
> +           return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params);
> +       };
> +
> +       # skip if it is not possible to create it
> +       next if $@;
> +
> +       my $temp_prefix_id = $temp_prefix->{id};
> +
> +       # Fetch available IPs from the temporary pool and find a matching IP
> +       my $result = eval {
> +           return nautobot_api_request(
> +               $config,
> +               "GET",
> +               "/ipam/prefixes/$temp_prefix_id/available-ips/?limit=$increment"
> +           );
> +       };
> +
> +       # search list for IPs in actual range
> +       if (!$@) {
> +           foreach my $entry (@$result) {
> +               my $ip = NetAddr::IP->new($entry->{address});
> +               # comparison is only possible because they are in the same subnet
> +               if ($start_range <= $ip && $ip <= $end_range) {
> +                   $found_ip = $ip->addr;
> +                   last;
> +               }
> +           }
> +       }
> +
> +       # Delete temporary prefix pool
> +       eval {
> +           nautobot_api_request($config, "DELETE", "/ipam/prefixes/$temp_prefix_id/");
> +       };
> +
> +       last if $found_ip;
> +
> +       # we searched the last pool prefix
> +       last if $matcher->($current_ip->addr);
> +
> +       $current_ip = $current_ip->plus($increment);
> +    }
> +
> +    if (!$found_ip) {
> +       return if $noerr;
> +       die "could not allocate ip in the range " . $start_range->addr . " - "
> +           . $end_range->addr . ": $@\n";
> +    }
> +
> +    $class->add_ip($config, undef, $subnet, $found_ip, $data->{hostname}, $data->{mac}, undef, 0, $noerr);
> +
> +    return $found_ip;
> +}
> +
> +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 {
> +       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.39.5
>
>
>


[-- Attachment #2: Type: text/plain, Size: 160 bytes --]

_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 9+ messages in thread

end of thread, other threads:[~2025-05-14 14:00 UTC | newest]

Thread overview: 9+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-05-06  9:00 [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin Hannes Duerr
2025-05-06  9:00 ` [pve-devel] [PATCH pve-network v4 2/2] ipam: add test cases for nautobot plugin Hannes Duerr
2025-05-06  9:00 ` [pve-devel] [PATCH pve-docs v4 1/1] add documentation for nautobot ipam plugin Hannes Duerr
2025-05-06  9:00 ` [pve-devel] [PATCH pve-manager v4 1/1] ipam: add UI dialog " Hannes Duerr
2025-05-07  9:17 ` [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin - range workaround questions Lou Lecrivain via pve-devel
     [not found] ` <FR2PPF45D006270C594040B87F6E418E7548588A@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
2025-05-09  8:46   ` [pve-devel] SPAM: " Hannes Duerr
2025-05-09  9:34     ` [pve-devel] [!!ACHTUNG extern!!] - " Lou Lecrivain via pve-devel
     [not found]     ` <FR2PPF45D0062706987369A6C23E8E8DF74858AA@FR2PPF45D006270.DEUP281.PROD.OUTLOOK.COM>
2025-05-12  8:38       ` [pve-devel] SPAM: " Hannes Duerr
2025-05-14 14:00         ` [pve-devel] [!!ACHTUNG extern!!] - " Lou Lecrivain via pve-devel

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal