Add configurable catalog zone naming

- New setting 'catalog_zone_name' (default: 'catz') to change the name of
  the per-view catalog zone. Full DNS names (multiple labels) are
  supported; names are handled as dns.name.Name objects, making the
  comparison case-insensitive and tolerant of trailing dots.
- New setting 'catalog_zone_name_includes_view' (default: False). When
  enabled, the catalog zone name is prefixed with the view name:
  '<view>.<catalog_zone_name>'.
- The transfer endpoint now serves exactly the one configured catalog
  zone name per view instead of accepting both 'catz' and
  '<view>.catz'.
- The catalog zone NOTIFY now targets only the configured name instead
  of notifying for both variants. Scheduling moved to
  catalog_zone_manager, which owns all catalog zone naming.
This commit is contained in:
sven committed 2026-09-22 22:22:26 +02:00
1 parent 5c673d9903
commit fe24dbdd98
5 files changed
+89 -48

No files matched your search

+28 -3
View File
@@ -24,9 +24,9 @@ While providing Zone transfers via AXFR, the Server also exposes specialized cat
and other RFC9432 compliant DNS Servers use to automatically discover newly created zones and remove and other RFC9432 compliant DNS Servers use to automatically discover newly created zones and remove
deleted ones. The plugin supports views and basic DNS security via TSIG. deleted ones. The plugin supports views and basic DNS security via TSIG.
The plugin exposes one catalog zone per view. Each catalog zone is made available under the special The plugin exposes one catalog zone per view. The catalog zone name is configurable (see
zone name **"catz"** and addtionally under **"[viewname].catz"** and may be queried through the [Catalog Zone Naming](#catalog-zone-naming) below) and defaults to **"catz"**. It may be queried
built-in DNS server just like any other dns zone. through the built-in DNS server just like any other dns zone.
For proper operation, each view requires an installed TSIG key, and the `dns-transfer-endpoint` must For proper operation, each view requires an installed TSIG key, and the `dns-transfer-endpoint` must
be running as a separate background service using the `manage.py` command. Note that DNSSEC support be running as a separate background service using the `manage.py` command. Note that DNSSEC support
@@ -59,6 +59,31 @@ PLUGINS_CONFIG = {
} }
``` ```
### Catalog Zone Naming
The name of the catalog zone exposed for each view is configurable:
Setting | Default | Description
------- | ------- | -----------------------------------------------------------
`catalog_zone_name` | `catz` | The name of the catalog zone. May be a full DNS name.
`catalog_zone_name_includes_view` | `False` | Prepend the view name: `<view>.<catalog_zone_name>`.
`catalog_zone_name` accepts full DNS names with multiple labels. Case and a trailing dot are
irrelevant — names are handled and compared as `dns.name.Name` objects. With
`catalog_zone_name_includes_view` disabled (the default), the plain `catalog_zone_name` is used for
every view; when enabled, the view name is prepended (e.g. `_default_.catz`).
```
'netbox_dns_bridge': {
'catalog_zone_name': 'catz',
'catalog_zone_name_includes_view': False,
...
}
```
Note that the DNS server consuming the catalog zone must be configured with the same zone name,
e.g. BIND9's `catalog-zones { zone "<catalog_zone_name>" ... }` statement and the corresponding
slave zone declaration must match.
### TSIG Authentication ### TSIG Authentication
Following sets the TSIG key that allows clients to query the transfer endpoint and also the key to Following sets the TSIG key that allows clients to query the transfer endpoint and also the key to
be used to sign NOTIFY messages to clients on zone changes. Each view should have its own unique be used to sign NOTIFY messages to clients on zone changes. Each view should have its own unique
+1 -1
View File
@@ -1,7 +1,7 @@
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from django.conf import settings from django.conf import settings
__version__ = "1.6.0-rc1" __version__ = "1.6.0-rc2"
class DNSBridgeConfig(PluginConfig): class DNSBridgeConfig(PluginConfig):
+1 -36
View File
@@ -12,7 +12,7 @@ from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record from netbox_dns.models import View, Zone, Record
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns_bridge.models import SeenTransferClient, CatalogZone from netbox_dns_bridge.models import SeenTransferClient
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@@ -281,38 +281,3 @@ def schedule_ns_notify(zone: Zone):
view_name=zone.view.name, view_name=zone.view.name,
soa_serial=soa_serial, soa_serial=soa_serial,
) )
def schedule_catalog_zone_notify(view: View):
if not SETTINGS.get("notify_clients", False):
return
try:
catalog_zone = view.catalog_zone
except CatalogZone.DoesNotExist:
return
serial = catalog_zone.soa_serial
soa_refresh = catalog_zone.soa_refresh
soa_retry = catalog_zone.soa_retry
soa_expire = catalog_zone.soa_expire
soa_minimum = catalog_zone.soa_minimum
SendClientNotify.enqueue(
zone_name="catz",
soa_serial=serial,
view_name=view.name,
soa_refresh=soa_refresh,
soa_retry=soa_retry,
soa_expire=soa_expire,
soa_minimum=soa_minimum,
)
SendClientNotify.enqueue(
zone_name=f"{view.name}.catz",
soa_serial=serial,
view_name=view.name,
soa_refresh=soa_refresh,
soa_retry=soa_retry,
soa_expire=soa_expire,
soa_minimum=soa_minimum,
)
@@ -8,11 +8,37 @@ from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember
from netbox_dns_bridge.jobs import notify from netbox_dns_bridge.jobs import notify
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.conf import settings
from django.db import transaction, close_old_connections, OperationalError from django.db import transaction, close_old_connections, OperationalError
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def catalog_zone_name(view_name: str) -> dns.name.Name:
"""Return the fully qualified catalog zone name for a view as a
dns.name.Name.
The name is configured via the ``catalog_zone_name`` setting (fallback:
``catz``). With ``catalog_zone_name_includes_view`` enabled, the view
name is prepended: ``<view>.<catalog_zone_name>``.
"""
base = dns.name.from_text(SETTINGS.get("catalog_zone_name") or "catz")
if SETTINGS.get("catalog_zone_name_includes_view", False):
return dns.name.from_text(view_name, origin=base)
return base
def is_catalog_zone_query(qname: dns.name.Name, view_name: str) -> bool:
"""Check whether a queried name is the catalog zone name of a view.
dns.name.Name equality is case-insensitive, so queries from BIND match
regardless of case.
"""
return qname == catalog_zone_name(view_name)
def increment_soa_serial(view: NBView) -> int: def increment_soa_serial(view: NBView) -> int:
with transaction.atomic(): with transaction.atomic():
@@ -47,13 +73,13 @@ def increment_soa_serial(view: NBView) -> int:
close_old_connections() close_old_connections()
return None return None
notify.schedule_catalog_zone_notify(view) schedule_catalog_zone_notify(view)
return serial return serial
def create_zone(name, view_name) -> dns.zone.Zone: def create_zone(view_name) -> dns.zone.Zone:
# Zone origin # Zone origin: the configured catalog zone name for this view
origin = dns.name.from_text(name, dns.name.root) origin = catalog_zone_name(view_name)
# Create a new empty zone # Create a new empty zone
zone = dns.zone.Zone(origin) zone = dns.zone.Zone(origin)
@@ -117,7 +143,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
ns_rdataset = _create_ns_rdataset() ns_rdataset = _create_ns_rdataset()
zone_root_node.rdatasets.append(ns_rdataset) zone_root_node.rdatasets.append(ns_rdataset)
# version.catz. record # version.<catalog zone name>. record
version_rdataset = _create_version_rdataset() version_rdataset = _create_version_rdataset()
zone_version_node = zone.find_node( zone_version_node = zone.find_node(
dns.name.from_text("version", origin), create=True dns.name.from_text("version", origin), create=True
@@ -158,6 +184,32 @@ def update_member(zone: NBZone) -> None:
close_old_connections() close_old_connections()
def schedule_catalog_zone_notify(view: NBView) -> None:
"""Schedule a NOTIFY for the catalog zone of a view.
Notifies transfer clients for the single configured catalog zone name
(see ``catalog_zone_name``). The job receives the name as text, since
job kwargs are serialized to JSON.
"""
if not SETTINGS.get("notify_clients", False):
return
try:
catalog_zone = view.catalog_zone
except CatalogZone.DoesNotExist:
return
notify.SendClientNotify.enqueue(
zone_name=catalog_zone_name(view.name).to_text(omit_final_dot=True),
soa_serial=catalog_zone.soa_serial,
view_name=view.name,
soa_refresh=catalog_zone.soa_refresh,
soa_retry=catalog_zone.soa_retry,
soa_expire=catalog_zone.soa_expire,
soa_minimum=catalog_zone.soa_minimum,
)
def _create_soa_rdataset(view: NBView) -> dns.rdataset: def _create_soa_rdataset(view: NBView) -> dns.rdataset:
try: try:
serial_obj, _ = CatalogZone.objects.get_or_create( serial_obj, _ = CatalogZone.objects.get_or_create(
@@ -289,9 +289,8 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
return return
# Check if catalog zone # Check if catalog zone
dname_lower = dname.lower() if catzm.is_catalog_zone_query(qname, nb_view.name):
if dname_lower == "catz" or dname_lower == f"{nb_view.name.lower()}.catz": zone = catzm.create_zone(nb_view.name)
zone = catzm.create_zone(dname_lower, nb_view.name)
else: else:
zone = self._get_zone_from_nb(dname, nb_view.name) zone = self._get_zone_from_nb(dname, nb_view.name)
# When zone was not found, let client know # When zone was not found, let client know