Compare commits

...
11 Commits
Author SHA1 Message Date
Sven Luethi c054a00039 Bump to v1.5.7 2026-07-16 14:27:49 +02:00
Sven Luethi 4eb018d8ca Merge pull request #25 from dmitriev-e/feature/force-notify-ns
Feature/force notify ns
2026-07-16 14:24:31 +02:00
d_ev 4ad41360de Address review: rename settings to notify_ns_* and add global toggle
Per PR review feedback:
- Rename settings: custom_field_force_notify -> notify_ns_custom_field_name,
  force_notify_port -> notify_ns_port.
- Add notify_ns_all_zones (bool, default False) to enable the feature globally
  for every zone; it coexists with the per-zone custom field — a NOTIFY is sent
  if the global switch is on OR the zone's custom field is set.
- Rename the internal job/scheduler/log strings to the notify_ns naming for
  consistency (SendZoneNSNotify, schedule_notify_ns).
- Update README and CHANGELOG.
2026-07-16 14:57:14 +03:00
Sven Luethi f80318707d Merge pull request #24 from dmitriev-e/fix/catalog-zone-get-or-create
Fix/catalog zone get or create
2026-07-16 09:07:45 +02:00
d_ev db09fd70be Fix get_or_create tuple unpacking in catalog_zone_manager
v1.5.6 fixed the wrong RelatedObjectDoesNotExist import and the catz
member-identifier creation, but two get_or_create() calls still used the
returned value directly instead of unpacking the (obj, created) tuple:

- increment_soa_serial(): `soa_serial_obj.value` on a tuple
- _create_soa_rdataset(): `.get_or_create(...).value` on a tuple

Both raised `'tuple' object has no attribute 'value'` at runtime — the
first on every zone save, the second on every catalog-zone (catz) query.
Unpack the tuple in both places.
2026-07-15 22:44:53 +03:00
d_ev dc1c223400 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.
2026-07-15 22:34:41 +03:00
Sven Luthi 41d48f2807 modified: netbox_dns_bridge/__init__.py
modified:   netbox_dns_bridge/catalog_zone_manager.py
Fix wrong import in previous version and slight change to
meachanism to create catz member identifier.
2026-07-15 17:48:49 +02:00
Sven Luthi e50cfd7cb6 modified: netbox_dns_bridge/__init__.py
Bump version
2026-07-14 17:51:37 +02:00
Sven Luthi 55f6622997 modified: README.md 2026-07-14 14:53:12 +02:00
Sven Luethi fd8a5a0d9d modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Range of bug fixes and improvements
2026-07-13 13:41:29 +02:00
Sven Luethi b72fa9b664 modified: README.md
modified:   netbox_dns_bridge/notify_handler.py
2026-07-11 02:46:17 +02:00
8 changed files with 299 additions and 123 deletions

No files matched your search

+8
View File
@@ -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.
+48 -7
View File
@@ -74,27 +74,68 @@ multiple views yields unpredicted behavior.
#### NOTIFY
Clients may be notified of zone changes using the NOTIFY mechanism defined in RFC 1996.
When enabled, the zone transfer endpoint keeps track of any client that successfully queried the
endpoint and when a zone changes. Once a zone has changed, NetBox informs each client of change.
endpoint. Once a zone has changed, NetBox informs each client about the zone changed so that the
client can request a new zone transfer.
Note that this feature uses NetBox's background job system to schedule the messages asynchronously.
In order to work, you need at least one `rq-worker` service running in the background to handle
the queried jobs.
the queued jobs.
To enable the NOTIFY feature, set following:
##### NOTIFY Settings
```
'notify_clients': True
```
##### NOTIFY Settings
This enables the NOTIFY system. Defaults to False
```
notify_client_alive_threshold_hours: 24
'notify_client_alive_threshold_hours': 24
```
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
list of clients to be notified on zone changes. Default is 24 hours.
```
notify_over_tcp: True
'notify_over_tcp': True
```
Switch to using TCP instead of UDP. Default: False
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
+1 -1
View File
@@ -2,7 +2,7 @@ from netbox.plugins import PluginConfig
from django.conf import settings
from .logger import get_logger
__version__ = "1.5.4"
__version__ = "1.5.7"
LOGGER = get_logger(__name__)
+29 -64
View File
@@ -3,7 +3,7 @@ import dns.zone
import dns.rdatatype
import dns.rdataclass
from .logger import get_logger
from netbox_dns.models import Zone
from netbox_dns.models import Zone as NBZone
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
from uuid import uuid4
from base64 import b32encode
@@ -12,30 +12,30 @@ from django.db import transaction, close_old_connections, OperationalError
LOGGER = get_logger(__name__)
def init() -> None:
_init_soa_serial()
_create_missing_member_identifiers()
def increment_soa_serial() -> int:
with transaction.atomic():
try:
soa_serial = IntegerKeyValueSetting.objects.select_for_update().get(
key="catalog-zone-soa-serial"
soa_serial_obj, _ = (
IntegerKeyValueSetting.objects.select_for_update().get_or_create(
key="catalog-zone-soa-serial",
defaults={"value": 1},
)
)
new_soa_serial = soa_serial.value + 1
new_soa_serial = soa_serial_obj.value + 1
if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning(
f"Catalog serial {soa_serial.value} reached max — wrapping back to 1"
f"Catalog serial {soa_serial_obj.value} reached max — wrapping back to 1"
)
new_soa_serial = 1
soa_serial.value = new_soa_serial
soa_serial.save()
soa_serial.refresh_from_db()
LOGGER.debug(f"Catalog zone SOA serial number is now {soa_serial.value}")
soa_serial_obj.value = new_soa_serial
soa_serial_obj.save()
soa_serial_obj.refresh_from_db()
LOGGER.debug(
f"Catalog zone SOA serial number is now {soa_serial_obj.value}"
)
return soa_serial.value
return soa_serial_obj.value
except OperationalError as e:
LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
@@ -53,7 +53,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
try:
# get zones from netbox
nb_zones = Zone.objects.filter(
nb_zones = NBZone.objects.filter(
view__name=view_name, active=True
).select_related("catz_identifier")
@@ -64,7 +64,14 @@ def create_zone(name, view_name) -> dns.zone.Zone:
qname = dns.name.from_text(nb_zone.name, dns.name.root)
# Create PTR record
p_name = nb_zone.catz_identifier.name
try:
catz_identifier = nb_zone.catz_identifier
except NBZone.catz_identifier.RelatedObjectDoesNotExist:
catz_identifier = CatalogZoneMemberIdentifier.objects.create(
zone=nb_zone, name=_generate_member_identifier()
)
p_name = catz_identifier.name
ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin):
@@ -113,25 +120,11 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return None
def _init_soa_serial() -> None:
try:
IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
LOGGER.info("Catalog zone SOA serial number loaded from database")
except IntegerKeyValueSetting.DoesNotExist:
IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1)
LOGGER.debug(
"Catalog zone SOA serial number was not set in the database. Set to 1"
)
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}")
close_old_connections()
def _generate_member_identifier() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: Zone) -> None:
def update_member_identifier(zone: NBZone) -> None:
try:
CatalogZoneMemberIdentifier.objects.update_or_create(
zone=zone,
@@ -142,40 +135,12 @@ def update_member_identifier(zone: Zone) -> None:
close_old_connections()
def _create_missing_member_identifiers() -> None:
try:
existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list(
"zone_id", flat=True
)
missing_zones = Zone.objects.exclude(id__in=existing_zone_ids)
new_objects = [
CatalogZoneMemberIdentifier(
zone=zone,
name=_generate_member_identifier(),
)
for zone in missing_zones
]
for identifier in new_objects:
LOGGER.debug(
f"Zone {identifier.zone} missing catz member identifier. Creating..."
)
CatalogZoneMemberIdentifier.objects.bulk_create(
new_objects,
ignore_conflicts=False,
)
except OperationalError as e:
LOGGER.error(f"ERROR: Could not create catz member identifier: {e}")
close_old_connections()
def _create_soa_rdataset() -> dns.rdataset:
try:
serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value
serial_obj, _ = IntegerKeyValueSetting.objects.get_or_create(
key="catalog-zone-soa-serial", defaults={"value": 1}
)
serial = serial_obj.value
# SOA Record components
ttl = 0
@@ -14,7 +14,6 @@ from django.core.management.base import BaseCommand
from django.conf import settings
from django.db import close_old_connections, OperationalError
from netbox_dns.models import View
from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler
from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer
from netbox_dns_bridge.logger import get_logger
@@ -80,7 +79,6 @@ class Command(BaseCommand):
# Load parameters
port = options["port"]
address = options["address"]
catzm.init()
self.tsig_keys = SETTINGS.get("tsig_keys", None)
if not self.tsig_keys:
+170 -46
View File
@@ -18,50 +18,51 @@ 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"]
try:
catz_serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value
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)
("catz", catz_serial),
(f"{view_name}.catz", catz_serial),
]
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)
@@ -72,7 +73,7 @@ class SendDNSNotify(JobRunner):
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 = 0
msg.flags |= dns.flags.AA # = 0
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
@@ -80,9 +81,7 @@ class SendDNSNotify(JobRunner):
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(
zone_name, 0, soa_rdata
)
soa_rrset = dns.rrset.from_rdata(zone_name, 0, soa_rdata)
msg.authority.append(soa_rrset)
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
@@ -112,27 +111,139 @@ class SendDNSNotify(JobRunner):
close_old_connections()
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"
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
soa_serial = int(soa_record.value.split()[2])
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 None
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}"
)
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(
@@ -140,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,
)
+4
View File
@@ -215,6 +215,10 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
else:
rrsets.append(rrset)
if soa_rrset is None:
LOGGER.error(f"Zone {dname} has no SOA — aborting")
return
rrsets.insert(0, soa_rrset) # Opening SOA
rrsets.append(soa_rrset) # Closing SOA
+39 -3
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)
@@ -27,13 +31,21 @@ def zone_post_save(sender, instance, created, **kwargs):
zone = instance
# Quit if this is not the first signal in this session for this zone.
request = current_request.get()
if request is not None:
try:
request = current_request.get()
handled_zones = getattr(request, "_handled_zones", set())
if zone.pk in handled_zones:
return
handled_zones.add(zone.pk)
request._handled_zones = handled_zones
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():
@@ -42,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:
@@ -53,6 +70,25 @@ def zone_post_save(sender, instance, created, **kwargs):
if old_name and zone.name != old_name:
catzm.update_member_identifier(zone)
@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