modified: ../README.md

modified:   __init__.py
modified:   catalog_zone_manager.py
modified:   dns_server.py
modified:   notify_handler.py
Adding serial to notify messages to allow client to determine state.
This commit is contained in:
Sven Luethi committed 2026-07-11 00:04:45 +02:00
1 parent 3eb17187b4
commit f5791e94d5
5 files changed
+84 -26

No files matched your search

+4
View File
@@ -91,6 +91,10 @@ 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
```
Switch to using TCP instead of UDP. Default: 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
@@ -4,7 +4,7 @@ from .logger import get_logger
__version__ = "1.5.4" __version__ = "1.5.4"
logger = get_logger(__name__) LOGGER = get_logger(__name__)
class DNSBridgeConfig(PluginConfig): class DNSBridgeConfig(PluginConfig):
+12 -12
View File
@@ -9,7 +9,7 @@ from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.db import transaction, close_old_connections, OperationalError from django.db import transaction, close_old_connections, OperationalError
logger = get_logger(__name__) LOGGER = get_logger(__name__)
def init() -> None: def init() -> None:
@@ -26,18 +26,18 @@ def increment_soa_serial() -> int:
new_soa_serial = soa_serial.value + 1 new_soa_serial = soa_serial.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.value} reached max — wrapping back to 1"
) )
new_soa_serial = 1 new_soa_serial = 1
soa_serial.value = new_soa_serial soa_serial.value = new_soa_serial
soa_serial.save() soa_serial.save()
soa_serial.refresh_from_db() soa_serial.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.value}")
return soa_serial.value return soa_serial.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}"
) )
close_old_connections() close_old_connections()
@@ -108,7 +108,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return zone return zone
except OperationalError as e: except OperationalError as e:
logger.error(f"ERROR: Failed to create Catalog Zone: {e}") LOGGER.error(f"ERROR: Failed to create Catalog Zone: {e}")
close_old_connections() close_old_connections()
return None return None
@@ -116,14 +116,14 @@ def create_zone(name, view_name) -> dns.zone.Zone:
def _init_soa_serial() -> None: def _init_soa_serial() -> None:
try: try:
IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
logger.info("Catalog zone SOA serial number loaded from database") LOGGER.info("Catalog zone SOA serial number loaded from database")
except IntegerKeyValueSetting.DoesNotExist: except IntegerKeyValueSetting.DoesNotExist:
IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1) IntegerKeyValueSetting.objects.create(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" "Catalog zone SOA serial number was not set in the database. Set to 1"
) )
except OperationalError as e: except OperationalError as e:
logger.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}") LOGGER.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}")
close_old_connections() close_old_connections()
@@ -138,7 +138,7 @@ def update_member_identifier(zone: Zone) -> None:
defaults={"name": _generate_member_identifier()}, defaults={"name": _generate_member_identifier()},
) )
except OperationalError as e: except OperationalError as e:
logger.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}") LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}")
close_old_connections() close_old_connections()
@@ -159,7 +159,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} missing catz member identifier. Creating..." f"Zone {identifier.zone} missing catz member identifier. Creating..."
) )
@@ -169,7 +169,7 @@ def _create_missing_member_identifiers() -> None:
) )
except OperationalError as e: except OperationalError as e:
logger.error(f"ERROR: Could not create catz member identifier: {e}") LOGGER.error(f"ERROR: Could not create catz member identifier: {e}")
close_old_connections() close_old_connections()
@@ -201,7 +201,7 @@ def _create_soa_rdataset() -> dns.rdataset:
return rdataset return rdataset
except OperationalError as e: except OperationalError as e:
logger.error(f"ERROR: Failed to access catz SOA record in database: {e}") LOGGER.error(f"ERROR: Failed to access catz SOA record in database: {e}")
close_old_connections() close_old_connections()
+1 -1
View File
@@ -2,7 +2,7 @@ import socketserver
import socket import socket
from .logger import get_logger from .logger import get_logger
logger = get_logger(__name__) LOGGER = get_logger(__name__)
class DNSAddressMixin: class DNSAddressMixin:
+66 -12
View File
@@ -3,16 +3,18 @@ import dns.name
import dns.opcode import dns.opcode
import dns.query import dns.query
import dns.rdatatype import dns.rdatatype
import dns.rdataclass
import dns.rrset
import dns.tsig import dns.tsig
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.db import close_old_connections, OperationalError from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone from netbox_dns.models import View, Zone, Record
from .logger import get_logger from .logger import get_logger
from .models import SeenTransferClients from .models import SeenTransferClients, IntegerKeyValueSetting
logger = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"] SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
@@ -46,41 +48,93 @@ class SendDNSNotify(JobRunner):
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm) return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
view_name = kwargs["view_name"] view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
try: try:
catz_serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value
zones = [
(kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial),
(f"{view_name}.catz", catz_serial)
]
view = View.objects.get(name=view_name) view = View.objects.get(name=view_name)
tsig_key = self._load_tsig_key(view_name) tsig_key = self._load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24) cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = SeenTransferClients.objects.filter( clients = SeenTransferClients.objects.filter(
view=view, last_seen__gte=seen_cutoff view=view, last_seen__gte=seen_cutoff
) )
for name in (zone_name, "catz", f"{view_name}.catz"): for zone_name, soa_serial in zones:
msg = dns.message.make_query(name, dns.rdatatype.SOA) msg = dns.message.make_query(zone_name, dns.rdatatype.SOA)
msg.flags = 0 msg.flags = 0
msg.set_opcode(dns.opcode.NOTIFY) msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid invalid {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(
dns.name.from_text(zone_name, dns.name.root), 0, soa_rdata
)
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)
for client in clients: for client in clients:
try: try:
dns.query.udp(msg, client.source_ip, port=5355, timeout=2) if notify_over_tcp:
self.logger.info( LOGGER.info("Sending over TCP")
f"NOTIFY sent to {client.source_ip} for zone {name}" dns.query.tcp(msg, client.source_ip, timeout=2)
else:
LOGGER.info("Sending over UDP")
dns.query.udp(msg, client.source_ip, timeout=2)
LOGGER.info(
f"NOTIFY sent to {client.source_ip} for zone {zone_name}"
) )
except Exception as e: except Exception as e:
self.logger.error( LOGGER.error(
f"NOTIFY failed for {client.source_ip} zone {name}: {e}" f"NOTIFY failed for {client.source_ip} zone {zone_name}: {e}"
) )
except IntegerKeyValueSetting.DoesNotExist as e:
LOGGER.error(f"Failed to get catalog zone serial: {e}")
except OperationalError as e: except OperationalError as e:
self.logger.error(f"NOTIFY failed due to unexpected error: {e}") LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
close_old_connections() close_old_connections()
def schedule(zone: Zone): def schedule(zone: Zone):
try:
soa_record = Record.objects.filter(
zone=zone, type="SOA", active=True
).first()
if soa_record is None:
LOGGER.error(
f"Zone {zone.name} has no active SOA record — skipping NOTIFY"
)
return
soa_serial = int(soa_record.value.split()[2])
except OperationalError as e:
LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}")
close_old_connections()
return
except (ValueError, IndexError) as e:
LOGGER.error(
f"Failed to parse SOA serial for zone {zone.name}: {e}"
)
return
SendDNSNotify.enqueue( SendDNSNotify.enqueue(
zone_name=zone.name, zone_name=zone.name,
view_name=zone.view.name, view_name=zone.view.name,
soa_serial=soa_serial,
) )