From: Hannes Duerr <h.duerr@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: Re: [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin
Date: Mon, 26 May 2025 14:20:09 +0200 [thread overview]
Message-ID: <8d2b3e39-23f6-4a7e-b75a-34ca0812baae@proxmox.com> (raw)
In-Reply-To: <20250526121439.26728-1-h.duerr@proxmox.com>
sorry forgot to increase the version
On 5/26/25 14:14, Hannes Duerr wrote:
> 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 v4 -> v5:
> * instead of skipping already created prefixes, search them
>
> 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 | 510 ++++++++++++++++++++
> 4 files changed, 515 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..a6eb7ff
> --- /dev/null
> +++ b/src/PVE/Network/SDN/Ipams/NautobotPlugin.pm
> @@ -0,0 +1,510 @@
> +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 find_ip_in_prefix {
> + my ($config, $prefix_id, $limit, $start_range, $end_range) = @_;
> +
> + # Fetch available IPs from the temporary pool and find a matching IP
> + my $result = eval {
> + return nautobot_api_request(
> + $config,
> + "GET",
> + "/ipam/prefixes/$prefix_id/available-ips/?limit=$limit",
> + );
> + };
> +
> + # search list for IPs in actual range
> + if (!$@ && defined($result)) {
> + 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) {
> + return $ip->addr;
> + }
> + }
> + }
> + return;
> +}
> +
> +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.
> + # Therefore we divide the range into smaller pool prefixes,
> + # each containing 256 addresses, and search them for available IPs
> + 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",
> + };
> +
> + my $prefix_id = get_prefix_id($config, $current_cidr, $noerr);
> + if ($prefix_id) {
> + # search the existing prefix for valid ip
> + $found_ip =
> + find_ip_in_prefix($config, $prefix_id, $increment, $start_range, $end_range);
> + } else {
> + # create temporary pool prefix
> + my $temp_prefix =
> + eval { return nautobot_api_request($config, "POST", "/ipam/prefixes/", $params); };
> +
> + my $temp_prefix_id = $temp_prefix->{id};
> +
> + # search temporarly created prefix
> + $found_ip =
> + find_ip_in_prefix($config, $temp_prefix_id, $increment, $start_range, $end_range);
> +
> + # 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;
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
next prev parent reply other threads:[~2025-05-26 12:20 UTC|newest]
Thread overview: 6+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-05-26 12:14 Hannes Duerr
2025-05-26 12:14 ` [pve-devel] [PATCH pve-network v4 2/2] ipam: add test cases for nautobot plugin Hannes Duerr
2025-05-26 12:14 ` [pve-devel] [PATCH pve-docs v4 1/1] add documentation for nautobot ipam plugin Hannes Duerr
2025-05-26 12:14 ` [pve-devel] [PATCH pve-manager v4 1/1] ipam: add UI dialog " Hannes Duerr
2025-05-26 12:20 ` Hannes Duerr [this message]
-- strict thread matches above, loose matches on Subject: below --
2025-05-06 9:00 [pve-devel] [PATCH pve-network v4 1/2] ipam: add Nautobot plugin Hannes Duerr
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=8d2b3e39-23f6-4a7e-b75a-34ca0812baae@proxmox.com \
--to=h.duerr@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
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