From 6d4fb82ac5f18204df6f283f1cae2799e923d3de Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Mon, 17 Aug 2026 09:59:06 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 12 ++ netbox_dns_bridge/jobs/__init__.py | 0 .../notify_handler.py => jobs/notify.py} | 186 ++++++++++-------- .../catalog_zone_manager.py | 7 +- netbox_dns_bridge/signals/netbox_dns_zones.py | 35 +--- 5 files changed, 132 insertions(+), 108 deletions(-) create mode 100644 netbox_dns_bridge/jobs/__init__.py rename netbox_dns_bridge/{signals/notify_handler.py => jobs/notify.py} (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ae9a3..25321ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,16 @@ ## 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 diff --git a/netbox_dns_bridge/jobs/__init__.py b/netbox_dns_bridge/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_dns_bridge/signals/notify_handler.py b/netbox_dns_bridge/jobs/notify.py similarity index 62% rename from netbox_dns_bridge/signals/notify_handler.py rename to netbox_dns_bridge/jobs/notify.py index 8a1507e..b919244 100644 --- a/netbox_dns_bridge/signals/notify_handler.py +++ b/netbox_dns_bridge/jobs/notify.py @@ -17,6 +17,7 @@ from netbox_dns_bridge.models import SeenTransferClients, CatalogZone LOGGER = get_logger(__name__) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) + def _load_tsig_key(view_name: str) -> dns.tsig.Key: tsig_keys = SETTINGS.get("tsig_keys", {}) if view_name not in tsig_keys: @@ -41,7 +42,50 @@ def _load_tsig_key(view_name: str) -> dns.tsig.Key: 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", +): + """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} 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 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: name = "Send DNS NOTIFY" @@ -49,61 +93,25 @@ class SendClientDNSNotify(JobRunner): notify_over_tcp = SETTINGS.get("notify_over_tcp", False) port = SETTINGS.get("notify_client_port", 53) view_name = kwargs["view_name"] + zone_name = kwargs["zone_name"] + soa_serial = kwargs["soa_serial"] try: view = View.objects.get(name=view_name) - try: - catz_serial = view.catalog_zone.soa_serial - except CatalogZone.DoesNotExist as e: - LOGGER.error(f"Failed to get catalog zone serial: {e}") - return - - zones = [ - (kwargs["zone_name"], kwargs["soa_serial"]), - ("catz", catz_serial), - (f"{view_name}.catz", catz_serial), - ] - tsig_key = _load_tsig_key(view_name) cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24) seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) - clients = SeenTransferClients.objects.filter( - view=view, last_seen__gte=seen_cutoff + clients = list( + SeenTransferClients.objects.filter( + view=view, last_seen__gte=seen_cutoff + ).values_list("source_ip", flat=True) ) - for zone_name, soa_serial in zones: - zone_name = dns.name.from_text(zone_name, dns.name.root).to_text() - msg = dns.message.make_query(zone_name, dns.rdatatype.SOA) - msg.flags |= dns.flags.AA # = 0 - 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", - ) - 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}" - ) + _send_notify( + zone_name, soa_serial, clients, tsig_key, + view_name, notify_over_tcp, port, log_prefix="NOTIFY", + ) except View.DoesNotExist as e: LOGGER.error(f"Failed to get view for NOTIFY: {e}") @@ -116,10 +124,10 @@ class SendClientDNSNotify(JobRunner): class SendNSNotify(JobRunner): """Send a NOTIFY straight to a zone's own nameservers. - Unlike ``SendClientDNSNotify`` (which notifies clients that previously queried the - transfer endpoint), this job resolves the addresses of the zone's configured - nameservers from NetBox DNS itself and notifies each of them so they pick up - the changed zone quickly. + Unlike ``SendClientNotify`` (which notifies clients that previously queried + the transfer endpoint), this job resolves the addresses of the zone's + configured nameservers from NetBox DNS itself and notifies each of them so + they pick up the changed zone quickly. """ class Meta: @@ -156,33 +164,10 @@ class SendNSNotify(JobRunner): LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}") return - 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} 60 10 1209600 0", + _send_notify( + zone_name, soa_serial, ns_targets, tsig_key, + view_name, notify_over_tcp, port, log_prefix="NS NOTIFY", ) - 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}" - ) def _get_soa_serial(zone: Zone): @@ -239,12 +224,33 @@ def _resolve_zone_nameserver_ips(zone: Zone) -> list: 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): + if not SETTINGS.get("notify_clients", False): + return + soa_serial = _get_soa_serial(zone) if soa_serial is None: return - SendClientDNSNotify.enqueue( + SendClientNotify.enqueue( zone_name=zone.name, view_name=zone.view.name, soa_serial=soa_serial, @@ -252,6 +258,9 @@ def schedule_client_notify(zone: Zone): def schedule_ns_notify(zone: Zone): + if not _notify_ns_enabled(zone): + return + soa_serial = _get_soa_serial(zone) if soa_serial is None: return @@ -262,3 +271,24 @@ def schedule_ns_notify(zone: Zone): view_name=zone.view.name, soa_serial=soa_serial, ) + + +def schedule_catalog_zone_notify(view: View): + if not SETTINGS.get("notify_clients", False): + return + + try: + serial = view.catalog_zone.soa_serial + except CatalogZone.DoesNotExist: + return + + SendClientNotify.enqueue( + zone_name="catz", + soa_serial=serial, + view_name=view.name, + ) + SendClientNotify.enqueue( + zone_name=f"{view.name}.catz", + soa_serial=serial, + view_name=view.name, + ) diff --git a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py index 0617268..e0dbe78 100644 --- a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py +++ b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py @@ -5,6 +5,7 @@ import dns.rdataclass from netbox_dns_bridge.utils import get_logger from netbox_dns.models import Zone as NBZone, View as NBView from netbox_dns_bridge.models import CatalogZone, CatalogZoneMemberIdentifier +from netbox_dns_bridge.jobs import notify from uuid import uuid4 from base64 import b32encode from django.db import transaction, close_old_connections, OperationalError @@ -38,12 +39,16 @@ def increment_soa_serial(view: NBView) -> int: f"is now {soa_serial_obj.soa_serial}" ) - return soa_serial_obj.soa_serial + serial = soa_serial_obj.soa_serial except OperationalError as e: LOGGER.error( f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" ) close_old_connections() + return None + + notify.schedule_catalog_zone_notify(view) + return serial def create_zone(name, view_name) -> dns.zone.Zone: diff --git a/netbox_dns_bridge/signals/netbox_dns_zones.py b/netbox_dns_bridge/signals/netbox_dns_zones.py index f8c33be..f335254 100644 --- a/netbox_dns_bridge/signals/netbox_dns_zones.py +++ b/netbox_dns_bridge/signals/netbox_dns_zones.py @@ -1,13 +1,11 @@ from django.db.models.signals import pre_save, post_save, post_delete from django.dispatch import receiver from django.db import transaction -from django.conf import settings from netbox.context import current_request from netbox_dns.models import Zone, View 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) def zone_pre_save(sender, instance, **kwargs): @@ -61,7 +59,8 @@ def zone_post_save(sender, instance, created, **kwargs): and old_view_id != zone.view_id ) - # On commit, increment soa serial and if notify is enabled, schedule bg job. + # On commit, increment soa serial and schedule notify jobs. All settings + # gating happens inside the scheduling functions. def _on_commit(): if view_changed: try: @@ -71,13 +70,9 @@ def zone_post_save(sender, instance, created, **kwargs): pass catzm.increment_soa_serial(zone.view) - if SETTINGS.get("notify_clients", False): - notify_handler.schedule_client_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) + if soa_serial_changed: + notify.schedule_client_notify(zone) + notify.schedule_ns_notify(zone) transaction.on_commit(_on_commit) @@ -99,21 +94,3 @@ def zone_post_delete(sender, instance, **kwargs): catzm.increment_soa_serial(instance.view) 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