diff --git a/CHANGELOG.md b/CHANGELOG.md index 363eed7..2b74c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased +- Add `custom_field_force_notify` setting. When set to the name of a boolean custom field on the + NetBox DNS Zone object, the plugin sends a NOTIFY directly to a zone's own nameservers whenever + the zone has that custom field set to `True` and its SOA serial number changes. Nameserver + addresses are resolved from NetBox DNS A/AAAA records within the zone's view and the NOTIFY is + signed with the per-view TSIG key. A companion `force_notify_port` setting (default 53) controls + the destination port. + ## 1.0.7 - 2026-03-02 README Change - Moving private keys to global scope since Bind 9.20 view scoped keys have become unreliable and sometimes wouldnt match. diff --git a/README.md b/README.md index 4de3bf6..1effa9b 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,36 @@ list of clients to be notified on zone changes. Default is 24 hours. ``` Switch to using TCP instead of UDP. Defaults to False +#### Forced NOTIFY to a zone's nameservers +In addition to notifying clients that queried the transfer endpoint, the plugin can send a NOTIFY +directly to a zone's own nameservers whenever that zone's SOA serial number changes. This is useful +when you want specific zones to have their nameservers pull updates immediately, independent of the +`notify_clients` mechanism. + +The feature is driven by a boolean custom field on the NetBox DNS Zone object. Create a boolean +custom field (e.g. `dns_bridge_force_notify_ns`) that applies to `netbox_dns > zone`, then point the +plugin at it: +``` +'custom_field_force_notify': 'dns_bridge_force_notify_ns' +``` +When this setting is present, every time a zone whose custom field is set to `True` changes its SOA +serial number, the plugin schedules a background NOTIFY to each of the zone's configured nameservers. +Only a change of the SOA serial triggers this — other zone edits that leave the serial untouched do +not. The setting is unset by default, which disables the feature entirely. + +The nameserver addresses are resolved from NetBox DNS itself by looking up active A/AAAA records +(within the zone's view) matching each nameserver's hostname. The NOTIFY messages are signed with the +same per-view TSIG key configured under `tsig_keys`, so the receiving nameservers must accept NOTIFY +signed with that key (e.g. BIND's `allow-notify { key "..."; };`). + +``` +'force_notify_port': 53 +``` +Destination port used for these forced NOTIFY messages. Defaults to 53. + +As with the client NOTIFY feature, this uses NetBox's background job system, so at least one +`rq-worker` service must be running. + ## Plugin compatibility This plugin extends the netbox-plugin-dns plugin. As such the versioning was changed to match the one of netbox-plugin-dns more closely. To guarantee compatability, ensure that the major and minor diff --git a/netbox_dns_bridge/notify_handler.py b/netbox_dns_bridge/notify_handler.py index c8bda1f..54b6b77 100644 --- a/netbox_dns_bridge/notify_handler.py +++ b/netbox_dns_bridge/notify_handler.py @@ -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, + ) diff --git a/netbox_dns_bridge/signals/netbox_dns_zones.py b/netbox_dns_bridge/signals/netbox_dns_zones.py index 38712d1..8a6656a 100644 --- a/netbox_dns_bridge/signals/netbox_dns_zones.py +++ b/netbox_dns_bridge/signals/netbox_dns_zones.py @@ -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