modified: netbox_dns_bridge/catalog_zone_manager.py

Move state to DB
This commit is contained in:
Sven Luethi committed 2026-05-23 14:58:24 +02:00
1 parent 1426ef2f70
commit a4036c285c
1 file changed
+52 -66
+52 -66
View File
@@ -1,75 +1,48 @@
import threading
import dns.query
import dns.message
import dns.tsigkeyring
import dns.name import dns.name
import dns.zone import dns.zone
import dns.rdatatype import dns.rdatatype
import dns.rdataclass import dns.rdataclass
import dns.rdtypes
import dns.exception
import dns.renderer
import logging
from .logger import get_logger from .logger import get_logger
from netbox_dns.models import Zone from netbox_dns.models import Zone
from netbox_dns.choices import ZoneStatusChoices from netbox_dns.choices import ZoneStatusChoices
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.db import transaction
logger = get_logger(__name__) logger = get_logger(__name__)
_LOCK = threading.Lock()
_SERIAL_MAX = 0xFFFFFFFF _SERIAL_MAX = 0xFFFFFFFF
_SERIAL_OBJ = None
_PREVIOUS_LAST_ZONE_UPDATE = None
def init() -> None: def init() -> None:
_init_serial() _init_serial()
_init_last_zone_update_timestamp()
_create_missing_member_identifiers() _create_missing_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.
def _init_serial() -> None: def _init_serial() -> None:
global _SERIAL_OBJ
try: try:
_SERIAL_OBJ = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
logger.info( logger.info("Catalog zone SOA serial number loaded from database")
f"Catalog zone SOA serial number {_SERIAL_OBJ.value} loaded from database"
)
except IntegerKeyValueSetting.DoesNotExist: except IntegerKeyValueSetting.DoesNotExist:
_SERIAL_OBJ = IntegerKeyValueSetting.objects.create( IntegerKeyValueSetting.objects.create(
key="catalog-zone-soa-serial", value=1 key="catalog-zone-soa-serial", value=1
) )
logger.debug( logger.debug("Catalog zone SOA serial number was not set in the database. Set to 1")
f"Catalog zone SOA serial number was not set in the database. Set to {_SERIAL_OBJ.value}"
def _init_last_zone_update_timestamp() -> None:
try:
IntegerKeyValueSetting.objects.get(key="last-zone-update-timestamp")
logger.info("Last zone update timestamp loaded from database")
except IntegerKeyValueSetting.DoesNotExist:
IntegerKeyValueSetting.objects.create(
key="last-zone-update-timestamp", value=0
) )
logger.debug("Last zone update timestamp was not set in the database. Set to 0")
def _set_serial(new_serial: int) -> bool:
global _SERIAL_OBJ
if 0 < new_serial < _SERIAL_MAX:
_SERIAL_OBJ.value = new_serial
_SERIAL_OBJ.save()
return True
else:
return False
def _increment_serial() -> None:
if not _set_serial(_SERIAL_OBJ.value + 1):
logger.warning(
f"Catalog serial {_SERIAL_OBJ.value} reached max — wrapping back to 1"
)
_set_serial(1)
logger.debug(f"Catalog zone SOA serial number is now {_SERIAL_OBJ.value}")
# If a zone has no catz identifier yet, create it:
def _create_missing_member_identifiers() -> None: def _create_missing_member_identifiers() -> None:
existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list( existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list(
"zone_id", flat=True "zone_id", flat=True
@@ -87,7 +60,7 @@ def _create_missing_member_identifiers() -> None:
for identifier in new_objects: for identifier in new_objects:
logger.debug( logger.debug(
f"Zone {identifier.zone} has no catz member identifier. Creating..." f"Zone {identifier.zone} missing catz member identifier. Creating..."
) )
CatalogZoneMemberIdentifier.objects.bulk_create( CatalogZoneMemberIdentifier.objects.bulk_create(
@@ -97,9 +70,17 @@ def _create_missing_member_identifiers() -> None:
def create_zone(name, view_name) -> dns.zone.Zone: def create_zone(name, view_name) -> dns.zone.Zone:
global _PREVIOUS_LAST_ZONE_UPDATE # Atomic transaction with row-level locking to prevent diverging state
# Synchronize following across threads as TCP and UDP listener both use it. current_serial: int
with _LOCK:
with transaction.atomic():
serial_setting = IntegerKeyValueSetting.objects.select_for_update().get(
key="catalog-zone-soa-serial"
)
timestamp_setting = IntegerKeyValueSetting.objects.select_for_update().get(
key="last-zone-update-timestamp"
)
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")
@@ -107,17 +88,32 @@ def create_zone(name, view_name) -> dns.zone.Zone:
) )
last_zone_update = getattr(latest_zone, "last_updated", None) last_zone_update = getattr(latest_zone, "last_updated", None)
last_zone_update_ts = (
int(last_zone_update.timestamp()) if last_zone_update else 0
)
# Check if there was a zone updated since last call # Check if any zone was updated since last call
# If no zone was found previously then this will be false since (None != None) = False if timestamp_setting.value != last_zone_update_ts:
if _PREVIOUS_LAST_ZONE_UPDATE != last_zone_update:
if last_zone_update is not None: if last_zone_update is not None:
logger.debug( logger.debug(
f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}" f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}"
) )
# Setting previous last zone update for next iteration: # Update timestamp for next iteration
_PREVIOUS_LAST_ZONE_UPDATE = last_zone_update timestamp_setting.value = last_zone_update_ts
_increment_serial() timestamp_setting.save()
# Increment serial
new_serial = serial_setting.value + 1
if new_serial > _SERIAL_MAX:
logger.warning(
f"Catalog serial {serial_setting.value} reached max — wrapping back to 1"
)
new_serial = 1
serial_setting.value = new_serial
serial_setting.save()
logger.debug(f"Catalog zone SOA serial number is now {serial_setting.value}")
current_serial = serial_setting.value
# Zone origin # Zone origin
origin = dns.name.from_text(name, dns.name.root) origin = dns.name.from_text(name, dns.name.root)
@@ -138,13 +134,12 @@ def create_zone(name, view_name) -> dns.zone.Zone:
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 = nb_zone.catz_identifier.name 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)
if not ptr_name.is_subdomain(origin): if not ptr_name.is_subdomain(origin):
raise ValueError( raise ValueError(
"Catalog zone member identifier {ptr_name.to_text()} not a subdomain" f"Catalog zone member identifier {ptr_name.to_text()} not a subdomain"
) )
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
@@ -154,7 +149,6 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# Configure DNSSec Policy for member Zone if DNSSec is enabled # Configure DNSSec Policy for member Zone if DNSSec is enabled
if nb_zone.dnssec_policy: if nb_zone.dnssec_policy:
# Configure policy
rid = dns.name.from_text("group", ptr_name) rid = dns.name.from_text("group", ptr_name)
policy_name = nb_zone.dnssec_policy.name.rstrip(" ") policy_name = nb_zone.dnssec_policy.name.rstrip(" ")
group_name = f"dnssec-policy-{policy_name}" group_name = f"dnssec-policy-{policy_name}"
@@ -164,20 +158,12 @@ def create_zone(name, view_name) -> dns.zone.Zone:
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
rdataset.add(rdata, ttl) rdataset.add(rdata, ttl)
## Configure dnssec status for member zone
# status = str(1 if nb_zone.dnssec_policy else 0)
# rid = dns.name.from_text("enabled.dnssec.ext", ptr_name)
# rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
# rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
# rdataset.add(rdata, ttl)
# SOA Record components # SOA Record components
ttl = 0 ttl = 0
rclass = dns.rdataclass.IN rclass = dns.rdataclass.IN
rtype = dns.rdatatype.SOA rtype = dns.rdatatype.SOA
mname = dns.name.from_text("invalid", dns.name.root) mname = dns.name.from_text("invalid", dns.name.root)
rname = dns.name.from_text("invalid", dns.name.root) rname = dns.name.from_text("invalid", dns.name.root)
serial = _SERIAL_OBJ.value
refresh = 60 refresh = 60
retry = 10 retry = 10
expire = 1209600 expire = 1209600
@@ -187,7 +173,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
soa_rdata = dns.rdata.from_text( soa_rdata = dns.rdata.from_text(
rclass, rclass,
rtype, rtype,
f"{mname} {rname} {serial} {refresh} {retry} {expire} {minimum}", f"{mname} {rname} {current_serial} {refresh} {retry} {expire} {minimum}",
) )
# Create Rdataset and add the RDATA to it # Create Rdataset and add the RDATA to it
@@ -209,7 +195,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
ns_node.rdatasets.append(ns_rdataset) ns_node.rdatasets.append(ns_rdataset)
# TXT record for version.catz. # TXT record for version.catz.
version_name = dns.name.from_text("version", origin) # relative to origin "catz." version_name = dns.name.from_text("version", origin)
txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"') txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"')
txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT) txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT)