Major restructure and add support for extra DNS names

This commit is contained in:
Sander Steffann 2020-04-19 21:28:06 +02:00
parent 89e31f1f9f
commit 0ff9d62ba6
16 changed files with 628 additions and 170 deletions

View file

@ -23,10 +23,5 @@
</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

@ -10,8 +10,10 @@ from django.utils.translation import gettext_lazy as _
from ipam.models import IPAddress from ipam.models import IPAddress
from netbox.admin import admin_site from netbox.admin import admin_site
from .background_tasks import update_dns from netbox_ddns.models import DNSStatus, ExtraDNSName
from .background_tasks import dns_create
from .models import ReverseZone, Server, Zone from .models import ReverseZone, Server, Zone
from .utils import normalize_fqdn
logger = logging.getLogger('netbox_ddns') logger = logging.getLogger('netbox_ddns')
@ -65,20 +67,46 @@ class ZoneAdmin(admin.ModelAdmin):
more_specifics = Zone.objects.filter(name__endswith=zone.name).exclude(pk=zone.pk) more_specifics = Zone.objects.filter(name__endswith=zone.name).exclude(pk=zone.pk)
# Find all IPAddress objects in this zone but not in the more-specifics # Find all IPAddress objects in this zone but not in the more-specifics
addresses = IPAddress.objects.filter(Q(dns_name__endswith=zone.name) | ip_addresses = IPAddress.objects.filter(Q(dns_name__endswith=zone.name) |
Q(dns_name__endswith=zone.name.rstrip('.'))) Q(dns_name__endswith=zone.name.rstrip('.')))
for more_specific in more_specifics: for more_specific in more_specifics:
addresses = addresses.exclude(Q(dns_name__endswith=more_specific.name) | ip_addresses = ip_addresses.exclude(Q(dns_name__endswith=more_specific.name) |
Q(dns_name__endswith=more_specific.name.rstrip('.'))) Q(dns_name__endswith=more_specific.name.rstrip('.')))
for address in addresses: for ip_address in ip_addresses:
if address.dns_name: new_address = ip_address.address.ip
update_dns.delay( new_dns_name = normalize_fqdn(ip_address.dns_name)
new_record=address,
skip_reverse=True if new_dns_name:
status, created = DNSStatus.objects.get_or_create(ip_address=ip_address)
dns_create.delay(
dns_name=new_dns_name,
address=new_address,
status=status,
reverse=False,
) )
counter += 1 counter += 1
# Find all ExtraDNSName objects in this zone but not in the more-specifics
extra_names = ExtraDNSName.objects.filter(name__endswith=zone.name)
for more_specific in more_specifics:
extra_names = extra_names.exclude(name__endswith=more_specific.name)
for extra in extra_names:
new_address = extra.ip_address.address.ip
new_dns_name = extra.name
dns_create.delay(
dns_name=new_dns_name,
address=new_address,
status=extra,
reverse=False,
)
counter += 1
messages.info(request, _("Updating {count} forward records in {name}").format(count=counter, messages.info(request, _("Updating {count} forward records in {name}").format(count=counter,
name=zone.name)) name=zone.name))
@ -99,17 +127,30 @@ class ReverseZoneAdmin(admin.ModelAdmin):
more_specifics = ReverseZone.objects.filter(prefix__net_contained=zone.prefix).exclude(pk=zone.pk) more_specifics = ReverseZone.objects.filter(prefix__net_contained=zone.prefix).exclude(pk=zone.pk)
# Find all IPAddress objects in this zone but not in the more-specifics # Find all IPAddress objects in this zone but not in the more-specifics
addresses = IPAddress.objects.filter(address__net_contained=zone.prefix) ip_addresses = IPAddress.objects.filter(address__net_contained=zone.prefix)
for more_specific in more_specifics: for more_specific in more_specifics:
addresses = addresses.exclude(address__net_contained=more_specific.prefix) ip_addresses = ip_addresses.exclude(address__net_contained=more_specific.prefix)
for address in addresses: for ip_address in ip_addresses:
if address.dns_name: new_address = ip_address.address.ip
update_dns.delay( new_dns_name = normalize_fqdn(ip_address.dns_name)
new_record=address,
skip_forward=True if new_dns_name:
status, created = DNSStatus.objects.get_or_create(ip_address=ip_address)
dns_create.delay(
dns_name=new_dns_name,
address=new_address,
status=status,
forward=False,
) )
counter += 1 counter += 1
messages.info(request, _("Updating {count} reverse records in {name}").format(count=counter, messages.info(request, _("Updating {count} reverse records in {name}").format(count=counter,
name=zone.name)) name=zone.name))
@admin.register(ExtraDNSName, site=admin_site)
class ExtraDNSNameAdmin(admin.ModelAdmin):
pass

View file

@ -1,16 +1,17 @@
import logging import logging
from typing import Optional from typing import List, Optional
import dns.query import dns.query
import dns.rdatatype import dns.rdatatype
import dns.resolver import dns.resolver
from django.db import IntegrityError
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 dns import rcode from dns import rcode
from netaddr import ip from netaddr import ip
from ipam.models import IPAddress
from netbox_ddns.models import ACTION_CREATE, ACTION_DELETE, DNSStatus, ReverseZone, Zone from netbox_ddns.models import ACTION_CREATE, ACTION_DELETE, DNSStatus, ReverseZone, Zone
from netbox_ddns.utils import normalize_fqdn
logger = logging.getLogger('netbox_ddns') logger = logging.getLogger('netbox_ddns')
@ -29,7 +30,7 @@ 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 = normalize_fqdn('.'.join(parts[i:]))
try: try:
dns.resolver.query(zone_name, dns.rdatatype.SOA) dns.resolver.query(zone_name, dns.rdatatype.SOA)
@ -55,7 +56,7 @@ def get_reverse_zone(address: ip.IPAddress) -> Optional[ReverseZone]:
return zones[-1] return zones[-1]
def status_update(output: list, operation: str, response) -> None: def status_update(output: List[str], operation: str, response) -> None:
code = response.rcode() code = response.rcode()
if code == dns.rcode.NOERROR: if code == dns.rcode.NOERROR:
@ -68,130 +69,161 @@ def status_update(output: list, operation: str, response) -> None:
output.append(message) output.append(message)
def create_forward(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]):
zone = get_zone(dns_name)
if zone:
logger.debug(f"Found zone {zone.name} for {dns_name}")
if status:
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(dns_name)
if soa == zone.name:
record_type = 'A' if address.version == 4 else 'AAAA'
update = zone.server.create_update(zone.name)
update.add(
dns_name,
zone.ttl,
record_type,
str(address)
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Adding {dns_name} {record_type} {address}', response)
if status:
status.forward_rcode = response.rcode()
else:
logger.warning(f"Can't update zone {zone.name} for {dns_name}, "
f"it has delegated authority for {soa}")
if status:
status.forward_rcode = rcode.NOTAUTH
else:
logger.debug(f"No zone found for {dns_name}")
def delete_forward(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]):
zone = get_zone(dns_name)
if zone:
logger.debug(f"Found zone {zone.name} for {dns_name}")
if status:
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(dns_name)
if soa == zone.name:
record_type = 'A' if address.version == 4 else 'AAAA'
update = zone.server.create_update(zone.name)
update.delete(
dns_name,
record_type,
str(address)
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Deleting {dns_name} {record_type} {address}', response)
if status:
status.forward_rcode = response.rcode()
else:
logger.warning(f"Can't update zone {zone.name} {dns_name}, "
f"it has delegated authority for {soa}")
if status:
status.forward_rcode = rcode.NOTAUTH
else:
logger.debug(f"No zone found for {dns_name}")
def create_reverse(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]):
zone = get_reverse_zone(address)
if zone and dns_name:
record_name = zone.record_name(address)
logger.debug(f"Found zone {zone.name} for {record_name}")
if status:
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,
zone.ttl,
'ptr',
dns_name
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Adding {record_name} PTR {dns_name}', response)
if status:
status.reverse_rcode = response.rcode()
else:
logger.warning(f"Can't update zone {zone.name} for {record_name}, "
f"it has delegated authority for {soa}")
if status:
status.reverse_rcode = rcode.NOTAUTH
else:
logger.debug(f"No zone found for {address}")
def delete_reverse(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]):
zone = get_reverse_zone(address)
if zone and dns_name:
record_name = zone.record_name(address)
logger.debug(f"Found zone {zone.name} for {record_name}")
if status:
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,
'ptr',
dns_name
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Deleting {record_name} PTR {dns_name}', response)
if status:
status.reverse_rcode = response.rcode()
else:
logger.warning(f"Can't update zone {zone.name} for {record_name}, "
f"it has delegated authority for {soa}")
if status:
status.reverse_rcode = rcode.NOTAUTH
else:
logger.debug(f"No zone found for {address}")
@job @job
def update_dns(old_record: IPAddress = None, new_record: IPAddress = None, def dns_create(dns_name: str, address: ip.IPAddress, forward=True, reverse=True, status: DNSStatus = None):
skip_forward=False, skip_reverse=False):
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 = [] 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 forward:
if old_dns_name and old_address and (old_dns_name != new_dns_name or old_address != new_address): create_forward(dns_name, address, status, output)
# Delete old forward record if reverse:
if not skip_forward: create_reverse(dns_name, address, status, output)
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 if status:
soa = get_soa(old_dns_name) try:
if soa == zone.name: status.save()
update = zone.server.create_update(zone.name) except IntegrityError:
update.delete( # Race condition when creating?
old_dns_name, status.save(force_update=True)
'a' if old_address.version == 4 else 'aaaa',
str(old_address) return ', '.join(output)
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Deleting {old_dns_name} {old_address}', response) @job
status.forward_rcode = response.rcode() def dns_delete(dns_name: str, address: ip.IPAddress, forward=True, reverse=True, status: DNSStatus = None):
else: output = []
logger.warning(f"Can't update zone {zone.name} for {old_dns_name}, "
f"it has delegated authority for {soa}") if forward:
status.forward_rcode = rcode.NOTAUTH delete_forward(dns_name, address, status, output)
else: if reverse:
logger.debug(f"No zone found for {old_dns_name}") delete_reverse(dns_name, address, status, output)
# Delete old reverse record if status:
if not skip_reverse: try:
zone = get_reverse_zone(old_address) status.save()
if zone and old_dns_name: except IntegrityError:
record_name = zone.record_name(old_address) # Race condition when creating?
logger.debug(f"Found zone {zone.name} for {record_name}") status.save(force_update=True)
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,
'ptr',
old_dns_name
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Deleting {record_name} {old_dns_name}', response)
status.reverse_rcode = response.rcode()
else:
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}")
# Always try to add, just in case a previous update failed
if new_dns_name and new_address:
# Add new forward record
if not skip_forward:
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,
zone.ttl,
'a' if new_address.version == 4 else 'aaaa',
str(new_address)
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Adding {new_dns_name} {new_address}', response)
status.forward_rcode = response.rcode()
else:
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 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,
zone.ttl,
'ptr',
new_dns_name
)
response = dns.query.udp(update, zone.server.address)
status_update(output, f'Adding {record_name} {new_dns_name}', response)
status.reverse_rcode = response.rcode()
else:
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}")
# Store the status
status.save()
return ', '.join(output) return ', '.join(output)

10
netbox_ddns/forms.py Normal file
View file

@ -0,0 +1,10 @@
from django import forms
from netbox_ddns.models import ExtraDNSName
from utilities.forms import BootstrapMixin
class ExtraDNSNameEditForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ExtraDNSName
fields = ['name']

View file

@ -0,0 +1,34 @@
# Generated by Django 3.0.5 on 2020-04-18 22:20
from django.db import migrations, models
import django.db.models.deletion
import netbox_ddns.validators
class Migration(migrations.Migration):
dependencies = [
('ipam', '0036_standardize_description'),
('netbox_ddns', '0004_ensure_trailing_dot'),
]
operations = [
migrations.CreateModel(
name='ExtraDNSName',
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)),
('name', models.CharField(max_length=255, validators=[netbox_ddns.validators.HostnameValidator()])),
('ip_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.IPAddress')),
],
options={
'verbose_name': 'extra DNS name',
'verbose_name_plural': 'extra DNS names',
'unique_together': {('ip_address', 'name')},
},
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 3.0.5 on 2020-04-19 16:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('netbox_ddns', '0005_extradnsname'),
]
operations = [
migrations.RemoveField(
model_name='extradnsname',
name='reverse_action',
),
migrations.RemoveField(
model_name='extradnsname',
name='reverse_rcode',
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.5 on 2020-04-19 19:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('netbox_ddns', '0006_extradns_cname'),
]
operations = [
migrations.AlterModelOptions(
name='zone',
options={'ordering': ('name',), 'verbose_name': 'forward zone', 'verbose_name_plural': 'forward zones'},
),
]

View file

@ -6,6 +6,7 @@ import dns.tsigkeyring
import dns.update 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.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dns import rcode 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
@ -13,6 +14,7 @@ from netaddr import ip
from ipam.fields import IPNetworkField from ipam.fields import IPNetworkField
from ipam.models import IPAddress from ipam.models import IPAddress
from .utils import normalize_fqdn
from .validators import HostnameAddressValidator, HostnameValidator, validate_base64 from .validators import HostnameAddressValidator, HostnameValidator, validate_base64
logger = logging.getLogger('netbox_ddns') logger = logging.getLogger('netbox_ddns')
@ -93,7 +95,7 @@ class Server(models.Model):
self.server = self.server.lower().rstrip('.') self.server = self.server.lower().rstrip('.')
# Ensure trailing dots from domain-style fields # Ensure trailing dots from domain-style fields
self.tsig_key_name = self.tsig_key_name.lower().rstrip('.') + '.' self.tsig_key_name = normalize_fqdn(self.tsig_key_name.lower().rstrip('.'))
@property @property
def address(self) -> Optional[str]: def address(self) -> Optional[str]:
@ -104,7 +106,7 @@ class Server(models.Model):
def create_update(self, zone: str) -> dns.update.Update: def create_update(self, zone: str) -> dns.update.Update:
return dns.update.Update( return dns.update.Update(
zone=zone.rstrip('.') + '.', zone=normalize_fqdn(zone),
keyring=dns.tsigkeyring.from_text({ keyring=dns.tsigkeyring.from_text({
self.tsig_key_name: self.tsig_key self.tsig_key_name: self.tsig_key
}), }),
@ -131,15 +133,15 @@ class Zone(models.Model):
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
verbose_name = _('zone') verbose_name = _('forward zone')
verbose_name_plural = _('zones') verbose_name_plural = _('forward zones')
def __str__(self): def __str__(self):
return self.name return self.name
def clean(self): def clean(self):
# Ensure trailing dots from domain-style fields # Ensure trailing dots from domain-style fields
self.name = self.name.lower().rstrip('.') + '.' self.name = normalize_fqdn(self.name)
def get_updater(self): def get_updater(self):
return self.server.create_update(self.name) return self.server.create_update(self.name)
@ -221,7 +223,7 @@ class ReverseZone(models.Model):
self.name = f'{nibble}.{self.name}' self.name = f'{nibble}.{self.name}'
# Ensure trailing dots from domain-style fields # Ensure trailing dots from domain-style fields
self.name = self.name.lower().rstrip('.') + '.' self.name = normalize_fqdn(self.name)
class DNSStatus(models.Model): class DNSStatus(models.Model):
@ -230,6 +232,7 @@ class DNSStatus(models.Model):
verbose_name=_('IP address'), verbose_name=_('IP address'),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
last_update = models.DateTimeField( last_update = models.DateTimeField(
verbose_name=_('last update'), verbose_name=_('last update'),
auto_now=True, auto_now=True,
@ -266,5 +269,69 @@ class DNSStatus(models.Model):
def get_forward_rcode_display(self) -> Optional[str]: def get_forward_rcode_display(self) -> Optional[str]:
return get_rcode_display(self.forward_rcode) return get_rcode_display(self.forward_rcode)
def get_forward_rcode_html_display(self) -> Optional[str]:
output = get_rcode_display(self.forward_rcode)
colour = 'green' if self.forward_rcode == rcode.NOERROR else 'red'
return format_html('<span style="color:{colour}">{output}</span', colour=colour, output=output)
def get_reverse_rcode_display(self) -> Optional[str]: def get_reverse_rcode_display(self) -> Optional[str]:
return get_rcode_display(self.reverse_rcode) return get_rcode_display(self.reverse_rcode)
def get_reverse_rcode_html_display(self) -> Optional[str]:
output = get_rcode_display(self.reverse_rcode)
colour = 'green' if self.reverse_rcode == rcode.NOERROR else 'red'
return format_html('<span style="color:{colour}">{output}</span', colour=colour, output=output)
class ExtraDNSName(models.Model):
ip_address = models.ForeignKey(
to=IPAddress,
verbose_name=_('IP address'),
on_delete=models.CASCADE,
)
name = models.CharField(
verbose_name=_('DNS name'),
max_length=255,
validators=[HostnameValidator()],
)
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,
)
before_save = None
class Meta:
unique_together = (
('ip_address', 'name'),
)
verbose_name = _('extra DNS name')
verbose_name_plural = _('extra DNS names')
def __str__(self):
return self.name
def clean(self):
# Ensure trailing dots from domain-style fields
self.name = normalize_fqdn(self.name)
def get_forward_rcode_display(self) -> Optional[str]:
return get_rcode_display(self.forward_rcode)
def get_forward_rcode_html_display(self) -> Optional[str]:
output = get_rcode_display(self.forward_rcode)
colour = 'green' if self.forward_rcode == rcode.NOERROR else 'red'
return format_html('<span style="color:{colour}">{output}</span', colour=colour, output=output)

View file

@ -4,7 +4,9 @@ from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from ipam.models import IPAddress from ipam.models import IPAddress
from netbox_ddns.background_tasks import update_dns from netbox_ddns.background_tasks import dns_create, dns_delete
from netbox_ddns.models import DNSStatus, ExtraDNSName
from netbox_ddns.utils import normalize_fqdn
logger = logging.getLogger('netbox_ddns') logger = logging.getLogger('netbox_ddns')
@ -16,19 +18,114 @@ def store_original_ipaddress(instance: IPAddress, **_kwargs):
@receiver(post_save, sender=IPAddress) @receiver(post_save, sender=IPAddress)
def trigger_ddns_update(instance: IPAddress, **_kwargs): def trigger_ddns_update(instance: IPAddress, **_kwargs):
old_address = instance.before_save.address if instance.before_save else None old_address = instance.before_save.address.ip if instance.before_save else None
old_dns_name = instance.before_save.dns_name if instance.before_save else '' old_dns_name = normalize_fqdn(instance.before_save.dns_name) if instance.before_save else ''
if instance.address != old_address or instance.dns_name != old_dns_name: new_address = instance.address.ip
# IP address or DNS name has changed new_dns_name = normalize_fqdn(instance.dns_name)
update_dns.delay(
old_record=instance.before_save, extra_dns_names = {normalize_fqdn(extra.name): extra for extra in instance.extradnsname_set.all()}
new_record=instance,
) if new_address != old_address or new_dns_name != old_dns_name:
status, created = DNSStatus.objects.get_or_create(ip_address=instance)
if old_address and old_dns_name and old_dns_name not in extra_dns_names:
delete = dns_delete.delay(
dns_name=old_dns_name,
address=old_address,
status=status,
)
else:
delete = None
if new_address and new_dns_name:
dns_create.delay(
dns_name=new_dns_name,
address=new_address,
status=status,
depends_on=delete,
)
if old_address != new_address:
# This affects extra names
for dns_name, status in extra_dns_names.items():
# Don't touch the main dns_name
if dns_name == old_dns_name or dns_name == new_dns_name:
continue
if old_dns_name:
delete = dns_delete.delay(
dns_name=dns_name,
address=old_address,
reverse=False,
status=status,
)
else:
delete = None
if new_dns_name:
dns_create.delay(
dns_name=dns_name,
address=new_address,
reverse=False,
status=status,
depends_on=delete,
)
@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( old_address = instance.address.ip
old_record=instance, old_dns_name = normalize_fqdn(instance.dns_name)
if old_address and old_dns_name:
dns_delete.delay(
address=old_address,
dns_name=old_dns_name,
)
@receiver(pre_save, sender=ExtraDNSName)
def store_original_extra(instance: ExtraDNSName, **_kwargs):
instance.before_save = ExtraDNSName.objects.filter(pk=instance.pk).first()
@receiver(post_save, sender=ExtraDNSName)
def trigger_extra_ddns_update(instance: ExtraDNSName, **_kwargs):
address = instance.ip_address.address.ip
old_dns_name = instance.before_save.name if instance.before_save else ''
new_dns_name = instance.name
if new_dns_name != old_dns_name:
if old_dns_name:
delete = dns_delete.delay(
dns_name=old_dns_name,
address=address,
reverse=False,
status=instance,
)
else:
delete = None
dns_create.delay(
dns_name=new_dns_name,
address=address,
status=instance,
reverse=False,
depends_on=delete,
)
@receiver(post_delete, sender=ExtraDNSName)
def trigger_extra_ddns_delete(instance: ExtraDNSName, **_kwargs):
address = instance.ip_address.address.ip
old_dns_name = instance.name
if old_dns_name == normalize_fqdn(instance.ip_address.dns_name):
return
dns_delete.delay(
dns_name=old_dns_name,
address=address,
reverse=False,
) )

44
netbox_ddns/tables.py Normal file
View file

@ -0,0 +1,44 @@
import django_tables2 as tables
from netbox_ddns.models import ExtraDNSName
from utilities.tables import BaseTable, ToggleColumn
FORWARD_DNS = """
{% if record.forward_action is not None %}
{{ record.get_forward_action_display }}:
{{ record.get_forward_rcode_html_display }}
{% else %}
<span class="text-muted">Not created</span>
{% endif %}
"""
ACTIONS = """
{% if perms.dcim.change_extradnsname %}
<a href="{% url 'plugins:netbox_ddns:extradnsname_edit' ipaddress_pk=record.ip_address.pk pk=record.pk %}"
class="btn btn-xs btn-warning">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.delete_extradnsname %}
<a href="{% url 'plugins:netbox_ddns:extradnsname_delete' ipaddress_pk=record.ip_address.pk pk=record.pk %}"
class="btn btn-xs btn-danger">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}
"""
class PrefixTable(BaseTable):
pk = ToggleColumn()
name = tables.Column()
last_update = tables.Column()
forward_dns = tables.TemplateColumn(template_code=FORWARD_DNS)
actions = tables.TemplateColumn(
template_code=ACTIONS,
attrs={'td': {'class': 'text-right text-nowrap noprint'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = ExtraDNSName
fields = ('pk', 'name', 'last_update', 'forward_dns', 'actions')

View file

@ -1,6 +1,7 @@
from django.contrib.auth.context_processors import PermWrapper from django.contrib.auth.context_processors import PermWrapper
from extras.plugins import PluginTemplateExtension from extras.plugins import PluginTemplateExtension
from . import tables
# noinspection PyAbstractClass # noinspection PyAbstractClass
@ -9,9 +10,17 @@ class DNSInfo(PluginTemplateExtension):
def left_page(self): def left_page(self):
""" """
An info-box with edit button for the vCenter settings An info-box with the status of the DNS modifications and records
""" """
return self.render('netbox_ddns/ipaddress/dns_info.html') extra_dns_name_table = tables.PrefixTable(list(self.context['object'].extradnsname_set.all()), orderable=False)
return (
self.render('netbox_ddns/ipaddress/dns_info.html') +
self.render('netbox_ddns/ipaddress/dns_extra.html', {
'perms': PermWrapper(self.context['request'].user),
'extra_dns_name_table': extra_dns_name_table,
})
)
template_extensions = [DNSInfo] template_extensions = [DNSInfo]

View file

@ -0,0 +1,20 @@
{% load render_table from django_tables2 %}
{% if perms.netbox_ddns.view_extradnsname %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Extra DNS Names</strong>
</div>
{% render_table extra_dns_name_table 'inc/table.html' %}
{% if perms.netbox_ddns.add_extradnsname %}
<div class="panel-footer text-right noprint">
<a href="{% url 'plugins:netbox_ddns:extradnsname_create' ipaddress_pk=object.pk %}"
class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add extra DNS name
</a>
</div>
{% endif %}
</div>
{% endif %}

View file

@ -16,7 +16,7 @@
<td> <td>
{% if object.dnsstatus.forward_action is not None %} {% if object.dnsstatus.forward_action is not None %}
{{ object.dnsstatus.get_forward_action_display }}: {{ object.dnsstatus.get_forward_action_display }}:
{{ object.dnsstatus.get_forward_rcode_display }} {{ object.dnsstatus.get_forward_rcode_html_display }}
{% else %} {% else %}
<span class="text-muted">Not created</span> <span class="text-muted">Not created</span>
{% endif %} {% endif %}
@ -27,7 +27,7 @@
<td> <td>
{% if object.dnsstatus.reverse_action is not None %} {% if object.dnsstatus.reverse_action is not None %}
{{ object.dnsstatus.get_reverse_action_display }}: {{ object.dnsstatus.get_reverse_action_display }}:
{{ object.dnsstatus.get_reverse_rcode_display }} {{ object.dnsstatus.get_reverse_rcode_html_display }}
{% else %} {% else %}
<span class="text-muted">Not created</span> <span class="text-muted">Not created</span>
{% endif %} {% endif %}

View file

@ -1 +1,15 @@
urlpatterns = [] from django.urls import path
from .views import ExtraDNSNameCreateView, ExtraDNSNameDeleteView, ExtraDNSNameEditView
urlpatterns = [
path(route='ip-addresses/<int:ipaddress_pk>/extra/create/',
view=ExtraDNSNameCreateView.as_view(),
name='extradnsname_create'),
path(route='ip-addresses/<int:ipaddress_pk>/extra/<int:pk>/edit/',
view=ExtraDNSNameEditView.as_view(),
name='extradnsname_edit'),
path(route='ip-addresses/<int:ipaddress_pk>/extra/<int:pk>/delete/',
view=ExtraDNSNameDeleteView.as_view(),
name='extradnsname_delete'),
]

5
netbox_ddns/utils.py Normal file
View file

@ -0,0 +1,5 @@
def normalize_fqdn(dns_name: str) -> str:
if not dns_name:
return ''
return dns_name.lower().rstrip('.') + '.'

View file

@ -0,0 +1,52 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.http import is_safe_url
from ipam.models import IPAddress
from netbox_ddns.forms import ExtraDNSNameEditForm
from netbox_ddns.models import ExtraDNSName
from utilities.views import ObjectDeleteView, ObjectEditView
class ExtraDNSNameObjectMixin:
def get_object(self, kwargs):
if 'ipaddress_pk' not in kwargs:
raise Http404
ip_address = get_object_or_404(IPAddress, pk=kwargs['ipaddress_pk'])
if 'pk' in kwargs:
return get_object_or_404(ExtraDNSName, ip_address=ip_address, pk=kwargs['pk'])
return ExtraDNSName(ip_address=ip_address)
def get_return_url(self, request, obj=None):
# First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
# considered safe.
query_param = request.GET.get('return_url') or request.POST.get('return_url')
if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()):
return query_param
# Otherwise check we have an object and can return to its ip-address
elif obj is not None and obj.ip_address is not None:
return obj.ip_address.get_absolute_url()
# If all else fails, return home. Ideally this should never happen.
return reverse('home')
class ExtraDNSNameCreateView(PermissionRequiredMixin, ExtraDNSNameObjectMixin, ObjectEditView):
permission_required = 'netbox_ddns.add_extradnsname'
model = ExtraDNSName
model_form = ExtraDNSNameEditForm
class ExtraDNSNameEditView(ExtraDNSNameCreateView):
permission_required = 'netbox_ddns.change_extradnsname'
class ExtraDNSNameDeleteView(PermissionRequiredMixin, ExtraDNSNameObjectMixin, ObjectDeleteView):
permission_required = 'netbox_ddns.delete_extradnsname'
model = ExtraDNSName