From b447e6816a1653affb04453c9e056ba35e988ec0 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Wed, 15 Apr 2020 13:46:10 +0200 Subject: [PATCH] Ensure trailing dot (fixes #1) and keep track of update status --- .idea/netbox_ddns.iml | 5 + netbox_ddns/background_tasks.py | 92 ++++++++++------- netbox_ddns/migrations/0003_dnsstatus.py | 31 ++++++ .../migrations/0004_ensure_trailing_dot.py | 43 ++++++++ netbox_ddns/models.py | 98 ++++++++++++++++--- netbox_ddns/signals.py | 9 +- netbox_ddns/template_content.py | 18 +++- .../netbox_ddns/ipaddress/dns_info.html | 30 ++++++ 8 files changed, 272 insertions(+), 54 deletions(-) create mode 100644 netbox_ddns/migrations/0003_dnsstatus.py create mode 100644 netbox_ddns/migrations/0004_ensure_trailing_dot.py create mode 100644 netbox_ddns/templates/netbox_ddns/ipaddress/dns_info.html diff --git a/.idea/netbox_ddns.iml b/.idea/netbox_ddns.iml index 5777f4f..3f35684 100644 --- a/.idea/netbox_ddns.iml +++ b/.idea/netbox_ddns.iml @@ -22,5 +22,10 @@ \ No newline at end of file diff --git a/netbox_ddns/background_tasks.py b/netbox_ddns/background_tasks.py index 7cd7a64..66b92db 100644 --- a/netbox_ddns/background_tasks.py +++ b/netbox_ddns/background_tasks.py @@ -6,9 +6,11 @@ import dns.rdatatype import dns.resolver from django.db.models.functions import Length from django_rq import job -from netaddr.ip import IPAddress +from dns import rcode +from netaddr import ip -from netbox_ddns.models import ReverseZone, Zone +from ipam.models import IPAddress +from netbox_ddns.models import ACTION_CREATE, ACTION_DELETE, DNSStatus, ReverseZone, Zone logger = logging.getLogger('netbox_ddns') @@ -27,10 +29,10 @@ def get_zone(dns_name: str) -> Optional[Zone]: def get_soa(dns_name: str) -> str: parts = dns_name.rstrip('.').split('.') for i in range(len(parts)): - zone_name = '.'.join(parts[i:]) + zone_name = '.'.join(parts[i:]) + '.' try: - dns.resolver.query(zone_name + '.', dns.rdatatype.SOA) + dns.resolver.query(zone_name, dns.rdatatype.SOA) return zone_name except dns.resolver.NoAnswer: # The name exists, but has no SOA. Continue one level further up @@ -40,10 +42,10 @@ def get_soa(dns_name: str) -> str: for query, response in e.responses().items(): for rrset in response.authority: if rrset.rdtype == dns.rdatatype.SOA: - return rrset.name.to_text(omit_final_dot=True) + return rrset.name.to_text() -def get_reverse_zone(address: IPAddress) -> Optional[ReverseZone]: +def get_reverse_zone(address: ip.IPAddress) -> Optional[ReverseZone]: # Find the zone, if any zones = list(ReverseZone.objects.filter(prefix__net_contains=address)) if not zones: @@ -53,24 +55,29 @@ def get_reverse_zone(address: IPAddress) -> Optional[ReverseZone]: return zones[-1] -def update_status(status: list, operation: str, response) -> None: - rcode = response.rcode() +def status_update(output: list, operation: str, response) -> None: + code = response.rcode() - if rcode == dns.rcode.NOERROR: + if code == dns.rcode.NOERROR: message = f"{operation} successful" logger.info(message) else: - message = f"{operation} failed: {dns.rcode.to_text(rcode)}" + message = f"{operation} failed: {dns.rcode.to_text(code)}" logger.error(message) - status.append(message) + output.append(message) @job -def update_dns(old_address: IPAddress = None, new_address: IPAddress = None, - old_dns_name: str = '', new_dns_name: str = '', +def update_dns(old_record: IPAddress = None, new_record: IPAddress = None, skip_forward=False, skip_reverse=False): - status = [] + old_address = old_record.address.ip if old_record else None + new_address = new_record.address.ip if new_record else None + old_dns_name = old_record.dns_name.rstrip('.') + '.' if old_record and old_record.dns_name else '' + new_dns_name = new_record.dns_name.rstrip('.') + '.' if new_record and new_record.dns_name else '' + + output = [] + status, created = DNSStatus.objects.get_or_create(ip_address=new_record or old_record) # Only delete old records when they are provided and not the same as the new records if old_dns_name and old_address and (old_dns_name != new_dns_name or old_address != new_address): @@ -79,45 +86,51 @@ def update_dns(old_address: IPAddress = None, new_address: IPAddress = None, zone = get_zone(old_dns_name) if zone: logger.debug(f"Found zone {zone.name} for {old_dns_name}") + status.forward_action = ACTION_DELETE # Check the SOA, we don't want to write to a parent zone if it has delegated authority soa = get_soa(old_dns_name) if soa == zone.name: update = zone.server.create_update(zone.name) update.delete( - old_dns_name + '.', + old_dns_name, 'a' if old_address.version == 4 else 'aaaa', str(old_address) ) response = dns.query.udp(update, zone.server.address) - update_status(status, f'Deleting {old_dns_name} {old_address}', response) + status_update(output, f'Deleting {old_dns_name} {old_address}', response) + status.forward_rcode = response.rcode() else: - logger.debug(f"Can't update zone {zone.name} for {old_dns_name}, " - f"it has delegated authority for {soa}") + logger.warning(f"Can't update zone {zone.name} for {old_dns_name}, " + f"it has delegated authority for {soa}") + status.forward_rcode = rcode.NOTAUTH else: logger.debug(f"No zone found for {old_dns_name}") # Delete old reverse record if not skip_reverse: zone = get_reverse_zone(old_address) - if zone: + if zone and old_dns_name: record_name = zone.record_name(old_address) logger.debug(f"Found zone {zone.name} for {record_name}") + status.reverse_action = ACTION_DELETE # Check the SOA, we don't want to write to a parent zone if it has delegated authority soa = get_soa(record_name) if soa == zone.name: update = zone.server.create_update(zone.name) update.delete( - record_name + '.', + record_name, 'ptr', - old_dns_name + '.' + old_dns_name ) response = dns.query.udp(update, zone.server.address) - update_status(status, f'Deleting {record_name} {old_dns_name}', response) + status_update(output, f'Deleting {record_name} {old_dns_name}', response) + status.reverse_rcode = response.rcode() else: - logger.debug(f"Can't update zone {zone.name} for {record_name}, " - f"it has delegated authority for {soa}") + logger.warning(f"Can't update zone {zone.name} for {record_name}, " + f"it has delegated authority for {soa}") + status.reverse_rcode = rcode.NOTAUTH else: logger.debug(f"No zone found for {old_address}") @@ -128,48 +141,57 @@ def update_dns(old_address: IPAddress = None, new_address: IPAddress = None, zone = get_zone(new_dns_name) if zone: logger.debug(f"Found zone {zone.name} for {new_dns_name}") + status.forward_action = ACTION_CREATE # Check the SOA, we don't want to write to a parent zone if it has delegated authority soa = get_soa(new_dns_name) if soa == zone.name: update = zone.server.create_update(zone.name) update.add( - new_dns_name + '.', + new_dns_name, zone.ttl, 'a' if new_address.version == 4 else 'aaaa', str(new_address) ) response = dns.query.udp(update, zone.server.address) - update_status(status, f'Adding {new_dns_name} {new_address}', response) + status_update(output, f'Adding {new_dns_name} {new_address}', response) + status.forward_rcode = response.rcode() else: - logger.debug(f"Can't update zone {zone.name} for {old_dns_name}, " - f"it has delegated authority for {soa}") + logger.warning(f"Can't update zone {zone.name} for {old_dns_name}, " + f"it has delegated authority for {soa}") + status.forward_rcode = rcode.NOTAUTH else: logger.debug(f"No zone found for {new_dns_name}") # Add new reverse record if not skip_reverse: zone = get_reverse_zone(new_address) - if zone: + if zone and new_dns_name: record_name = zone.record_name(new_address) logger.debug(f"Found zone {zone.name} for {record_name}") + status.reverse_action = ACTION_CREATE # Check the SOA, we don't want to write to a parent zone if it has delegated authority soa = get_soa(record_name) if soa == zone.name: update = zone.server.create_update(zone.name) update.add( - record_name + '.', + record_name, zone.ttl, 'ptr', - new_dns_name + '.' + new_dns_name ) response = dns.query.udp(update, zone.server.address) - update_status(status, f'Adding {record_name} {old_dns_name}', response) + status_update(output, f'Adding {record_name} {new_dns_name}', response) + status.reverse_rcode = response.rcode() else: - logger.debug(f"Can't update zone {zone.name} for {record_name}, " - f"it has delegated authority for {soa}") + logger.warning(f"Can't update zone {zone.name} for {record_name}, " + f"it has delegated authority for {soa}") + status.reverse_rcode = rcode.NOTAUTH else: logger.debug(f"No zone found for {new_address}") - return ', '.join(status) + # Store the status + status.save() + + return ', '.join(output) diff --git a/netbox_ddns/migrations/0003_dnsstatus.py b/netbox_ddns/migrations/0003_dnsstatus.py new file mode 100644 index 0000000..c1b8054 --- /dev/null +++ b/netbox_ddns/migrations/0003_dnsstatus.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.5 on 2020-04-15 10:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0036_standardize_description'), + ('netbox_ddns', '0002_add_ttl'), + ] + + operations = [ + migrations.CreateModel( + name='DNSStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('last_update', models.DateTimeField(auto_now=True)), + ('forward_action', models.PositiveSmallIntegerField(blank=True, null=True)), + ('forward_rcode', models.PositiveIntegerField(blank=True, null=True)), + ('reverse_action', models.PositiveSmallIntegerField(blank=True, null=True)), + ('reverse_rcode', models.PositiveIntegerField(blank=True, null=True)), + ('ip_address', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='ipam.IPAddress')), + ], + options={ + 'verbose_name': 'DNS status', + 'verbose_name_plural': 'DNS status', + }, + ), + ] diff --git a/netbox_ddns/migrations/0004_ensure_trailing_dot.py b/netbox_ddns/migrations/0004_ensure_trailing_dot.py new file mode 100644 index 0000000..0e21b4b --- /dev/null +++ b/netbox_ddns/migrations/0004_ensure_trailing_dot.py @@ -0,0 +1,43 @@ +# Generated by Django 3.0.5 on 2020-04-15 10:57 + +from django.db import migrations + + +# noinspection PyUnusedLocal +def add_trailing_dots(apps, schema_editor): + update_trailing_dots(apps, trailing_dot='.') + + +# noinspection PyUnusedLocal +def remove_trailing_dots(apps, schema_editor): + update_trailing_dots(apps, trailing_dot='') + + +def update_trailing_dots(apps, trailing_dot): + server_model = apps.get_model('netbox_ddns', 'Server') + zone_model = apps.get_model('netbox_ddns', 'Zone') + reverse_zone_model = apps.get_model('netbox_ddns', 'ReverseZone') + + for server in server_model.objects.all(): + server.tsig_key_name = server.tsig_key_name.rstrip('.') + trailing_dot + server.save() + + for zone in zone_model.objects.all(): + zone.name = zone.name.rstrip('.') + trailing_dot + zone.save() + + for reverse_zone in reverse_zone_model.objects.all(): + reverse_zone.name = reverse_zone.name.rstrip('.') + trailing_dot + reverse_zone.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('netbox_ddns', '0003_dnsstatus'), + ] + + operations = [ + migrations.RunPython( + code=add_trailing_dots, + reverse_code=remove_trailing_dots), + ] diff --git a/netbox_ddns/models.py b/netbox_ddns/models.py index d947f20..072bff5 100644 --- a/netbox_ddns/models.py +++ b/netbox_ddns/models.py @@ -7,10 +7,12 @@ import dns.update from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ +from dns import rcode from dns.tsig import HMAC_MD5, HMAC_SHA1, HMAC_SHA224, HMAC_SHA256, HMAC_SHA384, HMAC_SHA512 -from netaddr.ip import IPAddress +from netaddr import ip from ipam.fields import IPNetworkField +from ipam.models import IPAddress from .validators import HostnameAddressValidator, HostnameValidator, validate_base64 logger = logging.getLogger('netbox_ddns') @@ -24,6 +26,33 @@ TSIG_ALGORITHM_CHOICES = ( (str(HMAC_SHA512), 'HMAC SHA512'), ) +ACTION_CREATE = 1 +ACTION_DELETE = 2 + +ACTION_CHOICES = ( + (ACTION_CREATE, 'Create'), + (ACTION_DELETE, 'Delete'), +) + + +def get_rcode_display(code): + if code is None: + return None + elif code == rcode.NOERROR: + return _('Success') + elif code == rcode.SERVFAIL: + return _('Server failure') + elif code == rcode.NXDOMAIN: + return _('Name does not exist') + elif code == rcode.NOTIMP: + return _('Not implemented') + elif code == rcode.REFUSED: + return _('Refused') + elif code == rcode.NOTAUTH: + return _('Server not authoritative') + else: + return _('Unknown response: {}').format(code) + class Server(models.Model): server = models.CharField( @@ -60,9 +89,11 @@ class Server(models.Model): return f'{self.server} ({self.tsig_key_name})' def clean(self): - # Remove trailing dots from domain-style fields - self.server = self.server.rstrip('.').lower() - self.tsig_key_name = self.tsig_key_name.rstrip('.').lower() + # Remove trailing dots from the server name/address + self.server = self.server.lower().rstrip('.') + + # Ensure trailing dots from domain-style fields + self.tsig_key_name = self.tsig_key_name.lower().rstrip('.') + '.' @property def address(self) -> Optional[str]: @@ -107,8 +138,8 @@ class Zone(models.Model): return self.name def clean(self): - # Remove trailing dots from domain-style fields - self.name = self.name.rstrip('.').lower() + # Ensure trailing dots from domain-style fields + self.name = self.name.lower().rstrip('.') + '.' def get_updater(self): return self.server.create_update(self.name) @@ -142,7 +173,7 @@ class ReverseZone(models.Model): def __str__(self): return f'for {self.prefix}' - def record_name(self, address: IPAddress): + def record_name(self, address: ip.IPAddress): record_name = self.name if self.prefix.version == 4: for pos, octet in enumerate(address.words): @@ -161,9 +192,6 @@ class ReverseZone(models.Model): return record_name def clean(self): - # Remove trailing dots from domain-style fields - self.name = self.name.rstrip('.') - if self.prefix.version == 4: if self.prefix.prefixlen not in [0, 8, 16, 24] and not self.name: raise ValidationError({ @@ -192,5 +220,51 @@ class ReverseZone(models.Model): self.name = f'{nibble}.{self.name}' - # Store zone names in lowercase - self.name = self.name.lower() + # Ensure trailing dots from domain-style fields + self.name = self.name.lower().rstrip('.') + '.' + + +class DNSStatus(models.Model): + ip_address = models.OneToOneField( + to=IPAddress, + verbose_name=_('IP address'), + on_delete=models.CASCADE, + ) + last_update = models.DateTimeField( + verbose_name=_('last update'), + auto_now=True, + ) + + forward_action = models.PositiveSmallIntegerField( + verbose_name=_('forward record action'), + choices=ACTION_CHOICES, + blank=True, + null=True, + ) + forward_rcode = models.PositiveIntegerField( + verbose_name=_('forward record response'), + blank=True, + null=True, + ) + + reverse_action = models.PositiveSmallIntegerField( + verbose_name=_('reverse record action'), + choices=ACTION_CHOICES, + blank=True, + null=True, + ) + reverse_rcode = models.PositiveIntegerField( + verbose_name=_('reverse record response'), + blank=True, + null=True, + ) + + class Meta: + verbose_name = _('DNS status') + verbose_name_plural = _('DNS status') + + def get_forward_rcode_display(self) -> Optional[str]: + return get_rcode_display(self.forward_rcode) + + def get_reverse_rcode_display(self) -> Optional[str]: + return get_rcode_display(self.reverse_rcode) diff --git a/netbox_ddns/signals.py b/netbox_ddns/signals.py index 77ee622..244cb11 100644 --- a/netbox_ddns/signals.py +++ b/netbox_ddns/signals.py @@ -22,16 +22,13 @@ def trigger_ddns_update(instance: IPAddress, **_kwargs): if instance.address != old_address or instance.dns_name != old_dns_name: # IP address or DNS name has changed update_dns.delay( - old_address=old_address.ip if old_address else None, - new_address=instance.address.ip, - old_dns_name=old_dns_name.rstrip('.'), - new_dns_name=instance.dns_name.rstrip('.'), + old_record=instance.before_save, + new_record=instance, ) @receiver(post_delete, sender=IPAddress) def trigger_ddns_delete(instance: IPAddress, **_kwargs): update_dns.delay( - old_address=instance.address.ip, - old_dns_name=instance.dns_name.rstrip('.'), + old_record=instance, ) diff --git a/netbox_ddns/template_content.py b/netbox_ddns/template_content.py index 75e3a92..190d806 100644 --- a/netbox_ddns/template_content.py +++ b/netbox_ddns/template_content.py @@ -1 +1,17 @@ -template_extensions = [] +from django.contrib.auth.context_processors import PermWrapper + +from extras.plugins import PluginTemplateExtension + + +# noinspection PyAbstractClass +class DNSInfo(PluginTemplateExtension): + model = 'ipam.ipaddress' + + def left_page(self): + """ + An info-box with edit button for the vCenter settings + """ + return self.render('netbox_ddns/ipaddress/dns_info.html') + + +template_extensions = [DNSInfo] diff --git a/netbox_ddns/templates/netbox_ddns/ipaddress/dns_info.html b/netbox_ddns/templates/netbox_ddns/ipaddress/dns_info.html new file mode 100644 index 0000000..3b8c101 --- /dev/null +++ b/netbox_ddns/templates/netbox_ddns/ipaddress/dns_info.html @@ -0,0 +1,30 @@ +{% if object.dnsstatus %} +
+
+ Dynamic DNS Status +
+ + + + + + + + + + + + + + + +
Last update + {{ object.dnsstatus.last_update }} +
Forward DNS + {{ object.dnsstatus.get_forward_action_display }}: + {{ object.dnsstatus.get_forward_rcode_display }} +
Reverse DNS + {{ object.dnsstatus.get_reverse_action_display }}: + {{ object.dnsstatus.get_reverse_rcode_display }}
+
+{% endif %}