Ensure trailing dot (fixes #1) and keep track of update status
This commit is contained in:
parent
d962420de7
commit
b447e6816a
|
@ -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>
|
|
@ -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)
|
||||
|
|
31
netbox_ddns/migrations/0003_dnsstatus.py
Normal file
31
netbox_ddns/migrations/0003_dnsstatus.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
43
netbox_ddns/migrations/0004_ensure_trailing_dot.py
Normal file
43
netbox_ddns/migrations/0004_ensure_trailing_dot.py
Normal 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),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
|
|
30
netbox_ddns/templates/netbox_ddns/ipaddress/dns_info.html
Normal file
30
netbox_ddns/templates/netbox_ddns/ipaddress/dns_info.html
Normal 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 %}
|
Loading…
Reference in a new issue