diff --git a/README.md b/README.md index 8d50116..d1ac2b3 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,6 @@ To start the service: 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 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. @@ -44,7 +37,7 @@ Parameter | Description ### Plugin settings 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. ## Installation guide @@ -93,7 +86,6 @@ This guide assumes: ``` PLUGINS_CONFIG = { "netbox_plugin_bind_provisioner": { - "catalog_serial_file": "/opt/netbox/catalog-serial.txt", "tsig_keys": { "public": { "keyname": "public_view_key", diff --git a/src/netbox_plugin_bind_provisioner/__init__.py b/src/netbox_plugin_bind_provisioner/__init__.py index 354a150..ce8af3a 100644 --- a/src/netbox_plugin_bind_provisioner/__init__.py +++ b/src/netbox_plugin_bind_provisioner/__init__.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from django.dispatch import receiver from django.conf import settings -__version__ = "0.9.5" +__version__ = "0.9.6" 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." ) - # 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 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 3937bbb..ee5e668 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,70 +20,44 @@ 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 logger = logging.getLogger('bind-transfer-endpoint') -class CatalogZoneAssembler(): +class CatalogZone(): lock = threading.Lock() _serial_max = 0xFFFFFFFF - _serial = 1 - _serial_file = None + _serial_obj = 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. @classmethod - def initCatalogZoneSerial(cls, serial_file): - """ - Load a 32-bit unsigned int serial from a text file. - If the file doesn't exist, initialize it with 1. - Returns the serial as an int. - """ - cls._serial_file = serial_file + def initSerial(cls): + try: + cls._serial_obj = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") + logger.debug(f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database") + except IntegerKeyValueSetting.DoesNotExist: + cls._serial_obj = IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1) + 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 - def incrementCatalogZoneSerial(cls): - """ - Increments the in-memory catalog serial and saves it to file. - Updates _serial. - """ - if 0 < cls._serial < cls._serial_max: - cls._serial += 1 + def _incrementSerial(cls): + if 0 < cls._serial_obj.value < cls._serial_max: + cls._serial_obj.value += 1 + cls._serial_obj.save() else: 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 - 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. with cls.lock: #cls = self.__class__ @@ -102,7 +76,7 @@ class CatalogZoneAssembler(): ) # Setting previous last zone update for next iteration: cls._previous_last_zone_update = last_zone_update - cls.incrementCatalogZoneSerial() + cls._incrementSerial() # If there was no update, do nothing. #else: # logger.debug(f"No need to update serial for catalog zone") @@ -159,7 +133,7 @@ class CatalogZoneAssembler(): rtype = dns.rdatatype.SOA mname = dns.name.root # dns.name.from_text("invalid.", origin) rname = dns.name.root # dns.name.from_text("contact.master.", origin) - serial = cls._serial + serial = cls._serial_obj.value refresh = 60 retry = 10 expire = 1209600 @@ -208,6 +182,19 @@ class CatalogZoneAssembler(): 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): # getZoneFromNB rewritten @@ -356,7 +343,7 @@ class UDPRequestHandler(DNSBaseRequestHandler): # Check if catalog zone 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 else: zone = self.getZoneFromNB(dname, nb_view, soa_only=True) @@ -471,8 +458,7 @@ class TCPRequestHandler(DNSBaseRequestHandler): # Check if catalog zone if dname == "catz": - #zone = self.createCatalogZone(str(nb_view)) - zone = CatalogZoneAssembler.createCatalogZone(nb_view.name) + zone = CatalogZone.create(nb_view.name) else: zone = self.getZoneFromNB(dname, nb_view) @@ -610,17 +596,8 @@ class Command(BaseCommand): "tsig_keys variable not set in plugin settings." ) - def loadCatalogZoneSettings(self): - 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) - + # Load TSIG keys and map them to views def loadTSIGKeySettings(self): - # Load TSIG keys and map them to views self.keyring = {} self.tsig_view_map = {} @@ -668,10 +645,15 @@ class Command(BaseCommand): # Load parameters port = options['port'] address = options['address'] + CatalogZone.initSerial() # Initialize settings self.loadSettings() - self.loadCatalogZoneSettings() + #self.loadCatalogZoneSettings() 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) tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map) diff --git a/src/netbox_plugin_bind_provisioner/migrations/0001_initial.py b/src/netbox_plugin_bind_provisioner/migrations/0001_initial.py new file mode 100644 index 0000000..4fbf101 --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/migrations/0001_initial.py @@ -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), + ), + ] diff --git a/src/netbox_plugin_bind_provisioner/migrations/__init__.py b/src/netbox_plugin_bind_provisioner/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/netbox_plugin_bind_provisioner/models.py b/src/netbox_plugin_bind_provisioner/models.py new file mode 100644 index 0000000..4eec27e --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/models.py @@ -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}"; }};'