public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Stefan Hanreich <s.hanreich@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: Re: [PATCH pve-network v3 05/17] sdn: dhcp: update reservations when ip mappings change
Date: Tue, 20 Oct 2026 15:42:09 +0200	[thread overview]
Message-ID: <b5a19351-b64f-4569-904e-2f3191dc2a4e@proxmox.com> (raw)
In-Reply-To: <20261020080005.470757-6-h.laimer@proxmox.com>

Tested-by: Stefan Hanreich <s.hanreich@proxmox.com>

On 10/20/26 10:03 AM, Hannes Laimer wrote:
> Creating, updating or deleting an IP mapping through the API changed
> the IPAM and the MAC cache, but never the zone's dnsmasq reservation
> file. dnsmasq kept serving the old reservation, or none for a new
> mapping, until the guest's next start. Deleting a guest NIC's
> addresses left its reservation in the file as well.
> 
> A guest start, an API edit and the deletion of a NIC's addresses now
> all rewrite the MAC's reservation from the cache, on the node the
> change is made on. The other nodes catch up when the guest starts
> there. The zone and its backend come from the applied config, and a
> zone confined to other nodes is skipped. A failed update only warns,
> since the IPAM records are already written.
> 
> The rewrite reads the cache only once it holds the file's lock. A
> read from before could undo a rewrite that ran in between. The lock
> is on a separate file, since a lock on the reservation file would not
> survive the rename that replaces it. The MAC's line is written in full
> from the cache, so a guest gaining an IPv6 address next to its IPv4
> one gets it reserved too. MACs and addresses are compared in lower
> case, since the cache keeps each as its writer spelled it. A cache
> entry without any address no longer keeps a stale line.
> 
> Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
> ---
>  src/PVE/API2/Network/SDN/Ips.pm     |   7 +
>  src/PVE/Network/SDN/Dhcp.pm         |  48 +++---
>  src/PVE/Network/SDN/Dhcp/Dnsmasq.pm | 110 ++++++++----
>  src/PVE/Network/SDN/Dhcp/Plugin.pm  |   7 +-
>  src/PVE/Network/SDN/Vnets.pm        |   5 +-
>  src/test/run_test_vnets_blackbox.pl | 250 +++++++++++++++++++++++++++-
>  6 files changed, 352 insertions(+), 75 deletions(-)
> 
> diff --git a/src/PVE/API2/Network/SDN/Ips.pm b/src/PVE/API2/Network/SDN/Ips.pm
> index 5ff05e75..d7b682dd 100644
> --- a/src/PVE/API2/Network/SDN/Ips.pm
> +++ b/src/PVE/API2/Network/SDN/Ips.pm
> @@ -46,6 +46,8 @@ __PACKAGE__->register_method({
>          eval { PVE::Network::SDN::Vnets::del_ip($vnet, $ip, '', $mac); };
>          die "$@\n" if $@;
>  
> +        PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
> +
>          return undef;
>      },
>  });
> @@ -82,6 +84,8 @@ __PACKAGE__->register_method({
>  
>          PVE::Network::SDN::Vnets::add_ip($vnet, $ip, '', $mac, undef);
>  
> +        PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
> +
>          return undef;
>      },
>  });
> @@ -132,6 +136,9 @@ __PACKAGE__->register_method({
>          }
>  
>          die "$error\n" if $error;
> +
> +        PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
> +
>          return undef;
>      },
>  });
> diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm
> index 65e40d40..ab5d6b5b 100644
> --- a/src/PVE/Network/SDN/Dhcp.pm
> +++ b/src/PVE/Network/SDN/Dhcp.pm
> @@ -6,7 +6,6 @@ use warnings;
>  use PVE::Cluster;
>  
>  use PVE::Network::SDN;
> -use PVE::Network::SDN::Ipams;
>  use PVE::Network::SDN::Subnets;
>  use PVE::Network::SDN::Dhcp::Plugin;
>  use PVE::Network::SDN::Dhcp::Dnsmasq;
> @@ -22,37 +21,32 @@ sub plugin_types {
>      return PVE::Network::SDN::Dhcp::Plugin->lookup_types();
>  }
>  
> -sub add_mapping {
> -    my ($vnetid, $mac, $ip4, $ip6) = @_;
> +# re-apply a MAC's mapping on this node from the current records, best effort
> +# since the record write stays authoritative. A caller that knows the guest
> +# passes its vmid, so only that guest's config is read
> +sub update_mapping {
> +    my ($vnetid, $mac, $vmid) = @_;
>  
> -    my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid);
> -    return if !$vnet;
> +    # a record without a MAC has no guest NIC behind it, so no backend serves it
> +    return if !$mac;
>  
> -    my $zoneid = $vnet->{zone};
> -    my $zone = PVE::Network::SDN::Zones::get_zone($zoneid);
> +    eval {
> +        # the backend to push to is the applied one, since a zone edited but
> +        # not applied has nothing running yet
> +        my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid, 1);
> +        return if !$vnet;
>  
> -    return if !$zone->{ipam} || !$zone->{dhcp};
> +        my $zoneid = $vnet->{zone};
> +        my $zone = PVE::Network::SDN::Zones::get_zone($zoneid, 1);
> +        return if !$zone || !$zone->{ipam} || !$zone->{dhcp};
>  
> -    my $dhcptype = $zone->{dhcp};
> +        # a zone confined to other nodes has no backend running here
> +        return if defined($zone->{nodes}) && !$zone->{nodes}->{ PVE::INotify::nodename() };
>  
> -    my $macdb = PVE::Network::SDN::Ipams::read_macdb();
> -    my $dhcp_plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($dhcptype);
> -    $dhcp_plugin->add_ip_mapping($zoneid, $macdb, $mac, $ip4, $ip6);
> -}
> -
> -sub remove_mapping {
> -    my ($vnetid, $mac) = @_;
> -
> -    my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid);
> -    return if !$vnet;
> -
> -    my $zoneid = $vnet->{zone};
> -    my $zone = PVE::Network::SDN::Zones::get_zone($zoneid);
> -
> -    return if !$zone->{ipam} || !$zone->{dhcp};
> -
> -    my $dhcp_plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($zone->{dhcp});
> -    $dhcp_plugin->del_ip_mapping($zoneid, $mac);
> +        PVE::Network::SDN::Dhcp::Plugin->lookup($zone->{dhcp})
> +            ->update_ip_mapping($zoneid, $mac, $vmid);
> +    };
> +    warn "could not update dhcp mapping for $mac: $@" if $@;
>  }
>  
>  sub regenerate_config {
> diff --git a/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm b/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm
> index 5c9496c0..57ce1f73 100644
> --- a/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm
> +++ b/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm
> @@ -12,6 +12,7 @@ use File::Copy;
>  use Net::DBus;
>  
>  use PVE::RESTEnvironment qw(log_warn);
> +use PVE::Network::SDN::Ipams;
>  
>  my $DNSMASQ_CONFIG_ROOT = '/etc/dnsmasq.d';
>  my $DNSMASQ_DEFAULT_ROOT = '/etc/default';
> @@ -51,29 +52,65 @@ sub update_lease {
>      $manager->AddDhcpLease($ip4, $mac, \@hostname, undef, 0, 0, 0) if $ip4;
>  }
>  
> -sub add_ip_mapping {
> -    my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_;
> +sub update_ip_mapping {
> +    my ($class, $dhcpid, $mac) = @_;
>  
>      my $ethers_file = ethers_file($dhcpid);
>      my $ethers_tmp_file = "$ethers_file.tmp";
>  
> -    my $reload = undef;
> +    my ($ip4, $ip6);
> +    my $reservation = undef;
> +    my $changed = undef;
> +
> +    my $rewriteFn = sub {
> +        # read under the ethers lock. A snapshot from before it would sweep a
> +        # line another push wrote meanwhile, and an older push would put its
> +        # addresses back
> +
> +        my $macdb = PVE::Network::SDN::Ipams::read_macdb();
> +        # the cache spells a MAC and an address as their writer did, while the
> +        # file holds one spelling of both. An entry without an address says
> +        # nothing about its MAC, so a line of it is stale
> +        my %macs;
> +        for my $key (sort keys $macdb->{macs}->%*) {
> +            my $entry = $macdb->{macs}->{$key};
> +            my %addresses =
> +                map { $_ => lc($entry->{$_}) } grep { defined($entry->{$_}) } qw(ip4 ip6);
> +            next if !%addresses;
> +            $macs{ lc($key) } = { %{ $macs{ lc($key) } // {} }, %addresses };
> +        }

we could simplify this here as well with integrating case-folding into the read_macdb fn.
The more I think about, the more I'd prefer a small object that provides an API similar
to HashMaps that handle case-folding when querying / inserting / ... I think that'd save
us lots of duplication.

> +
> +        my $entry = $macs{ lc($mac) };
> +        ($ip4, $ip6) = ($entry->{ip4}, $entry->{ip6}) if $entry;
> +        if ($ip4 || $ip6) {
> +            $reservation = $mac;
> +            $reservation .= ",$ip4" if $ip4;
> +            $reservation .= ",[$ip6]" if $ip6;
> +            # an external IPAM spells addresses its own way, while the file holds one spelling
> +            $reservation = lc($reservation);
> +        }
>  
> -    my $appendFn = sub {
> +        # the first reservation of a zone finds no file yet
> +        file_set_contents($ethers_file, '') if !-e $ethers_file;
>          open(my $in, '<', $ethers_file) or die "Could not open file '$ethers_file' $!\n";
>          open(my $out, '>', $ethers_tmp_file)
>              or die "Could not open file '$ethers_tmp_file' $!\n";
>  
> -        my $match = undef;
> +        my $kept = undef;
>  
> -        my $line_no = 0;
>          while (my $line = <$in>) {
> -            $line_no++;
>              chomp($line);
> +            next if $line !~ m/\S/;
>              my ($parsed_mac, $parsed_ip1, $parsed_ip2) = split(/,/, $line);
>  
> -            if (!defined($parsed_mac)) {
> -                warn "failed to parse MAC from $dhcpid ethers file on line $line_no: '$line'\n";
> +            # the MAC's own reservation is rewritten. An unchanged one stays in place
> +            if (lc($parsed_mac) eq lc($mac)) {
> +                if (defined($reservation) && !$kept && lc($line) eq $reservation) {
> +                    $kept = 1;
> +                    print $out "$line\n";
> +                } else {
> +                    $changed = 1;
> +                }
>                  next;
>              }
>  
> @@ -81,50 +118,43 @@ sub add_ip_mapping {
>              if ($parsed_ip2) {
>                  $parsed_ip4 = $parsed_ip1;
>                  $parsed_ip6 = $parsed_ip2;
> -            } elsif (Net::IP::ip_is_ipv4($parsed_ip1)) {
> +            } elsif (defined($parsed_ip1) && Net::IP::ip_is_ipv4($parsed_ip1)) {
>                  $parsed_ip4 = $parsed_ip1;
> -            } else {
> +            } elsif (defined($parsed_ip1)) {
>                  $parsed_ip6 = $parsed_ip1;
>              }
>              $parsed_ip6 = $1 if $parsed_ip6 && $parsed_ip6 =~ m/\[(\S+)\]/;
> +            # a line from before addresses were lower-cased may spell one in upper case
> +            $parsed_ip6 = lc($parsed_ip6) if $parsed_ip6;
>  
>              # a line that no longer matches the cache is replaced by the cache's
>              # current one. Dropping it left that guest unknown to dnsmasq until
>              # its next start
>              if (
> -                !defined($macdb->{macs}->{$parsed_mac})
> +                !defined($macs{ lc($parsed_mac) })
>                  || ($parsed_ip4
> -                    && $macdb->{macs}->{$parsed_mac}->{'ip4'}
> -                    && $macdb->{macs}->{$parsed_mac}->{'ip4'} ne $parsed_ip4)
> +                    && $macs{ lc($parsed_mac) }->{'ip4'}
> +                    && $macs{ lc($parsed_mac) }->{'ip4'} ne $parsed_ip4)
>                  || ($parsed_ip6
> -                    && $macdb->{macs}->{$parsed_mac}->{'ip6'}
> -                    && $macdb->{macs}->{$parsed_mac}->{'ip6'} ne $parsed_ip6)
> +                    && $macs{ lc($parsed_mac) }->{'ip6'}
> +                    && $macs{ lc($parsed_mac) }->{'ip6'} ne $parsed_ip6)
>              ) {
> -                $reload = 1;
> -                # the MAC being added gets its line below, like a missing one
> -                my $current = $macdb->{macs}->{$parsed_mac};
> -                next if $parsed_mac eq $mac || !$current;
> -                my $replacement = $parsed_mac;
> +                $changed = 1;
> +                my $current = $macs{ lc($parsed_mac) };
> +                next if !$current;
> +                my $replacement = lc($parsed_mac);

a dedicated class could simplify this here a lot

>                  $replacement .= ",$current->{ip4}" if $current->{ip4};
>                  $replacement .= ",[$current->{ip6}]" if $current->{ip6};
> -                print $out "$replacement\n" if $replacement ne $parsed_mac;
> +                print $out "$replacement\n";
>                  next;
>              }
>  
> -            if ($parsed_mac eq $mac) {
> -                $match = 1 if $ip4 && $parsed_ip4 && $ip4;
> -                $match = 1 if $ip6 && $parsed_ip6 && $ip6;
> -            }
> -
>              print $out "$line\n";
>          }
>  
> -        if (!$match) {
> -            my $reservation = $mac;
> -            $reservation .= ",$ip4" if $ip4;
> -            $reservation .= ",[$ip6]" if $ip6;
> +        if (defined($reservation) && !$kept) {
>              print $out "$reservation\n";
> -            $reload = 1;
> +            $changed = 1;
>          }
>  
>          close $in;
> @@ -133,16 +163,22 @@ sub add_ip_mapping {
>          chmod 0644, $ethers_file;
>      };
>  
> -    PVE::Tools::lock_file($ethers_file, 10, $appendFn);
> +    # the rewrite replaces the reservation file, so a lock on the file itself
> +    # would not outlive the rename
> +    PVE::Tools::lock_file("$ethers_file.lock", 10, $rewriteFn);
>  
>      if ($@) {
> -        warn "Unable to add $mac to the dnsmasq configuration: $@\n";
> +        warn "Unable to update $mac in the dnsmasq configuration: $@\n";
>          return;
>      }
>  
> -    my $service_name = "dnsmasq\@$dhcpid";
> -    systemctl_service('reload', $service_name) if $reload;
> -    update_lease($dhcpid, $ip4, $mac);
> +    systemctl_service('reload', "dnsmasq\@$dhcpid") if $changed;
> +
> +    # the reservation is written, so a lease refresh that fails only warns
> +    if ($ip4) {
> +        eval { update_lease($dhcpid, $ip4, $mac) };
> +        log_warn("could not update the dnsmasq lease of $mac: $@") if $@;
> +    }
>  }
>  
>  sub configure_subnet {
> diff --git a/src/PVE/Network/SDN/Dhcp/Plugin.pm b/src/PVE/Network/SDN/Dhcp/Plugin.pm
> index b5d32fa1..2fa8683c 100644
> --- a/src/PVE/Network/SDN/Dhcp/Plugin.pm
> +++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm
> @@ -22,8 +22,11 @@ sub private {
>      return $defaultData;
>  }
>  
> -sub add_ip_mapping {
> -    my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_;
> +# the MAC's records changed, so make the backend serve the current ones. With no
> +# record left its reservation goes. The vmid names the guest when the caller
> +# knows it, which an API edit does not
> +sub update_ip_mapping {
> +    my ($class, $dhcpid, $mac, $vmid) = @_;
>      die 'implement in sub class';
>  }
>  
> diff --git a/src/PVE/Network/SDN/Vnets.pm b/src/PVE/Network/SDN/Vnets.pm
> index c327a4be..84b08037 100644
> --- a/src/PVE/Network/SDN/Vnets.pm
> +++ b/src/PVE/Network/SDN/Vnets.pm
> @@ -211,6 +211,8 @@ sub del_ips_from_mac {
>      PVE::Network::SDN::Vnets::del_ip($vnetid, $ip4, $hostname, $mac) if $ip4;
>      PVE::Network::SDN::Vnets::del_ip($vnetid, $ip6, $hostname, $mac) if $ip6;
>  
> +    PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac) if $ip4 || $ip6;
> +
>      return ($ip4, $ip6);
>  }
>  
> @@ -225,11 +227,12 @@ sub add_dhcp_mapping {
>      return if !$zone->{ipam} || !$zone->{dhcp};
>  
>      my ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac);
> +    # one push at the end covers the allocated and the existing records alike
>      add_next_free_cidr($vnetid, $name, $mac, "$vmid", undef, 1, 4) if !$ip4;
>      add_next_free_cidr($vnetid, $name, $mac, "$vmid", undef, 1, 6) if !$ip6;
>  
>      ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac);
> -    PVE::Network::SDN::Dhcp::add_mapping($vnetid, $mac, $ip4, $ip6) if $ip4 || $ip6;
> +    PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac, $vmid) if $ip4 || $ip6;
>  }
>  
>  1;
> diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
> index 36225a92..d324538e 100755
> --- a/src/test/run_test_vnets_blackbox.pl
> +++ b/src/test/run_test_vnets_blackbox.pl
> @@ -46,6 +46,7 @@ sub clear_test_state {
>          vnets_config => {},
>          macdb => {},
>          ipamdb => {},
> +        dnsmasq_calls => [],
>          ipam_config => {
>              'ids' => {
>                  'pve' => {
> @@ -220,7 +221,9 @@ $mocked_sdn_dhcp_dnsmasq->mock(
>      assert_dnsmasq_installed => sub { return 1; },
>      before_configure => sub { },
>      ethers_file => sub { return "/tmp/ethers"; },
> -    systemctl_service => sub { },
> +    systemctl_service => sub {
> +        push $test_state->{dnsmasq_calls}->@*, $_[0];
> +    },
>      update_lease => sub { },
>  );
>  
> @@ -239,6 +242,11 @@ $mocked_rpc_env_obj->mock(
>      check_any => sub { return 1; },
>  );
>  
> +my $mocked_pve_inotify = Test::MockModule->new('PVE::INotify');
> +$mocked_pve_inotify->mock(
> +    nodename => sub { return 'localnode'; },
> +);
> +
>  my $mocked_pve_cluster_obj = Test::MockModule->new('PVE::Cluster');
>  $mocked_pve_cluster_obj->mock(
>      check_cfs_quorum => sub { return 1; },
> @@ -288,6 +296,11 @@ sub create_zone {
>      return $zone;
>  }
>  
> +sub update_zone {
> +    my ($zoneid, $params) = @_;
> +    PVE::API2::Network::SDN::Zones->update({ zone => $zoneid, %$params });
> +}
> +
>  sub get_vnet {
>      my ($id) = @_;
>      return eval { PVE::API2::Network::SDN::Vnets->read({ vnet => $id }); };
> @@ -330,6 +343,16 @@ sub create_ip {
>      return PVE::API2::Network::SDN::Ips->ipcreate($param);
>  }
>  
> +sub update_ip {
> +    my ($param) = @_;
> +    return PVE::API2::Network::SDN::Ips->ipupdate($param);
> +}
> +
> +sub delete_ip {
> +    my ($param) = @_;
> +    return PVE::API2::Network::SDN::Ips->ipdelete($param);
> +}
> +
>  sub run_test {
>      my $test = shift;
>      clear_test_state();
> @@ -977,18 +1000,229 @@ sub test_dnsmasq_stale_reservation_replaced {
>      };
>      PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "$stale,10.0.1.10\n$gone,10.0.1.12\n");
>  
> -    PVE::Network::SDN::Dhcp::Dnsmasq->add_ip_mapping(
> -        'TESTZONE', $test_state->{macdb}, $added, '10.0.1.13', undef,
> -    );
> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping('TESTZONE', $added);
>  
> -    my @lines = sort grep { /\S/ } split(/\n/, PVE::Tools::file_get_contents($TMP_ETHERS_FILE));
> -    eq_or_diff(
> -        \@lines,
> -        ["$stale,10.0.1.11", "$added,10.0.1.13"],
> +    is(
> +        PVE::Tools::file_get_contents($TMP_ETHERS_FILE),
> +        "$stale,10.0.1.11\n$added,10.0.1.13\n",
>          "$test_name: the stale line is replaced, the unknown one dropped",
>      );
>  }
>  
>  run_test(\&test_dnsmasq_stale_reservation_replaced);
>  
> +# -------------- dnsmasq mapping pushes
> +
> +sub test_dnsmasq_mapping_push {
> +    my $test_name = (split(/::/, (caller(0))[3]))[-1];
> +    my $zoneid = "TESTZONE";
> +    my $vnetid = "testvnet";
> +    my $mac = "da:65:8f:18:9b:6f";
> +
> +    create_zone({
> +        type => "simple",
> +        dhcp => "dnsmasq",
> +        ipam => "pve",
> +        zone => $zoneid,
> +    });
> +    create_vnet({
> +        type => "vnet",
> +        zone => $zoneid,
> +        vnet => $vnetid,
> +    });
> +    create_subnet({
> +        type => "subnet",
> +        vnet => $vnetid,
> +        subnet => "10.0.0.0/24",
> +        gateway => "10.0.0.1",
> +        'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
> +    });
> +
> +    # the first push of a zone finds no ethers file yet
> +    unlink($TMP_ETHERS_FILE);
> +
> +    my $ethers = sub { PVE::Tools::file_get_contents($TMP_ETHERS_FILE) };
> +    my $reloads = sub {
> +        my $count = scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*);
> +        $test_state->{dnsmasq_calls} = [];
> +        return $count;
> +    };
> +
> +    eval { nic_start($vnetid, $mac, "999", "testhostname"); };
> +    if ($@) {
> +        fail("$test_name: nic_start: $@");
> +        return;
> +    }
> +    is($ethers->(), "$mac,10.0.0.100\n", "$test_name: guest start reserves the allocation");
> +    is($reloads->(), 1, "$test_name: guest start reloads once");
> +
> +    # the records exist already, so the restart pushes them without a change
> +    nic_start($vnetid, $mac, "999", "testhostname");
> +    is($ethers->(), "$mac,10.0.0.100\n", "$test_name: restart keeps the reservation");
> +    is($reloads->(), 0, "$test_name: restart without a change does not reload");
> +
> +    update_ip({
> +        zone => $zoneid,
> +        vnet => $vnetid,
> +        mac => $mac,
> +        ip => "10.0.0.150",
> +    });
> +    is($ethers->(), "$mac,10.0.0.150\n", "$test_name: mapping edit replaces the reservation");
> +    is($reloads->(), 1, "$test_name: mapping edit reloads once");
> +
> +    # a push writes what the cache holds, so a repeated one rewrites nothing
> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac);
> +    is($ethers->(), "$mac,10.0.0.150\n", "$test_name: a repeated push keeps the reservation");
> +    is($reloads->(), 0, "$test_name: a repeated push changes nothing");
> +
> +    # the cache and the file may spell a MAC differently, but it is one MAC
> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, uc($mac));
> +    is(
> +        $ethers->(),
> +        "$mac,10.0.0.150\n",
> +        "$test_name: a push spelled differently keeps the line as it is",
> +    );
> +    is($reloads->(), 0, "$test_name: and reloads nothing");
> +
> +    delete_ip({
> +        zone => $zoneid,
> +        vnet => $vnetid,
> +        mac => $mac,
> +        ip => "10.0.0.150",
> +    });
> +    is($ethers->(), "", "$test_name: mapping delete drops the reservation");
> +    is($reloads->(), 1, "$test_name: mapping delete reloads once");
> +
> +    # a record without a MAC has no NIC to serve, so nothing is pushed for it
> +    my @warnings;
> +    {
> +        local $SIG{__WARN__} = sub { push @warnings, @_; };
> +        create_ip({ zone => $zoneid, vnet => $vnetid, ip => "10.0.0.160" });
> +        delete_ip({ zone => $zoneid, vnet => $vnetid, ip => "10.0.0.160" });
> +    }
> +    is($reloads->(), 0, "$test_name: a record without a MAC is not pushed");
> +    eq_or_diff(\@warnings, [], "$test_name: and warns nothing");
> +
> +    # a zone confined to other nodes runs no dnsmasq here, so an edit is not
> +    # pushed on this node
> +    update_zone($zoneid, { nodes => 'other' });
> +    create_ip({
> +        zone => $zoneid,
> +        vnet => $vnetid,
> +        mac => $mac,
> +        ip => "10.0.0.150",
> +    });
> +    is($ethers->(), "", "$test_name: a zone confined to other nodes gets no push here");
> +    is($reloads->(), 0, "$test_name: nothing to reload for it");
> +}
> +
> +run_test(\&test_dnsmasq_mapping_push);
> +
> +sub test_dnsmasq_dual_stack_and_sweep {
> +    my $test_name = (split(/::/, (caller(0))[3]))[-1];
> +    my $zoneid = "TESTZONE";
> +    my $vnetid = "testvnet";
> +    my $mac = "da:65:8f:18:9b:6f";
> +
> +    create_zone({
> +        type => "simple",
> +        dhcp => "dnsmasq",
> +        ipam => "pve",
> +        zone => $zoneid,
> +    });
> +    create_vnet({
> +        type => "vnet",
> +        zone => $zoneid,
> +        vnet => $vnetid,
> +    });
> +    create_subnet({
> +        type => "subnet",
> +        vnet => $vnetid,
> +        subnet => "10.0.0.0/24",
> +        gateway => "10.0.0.1",
> +        'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
> +    });
> +    create_subnet({
> +        type => "subnet",
> +        vnet => $vnetid,
> +        subnet => "8888::/64",
> +        gateway => "8888::1",
> +        'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
> +    });
> +
> +    # a reservation of a MAC the cache does not know is swept by the next
> +    # rewrite, and both addresses of the guest go on one line
> +    PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "aa:bb:cc:dd:ee:ff,10.0.0.77\n");
> +    $test_state->{dnsmasq_calls} = [];
> +
> +    eval { nic_start($vnetid, $mac, "999", "testhostname"); };
> +    if ($@) {
> +        fail("$test_name: nic_start: $@");
> +        return;
> +    }
> +    is(
> +        PVE::Tools::file_get_contents($TMP_ETHERS_FILE),
> +        "$mac,10.0.0.100,[8888::100]\n",
> +        "$test_name: dual-stack reservation on one line, the unknown MAC swept",
> +    );
> +    is(
> +        scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*),
> +        1,
> +        "$test_name: one reload for the rewrite",
> +    );
> +
> +    # an external IPAM may spell an address in upper case, but the file holds
> +    # one spelling
> +    $test_state->{macdb}->{macs}->{$mac}->{ip6} = '8888::AB';
> +    $test_state->{dnsmasq_calls} = [];
> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac);
> +    is(
> +        PVE::Tools::file_get_contents($TMP_ETHERS_FILE),
> +        "$mac,10.0.0.100,[8888::ab]\n",
> +        "$test_name: the reservation is written in one spelling",
> +    );
> +    is(
> +        scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*),
> +        1,
> +        "$test_name: one reload for the changed address",
> +    );
> +
> +    # an entry without an address, as a miss once left behind, says nothing
> +    # about its MAC, so a line of it is swept
> +    my $stale = 'da:65:8f:18:9b:70';
> +    $test_state->{macdb}->{macs}->{$stale} = { ip4 => undef, ip6 => undef };
> +    PVE::Tools::file_set_contents(
> +        $TMP_ETHERS_FILE,
> +        "$mac,10.0.0.100,[8888::ab]\n$stale,10.0.0.101\n",
> +    );
> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac);
> +    is(
> +        PVE::Tools::file_get_contents($TMP_ETHERS_FILE),
> +        "$mac,10.0.0.100,[8888::ab]\n",
> +        "$test_name: a line of a MAC cached without an address is swept",
> +    );
> +
> +    # a line with an upper-case IPv6 address from before they were lower-cased is kept
> +    my $other = 'da:65:8f:18:9b:71';
> +    $test_state->{macdb}->{macs}->{$other} = { ip4 => '10.0.0.102', ip6 => '8888::AB' };
> +    PVE::Tools::file_set_contents(
> +        $TMP_ETHERS_FILE,
> +        "$mac,10.0.0.100,[8888::ab]\n$other,10.0.0.102,[8888::AB]\n",
> +    );
> +    $test_state->{dnsmasq_calls} = [];
> +    PVE::Network::SDN::Dhcp::Dnsmasq->update_ip_mapping($zoneid, $mac);
> +    is(
> +        PVE::Tools::file_get_contents($TMP_ETHERS_FILE),
> +        "$mac,10.0.0.100,[8888::ab]\n$other,10.0.0.102,[8888::AB]\n",
> +        "$test_name: a line of another MAC spelled by the old code is kept",
> +    );
> +    is(
> +        scalar(grep { $_ eq 'reload' } $test_state->{dnsmasq_calls}->@*),
> +        0,
> +        "$test_name: without a reload",
> +    );
> +}
> +
> +run_test(\&test_dnsmasq_dual_stack_and_sweep);
> +
>  done_testing();





  reply	other threads:[~2026-10-20 13:42 UTC|newest]

Thread overview: 43+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-20  7:59 [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-10-20  7:59 ` [PATCH proxmox-ebpf v3 01/17] dhcp: add the per-tap DHCPv4 responder program Hannes Laimer
2026-10-20  7:59 ` [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records Hannes Laimer
2026-10-20 13:09   ` Stefan Hanreich
2026-10-21  7:09     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH proxmox-perl-rs v3 03/17] pve-rs: sdn: add bindings for the eBPF DHCP responder Hannes Laimer
2026-10-20 13:10   ` Stefan Hanreich
2026-10-21  7:32     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 04/17] sdn: dhcp: replace a stale dnsmasq reservation instead of dropping it Hannes Laimer
2026-10-20 13:15   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 05/17] sdn: dhcp: update reservations when ip mappings change Hannes Laimer
2026-10-20 13:42   ` Stefan Hanreich [this message]
2026-10-21  7:21     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 06/17] sdn: ipam: key the MAC cache by lower-case MAC, stop caching misses Hannes Laimer
2026-10-20 13:10   ` Stefan Hanreich
2026-10-21  6:17     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 07/17] sdn: dhcp: make the lease time configurable per subnet Hannes Laimer
2026-10-20 13:17   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 08/17] sdn: dhcp: require a backend only if a zone on this node uses it Hannes Laimer
2026-10-20 13:17   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 09/17] sdn: dhcp: add an eBPF backend answering on each guest interface Hannes Laimer
2026-10-20 13:10   ` Stefan Hanreich
2026-10-21  6:16     ` Hannes Laimer
2026-10-20  7:59 ` [PATCH pve-network v3 10/17] sdn: zones: report interface plugs and unplugs to the DHCP backends Hannes Laimer
2026-10-20 13:17   ` Stefan Hanreich
2026-10-20  7:59 ` [PATCH pve-network v3 11/17] sdn: dhcp: push IP mapping edits to the guest's node Hannes Laimer
2026-10-20 13:09   ` Stefan Hanreich
2026-10-21  6:44     ` Hannes Laimer
2026-10-20  8:00 ` [PATCH pve-network v3 12/17] sdn: zones: allow the ebpf DHCP backend on every zone type Hannes Laimer
2026-10-20  8:00 ` [PATCH qemu-server v3 13/17] network: sdn: keep the DHCP backends current on guest NICs Hannes Laimer
2026-10-20 13:38   ` Stefan Hanreich
2026-10-20  8:00 ` [PATCH pve-container v3 14/17] net: report NIC plugs and removals to the SDN DHCP backends Hannes Laimer
2026-10-20 13:36   ` Stefan Hanreich
2026-10-21  7:29     ` Hannes Laimer
2026-10-20  8:00 ` [PATCH pve-manager v3 15/17] ui: sdn: add zone DHCP backend selector and subnet DHCP options Hannes Laimer
2026-10-20 13:19   ` Stefan Hanreich
2026-10-20  8:00 ` [PATCH pve-manager v3 16/17] sdn: bring up the DHCP backends at boot before the guests start Hannes Laimer
2026-10-20 13:21   ` Stefan Hanreich
2026-10-21  7:17     ` Hannes Laimer
2026-10-20  8:00 ` [PATCH pve-docs v3 17/17] sdn: dhcp: add eBPF backend section Hannes Laimer
2026-10-20 13:11 ` [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Stefan Hanreich
2026-10-21  7:35   ` Hannes Laimer
2026-10-21  8:46 ` superseded: " Hannes Laimer

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=b5a19351-b64f-4569-904e-2f3191dc2a4e@proxmox.com \
    --to=s.hanreich@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