netbox-plugin-dns 1.1.3__py3-none-any.whl → 1.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of netbox-plugin-dns might be problematic. Click here for more details.

Files changed (39) hide show
  1. netbox_dns/__init__.py +26 -5
  2. netbox_dns/api/views.py +1 -1
  3. netbox_dns/choices/zone.py +2 -0
  4. netbox_dns/fields/address.py +3 -21
  5. netbox_dns/filtersets/record.py +3 -0
  6. netbox_dns/filtersets/zone.py +1 -2
  7. netbox_dns/forms/record.py +29 -13
  8. netbox_dns/forms/view.py +2 -3
  9. netbox_dns/forms/zone.py +15 -10
  10. netbox_dns/forms/zone_template.py +5 -5
  11. netbox_dns/locale/de/LC_MESSAGES/django.mo +0 -0
  12. netbox_dns/models/nameserver.py +4 -8
  13. netbox_dns/models/record.py +26 -41
  14. netbox_dns/models/record_template.py +5 -5
  15. netbox_dns/models/view.py +2 -3
  16. netbox_dns/models/zone.py +96 -39
  17. netbox_dns/signals/ipam_dnssync.py +1 -1
  18. netbox_dns/tables/record.py +14 -2
  19. netbox_dns/tables/zone.py +1 -2
  20. netbox_dns/template_content.py +16 -0
  21. netbox_dns/templates/netbox_dns/record.html +12 -0
  22. netbox_dns/templates/netbox_dns/view.html +1 -1
  23. netbox_dns/templates/netbox_dns/zone/delegation_record.html +18 -0
  24. netbox_dns/templates/netbox_dns/zone.html +1 -1
  25. netbox_dns/utilities/__init__.py +1 -0
  26. netbox_dns/utilities/dns.py +12 -0
  27. netbox_dns/utilities/ipam_dnssync.py +10 -13
  28. netbox_dns/validators/dns_value.py +47 -6
  29. netbox_dns/views/nameserver.py +3 -3
  30. netbox_dns/views/record.py +44 -11
  31. netbox_dns/views/registrar.py +1 -1
  32. netbox_dns/views/registration_contact.py +1 -1
  33. netbox_dns/views/view.py +2 -2
  34. netbox_dns/views/zone.py +49 -20
  35. {netbox_plugin_dns-1.1.3.dist-info → netbox_plugin_dns-1.1.5.dist-info}/METADATA +3 -2
  36. {netbox_plugin_dns-1.1.3.dist-info → netbox_plugin_dns-1.1.5.dist-info}/RECORD +39 -37
  37. {netbox_plugin_dns-1.1.3.dist-info → netbox_plugin_dns-1.1.5.dist-info}/WHEEL +1 -1
  38. {netbox_plugin_dns-1.1.3.dist-info → netbox_plugin_dns-1.1.5.dist-info}/LICENSE +0 -0
  39. {netbox_plugin_dns-1.1.3.dist-info → netbox_plugin_dns-1.1.5.dist-info}/top_level.txt +0 -0
@@ -133,17 +133,17 @@ class RecordTemplate(NetBoxModel):
133
133
  {
134
134
  "record_name": exc,
135
135
  }
136
- ) from None
136
+ )
137
137
 
138
138
  def validate_value(self):
139
139
  try:
140
- validate_record_value(self.type, self.value)
140
+ validate_record_value(self)
141
141
  except ValidationError as exc:
142
- raise ValidationError({"value": exc}) from None
142
+ raise ValidationError({"value": exc})
143
143
 
144
144
  def matching_records(self, zone):
145
- return Record.objects.filter(
146
- zone=zone, name=self.record_name, type=self.type, value=self.value
145
+ return zone.record_set.filter(
146
+ name=self.record_name, type=self.type, value=self.value
147
147
  )
148
148
 
149
149
  def create_record(self, zone):
netbox_dns/models/view.py CHANGED
@@ -2,7 +2,6 @@ from django.db import models
2
2
  from django.urls import reverse
3
3
  from django.core.exceptions import ValidationError
4
4
  from django.utils.translation import gettext_lazy as _
5
- from django.utils.translation import pgettext_lazy as _p
6
5
 
7
6
  from netbox.models import NetBoxModel
8
7
  from netbox.models.features import ContactsMixin
@@ -77,8 +76,8 @@ class View(ObjectModificationMixin, ContactsMixin, NetBoxModel):
77
76
  return str(self.name)
78
77
 
79
78
  class Meta:
80
- verbose_name = _p("DNS", "View")
81
- verbose_name_plural = _p("DNS", "Views")
79
+ verbose_name = _("View")
80
+ verbose_name_plural = _("Views")
82
81
 
83
82
  ordering = ("name",)
84
83
 
netbox_dns/models/zone.py CHANGED
@@ -12,12 +12,12 @@ from django.core.validators import (
12
12
  from django.core.exceptions import ObjectDoesNotExist, ValidationError
13
13
  from django.db import models, transaction
14
14
  from django.db.models import Q, Max, ExpressionWrapper, BooleanField
15
- from django.urls import reverse
15
+ from django.db.models.functions import Length
16
16
  from django.db.models.signals import m2m_changed
17
+ from django.urls import reverse
17
18
  from django.dispatch import receiver
18
19
  from django.conf import settings
19
20
  from django.utils.translation import gettext_lazy as _
20
- from django.utils.translation import pgettext_lazy as _p
21
21
 
22
22
  from netbox.models import NetBoxModel
23
23
  from netbox.models.features import ContactsMixin
@@ -35,6 +35,7 @@ from netbox_dns.utilities import (
35
35
  arpa_to_prefix,
36
36
  name_to_unicode,
37
37
  normalize_name,
38
+ get_parent_zone_names,
38
39
  NameFormatError,
39
40
  )
40
41
  from netbox_dns.validators import (
@@ -53,6 +54,9 @@ __all__ = (
53
54
  "ZoneIndex",
54
55
  )
55
56
 
57
+ ZONE_ACTIVE_STATUS_LIST = get_plugin_config("netbox_dns", "zone_active_status")
58
+ RECORD_ACTIVE_STATUS_LIST = get_plugin_config("netbox_dns", "record_active_status")
59
+
56
60
 
57
61
  class ZoneManager(models.Manager.from_queryset(RestrictedQuerySet)):
58
62
  """
@@ -65,15 +69,13 @@ class ZoneManager(models.Manager.from_queryset(RestrictedQuerySet)):
65
69
  .get_queryset()
66
70
  .annotate(
67
71
  active=ExpressionWrapper(
68
- Q(status__in=Zone.ACTIVE_STATUS_LIST), output_field=BooleanField()
72
+ Q(status__in=ZONE_ACTIVE_STATUS_LIST), output_field=BooleanField()
69
73
  )
70
74
  )
71
75
  )
72
76
 
73
77
 
74
78
  class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
75
- ACTIVE_STATUS_LIST = (ZoneStatusChoices.STATUS_ACTIVE,)
76
-
77
79
  def __init__(self, *args, **kwargs):
78
80
  kwargs.pop("template", None)
79
81
 
@@ -83,7 +85,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
83
85
  self._ip_addresses_checked = False
84
86
 
85
87
  view = models.ForeignKey(
86
- verbose_name=_p("DNS", "View"),
88
+ verbose_name=_("View"),
87
89
  to="View",
88
90
  on_delete=models.PROTECT,
89
91
  null=False,
@@ -297,13 +299,27 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
297
299
 
298
300
  return str(name)
299
301
 
302
+ @property
303
+ def fqdn(self):
304
+ return f"{self.name}."
305
+
300
306
  @staticmethod
301
307
  def get_defaults():
308
+ default_fields = (
309
+ "zone_default_ttl",
310
+ "zone_soa_ttl",
311
+ "zone_soa_serial",
312
+ "zone_soa_refresh",
313
+ "zone_soa_retry",
314
+ "zone_soa_expire",
315
+ "zone_soa_minimum",
316
+ "zone_soa_rname",
317
+ )
318
+
302
319
  return {
303
320
  field[5:]: value
304
321
  for field, value in settings.PLUGINS_CONFIG.get("netbox_dns").items()
305
- if field.startswith("zone_")
306
- and field not in ("zone_soa_mname", "zone_nameservers")
322
+ if field in default_fields
307
323
  }
308
324
 
309
325
  @property
@@ -337,7 +353,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
337
353
 
338
354
  @property
339
355
  def is_active(self):
340
- return self.status in Zone.ACTIVE_STATUS_LIST
356
+ return self.status in ZONE_ACTIVE_STATUS_LIST
341
357
 
342
358
  @property
343
359
  def is_reverse_zone(self):
@@ -352,10 +368,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
352
368
  return None
353
369
 
354
370
  return (
355
- Zone.objects.filter(
356
- view=self.view,
357
- arpa_network__net_contains=self.rfc2317_prefix,
358
- )
371
+ self.view.zone_set.filter(arpa_network__net_contains=self.rfc2317_prefix)
359
372
  .order_by("arpa_network__net_mask_length")
360
373
  .last()
361
374
  )
@@ -376,25 +389,73 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
376
389
 
377
390
  @property
378
391
  def child_zones(self):
379
- return Zone.objects.filter(
380
- name__iregex=rf"^[^.]+\.{re.escape(self.name)}$", view=self.view
392
+ return self.view.zone_set.filter(
393
+ name__iregex=rf"^[^.]+\.{re.escape(self.name)}$"
381
394
  )
382
395
 
396
+ @property
397
+ def descendant_zones(self):
398
+ return self.view.zone_set.filter(name__endswith=f".{self.name}")
399
+
383
400
  @property
384
401
  def parent_zone(self):
385
- parent_name = (
386
- dns_name.from_text(self.name).parent().relativize(dns_name.root).to_text()
387
- )
388
402
  try:
389
- return Zone.objects.get(name=parent_name, view=self.view)
390
- except Zone.DoesNotExist:
403
+ return self.view.zone_set.get(name=get_parent_zone_names(self.name)[-1])
404
+ except (Zone.DoesNotExist, IndexError):
391
405
  return None
392
406
 
393
- def record_count(self, managed=False):
394
- return Record.objects.filter(zone=self, managed=managed).count()
407
+ @property
408
+ def ancestor_zones(self):
409
+ return (
410
+ self.view.zone_set.annotate(name_length=Length("name"))
411
+ .filter(name__in=get_parent_zone_names(self.name))
412
+ .order_by("name_length")
413
+ )
395
414
 
396
- def rfc2317_child_zone_count(self):
397
- return Zone.objects.filter(rfc2317_parent_zone=self).count()
415
+ @property
416
+ def delegation_records(self):
417
+ descendant_zone_names = [
418
+ f"{name}." for name in self.descendant_zones.values_list("name", flat=True)
419
+ ]
420
+
421
+ ns_records = (
422
+ self.record_set.filter(type=RecordTypeChoices.NS)
423
+ .exclude(fqdn=self.fqdn)
424
+ .filter(fqdn__in=descendant_zone_names)
425
+ )
426
+ ns_values = [record.value_fqdn for record in ns_records]
427
+
428
+ return (
429
+ ns_records
430
+ | self.record_set.filter(type=RecordTypeChoices.DS)
431
+ | self.record_set.filter(
432
+ type__in=(RecordTypeChoices.A, RecordTypeChoices.AAAA),
433
+ fqdn__in=ns_values,
434
+ )
435
+ )
436
+
437
+ @property
438
+ def ancestor_delegation_records(self):
439
+ ancestor_zones = self.ancestor_zones
440
+
441
+ ns_records = Record.objects.filter(
442
+ type=RecordTypeChoices.NS, zone__in=ancestor_zones, fqdn=self.fqdn
443
+ )
444
+ ns_values = [record.value_fqdn for record in ns_records]
445
+
446
+ ds_records = Record.objects.filter(
447
+ type=RecordTypeChoices.DS, zone__in=ancestor_zones, fqdn=self.fqdn
448
+ )
449
+
450
+ return (
451
+ ns_records
452
+ | ds_records
453
+ | Record.objects.filter(
454
+ zone__in=ancestor_zones,
455
+ type__in=(RecordTypeChoices.A, RecordTypeChoices.AAAA),
456
+ fqdn__in=ns_values,
457
+ )
458
+ )
398
459
 
399
460
  def update_soa_record(self):
400
461
  soa_name = "@"
@@ -472,9 +533,8 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
472
533
  continue
473
534
 
474
535
  relative_name = name.relativize(parent).to_text()
475
- address_records = Record.objects.filter(
476
- Q(zone=ns_zone),
477
- Q(status__in=Record.ACTIVE_STATUS_LIST),
536
+ address_records = ns_zone.record_set.filter(
537
+ Q(status__in=RECORD_ACTIVE_STATUS_LIST),
478
538
  Q(Q(name=f"{_nameserver.name}.") | Q(name=relative_name)),
479
539
  Q(Q(type=RecordTypeChoices.A) | Q(type=RecordTypeChoices.AAAA)),
480
540
  )
@@ -630,7 +690,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
630
690
  {
631
691
  "name": str(exc),
632
692
  }
633
- ) from None
693
+ )
634
694
 
635
695
  try:
636
696
  validate_domain_name(self.name, zone_name=True)
@@ -639,7 +699,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
639
699
  {
640
700
  "name": exc,
641
701
  }
642
- ) from None
702
+ )
643
703
 
644
704
  if self.soa_rname in (None, ""):
645
705
  raise ValidationError(_("soa_rname not set and no default value defined"))
@@ -651,7 +711,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
651
711
  {
652
712
  "soa_rname": exc,
653
713
  }
654
- ) from None
714
+ )
655
715
 
656
716
  if not self.soa_serial_auto:
657
717
  if self.soa_serial is None:
@@ -735,8 +795,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
735
795
  else:
736
796
  self.rfc2317_parent_zone = None
737
797
 
738
- overlapping_zones = Zone.objects.filter(
739
- view=self.view,
798
+ overlapping_zones = self.view.zone_set.filter(
740
799
  rfc2317_prefix__net_overlap=self.rfc2317_prefix,
741
800
  active=True,
742
801
  ).exclude(pk=self.pk)
@@ -769,9 +828,8 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
769
828
  if (
770
829
  changed_fields is None or {"name", "view", "status"} & changed_fields
771
830
  ) and self.is_reverse_zone:
772
- zones = Zone.objects.filter(
773
- view=self.view,
774
- arpa_network__net_contains_or_equals=self.arpa_network,
831
+ zones = self.view.zone_set.filter(
832
+ arpa_network__net_contains_or_equals=self.arpa_network
775
833
  )
776
834
  address_records = Record.objects.filter(
777
835
  Q(ptr_record__isnull=True) | Q(ptr_record__zone__in=zones),
@@ -800,9 +858,8 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
800
858
  or {"name", "view", "status", "rfc2317_prefix", "rfc2317_parent_managed"}
801
859
  & changed_fields
802
860
  ) and self.is_rfc2317_zone:
803
- zones = Zone.objects.filter(
804
- view=self.view,
805
- arpa_network__net_contains=self.rfc2317_prefix,
861
+ zones = self.view.zone_set.filter(
862
+ arpa_network__net_contains=self.rfc2317_prefix
806
863
  )
807
864
  address_records = Record.objects.filter(
808
865
  Q(ptr_record__isnull=True)
@@ -883,7 +940,7 @@ class Zone(ObjectModificationMixin, ContactsMixin, NetBoxModel):
883
940
  cname_zone.update_soa_record()
884
941
 
885
942
  rfc2317_child_zones = list(
886
- self.rfc2317_child_zones.all().values_list("pk", flat=True)
943
+ self.rfc2317_child_zones.values_list("pk", flat=True)
887
944
  )
888
945
 
889
946
  ipam_ip_addresses = list(
@@ -63,7 +63,7 @@ def ipam_dnssync_ipaddress_post_clean(instance, **kwargs):
63
63
  {
64
64
  "dns_name": _(
65
65
  "Unique DNS records are enforced and there is already "
66
- "an active IP address {address} with DNS name {name}. Plesase choose "
66
+ "an active IP address {address} with DNS name {name}. Please choose "
67
67
  "a different name or disable record creation for this IP address."
68
68
  ).format(address=instance.address, name=instance.dns_name)
69
69
  }
@@ -1,7 +1,6 @@
1
1
  import django_tables2 as tables
2
2
  from django.utils.html import format_html
3
3
  from django.utils.translation import gettext_lazy as _
4
- from django.utils.translation import pgettext_lazy as _p
5
4
 
6
5
 
7
6
  from netbox.tables import (
@@ -20,6 +19,7 @@ __all__ = (
20
19
  "RecordTable",
21
20
  "ManagedRecordTable",
22
21
  "RelatedRecordTable",
22
+ "DelegationRecordTable",
23
23
  )
24
24
 
25
25
 
@@ -29,7 +29,7 @@ class RecordBaseTable(TenancyColumnsMixin, NetBoxTable):
29
29
  linkify=True,
30
30
  )
31
31
  view = tables.Column(
32
- verbose_name=_p("DNS", "View"),
32
+ verbose_name=_("View"),
33
33
  accessor="zone__view",
34
34
  linkify=True,
35
35
  )
@@ -162,3 +162,15 @@ class RelatedRecordTable(RecordBaseTable):
162
162
  "type",
163
163
  "value",
164
164
  )
165
+
166
+
167
+ class DelegationRecordTable(RecordBaseTable):
168
+ class Meta(NetBoxTable.Meta):
169
+ model = Record
170
+ fields = ()
171
+ default_columns = (
172
+ "name",
173
+ "zone",
174
+ "type",
175
+ "value",
176
+ )
netbox_dns/tables/zone.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import django_tables2 as tables
2
2
  from django.utils.translation import gettext_lazy as _
3
- from django.utils.translation import pgettext_lazy as _p
4
3
 
5
4
  from netbox.tables import (
6
5
  ChoiceFieldColumn,
@@ -21,7 +20,7 @@ class ZoneTable(TenancyColumnsMixin, NetBoxTable):
21
20
  linkify=True,
22
21
  )
23
22
  view = tables.Column(
24
- verbose_name=_p("DNS", "View"),
23
+ verbose_name=_("View"),
25
24
  linkify=True,
26
25
  )
27
26
  soa_mname = tables.Column(
@@ -1,8 +1,12 @@
1
+ import django_tables2 as tables
2
+
1
3
  from django.conf import settings
2
4
  from django.urls import reverse
3
5
 
4
6
  from netbox.plugins.utils import get_plugin_config
5
7
  from netbox.plugins import PluginTemplateExtension
8
+ from utilities.tables import register_table_column
9
+ from ipam.tables import IPAddressTable
6
10
 
7
11
  from netbox_dns.models import Record
8
12
  from netbox_dns.choices import RecordTypeChoices
@@ -114,8 +118,20 @@ class IPRelatedDNSRecords(PluginTemplateExtension):
114
118
  )
115
119
 
116
120
 
121
+ address_records = tables.ManyToManyColumn(
122
+ verbose_name="DNS Address Records",
123
+ accessor="netbox_dns_records",
124
+ linkify_item=True,
125
+ transform=lambda obj: (
126
+ obj.fqdn.rstrip(".")
127
+ if obj.zone.view.default_view
128
+ else f"[{obj.zone.view.name}] {obj.fqdn.rstrip('.')}"
129
+ ),
130
+ )
131
+
117
132
  if not settings.PLUGINS_CONFIG["netbox_dns"].get("dnssync_disabled"):
118
133
  template_extensions = [RelatedDNSRecords, RelatedDNSViews]
134
+ register_table_column(address_records, "address_records", IPAddressTable)
119
135
  else:
120
136
  template_extensions = []
121
137
 
@@ -43,6 +43,12 @@
43
43
  <th scope="row">{% trans "Name" %}</th>
44
44
  <td style="word-break:break-all;">{{ object.name }}</td>
45
45
  </tr>
46
+ {% if mask_warning %}
47
+ <tr class="text-warning">
48
+ <th scope="row">{% trans "Warning" %}</th>
49
+ <td>{{ mask_warning }}</td>
50
+ </tr>
51
+ {% endif %}
46
52
  {% if unicode_name %}
47
53
  <tr>
48
54
  <th scope="row">{% trans "IDN" %}</th>
@@ -76,6 +82,12 @@
76
82
  <th scope="row">{% trans "Value" %}</th>
77
83
  <td style="word-break:break-all;">{{ object.value }}</td>
78
84
  </tr>
85
+ {% if cname_warning %}
86
+ <tr class="text-warning">
87
+ <th scope="row">{% trans "Warning" %}</th>
88
+ <td>{{ cname_warning }}</td>
89
+ </tr>
90
+ {% endif %}
79
91
  {% if unicode_value %}
80
92
  <tr>
81
93
  <th scope="row">{% trans "Unicode Value" %}</th>
@@ -5,7 +5,7 @@
5
5
  <div class="row">
6
6
  <div class="col col-md-6">
7
7
  <div class="card">
8
- <h5 class="card-header">{% trans "View" context "DNS" %}</h5>
8
+ <h5 class="card-header">{% trans "View" %}</h5>
9
9
  <table class="table table-hover attr-table">
10
10
  <tr>
11
11
  <th scope="row">{% trans "Name" %}</th>
@@ -0,0 +1,18 @@
1
+ {% extends 'netbox_dns/zone/base.html' %}
2
+ {% load helpers %}
3
+ {% load render_table from django_tables2 %}
4
+ {% load perms %}
5
+
6
+ {% block content %}
7
+ {% include 'inc/table_controls_htmx.html' with table_modal="DelegationRecordTable_config" %}
8
+ <div class="card">
9
+ <div class="htmx-container table-responsive" id="object_list">
10
+ {% include 'htmx/table.html' %}
11
+ </div>
12
+ </div>
13
+ {% endblock %}
14
+
15
+ {% block modals %}
16
+ {{ block.super }}
17
+ {% table_config_form table %}
18
+ {% endblock modals %}
@@ -25,7 +25,7 @@
25
25
  </tr>
26
26
  {% endif %}
27
27
  <tr>
28
- <th scope="row">{% trans "View" context "DNS" %}</th>
28
+ <th scope="row">{% trans "View" %}</th>
29
29
  <td>{{ object.view|linkify }}</td>
30
30
  </tr>
31
31
  {% if object.description %}
@@ -1,2 +1,3 @@
1
+ from .dns import *
1
2
  from .conversions import *
2
3
  from .ipam_dnssync import *
@@ -0,0 +1,12 @@
1
+ from dns import name as dns_name
2
+
3
+
4
+ __all__ = ("get_parent_zone_names",)
5
+
6
+
7
+ def get_parent_zone_names(name, min_labels=1, include_self=False):
8
+ fqdn = dns_name.from_text(name)
9
+ return [
10
+ fqdn.split(i)[1].to_text().rstrip(".")
11
+ for i in range(min_labels + 1, len(fqdn.labels) + include_self)
12
+ ]
@@ -12,6 +12,8 @@ from ipam.models import IPAddress, Prefix
12
12
 
13
13
  from netbox_dns.choices import RecordStatusChoices
14
14
 
15
+ from .dns import get_parent_zone_names
16
+
15
17
 
16
18
  __all__ = (
17
19
  "get_zones",
@@ -85,15 +87,12 @@ def get_zones(ip_address, view=None, old_zone=None):
85
87
  min_labels = settings.PLUGINS_CONFIG["netbox_dns"].get(
86
88
  "dnssync_minimum_zone_labels", 2
87
89
  )
88
- fqdn = dns_name.from_text(ip_address.dns_name)
89
- zone_name_candidates = [
90
- fqdn.split(i)[1].to_text().rstrip(".")
91
- for i in range(min_labels + 1, len(fqdn.labels) + 1)
92
- ]
93
90
 
94
91
  zones = Zone.objects.filter(
95
92
  view__in=views,
96
- name__in=zone_name_candidates,
93
+ name__in=get_parent_zone_names(
94
+ ip_address.dns_name, min_labels=min_labels, include_self=True
95
+ ),
97
96
  active=True,
98
97
  )
99
98
 
@@ -194,9 +193,7 @@ def update_dns_records(ip_address, view=None, force=False):
194
193
  updated = True
195
194
 
196
195
  zones = Zone.objects.filter(pk__in=[zone.pk for zone in zones]).exclude(
197
- pk__in=set(
198
- ip_address.netbox_dns_records.all().values_list("zone", flat=True)
199
- )
196
+ pk__in=set(ip_address.netbox_dns_records.values_list("zone", flat=True))
200
197
  )
201
198
 
202
199
  for zone in zones:
@@ -213,18 +210,18 @@ def update_dns_records(ip_address, view=None, force=False):
213
210
 
214
211
 
215
212
  def delete_dns_records(ip_address, view=None):
216
- deleted = False
217
-
218
213
  if view is None:
219
214
  address_records = ip_address.netbox_dns_records.all()
220
215
  else:
221
216
  address_records = ip_address.netbox_dns_records.filter(zone__view=view)
222
217
 
218
+ if not address_records.exists():
219
+ return False
220
+
223
221
  for record in address_records:
224
222
  record.delete()
225
- deleted = True
226
223
 
227
- return deleted
224
+ return True
228
225
 
229
226
 
230
227
  def get_views_by_prefix(prefix):
@@ -1,5 +1,8 @@
1
- import dns
1
+ import re
2
+ import textwrap
3
+
2
4
  from dns import rdata, name as dns_name
5
+ from dns.exception import SyntaxError
3
6
 
4
7
  from django.core.exceptions import ValidationError
5
8
  from django.utils.translation import gettext as _
@@ -11,11 +14,12 @@ from netbox_dns.validators import (
11
14
  validate_generic_name,
12
15
  )
13
16
 
17
+ MAX_TXT_LENGTH = 255
14
18
 
15
19
  __all__ = ("validate_record_value",)
16
20
 
17
21
 
18
- def validate_record_value(record_type, value):
22
+ def validate_record_value(record):
19
23
  def _validate_idn(name):
20
24
  try:
21
25
  name.to_unicode()
@@ -26,16 +30,53 @@ def validate_record_value(record_type, value):
26
30
  )
27
31
  )
28
32
 
33
+ def _split_text_value(value):
34
+ # +
35
+ # Text values longer than 255 characters need to be broken up for TXT and
36
+ # SPF records.
37
+ # First, in case they had been split into separate strings, reassemble the
38
+ # original (long) value, then split it into chunks of a maximum length of
39
+ # 255 (preferably at word boundaries), and then build a sequence of partial
40
+ # strings enclosed in double quotes and separated by space.
41
+ #
42
+ # See https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3 for details.
43
+ # -
44
+ raw_value = "".join(re.findall(r'"([^"]+)"', value))
45
+ if not raw_value:
46
+ raw_value = value
47
+
48
+ return " ".join(
49
+ f'"{part}"'
50
+ for part in textwrap.wrap(raw_value, MAX_TXT_LENGTH, drop_whitespace=False)
51
+ )
52
+
53
+ if record.type in (RecordTypeChoices.TXT, RecordTypeChoices.SPF):
54
+ if not (record.value.isascii() and record.value.isprintable()):
55
+ raise ValidationError(
56
+ _(
57
+ "Record value {value} for a type {type} record is not a printable ASCII string."
58
+ ).format(value=record.value, type=record.type)
59
+ )
60
+
61
+ if len(record.value) <= MAX_TXT_LENGTH:
62
+ return
63
+
64
+ try:
65
+ rr = rdata.from_text(RecordClassChoices.IN, record.type, record.value)
66
+ except SyntaxError as exc:
67
+ if str(exc) == "string too long":
68
+ record.value = _split_text_value(record.value)
69
+
29
70
  try:
30
- rr = rdata.from_text(RecordClassChoices.IN, record_type, value)
31
- except dns.exception.SyntaxError as exc:
71
+ rr = rdata.from_text(RecordClassChoices.IN, record.type, record.value)
72
+ except SyntaxError as exc:
32
73
  raise ValidationError(
33
74
  _(
34
75
  "Record value {value} is not a valid value for a {type} record: {error}."
35
- ).format(value=value, type=record_type, error=exc)
76
+ ).format(value=record.value, type=record.type, error=exc)
36
77
  )
37
78
 
38
- match record_type:
79
+ match record.type:
39
80
  case RecordTypeChoices.CNAME:
40
81
  _validate_idn(rr.target)
41
82
  validate_domain_name(
@@ -36,7 +36,7 @@ class NameServerListView(generic.ObjectListView):
36
36
 
37
37
 
38
38
  class NameServerView(generic.ObjectView):
39
- queryset = NameServer.objects.all().prefetch_related("zones")
39
+ queryset = NameServer.objects.prefetch_related("zones")
40
40
 
41
41
  def get_extra_context(self, request, instance):
42
42
  name = dns_name.from_text(instance.name)
@@ -85,7 +85,7 @@ class NameServerContactsView(ObjectContactsView):
85
85
 
86
86
  @register_model_view(NameServer, "zones")
87
87
  class NameServerZoneListView(generic.ObjectChildrenView):
88
- queryset = NameServer.objects.all().prefetch_related("zones")
88
+ queryset = NameServer.objects.prefetch_related("zones")
89
89
  child_model = Zone
90
90
  table = ZoneTable
91
91
  filterset = ZoneFilterSet
@@ -105,7 +105,7 @@ class NameServerZoneListView(generic.ObjectChildrenView):
105
105
 
106
106
  @register_model_view(NameServer, "soa_zones")
107
107
  class NameServerSOAZoneListView(generic.ObjectChildrenView):
108
- queryset = NameServer.objects.all().prefetch_related("zones_soa")
108
+ queryset = NameServer.objects.prefetch_related("zones_soa")
109
109
  child_model = Zone
110
110
  table = ZoneTable
111
111
  filterset = ZoneFilterSet