- 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.
This commit is contained in:
Sven Luethi committed 2026-02-05 12:26:55 +01:00
1 parent 1fae3bfe54
commit 9afdfe869b
8 files changed
+185 -16

No files matched your search

@@ -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"
@@ -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()
@@ -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',),
},
),
]
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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
@@ -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},
)
@@ -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')