Sfoglia il codice sorgente

feat(ipam): Allow single-address IP Ranges

Allow IP ranges where start_address equals end_address to model
single-IP pools like DHCP or NAT reservations. Add validation tests,
filterset coverage, and display logic to render both endpoints.

Fixes #21993
Martin Hauser 1 settimana fa
parent
commit
f66e6f360a

+ 7 - 0
docs/models/ipam/iprange.md

@@ -21,6 +21,13 @@ The [Virtual Routing and Forwarding](./vrf.md) instance in which this IP range e
 
 The beginning and ending IP addresses (inclusive) which define the boundaries of the range. Both IP addresses must specify the correct mask.
 
+A range may contain a single IP address by setting the start and end address to the same value, including the same mask length. This is useful when a pool-like construct, such as a DHCP or NAT pool, contains only one usable address.
+
+!!! note
+    A single-address IP range is not a replacement for an [IP address](./ipaddress.md). Use an IP address object when modeling an address configured on an interface, assigned as a primary IP, or otherwise participating in IP-address-specific relationships. Use an IP range only when the address is being modeled as a pool, reservation range, or similar range-oriented construct.
+
+    The end address is still required. To create a single-address range, enter the same address and mask for both the start and end address.
+
 !!! note
     The maximum supported size of an IP range is 2^32 - 1.
 

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

@@ -605,11 +605,12 @@ class IPRange(ContactsMixin, PrimaryModel):
                     'end_address': _("Starting and ending IP address masks must match")
                 })
 
-            # Check that the ending address is greater than the starting address
-            if not self.end_address > self.start_address:
+            # Equal start/end addresses are permitted for single-address ranges.
+            # Use .ip (host portion) rather than the IPNetwork object to avoid comparing prefix lengths again.
+            if self.end_address.ip < self.start_address.ip:
                 raise ValidationError({
                     'end_address': _(
-                        "Ending address must be greater than the starting address ({start_address})"
+                        "Ending address must be greater than or equal to the starting address ({start_address})"
                     ).format(start_address=self.start_address)
                 })
 
@@ -677,6 +678,10 @@ class IPRange(ContactsMixin, PrimaryModel):
         """
         Return an efficient string representation of the IP range.
         """
+        # Single-address ranges render both endpoints to stay distinct from IPAddress rows.
+        if self.start_address.ip == self.end_address.ip:
+            return f'{self.start_address.ip}-{self.end_address.ip}/{self.start_address.prefixlen}'
+
         separator = ':' if self.family == 6 else '.'
         start_chunks = str(self.start_address.ip).split(separator)
         end_chunks = str(self.end_address.ip).split(separator)

+ 5 - 0
netbox/ipam/tests/test_api.py

@@ -680,6 +680,11 @@ class IPRangeTestCase(APIViewTestCases.APIViewTestCase):
             'start_address': '192.168.6.10/24',
             'end_address': '192.168.6.50/24',
         },
+        {
+            # Single-address range (start == end)
+            'start_address': '192.168.7.10/24',
+            'end_address': '192.168.7.10/24',
+        },
     ]
     bulk_update_data = {
         'description': 'New description',

+ 19 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1110,6 +1110,25 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'mark_populated': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
+    def test_single_address_range(self):
+        # A range with start_address == end_address must be discoverable by the
+        # start, end, and contains filters.
+        iprange = IPRange(
+            start_address=IPNetwork('10.0.5.1/24'),
+            end_address=IPNetwork('10.0.5.1/24'),
+        )
+        iprange.clean()
+        iprange.save()
+
+        params = {'start_address': ['10.0.5.1']}
+        self.assertIn(iprange, self.filterset(params, self.queryset).qs)
+
+        params = {'end_address': ['10.0.5.1']}
+        self.assertIn(iprange, self.filterset(params, self.queryset).qs)
+
+        params = {'contains': '10.0.5.1/24'}
+        self.assertIn(iprange, self.filterset(params, self.queryset).qs)
+
 
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()

+ 76 - 0
netbox/ipam/tests/test_models.py

@@ -101,6 +101,69 @@ class TestIPRange(TestCase):
             )
             iprange_4_198_201.clean()
 
+    def test_single_address_range(self):
+        iprange = IPRange(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.10/24'),
+        )
+
+        iprange.clean()
+        iprange.save()
+
+        self.assertEqual(iprange.size, 1)
+        self.assertEqual(str(iprange), '192.0.2.10-192.0.2.10/24')
+        self.assertEqual(iprange.first_available_ip, '192.0.2.10/24')
+
+    def test_first_available_ip_consumed_single_address_range(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.10/24'),
+        )
+        IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
+
+        # The sole address in the range is now assigned, so no IPs remain available.
+        self.assertIsNone(iprange.first_available_ip)
+
+    def test_single_address_range_ipv6(self):
+        # IPRange.name has IPv4/IPv6-specific formatting; exercise the IPv6 branch
+        # for a single-address range too.
+        iprange = IPRange(
+            start_address=IPNetwork('2001:db8::10/64'),
+            end_address=IPNetwork('2001:db8::10/64'),
+        )
+
+        iprange.clean()
+        iprange.save()
+
+        self.assertEqual(iprange.size, 1)
+        self.assertEqual(str(iprange), '2001:db8::10-2001:db8::10/64')
+        self.assertEqual(iprange.first_available_ip, '2001:db8::10/64')
+
+    def test_reversed_range(self):
+        iprange = IPRange(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.9/24'),
+        )
+
+        with self.assertRaises(ValidationError):
+            iprange.clean()
+
+    def test_overlapping_single_address_range(self):
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.10/24'),
+        )
+
+        iprange = IPRange(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.10/24'),
+        )
+
+        # Assert the overlap-specific error message so this test cannot pass on a
+        # regression where start_address == end_address is rejected earlier.
+        with self.assertRaisesMessage(ValidationError, 'Defined addresses overlap'):
+            iprange.clean()
+
 
 class TestPrefix(TestCase):
 
@@ -640,6 +703,19 @@ class TestIPAddress(TestCase):
         ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
         self.assertRaises(ValidationError, ip.full_clean)
 
+    def test_mark_populated_single_address_range_blocks_ip(self):
+        # A single-address range with mark_populated=True must still block creation
+        # of an IPAddress at the same host with the same mask.
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.10/24'),
+            mark_populated=True,
+        )
+        ipaddress = IPAddress(address=IPNetwork('192.0.2.10/24'))
+
+        with self.assertRaisesMessage(ValidationError, 'Cannot create IP address'):
+            ipaddress.clean()
+
 
 class TestVLANGroup(TestCase):
 

+ 46 - 0
netbox/ipam/tests/test_views.py

@@ -617,6 +617,28 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         self.assertHttpStatus(self.client.get(url), 200)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_ipaddresses_with_single_address_range(self):
+        # The IP Addresses tab annotates child IP addresses alongside any
+        # mark-populated child IP ranges. Make sure a single-address range
+        # (start_address == end_address) renders without errors and is shown
+        # in its range-like display form rather than as a plain IP address.
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
+        IPAddress.objects.create(address=IPNetwork('192.168.0.1/16'))
+        IPRange.objects.create(
+            start_address=IPNetwork('192.168.0.50/16'),
+            end_address=IPNetwork('192.168.0.50/16'),
+            mark_populated=True,
+        )
+
+        url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+        # The single-address range is rendered with both endpoints, not as
+        # 192.168.0.50/16 (which would make it indistinguishable from an
+        # IPAddress row in this mixed-record view).
+        self.assertContains(response, '192.168.0.50-192.168.0.50/16')
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_prefix_import(self):
         """
@@ -830,6 +852,8 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             "VRF 1,10.1.0.1/16,10.1.9.254/16,active",
             "VRF 1,10.2.0.1/16,10.2.9.254/16,active",
             "VRF 1,10.3.0.1/16,10.3.9.254/16,active",
+            # Single-address range (start == end)
+            "VRF 1,10.4.0.1/16,10.4.0.1/16,active",
         )
 
         cls.csv_update_data = (
@@ -865,6 +889,28 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
         self.assertHttpStatus(self.client.get(url), 200)
 
+    def test_create_single_address_range(self):
+        # Exercise the UI form path with start_address == end_address. The
+        # generic test_create_object_with_permission covers the multi-address
+        # case via cls.form_data; this test mirrors that flow for the single-
+        # address case so both paths stay covered.
+        self.add_permissions('ipam.add_iprange')
+        form_data = {
+            'start_address': '192.0.6.10/24',
+            'end_address': '192.0.6.10/24',
+            'status': IPRangeStatusChoices.STATUS_ACTIVE,
+        }
+        initial_count = IPRange.objects.count()
+
+        response = self.client.post(reverse('ipam:iprange_add'), data=form_data)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(IPRange.objects.count(), initial_count + 1)
+
+        iprange = IPRange.objects.order_by('pk').last()
+        self.assertEqual(str(iprange.start_address), '192.0.6.10/24')
+        self.assertEqual(str(iprange.end_address), '192.0.6.10/24')
+        self.assertEqual(iprange.size, 1)
+
 
 class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPAddress