Add RFC 2317 classless reverse zone names for sub-/24 subnets

This commit is contained in:
ponas committed 2026-06-10 00:31:49 +02:00
1 parent 47acd88923
commit 5c275dd1e7
6 files changed
+215 -12

No files matched your search

+39 -5
View File
@@ -169,16 +169,18 @@ class DhcpCustomFieldInline(admin.TabularInline):
extra = 0 extra = 0
class SubnetAdmin(admin.ModelAdmin): class Ip4SubnetAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"name", "name",
"network", "network",
"netmask", "netmask",
"domain_name",
"num_addresses", "num_addresses",
"broadcast_address", "broadcast_address",
"first_address", "first_address",
"last_address", "last_address",
] ]
readonly_fields = ["domain_name"]
inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline] inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline]
def num_addresses(self, ip4subnet): def num_addresses(self, ip4subnet):
@@ -198,13 +200,45 @@ class SubnetAdmin(admin.ModelAdmin):
return list(subnet.hosts())[-1] return list(subnet.hosts())[-1]
broadcast_address.short_description = "broadcast" broadcast_address.short_description = "broadcast"
num_addresses.short_description = "#addresses" num_addresses.short_description = "# addresses"
first_address.short_description = "first address" first_address.short_description = "first address"
last_address.short_description = "last address" last_address.short_description = "last address"
def get_queryset(self, request): def get_queryset(self, request):
# TODO: optimize # TODO: optimize
return super(SubnetAdmin, self).get_queryset(request) return super(Ip4SubnetAdmin, self).get_queryset(request)
class Ip6SubnetAdmin(admin.ModelAdmin):
list_display = [
"name",
"network",
"netmask",
"domain_name",
"num_addresses",
"first_address",
"last_address",
]
readonly_fields = ["domain_name"]
def _network(self, ip6subnet):
return ipaddress.IPv6Network(
"%s::/%d" % (ip6subnet.network, ip6subnet.netmask), strict=False
)
def num_addresses(self, ip6subnet):
return self._network(ip6subnet).num_addresses
def first_address(self, ip6subnet):
return self._network(ip6subnet).network_address
def last_address(self, ip6subnet):
# subnet[-1] is O(1); never materialize hosts() for a /64.
return self._network(ip6subnet)[-1]
num_addresses.short_description = "# addresses"
first_address.short_description = "first address"
last_address.short_description = "last address"
class DomainSrvRecordInline(admin.TabularInline): class DomainSrvRecordInline(admin.TabularInline):
@@ -283,8 +317,8 @@ class OperatingSystemAdmin(admin.ModelAdmin):
admin.site.register(Domain, DomainAdmin) admin.site.register(Domain, DomainAdmin)
admin.site.register(Host, HostAdmin) admin.site.register(Host, HostAdmin)
admin.site.register(Ip4Subnet, SubnetAdmin) admin.site.register(Ip4Subnet, Ip4SubnetAdmin)
admin.site.register(Ip6Subnet) admin.site.register(Ip6Subnet, Ip6SubnetAdmin)
admin.site.register(Ip6Address) admin.site.register(Ip6Address)
admin.site.register(Nameserver) admin.site.register(Nameserver)
admin.site.register(MailExchange) admin.site.register(MailExchange)
@@ -0,0 +1,39 @@
import ipaddress
from django.db import migrations
def set_classless_reverse_names(apps, schema_editor):
"""Recompute domain_name for sub-/24 IPv4 subnets (RFC 2317 classless).
domain_name is frozen on first save, so subnets created before classless
support kept the plain /24 name (190.36.158.in-addr.arpa) even for a /26.
That apex no longer matches the classless zone declared in named.conf, so
BIND refuses to load the file. Rewrite those names to the RFC 2317 form
(192-26.190.36.158.in-addr.arpa). /24-or-shorter subnets are left alone.
"""
Ip4Subnet = apps.get_model("mdb", "Ip4Subnet")
for subnet in Ip4Subnet.objects.all():
try:
net = ipaddress.IPv4Network(
"%s/%s" % (subnet.network, subnet.netmask), strict=False
)
except ValueError:
continue
if net.prefixlen <= 24:
continue
o1, o2, o3, o4 = str(net.network_address).split(".")
name = "%s-%d.%s.%s.%s.in-addr.arpa" % (o4, net.prefixlen, o3, o2, o1)
if subnet.domain_name != name:
subnet.domain_name = name
subnet.save(update_fields=["domain_name"])
class Migration(migrations.Migration):
dependencies = [
("mdb", "0013_host_allow_email"),
]
operations = [
migrations.RunPython(set_classless_reverse_names, migrations.RunPython.noop),
]
+31
View File
@@ -299,6 +299,18 @@ class Ip6Subnet(models.Model):
def __str__(self): def __str__(self):
return self.network + " (" + self.name + ")" return self.network + " (" + self.name + ")"
@property
def reverse_zone_name(self):
"""The ip6.arpa reverse zone name for this subnet's prefix.
One label per nibble, derived from ``netmask`` (the prefix length).
Delegation in ip6.arpa happens on nibble (4-bit) boundaries, so the
prefix is truncated down to whole nibbles.
"""
network = ipaddress.IPv6Address("%s::" % self.network)
nibbles = self.netmask // 4
return ".".join(network.exploded.replace(":", "")[:nibbles])[::-1] + ".ip6.arpa"
def zone_file_contents(self, generate_unassigned=False): def zone_file_contents(self, generate_unassigned=False):
content = "" content = ""
content += "; zone file for %s\n" % self.domain_name content += "; zone file for %s\n" % self.domain_name
@@ -379,6 +391,25 @@ class Ip4Subnet(models.Model):
def __str__(self): def __str__(self):
return self.network + " (" + self.name + ")" return self.network + " (" + self.name + ")"
@property
def reverse_zone_name(self):
"""The in-addr.arpa reverse zone name for this subnet.
Masks of /24 or shorter give the standard octet-boundary name
(190.36.158.in-addr.arpa). Masks longer than /24 give the RFC 2317
classless name for the sub-/24 block (192-26.190.36.158.in-addr.arpa).
This is the apex BIND expects -- it must match the zone name in
named.conf or rndc refuses to load the file.
"""
net = ipaddress.IPv4Network(
"%s/%s" % (self.network, self.netmask), strict=False
)
o1, o2, o3, o4 = str(net.network_address).split(".")
rev = "%s.%s.%s" % (o3, o2, o1)
if net.prefixlen > 24:
return "%s-%d.%s.in-addr.arpa" % (o4, net.prefixlen, rev)
return "%s.in-addr.arpa" % rev
def zone_file_contents(self, generate_unassigned=False): def zone_file_contents(self, generate_unassigned=False):
content = "" content = ""
content += "; zone file for %s\n" % self.domain_name content += "; zone file for %s\n" % self.domain_name
+2 -7
View File
@@ -34,9 +34,7 @@ def delete_ips_for_subnet(sender, instance, **kwargs):
def set_domain_name_for_subnet(sender, instance, **kwargs): def set_domain_name_for_subnet(sender, instance, **kwargs):
# we assume that the reverse domain_name does not change # we assume that the reverse domain_name does not change
if len(instance.domain_name) == 0: if len(instance.domain_name) == 0:
ipspl = instance.network.split(".") instance.domain_name = instance.reverse_zone_name
rev = "%s.%s.%s" % (ipspl[2], ipspl[1], ipspl[0])
instance.domain_name = "%s.in-addr.arpa" % rev
# update it's own serial # update it's own serial
# if instance.domain_serial is not None: # if instance.domain_serial is not None:
@@ -57,10 +55,7 @@ def set_domain_name_for_ipv6_subnet(sender, instance, **kwargs):
if len(instance.domain_name) > 0: if len(instance.domain_name) > 0:
return return
network = ipaddress.IPv6Address("%s::" % instance.network) instance.domain_name = instance.reverse_zone_name
instance.domain_name = (
".".join(network.exploded.replace(":", "")[:16])[::-1] + ".ip6.arpa"
)
@receiver(post_save, sender=Interface) @receiver(post_save, sender=Interface)
+37
View File
@@ -0,0 +1,37 @@
import pytest
from mdb.models import Ip4Subnet, Ip6Subnet
@pytest.mark.parametrize(
"network,netmask,expected",
[
# /24 and shorter -> standard octet-boundary name
("203.0.113.0", "255.255.255.0", "113.0.203.in-addr.arpa"),
("10.0.3.0", "255.255.255.0", "3.0.10.in-addr.arpa"),
("10.0.0.0", "255.255.0.0", "0.0.10.in-addr.arpa"),
# longer than /24 -> RFC 2317 classless name
("192.0.2.192", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
("10.0.0.0", "255.255.255.252", "0-30.0.0.10.in-addr.arpa"),
("10.1.2.0", "255.255.255.128", "0-25.2.1.10.in-addr.arpa"),
# host bits set -> normalized down to the network address
("192.0.2.200", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
],
)
def test_ip4_reverse_zone_name(network, netmask, expected):
subnet = Ip4Subnet(network=network, netmask=netmask)
assert subnet.reverse_zone_name == expected
@pytest.mark.parametrize(
"network,netmask,expected",
[
("2001:db8", 64, "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8", 48, "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8:abcd", 56, "0.0.d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa"),
("2001:db8", 32, "8.b.d.0.1.0.0.2.ip6.arpa"),
],
)
def test_ip6_reverse_zone_name(network, netmask, expected):
subnet = Ip6Subnet(network=network, netmask=netmask)
assert subnet.reverse_zone_name == expected
+67
View File
@@ -0,0 +1,67 @@
import pytest
from mdb.models import DhcpConfig, Ip4Subnet, Ip6Subnet
# ---------------------------------------------------------------------------
# pre_save signals: populate domain_name once, then leave it frozen
# ---------------------------------------------------------------------------
def make_ip4subnet(**overrides):
dhcp_config = DhcpConfig.objects.create(serial=1, active_serial=1)
fields = {
"name": "test-subnet",
"netmask": "255.255.255.192",
"network": "192.0.2.192",
"domain_soa": "ns1.example.org",
"domain_admin": "admin@example.org",
"domain_filename": "db.test-subnet",
"dhcp_config": dhcp_config,
}
fields.update(overrides)
return Ip4Subnet.objects.create(**fields)
@pytest.mark.django_db
def test_ip4_signal_sets_classless_domain_name():
subnet = make_ip4subnet()
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
@pytest.mark.django_db
def test_ip4_signal_sets_plain_domain_name_for_slash24():
subnet = make_ip4subnet(network="203.0.113.0", netmask="255.255.255.0")
assert subnet.domain_name == "113.0.203.in-addr.arpa"
@pytest.mark.django_db
def test_ip4_signal_does_not_overwrite_existing_domain_name():
subnet = make_ip4subnet(domain_name="manual.in-addr.arpa")
assert subnet.domain_name == "manual.in-addr.arpa"
@pytest.mark.django_db
def test_ip4_domain_name_is_frozen_when_network_changes():
subnet = make_ip4subnet()
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
subnet.network = "10.0.0.0"
subnet.netmask = "255.255.255.0"
subnet.save()
subnet.refresh_from_db()
# The name is derived once and then left alone.
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
@pytest.mark.django_db
def test_ip6_signal_sets_domain_name():
subnet = Ip6Subnet.objects.create(
name="ipv6-doc",
network="2001:db8",
netmask=64,
domain_soa="ns1.example.org",
domain_admin="admin@example.org",
domain_filename="db.reverse-v6",
)
assert subnet.domain_name == "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"