Compare commits

...
6 Commits
Author SHA1 Message Date
Sven Luthi 240f1293ae Bump to version v1.5.9-beta 2026-09-02 12:28:17 +02:00
sven a3bc64df62 Upgrade models to full NetboxModel to allow UI/API interaction:
modified:   netbox_dns_bridge/models.py
deleted:    netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
new file:   netbox_dns_bridge/migrations/0003_full_netbox_models.py
This assumes the migration starts at v1.5.8.

Adjust model access and function calls to match new models:
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py

Update UI elements and permission interactions:
modified:   netbox_dns_bridge/filtersets.py
modified:   netbox_dns_bridge/forms.py
modified:   netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/navigation.py
modified:   netbox_dns_bridge/tables.py
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html
modified:   netbox_dns_bridge/urls.py
modified:   netbox_dns_bridge/views.py
2026-09-02 12:08:27 +02:00
sven a3f53767e4 Move from hard-coded CatalogZone SOA values (refresh, retry, expire and minimum)
to DB driven to permit user modification:

modified:   netbox_dns_bridge/models.py
  Add 4 cols for values.

modified:   netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
  Adjust migration. This expects that the last migration the user has is 0002 (<=v1.5.8)

modified:   netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
  Update to use new db values for SOA creation.

Change UI to permit editing for SOA values and disable member identifier editing.
modified:   netbox_dns_bridge/forms.py
modified:   netbox_dns_bridge/navigation.py
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_edit.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_list.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html
modified:   netbox_dns_bridge/tables.py
modified:   netbox_dns_bridge/urls.py
modified:   netbox_dns_bridge/views.py
modified:   netbox_dns_bridge/filtersets.py
  GUI components are entirely AI generated. Plumbing also with AI assist but code verified.
2026-09-02 12:05:21 +02:00
sven 69781e2b08 renamed: netbox_dns_bridge/migrations/0004_catalogzone_and_per_zone_identifier.py -> netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
deleted:    netbox_dns_bridge/migrations/0003_integerkeyvaluesetting_key_unique.py

Migration 3 not required since model is being replaced anyway. Removed.
2026-09-02 12:03:21 +02:00
sven 6d4fb82ac5 modified: CHANGELOG.md
new file:   netbox_dns_bridge/jobs/__init__.py
renamed:    netbox_dns_bridge/signals/notify_handler.py -> netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Restructuring
2026-09-02 12:02:55 +02:00
sven 6be7359c27 Replace global IntegerKeyValueSetting with per-view CatalogZone model
Track the catalog zone SOA serial per view instead of a single global value.
The new CatalogZone model has a 1:1 FK to netbox_dns.models.View and its own
soa_serial field; each view's catalog zone now increments independently. The
existing global serial is preserved during migration by copying it into every
view's new CatalogZone row.

Change CatalogZoneMemberIdentifier.name from globally unique to unique per
catalog zone (UniqueConstraint(name, catalog_zone)) and add a catalog_zone FK
to enforce it.

Remove the dns-settings management command, which was a generic interface over
the now-removed IntegerKeyValueSetting model.

Increment both catalog zone serials when a zone moves between views: the old
view's catalog zone loses a member and the new view's gains one, so both must
be bumped. Also refresh the member identifier's catalog_zone FK on a view move
so it does not go stale. The pre_save handler now captures _old_view_id and the
post_save handler detects view changes, increments the old view's serial inside
on_commit (before the new view's), and re-creates the identifier.

AI generated, code manually verified.
2026-09-02 12:01:54 +02:00
24 changed files with 938 additions and 365 deletions

No files matched your search

+23
View File
@@ -1,4 +1,27 @@
## Unreleased ## Unreleased
- Move `notify_handler` from `signals/` to `jobs/` and rename to `notify`.
- Rename `SendClientDNSNotify` to `SendClientNotify`. It now sends a single NOTIFY
for the zone only; catalog zone entries (`catz`, `{view}.catz`) have been removed.
- Catalog zone NOTIFY is now scheduled by `increment_soa_serial` via
`schedule_catalog_zone_notify`, which enqueues `SendClientNotify` for `catz` and
`{view}.catz` — no separate job class needed.
- Extract a shared `_send_notify` helper used by both `SendClientNotify` and
`SendNSNotify` for message building and the send loop.
- Move all NOTIFY scheduling gates (`notify_clients`, `notify_ns_all_zones`, custom
field) into the scheduling functions. The signal handler calls the scheduling
functions unconditionally; each function decides whether to enqueue based on its
own settings gate.
- Replace the global `IntegerKeyValueSetting` model (single `catalog-zone-soa-serial`
value shared by all catalog zones) with a new `CatalogZone` model that has a 1:1
foreign key to `netbox_dns.models.View` and its own `soa_serial` field. Each view's
catalog zone now tracks and increments its SOA serial independently. Existing serials
are preserved during migration by copying the old global value into every view's new
`CatalogZone` row.
- Change the `CatalogZoneMemberIdentifier.name` uniqueness from globally unique to
unique per catalog zone (`UniqueConstraint(name, catalog_zone)`). A `catalog_zone`
foreign key has been added to the identifier model to enforce this.
- Remove the `dns-settings` management command, which was a generic get/set/list
interface over `IntegerKeyValueSetting` (now removed).
- Defer the catalog zone SOA serial increment on zone deletion to `transaction.on_commit`, matching - Defer the catalog zone SOA serial increment on zone deletion to `transaction.on_commit`, matching
the create/modify path. Previously the increment ran immediately at `post_delete` time, so a the create/modify path. Previously the increment ran immediately at `post_delete` time, so a
rollback of the delete transaction would have left the catalog serial bumped anyway. rollback of the delete transaction would have left the catalog serial bumped anyway.
+1 -1
View File
@@ -1,7 +1,7 @@
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from django.conf import settings from django.conf import settings
__version__ = "1.5.8" __version__ = "1.5.9-beta"
class DNSBridgeConfig(PluginConfig): class DNSBridgeConfig(PluginConfig):
name = "netbox_dns_bridge" name = "netbox_dns_bridge"
+39 -9
View File
@@ -3,27 +3,57 @@ from django.db.models import Q
import netbox_dns.models import netbox_dns.models
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
from netbox_dns.choices import ZoneStatusChoices
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class ZoneFilterSet(NetBoxModelFilterSet): class CatalogZoneFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter( view = django_filters.ModelMultipleChoiceFilter(
field_name="view", field_name="view",
queryset=netbox_dns.models.View.objects.all(), queryset=netbox_dns.models.View.objects.all(),
) )
status = django_filters.MultipleChoiceFilter(
choices=ZoneStatusChoices, class Meta:
null_value=None, model = CatalogZone
fields = ("id", "view")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(Q(view__name__icontains=value))
class CatalogZoneMemberFilterSet(NetBoxModelFilterSet):
catalog_zone = django_filters.ModelMultipleChoiceFilter(
field_name="catalog_zone",
queryset=CatalogZone.objects.all(),
) )
class Meta: class Meta:
model = netbox_dns.models.Zone model = CatalogZoneMember
fields = ("id", "name", "view", "status", "catz_identifier") fields = ("id", "catalog_zone", "zone")
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) Q(name__icontains=value) | Q(zone__name__icontains=value)
| Q(catz_identifier__name__icontains=value) )
class SeenTransferClientFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter(
field_name="view",
queryset=netbox_dns.models.View.objects.all(),
)
class Meta:
model = SeenTransferClient
fields = ("id", "view", "source_ip")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(source_ip__icontains=value) | Q(view__name__icontains=value)
) )
+48 -5
View File
@@ -2,20 +2,63 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import netbox_dns.models import netbox_dns.models
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
from netbox_dns.choices import ZoneStatusChoices
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneForm(NetBoxModelForm):
soa_serial = forms.IntegerField(label=_("SOA Serial"))
soa_refresh = forms.IntegerField(label=_("Refresh"))
soa_retry = forms.IntegerField(label=_("Retry"))
soa_expire = forms.IntegerField(label=_("Expire"))
soa_minimum = forms.IntegerField(label=_("Minimum"))
view = forms.ModelChoiceField(
queryset=netbox_dns.models.View.objects.all(),
disabled=True,
required=False,
label=_("View"),
)
class Meta:
model = CatalogZone
fields = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
class CatalogZoneFilterForm(NetBoxModelFilterSetForm): class CatalogZoneFilterForm(NetBoxModelFilterSetForm):
model = netbox_dns.models.Zone model = CatalogZone
q = forms.CharField(required=False, label=_("Search")) q = forms.CharField(required=False, label=_("Search"))
view = DynamicModelMultipleChoiceField( view = DynamicModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(), queryset=netbox_dns.models.View.objects.all(),
required=False, required=False,
) )
status = forms.MultipleChoiceField(
choices=ZoneStatusChoices,
class CatalogZoneMemberFilterForm(NetBoxModelFilterSetForm):
model = CatalogZoneMember
q = forms.CharField(required=False, label=_("Search"))
catalog_zone = forms.ModelMultipleChoiceField(
queryset=CatalogZone.objects.all(),
required=False,
)
class SeenTransferClientFilterForm(NetBoxModelFilterSetForm):
model = SeenTransferClient
q = forms.CharField(required=False, label=_("Search"))
view = forms.ModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(),
required=False, required=False,
) )
View File
Whitespace-only changes.
@@ -12,11 +12,12 @@ from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record from netbox_dns.models import View, Zone, Record
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns_bridge.models import SeenTransferClients, IntegerKeyValueSetting from netbox_dns_bridge.models import SeenTransferClient, CatalogZone
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def _load_tsig_key(view_name: str) -> dns.tsig.Key: def _load_tsig_key(view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {}) tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys: if view_name not in tsig_keys:
@@ -41,7 +42,54 @@ def _load_tsig_key(view_name: str) -> dns.tsig.Key:
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm) return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
class SendClientDNSNotify(JobRunner): def _send_notify(
zone_name,
soa_serial,
targets,
tsig_key,
view_name,
notify_over_tcp,
port,
log_prefix="NOTIFY",
soa_refresh=60,
soa_retry=10,
soa_expire=1209600,
soa_minimum=0,
):
"""Build a DNS NOTIFY message for *zone_name* and send it to every target IP."""
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} {soa_refresh} {soa_retry} {soa_expire} {soa_minimum}",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for target in targets:
try:
if notify_over_tcp:
LOGGER.debug(f"Sending {log_prefix} over TCP")
dns.query.tcp(msg, target, port=port, timeout=2)
else:
LOGGER.debug(f"Sending {log_prefix} over UDP")
dns.query.udp(msg, target, port=port, timeout=2)
LOGGER.info(
f"{log_prefix} sent: {target} {view_name}/{fqdn} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"{log_prefix} failed: {target}:{port}"
f" {view_name}/{fqdn} {soa_serial}: {e}"
)
class SendClientNotify(JobRunner):
class Meta: class Meta:
name = "Send DNS NOTIFY" name = "Send DNS NOTIFY"
@@ -49,62 +97,34 @@ class SendClientDNSNotify(JobRunner):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False) notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_client_port", 53) port = SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"] view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
soa_refresh = kwargs.get("soa_refresh", 60)
soa_retry = kwargs.get("soa_retry", 10)
soa_expire = kwargs.get("soa_expire", 1209600)
soa_minimum = kwargs.get("soa_minimum", 0)
try: try:
catz_serial = IntegerKeyValueSetting.objects.get(
key="catalog-zone-soa-serial"
).value
zones = [
(kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial),
(f"{view_name}.catz", catz_serial),
]
view = View.objects.get(name=view_name) view = View.objects.get(name=view_name)
tsig_key = _load_tsig_key(view_name) tsig_key = _load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24) cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = SeenTransferClients.objects.filter( clients = list(
SeenTransferClient.objects.filter(
view=view, last_seen__gte=seen_cutoff view=view, last_seen__gte=seen_cutoff
).values_list("source_ip", flat=True)
) )
for zone_name, soa_serial in zones: _send_notify(
zone_name = dns.name.from_text(zone_name, dns.name.root).to_text() zone_name, soa_serial, clients, tsig_key,
msg = dns.message.make_query(zone_name, dns.rdatatype.SOA) view_name, notify_over_tcp, port, log_prefix="NOTIFY",
msg.flags |= dns.flags.AA # = 0 soa_refresh=soa_refresh, soa_retry=soa_retry,
msg.set_opcode(dns.opcode.NOTIFY) soa_expire=soa_expire, soa_minimum=soa_minimum,
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(zone_name, 0, soa_rdata)
msg.answer.append(soa_rrset)
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for client in clients:
try:
if notify_over_tcp:
LOGGER.debug("Sending NOTIFY over TCP")
dns.query.tcp(msg, client.source_ip, port=port, timeout=2)
else:
LOGGER.debug("Sending NOTIFY over UDP")
dns.query.udp(msg, client.source_ip, port=port, timeout=2)
LOGGER.info(
f"NOTIFY sent: {client.source_ip} {view_name}/{zone_name} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"NOTIFY failed: {client.source_ip}:{port}"
f" {view_name}/{zone_name} {soa_serial}: {e}"
) )
except IntegerKeyValueSetting.DoesNotExist as e: except View.DoesNotExist as e:
LOGGER.error(f"Failed to get catalog zone serial: {e}") LOGGER.error(f"Failed to get view for NOTIFY: {e}")
except OperationalError as e: except OperationalError as e:
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}") LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
@@ -114,10 +134,10 @@ class SendClientDNSNotify(JobRunner):
class SendNSNotify(JobRunner): class SendNSNotify(JobRunner):
"""Send a NOTIFY straight to a zone's own nameservers. """Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendClientDNSNotify`` (which notifies clients that previously queried the Unlike ``SendClientNotify`` (which notifies clients that previously queried
transfer endpoint), this job resolves the addresses of the zone's configured the transfer endpoint), this job resolves the addresses of the zone's
nameservers from NetBox DNS itself and notifies each of them so they pick up configured nameservers from NetBox DNS itself and notifies each of them so
the changed zone quickly. they pick up the changed zone quickly.
""" """
class Meta: class Meta:
@@ -154,32 +174,9 @@ class SendNSNotify(JobRunner):
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}") LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
return return
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text() _send_notify(
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA) zone_name, soa_serial, ns_targets, tsig_key,
msg.flags |= dns.flags.AA view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for ip in ns_targets:
try:
if notify_over_tcp:
LOGGER.debug("Sending NS NOTIFY over TCP")
dns.query.tcp(msg, ip, port=port, timeout=2)
else:
LOGGER.debug("Sending NS NOTIFY over UDP")
dns.query.udp(msg, ip, port=port, timeout=2)
LOGGER.info(f"NS NOTIFY sent: {ip} {view_name}/{fqdn} {soa_serial}")
except Exception as e:
LOGGER.error(
f"NS NOTIFY failed: {ip} {view_name}/{fqdn} {soa_serial}: {e}"
) )
@@ -237,12 +234,33 @@ def _resolve_zone_nameserver_ips(zone: Zone) -> list:
return ips return ips
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
def schedule_client_notify(zone: Zone): def schedule_client_notify(zone: Zone):
if not SETTINGS.get("notify_clients", False):
return
soa_serial = _get_soa_serial(zone) soa_serial = _get_soa_serial(zone)
if soa_serial is None: if soa_serial is None:
return return
SendClientDNSNotify.enqueue( SendClientNotify.enqueue(
zone_name=zone.name, zone_name=zone.name,
view_name=zone.view.name, view_name=zone.view.name,
soa_serial=soa_serial, soa_serial=soa_serial,
@@ -250,6 +268,9 @@ def schedule_client_notify(zone: Zone):
def schedule_ns_notify(zone: Zone): def schedule_ns_notify(zone: Zone):
if not _notify_ns_enabled(zone):
return
soa_serial = _get_soa_serial(zone) soa_serial = _get_soa_serial(zone)
if soa_serial is None: if soa_serial is None:
return return
@@ -260,3 +281,38 @@ def schedule_ns_notify(zone: Zone):
view_name=zone.view.name, view_name=zone.view.name,
soa_serial=soa_serial, soa_serial=soa_serial,
) )
def schedule_catalog_zone_notify(view: View):
if not SETTINGS.get("notify_clients", False):
return
try:
catalog_zone = view.catalog_zone
except CatalogZone.DoesNotExist:
return
serial = catalog_zone.soa_serial
soa_refresh = catalog_zone.soa_refresh
soa_retry = catalog_zone.soa_retry
soa_expire = catalog_zone.soa_expire
soa_minimum = catalog_zone.soa_minimum
SendClientNotify.enqueue(
zone_name="catz",
soa_serial=serial,
view_name=view.name,
soa_refresh=soa_refresh,
soa_retry=soa_retry,
soa_expire=soa_expire,
soa_minimum=soa_minimum,
)
SendClientNotify.enqueue(
zone_name=f"{view.name}.catz",
soa_serial=serial,
view_name=view.name,
soa_refresh=soa_refresh,
soa_retry=soa_retry,
soa_expire=soa_expire,
soa_minimum=soa_minimum,
)
@@ -1,51 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from netbox_dns_bridge.models import IntegerKeyValueSetting
class Command(BaseCommand):
help = "Get or set integer key-value settings"
def add_arguments(self, parser):
parser.add_argument(
"action", choices=["get", "set", "list"], help="Action to perform"
)
parser.add_argument("key", nargs="?", help="Setting key")
parser.add_argument(
"value", nargs="?", type=int, help="Value to set (required for 'set')"
)
def handle(self, *args, **options):
action = options["action"]
key = options.get("key")
value = options.get("value")
if action == "list":
settings = IntegerKeyValueSetting.objects.all()
if not settings.exists():
self.stdout.write("No settings found.")
return
for setting in settings:
self.stdout.write(str(setting))
elif action == "get":
if not key:
raise CommandError("A key is required for GET")
try:
setting = IntegerKeyValueSetting.objects.get(key=key)
self.stdout.write(f"{setting.key}: {setting.value}")
except IntegerKeyValueSetting.DoesNotExist:
raise CommandError(f"No setting was found for {key}")
elif action == "set":
if not key:
raise CommandError("A setting is required for SET command")
if value is None:
raise CommandError("A value is required for SET command")
try:
setting = IntegerKeyValueSetting.objects.get(key=key)
old_value = setting.value
setting.value = value
setting.save()
self.stdout.write(f"Updated {key} from {old_value} to {value}")
except IntegerKeyValueSetting.DoesNotExist:
raise CommandError(f"No setting found for {key}")
@@ -3,8 +3,9 @@ import dns.zone
import dns.rdatatype import dns.rdatatype
import dns.rdataclass import dns.rdataclass
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns.models import Zone as NBZone from netbox_dns.models import Zone as NBZone, View as NBView
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember
from netbox_dns_bridge.jobs import notify
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.db import transaction, close_old_connections, OperationalError from django.db import transaction, close_old_connections, OperationalError
@@ -13,36 +14,41 @@ from django.db import transaction, close_old_connections, OperationalError
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
def increment_soa_serial() -> int: def increment_soa_serial(view: NBView) -> int:
with transaction.atomic(): with transaction.atomic():
try: try:
( (
soa_serial_obj, soa_serial_obj,
_, _,
) = IntegerKeyValueSetting.objects.select_for_update().get_or_create( ) = CatalogZone.objects.select_for_update().get_or_create(
key="catalog-zone-soa-serial", view=view,
defaults={"value": 1}, defaults={"soa_serial": 1},
) )
new_soa_serial = soa_serial_obj.value + 1 new_soa_serial = soa_serial_obj.soa_serial + 1
if new_soa_serial > 0xFFFFFFFF: if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning( LOGGER.warning(
f"Catalog serial {soa_serial_obj.value} reached max — wrapping back to 1" f"Catalog serial {soa_serial_obj.soa_serial} reached max — wrapping back to 1"
) )
new_soa_serial = 1 new_soa_serial = 1
soa_serial_obj.value = new_soa_serial soa_serial_obj.soa_serial = new_soa_serial
soa_serial_obj.save() soa_serial_obj.save()
soa_serial_obj.refresh_from_db() soa_serial_obj.refresh_from_db()
LOGGER.debug( LOGGER.debug(
f"Catalog zone SOA serial number is now {soa_serial_obj.value}" f"Catalog zone SOA serial for view '{view.name}' "
f"is now {soa_serial_obj.soa_serial}"
) )
return soa_serial_obj.value serial = soa_serial_obj.soa_serial
except OperationalError as e: except OperationalError as e:
LOGGER.error( LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
) )
close_old_connections() close_old_connections()
return None
notify.schedule_catalog_zone_notify(view)
return serial
def create_zone(name, view_name) -> dns.zone.Zone: def create_zone(name, view_name) -> dns.zone.Zone:
@@ -57,7 +63,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# get zones from netbox # get zones from netbox
nb_zones = NBZone.objects.filter( nb_zones = NBZone.objects.filter(
view__name=view_name, active=True view__name=view_name, active=True
).select_related("catz_identifier") ).select_related("catalog_zone_member")
ptr_base = dns.name.from_text("zones", origin) ptr_base = dns.name.from_text("zones", origin)
@@ -67,18 +73,21 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# Create PTR record # Create PTR record
try: try:
catz_identifier = nb_zone.catz_identifier member = nb_zone.catalog_zone_member
except NBZone.catz_identifier.RelatedObjectDoesNotExist: except NBZone.catalog_zone_member.RelatedObjectDoesNotExist:
catz_identifier = CatalogZoneMemberIdentifier.objects.create( catalog_zone = _get_or_create_catalog_zone(nb_zone.view)
zone=nb_zone, name=_generate_member_identifier() member = CatalogZoneMember.objects.create(
zone=nb_zone,
name=_generate_member_name(),
catalog_zone=catalog_zone,
) )
p_name = catz_identifier.name p_name = member.name
ptr_name = dns.name.from_text(p_name, ptr_base) ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin): if not ptr_name.is_subdomain(origin):
raise ValueError( raise ValueError(
f"Catalog zone member identifier {ptr_name.to_text()} not a subdomain" f"Catalog zone member {ptr_name.to_text()} not a subdomain"
) )
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
@@ -100,7 +109,8 @@ def create_zone(name, view_name) -> dns.zone.Zone:
zone_root_node = zone.find_node(origin, create=True) zone_root_node = zone.find_node(origin, create=True)
# SOA record # SOA record
soa_rdataset = _create_soa_rdataset() view = NBView.objects.get(name=view_name)
soa_rdataset = _create_soa_rdataset(view)
zone_root_node.rdatasets.append(soa_rdataset) zone_root_node.rdatasets.append(soa_rdataset)
# NS record # NS record
@@ -122,27 +132,38 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return None return None
def _generate_member_identifier() -> str: def _generate_member_name() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8") return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: NBZone) -> None: def _get_or_create_catalog_zone(view: NBView) -> CatalogZone:
catalog_zone, _ = CatalogZone.objects.get_or_create(
view=view, defaults={"soa_serial": 1}
)
return catalog_zone
def update_member(zone: NBZone) -> None:
try: try:
CatalogZoneMemberIdentifier.objects.update_or_create( catalog_zone = _get_or_create_catalog_zone(zone.view)
CatalogZoneMember.objects.update_or_create(
zone=zone, zone=zone,
defaults={"name": _generate_member_identifier()}, defaults={
"name": _generate_member_name(),
"catalog_zone": catalog_zone,
},
) )
except OperationalError as e: except OperationalError as e:
LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}") LOGGER.error(f"ERROR: Failed to update Catalog Zone member: {e}")
close_old_connections() close_old_connections()
def _create_soa_rdataset() -> dns.rdataset: def _create_soa_rdataset(view: NBView) -> dns.rdataset:
try: try:
serial_obj, _ = IntegerKeyValueSetting.objects.get_or_create( serial_obj, _ = CatalogZone.objects.get_or_create(
key="catalog-zone-soa-serial", defaults={"value": 1} view=view, defaults={"soa_serial": 1}
) )
serial = serial_obj.value serial = serial_obj.soa_serial
# SOA Record components # SOA Record components
ttl = 0 ttl = 0
@@ -153,7 +174,9 @@ def _create_soa_rdataset() -> dns.rdataset:
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
rclass, rclass,
rtype, rtype,
f"invalid. invalid. {serial} 60 10 1209600 0", f"invalid. invalid. {serial} {serial_obj.soa_refresh} "
f"{serial_obj.soa_retry} {serial_obj.soa_expire} "
f"{serial_obj.soa_minimum}",
) )
# Create Rdataset and add the RDATA to it # Create Rdataset and add the RDATA to it
@@ -15,7 +15,7 @@ from django.db import close_old_connections, OperationalError
from django.conf import settings from django.conf import settings
from netbox_dns.models import Zone, View from netbox_dns.models import Zone, View
from . import catalog_zone_manager as catzm from . import catalog_zone_manager as catzm
from netbox_dns_bridge.models import SeenTransferClients from netbox_dns_bridge.models import SeenTransferClient
from netbox_dns_bridge.utils import build_dns_zone, get_logger from netbox_dns_bridge.utils import build_dns_zone, get_logger
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
@@ -77,7 +77,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
def _track_seen_client(self, source_ip: str, view: View) -> None: def _track_seen_client(self, source_ip: str, view: View) -> None:
if SETTINGS.get("notify_clients", False): if SETTINGS.get("notify_clients", False):
try: try:
SeenTransferClients.objects.update_or_create( SeenTransferClient.objects.update_or_create(
source_ip=source_ip, source_ip=source_ip,
view=view, view=view,
defaults={"last_seen": timezone.now()}, defaults={"last_seen": timezone.now()},
@@ -0,0 +1,233 @@
import taggit.managers
import utilities.json
import django.db.models.deletion
from django.db import migrations, models
def copy_serials_and_link_members(apps, schema_editor):
"""Seed one CatalogZone per view from the old global serial and link members."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Preserve the current global serial; default to 1 when never initialized.
seed_serial = 1
kv = IntegerKeyValueSetting.objects.filter(key="catalog-zone-soa-serial").first()
if kv is not None:
seed_serial = kv.value
# Create a CatalogZone for every view that currently has at least one
# member, seeding each with the old global serial.
view_ids = set(
CatalogZoneMember.objects.exclude(
zone__view__isnull=True
).values_list("zone__view_id", flat=True)
)
view_id_to_catalog_zone = {}
for view_id in view_ids:
cz, _ = CatalogZone.objects.get_or_create(
view_id=view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[view_id] = cz
# Link each member to the CatalogZone matching its zone's view.
for member in CatalogZoneMember.objects.select_related("zone__view"):
cz = view_id_to_catalog_zone.get(member.zone.view_id)
if cz is None:
# Defensive: member existed without a resolvable view. Create
# the CatalogZone on demand so the NOT NULL constraint can be set.
cz, _ = CatalogZone.objects.get_or_create(
view_id=member.zone.view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[member.zone.view_id] = cz
member.catalog_zone = cz
member.save(update_fields=["catalog_zone"])
def reverse_migration(apps, schema_editor):
"""Best-effort reverse: restore the KV row and null the FK."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Pick a representative serial (first catalog zone) to write back.
first = CatalogZone.objects.order_by("pk").first()
serial = first.soa_serial if first is not None else 1
IntegerKeyValueSetting.objects.update_or_create(
key="catalog-zone-soa-serial", defaults={"value": serial}
)
CatalogZoneMember.objects.update(catalog_zone=None)
class Migration(migrations.Migration):
dependencies = [
("extras", "0140_imageattachment_image_size"),
(
"netbox_dns",
"0032_record_expiration_date",
),
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
# --- CatalogZone (full NetBoxModel, born with all fields) ---
migrations.CreateModel(
name="CatalogZone",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("soa_serial", models.IntegerField(default=1)),
("soa_refresh", models.IntegerField(default=60)),
("soa_retry", models.IntegerField(default=10)),
("soa_expire", models.IntegerField(default=1209600)),
("soa_minimum", models.IntegerField(default=0)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
(
"custom_field_data",
models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
(
"tags",
taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
(
"view",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone",
to="netbox_dns.view",
),
),
],
options={
"ordering": ("view__name",),
},
),
# --- CatalogZoneMemberIdentifier → CatalogZoneMember (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="CatalogZoneMemberIdentifier",
new_name="CatalogZoneMember",
),
migrations.AddField(
model_name="catalogzonemember",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="catalogzonemember",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
migrations.AlterField(
model_name="catalogzonemember",
name="name",
field=models.CharField(max_length=26),
),
migrations.AlterField(
model_name="catalogzonemember",
name="zone",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone_member",
to="netbox_dns.zone",
),
),
migrations.AddField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.RunPython(
copy_serials_and_link_members,
reverse_code=reverse_migration,
),
migrations.AlterField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.AddConstraint(
model_name="catalogzonemember",
constraint=models.UniqueConstraint(
fields=("name", "catalog_zone"),
name="unique_name_per_catalog_zone",
),
),
migrations.DeleteModel(
name="IntegerKeyValueSetting",
),
# --- SeenTransferClients → SeenTransferClient (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="SeenTransferClients",
new_name="SeenTransferClient",
),
migrations.AddField(
model_name="seentransferclient",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="seentransferclient",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
]
@@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="integerkeyvaluesetting",
name="key",
field=models.CharField(max_length=64, unique=True),
),
]
+36 -13
View File
@@ -2,42 +2,63 @@ from django.db import models
# import netbox.models # import netbox.models
import netbox_dns.models import netbox_dns.models
from netbox.models import NetBoxModel
class IntegerKeyValueSetting(models.Model): class CatalogZone(NetBoxModel):
_netbox_private = True view = models.OneToOneField(
to=netbox_dns.models.View,
on_delete=models.CASCADE,
related_name="catalog_zone",
)
soa_serial = models.IntegerField(default=1)
soa_refresh = models.IntegerField(default=60)
soa_retry = models.IntegerField(default=10)
soa_expire = models.IntegerField(default=1209600)
soa_minimum = models.IntegerField(default=0)
key = models.CharField(max_length=64, unique=True) class Meta:
value = models.IntegerField() ordering = ("view__name",)
verbose_name = "Catalog Zone"
verbose_name_plural = "Catalog Zones"
def __str__(self): def __str__(self):
return f"{self.key}: {str(self.value)}" return f"{self.view.name}: {self.soa_serial}"
class CatalogZoneMemberIdentifier(models.Model): class CatalogZoneMember(NetBoxModel):
_netbox_private = True
name = models.CharField( name = models.CharField(
max_length=26, max_length=26,
unique=True,
) )
zone = models.OneToOneField( zone = models.OneToOneField(
to=netbox_dns.models.Zone, to=netbox_dns.models.Zone,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="catz_identifier", related_name="catalog_zone_member",
)
catalog_zone = models.ForeignKey(
to=CatalogZone,
on_delete=models.CASCADE,
related_name="members",
) )
class Meta: class Meta:
ordering = ("name",) ordering = ("name",)
verbose_name = "Catalog Zone Member"
verbose_name_plural = "Catalog Zone Members"
constraints = [
models.UniqueConstraint(
fields=["name", "catalog_zone"],
name="unique_name_per_catalog_zone",
),
]
def __str__(self): def __str__(self):
return self.name return self.name
class SeenTransferClients(models.Model): class SeenTransferClient(NetBoxModel):
_netbox_private = True
source_ip = models.GenericIPAddressField() source_ip = models.GenericIPAddressField()
last_seen = models.DateTimeField() last_seen = models.DateTimeField()
view = models.ForeignKey( view = models.ForeignKey(
@@ -51,6 +72,8 @@ class SeenTransferClients(models.Model):
class Meta: class Meta:
ordering = ("source_ip",) ordering = ("source_ip",)
verbose_name = "Seen Transfer Client"
verbose_name_plural = "Seen Transfer Clients"
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["source_ip", "view"], fields=["source_ip", "view"],
+21 -10
View File
@@ -1,24 +1,35 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuItem from netbox.plugins import PluginMenu, PluginMenuItem
information_menu_item = PluginMenuItem( catalog_zones_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:catz", link="plugins:netbox_dns_bridge:catalogzone_list",
link_text=_("Catalog Zones"), link_text=_("Catalog Zones"),
permissions=[], permissions=["netbox_dns_bridge.view_catalogzone"],
staff_only=True,
) )
notify_menu_item = PluginMenuItem( catalog_zone_members_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:notify", link="plugins:netbox_dns_bridge:catalogzonemember_list",
link_text=_("Notify"), link_text=_("Catalog Zone Members"),
permissions=[], permissions=["netbox_dns_bridge.view_catalogzonemember"],
staff_only=True, )
seen_transfer_clients_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:seentransferclient_list",
link_text=_("Clients"),
permissions=["netbox_dns_bridge.view_seentransferclient"],
) )
menu = PluginMenu( menu = PluginMenu(
label=_("DNS Bridge"), label=_("DNS Bridge"),
groups=( groups=(
(_("Information"), (information_menu_item, notify_menu_item)), (
_("Catalog Zone"),
(catalog_zones_menu_item, catalog_zone_members_menu_item),
),
(
_("Notify"),
(seen_transfer_clients_menu_item,),
),
), ),
icon_class="mdi mdi-bridge", icon_class="mdi mdi-bridge",
) )
+38 -38
View File
@@ -1,28 +1,29 @@
from django.db.models.signals import pre_save, post_save, post_delete from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.db import transaction from django.db import transaction
from django.conf import settings
from netbox.context import current_request from netbox.context import current_request
from netbox_dns.models import Zone from netbox_dns.models import Zone, View
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm
from . import notify_handler from netbox_dns_bridge.jobs import notify
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@receiver(pre_save, sender=Zone) @receiver(pre_save, sender=Zone)
def zone_pre_save(sender, instance, **kwargs): def zone_pre_save(sender, instance, **kwargs):
zone = instance zone = instance
if zone.pk: if zone.pk:
try: try:
old = sender.objects.only("name", "soa_serial").get(pk=zone.pk) old = sender.objects.only("name", "soa_serial", "view_id").get(pk=zone.pk)
zone._old_name = old.name zone._old_name = old.name
zone._old_soa_serial = old.soa_serial zone._old_soa_serial = old.soa_serial
zone._old_view_id = old.view_id
except sender.DoesNotExist: except sender.DoesNotExist:
zone._old_name = None zone._old_name = None
zone._old_soa_serial = None zone._old_soa_serial = None
zone._old_view_id = None
else: else:
zone._old_name = None zone._old_name = None
zone._old_soa_serial = None zone._old_soa_serial = None
zone._old_view_id = None
@receiver(post_save, sender=Zone) @receiver(post_save, sender=Zone)
@@ -32,6 +33,8 @@ def zone_post_save(sender, instance, created, **kwargs):
# Quit if this is not the first signal in this session for this zone. # Quit if this is not the first signal in this session for this zone.
try: try:
request = current_request.get() request = current_request.get()
if request is None:
raise LookupError
handled_zones = getattr(request, "_handled_zones", set()) handled_zones = getattr(request, "_handled_zones", set())
if zone.pk in handled_zones: if zone.pk in handled_zones:
return return
@@ -46,51 +49,48 @@ def zone_post_save(sender, instance, created, **kwargs):
new_soa_serial = getattr(zone, "soa_serial", None) new_soa_serial = getattr(zone, "soa_serial", None)
soa_serial_changed = created or (old_soa_serial != new_soa_serial) soa_serial_changed = created or (old_soa_serial != new_soa_serial)
# On commit, increment soa serial and if notify is enabled, schedule bg job. # Detect whether the zone moved between views. When it does, the old
# view's catalog zone loses a member and the new view's gains one, so
# both serials must be incremented.
old_view_id = getattr(zone, "_old_view_id", None)
view_changed = (
not created
and old_view_id is not None
and old_view_id != zone.view_id
)
# On commit, increment soa serial and schedule notify jobs. All settings
# gating happens inside the scheduling functions.
def _on_commit(): def _on_commit():
catzm.increment_soa_serial() if view_changed:
try:
old_view = View.objects.get(pk=old_view_id)
catzm.increment_soa_serial(old_view)
except View.DoesNotExist:
pass
catzm.increment_soa_serial(zone.view)
if SETTINGS.get("notify_clients", False): if soa_serial_changed:
notify_handler.schedule_client_notify(zone) notify.schedule_client_notify(zone)
notify.schedule_ns_notify(zone)
# Send a NOTIFY to the zone's own nameservers when its SOA serial changed
# and the feature is enabled — globally or per zone via a custom field.
if soa_serial_changed and _notify_ns_enabled(zone):
notify_handler.schedule_ns_notify(zone)
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
if created: if created:
# If zone was created, create catz member identifier record for this zone. # If zone was created, create the catalog zone member record for this zone.
catzm.update_member_identifier(zone) catzm.update_member(zone)
else: else:
# Check if zone name has changed and change the catz member identifier if so. # Re-create the catalog zone member when the zone name or its view
# changed. A view move leaves the member pointing at the wrong
# catalog zone, so it must be refreshed.
old_name = getattr(zone, "_old_name", None) old_name = getattr(zone, "_old_name", None)
if old_name and zone.name != old_name: if (old_name and zone.name != old_name) or view_changed:
catzm.update_member_identifier(zone) catzm.update_member(zone)
@receiver(post_delete, sender=Zone) @receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs): def zone_post_delete(sender, instance, **kwargs):
def _on_commit(): def _on_commit():
catzm.increment_soa_serial() catzm.increment_soa_serial(instance.view)
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
+65 -11
View File
@@ -1,24 +1,78 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import netbox_dns.models
from netbox.tables import NetBoxTable from netbox.tables import NetBoxTable
from netbox.tables.columns import ActionsColumn
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneMemberTable(NetBoxTable):
name = tables.Column(linkify=True)
zone = tables.Column(
accessor="zone__name",
verbose_name=_("Zone"),
linkify=True,
)
view = tables.Column(
accessor="catalog_zone__view__name",
verbose_name=_("View"),
)
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = CatalogZoneMember
fields = ("name", "zone", "view")
default_columns = ("name", "zone", "view")
class CatalogZoneTable(NetBoxTable): class CatalogZoneTable(NetBoxTable):
name = tables.Column(linkify=True)
view = tables.Column( view = tables.Column(
accessor="view__name", accessor="view__name",
verbose_name=_("View"), verbose_name=_("View"),
linkify=True,
) )
status = tables.Column(verbose_name=_("Status")) soa_serial = tables.Column(verbose_name=_("SOA Serial"))
catz_identifier = tables.Column( soa_refresh = tables.Column(verbose_name=_("Refresh"))
accessor="catz_identifier__name", soa_retry = tables.Column(verbose_name=_("Retry"))
verbose_name=_("Catalog Zone Identifier"), soa_expire = tables.Column(verbose_name=_("Expire"))
default="—", soa_minimum = tables.Column(verbose_name=_("Minimum"))
)
actions = ActionsColumn(actions=("edit", "changelog"))
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = netbox_dns.models.Zone model = CatalogZone
fields = ("name", "view", "status", "catz_identifier") fields = (
default_columns = ("name", "view", "status", "catz_identifier") "view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
default_columns = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
class SeenTransferClientTable(NetBoxTable):
source_ip = tables.Column(linkify=True)
view = tables.Column(
accessor="view__name",
verbose_name=_("View"),
linkify=True,
)
last_seen = tables.DateTimeColumn(verbose_name=_("Last Seen"))
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = SeenTransferClient
fields = ("source_ip", "view", "last_seen")
default_columns = ("source_ip", "view", "last_seen")
@@ -0,0 +1,4 @@
{% extends 'generic/object_edit.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Catalog Zone" %}{% endblock %}
@@ -0,0 +1,4 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block title %}{% trans "Catalog Zones" %}{% endblock %}
@@ -0,0 +1,50 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone SOA" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "SOA Serial" %}</th>
<td>{{ object.soa_serial }}</td>
</tr>
<tr>
<th scope="row">{% trans "Refresh" %}</th>
<td>{{ object.soa_refresh }}</td>
</tr>
<tr>
<th scope="row">{% trans "Retry" %}</th>
<td>{{ object.soa_retry }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expire" %}</th>
<td>{{ object.soa_expire }}</td>
</tr>
<tr>
<th scope="row">{% trans "Minimum" %}</th>
<td>{{ object.soa_minimum }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone Member" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Zone" %}</th>
<td>{{ object.zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Catalog Zone" %}</th>
<td>{{ object.catalog_zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.catalog_zone.view|linkify }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,22 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block title %}{% trans "Catalog Zone Info" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone SOA Serial Number" %}</h5>
<div class="card-body">
{% if soa_serial is not None %}
<span>{{ soa_serial }}</span>
{% else %}
<span class="text-muted">{% trans "Not yet initialized." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}
@@ -1,37 +0,0 @@
{% extends 'base/layout.html' %}
{% block title %}DNS Bridge Notify{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="card mb-3">
<h5 class="card-header">Seen Transfer Clients</h5>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>Source IP</th>
<th>View</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{% for c in seen_clients %}
<tr>
<td>{{ c.source_ip }}</td>
<td>{{ c.view.name }}</td>
<td>{{ c.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-muted">No seen transfer clients.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Seen Transfer Client" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Source IP" %}</th>
<td>{{ object.source_ip }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Seen" %}</th>
<td>{{ object.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+43 -4
View File
@@ -1,8 +1,47 @@
from django.urls import path from django.urls import include, path
from .views import CatzOverviewView, NotifyOverviewView from utilities.urls import get_model_urls
# Import views so register_model_view decorators run.
from .views import ( # noqa: F401
CatalogZoneEditView,
CatalogZoneListView,
CatalogZoneMemberListView,
CatalogZoneMemberView,
CatalogZoneView,
SeenTransferClientListView,
SeenTransferClientView,
)
urlpatterns = [ urlpatterns = [
path("catz/", CatzOverviewView.as_view(), name="catz"), path(
path("notify/", NotifyOverviewView.as_view(), name="notify"), "catalog-zones/",
include(
get_model_urls("netbox_dns_bridge", "catalogzone", detail=False)
),
),
path(
"catalog-zones/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzone")),
),
path(
"catalog-zone-members/",
include(
get_model_urls("netbox_dns_bridge", "catalogzonemember", detail=False)
),
),
path(
"catalog-zone-members/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzonemember")),
),
path(
"seen-transfer-clients/",
include(
get_model_urls("netbox_dns_bridge", "seentransferclient", detail=False)
),
),
path(
"seen-transfer-clients/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "seentransferclient")),
),
] ]
+66 -40
View File
@@ -1,50 +1,76 @@
from django.contrib.auth.decorators import user_passes_test from netbox.object_actions import EditObject
from django.shortcuts import render from netbox.views import generic
from django.utils.decorators import method_decorator from utilities.views import register_model_view
from django.views import View
import netbox_dns.models from .filtersets import (
from netbox.views.generic import ObjectListView CatalogZoneFilterSet,
CatalogZoneMemberFilterSet,
from .filtersets import ZoneFilterSet SeenTransferClientFilterSet,
from .forms import CatalogZoneFilterForm
from .models import IntegerKeyValueSetting, SeenTransferClients
from .tables import CatalogZoneTable
@method_decorator(
user_passes_test(lambda u: u.is_superuser), name="dispatch"
) )
class CatzOverviewView(ObjectListView): from .forms import (
queryset = netbox_dns.models.Zone.objects.select_related( CatalogZoneFilterForm,
"view", "catz_identifier" CatalogZoneForm,
).order_by("view__name", "name") CatalogZoneMemberFilterForm,
SeenTransferClientFilterForm,
)
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
from .tables import (
CatalogZoneMemberTable,
CatalogZoneTable,
SeenTransferClientTable,
)
@register_model_view(CatalogZone, "list", path="", detail=False)
class CatalogZoneListView(generic.ObjectListView):
queryset = CatalogZone.objects.select_related("view").order_by("view__name")
table = CatalogZoneTable table = CatalogZoneTable
filterset = ZoneFilterSet filterset = CatalogZoneFilterSet
filterset_form = CatalogZoneFilterForm filterset_form = CatalogZoneFilterForm
template_name = "netbox_dns_bridge/catz_overview.html"
actions = () actions = ()
def get_extra_context(self, request):
context = super().get_extra_context(request) @register_model_view(CatalogZone)
soa_serial_obj = IntegerKeyValueSetting.objects.filter( class CatalogZoneView(generic.ObjectView):
key="catalog-zone-soa-serial" queryset = CatalogZone.objects.select_related("view")
).first() actions = (EditObject,)
context["soa_serial"] = soa_serial_obj.value if soa_serial_obj else None
return context
@method_decorator( @register_model_view(CatalogZone, "edit")
user_passes_test(lambda u: u.is_superuser), name="dispatch" class CatalogZoneEditView(generic.ObjectEditView):
) queryset = CatalogZone.objects.select_related("view")
class NotifyOverviewView(View): form = CatalogZoneForm
template_name = "netbox_dns_bridge/notify_overview.html"
def get(self, request):
seen_clients = ( @register_model_view(CatalogZoneMember, "list", path="", detail=False)
SeenTransferClients.objects.select_related("view") class CatalogZoneMemberListView(generic.ObjectListView):
.all() queryset = CatalogZoneMember.objects.select_related(
.order_by("source_ip") "zone", "catalog_zone__view"
).order_by("catalog_zone__view__name", "name")
table = CatalogZoneMemberTable
filterset = CatalogZoneMemberFilterSet
filterset_form = CatalogZoneMemberFilterForm
actions = ()
@register_model_view(CatalogZoneMember)
class CatalogZoneMemberView(generic.ObjectView):
queryset = CatalogZoneMember.objects.select_related(
"zone", "catalog_zone__view"
) )
context = {"seen_clients": seen_clients} actions = ()
return render(request, self.template_name, context)
@register_model_view(SeenTransferClient, "list", path="", detail=False)
class SeenTransferClientListView(generic.ObjectListView):
queryset = SeenTransferClient.objects.select_related("view").order_by("source_ip")
table = SeenTransferClientTable
filterset = SeenTransferClientFilterSet
filterset_form = SeenTransferClientFilterForm
actions = ()
@register_model_view(SeenTransferClient)
class SeenTransferClientView(generic.ObjectView):
queryset = SeenTransferClient.objects.select_related("view")
actions = ()