Add RFC 2317 classless reverse zone names for sub-/24 subnets
This commit is contained in:
6 files changed
+215
-12
No files matched your search
+39
-5
@@ -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),
|
||||||
|
]
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in new issue
Block a user