From f66e6f360a93c4c2f56d479d42cc1003d273d640 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Wed, 13 May 2026 21:40:40 +0200 Subject: [PATCH] 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 --- docs/models/ipam/iprange.md | 7 +++ netbox/ipam/models/ip.py | 11 ++-- netbox/ipam/tests/test_api.py | 5 ++ netbox/ipam/tests/test_filtersets.py | 19 +++++++ netbox/ipam/tests/test_models.py | 76 ++++++++++++++++++++++++++++ netbox/ipam/tests/test_views.py | 46 +++++++++++++++++ 6 files changed, 161 insertions(+), 3 deletions(-) diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md index fd439998a..760dde699 100644 --- a/docs/models/ipam/iprange.md +++ b/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. diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a776aa9b3..cd4554257 100644 --- a/netbox/ipam/models/ip.py +++ b/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) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index eff18e8f0..70d884b5e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/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', diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 856f8d4b5..fdbb0767f 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/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() diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index d7a1a1267..be628bcd3 100644 --- a/netbox/ipam/tests/test_models.py +++ b/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): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index e77d88b68..23e0b9e08 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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