From f5791e94d545d2b6bc1505d3cc0760c7b0e9fed7 Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Sat, 11 Jul 2026 00:04:45 +0200 Subject: [PATCH] modified: ../README.md modified: __init__.py modified: catalog_zone_manager.py modified: dns_server.py modified: notify_handler.py Adding serial to notify messages to allow client to determine state. --- README.md | 4 ++ netbox_dns_bridge/__init__.py | 2 +- netbox_dns_bridge/catalog_zone_manager.py | 24 +++---- netbox_dns_bridge/dns_server.py | 2 +- netbox_dns_bridge/notify_handler.py | 78 +++++++++++++++++++---- 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 09c2872..925f734 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ notify_client_alive_threshold_hours: 24 This sets how long a client is considred "alive" after it last queried the transfer endpoint. Once the client has failed to check in for this amout of time, it is automatically removed from the list of clients to be notified on zone changes. Default is 24 hours. +``` +notify_over_tcp: True +``` +Switch to using TCP instead of UDP. Default: False ## Plugin compatibility This plugin extends the netbox-plugin-dns plugin. As such the versioning was changed to match the diff --git a/netbox_dns_bridge/__init__.py b/netbox_dns_bridge/__init__.py index e2d1ece..5276320 100644 --- a/netbox_dns_bridge/__init__.py +++ b/netbox_dns_bridge/__init__.py @@ -4,7 +4,7 @@ from .logger import get_logger __version__ = "1.5.4" -logger = get_logger(__name__) +LOGGER = get_logger(__name__) class DNSBridgeConfig(PluginConfig): diff --git a/netbox_dns_bridge/catalog_zone_manager.py b/netbox_dns_bridge/catalog_zone_manager.py index 43617e8..6a11d02 100644 --- a/netbox_dns_bridge/catalog_zone_manager.py +++ b/netbox_dns_bridge/catalog_zone_manager.py @@ -9,7 +9,7 @@ from uuid import uuid4 from base64 import b32encode from django.db import transaction, close_old_connections, OperationalError -logger = get_logger(__name__) +LOGGER = get_logger(__name__) def init() -> None: @@ -26,18 +26,18 @@ def increment_soa_serial() -> int: new_soa_serial = soa_serial.value + 1 if new_soa_serial > 0xFFFFFFFF: - logger.warning( + LOGGER.warning( f"Catalog serial {soa_serial.value} reached max — wrapping back to 1" ) new_soa_serial = 1 soa_serial.value = new_soa_serial soa_serial.save() soa_serial.refresh_from_db() - logger.debug(f"Catalog zone SOA serial number is now {soa_serial.value}") + LOGGER.debug(f"Catalog zone SOA serial number is now {soa_serial.value}") return soa_serial.value except OperationalError as e: - logger.error( + LOGGER.error( f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" ) close_old_connections() @@ -108,7 +108,7 @@ def create_zone(name, view_name) -> dns.zone.Zone: return zone except OperationalError as e: - logger.error(f"ERROR: Failed to create Catalog Zone: {e}") + LOGGER.error(f"ERROR: Failed to create Catalog Zone: {e}") close_old_connections() return None @@ -116,14 +116,14 @@ def create_zone(name, view_name) -> dns.zone.Zone: def _init_soa_serial() -> None: try: IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") - logger.info("Catalog zone SOA serial number loaded from database") + LOGGER.info("Catalog zone SOA serial number loaded from database") except IntegerKeyValueSetting.DoesNotExist: IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1) - logger.debug( + LOGGER.debug( "Catalog zone SOA serial number was not set in the database. Set to 1" ) except OperationalError as e: - logger.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}") + LOGGER.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}") close_old_connections() @@ -138,7 +138,7 @@ def update_member_identifier(zone: Zone) -> None: defaults={"name": _generate_member_identifier()}, ) 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 identifier: {e}") close_old_connections() @@ -159,7 +159,7 @@ def _create_missing_member_identifiers() -> None: ] for identifier in new_objects: - logger.debug( + LOGGER.debug( f"Zone {identifier.zone} missing catz member identifier. Creating..." ) @@ -169,7 +169,7 @@ def _create_missing_member_identifiers() -> None: ) except OperationalError as e: - logger.error(f"ERROR: Could not create catz member identifier: {e}") + LOGGER.error(f"ERROR: Could not create catz member identifier: {e}") close_old_connections() @@ -201,7 +201,7 @@ def _create_soa_rdataset() -> dns.rdataset: return rdataset except OperationalError as e: - logger.error(f"ERROR: Failed to access catz SOA record in database: {e}") + LOGGER.error(f"ERROR: Failed to access catz SOA record in database: {e}") close_old_connections() diff --git a/netbox_dns_bridge/dns_server.py b/netbox_dns_bridge/dns_server.py index 7d25af9..802a814 100644 --- a/netbox_dns_bridge/dns_server.py +++ b/netbox_dns_bridge/dns_server.py @@ -2,7 +2,7 @@ import socketserver import socket from .logger import get_logger -logger = get_logger(__name__) +LOGGER = get_logger(__name__) class DNSAddressMixin: diff --git a/netbox_dns_bridge/notify_handler.py b/netbox_dns_bridge/notify_handler.py index efa1c4b..5cb3c39 100644 --- a/netbox_dns_bridge/notify_handler.py +++ b/netbox_dns_bridge/notify_handler.py @@ -3,16 +3,18 @@ import dns.name import dns.opcode import dns.query import dns.rdatatype +import dns.rdataclass +import dns.rrset import dns.tsig from django.conf import settings from django.utils import timezone from django.db import close_old_connections, OperationalError from netbox.jobs import JobRunner -from netbox_dns.models import View, Zone +from netbox_dns.models import View, Zone, Record from .logger import get_logger -from .models import SeenTransferClients +from .models import SeenTransferClients, IntegerKeyValueSetting -logger = get_logger(__name__) +LOGGER = get_logger(__name__) SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"] @@ -46,41 +48,93 @@ class SendDNSNotify(JobRunner): return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm) def run(self, *args, **kwargs): + notify_over_tcp = SETTINGS.get("notify_over_tcp", False) view_name = kwargs["view_name"] - zone_name = kwargs["zone_name"] 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) tsig_key = self._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 ) - for name in (zone_name, "catz", f"{view_name}.catz"): - msg = dns.message.make_query(name, dns.rdatatype.SOA) + for zone_name, soa_serial in zones: + msg = dns.message.make_query(zone_name, dns.rdatatype.SOA) msg.flags = 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( + dns.name.from_text(zone_name, dns.name.root), 0, soa_rdata + ) + msg.authority.append(soa_rrset) msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name) for client in clients: try: - dns.query.udp(msg, client.source_ip, port=5355, timeout=2) - self.logger.info( - f"NOTIFY sent to {client.source_ip} for zone {name}" + if notify_over_tcp: + LOGGER.info("Sending over TCP") + dns.query.tcp(msg, client.source_ip, timeout=2) + else: + LOGGER.info("Sending over UDP") + dns.query.udp(msg, client.source_ip, timeout=2) + + LOGGER.info( + f"NOTIFY sent to {client.source_ip} for zone {zone_name}" ) except Exception as e: - self.logger.error( - f"NOTIFY failed for {client.source_ip} zone {name}: {e}" + LOGGER.error( + f"NOTIFY failed for {client.source_ip} zone {zone_name}: {e}" ) + + except IntegerKeyValueSetting.DoesNotExist as e: + LOGGER.error(f"Failed to get catalog zone serial: {e}") + except OperationalError as e: - self.logger.error(f"NOTIFY failed due to unexpected error: {e}") + LOGGER.error(f"NOTIFY failed due to unexpected error: {e}") close_old_connections() def schedule(zone: Zone): + try: + soa_record = Record.objects.filter( + zone=zone, type="SOA", active=True + ).first() + if soa_record is None: + LOGGER.error( + f"Zone {zone.name} has no active SOA record — skipping NOTIFY" + ) + return + + soa_serial = int(soa_record.value.split()[2]) + + except OperationalError as e: + LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}") + close_old_connections() + return + except (ValueError, IndexError) as e: + LOGGER.error( + f"Failed to parse SOA serial for zone {zone.name}: {e}" + ) + return + SendDNSNotify.enqueue( zone_name=zone.name, view_name=zone.view.name, + soa_serial=soa_serial, )