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>
<component name="TemplatesService"> <component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" /> <option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/netbox_ddns/templates" />
</list>
</option>
</component> </component>
</module> </module>

View File

@ -6,9 +6,11 @@ import dns.rdatatype
import dns.resolver import dns.resolver
from django.db.models.functions import Length from django.db.models.functions import Length
from django_rq import job 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') logger = logging.getLogger('netbox_ddns')
@ -27,10 +29,10 @@ def get_zone(dns_name: str) -> Optional[Zone]:
def get_soa(dns_name: str) -> str: def get_soa(dns_name: str) -> str:
parts = dns_name.rstrip('.').split('.') parts = dns_name.rstrip('.').split('.')
for i in range(len(parts)): for i in range(len(parts)):
zone_name = '.'.join(parts[i:]) zone_name = '.'.join(parts[i:]) + '.'
try: try:
dns.resolver.query(zone_name + '.', dns.rdatatype.SOA) dns.resolver.query(zone_name, dns.rdatatype.SOA)
return zone_name return zone_name
except dns.resolver.NoAnswer: except dns.resolver.NoAnswer:
# The name exists, but has no SOA. Continue one level further up # 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 query, response in e.responses().items():
for rrset in response.authority: for rrset in response.authority:
if rrset.rdtype == dns.rdatatype.SOA: 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 # Find the zone, if any
zones = list(ReverseZone.objects.filter(prefix__net_contains=address)) zones = list(ReverseZone.objects.filter(prefix__net_contains=address))
if not zones: if not zones:
@ -53,24 +55,29 @@ def get_reverse_zone(address: IPAddress) -> Optional[ReverseZone]:
return zones[-1] return zones[-1]
def update_status(status: list, operation: str, response) -> None: def status_update(output: list, operation: str, response) -> None:
rcode = response.rcode() code = response.rcode()
if rcode == dns.rcode.NOERROR: if code == dns.rcode.NOERROR:
message = f"{operation} successful" message = f"{operation} successful"
logger.info(message) logger.info(message)
else: else:
message = f"{operation} failed: {dns.rcode.to_text(rcode)}" message = f"{operation} failed: {dns.rcode.to_text(code)}"
logger.error(message) logger.error(message)
status.append(message) output.append(message)
@job @job
def update_dns(old_address: IPAddress = None, new_address: IPAddress = None, def update_dns(old_record: IPAddress = None, new_record: IPAddress = None,
old_dns_name: str = '', new_dns_name: str = '',
skip_forward=False, skip_reverse=False): 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 # 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): 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) zone = get_zone(old_dns_name)
if zone: if zone:
logger.debug(f"Found zone {zone.name} for {old_dns_name}") 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 # Check the SOA, we don't want to write to a parent zone if it has delegated authority
soa = get_soa(old_dns_name) soa = get_soa(old_dns_name)
if soa == zone.name: if soa == zone.name:
update = zone.server.create_update(zone.name) update = zone.server.create_update(zone.name)
update.delete( update.delete(
old_dns_name + '.', old_dns_name,
'a' if old_address.version == 4 else 'aaaa', 'a' if old_address.version == 4 else 'aaaa',
str(old_address) str(old_address)
) )
response = dns.query.udp(update, zone.server.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: 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}") f"it has delegated authority for {soa}")
status.forward_rcode = rcode.NOTAUTH
else: else:
logger.debug(f"No zone found for {old_dns_name}") logger.debug(f"No zone found for {old_dns_name}")
# Delete old reverse record # Delete old reverse record
if not skip_reverse: if not skip_reverse:
zone = get_reverse_zone(old_address) zone = get_reverse_zone(old_address)
if zone: if zone and old_dns_name:
record_name = zone.record_name(old_address) record_name = zone.record_name(old_address)
logger.debug(f"Found zone {zone.name} for {record_name}") 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 # Check the SOA, we don't want to write to a parent zone if it has delegated authority
soa = get_soa(record_name) soa = get_soa(record_name)
if soa == zone.name: if soa == zone.name:
update = zone.server.create_update(zone.name) update = zone.server.create_update(zone.name)
update.delete( update.delete(
record_name + '.', record_name,
'ptr', 'ptr',
old_dns_name + '.' old_dns_name
) )
response = dns.query.udp(update, zone.server.address) 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: 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}") f"it has delegated authority for {soa}")
status.reverse_rcode = rcode.NOTAUTH
else: else:
logger.debug(f"No zone found for {old_address}") 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) zone = get_zone(new_dns_name)
if zone: if zone:
logger.debug(f"Found zone {zone.name} for {new_dns_name}") 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 # Check the SOA, we don't want to write to a parent zone if it has delegated authority
soa = get_soa(new_dns_name) soa = get_soa(new_dns_name)
if soa == zone.name: if soa == zone.name:
update = zone.server.create_update(zone.name) update = zone.server.create_update(zone.name)
update.add( update.add(
new_dns_name + '.', new_dns_name,
zone.ttl, zone.ttl,
'a' if new_address.version == 4 else 'aaaa', 'a' if new_address.version == 4 else 'aaaa',
str(new_address) str(new_address)
) )
response = dns.query.udp(update, zone.server.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: 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}") f"it has delegated authority for {soa}")
status.forward_rcode = rcode.NOTAUTH
else: else:
logger.debug(f"No zone found for {new_dns_name}") logger.debug(f"No zone found for {new_dns_name}")
# Add new reverse record # Add new reverse record
if not skip_reverse: if not skip_reverse:
zone = get_reverse_zone(new_address) zone = get_reverse_zone(new_address)
if zone: if zone and new_dns_name:
record_name = zone.record_name(new_address) record_name = zone.record_name(new_address)
logger.debug(f"Found zone {zone.name} for {record_name}") 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 # Check the SOA, we don't want to write to a parent zone if it has delegated authority
soa = get_soa(record_name) soa = get_soa(record_name)
if soa == zone.name: if soa == zone.name:
update = zone.server.create_update(zone.name) update = zone.server.create_update(zone.name)
update.add( update.add(
record_name + '.', record_name,
zone.ttl, zone.ttl,
'ptr', 'ptr',
new_dns_name + '.' new_dns_name
) )
response = dns.query.udp(update, zone.server.address) 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: 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}") f"it has delegated authority for {soa}")
status.reverse_rcode = rcode.NOTAUTH
else: else:
logger.debug(f"No zone found for {new_address}") 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.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ 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 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.fields import IPNetworkField
from ipam.models import IPAddress
from .validators import HostnameAddressValidator, HostnameValidator, validate_base64 from .validators import HostnameAddressValidator, HostnameValidator, validate_base64
logger = logging.getLogger('netbox_ddns') logger = logging.getLogger('netbox_ddns')
@ -24,6 +26,33 @@ TSIG_ALGORITHM_CHOICES = (
(str(HMAC_SHA512), 'HMAC SHA512'), (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): class Server(models.Model):
server = models.CharField( server = models.CharField(
@ -60,9 +89,11 @@ class Server(models.Model):
return f'{self.server} ({self.tsig_key_name})' return f'{self.server} ({self.tsig_key_name})'
def clean(self): def clean(self):
# Remove trailing dots from domain-style fields # Remove trailing dots from the server name/address
self.server = self.server.rstrip('.').lower() self.server = self.server.lower().rstrip('.')
self.tsig_key_name = self.tsig_key_name.rstrip('.').lower()
# Ensure trailing dots from domain-style fields
self.tsig_key_name = self.tsig_key_name.lower().rstrip('.') + '.'
@property @property
def address(self) -> Optional[str]: def address(self) -> Optional[str]:
@ -107,8 +138,8 @@ class Zone(models.Model):
return self.name return self.name
def clean(self): def clean(self):
# Remove trailing dots from domain-style fields # Ensure trailing dots from domain-style fields
self.name = self.name.rstrip('.').lower() self.name = self.name.lower().rstrip('.') + '.'
def get_updater(self): def get_updater(self):
return self.server.create_update(self.name) return self.server.create_update(self.name)
@ -142,7 +173,7 @@ class ReverseZone(models.Model):
def __str__(self): def __str__(self):
return f'for {self.prefix}' return f'for {self.prefix}'
def record_name(self, address: IPAddress): def record_name(self, address: ip.IPAddress):
record_name = self.name record_name = self.name
if self.prefix.version == 4: if self.prefix.version == 4:
for pos, octet in enumerate(address.words): for pos, octet in enumerate(address.words):
@ -161,9 +192,6 @@ class ReverseZone(models.Model):
return record_name return record_name
def clean(self): def clean(self):
# Remove trailing dots from domain-style fields
self.name = self.name.rstrip('.')
if self.prefix.version == 4: if self.prefix.version == 4:
if self.prefix.prefixlen not in [0, 8, 16, 24] and not self.name: if self.prefix.prefixlen not in [0, 8, 16, 24] and not self.name:
raise ValidationError({ raise ValidationError({
@ -192,5 +220,51 @@ class ReverseZone(models.Model):
self.name = f'{nibble}.{self.name}' self.name = f'{nibble}.{self.name}'
# Store zone names in lowercase # Ensure trailing dots from domain-style fields
self.name = self.name.lower() 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: if instance.address != old_address or instance.dns_name != old_dns_name:
# IP address or DNS name has changed # IP address or DNS name has changed
update_dns.delay( update_dns.delay(
old_address=old_address.ip if old_address else None, old_record=instance.before_save,
new_address=instance.address.ip, new_record=instance,
old_dns_name=old_dns_name.rstrip('.'),
new_dns_name=instance.dns_name.rstrip('.'),
) )
@receiver(post_delete, sender=IPAddress) @receiver(post_delete, sender=IPAddress)
def trigger_ddns_delete(instance: IPAddress, **_kwargs): def trigger_ddns_delete(instance: IPAddress, **_kwargs):
update_dns.delay( update_dns.delay(
old_address=instance.address.ip, old_record=instance,
old_dns_name=instance.dns_name.rstrip('.'),
) )

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 %}