- 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."
|
f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import signals
|
||||||
|
|
||||||
|
|
||||||
config = BindProvisionerConfig
|
config = BindProvisionerConfig
|
||||||
default_app_config = ".apps.NetboxBindProvisionerConfig"
|
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.models import Zone, Record, View
|
||||||
from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
|
from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
|
||||||
from datetime import datetime
|
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")
|
logger = logging.getLogger("bind-transfer-endpoint")
|
||||||
|
|
||||||
@@ -31,10 +32,15 @@ class CatalogZone:
|
|||||||
_serial_obj = None
|
_serial_obj = None
|
||||||
_previous_last_zone_update = 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
|
# 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.
|
# but it sets the setting "catalog_serial" or terminates the plugin on failure.
|
||||||
@classmethod
|
@classmethod
|
||||||
def initSerial(cls):
|
def _init_serial(cls) -> None:
|
||||||
try:
|
try:
|
||||||
cls._serial_obj = IntegerKeyValueSetting.objects.get(
|
cls._serial_obj = IntegerKeyValueSetting.objects.get(
|
||||||
key="catalog-zone-soa-serial"
|
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}"
|
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
|
@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:
|
if 0 < cls._serial_obj.value < cls._serial_max:
|
||||||
cls._serial_obj.value += 1
|
cls._serial_obj.value += 1
|
||||||
cls._serial_obj.save()
|
cls._serial_obj.save()
|
||||||
@@ -66,12 +97,9 @@ class CatalogZone:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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.
|
# Synchronize following across threads as TCP and UDP listener both use it.
|
||||||
with cls.lock:
|
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 = (
|
latest_zone = (
|
||||||
Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE)
|
Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE)
|
||||||
.order_by("-last_updated")
|
.order_by("-last_updated")
|
||||||
@@ -101,7 +129,7 @@ class CatalogZone:
|
|||||||
# get zones from netbox
|
# get zones from netbox
|
||||||
nb_zones = Zone.objects.filter(
|
nb_zones = Zone.objects.filter(
|
||||||
view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE
|
view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE
|
||||||
)
|
).select_related("catz_identifier")
|
||||||
|
|
||||||
ptr_base = dns.name.from_text("zones", origin)
|
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)
|
qname = dns.name.from_text(nb_zone.name, dns.name.root)
|
||||||
|
|
||||||
# Create PTR record
|
# 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)
|
ptr_name = dns.name.from_text(p_name, ptr_base)
|
||||||
assert ptr_name.is_subdomain(origin)
|
assert ptr_name.is_subdomain(origin)
|
||||||
rdata = dns.rdata.from_text(
|
rdata = dns.rdata.from_text(
|
||||||
@@ -640,7 +670,7 @@ class UDPDNSServer(DNSAddressMixin, socketserver.UDPServer):
|
|||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Run a minimal AXFR DNS server using NetBox DNS plugin data"
|
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(
|
self.settings = settings.PLUGINS_CONFIG.get(
|
||||||
"netbox_plugin_bind_provisioner", None
|
"netbox_plugin_bind_provisioner", None
|
||||||
)
|
)
|
||||||
@@ -654,7 +684,7 @@ class Command(BaseCommand):
|
|||||||
raise RuntimeError("tsig_keys variable not set in plugin settings.")
|
raise RuntimeError("tsig_keys variable not set in plugin settings.")
|
||||||
|
|
||||||
# Load TSIG keys and map them to views
|
# Load TSIG keys and map them to views
|
||||||
def loadTSIGKeySettings(self):
|
def load_tsig_key_settings(self):
|
||||||
self.keyring = {}
|
self.keyring = {}
|
||||||
self.tsig_view_map = {}
|
self.tsig_view_map = {}
|
||||||
|
|
||||||
@@ -706,11 +736,11 @@ class Command(BaseCommand):
|
|||||||
# Load parameters
|
# Load parameters
|
||||||
port = options["port"]
|
port = options["port"]
|
||||||
address = options["address"]
|
address = options["address"]
|
||||||
CatalogZone.initSerial()
|
CatalogZone.init()
|
||||||
|
|
||||||
# Initialize settings
|
# Initialize settings
|
||||||
self.loadSettings()
|
self.load_settings()
|
||||||
self.loadTSIGKeySettings()
|
self.load_tsig_key_settings()
|
||||||
|
|
||||||
udp_server = UDPDNSServer(
|
udp_server = UDPDNSServer(
|
||||||
(address, port), UDPRequestHandler, self.keyring, self.tsig_view_map
|
(address, port), UDPRequestHandler, self.keyring, self.tsig_view_map
|
||||||
@@ -721,7 +751,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def run_udp_server(server):
|
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()
|
server.serve_forever()
|
||||||
|
|
||||||
udp_thread = threading.Thread(
|
udp_thread = threading.Thread(
|
||||||
@@ -730,5 +760,5 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
udp_thread.start()
|
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()
|
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:
|
class Meta:
|
||||||
default_permissions = ()
|
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.rdataclass
|
||||||
import dns.exception
|
import dns.exception
|
||||||
import netbox_dns.models
|
import netbox_dns.models
|
||||||
|
import uuid, base64
|
||||||
|
|
||||||
def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str):
|
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:
|
except IOError as e:
|
||||||
raise IOError(f"Failed to write zone file to {file_path}: {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