Compare commits
3
Commits
v1.6.0-rc1
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa3cd6a01 | ||
|
|
c15bdfca05 | ||
|
|
fe24dbdd98 |
No files matched your search
@@ -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: `<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
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from netbox.plugins import PluginConfig
|
||||
from django.conf import settings
|
||||
|
||||
__version__ = "1.6.0-rc1"
|
||||
__version__ = "1.7.0"
|
||||
|
||||
|
||||
class DNSBridgeConfig(PluginConfig):
|
||||
|
||||
+125
-157
@@ -12,14 +12,18 @@ 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__)
|
||||
|
||||
|
||||
class NotifyJobRunner(JobRunner):
|
||||
"""Base class for NOTIFY jobs: shared plugin settings and helpers."""
|
||||
|
||||
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
|
||||
|
||||
|
||||
def _load_tsig_key(view_name: str) -> dns.tsig.Key:
|
||||
tsig_keys = SETTINGS.get("tsig_keys", {})
|
||||
def load_tsig_key(self, view_name: str) -> dns.tsig.Key:
|
||||
tsig_keys = self.SETTINGS.get("tsig_keys", {})
|
||||
if view_name not in tsig_keys:
|
||||
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
|
||||
|
||||
@@ -41,8 +45,8 @@ def _load_tsig_key(view_name: str) -> dns.tsig.Key:
|
||||
|
||||
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
|
||||
|
||||
|
||||
def _send_notify(
|
||||
def send_notify(
|
||||
self,
|
||||
zone_name,
|
||||
soa_serial,
|
||||
targets,
|
||||
@@ -88,99 +92,8 @@ def _send_notify(
|
||||
f" {view_name}/{fqdn} {soa_serial}: {e}"
|
||||
)
|
||||
|
||||
|
||||
class SendClientNotify(JobRunner):
|
||||
class Meta:
|
||||
name = "Send DNS NOTIFY"
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
|
||||
port = SETTINGS.get("notify_client_port", 53)
|
||||
view_name = kwargs["view_name"]
|
||||
zone_name = kwargs["zone_name"]
|
||||
soa_serial = kwargs["soa_serial"]
|
||||
soa_refresh = kwargs.get("soa_refresh", 60)
|
||||
soa_retry = kwargs.get("soa_retry", 10)
|
||||
soa_expire = kwargs.get("soa_expire", 1209600)
|
||||
soa_minimum = kwargs.get("soa_minimum", 0)
|
||||
|
||||
try:
|
||||
view = View.objects.get(name=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)
|
||||
|
||||
clients = list(
|
||||
SeenTransferClient.objects.filter(
|
||||
view=view, last_seen__gte=seen_cutoff
|
||||
).values_list("source_ip", flat=True)
|
||||
)
|
||||
|
||||
_send_notify(
|
||||
zone_name, soa_serial, clients, tsig_key,
|
||||
view_name, notify_over_tcp, port, log_prefix="NOTIFY",
|
||||
soa_refresh=soa_refresh, soa_retry=soa_retry,
|
||||
soa_expire=soa_expire, soa_minimum=soa_minimum,
|
||||
)
|
||||
|
||||
except View.DoesNotExist as e:
|
||||
LOGGER.error(f"Failed to get view for NOTIFY: {e}")
|
||||
|
||||
except OperationalError as e:
|
||||
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
|
||||
close_old_connections()
|
||||
|
||||
|
||||
class SendNSNotify(JobRunner):
|
||||
"""Send a NOTIFY straight to a zone's own nameservers.
|
||||
|
||||
Unlike ``SendClientNotify`` (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
|
||||
|
||||
_send_notify(
|
||||
zone_name, soa_serial, ns_targets, tsig_key,
|
||||
view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
|
||||
)
|
||||
|
||||
|
||||
def _get_soa_serial(zone: Zone):
|
||||
@staticmethod
|
||||
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()
|
||||
@@ -199,7 +112,115 @@ def _get_soa_serial(zone: Zone):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_zone_nameserver_ips(zone: Zone) -> list:
|
||||
class SendClientNotify(NotifyJobRunner):
|
||||
class Meta:
|
||||
name = "Send DNS NOTIFY"
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
notify_over_tcp = self.SETTINGS.get("notify_over_tcp", False)
|
||||
port = self.SETTINGS.get("notify_client_port", 53)
|
||||
view_name = kwargs["view_name"]
|
||||
zone_name = kwargs["zone_name"]
|
||||
soa_serial = kwargs["soa_serial"]
|
||||
soa_refresh = kwargs.get("soa_refresh", 60)
|
||||
soa_retry = kwargs.get("soa_retry", 10)
|
||||
soa_expire = kwargs.get("soa_expire", 1209600)
|
||||
soa_minimum = kwargs.get("soa_minimum", 0)
|
||||
|
||||
try:
|
||||
view = View.objects.get(name=view_name)
|
||||
tsig_key = self.load_tsig_key(view_name)
|
||||
cutoff_hours = self.SETTINGS.get("notify_client_alive_threshold_hours", 24)
|
||||
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
|
||||
|
||||
clients = list(
|
||||
SeenTransferClient.objects.filter(
|
||||
view=view, last_seen__gte=seen_cutoff
|
||||
).values_list("source_ip", flat=True)
|
||||
)
|
||||
|
||||
self.send_notify(
|
||||
zone_name, soa_serial, clients, tsig_key,
|
||||
view_name, notify_over_tcp, port, log_prefix="NOTIFY",
|
||||
soa_refresh=soa_refresh, soa_retry=soa_retry,
|
||||
soa_expire=soa_expire, soa_minimum=soa_minimum,
|
||||
)
|
||||
|
||||
except View.DoesNotExist as e:
|
||||
LOGGER.error(f"Failed to get view for NOTIFY: {e}")
|
||||
|
||||
except OperationalError as e:
|
||||
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
|
||||
close_old_connections()
|
||||
|
||||
|
||||
class SendNSNotify(NotifyJobRunner):
|
||||
"""Send a NOTIFY straight to a zone's own nameservers.
|
||||
|
||||
Unlike ``SendClientNotify`` (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 = self.SETTINGS.get("notify_over_tcp", False)
|
||||
port = self.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 = self.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 = self.load_tsig_key(view_name)
|
||||
except RuntimeError as e:
|
||||
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
|
||||
return
|
||||
|
||||
self.send_notify(
|
||||
zone_name, soa_serial, ns_targets, tsig_key,
|
||||
view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def notify_ns_enabled(cls, 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 cls.SETTINGS.get("notify_ns_all_zones", False):
|
||||
return True
|
||||
|
||||
cf_name = cls.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
|
||||
|
||||
def resolve_zone_nameserver_ips(self, 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
|
||||
@@ -234,29 +255,11 @@ def _resolve_zone_nameserver_ips(zone: Zone) -> list:
|
||||
return ips
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def schedule_client_notify(zone: Zone):
|
||||
if not SETTINGS.get("notify_clients", False):
|
||||
if not SendClientNotify.SETTINGS.get("notify_clients", False):
|
||||
return
|
||||
|
||||
soa_serial = _get_soa_serial(zone)
|
||||
soa_serial = SendClientNotify.get_soa_serial(zone)
|
||||
if soa_serial is None:
|
||||
return
|
||||
|
||||
@@ -268,10 +271,10 @@ def schedule_client_notify(zone: Zone):
|
||||
|
||||
|
||||
def schedule_ns_notify(zone: Zone):
|
||||
if not _notify_ns_enabled(zone):
|
||||
if not SendNSNotify.notify_ns_enabled(zone):
|
||||
return
|
||||
|
||||
soa_serial = _get_soa_serial(zone)
|
||||
soa_serial = SendNSNotify.get_soa_serial(zone)
|
||||
if soa_serial is None:
|
||||
return
|
||||
|
||||
@@ -281,38 +284,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,
|
||||
)
|
||||
+57
-5
@@ -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: ``<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:
|
||||
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.<catalog zone name>. 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user