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
This commit is contained in:
Martin Hauser 2026-05-13 21:40:40 +02:00 committed by Jeremy Stretch
parent df5bc85b48
commit f66e6f360a
6 changed files with 161 additions and 3 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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',

View File

@ -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()

View File

@ -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):

View File

@ -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