diff --git a/CHANGELOG.md b/CHANGELOG.md index 363eed7..0fc6ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased +- Add the option to send a NOTIFY directly to a zone's own nameservers when its SOA serial number + changes. Enable it per zone via a boolean custom field named by `notify_ns_custom_field_name`, + globally for all zones via `notify_ns_all_zones` (default `False`), or both. 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 `notify_ns_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..26b36d8 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,45 @@ list of clients to be notified on zone changes. Default is 24 hours. ``` Switch to using TCP instead of UDP. Defaults to False +#### NOTIFY to a zone's own 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 nameservers to pull updates immediately, independent of the `notify_clients` mechanism. +Only a change of the SOA serial triggers this — other zone edits that leave the serial untouched do +not. + +This can be enabled per zone, globally, or both. If neither of the settings below is configured, the +feature is disabled entirely. + +**Per zone (custom field).** Create a boolean custom field (e.g. `dns_bridge_notify_ns`) that applies +to `netbox_dns > zone`, then point the plugin at it: +``` +'notify_ns_custom_field_name': 'dns_bridge_notify_ns' +``` +A NOTIFY is then sent for any zone whose custom field is set to `True`. This lets you toggle the +behavior per zone straight from the NetBox UI, without changing the plugin config. + +**Globally (all zones).** +``` +'notify_ns_all_zones': True +``` +When enabled, a NOTIFY is sent for every zone on serial change, regardless of the custom field. +Defaults to `False`. The two options coexist: a NOTIFY is sent if the global switch is on **or** the +zone's custom field is set. + +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 "..."; };`). + +``` +'notify_ns_port': 53 +``` +Destination port used for these 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..229b2ef 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 SendZoneNSNotify(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 DNS NOTIFY to zone nameservers" + + def run(self, *args, **kwargs): + notify_over_tcp = SETTINGS.get("notify_over_tcp", False) + port = SETTINGS.get("notify_ns_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"NS NOTIFY: zone id {kwargs['zone_id']} no longer exists") + return + except OperationalError as e: + LOGGER.error(f"NS 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"NS 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"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.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 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}" + ) + + +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_notify_ns(zone: Zone): + soa_serial = _get_soa_serial(zone) + if soa_serial is None: + return + + SendZoneNSNotify.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..8d617e8 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,11 @@ def zone_post_save(sender, instance, created, **kwargs): if SETTINGS.get("notify_clients", False): notify_handler.schedule(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_notify_ns(zone) + transaction.on_commit(_on_commit) if created: @@ -59,3 +74,21 @@ 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 _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