Compare commits

..
5 Commits
Author SHA1 Message Date
Sven Luthi 41d48f2807 modified: netbox_dns_bridge/__init__.py
modified:   netbox_dns_bridge/catalog_zone_manager.py
Fix wrong import in previous version and slight change to
meachanism to create catz member identifier.
2026-07-15 17:48:49 +02:00
Sven Luthi e50cfd7cb6 modified: netbox_dns_bridge/__init__.py
Bump version
2026-07-14 17:51:37 +02:00
Sven Luthi 55f6622997 modified: README.md 2026-07-14 14:53:12 +02:00
Sven Luethi fd8a5a0d9d modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Range of bug fixes and improvements
2026-07-13 13:41:29 +02:00
Sven Luethi b72fa9b664 modified: README.md
modified:   netbox_dns_bridge/notify_handler.py
2026-07-11 02:46:17 +02:00
7 changed files with 55 additions and 90 deletions

No files matched your search

+9 -7
View File
@@ -74,27 +74,29 @@ multiple views yields unpredicted behavior.
#### NOTIFY #### NOTIFY
Clients may be notified of zone changes using the NOTIFY mechanism defined in RFC 1996. Clients may be notified of zone changes using the NOTIFY mechanism defined in RFC 1996.
When enabled, the zone transfer endpoint keeps track of any client that successfully queried the When enabled, the zone transfer endpoint keeps track of any client that successfully queried the
endpoint and when a zone changes. Once a zone has changed, NetBox informs each client of change. endpoint. Once a zone has changed, NetBox informs each client about the zone changed so that the
client can request a new zone transfer.
Note that this feature uses NetBox's background job system to schedule the messages asynchronously. Note that this feature uses NetBox's background job system to schedule the messages asynchronously.
In order to work, you need at least one `rq-worker` service running in the background to handle In order to work, you need at least one `rq-worker` service running in the background to handle
the queried jobs. the queued jobs.
To enable the NOTIFY feature, set following: ##### NOTIFY Settings
``` ```
'notify_clients': True 'notify_clients': True
``` ```
##### NOTIFY Settings This enables the NOTIFY system. Defaults to False
``` ```
notify_client_alive_threshold_hours: 24 'notify_client_alive_threshold_hours': 24
``` ```
This sets how long a client is considred "alive" after it last queried the transfer endpoint. This sets how long a client is considred "alive" after it last queried the transfer endpoint.
Once the client has failed to check in for this amout of time, it is automatically removed from the Once the client has failed to check in for this amout of time, it is automatically removed from the
list of clients to be notified on zone changes. Default is 24 hours. list of clients to be notified on zone changes. Default is 24 hours.
``` ```
notify_over_tcp: True 'notify_over_tcp': True
``` ```
Switch to using TCP instead of UDP. Default: False Switch to using TCP instead of UDP. Defaults to False
## Plugin compatibility ## Plugin compatibility
This plugin extends the netbox-plugin-dns plugin. As such the versioning was changed to match the This plugin extends the netbox-plugin-dns plugin. As such the versioning was changed to match the
+1 -1
View File
@@ -2,7 +2,7 @@ from netbox.plugins import PluginConfig
from django.conf import settings from django.conf import settings
from .logger import get_logger from .logger import get_logger
__version__ = "1.5.4" __version__ = "1.5.6"
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
+28 -64
View File
@@ -3,7 +3,7 @@ import dns.zone
import dns.rdatatype import dns.rdatatype
import dns.rdataclass import dns.rdataclass
from .logger import get_logger from .logger import get_logger
from netbox_dns.models import Zone from netbox_dns.models import Zone as NBZone
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
@@ -12,30 +12,30 @@ from django.db import transaction, close_old_connections, OperationalError
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
def init() -> None:
_init_soa_serial()
_create_missing_member_identifiers()
def increment_soa_serial() -> int: def increment_soa_serial() -> int:
with transaction.atomic(): with transaction.atomic():
try: try:
soa_serial = IntegerKeyValueSetting.objects.select_for_update().get( soa_serial_obj = (
key="catalog-zone-soa-serial" IntegerKeyValueSetting.objects.select_for_update().get_or_create(
key="catalog-zone-soa-serial",
defaults={"value": 1},
)
) )
new_soa_serial = soa_serial.value + 1 new_soa_serial = soa_serial_obj.value + 1
if new_soa_serial > 0xFFFFFFFF: if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning( LOGGER.warning(
f"Catalog serial {soa_serial.value} reached max — wrapping back to 1" f"Catalog serial {soa_serial_obj.value} reached max — wrapping back to 1"
) )
new_soa_serial = 1 new_soa_serial = 1
soa_serial.value = new_soa_serial soa_serial_obj.value = new_soa_serial
soa_serial.save() soa_serial_obj.save()
soa_serial.refresh_from_db() soa_serial_obj.refresh_from_db()
LOGGER.debug(f"Catalog zone SOA serial number is now {soa_serial.value}") LOGGER.debug(
f"Catalog zone SOA serial number is now {soa_serial_obj.value}"
)
return soa_serial.value return soa_serial_obj.value
except OperationalError as e: except OperationalError as e:
LOGGER.error( LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
@@ -53,7 +53,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
try: try:
# get zones from netbox # get zones from netbox
nb_zones = Zone.objects.filter( nb_zones = NBZone.objects.filter(
view__name=view_name, active=True view__name=view_name, active=True
).select_related("catz_identifier") ).select_related("catz_identifier")
@@ -64,7 +64,14 @@ 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 = nb_zone.catz_identifier.name try:
catz_identifier = nb_zone.catz_identifier
except NBZone.catz_identifier.RelatedObjectDoesNotExist:
catz_identifier = CatalogZoneMemberIdentifier.objects.create(
zone=nb_zone, name=_generate_member_identifier()
)
p_name = 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):
@@ -113,25 +120,11 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return None return None
def _init_soa_serial() -> None:
try:
IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
LOGGER.info("Catalog zone SOA serial number loaded from database")
except IntegerKeyValueSetting.DoesNotExist:
IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1)
LOGGER.debug(
"Catalog zone SOA serial number was not set in the database. Set to 1"
)
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}")
close_old_connections()
def _generate_member_identifier() -> str: def _generate_member_identifier() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8") return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: Zone) -> None: def update_member_identifier(zone: NBZone) -> None:
try: try:
CatalogZoneMemberIdentifier.objects.update_or_create( CatalogZoneMemberIdentifier.objects.update_or_create(
zone=zone, zone=zone,
@@ -142,40 +135,11 @@ def update_member_identifier(zone: Zone) -> None:
close_old_connections() close_old_connections()
def _create_missing_member_identifiers() -> None:
try:
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_member_identifier(),
)
for zone in missing_zones
]
for identifier in new_objects:
LOGGER.debug(
f"Zone {identifier.zone} missing catz member identifier. Creating..."
)
CatalogZoneMemberIdentifier.objects.bulk_create(
new_objects,
ignore_conflicts=False,
)
except OperationalError as e:
LOGGER.error(f"ERROR: Could not create catz member identifier: {e}")
close_old_connections()
def _create_soa_rdataset() -> dns.rdataset: def _create_soa_rdataset() -> dns.rdataset:
try: try:
serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value serial = IntegerKeyValueSetting.objects.get_or_create(
key="catalog-zone-soa-serial", defaults={"value": 1}
).value
# SOA Record components # SOA Record components
ttl = 0 ttl = 0
@@ -14,7 +14,6 @@ from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from django.db import close_old_connections, OperationalError from django.db import close_old_connections, OperationalError
from netbox_dns.models import View from netbox_dns.models import View
from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler
from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer
from netbox_dns_bridge.logger import get_logger from netbox_dns_bridge.logger import get_logger
@@ -80,7 +79,6 @@ class Command(BaseCommand):
# Load parameters # Load parameters
port = options["port"] port = options["port"]
address = options["address"] address = options["address"]
catzm.init()
self.tsig_keys = SETTINGS.get("tsig_keys", None) self.tsig_keys = SETTINGS.get("tsig_keys", None)
if not self.tsig_keys: if not self.tsig_keys:
+9 -15
View File
@@ -52,12 +52,14 @@ class SendDNSNotify(JobRunner):
view_name = kwargs["view_name"] view_name = kwargs["view_name"]
try: try:
catz_serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value catz_serial = IntegerKeyValueSetting.objects.get(
key="catalog-zone-soa-serial"
).value
zones = [ zones = [
(kwargs["zone_name"], kwargs["soa_serial"]), (kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial), ("catz", catz_serial),
(f"{view_name}.catz", catz_serial) (f"{view_name}.catz", catz_serial),
] ]
view = View.objects.get(name=view_name) view = View.objects.get(name=view_name)
@@ -72,7 +74,7 @@ class SendDNSNotify(JobRunner):
for zone_name, soa_serial in zones: for zone_name, soa_serial in zones:
zone_name = dns.name.from_text(zone_name, dns.name.root).to_text() zone_name = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(zone_name, dns.rdatatype.SOA) msg = dns.message.make_query(zone_name, dns.rdatatype.SOA)
msg.flags = 0 msg.flags |= dns.flags.AA # = 0
msg.set_opcode(dns.opcode.NOTIFY) msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text( soa_rdata = dns.rdata.from_text(
@@ -80,9 +82,7 @@ class SendDNSNotify(JobRunner):
dns.rdatatype.SOA, dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0", f"invalid. invalid. {soa_serial} 60 10 1209600 0",
) )
soa_rrset = dns.rrset.from_rdata( soa_rrset = dns.rrset.from_rdata(zone_name, 0, soa_rdata)
zone_name, 0, soa_rdata
)
msg.authority.append(soa_rrset) msg.authority.append(soa_rrset)
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name) msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
@@ -114,13 +114,9 @@ class SendDNSNotify(JobRunner):
def schedule(zone: Zone): def schedule(zone: Zone):
try: try:
soa_record = Record.objects.filter( soa_record = Record.objects.filter(zone=zone, type="SOA", active=True).first()
zone=zone, type="SOA", active=True
).first()
if soa_record is None: if soa_record is None:
LOGGER.error( LOGGER.error(f"Zone {zone.name} has no active SOA record — skipping NOTIFY")
f"Zone {zone.name} has no active SOA record — skipping NOTIFY"
)
return return
soa_serial = int(soa_record.value.split()[2]) soa_serial = int(soa_record.value.split()[2])
@@ -130,9 +126,7 @@ def schedule(zone: Zone):
close_old_connections() close_old_connections()
return return
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
LOGGER.error( LOGGER.error(f"Failed to parse SOA serial for zone {zone.name}: {e}")
f"Failed to parse SOA serial for zone {zone.name}: {e}"
)
return return
SendDNSNotify.enqueue( SendDNSNotify.enqueue(
+4
View File
@@ -215,6 +215,10 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
else: else:
rrsets.append(rrset) rrsets.append(rrset)
if soa_rrset is None:
LOGGER.error(f"Zone {dname} has no SOA — aborting")
return
rrsets.insert(0, soa_rrset) # Opening SOA rrsets.insert(0, soa_rrset) # Opening SOA
rrsets.append(soa_rrset) # Closing SOA rrsets.append(soa_rrset) # Closing SOA
@@ -27,13 +27,15 @@ def zone_post_save(sender, instance, created, **kwargs):
zone = instance zone = instance
# Quit if this is not the first signal in this session for this zone. # Quit if this is not the first signal in this session for this zone.
try:
request = current_request.get() request = current_request.get()
if request is not None:
handled_zones = getattr(request, "_handled_zones", set()) handled_zones = getattr(request, "_handled_zones", set())
if zone.pk in handled_zones: if zone.pk in handled_zones:
return return
handled_zones.add(zone.pk) handled_zones.add(zone.pk)
request._handled_zones = handled_zones request._handled_zones = handled_zones
except LookupError:
pass
# On commit, increment soa serial and if notify is enabled, schedule bg job. # On commit, increment soa serial and if notify is enabled, schedule bg job.
def _on_commit(): def _on_commit():
@@ -53,6 +55,7 @@ def zone_post_save(sender, instance, created, **kwargs):
if old_name and zone.name != old_name: if old_name and zone.name != old_name:
catzm.update_member_identifier(zone) catzm.update_member_identifier(zone)
@receiver(post_delete, sender=Zone) @receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs): def zone_post_delete(sender, instance, **kwargs):
catzm.increment_soa_serial() catzm.increment_soa_serial()