From 9afdfe869b4c459ea1fc4f3bcc7ed4965973bd76 Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Tue, 3 Feb 2026 00:57:42 +0100 Subject: [PATCH] - Store catz member zone identifiers in database, linked to the corresponding zone object. - Catz member identifiers are created from uuid encoded in base32, lower-cased, and padding removed. - On endpoint startup, make sure all zones have a catz identifier assigned. If not, create them. - Use signals to create or update a zone's catz identifier. --- .../__init__.py | 2 + .../commands/bind-transfer-endpoint.py | 62 ++++++++++++++----- .../0003_catalogzonememberidentifier.py | 26 ++++++++ ..._alter_catalogzonememberidentifier_zone.py | 20 ++++++ ..._alter_catalogzonememberidentifier_zone.py | 20 ++++++ src/netbox_plugin_bind_provisioner/models.py | 17 +++++ src/netbox_plugin_bind_provisioner/signals.py | 50 +++++++++++++++ src/netbox_plugin_bind_provisioner/utils.py | 4 ++ 8 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 src/netbox_plugin_bind_provisioner/migrations/0003_catalogzonememberidentifier.py create mode 100644 src/netbox_plugin_bind_provisioner/migrations/0004_alter_catalogzonememberidentifier_zone.py create mode 100644 src/netbox_plugin_bind_provisioner/migrations/0005_alter_catalogzonememberidentifier_zone.py create mode 100644 src/netbox_plugin_bind_provisioner/signals.py diff --git a/src/netbox_plugin_bind_provisioner/__init__.py b/src/netbox_plugin_bind_provisioner/__init__.py index 753c13a..6f9659c 100644 --- a/src/netbox_plugin_bind_provisioner/__init__.py +++ b/src/netbox_plugin_bind_provisioner/__init__.py @@ -32,6 +32,8 @@ class BindProvisionerConfig(PluginConfig): f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox." ) + from . import signals + config = BindProvisionerConfig default_app_config = ".apps.NetboxBindProvisionerConfig" diff --git a/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py b/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py index c9035bd..ffa569e 100644 --- a/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py +++ b/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py @@ -20,7 +20,8 @@ from django.utils import timezone from netbox_dns.models import Zone, Record, View from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices from datetime import datetime -from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting +from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier +from netbox_plugin_bind_provisioner.utils import generate_catz_member_identifier logger = logging.getLogger("bind-transfer-endpoint") @@ -31,10 +32,15 @@ class CatalogZone: _serial_obj = None _previous_last_zone_update = None + @classmethod + def init(cls) -> None: + cls._init_serial() + cls._generate_member_identifiers() + # Following function loads the last serial from the DB. No return value # but it sets the setting "catalog_serial" or terminates the plugin on failure. @classmethod - def initSerial(cls): + def _init_serial(cls) -> None: try: cls._serial_obj = IntegerKeyValueSetting.objects.get( key="catalog-zone-soa-serial" @@ -50,8 +56,33 @@ class CatalogZone: f"Catalog zone SOA serial number was not set in the database. Set to {cls._serial_obj.value}" ) + # If a zone has no catz identifier yet, create it: @classmethod - def _incrementSerial(cls): + def _generate_member_identifiers(cls) -> None: + existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list( + "zone_id", flat=True + ) + + missing_zones = Zone.objects.exclude(id__in=existing_zone_ids) + + new_objects = [ + CatalogZoneMemberIdentifier( + zone=zone, + name=generate_catz_member_identifier(), + ) + for zone in missing_zones + ] + + for identifier in new_objects: + logger.debug(f"Zone {identifier.zone} has no catz member identifier. Creating...") + + CatalogZoneMemberIdentifier.objects.bulk_create( + new_objects, + ignore_conflicts=True, + ) + + @classmethod + def _incrementSerial(cls) -> None: if 0 < cls._serial_obj.value < cls._serial_max: cls._serial_obj.value += 1 cls._serial_obj.save() @@ -66,12 +97,9 @@ class CatalogZone: ) @classmethod - def create(cls, name, view_name): + def create(cls, name, view_name) -> dns.zone.Zone: # Synchronize following across threads as TCP and UDP listener both use it. with cls.lock: - # cls = self.__class__ - # datestamp = datetime.now().strftime("%y%m%d") - # latest_zone = Zone.objects.filter().order_by("-last_updated").first() latest_zone = ( Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE) .order_by("-last_updated") @@ -101,7 +129,7 @@ class CatalogZone: # get zones from netbox nb_zones = Zone.objects.filter( view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE - ) + ).select_related("catz_identifier") ptr_base = dns.name.from_text("zones", origin) @@ -110,7 +138,9 @@ class CatalogZone: qname = dns.name.from_text(nb_zone.name, dns.name.root) # Create PTR record - p_name = f"zid-{nb_zone.id:09d}" + #p_name = f"zid-{nb_zone.id:09d}" + p_name = nb_zone.catz_identifier.name + ptr_name = dns.name.from_text(p_name, ptr_base) assert ptr_name.is_subdomain(origin) rdata = dns.rdata.from_text( @@ -640,7 +670,7 @@ class UDPDNSServer(DNSAddressMixin, socketserver.UDPServer): class Command(BaseCommand): help = "Run a minimal AXFR DNS server using NetBox DNS plugin data" - def loadSettings(self): + def load_settings(self): self.settings = settings.PLUGINS_CONFIG.get( "netbox_plugin_bind_provisioner", None ) @@ -654,7 +684,7 @@ class Command(BaseCommand): raise RuntimeError("tsig_keys variable not set in plugin settings.") # Load TSIG keys and map them to views - def loadTSIGKeySettings(self): + def load_tsig_key_settings(self): self.keyring = {} self.tsig_view_map = {} @@ -706,11 +736,11 @@ class Command(BaseCommand): # Load parameters port = options["port"] address = options["address"] - CatalogZone.initSerial() + CatalogZone.init() # Initialize settings - self.loadSettings() - self.loadTSIGKeySettings() + self.load_settings() + self.load_tsig_key_settings() udp_server = UDPDNSServer( (address, port), UDPRequestHandler, self.keyring, self.tsig_view_map @@ -721,7 +751,7 @@ class Command(BaseCommand): ) def run_udp_server(server): - logger.debug(f"SOA endpoint listening on {address} udp/{port}") + logger.debug(f"Query endpoint listening on {address} udp/{port}") server.serve_forever() udp_thread = threading.Thread( @@ -730,5 +760,5 @@ class Command(BaseCommand): udp_thread.start() - logger.debug(f"AXFR endpoint listening on {address} tcp/{port}") + logger.debug(f"Query endpoint listening on {address} tcp/{port}") tcp_server.serve_forever() diff --git a/src/netbox_plugin_bind_provisioner/migrations/0003_catalogzonememberidentifier.py b/src/netbox_plugin_bind_provisioner/migrations/0003_catalogzonememberidentifier.py new file mode 100644 index 0000000..364310a --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/migrations/0003_catalogzonememberidentifier.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.9 on 2026-02-02 09:23 + +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_plugin_bind_provisioner', '0002_alter_integerkeyvaluesetting_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CatalogZoneMemberIdentifier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=26, unique=True)), + ('zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bind_provisioner_models', to='netbox_dns.zone')), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/src/netbox_plugin_bind_provisioner/migrations/0004_alter_catalogzonememberidentifier_zone.py b/src/netbox_plugin_bind_provisioner/migrations/0004_alter_catalogzonememberidentifier_zone.py new file mode 100644 index 0000000..b6486e9 --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/migrations/0004_alter_catalogzonememberidentifier_zone.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.9 on 2026-02-02 10:53 + +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_plugin_bind_provisioner', '0003_catalogzonememberidentifier'), + ] + + operations = [ + migrations.AlterField( + model_name='catalogzonememberidentifier', + name='zone', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bind_provisioner_catz_identifier', to='netbox_dns.zone'), + ), + ] diff --git a/src/netbox_plugin_bind_provisioner/migrations/0005_alter_catalogzonememberidentifier_zone.py b/src/netbox_plugin_bind_provisioner/migrations/0005_alter_catalogzonememberidentifier_zone.py new file mode 100644 index 0000000..3ab95dc --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/migrations/0005_alter_catalogzonememberidentifier_zone.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.9 on 2026-02-02 20:49 + +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_plugin_bind_provisioner', '0004_alter_catalogzonememberidentifier_zone'), + ] + + operations = [ + migrations.AlterField( + model_name='catalogzonememberidentifier', + name='zone', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='catz_identifier', to='netbox_dns.zone'), + ), + ] diff --git a/src/netbox_plugin_bind_provisioner/models.py b/src/netbox_plugin_bind_provisioner/models.py index 56b8411..08e3be0 100644 --- a/src/netbox_plugin_bind_provisioner/models.py +++ b/src/netbox_plugin_bind_provisioner/models.py @@ -12,3 +12,20 @@ class IntegerKeyValueSetting(models.Model): class Meta: default_permissions = () +class CatalogZoneMemberIdentifier(models.Model): + name = models.CharField( + max_length=26, + unique=True, + ) + + zone = models.OneToOneField( + to=netbox_dns.models.Zone, + on_delete=models.CASCADE, + related_name="catz_identifier", + ) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name diff --git a/src/netbox_plugin_bind_provisioner/signals.py b/src/netbox_plugin_bind_provisioner/signals.py new file mode 100644 index 0000000..5dd1b2a --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/signals.py @@ -0,0 +1,50 @@ +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver +from netbox_dns.models import Zone +from .models import CatalogZoneMemberIdentifier +from .utils import generate_catz_member_identifier + +@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. + """ + identifier = generate_catz_member_identifier() + + if created: + # Create the related object on Zone creation + CatalogZoneMemberIdentifier.objects.create( + zone=instance, + name=identifier + ) + else: + old_name = getattr(instance, "_old_name", None) + + # If the name did not change, do nothing + if old_name == instance.name: + return + + # Update identifier + CatalogZoneMemberIdentifier.objects.update_or_create( + zone=instance, + defaults={"name": identifier}, + ) diff --git a/src/netbox_plugin_bind_provisioner/utils.py b/src/netbox_plugin_bind_provisioner/utils.py index 643af44..8971bcb 100644 --- a/src/netbox_plugin_bind_provisioner/utils.py +++ b/src/netbox_plugin_bind_provisioner/utils.py @@ -4,6 +4,7 @@ import dns.rdatatype import dns.rdataclass import dns.exception import netbox_dns.models +import uuid, base64 def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str): @@ -33,3 +34,6 @@ def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str): except IOError as e: raise IOError(f"Failed to write zone file to {file_path}: {e}") + +def generate_catz_member_identifier(): + return base64.b32encode(uuid.uuid4().bytes)[0:26].lower().decode('UTF-8')