diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 96305cd02..439348152 100644 --- a/netbox/ipam/models/ip.py +++ b/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 diff --git a/netbox/ipam/tests/test_tables.py b/netbox/ipam/tests/test_tables.py index 2a6220f33..527da9677 100644 --- a/netbox/ipam/tests/test_tables.py +++ b/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) diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 619e7ffb8..390215873 100644 --- a/netbox/ipam/utils.py +++ b/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: