Add a DNS refresh button to IPAddress view

This commit is contained in:
Sander Steffann 2020-04-19 22:19:12 +02:00
parent 0ff9d62ba6
commit 2bc5f9f204
7 changed files with 138 additions and 49 deletions

View File

@ -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}")

View File

@ -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')

View File

@ -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

View File

@ -0,0 +1,11 @@
{% if perms.ipam.change_ipaddress %}
<form method="post" style="display: inline-block">
{% csrf_token %}
<button type="submit" name="_edit"
formaction="{% url 'plugins:netbox_ddns:ipaddress_dnsname_recreate' ipaddress_pk=object.pk %}"
class="btn btn-default">
<span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Recreate DNS
</button>
</form>
{% endif %}

View File

@ -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/<int:ipaddress_pk>/recreate/',
view=IPAddressDNSNameRecreateView.as_view(),
name='ipaddress_dnsname_recreate'),
path(route='ip-addresses/<int:ipaddress_pk>/extra/create/',
view=ExtraDNSNameCreateView.as_view(),
name='extradnsname_create'),

View File

@ -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()

View File

@ -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)