modified: README.md

modified:   src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
new file:   src/netbox_plugin_bind_provisioner/migrations/0001_initial.py
new file:   src/netbox_plugin_bind_provisioner/migrations/__init__.py
new file:   src/netbox_plugin_bind_provisioner/models.py

Adds a database model to track the catalog zone serial instead of tracking it inside a flat file.
This commit is contained in:
Sven Luethi committed 2025-11-23 15:56:06 +01:00
1 parent 0349204aff
commit c8e28d2397
6 files changed
+103 -91

No files matched your search

+1 -9
View File
@@ -24,13 +24,6 @@ To start the service:
manage.py bind-transfer-endpoint --port 5354 manage.py bind-transfer-endpoint --port 5354
``` ```
The plugin currently requires a file to track the SOA serial of the catalog zone.
This serial represents the SOA record’s version number and allows downstream
DNS servers to determine when the catalog zone has changed (for example,
when a zone is added, modified, or removed in NetBox). The file ensures the
serial persists across service restarts. It will be replaced with a
database-backed value once the plugin introduces its own models.
When configuring the plugin, you need to provide a path on the filesystem for When configuring the plugin, you need to provide a path on the filesystem for
this file where the plugin can create and manage it. Please do not update it by this file where the plugin can create and manage it. Please do not update it by
hand or remove it unless you know what you are doing. hand or remove it unless you know what you are doing.
@@ -44,7 +37,7 @@ Parameter | Description
### Plugin settings ### Plugin settings
Setting | Description Setting | Description
--------------------| --------------------------------------------------------- --------------------| ---------------------------------------------------------
catalog_serial_file | The catalog serial counter. The file needs to be writable. catalog_serial_file | !!!NO LONGER USED!!! Replaced by DB entry.
tsig_keys | Maps a TSIG Key to be used for each view. tsig_keys | Maps a TSIG Key to be used for each view.
## Installation guide ## Installation guide
@@ -93,7 +86,6 @@ This guide assumes:
``` ```
PLUGINS_CONFIG = { PLUGINS_CONFIG = {
"netbox_plugin_bind_provisioner": { "netbox_plugin_bind_provisioner": {
"catalog_serial_file": "/opt/netbox/catalog-serial.txt",
"tsig_keys": { "tsig_keys": {
"public": { "public": {
"keyname": "public_view_key", "keyname": "public_view_key",
+1 -20
View File
@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
__version__ = "0.9.5" __version__ = "0.9.6"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,24 +32,5 @@ 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 bind, rndc
#
# if not bind.zoneDirWritable():
# BIND_ZONE_DIR = settings.get("BIND_ZONE_DIR")
# logger.error(f"Bind's zone file directory {BIND_ZONE_DIR} is not writable.")
# raise RuntimeError(
# "Plugin initialization failed due to failed writable-zone-directory healthcheck."
# )
# if not rndc.tsigKeyFileAccessible():
# RNDC_KEY_FILE = settings.get("RNDC_KEY_FILE")
# logger.error(f"The RNDC key file {RNDC_KEY_FILE} is inaccessible.")
# raise RuntimeError(
# "Plugin initialization failed due to failed rndc-key-accessible healthcheck."
# )
#
# # Import signal callbacks from signals.py (With @receiver decorations)
# from . import signals
config = BindProvisionerConfig config = BindProvisionerConfig
default_app_config = ".apps.NetboxBindProvisionerConfig" default_app_config = ".apps.NetboxBindProvisionerConfig"
@@ -20,70 +20,44 @@ 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
logger = logging.getLogger('bind-transfer-endpoint') logger = logging.getLogger('bind-transfer-endpoint')
class CatalogZoneAssembler(): class CatalogZone():
lock = threading.Lock() lock = threading.Lock()
_serial_max = 0xFFFFFFFF _serial_max = 0xFFFFFFFF
_serial = 1 _serial_obj = None
_serial_file = None
_previous_last_zone_update = None _previous_last_zone_update = None
# Following function loads the last serial from file. 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 initCatalogZoneSerial(cls, serial_file): def initSerial(cls):
""" try:
Load a 32-bit unsigned int serial from a text file. cls._serial_obj = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
If the file doesn't exist, initialize it with 1. logger.debug(f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database")
Returns the serial as an int. except IntegerKeyValueSetting.DoesNotExist:
""" cls._serial_obj = IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1)
cls._serial_file = serial_file logger.debug(f"Catalog zone SOA serial number was not set in the database. Set to {cls._serial_obj.value}")
if not os.path.exists(cls._serial_file):
try:
with open(cls._serial_file, "w") as f:
f.write(f"{cls._serial}\n")
return
except OSError as e:
raise RuntimeError(
f"Failed to initialize serial file {cls._serial_file}: {e}"
)
else:
try:
with open(cls._serial_file, "r") as f:
value = int(f.read().strip())
if 0 < value <= cls._serial_max:
cls._serial = value
else:
raise ValueError(f"Serial value out of 32-bit range: {value}")
except (OSError, ValueError) as e:
raise RuntimeError(f"Failed to read serial from {cls._serial_file}: {e}")
@classmethod @classmethod
def incrementCatalogZoneSerial(cls): def _incrementSerial(cls):
""" if 0 < cls._serial_obj.value < cls._serial_max:
Increments the in-memory catalog serial and saves it to file. cls._serial_obj.value += 1
Updates _serial. cls._serial_obj.save()
"""
if 0 < cls._serial < cls._serial_max:
cls._serial += 1
else: else:
logger.warn( logger.warn(
f"Failed to incremenet catalog serial {cls._serial}. Will overflow serial back to 1." f"Failed to incremenet catalog serial {cls._serial_obj.value}. Will overflow serial back to 1"
) )
cls._serial = 1 cls._serial_obj = 1
cls._serial_obj.save()
logger.debug(f"Catalog zone SOA serial number incremented to {_serial_obj.value}")
try:
with open(cls._serial_file, "w") as f:
f.write(f"{cls._serial}\n")
except OSError as e:
logger.error(f"Failed to write updated serial to {cls._serial_file}: {e}")
@classmethod @classmethod
def createCatalogZone(cls, view_name, soa_only=False): def create(cls, view_name, soa_only=False):
# 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__ #cls = self.__class__
@@ -102,7 +76,7 @@ class CatalogZoneAssembler():
) )
# Setting previous last zone update for next iteration: # Setting previous last zone update for next iteration:
cls._previous_last_zone_update = last_zone_update cls._previous_last_zone_update = last_zone_update
cls.incrementCatalogZoneSerial() cls._incrementSerial()
# If there was no update, do nothing. # If there was no update, do nothing.
#else: #else:
# logger.debug(f"No need to update serial for catalog zone") # logger.debug(f"No need to update serial for catalog zone")
@@ -159,7 +133,7 @@ class CatalogZoneAssembler():
rtype = dns.rdatatype.SOA rtype = dns.rdatatype.SOA
mname = dns.name.root # dns.name.from_text("invalid.", origin) mname = dns.name.root # dns.name.from_text("invalid.", origin)
rname = dns.name.root # dns.name.from_text("contact.master.", origin) rname = dns.name.root # dns.name.from_text("contact.master.", origin)
serial = cls._serial serial = cls._serial_obj.value
refresh = 60 refresh = 60
retry = 10 retry = 10
expire = 1209600 expire = 1209600
@@ -208,6 +182,19 @@ class CatalogZoneAssembler():
return zone return zone
@classmethod
def importLegacySerialFile(cls, file_path):
if os.path.exists(file_path):
try:
with open(file_path, "r") as f:
serial = int(f.read().strip())
cls._serial_obj.value = int(serial)
cls._serial_obj.save()
logger.info(f"Legacy catalog serial file {file_path} imported into database and file removed. Serial is now {serial}")
os.remove(file_path)
except OSError as e:
logger.error(f"Failed to import catalog zone serial from legacy file {file_path}")
class DNSBaseRequestHandler(socketserver.BaseRequestHandler): class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# getZoneFromNB rewritten # getZoneFromNB rewritten
@@ -356,7 +343,7 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# Check if catalog zone # Check if catalog zone
if dname == "catz": if dname == "catz":
zone = CatalogZoneAssembler.createCatalogZone(nb_view.name, soa_only=True) zone = CatalogZone.create(nb_view.name, soa_only=True)
# If generic zone, retreive from NB # If generic zone, retreive from NB
else: else:
zone = self.getZoneFromNB(dname, nb_view, soa_only=True) zone = self.getZoneFromNB(dname, nb_view, soa_only=True)
@@ -471,8 +458,7 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Check if catalog zone # Check if catalog zone
if dname == "catz": if dname == "catz":
#zone = self.createCatalogZone(str(nb_view)) zone = CatalogZone.create(nb_view.name)
zone = CatalogZoneAssembler.createCatalogZone(nb_view.name)
else: else:
zone = self.getZoneFromNB(dname, nb_view) zone = self.getZoneFromNB(dname, nb_view)
@@ -610,17 +596,8 @@ class Command(BaseCommand):
"tsig_keys variable not set in plugin settings." "tsig_keys variable not set in plugin settings."
) )
def loadCatalogZoneSettings(self): # Load TSIG keys and map them to views
serial_file = self.settings.get("catalog_serial_file", None)
if not serial_file:
raise RuntimeError(
"catalog_serial_file variable not set in plugin settings."
)
else:
CatalogZoneAssembler.initCatalogZoneSerial(serial_file)
def loadTSIGKeySettings(self): def loadTSIGKeySettings(self):
# Load TSIG keys and map them to views
self.keyring = {} self.keyring = {}
self.tsig_view_map = {} self.tsig_view_map = {}
@@ -668,10 +645,15 @@ class Command(BaseCommand):
# Load parameters # Load parameters
port = options['port'] port = options['port']
address = options['address'] address = options['address']
CatalogZone.initSerial()
# Initialize settings # Initialize settings
self.loadSettings() self.loadSettings()
self.loadCatalogZoneSettings() #self.loadCatalogZoneSettings()
self.loadTSIGKeySettings() self.loadTSIGKeySettings()
# Following is temporary. Function and invocation can be deleted once v1.0 is reached.
serial_file = self.settings.get("catalog_serial_file", None)
if serial_file:
CatalogZone.importLegacySerialFile(serial_file)
udp_server = UDPDNSServer((address, port), UDPRequestHandler, self.keyring, self.tsig_view_map) udp_server = UDPDNSServer((address, port), UDPRequestHandler, self.keyring, self.tsig_view_map)
tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map) tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map)
@@ -0,0 +1,34 @@
# Generated by Django 5.2.8 on 2025-11-23 14:36
import netbox.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('extras', '0133_make_cf_minmax_decimal'),
]
operations = [
migrations.CreateModel(
name='IntegerKeyValueSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('key', models.CharField(max_length=64)),
('value', models.IntegerField()),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'abstract': False,
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
]
Whitespace-only changes.
@@ -0,0 +1,23 @@
from django.db import models
import netbox.models
import netbox_dns.models
#class CatalogZoneSerial(models.Model):
class IntegerKeyValueSetting(netbox.models.NetBoxModel):
#view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
key = models.CharField(max_length=64)
value = models.IntegerField()
def __str__(self):
#return f'{self.view.name}: {self.serial}'
return f'{self.key}: {str(self.value)}'
#class TSIGKey(netbox.models.NetBoxModel):
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
# name = models.CharField(max_length=256)
# algorithm = models.CharField(max_length=25)
# secret = models.CharField(max_length=50)
#
# def __str__(self):
# return f'key "{self.name}" {{ algorithm {self.algorithm}; secret "{self.secret}"; }};'