Просмотр исходного кода

fix(ipam): Avoid allocating IPv6 subnet-router anycast address (#21547)

Ensure available IP selection for IPv6 non-pool prefixes excludes the
subnet-router anycast address (RFC 4291), so allocation starts at ::1
for typical prefixes (e.g. /64).
Add tests for IPv4/IPv6 pools and special cases (/31-/32, /127-/128).

Fixes #21347
Martin Hauser 1 день назад
Родитель
Сommit
3f02309538
3 измененных файлов с 145 добавлено и 5 удалено
  1. 5 3
      netbox/ipam/models/ip.py
  2. 129 0
      netbox/ipam/tests/test_tables.py
  3. 11 2
      netbox/ipam/utils.py

+ 5 - 3
netbox/ipam/models/ip.py

@@ -432,9 +432,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         ])
         available_ips = prefix - child_ips - child_ranges
 
-        # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
-        if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
-                self.family == 4 and self.prefix.prefixlen >= 31
+        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
+        if (
+            self.is_pool
+            or (self.family == 4 and self.prefix.prefixlen >= 31)
+            or (self.family == 6 and self.prefix.prefixlen >= 127)
         ):
             return available_ips
 

+ 129 - 0
netbox/ipam/tests/test_tables.py

@@ -39,3 +39,132 @@ class AnnotatedIPAddressTableTest(TestCase):
 
         iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
         self.assertEqual(iprange_checkbox_count, 0)
+
+    def test_annotate_ip_space_ipv4_non_pool_excludes_network_and_broadcast(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),  # 8 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /29 non-pool: exclude .0 (network) and .7 (broadcast)
+        self.assertEqual(available.first_ip, '192.0.2.1/29')
+        self.assertEqual(available.size, 6)
+
+    def test_annotate_ip_space_ipv4_pool_includes_network_and_broadcast(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.8/29'),  # 8 addresses total
+            status='active',
+            is_pool=True,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # Pool: all addresses are usable, including network/broadcast
+        self.assertEqual(available.first_ip, '192.0.2.8/29')
+        self.assertEqual(available.size, 8)
+
+    def test_annotate_ip_space_ipv4_31_includes_all_ips(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.16/31'),  # 2 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /31: fully usable
+        self.assertEqual(available.first_ip, '192.0.2.16/31')
+        self.assertEqual(available.size, 2)
+
+    def test_annotate_ip_space_ipv4_32_includes_single_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.100/32'),  # 1 address total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /32: single usable address
+        self.assertEqual(available.first_ip, '192.0.2.100/32')
+        self.assertEqual(available.size, 1)
+
+    def test_annotate_ip_space_ipv6_non_pool_excludes_anycast_first_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),  # 4 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        # No child records -> expect one AvailableIPSpace entry
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # For IPv6 non-pool prefixes (except /127-/128), the first address is reserved (subnet-router anycast)
+        self.assertEqual(available.first_ip, '2001:db8::1/126')
+        self.assertEqual(available.size, 3)  # 4 total - 1 reserved anycast
+
+    def test_annotate_ip_space_ipv6_127_includes_all_ips(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/127'),  # 2 addresses total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /127 is fully usable (no anycast exclusion)
+        self.assertEqual(available.first_ip, '2001:db8::/127')
+        self.assertEqual(available.size, 2)
+
+    def test_annotate_ip_space_ipv6_128_includes_single_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::1/128'),  # 1 address total
+            status='active',
+            is_pool=False,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # /128 is fully usable (single host address)
+        self.assertEqual(available.first_ip, '2001:db8::1/128')
+        self.assertEqual(available.size, 1)
+
+    def test_annotate_ip_space_ipv6_pool_includes_anycast_first_ip(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8:1::/126'),  # 4 addresses total
+            status='active',
+            is_pool=True,
+        )
+
+        data = annotate_ip_space(prefix)
+
+        self.assertEqual(len(data), 1)
+        available = data[0]
+
+        # Pools are fully usable
+        self.assertEqual(available.first_ip, '2001:db8:1::/126')
+        self.assertEqual(available.size, 4)

+ 11 - 2
netbox/ipam/utils.py

@@ -78,12 +78,21 @@ def annotate_ip_space(prefix):
     records = sorted(records, key=lambda x: x[0])
 
     # Determine the first & last valid IP addresses in the prefix
-    if prefix.family == 4 and prefix.mask_length < 31 and not prefix.is_pool:
+    if (
+        prefix.is_pool
+        or (prefix.family == 4 and prefix.mask_length >= 31)
+        or (prefix.family == 6 and prefix.mask_length >= 127)
+    ):
+        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
+        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
+        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
+    elif prefix.family == 4:
         # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
         first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
         last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
     else:
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
+        # For IPv6 prefixes, omit the Subnet-Router anycast address (RFC 4291)
+        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
         last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
 
     if not records: