Add a DNS refresh button to IPAddress view
This commit is contained in:
parent
0ff9d62ba6
commit
2bc5f9f204
|
@ -5,57 +5,16 @@ import dns.query
|
||||||
import dns.rdatatype
|
import dns.rdatatype
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
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 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
|
from netbox_ddns.utils import get_soa
|
||||||
|
|
||||||
logger = logging.getLogger('netbox_ddns')
|
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:
|
def status_update(output: List[str], operation: str, response) -> None:
|
||||||
code = response.rcode()
|
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]):
|
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:
|
if zone:
|
||||||
logger.debug(f"Found zone {zone.name} for {dns_name}")
|
logger.debug(f"Found zone {zone.name} for {dns_name}")
|
||||||
if status:
|
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]):
|
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:
|
if zone:
|
||||||
logger.debug(f"Found zone {zone.name} for {dns_name}")
|
logger.debug(f"Found zone {zone.name} for {dns_name}")
|
||||||
if status:
|
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]):
|
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:
|
if zone and dns_name:
|
||||||
record_name = zone.record_name(address)
|
record_name = zone.record_name(address)
|
||||||
logger.debug(f"Found zone {zone.name} for {record_name}")
|
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]):
|
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:
|
if zone and dns_name:
|
||||||
record_name = zone.record_name(address)
|
record_name = zone.record_name(address)
|
||||||
logger.debug(f"Found zone {zone.name} for {record_name}")
|
logger.debug(f"Found zone {zone.name} for {record_name}")
|
||||||
|
|
|
@ -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.db.models.functions import Length
|
||||||
from django.utils.html import format_html
|
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
|
||||||
|
@ -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):
|
class Zone(models.Model):
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
verbose_name=_('zone name'),
|
verbose_name=_('zone name'),
|
||||||
|
@ -131,6 +144,8 @@ class Zone(models.Model):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = ZoneQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
verbose_name = _('forward zone')
|
verbose_name = _('forward zone')
|
||||||
|
@ -147,6 +162,17 @@ class Zone(models.Model):
|
||||||
return self.server.create_update(self.name)
|
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):
|
class ReverseZone(models.Model):
|
||||||
prefix = IPNetworkField(
|
prefix = IPNetworkField(
|
||||||
verbose_name=_('prefix'),
|
verbose_name=_('prefix'),
|
||||||
|
@ -167,6 +193,8 @@ class ReverseZone(models.Model):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = ReverseZoneQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('prefix',)
|
ordering = ('prefix',)
|
||||||
verbose_name = _('reverse zone')
|
verbose_name = _('reverse zone')
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from django.contrib.auth.context_processors import PermWrapper
|
from django.contrib.auth.context_processors import PermWrapper
|
||||||
|
from django.template.context_processors import csrf
|
||||||
|
|
||||||
from extras.plugins import PluginTemplateExtension
|
from extras.plugins import PluginTemplateExtension
|
||||||
from . import tables
|
from . import tables
|
||||||
|
@ -8,6 +9,16 @@ from . import tables
|
||||||
class DNSInfo(PluginTemplateExtension):
|
class DNSInfo(PluginTemplateExtension):
|
||||||
model = 'ipam.ipaddress'
|
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):
|
def left_page(self):
|
||||||
"""
|
"""
|
||||||
An info-box with the status of the DNS modifications and records
|
An info-box with the status of the DNS modifications and records
|
||||||
|
|
|
@ -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 %}
|
|
@ -1,8 +1,11 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import ExtraDNSNameCreateView, ExtraDNSNameDeleteView, ExtraDNSNameEditView
|
from .views import ExtraDNSNameCreateView, ExtraDNSNameDeleteView, ExtraDNSNameEditView, IPAddressDNSNameRecreateView
|
||||||
|
|
||||||
urlpatterns = [
|
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/',
|
path(route='ip-addresses/<int:ipaddress_pk>/extra/create/',
|
||||||
view=ExtraDNSNameCreateView.as_view(),
|
view=ExtraDNSNameCreateView.as_view(),
|
||||||
name='extradnsname_create'),
|
name='extradnsname_create'),
|
||||||
|
|
|
@ -1,5 +1,28 @@
|
||||||
|
import dns.rdatatype
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
|
||||||
def normalize_fqdn(dns_name: str) -> str:
|
def normalize_fqdn(dns_name: str) -> str:
|
||||||
if not dns_name:
|
if not dns_name:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
return dns_name.lower().rstrip('.') + '.'
|
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()
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.http import Http404
|
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.urls import reverse
|
||||||
from django.utils.http import is_safe_url
|
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 ipam.models import IPAddress
|
||||||
|
from netbox_ddns.background_tasks import dns_create
|
||||||
from netbox_ddns.forms import ExtraDNSNameEditForm
|
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
|
from utilities.views import ObjectDeleteView, ObjectEditView
|
||||||
|
|
||||||
|
|
||||||
|
@ -50,3 +55,52 @@ class ExtraDNSNameEditView(ExtraDNSNameCreateView):
|
||||||
class ExtraDNSNameDeleteView(PermissionRequiredMixin, ExtraDNSNameObjectMixin, ObjectDeleteView):
|
class ExtraDNSNameDeleteView(PermissionRequiredMixin, ExtraDNSNameObjectMixin, ObjectDeleteView):
|
||||||
permission_required = 'netbox_ddns.delete_extradnsname'
|
permission_required = 'netbox_ddns.delete_extradnsname'
|
||||||
model = 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)
|
||||||
|
|
Loading…
Reference in a new issue