modified: CHANGELOG.md

new file:   netbox_dns_bridge/jobs/__init__.py
renamed:    netbox_dns_bridge/signals/notify_handler.py -> netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Restructuring
This commit is contained in:
sven authored and Sven Luthi committed 2026-09-02 12:02:55 +02:00
1 parent 6be7359c27
commit 6d4fb82ac5
5 files changed
+130 -106

No files matched your search

+12
View File
@@ -1,4 +1,16 @@
## Unreleased
- Move `notify_handler` from `signals/` to `jobs/` and rename to `notify`.
- Rename `SendClientDNSNotify` to `SendClientNotify`. It now sends a single NOTIFY
for the zone only; catalog zone entries (`catz`, `{view}.catz`) have been removed.
- Catalog zone NOTIFY is now scheduled by `increment_soa_serial` via
`schedule_catalog_zone_notify`, which enqueues `SendClientNotify` for `catz` and
`{view}.catz` — no separate job class needed.
- Extract a shared `_send_notify` helper used by both `SendClientNotify` and
`SendNSNotify` for message building and the send loop.
- Move all NOTIFY scheduling gates (`notify_clients`, `notify_ns_all_zones`, custom
field) into the scheduling functions. The signal handler calls the scheduling
functions unconditionally; each function decides whether to enqueue based on its
own settings gate.
- Replace the global `IntegerKeyValueSetting` model (single `catalog-zone-soa-serial`
value shared by all catalog zones) with a new `CatalogZone` model that has a 1:1
foreign key to `netbox_dns.models.View` and its own `soa_serial` field. Each view's
View File
Whitespace-only changes.
@@ -17,6 +17,7 @@ from netbox_dns_bridge.models import SeenTransferClients, CatalogZone
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def _load_tsig_key(view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
@@ -41,41 +42,20 @@ def _load_tsig_key(view_name: str) -> dns.tsig.Key:
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
class SendClientDNSNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"]
try:
view = View.objects.get(name=view_name)
try:
catz_serial = view.catalog_zone.soa_serial
except CatalogZone.DoesNotExist as e:
LOGGER.error(f"Failed to get catalog zone serial: {e}")
return
zones = [
(kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial),
(f"{view_name}.catz", catz_serial),
]
tsig_key = _load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = SeenTransferClients.objects.filter(
view=view, last_seen__gte=seen_cutoff
)
for zone_name, soa_serial in zones:
zone_name = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(zone_name, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA # = 0
def _send_notify(
zone_name,
soa_serial,
targets,
tsig_key,
view_name,
notify_over_tcp,
port,
log_prefix="NOTIFY",
):
"""Build a DNS NOTIFY message for *zone_name* and send it to every target IP."""
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
@@ -83,26 +63,54 @@ class SendClientDNSNotify(JobRunner):
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(zone_name, 0, soa_rdata)
msg.answer.append(soa_rrset)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for client in clients:
for target in targets:
try:
if notify_over_tcp:
LOGGER.debug("Sending NOTIFY over TCP")
dns.query.tcp(msg, client.source_ip, port=port, timeout=2)
LOGGER.debug(f"Sending {log_prefix} over TCP")
dns.query.tcp(msg, target, port=port, timeout=2)
else:
LOGGER.debug("Sending NOTIFY over UDP")
dns.query.udp(msg, client.source_ip, port=port, timeout=2)
LOGGER.debug(f"Sending {log_prefix} over UDP")
dns.query.udp(msg, target, port=port, timeout=2)
LOGGER.info(
f"NOTIFY sent: {client.source_ip} {view_name}/{zone_name} {soa_serial}"
f"{log_prefix} sent: {target} {view_name}/{fqdn} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"NOTIFY failed: {client.source_ip}:{port}"
f" {view_name}/{zone_name} {soa_serial}: {e}"
f"{log_prefix} failed: {target}:{port}"
f" {view_name}/{fqdn} {soa_serial}: {e}"
)
class SendClientNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
try:
view = View.objects.get(name=view_name)
tsig_key = _load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = list(
SeenTransferClients.objects.filter(
view=view, last_seen__gte=seen_cutoff
).values_list("source_ip", flat=True)
)
_send_notify(
zone_name, soa_serial, clients, tsig_key,
view_name, notify_over_tcp, port, log_prefix="NOTIFY",
)
except View.DoesNotExist as e:
@@ -116,10 +124,10 @@ class SendClientDNSNotify(JobRunner):
class SendNSNotify(JobRunner):
"""Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendClientDNSNotify`` (which notifies clients that previously queried the
transfer endpoint), this job resolves the addresses of the zone's configured
nameservers from NetBox DNS itself and notifies each of them so they pick up
the changed zone quickly.
Unlike ``SendClientNotify`` (which notifies clients that previously queried
the transfer endpoint), this job resolves the addresses of the zone's
configured nameservers from NetBox DNS itself and notifies each of them so
they pick up the changed zone quickly.
"""
class Meta:
@@ -156,32 +164,9 @@ class SendNSNotify(JobRunner):
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
return
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
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",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for ip in ns_targets:
try:
if notify_over_tcp:
LOGGER.debug("Sending NS NOTIFY over TCP")
dns.query.tcp(msg, ip, port=port, timeout=2)
else:
LOGGER.debug("Sending NS NOTIFY over UDP")
dns.query.udp(msg, ip, port=port, timeout=2)
LOGGER.info(f"NS NOTIFY sent: {ip} {view_name}/{fqdn} {soa_serial}")
except Exception as e:
LOGGER.error(
f"NS NOTIFY failed: {ip} {view_name}/{fqdn} {soa_serial}: {e}"
_send_notify(
zone_name, soa_serial, ns_targets, tsig_key,
view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
)
@@ -239,12 +224,33 @@ def _resolve_zone_nameserver_ips(zone: Zone) -> list:
return ips
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
def schedule_client_notify(zone: Zone):
if not SETTINGS.get("notify_clients", False):
return
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendClientDNSNotify.enqueue(
SendClientNotify.enqueue(
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
@@ -252,6 +258,9 @@ def schedule_client_notify(zone: Zone):
def schedule_ns_notify(zone: Zone):
if not _notify_ns_enabled(zone):
return
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
@@ -262,3 +271,24 @@ def schedule_ns_notify(zone: Zone):
view_name=zone.view.name,
soa_serial=soa_serial,
)
def schedule_catalog_zone_notify(view: View):
if not SETTINGS.get("notify_clients", False):
return
try:
serial = view.catalog_zone.soa_serial
except CatalogZone.DoesNotExist:
return
SendClientNotify.enqueue(
zone_name="catz",
soa_serial=serial,
view_name=view.name,
)
SendClientNotify.enqueue(
zone_name=f"{view.name}.catz",
soa_serial=serial,
view_name=view.name,
)
@@ -5,6 +5,7 @@ import dns.rdataclass
from netbox_dns_bridge.utils import get_logger
from netbox_dns.models import Zone as NBZone, View as NBView
from netbox_dns_bridge.models import CatalogZone, CatalogZoneMemberIdentifier
from netbox_dns_bridge.jobs import notify
from uuid import uuid4
from base64 import b32encode
from django.db import transaction, close_old_connections, OperationalError
@@ -38,12 +39,16 @@ def increment_soa_serial(view: NBView) -> int:
f"is now {soa_serial_obj.soa_serial}"
)
return soa_serial_obj.soa_serial
serial = soa_serial_obj.soa_serial
except OperationalError as e:
LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
)
close_old_connections()
return None
notify.schedule_catalog_zone_notify(view)
return serial
def create_zone(name, view_name) -> dns.zone.Zone:
+6 -29
View File
@@ -1,13 +1,11 @@
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.db import transaction
from django.conf import settings
from netbox.context import current_request
from netbox_dns.models import Zone, View
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm
from . import notify_handler
from netbox_dns_bridge.jobs import notify
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@receiver(pre_save, sender=Zone)
def zone_pre_save(sender, instance, **kwargs):
@@ -61,7 +59,8 @@ def zone_post_save(sender, instance, created, **kwargs):
and old_view_id != zone.view_id
)
# On commit, increment soa serial and if notify is enabled, schedule bg job.
# On commit, increment soa serial and schedule notify jobs. All settings
# gating happens inside the scheduling functions.
def _on_commit():
if view_changed:
try:
@@ -71,13 +70,9 @@ def zone_post_save(sender, instance, created, **kwargs):
pass
catzm.increment_soa_serial(zone.view)
if SETTINGS.get("notify_clients", False):
notify_handler.schedule_client_notify(zone)
# Send a NOTIFY to the zone's own nameservers when its SOA serial changed
# and the feature is enabled — globally or per zone via a custom field.
if soa_serial_changed and _notify_ns_enabled(zone):
notify_handler.schedule_ns_notify(zone)
if soa_serial_changed:
notify.schedule_client_notify(zone)
notify.schedule_ns_notify(zone)
transaction.on_commit(_on_commit)
@@ -99,21 +94,3 @@ def zone_post_delete(sender, instance, **kwargs):
catzm.increment_soa_serial(instance.view)
transaction.on_commit(_on_commit)
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True