diff --git a/mdb/admin.py b/mdb/admin.py index e27c544..f0cc39d 100644 --- a/mdb/admin.py +++ b/mdb/admin.py @@ -169,16 +169,18 @@ class DhcpCustomFieldInline(admin.TabularInline): extra = 0 -class SubnetAdmin(admin.ModelAdmin): +class Ip4SubnetAdmin(admin.ModelAdmin): list_display = [ "name", "network", "netmask", + "domain_name", "num_addresses", "broadcast_address", "first_address", "last_address", ] + readonly_fields = ["domain_name"] inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline] def num_addresses(self, ip4subnet): @@ -198,13 +200,45 @@ class SubnetAdmin(admin.ModelAdmin): return list(subnet.hosts())[-1] broadcast_address.short_description = "broadcast" - num_addresses.short_description = "#addresses" + num_addresses.short_description = "# addresses" first_address.short_description = "first address" last_address.short_description = "last address" def get_queryset(self, request): # 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): @@ -283,8 +317,8 @@ class OperatingSystemAdmin(admin.ModelAdmin): admin.site.register(Domain, DomainAdmin) admin.site.register(Host, HostAdmin) -admin.site.register(Ip4Subnet, SubnetAdmin) -admin.site.register(Ip6Subnet) +admin.site.register(Ip4Subnet, Ip4SubnetAdmin) +admin.site.register(Ip6Subnet, Ip6SubnetAdmin) admin.site.register(Ip6Address) admin.site.register(Nameserver) admin.site.register(MailExchange) diff --git a/mdb/migrations/0014_classless_reverse_zone_names.py b/mdb/migrations/0014_classless_reverse_zone_names.py new file mode 100644 index 0000000..b44a278 --- /dev/null +++ b/mdb/migrations/0014_classless_reverse_zone_names.py @@ -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), + ] diff --git a/mdb/models.py b/mdb/models.py index 930b1bf..452503f 100644 --- a/mdb/models.py +++ b/mdb/models.py @@ -299,6 +299,18 @@ class Ip6Subnet(models.Model): def __str__(self): 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): content = "" content += "; zone file for %s\n" % self.domain_name @@ -379,6 +391,25 @@ class Ip4Subnet(models.Model): def __str__(self): 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): content = "" content += "; zone file for %s\n" % self.domain_name diff --git a/mdb/signals.py b/mdb/signals.py index d959cb2..fc71df6 100644 --- a/mdb/signals.py +++ b/mdb/signals.py @@ -34,9 +34,7 @@ def delete_ips_for_subnet(sender, instance, **kwargs): def set_domain_name_for_subnet(sender, instance, **kwargs): # we assume that the reverse domain_name does not change if len(instance.domain_name) == 0: - ipspl = instance.network.split(".") - rev = "%s.%s.%s" % (ipspl[2], ipspl[1], ipspl[0]) - instance.domain_name = "%s.in-addr.arpa" % rev + instance.domain_name = instance.reverse_zone_name # update it's own serial # 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: return - network = ipaddress.IPv6Address("%s::" % instance.network) - instance.domain_name = ( - ".".join(network.exploded.replace(":", "")[:16])[::-1] + ".ip6.arpa" - ) + instance.domain_name = instance.reverse_zone_name @receiver(post_save, sender=Interface) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..cc62b07 --- /dev/null +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 0000000..a6f9393 --- /dev/null +++ b/tests/test_signals.py @@ -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"