From fe24dbdd98ee7a62760eebd26df2c957a7cf2b1e Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Tue, 22 Sep 2026 18:08:17 +0200 Subject: [PATCH] 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: '.'. - The transfer endpoint now serves exactly the one configured catalog zone name per view instead of accepting both 'catz' and '.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. --- README.md | 31 +++++++++- netbox_dns_bridge/__init__.py | 2 +- netbox_dns_bridge/jobs/notify.py | 37 +---------- .../catalog_zone_manager.py | 62 +++++++++++++++++-- .../dns_transfer_endpoint/request_handler.py | 5 +- 5 files changed, 89 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index e089fcc..0146955 100644 --- a/README.md +++ b/README.md @@ -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 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 -zone name **"catz"** and addtionally under **"[viewname].catz"** and may be queried through the -built-in DNS server just like any other dns zone. +The plugin exposes one catalog zone per view. The catalog zone name is configurable (see +[Catalog Zone Naming](#catalog-zone-naming) below) and defaults to **"catz"**. It may be queried +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 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: `.`. + +`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 "" ... }` statement and the corresponding +slave zone declaration must match. + ### TSIG Authentication 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 diff --git a/netbox_dns_bridge/__init__.py b/netbox_dns_bridge/__init__.py index f965cb9..f1a523e 100644 --- a/netbox_dns_bridge/__init__.py +++ b/netbox_dns_bridge/__init__.py @@ -1,7 +1,7 @@ from netbox.plugins import PluginConfig from django.conf import settings -__version__ = "1.6.0-rc1" +__version__ = "1.6.0-rc2" class DNSBridgeConfig(PluginConfig): diff --git a/netbox_dns_bridge/jobs/notify.py b/netbox_dns_bridge/jobs/notify.py index 1598647..74862dd 100644 --- a/netbox_dns_bridge/jobs/notify.py +++ b/netbox_dns_bridge/jobs/notify.py @@ -12,7 +12,7 @@ from django.db import close_old_connections, OperationalError from netbox.jobs import JobRunner from netbox_dns.models import View, Zone, Record 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__) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) @@ -281,38 +281,3 @@ def schedule_ns_notify(zone: Zone): view_name=zone.view.name, 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, - ) diff --git a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py index a565de3..8bc5fa9 100644 --- a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py +++ b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py @@ -8,11 +8,37 @@ from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember from netbox_dns_bridge.jobs import notify from uuid import uuid4 from base64 import b32encode +from django.conf import settings from django.db import transaction, close_old_connections, OperationalError 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: ``.``. + """ + 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: with transaction.atomic(): @@ -47,13 +73,13 @@ def increment_soa_serial(view: NBView) -> int: close_old_connections() return None - notify.schedule_catalog_zone_notify(view) + schedule_catalog_zone_notify(view) return serial -def create_zone(name, view_name) -> dns.zone.Zone: - # Zone origin - origin = dns.name.from_text(name, dns.name.root) +def create_zone(view_name) -> dns.zone.Zone: + # Zone origin: the configured catalog zone name for this view + origin = catalog_zone_name(view_name) # Create a new empty zone zone = dns.zone.Zone(origin) @@ -117,7 +143,7 @@ def create_zone(name, view_name) -> dns.zone.Zone: ns_rdataset = _create_ns_rdataset() zone_root_node.rdatasets.append(ns_rdataset) - # version.catz. record + # version.. record version_rdataset = _create_version_rdataset() zone_version_node = zone.find_node( dns.name.from_text("version", origin), create=True @@ -158,6 +184,32 @@ def update_member(zone: NBZone) -> None: 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: try: serial_obj, _ = CatalogZone.objects.get_or_create( diff --git a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py index 0f725b3..b011d63 100644 --- a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py +++ b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py @@ -289,9 +289,8 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): return # Check if catalog zone - dname_lower = dname.lower() - if dname_lower == "catz" or dname_lower == f"{nb_view.name.lower()}.catz": - zone = catzm.create_zone(dname_lower, nb_view.name) + if catzm.is_catalog_zone_query(qname, nb_view.name): + zone = catzm.create_zone(nb_view.name) else: zone = self._get_zone_from_nb(dname, nb_view.name) # When zone was not found, let client know