diff --git a/netbox_ddns/background_tasks.py b/netbox_ddns/background_tasks.py index 690ceea..5f5c5e5 100644 --- a/netbox_ddns/background_tasks.py +++ b/netbox_ddns/background_tasks.py @@ -5,57 +5,16 @@ import dns.query import dns.rdatatype import dns.resolver from django.db import IntegrityError -from django.db.models.functions import Length from django_rq import job from dns import rcode from netaddr import ip from netbox_ddns.models import ACTION_CREATE, ACTION_DELETE, DNSStatus, ReverseZone, Zone -from netbox_ddns.utils import normalize_fqdn +from netbox_ddns.utils import get_soa logger = logging.getLogger('netbox_ddns') -def get_zone(dns_name: str) -> Optional[Zone]: - # Generate all possible zones - zones = [] - parts = dns_name.lower().split('.') - for i in range(len(parts)): - zones.append('.'.join(parts[-i - 1:])) - - # Find the zone, if any - return Zone.objects.filter(name__in=zones).order_by(Length('name').desc()).first() - - -def get_soa(dns_name: str) -> str: - parts = dns_name.rstrip('.').split('.') - for i in range(len(parts)): - zone_name = normalize_fqdn('.'.join(parts[i:])) - - try: - 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 - continue - except dns.resolver.NXDOMAIN as e: - # Look for a SOA record in the authority section - for query, response in e.responses().items(): - for rrset in response.authority: - if rrset.rdtype == dns.rdatatype.SOA: - return rrset.name.to_text() - - -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: - return None - - zones.sort(key=lambda zone: zone.prefix.prefixlen) - return zones[-1] - - def status_update(output: List[str], operation: str, response) -> None: code = response.rcode() @@ -70,7 +29,7 @@ def status_update(output: List[str], operation: str, response) -> None: def create_forward(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]): - zone = get_zone(dns_name) + zone = Zone.objects.find_for_dns_name(dns_name) if zone: logger.debug(f"Found zone {zone.name} for {dns_name}") if status: @@ -101,7 +60,7 @@ def create_forward(dns_name: str, address: ip.IPAddress, status: Optional[DNSSta def delete_forward(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]): - zone = get_zone(dns_name) + zone = Zone.objects.find_for_dns_name(dns_name) if zone: logger.debug(f"Found zone {zone.name} for {dns_name}") if status: @@ -131,7 +90,7 @@ def delete_forward(dns_name: str, address: ip.IPAddress, status: Optional[DNSSta def create_reverse(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]): - zone = get_reverse_zone(address) + zone = ReverseZone.objects.find_for_address(address) if zone and dns_name: record_name = zone.record_name(address) logger.debug(f"Found zone {zone.name} for {record_name}") @@ -162,7 +121,7 @@ def create_reverse(dns_name: str, address: ip.IPAddress, status: Optional[DNSSta def delete_reverse(dns_name: str, address: ip.IPAddress, status: Optional[DNSStatus], output: List[str]): - zone = get_reverse_zone(address) + zone = ReverseZone.objects.find_for_address(address) if zone and dns_name: record_name = zone.record_name(address) logger.debug(f"Found zone {zone.name} for {record_name}") diff --git a/netbox_ddns/models.py b/netbox_ddns/models.py index 7cc2e12..7a66168 100644 --- a/netbox_ddns/models.py +++ b/netbox_ddns/models.py @@ -6,6 +6,7 @@ import dns.tsigkeyring import dns.update from django.core.exceptions import ValidationError from django.db import models +from django.db.models.functions import Length from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from dns import rcode @@ -115,6 +116,18 @@ class Server(models.Model): ) +class ZoneQuerySet(models.QuerySet): + def find_for_dns_name(self, dns_name: str) -> Optional['Zone']: + # Generate all possible zones + zones = [] + parts = dns_name.lower().split('.') + for i in range(len(parts)): + zones.append('.'.join(parts[-i - 1:])) + + # Find the zone, if any + return self.filter(name__in=zones).order_by(Length('name').desc()).first() + + class Zone(models.Model): name = models.CharField( verbose_name=_('zone name'), @@ -131,6 +144,8 @@ class Zone(models.Model): on_delete=models.PROTECT, ) + objects = ZoneQuerySet.as_manager() + class Meta: ordering = ('name',) verbose_name = _('forward zone') @@ -147,6 +162,17 @@ class Zone(models.Model): return self.server.create_update(self.name) +class ReverseZoneQuerySet(models.QuerySet): + def find_for_address(self, address: ip.IPAddress) -> Optional['ReverseZone']: + # Find the zone, if any + zones = list(ReverseZone.objects.filter(prefix__net_contains=address)) + if not zones: + return None + + zones.sort(key=lambda zone: zone.prefix.prefixlen) + return zones[-1] + + class ReverseZone(models.Model): prefix = IPNetworkField( verbose_name=_('prefix'), @@ -167,6 +193,8 @@ class ReverseZone(models.Model): on_delete=models.PROTECT, ) + objects = ReverseZoneQuerySet.as_manager() + class Meta: ordering = ('prefix',) verbose_name = _('reverse zone') diff --git a/netbox_ddns/template_content.py b/netbox_ddns/template_content.py index ca439f7..3c00f8a 100644 --- a/netbox_ddns/template_content.py +++ b/netbox_ddns/template_content.py @@ -1,4 +1,5 @@ from django.contrib.auth.context_processors import PermWrapper +from django.template.context_processors import csrf from extras.plugins import PluginTemplateExtension from . import tables @@ -8,6 +9,16 @@ from . import tables class DNSInfo(PluginTemplateExtension): model = 'ipam.ipaddress' + def buttons(self): + """ + A button to force DNS re-provisioning + """ + context = { + 'perms': PermWrapper(self.context['request'].user), + } + context.update(csrf(self.context['request'])) + return self.render('netbox_ddns/ipaddress/dns_refresh_button.html', context) + def left_page(self): """ An info-box with the status of the DNS modifications and records diff --git a/netbox_ddns/templates/netbox_ddns/ipaddress/dns_refresh_button.html b/netbox_ddns/templates/netbox_ddns/ipaddress/dns_refresh_button.html new file mode 100644 index 0000000..5c5f5b2 --- /dev/null +++ b/netbox_ddns/templates/netbox_ddns/ipaddress/dns_refresh_button.html @@ -0,0 +1,11 @@ +{% if perms.ipam.change_ipaddress %} +
+ {% csrf_token %} + + +
+{% endif %} diff --git a/netbox_ddns/urls.py b/netbox_ddns/urls.py index 8d90a62..b667169 100644 --- a/netbox_ddns/urls.py +++ b/netbox_ddns/urls.py @@ -1,8 +1,11 @@ from django.urls import path -from .views import ExtraDNSNameCreateView, ExtraDNSNameDeleteView, ExtraDNSNameEditView +from .views import ExtraDNSNameCreateView, ExtraDNSNameDeleteView, ExtraDNSNameEditView, IPAddressDNSNameRecreateView urlpatterns = [ + path(route='ip-addresses//recreate/', + view=IPAddressDNSNameRecreateView.as_view(), + name='ipaddress_dnsname_recreate'), path(route='ip-addresses//extra/create/', view=ExtraDNSNameCreateView.as_view(), name='extradnsname_create'), diff --git a/netbox_ddns/utils.py b/netbox_ddns/utils.py index e88a306..4714e82 100644 --- a/netbox_ddns/utils.py +++ b/netbox_ddns/utils.py @@ -1,5 +1,28 @@ +import dns.rdatatype +import dns.resolver + + def normalize_fqdn(dns_name: str) -> str: if not dns_name: return '' return dns_name.lower().rstrip('.') + '.' + + +def get_soa(dns_name: str) -> str: + parts = dns_name.rstrip('.').split('.') + for i in range(len(parts)): + zone_name = normalize_fqdn('.'.join(parts[i:])) + + try: + 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 + continue + except dns.resolver.NXDOMAIN as e: + # Look for a SOA record in the authority section + for query, response in e.responses().items(): + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.SOA: + return rrset.name.to_text() diff --git a/netbox_ddns/views.py b/netbox_ddns/views.py index 5324a11..d91cc3c 100644 --- a/netbox_ddns/views.py +++ b/netbox_ddns/views.py @@ -1,12 +1,17 @@ +from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.http import Http404 -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.http import is_safe_url +from django.utils.translation import gettext as _ +from django.views import View from ipam.models import IPAddress +from netbox_ddns.background_tasks import dns_create from netbox_ddns.forms import ExtraDNSNameEditForm -from netbox_ddns.models import ExtraDNSName +from netbox_ddns.models import DNSStatus, ExtraDNSName, Zone +from netbox_ddns.utils import normalize_fqdn from utilities.views import ObjectDeleteView, ObjectEditView @@ -50,3 +55,52 @@ class ExtraDNSNameEditView(ExtraDNSNameCreateView): class ExtraDNSNameDeleteView(PermissionRequiredMixin, ExtraDNSNameObjectMixin, ObjectDeleteView): permission_required = 'netbox_ddns.delete_extradnsname' model = ExtraDNSName + + +class IPAddressDNSNameRecreateView(PermissionRequiredMixin, View): + permission_required = 'ipam.change_ipaddress' + + def post(self, request, ipaddress_pk): + ip_address = get_object_or_404(IPAddress, pk=ipaddress_pk) + + new_address = ip_address.address.ip + new_dns_name = normalize_fqdn(ip_address.dns_name) + + updated_names = [] + zoneless_names = [] + + if new_dns_name and Zone.objects.find_for_dns_name(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, + ) + + updated_names.append(new_dns_name) + else: + zoneless_names.append(new_dns_name) + + for extra in ip_address.extradnsname_set.all(): + new_address = extra.ip_address.address.ip + new_dns_name = extra.name + + if Zone.objects.find_for_dns_name(new_dns_name): + dns_create.delay( + dns_name=new_dns_name, + address=new_address, + status=extra, + reverse=False, + ) + + updated_names.append(new_dns_name) + else: + zoneless_names.append(new_dns_name) + + if updated_names: + messages.info(request, _("Updating DNS for {names}").format(names=', '.join(updated_names))) + if zoneless_names: + messages.warning(request, _("No DNS zone configured for {names}").format(names=', '.join(zoneless_names))) + + return redirect('ipam:ipaddress', pk=ip_address.pk)