Add forced NOTIFY feature for DNS zones

- Introduced `custom_field_force_notify` setting to trigger immediate NOTIFY to a zone's nameservers when its SOA serial number changes and the custom field is set to True.
- Updated README to document the new feature and its configuration.
- Refactored notify handler to include a new job for sending forced NOTIFY messages directly to zone nameservers.
- Enhanced zone signal handling to check for SOA serial changes and trigger forced NOTIFY accordingly.
This commit is contained in:
d_ev committed 2026-07-15 22:34:41 +03:00
1 parent 41d48f2807
commit dc1c223400
4 files changed
+221 -31

No files matched your search

+160 -30
View File
@@ -18,35 +18,34 @@ LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["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:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(f"Incomplete TSIG key configuration for view '{view_name}'")
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
class SendDNSNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def _load_tsig_key(self, view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(
f"Incomplete TSIG key configuration for view '{view_name}'"
)
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
view_name = kwargs["view_name"]
@@ -63,7 +62,7 @@ class SendDNSNotify(JobRunner):
]
view = View.objects.get(name=view_name)
tsig_key = self._load_tsig_key(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)
@@ -112,21 +111,139 @@ class SendDNSNotify(JobRunner):
close_old_connections()
def schedule(zone: Zone):
class SendForcedNSNotify(JobRunner):
"""Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendDNSNotify`` (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:
name = "Send forced DNS NOTIFY to zone nameservers"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("force_notify_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
try:
zone = Zone.objects.get(pk=kwargs["zone_id"])
except Zone.DoesNotExist:
LOGGER.error(f"Forced NOTIFY: zone id {kwargs['zone_id']} no longer exists")
return
except OperationalError as e:
LOGGER.error(f"Forced NOTIFY failed due to unexpected error: {e}")
close_old_connections()
return
ns_targets = _resolve_zone_nameserver_ips(zone)
if not ns_targets:
LOGGER.warning(
f"Forced NOTIFY: no nameserver addresses found in NetBox DNS for "
f"{view_name}/{zone_name} — skipping"
)
return
try:
tsig_key = _load_tsig_key(view_name)
except RuntimeError as e:
LOGGER.error(
f"Forced 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.authority.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 forced NOTIFY over TCP")
dns.query.tcp(msg, ip, port=port, timeout=2)
else:
LOGGER.debug("Sending forced NOTIFY over UDP")
dns.query.udp(msg, ip, port=port, timeout=2)
LOGGER.info(
f"Forced NOTIFY sent: {ip} {view_name}/{fqdn} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"Forced NOTIFY failed: {ip} {view_name}/{fqdn} {soa_serial}: {e}"
)
def _get_soa_serial(zone: Zone):
"""Return the current SOA serial of a zone parsed from its SOA record."""
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
return None
soa_serial = int(soa_record.value.split()[2])
return 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
return None
except (ValueError, IndexError) as e:
LOGGER.error(f"Failed to parse SOA serial for zone {zone.name}: {e}")
return None
def _resolve_zone_nameserver_ips(zone: Zone) -> list:
"""Resolve the zone's nameserver hostnames to IPs using NetBox DNS records.
For each nameserver assigned to the zone, matching active A/AAAA records
within the same view are looked up in NetBox DNS. Returns a de-duplicated,
order-preserving list of IP addresses.
"""
ips = []
try:
nameservers = list(zone.nameservers.all())
view = zone.view
for nameserver in nameservers:
fqdn = dns.name.from_text(nameserver.name, dns.name.root).to_text()
# Match both with and without a trailing dot to be resilient against
# how the fqdn is stored on the Record model.
candidates = {fqdn, fqdn.rstrip(".")}
records = Record.objects.filter(
fqdn__in=list(candidates),
type__in=["A", "AAAA"],
active=True,
zone__view=view,
)
for record in records:
if record.value not in ips:
ips.append(record.value)
except OperationalError as e:
LOGGER.error(f"DB ERROR while resolving nameservers for {zone.name}: {e}")
close_old_connections()
return ips
def schedule(zone: Zone):
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendDNSNotify.enqueue(
@@ -134,3 +251,16 @@ def schedule(zone: Zone):
view_name=zone.view.name,
soa_serial=soa_serial,
)
def schedule_force_ns_notify(zone: Zone):
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendForcedNSNotify.enqueue(
zone_id=zone.pk,
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
+23 -1
View File
@@ -15,11 +15,15 @@ def zone_pre_save(sender, instance, **kwargs):
zone = instance
if zone.pk:
try:
zone._old_name = sender.objects.only("name").get(pk=zone.pk).name
old = sender.objects.only("name", "soa_serial").get(pk=zone.pk)
zone._old_name = old.name
zone._old_soa_serial = old.soa_serial
except sender.DoesNotExist:
zone._old_name = None
zone._old_soa_serial = None
else:
zone._old_name = None
zone._old_soa_serial = None
@receiver(post_save, sender=Zone)
@@ -37,6 +41,12 @@ def zone_post_save(sender, instance, created, **kwargs):
except LookupError:
pass
# Determine whether the zone's SOA serial number changed with this save.
# A newly created zone is always considered to have a "changed" serial.
old_soa_serial = getattr(zone, "_old_soa_serial", None)
new_soa_serial = getattr(zone, "soa_serial", None)
soa_serial_changed = created or (old_soa_serial != new_soa_serial)
# On commit, increment soa serial and if notify is enabled, schedule bg job.
def _on_commit():
catzm.increment_soa_serial()
@@ -44,6 +54,12 @@ def zone_post_save(sender, instance, created, **kwargs):
if SETTINGS.get("notify_clients", False):
notify_handler.schedule(zone)
# Force a NOTIFY to the zone's own nameservers when the zone carries the
# configured boolean custom field set to True and its SOA serial changed.
cf_name = SETTINGS.get("custom_field_force_notify")
if cf_name and soa_serial_changed and _force_notify_enabled(zone, cf_name):
notify_handler.schedule_force_ns_notify(zone)
transaction.on_commit(_on_commit)
if created:
@@ -59,3 +75,9 @@ def zone_post_save(sender, instance, created, **kwargs):
@receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs):
catzm.increment_soa_serial()
def _force_notify_enabled(zone, cf_name: str) -> bool:
"""Return True only if the zone's boolean custom field ``cf_name`` is True."""
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True