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