- 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:
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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
+20
@@ -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'),
|
||||
),
|
||||
]
|
||||
+20
@@ -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')
|
||||
Reference in new issue
Block a user