diff --git a/netbox_dns_bridge/__init__.py b/netbox_dns_bridge/__init__.py index c8573ff..577b2a1 100644 --- a/netbox_dns_bridge/__init__.py +++ b/netbox_dns_bridge/__init__.py @@ -1,12 +1,11 @@ -import logging - from netbox.plugins import PluginConfig from django.conf import settings from .logger import get_logger __version__ = "1.5.3" -logger = logger.get_logger(__name__) +logger = get_logger(__name__) + class DNSBridgeConfig(PluginConfig): name = "netbox_dns_bridge" @@ -24,7 +23,7 @@ class DNSBridgeConfig(PluginConfig): f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox." ) - from . import signals as signals + from . import signals config = DNSBridgeConfig diff --git a/netbox_dns_bridge/catalog_zone_manager.py b/netbox_dns_bridge/catalog_zone_manager.py index 2446b72..4e2d439 100644 --- a/netbox_dns_bridge/catalog_zone_manager.py +++ b/netbox_dns_bridge/catalog_zone_manager.py @@ -12,35 +12,41 @@ from django.db import transaction logger = get_logger(__name__) -_SERIAL_MAX = 0xFFFFFFFF - def init() -> None: - _init_serial() - _init_last_zone_update_timestamp() + _init_soa_serial() _create_missing_member_identifiers() -def _init_serial() -> None: +def _init_soa_serial() -> None: try: IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") logger.info("Catalog zone SOA serial number loaded from database") except IntegerKeyValueSetting.DoesNotExist: - IntegerKeyValueSetting.objects.create( - key="catalog-zone-soa-serial", value=1 + IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1) + logger.debug( + "Catalog zone SOA serial number was not set in the database. Set to 1" ) - logger.debug("Catalog zone SOA serial number was not set in the database. Set to 1") -def _init_last_zone_update_timestamp() -> None: - try: - IntegerKeyValueSetting.objects.get(key="last-zone-update-timestamp") - logger.info("Last zone update timestamp loaded from database") - except IntegerKeyValueSetting.DoesNotExist: - IntegerKeyValueSetting.objects.create( - key="last-zone-update-timestamp", value=0 +def increment_soa_serial() -> int: + with transaction.atomic(): + soa_serial = IntegerKeyValueSetting.objects.select_for_update().get( + key="catalog-zone-soa-serial" ) - logger.debug("Last zone update timestamp was not set in the database. Set to 0") + + new_soa_serial = soa_serial.value + 1 + if new_soa_serial > 0xFFFFFFFF: + 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}") + + return soa_serial.value def _create_missing_member_identifiers() -> None: @@ -70,50 +76,7 @@ def _create_missing_member_identifiers() -> None: def create_zone(name, view_name) -> dns.zone.Zone: - # Atomic transaction with row-level locking to prevent diverging state - current_serial: int - - with transaction.atomic(): - serial_setting = IntegerKeyValueSetting.objects.select_for_update().get( - key="catalog-zone-soa-serial" - ) - timestamp_setting = IntegerKeyValueSetting.objects.select_for_update().get( - key="last-zone-update-timestamp" - ) - - latest_zone = ( - Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE) - .order_by("-last_updated") - .first() - ) - - last_zone_update = getattr(latest_zone, "last_updated", None) - last_zone_update_ts = ( - int(last_zone_update.timestamp()) if last_zone_update else 0 - ) - - # Check if any zone was updated since last call - if timestamp_setting.value != last_zone_update_ts: - if last_zone_update is not None: - logger.debug( - f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}" - ) - # Update timestamp for next iteration - timestamp_setting.value = last_zone_update_ts - timestamp_setting.save() - - # Increment serial - new_serial = serial_setting.value + 1 - if new_serial > _SERIAL_MAX: - logger.warning( - f"Catalog serial {serial_setting.value} reached max — wrapping back to 1" - ) - new_serial = 1 - serial_setting.value = new_serial - serial_setting.save() - logger.debug(f"Catalog zone SOA serial number is now {serial_setting.value}") - - current_serial = serial_setting.value + soa_serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value # Zone origin origin = dns.name.from_text(name, dns.name.root) @@ -173,7 +136,7 @@ def create_zone(name, view_name) -> dns.zone.Zone: soa_rdata = dns.rdata.from_text( rclass, rtype, - f"{mname} {rname} {current_serial} {refresh} {retry} {expire} {minimum}", + f"{mname} {rname} {soa_serial} {refresh} {retry} {expire} {minimum}", ) # Create Rdataset and add the RDATA to it @@ -208,7 +171,7 @@ def create_zone(name, view_name) -> dns.zone.Zone: return zone -def _generate_member_identifier() -> None: +def _generate_member_identifier() -> str: return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8") diff --git a/netbox_dns_bridge/dns_server.py b/netbox_dns_bridge/dns_server.py index a3e7383..7d25af9 100644 --- a/netbox_dns_bridge/dns_server.py +++ b/netbox_dns_bridge/dns_server.py @@ -1,7 +1,7 @@ import socketserver import socket -import logging from .logger import get_logger + logger = get_logger(__name__) diff --git a/netbox_dns_bridge/logger.py b/netbox_dns_bridge/logger.py index 03de457..0d6f314 100644 --- a/netbox_dns_bridge/logger.py +++ b/netbox_dns_bridge/logger.py @@ -2,10 +2,11 @@ import logging PLUGIN_NAME = "dns-bridge" + def get_logger(name: str) -> logging.Logger: - #name = name.replace("_","-").rsplit(".", 1)[-1] + # name = name.replace("_","-").rsplit(".", 1)[-1] # - #if name.startswith(PLUGIN_NAME): + # if name.startswith(PLUGIN_NAME): # return logging.getLogger(name) - #return logging.getLogger(f"{PLUGIN_NAME}.{name}") + # return logging.getLogger(f"{PLUGIN_NAME}.{name}") return logging.getLogger(f"{PLUGIN_NAME}") diff --git a/netbox_dns_bridge/migrations/0002_alter_integerkeyvaluesetting_options_and_more.py b/netbox_dns_bridge/migrations/0002_alter_integerkeyvaluesetting_options_and_more.py new file mode 100644 index 0000000..7aaa9bb --- /dev/null +++ b/netbox_dns_bridge/migrations/0002_alter_integerkeyvaluesetting_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.9 on 2026-06-09 21:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('netbox_dns', '0030_dnsseckeytemplate_comments_dnsseckeytemplate_owner_and_more'), + ('netbox_dns_bridge', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='integerkeyvaluesetting', + options={}, + ), + migrations.CreateModel( + name='SeenTransferClients', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('source_ip', models.GenericIPAddressField()), + ('last_seen', models.DateTimeField()), + ('view', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='seen_transfer_clients', to='netbox_dns.view')), + ], + options={ + 'ordering': ('source_ip',), + 'constraints': [models.UniqueConstraint(fields=('source_ip', 'view'), name='unique_ip_view_seen_client')], + }, + ), + ] diff --git a/netbox_dns_bridge/models.py b/netbox_dns_bridge/models.py index 9125fd5..fa599e9 100644 --- a/netbox_dns_bridge/models.py +++ b/netbox_dns_bridge/models.py @@ -1,20 +1,22 @@ from django.db import models -import netbox.models + +# import netbox.models import netbox_dns.models class IntegerKeyValueSetting(models.Model): + _netbox_private = True + key = models.CharField(max_length=64) value = models.IntegerField() def __str__(self): return f"{self.key}: {str(self.value)}" - class Meta: - default_permissions = () - class CatalogZoneMemberIdentifier(models.Model): + _netbox_private = True + name = models.CharField( max_length=26, unique=True, @@ -31,3 +33,27 @@ class CatalogZoneMemberIdentifier(models.Model): def __str__(self): return self.name + + +class SeenTransferClients(models.Model): + _netbox_private = True + + source_ip = models.GenericIPAddressField() + last_seen = models.DateTimeField() + view = models.ForeignKey( + to=netbox_dns.models.View, + on_delete=models.PROTECT, + related_name="seen_transfer_clients", + ) + + def __str__(self): + return f"{self.source_ip} ({self.view.name})" + + class Meta: + ordering = ("source_ip",) + constraints = [ + models.UniqueConstraint( + fields=["source_ip", "view"], + name="unique_ip_view_seen_client", + ), + ] diff --git a/netbox_dns_bridge/notify_handler.py b/netbox_dns_bridge/notify_handler.py new file mode 100644 index 0000000..7e57123 --- /dev/null +++ b/netbox_dns_bridge/notify_handler.py @@ -0,0 +1,80 @@ +import dns.message +import dns.name +import dns.opcode +import dns.query +import dns.rdatatype +import dns.tsig +from django.conf import settings +from django.utils import timezone +from netbox.jobs import JobRunner +from netbox_dns.models import View, Zone +from .logger import get_logger +from .models import SeenTransferClients + +logger = get_logger(__name__) + + +class SendDNSNotify(JobRunner): + class Meta: + name = "Send DNS NOTIFY" + + def _load_tsig_key(self, view_name: str) -> dns.tsig.Key: + tsig_keys = settings.PLUGINS_CONFIG["netbox_dns_bridge"].get("tsig_keys", {}) + if view_name not in tsig_keys: + raise RuntimeError(f"No TSIG key configured for view '{view_name}'") + + key_data = tsig_keys[view_name] + raw_key_name = key_data.get("keyname") + secret = key_data.get("secret") + algorithm = key_data.get("algorithm") + if not algorithm: + raise RuntimeError( + f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'" + ) + + if not raw_key_name or not secret: + raise RuntimeError( + f"Incomplete TSIG key configuration for view '{view_name}'" + ) + + key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize() + if not key_name.is_absolute(): + key_name = key_name.concatenate(dns.name.root) + + return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm) + + def run(self, *args, **kwargs): + view_name = kwargs["view_name"] + zone_name = kwargs["zone_name"] + + view = View.objects.get(name=view_name) + tsig_key = self._load_tsig_key(view_name) + + seen_cutoff = timezone.now() - timezone.timedelta(hours=1) + 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) + msg.flags = 0 + msg.set_opcode(dns.opcode.NOTIFY) + 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, timeout=2) + self.logger.info( + f"NOTIFY sent to {client.source_ip} for zone {name}" + ) + except Exception as e: + self.logger.error( + f"NOTIFY failed for {client.source_ip} zone {name}: {e}" + ) + + +def schedule(zone: Zone): + SendDNSNotify.enqueue( + zone_name=zone.name, + view_name=zone.view.name, + ) diff --git a/netbox_dns_bridge/request_handler.py b/netbox_dns_bridge/request_handler.py index 2195f81..24e472f 100644 --- a/netbox_dns_bridge/request_handler.py +++ b/netbox_dns_bridge/request_handler.py @@ -10,11 +10,12 @@ import dns.rdataclass import dns.rdtypes import dns.exception import dns.renderer -import logging from .logger import get_logger +from django.utils import timezone from netbox_dns.models import Zone, Record from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices from netbox_dns_bridge import catalog_zone_manager as catzm +from .models import SeenTransferClients logger = get_logger(__name__) @@ -275,6 +276,12 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): wire = r.get_wire() self._send_response(wire) + SeenTransferClients.objects.update_or_create( + source_ip=peer, + view=nb_view, + defaults={"last_seen": timezone.now()}, + ) + logger.info(f"{peer} AXFR {nb_view.name}/{dname}") def _handle_dns_query(self, wire) -> None: diff --git a/netbox_dns_bridge/signals/__init__.py b/netbox_dns_bridge/signals/__init__.py index d51a03a..283cffc 100644 --- a/netbox_dns_bridge/signals/__init__.py +++ b/netbox_dns_bridge/signals/__init__.py @@ -1 +1 @@ -from . import transfer_endpoint +from . import netbox_dns_zones diff --git a/netbox_dns_bridge/signals/netbox_dns_zones.py b/netbox_dns_bridge/signals/netbox_dns_zones.py new file mode 100644 index 0000000..e23c21b --- /dev/null +++ b/netbox_dns_bridge/signals/netbox_dns_zones.py @@ -0,0 +1,43 @@ +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver +from django.db import transaction +from netbox.context import current_request +from netbox_dns.models import Zone +from netbox_dns_bridge import catalog_zone_manager as catzm +from netbox_dns_bridge import notify_handler + + +@receiver(pre_save, sender=Zone) +def zone_pre_save(sender, instance, **kwargs): + zone = instance + if zone.pk: + try: + zone._old_name = sender.objects.only("name").get(pk=zone.pk).name + except sender.DoesNotExist: + zone._old_name = None + else: + zone._old_name = None + + +@receiver(post_save, sender=Zone) +def zone_post_save(sender, instance, created, **kwargs): + zone = instance + request = current_request.get() + handled_zones = getattr(request, "_handled_zones", set()) + if zone.pk in handled_zones: + return + handled_zones.add(zone.pk) + request._handled_zones = handled_zones + + def _on_commit(): + catzm.increment_soa_serial() + notify_handler.schedule(zone) + + transaction.on_commit(_on_commit) + + if created: + catzm.update_member_identifier(zone) + else: + old_name = getattr(zone, "_old_name", None) + if old_name != zone.name: + catzm.update_member_identifier(zone) diff --git a/netbox_dns_bridge/signals/transfer_endpoint.py b/netbox_dns_bridge/signals/transfer_endpoint.py deleted file mode 100644 index c41f426..0000000 --- a/netbox_dns_bridge/signals/transfer_endpoint.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.db.models.signals import pre_save, post_save -from django.dispatch import receiver -from netbox_dns.models import Zone -from . import catalog_zone_manager as catzm - - -@receiver(pre_save, sender=Zone) -def zone_pre_save(sender, instance, **kwargs): - """ - Cache the old name so post_save can see if it changed. - """ - if instance.pk: - try: - instance._old_name = sender.objects.only("name").get(pk=instance.pk).name - except sender.DoesNotExist: - instance._old_name = None - else: - instance._old_name = None - - -@receiver(post_save, sender=Zone) -def sync_catalog_zone_identifier(sender, instance, created, **kwargs): - """ - Ensure CatalogZoneMemberIdentifier exists for each Zone - and keep its identifier in sync. - """ - if created: - # Create the related object on Zone creation - catzm.update_member_identifier(instance) - else: - old_name = getattr(instance, "_old_name", None) - - # If the name did not change, do nothing - if old_name == instance.name: - return - - catzm.update_member_identifier(instance) diff --git a/netbox_dns_bridge/utils.py b/netbox_dns_bridge/utils.py index df07f4c..2684a39 100644 --- a/netbox_dns_bridge/utils.py +++ b/netbox_dns_bridge/utils.py @@ -1,4 +1,3 @@ -import os import dns.zone import dns.rdatatype import dns.rdataclass