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

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 неделя назад
Родитель
Сommit
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.
 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
 !!! note
     The maximum supported size of an IP range is 2^32 - 1.
     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")
                     '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({
                 raise ValidationError({
                     'end_address': _(
                     '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)
                     ).format(start_address=self.start_address)
                 })
                 })
 
 
@@ -677,6 +678,10 @@ class IPRange(ContactsMixin, PrimaryModel):
         """
         """
         Return an efficient string representation of the IP range.
         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 '.'
         separator = ':' if self.family == 6 else '.'
         start_chunks = str(self.start_address.ip).split(separator)
         start_chunks = str(self.start_address.ip).split(separator)
         end_chunks = str(self.end_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',
             'start_address': '192.168.6.10/24',
             'end_address': '192.168.6.50/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 = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',

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

@@ -1110,6 +1110,25 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'mark_populated': 'false'}
         params = {'mark_populated': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         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):
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()

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

@@ -101,6 +101,69 @@ class TestIPRange(TestCase):
             )
             )
             iprange_4_198_201.clean()
             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):
 class TestPrefix(TestCase):
 
 
@@ -640,6 +703,19 @@ class TestIPAddress(TestCase):
         ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
         ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
         self.assertRaises(ValidationError, ip.full_clean)
         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):
 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})
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         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=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_prefix_import(self):
     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.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.2.0.1/16,10.2.9.254/16,active",
             "VRF 1,10.3.0.1/16,10.3.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 = (
         cls.csv_update_data = (
@@ -865,6 +889,28 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
         url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         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):
 class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPAddress
     model = IPAddress