Ensure trailing dot (fixes #1) and keep track of update status

This commit is contained in:
Sander Steffann 2020-04-15 13:46:10 +02:00
parent d962420de7
commit b447e6816a
8 changed files with 272 additions and 54 deletions

View file

@ -22,5 +22,10 @@
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/netbox_ddns/templates" />
</list>
</option>
</component>
</module>

View file

@ -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}, "
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}, "
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}, "
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}, "
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)

View file

@ -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',
},
),
]

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
{% if object.dnsstatus %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Dynamic DNS Status</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tbody>
<tr>
<td>Last update</td>
<td>
{{ object.dnsstatus.last_update }}
</td>
</tr>
<tr>
<td>Forward DNS</td>
<td>
{{ object.dnsstatus.get_forward_action_display }}:
{{ object.dnsstatus.get_forward_rcode_display }}
</td>
</tr>
<tr>
<td>Reverse DNS</td>
<td>
{{ object.dnsstatus.get_reverse_action_display }}:
{{ object.dnsstatus.get_reverse_rcode_display }}</td>
</tr>
</tbody>
</table>
</div>
{% endif %}