From 19a946c880b9c4d4e72a9fe85b3767544443c512 Mon Sep 17 00:00:00 2001 From: Sven Luethi Date: Tue, 3 Feb 2026 21:55:39 +0100 Subject: [PATCH] - Split bind transfer command into multiple files for better code readability. - Rename bind-transfer-endpoint command -> dns-transfer-endpoint. --- README.md | 8 +- docs/install-systemd-service.md | 2 +- .../__init__.py | 5 +- .../commands/bind-transfer-endpoint.py | 764 ------------------ .../commands/dns-transfer-endpoint.py | 117 +++ .../service/endpoint/__init__.py | 0 .../service/endpoint/catalog_zone_manager.py | 227 ++++++ .../service/endpoint/dns_server.py | 54 ++ .../service/endpoint/request_handler.py | 413 ++++++++++ src/netbox_plugin_bind_provisioner/signals.py | 16 +- src/netbox_plugin_bind_provisioner/utils.py | 4 - 11 files changed, 820 insertions(+), 790 deletions(-) delete mode 100644 src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py create mode 100644 src/netbox_plugin_bind_provisioner/management/commands/dns-transfer-endpoint.py create mode 100644 src/netbox_plugin_bind_provisioner/service/endpoint/__init__.py create mode 100644 src/netbox_plugin_bind_provisioner/service/endpoint/catalog_zone_manager.py create mode 100644 src/netbox_plugin_bind_provisioner/service/endpoint/dns_server.py create mode 100644 src/netbox_plugin_bind_provisioner/service/endpoint/request_handler.py diff --git a/README.md b/README.md index de0dc26..e3f3e64 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,13 @@ 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. For proper operation, each view requires an installed TSIG key, and the -`bind-transfer-endpoint` must be running as a separate background service using +`dns-transfer-endpoint` must be running as a separate background service using the `manage.py` command. Note that DNSSEC support will be added once BIND9 provides a mechanism to configure it through the Catalog Zones system. To start the service in the foreground: ``` -manage.py bind-transfer-endpoint --port 5354 +manage.py dns-transfer-endpoint --port 5354 ``` This process needs to be scheduled as a background service for the built-in DNS Server to work correctly. For Linux users with Systemd (Ubuntu, etc), Matt Kollross @@ -135,10 +135,10 @@ This guide assumes: Note that `--port 5354` is optional. The listener will bind this port by default. ``` - python3 netbox/manage.py bind-transfer-endpoint --port 5354 + python3 netbox/manage.py dns-transfer-endpoint --port 5354 ``` -6. Configuring a Bind9 to interact with Netbox via the bind-transfer-endpoint +6. Configuring a Bind9 to interact with Netbox via the dns-transfer-endpoint endpoint. Note that its not possible to give all the correct details of the `options` block as it is heavily dependent on the Operating System used. Please dont forget to adjust as required. diff --git a/docs/install-systemd-service.md b/docs/install-systemd-service.md index 94e8a11..b687d8f 100644 --- a/docs/install-systemd-service.md +++ b/docs/install-systemd-service.md @@ -15,7 +15,7 @@ PIDFile=/var/tmp/netbox.pid WorkingDirectory=/opt/netbox - ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py bind-transfer-endpoint --port 5354 + ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py dns-transfer-endpoint --port 5354 Restart=on-failure RestartSec=30 diff --git a/src/netbox_plugin_bind_provisioner/__init__.py b/src/netbox_plugin_bind_provisioner/__init__.py index 6f9659c..7472e01 100644 --- a/src/netbox_plugin_bind_provisioner/__init__.py +++ b/src/netbox_plugin_bind_provisioner/__init__.py @@ -1,8 +1,5 @@ import logging from netbox.plugins import PluginConfig -from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.dispatch import receiver from django.conf import settings __version__ = "1.0.5" @@ -32,7 +29,7 @@ class BindProvisionerConfig(PluginConfig): f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox." ) - from . import signals + from . import signals as signals config = BindProvisionerConfig diff --git a/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py b/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py deleted file mode 100644 index ffa569e..0000000 --- a/src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py +++ /dev/null @@ -1,764 +0,0 @@ -import logging -import socketserver -import socket -import os -import threading -import dns.query -import dns.message -import dns.tsigkeyring -import dns.name -import dns.zone -import dns.rdatatype -import dns.rdataclass -import dns.rdtypes -import dns.exception -import dns.renderer - -from django.core.management.base import BaseCommand -from django.conf import settings -from django.utils import timezone -from netbox_dns.models import Zone, Record, View -from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices -from datetime import datetime -from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier -from netbox_plugin_bind_provisioner.utils import generate_catz_member_identifier - -logger = logging.getLogger("bind-transfer-endpoint") - - -class CatalogZone: - lock = threading.Lock() - _serial_max = 0xFFFFFFFF - _serial_obj = None - _previous_last_zone_update = None - - @classmethod - def init(cls) -> None: - cls._init_serial() - cls._generate_member_identifiers() - - # Following function loads the last serial from the DB. No return value - # but it sets the setting "catalog_serial" or terminates the plugin on failure. - @classmethod - def _init_serial(cls) -> None: - try: - cls._serial_obj = IntegerKeyValueSetting.objects.get( - key="catalog-zone-soa-serial" - ) - logger.debug( - f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database" - ) - except IntegerKeyValueSetting.DoesNotExist: - cls._serial_obj = IntegerKeyValueSetting.objects.create( - key="catalog-zone-soa-serial", value=1 - ) - logger.debug( - f"Catalog zone SOA serial number was not set in the database. Set to {cls._serial_obj.value}" - ) - - # If a zone has no catz identifier yet, create it: - @classmethod - def _generate_member_identifiers(cls) -> None: - 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_catz_member_identifier(), - ) - for zone in missing_zones - ] - - for identifier in new_objects: - logger.debug(f"Zone {identifier.zone} has no catz member identifier. Creating...") - - CatalogZoneMemberIdentifier.objects.bulk_create( - new_objects, - ignore_conflicts=True, - ) - - @classmethod - def _incrementSerial(cls) -> None: - if 0 < cls._serial_obj.value < cls._serial_max: - cls._serial_obj.value += 1 - cls._serial_obj.save() - else: - logger.warn( - f"Failed to incremenet catalog serial {cls._serial_obj.value}. Will overflow serial back to 1" - ) - cls._serial_obj = 1 - cls._serial_obj.save() - logger.debug( - f"Catalog zone SOA serial number incremented to {cls._serial_obj.value}" - ) - - @classmethod - def create(cls, name, view_name) -> dns.zone.Zone: - # Synchronize following across threads as TCP and UDP listener both use it. - with cls.lock: - latest_zone = ( - Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE) - .order_by("-last_updated") - .first() - ) - - last_zone_update = getattr(latest_zone, "last_updated", None) - - # Check if there was a zone updated since last call - # If no zone was found previously then this will be false since (None != None) = False - if cls._previous_last_zone_update != last_zone_update: - if last_zone_update is not None: - logger.debug( - f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}" - ) - # Setting previous last zone update for next iteration: - cls._previous_last_zone_update = last_zone_update - cls._incrementSerial() - - # Zone origin - origin = dns.name.from_text(name, dns.name.root) - - # Create a new empty zone - zone = dns.zone.Zone(origin) - zone.rdclass = dns.rdataclass.IN - - # get zones from netbox - nb_zones = Zone.objects.filter( - view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE - ).select_related("catz_identifier") - - ptr_base = dns.name.from_text("zones", origin) - - for nb_zone in nb_zones: - ttl = 0 - qname = dns.name.from_text(nb_zone.name, dns.name.root) - - # Create PTR record - #p_name = f"zid-{nb_zone.id:09d}" - p_name = nb_zone.catz_identifier.name - - ptr_name = dns.name.from_text(p_name, ptr_base) - assert ptr_name.is_subdomain(origin) - rdata = dns.rdata.from_text( - dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() - ) - rdataset = zone.find_rdataset(ptr_name, dns.rdatatype.PTR, create=True) - rdataset.add(rdata, ttl) - - # Configure DNSSec Policy for member Zone if DNSSec is enabled - if nb_zone.dnssec_policy: - # Configure policy - rid = dns.name.from_text("group", ptr_name) - policy_name = nb_zone.dnssec_policy.name.rstrip(" ") - group_name = f"dnssec-policy-{policy_name}" - rdata = dns.rdata.from_text( - dns.rdataclass.IN, dns.rdatatype.TXT, group_name - ) - rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) - rdataset.add(rdata, ttl) - - ## Configure dnssec status for member zone - # status = str(1 if nb_zone.dnssec_policy else 0) - # rid = dns.name.from_text("enabled.dnssec.ext", ptr_name) - # rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status) - # rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) - # rdataset.add(rdata, ttl) - - # SOA Record components - ttl = 0 - rclass = dns.rdataclass.IN - rtype = dns.rdatatype.SOA - mname = dns.name.from_text("invalid", dns.name.root) - rname = dns.name.from_text("invalid", dns.name.root) - serial = cls._serial_obj.value - refresh = 60 - retry = 10 - expire = 1209600 - minimum = 0 - - # Create SOA rdata object - soa_rdata = dns.rdata.from_text( - rclass, - rtype, - f"{mname} {rname} {serial} {refresh} {retry} {expire} {minimum}", - ) - - # Create Rdataset and add the RDATA to it - soa_rdataset = dns.rdataset.Rdataset(rclass, rtype) - soa_rdataset.add(soa_rdata, ttl) - - # Add to the origin node in the zone - node = zone.find_node(origin, create=True) - node.rdatasets.append(soa_rdataset) - - # NS record for catz. - ns_name = dns.name.from_text("invalid", dns.name.root) - ns_rdata = dns.rdata.from_text( - dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name) - ) - ns_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS) - ns_rdataset.add(ns_rdata, 0) - - # Add to node (catz. is the origin) - ns_node = zone.find_node(origin, create=True) - ns_node.rdatasets.append(ns_rdataset) - - # TXT record for version.catz. - version_name = dns.name.from_text( - "version", origin - ) # relative to origin "catz." - txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"') - - txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT) - txt_rdataset.add(txt_rdata, 0) - - # Add to node version.catz. - txt_node = zone.find_node(version_name, create=True) - txt_node.rdatasets.append(txt_rdataset) - - return zone - - -class DNSBaseRequestHandler(socketserver.BaseRequestHandler): - def __init__(self, request, client_address, server): - self.MAX_WIRE = 65535 - self.RESERVED_TSIG = 300 - super().__init__(request, client_address, server) - - # getZoneFromNB rewritten - def getZoneFromNB(self, zone_name, view_name): - # Find the zone - try: - nb_zone = Zone.objects.get( - name=zone_name, - view__name=view_name, - status=ZoneStatusChoices.STATUS_ACTIVE, - ) - except Zone.DoesNotExist: - return None - - # Build DNS zone - zone = dns.zone.Zone(zone_name, dns.name.root) - zone.rdclass = dns.rdataclass.IN - - nb_records = Record.objects.filter( - zone=nb_zone, status=RecordStatusChoices.STATUS_ACTIVE - ) - - rdatasets_dict = {} - - for record in nb_records: - rdtype = dns.rdatatype.from_text(record.type) - if not record.name: - name = zone.origin - elif record.name.endswith("."): - name = dns.name.from_text(record.name) - else: - name = dns.name.from_text(record.name, origin=zone.origin) - - # If the record has no TTL, use the zone default - ttl = record.ttl or nb_zone.default_ttl - - # Apply quoting for TXT records to stop tokanizer - # from cutting it up: - value = record.value - if rdtype == dns.rdatatype.TXT: - if value.startswith('"') and value.endswith('"'): - value = value[1:-1].replace('" "', "").replace('"', '"') - - if len(value) > 255: - # This is a bug fix for netbox. If netbox allowed for - # an unquoted value to be larger then 255 characters, - # it misunderstood everything behind a ; as a comment. - chunks = [ - '"{}"'.format(value[i : i + 255]) - for i in range(0, len(value), 255) - ] - value = " ".join(chunks) - else: - value = f'"{value}"' - - rdata = dns.rdata.from_text( - dns.rdataclass.IN, - rdtype, - value, - relativize=False, - origin=zone.origin, - ) - - # Initialize rdataset if it doesn't exist for this name and type - if name not in rdatasets_dict: - rdatasets_dict[name] = {} - if rdtype not in rdatasets_dict[name]: - rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset( - dns.rdataclass.IN, rdtype - ) - - # Add the rdata to the appropriate rdataset - rdatasets_dict[name][rdtype].add(rdata, ttl) - - # Now, add all rdatasets to the zone - for name, rdtypes in rdatasets_dict.items(): - for rdtype, rdataset in rdtypes.items(): - # Ensure rdataset has the same rdclass as the zone - if rdataset.rdclass != zone.rdclass: - raise ValueError( - f"rdataset rdclass {rdataset.rdclass} does not match zone rdclass {zone.rdclass}" - ) - - # Check if the rdataset has any rdata before creating an RRset - if not rdataset: - logger.debug(f"Skipping empty rdataset for {name} {rdtype}") - continue # Skip empty rdataset - - # Replace the rdataset for the given name and type - zone.replace_rdataset(name, rdataset) - return zone - - def denyRequestBadTSIG(self, wire, tsig_error: dns.rcode): - # Use empty keyring to parse TSIG without validating - query = dns.message.from_wire( - wire, keyring={}, ignore_trailing=True, continue_on_error=True - ) - - # Make a response - response = dns.message.make_response(query) - response.set_rcode(dns.rcode.REFUSED) - - if query.had_tsig: - # Add TSIG with BADKEY, but do not sign (no MAC) - response.use_tsig( - keyring={}, # empty; we're not signing - keyname=query.keyname, - tsig_error=tsig_error, - ) - self.denyRequest(query) - - def send_response(self, data): - raise NotImplemented - - def deny_request(self, query, rcode: dns.rcode = dns.rcode.REFUSED): - response = dns.message.make_response(query) - response.set_rcode(rcode) - wire = response.to_wire(multi=False) - self.send_response(wire) - - def handle_soa_request(self, query, soa_rrset, zone, peer, nb_view, dname): - # We assume that the SOA rdataset has at least one record (it usually does). - soa_rdata = soa_rrset[0] # Get the first SOA record - - # Now, create the rrset from the soa_rdata - rrset = dns.rrset.from_rdata(zone.origin, soa_rrset.ttl, soa_rdata) - - response = dns.message.make_response(query) - # Set the Authoritative Answer flag - response.flags |= dns.flags.AA - - # Append the rrset to the response's answer section - response.answer.append(rrset) - - # TSIG response - if query.had_tsig: - # If key was found in DB - if query.keyname in self.server.keyring: - response.use_tsig( - self.server.keyring, keyname=query.keyname, original_id=query.id - ) - else: - # If key is unknown - response.set_rcode(dns.rcode.REFUSED) - # Generate new key for keyname provided - # keyname = query.keyname - # b64 = base64.b64encode(urandom(32)).decode("ascii") - # newkey = dns.tsigkeyring.from_text({ keyname: b64 }) - response.use_tsig( - self.server.keyring, - keyname=query.keyname, - tsig_error=dns.rcode.BADKEY, - ) - - data = response.to_wire(max_size=512) - self.send_response(data) - logger.debug(f"{peer} SOA {nb_view.name}/{dname}") - - def handle_axfr_request(self, query, zone, peer, nb_view, dname): - rrsets = [] - soa_rrset = None - for name, rdataset in zone.iterate_rdatasets(): - if not name.is_absolute(): - name = name.concatenate(zone.origin) - rrset = dns.rrset.from_rdata_list(name, rdataset.ttl, rdataset) - if rdataset.rdtype == dns.rdatatype.SOA and soa_rrset is None: - soa_rrset = rrset - else: - rrsets.append(rrset) - - rrsets.insert(0, soa_rrset) # Opening SOA - rrsets.append(soa_rrset) # Closing SOA - - # 2. Create a Renderer for the first message - r = dns.renderer.Renderer( - id=query.id, - flags=(dns.flags.QR | dns.flags.AA), - max_size=self.MAX_WIRE, - origin=None, - ) - r.add_question( - query.question[0].name, - query.question[0].rdtype, - query.question[0].rdclass, - ) - - # 3. Loop through RRsets - tsig_ctx = None - for rrset in rrsets: - # logger.debug(f"Iterating over rrset {rrset}. tsig_ctx: {tsig_ctx}") - try: - # logger.debug(f"Adding {rrset} to renderer object") - r.add_rrset(dns.renderer.ANSWER, rrset) - if r.max_size - len(r.output.getvalue()) < self.RESERVED_TSIG: - raise dns.exception.TooBig("TSIG wont fit") - except dns.exception.TooBig: - # TSIG chain previous message - r.write_header() - tsig_ctx = r.add_multi_tsig( - ctx=tsig_ctx, - secret=self.server.keyring[query.keyname], - keyname=query.keyname, - fudge=300, - id=query.id, - tsig_error=0, - other_data=b"", - request_mac=r.mac if tsig_ctx else query.mac, - ) - wire = r.get_wire() - self.request.sendall(len(wire).to_bytes(2, "big") + wire) - - # Start new renderer - r = dns.renderer.Renderer( - id=query.id, - flags=(dns.flags.QR | dns.flags.AA), - max_size=self.MAX_WIRE, - origin=None, - ) - r.add_question( - query.question[0].name, - query.question[0].rdtype, - query.question[0].rdclass, - ) - r.add_rrset(dns.renderer.ANSWER, rrset) - - # 4. Final message with terminating TSIG - r.write_header() - # logger.debug(f"Final message. tsig_ctx: {tsig_ctx}") - tsig_ctx = r.add_multi_tsig( - ctx=tsig_ctx, - secret=self.server.keyring[query.keyname], - keyname=query.keyname, - fudge=300, - id=query.id, - tsig_error=0, - other_data=b"", - # request_mac=r.mac if tsig_ctx else None, - request_mac=r.mac if tsig_ctx else query.mac, - ) - wire = r.get_wire() - self.send_response(wire) - - logger.debug(f"{peer} AXFR {nb_view.name}/{dname}") - - def handle_dns_query(self, wire): - peer = self.client_address[0] - try: - query = dns.message.from_wire( - wire, - keyring=self.server.keyring, - continue_on_error=False, - ignore_trailing=True - ) - - except dns.tsig.BadSignature as e: - logger.warning( - f"Request denied from {peer} failed TSIG verification: {e}" - ) - self.denyRequestBadTSIG(wire, dns.rcode.BADSIG) - return - - except (dns.message.UnknownTSIGKey, dns.tsig.BadAlgorithm) as e: - logger.warning(f"Request denied from {peer} with bad TSIG key: {e}") - self.denyRequestBadTSIG(wire, dns.rcode.BADKEY) - return - - except Exception as e: - logger.error("Error parsing query: ", e) - return - - # If there was no question in the query, refuse - if len(query.question) != 1: - self.deny_request(query) - return - - question = query.question[0] - qname = question.name - qtype = question.rdtype - dname = qname.to_text().rstrip(".") - - # Only process AXFR/SOA queris - if qtype not in (dns.rdatatype.AXFR, dns.rdatatype.SOA): - logger.warning( - f"Request denied from {peer}: Request was not AXFR or SOA (Type: {qtype})" - ) - self.deny_request(query) - return - - # Identify TSIG key used - if not query.had_tsig: - logger.warning(f"Request denied from {peer}: No TSIG key used") - self.deny_request(query) - return - - key_name = query.keyname.canonicalize().to_text() - - # Check if the key matches a view - nb_view = self.server.tsig_view_map.get(key_name) - if not nb_view: - logger.warning( - f"Request denied from {peer}: {key_name} does not match a view" - ) - self.deny_request(query) - return - - # Check if catalog zone - if dname == "catz" or dname == f"{nb_view.name}.catz": - zone = CatalogZone.create(dname, nb_view.name) - else: - zone = self.getZoneFromNB(dname, nb_view.name) - # When zone was not found, let client know - if not zone: - logger.warning(f"Zone {dname} not found in view {nb_view.name}") - self.deny_request(query) - return - - # Retrieve the existing SOA record from the Zone - soa_rrset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA) - if soa_rrset is None: - logger.error(f"Zone {dname} has no SOA — aborting") - return - - if qtype == dns.rdatatype.SOA: - self.handle_soa_request(query, soa_rrset, zone, peer, nb_view, dname) - elif qtype == dns.rdatatype.AXFR: - self.handle_axfr_request(query, zone, peer, nb_view, dname) - - -class UDPRequestHandler(DNSBaseRequestHandler): - def __init__(self, request, client_address, server): - super().__init__(request, client_address, server) - - def send_response(self, data): - sock = self.request[1] - sock.sendto(data, self.client_address) - - def handle(self): - data, sock = self.request - peer = self.client_address[0] - try: - self.handle_dns_query(data) - except Exception as e: - logger.error(f"Error handling request from {peer}: {e}") - import traceback - traceback.print_exc() - - -class TCPRequestHandler(DNSBaseRequestHandler): - def __init__(self, request, client_address, server): - super().__init__(request, client_address, server) - - def send_response(self, data): - length = len(data).to_bytes(2, byteorder="big") - self.request.sendall(length + data) - - def handle(self): - peer = self.client_address[0] - sock = self.request # TCP socket - sock.settimeout(10.0) # Default 10 second timeout for inactivity - try: - while True: - # Read 2-byte length prefix - length_data = sock.recv(2) - if not length_data: - return # connection closed by peer - if len(length_data) < 2: - # Incomplete length data, wait for more or fail - while len(length_data) < 2: - chunk = sock.recv(2 - len(length_data)) - if not chunk: - return - length_data += chunk - - length = int.from_bytes(length_data, byteorder="big") - wire = b"" - while len(wire) < length: - chunk = sock.recv(length - len(wire)) - if not chunk: - return # connection closed - wire += chunk - self.handle_dns_query(wire) - - except socket.timeout: - logger.debug(f"Connection from {peer} timed out") - except Exception as e: - logger.error(f"Error handling request from {peer}: {e}") - import traceback - traceback.print_exc() - - -class DNSAddressMixin: - def _resolve_address(self, server_address, socktype, proto): - host, port = server_address - - infos = socket.getaddrinfo( - host, - port, - socket.AF_UNSPEC, - socktype, - proto, - socket.AI_PASSIVE - ) - - family, _, _, _, sockaddr = infos[0] - self.address_family = family - return sockaddr - - -class TCPDNSServer(DNSAddressMixin, socketserver.TCPServer): - allow_reuse_address = True - - def __init__(self, server_address, handler_class, keyring, tsig_view_map): - sockaddr = self._resolve_address( - server_address, - socket.SOCK_STREAM, - socket.IPPROTO_TCP - ) - - super().__init__(sockaddr, handler_class) - - self.keyring = keyring - self.tsig_view_map = tsig_view_map - - -class UDPDNSServer(DNSAddressMixin, socketserver.UDPServer): - allow_reuse_address = True - - def __init__(self, server_address, handler_class, keyring, tsig_view_map): - sockaddr = self._resolve_address( - server_address, - socket.SOCK_DGRAM, - socket.IPPROTO_UDP - ) - - super().__init__(sockaddr, handler_class) - - self.keyring = keyring - self.tsig_view_map = tsig_view_map - - -class Command(BaseCommand): - help = "Run a minimal AXFR DNS server using NetBox DNS plugin data" - - def load_settings(self): - self.settings = settings.PLUGINS_CONFIG.get( - "netbox_plugin_bind_provisioner", None - ) - if not self.settings: - raise RuntimeError( - "Command failed to initialize due to missing settings. Terminating Netbox." - ) - - self.tsig_keys = self.settings.get("tsig_keys", None) - if not self.tsig_keys: - raise RuntimeError("tsig_keys variable not set in plugin settings.") - - # Load TSIG keys and map them to views - def load_tsig_key_settings(self): - self.keyring = {} - self.tsig_view_map = {} - - for view_name, data in self.tsig_keys.items(): - raw_key_name = data.get("keyname") - secret = data.get("secret") - algorithm_str = data.get("algorithm", "hmac-sha256") - - if not raw_key_name or not secret: - logger.error( - f"Skipping TSIG key for view {view_name}: missing keyname or secret." - ) - continue - - try: - nb_view = View.objects.get(name=view_name) - except View.DoesNotExist: - logger.error( - f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found." - ) - continue - - # Normalize key name to absolute DNS name - key_name_obj = dns.name.from_text(raw_key_name, origin=None).canonicalize() - if not key_name_obj.is_absolute(): - key_name_obj = key_name_obj.concatenate(dns.name.root) - key_name_str = key_name_obj.to_text() # Will always include trailing do - - self.keyring[key_name_obj] = dns.tsig.Key( - name=key_name_obj, secret=secret, algorithm=algorithm_str - ) - self.tsig_view_map[key_name_str] = nb_view - logger.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}") - - if not self.keyring: - msg = "No TSIG keys found in database." - logger.critical(msg) - raise RuntimeError(msg) - - def add_arguments(self, parser): - parser.add_argument( - "--port", type=int, default=5354, help="Port number to listen on" - ) - parser.add_argument( - "--address", type=str, default="0.0.0.0", help="IP to bind to" - ) - - def handle(self, *args, **options): - # Load parameters - port = options["port"] - address = options["address"] - CatalogZone.init() - - # Initialize settings - self.load_settings() - self.load_tsig_key_settings() - - udp_server = UDPDNSServer( - (address, port), UDPRequestHandler, self.keyring, self.tsig_view_map - ) - - tcp_server = TCPDNSServer( - (address, port), TCPRequestHandler, self.keyring, self.tsig_view_map - ) - - def run_udp_server(server): - logger.debug(f"Query endpoint listening on {address} udp/{port}") - server.serve_forever() - - udp_thread = threading.Thread( - target=run_udp_server, args=(udp_server,), daemon=True - ) - - udp_thread.start() - - logger.debug(f"Query endpoint listening on {address} tcp/{port}") - tcp_server.serve_forever() diff --git a/src/netbox_plugin_bind_provisioner/management/commands/dns-transfer-endpoint.py b/src/netbox_plugin_bind_provisioner/management/commands/dns-transfer-endpoint.py new file mode 100644 index 0000000..03f9be0 --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/management/commands/dns-transfer-endpoint.py @@ -0,0 +1,117 @@ +import logging +import threading +import dns.query +import dns.message +import dns.tsigkeyring +import dns.name +import dns.zone +import dns.rdatatype +import dns.rdataclass +import dns.rdtypes +import dns.exception +import dns.renderer + +from django.core.management.base import BaseCommand +from django.conf import settings +from netbox_dns.models import View +from netbox_plugin_bind_provisioner.service.endpoint.request_handler import UDPRequestHandler, TCPRequestHandler +from netbox_plugin_bind_provisioner.service.endpoint.dns_server import UDPDNSServer, TCPDNSServer +from netbox_plugin_bind_provisioner.service.endpoint import catalog_zone_manager as catzm + +logger = logging.getLogger("dns-transfer-endpoint") + +class Command(BaseCommand): + help = "Run a minimal AXFR DNS server using data from NetBox DNS plugin" + + def load_settings(self): + self.settings = settings.PLUGINS_CONFIG.get( + "netbox_plugin_bind_provisioner", None + ) + if not self.settings: + raise RuntimeError( + "Command failed to initialize due to missing settings. Terminating Netbox." + ) + + self.tsig_keys = self.settings.get("tsig_keys", None) + if not self.tsig_keys: + raise RuntimeError("tsig_keys variable not set in plugin settings.") + + # Load TSIG keys and map them to views + def load_tsig_key_settings(self): + self.keyring = {} + self.tsig_view_map = {} + + for view_name, data in self.tsig_keys.items(): + raw_key_name = data.get("keyname") + secret = data.get("secret") + algorithm_str = data.get("algorithm", "hmac-sha256") + + if not raw_key_name or not secret: + logger.error( + f"Skipping TSIG key for view {view_name}: missing keyname or secret." + ) + continue + + try: + nb_view = View.objects.get(name=view_name) + except View.DoesNotExist: + logger.error( + f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found." + ) + continue + + # Normalize key name to absolute DNS name + key_name_obj = dns.name.from_text(raw_key_name, origin=None).canonicalize() + if not key_name_obj.is_absolute(): + key_name_obj = key_name_obj.concatenate(dns.name.root) + key_name_str = key_name_obj.to_text() # Will always include trailing do + + self.keyring[key_name_obj] = dns.tsig.Key( + name=key_name_obj, secret=secret, algorithm=algorithm_str + ) + self.tsig_view_map[key_name_str] = nb_view + logger.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}") + + if not self.keyring: + msg = "No TSIG keys found in database." + logger.critical(msg) + raise RuntimeError(msg) + + def add_arguments(self, parser): + parser.add_argument( + "--port", type=int, default=5354, help="Port number to listen on" + ) + parser.add_argument( + "--address", type=str, default="0.0.0.0", help="IP to bind to" + ) + + def handle(self, *args, **options): + # Load parameters + port = options["port"] + address = options["address"] + catzm.init() + + # Initialize settings + self.load_settings() + self.load_tsig_key_settings() + + udp_server = UDPDNSServer( + (address, port), UDPRequestHandler, self.keyring, self.tsig_view_map + ) + + tcp_server = TCPDNSServer( + (address, port), TCPRequestHandler, self.keyring, self.tsig_view_map + ) + + def run_udp_server(server): + logger.debug(f"Query endpoint listening on {address} udp/{port}") + server.serve_forever() + + udp_thread = threading.Thread( + target=run_udp_server, args=(udp_server,), daemon=True + ) + + udp_thread.start() + + logger.debug(f"Query endpoint listening on {address} tcp/{port}") + tcp_server.serve_forever() diff --git a/src/netbox_plugin_bind_provisioner/service/endpoint/__init__.py b/src/netbox_plugin_bind_provisioner/service/endpoint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/netbox_plugin_bind_provisioner/service/endpoint/catalog_zone_manager.py b/src/netbox_plugin_bind_provisioner/service/endpoint/catalog_zone_manager.py new file mode 100644 index 0000000..d37c2b9 --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/service/endpoint/catalog_zone_manager.py @@ -0,0 +1,227 @@ +import logging +import threading +import dns.query +import dns.message +import dns.tsigkeyring +import dns.name +import dns.zone +import dns.rdatatype +import dns.rdataclass +import dns.rdtypes +import dns.exception +import dns.renderer +from netbox_dns.models import Zone +from netbox_dns.choices import ZoneStatusChoices +from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier +from uuid import uuid4 +from base64 import b32encode + +logger = logging.getLogger("dns-transfer-endpoint") + +_LOCK = threading.Lock() +_SERIAL_MAX = 0xFFFFFFFF +_SERIAL_OBJ = None +_PREVIOUS_LAST_ZONE_UPDATE = None + +def init() -> None: + _init_serial() + _create_missing_member_identifiers() + +# Following function loads the last serial from the DB. No return value +# but it sets the setting "catalog_serial" or terminates the plugin on failure. +def _init_serial() -> None: + global _SERIAL_OBJ + + try: + _SERIAL_OBJ = IntegerKeyValueSetting.objects.get( + key="catalog-zone-soa-serial" + ) + logger.debug( + f"Catalog zone SOA serial number {_SERIAL_OBJ.value} loaded from database" + ) + except IntegerKeyValueSetting.DoesNotExist: + _SERIAL_OBJ = IntegerKeyValueSetting.objects.create( + key="catalog-zone-soa-serial", value=1 + ) + logger.debug( + f"Catalog zone SOA serial number was not set in the database. Set to {_SERIAL_OBJ.value}" + ) + +def _increment_serial() -> None: + global _SERIAL_OBJ + + if 0 < _SERIAL_OBJ.value < _SERIAL_MAX: + _SERIAL_OBJ.value += 1 + _SERIAL_OBJ.save() + else: + logger.warning( + f"Failed to incremenet catalog serial {_SERIAL_OBJ.value}. Will overflow serial back to 1" + ) + _SERIAL_OBJ.value = 1 + _SERIAL_OBJ.save() + logger.debug( + f"Catalog zone SOA serial number incremented to {_SERIAL_OBJ.value}" + ) + +# If a zone has no catz identifier yet, create it: +def _create_missing_member_identifiers() -> None: + 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} has no catz member identifier. Creating...") + + CatalogZoneMemberIdentifier.objects.bulk_create( + new_objects, + ignore_conflicts=False, + ) + + +def create_zone(name, view_name) -> dns.zone.Zone: + global _PREVIOUS_LAST_ZONE_UPDATE + # Synchronize following across threads as TCP and UDP listener both use it. + with _LOCK: + latest_zone = ( + Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE) + .order_by("-last_updated") + .first() + ) + + last_zone_update = getattr(latest_zone, "last_updated", None) + + # Check if there was a zone updated since last call + # If no zone was found previously then this will be false since (None != None) = False + if _PREVIOUS_LAST_ZONE_UPDATE != last_zone_update: + if last_zone_update is not None: + logger.debug( + f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}" + ) + # Setting previous last zone update for next iteration: + _PREVIOUS_LAST_ZONE_UPDATE = last_zone_update + _increment_serial() + + # Zone origin + origin = dns.name.from_text(name, dns.name.root) + + # Create a new empty zone + zone = dns.zone.Zone(origin) + zone.rdclass = dns.rdataclass.IN + + # get zones from netbox + nb_zones = Zone.objects.filter( + view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE + ).select_related("catz_identifier") + + ptr_base = dns.name.from_text("zones", origin) + + for nb_zone in nb_zones: + ttl = 0 + qname = dns.name.from_text(nb_zone.name, dns.name.root) + + # Create PTR record + #p_name = f"zid-{nb_zone.id:09d}" + p_name = nb_zone.catz_identifier.name + + ptr_name = dns.name.from_text(p_name, ptr_base) + if not ptr_name.is_subdomain(origin): + raise ValueError( + "Catalog zone member identifier {ptr_name.to_text()} not a subdomain" + ) + rdata = dns.rdata.from_text( + dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() + ) + rdataset = zone.find_rdataset(ptr_name, dns.rdatatype.PTR, create=True) + rdataset.add(rdata, ttl) + + # Configure DNSSec Policy for member Zone if DNSSec is enabled + if nb_zone.dnssec_policy: + # Configure policy + rid = dns.name.from_text("group", ptr_name) + policy_name = nb_zone.dnssec_policy.name.rstrip(" ") + group_name = f"dnssec-policy-{policy_name}" + rdata = dns.rdata.from_text( + dns.rdataclass.IN, dns.rdatatype.TXT, group_name + ) + rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) + rdataset.add(rdata, ttl) + + ## Configure dnssec status for member zone + # status = str(1 if nb_zone.dnssec_policy else 0) + # rid = dns.name.from_text("enabled.dnssec.ext", ptr_name) + # rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status) + # rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) + # rdataset.add(rdata, ttl) + + # SOA Record components + ttl = 0 + rclass = dns.rdataclass.IN + rtype = dns.rdatatype.SOA + mname = dns.name.from_text("invalid", dns.name.root) + rname = dns.name.from_text("invalid", dns.name.root) + serial = _SERIAL_OBJ.value + refresh = 60 + retry = 10 + expire = 1209600 + minimum = 0 + + # Create SOA rdata object + soa_rdata = dns.rdata.from_text( + rclass, + rtype, + f"{mname} {rname} {serial} {refresh} {retry} {expire} {minimum}", + ) + + # Create Rdataset and add the RDATA to it + soa_rdataset = dns.rdataset.Rdataset(rclass, rtype) + soa_rdataset.add(soa_rdata, ttl) + + # Add to the origin node in the zone + node = zone.find_node(origin, create=True) + node.rdatasets.append(soa_rdataset) + + # NS record for catz. + ns_name = dns.name.from_text("invalid", dns.name.root) + ns_rdata = dns.rdata.from_text( + dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name) + ) + ns_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS) + ns_rdataset.add(ns_rdata, 0) + + # Add to node (catz. is the origin) + ns_node = zone.find_node(origin, create=True) + ns_node.rdatasets.append(ns_rdataset) + + # TXT record for version.catz. + version_name = dns.name.from_text( + "version", origin + ) # relative to origin "catz." + txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"') + + txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT) + txt_rdataset.add(txt_rdata, 0) + + # Add to node version.catz. + txt_node = zone.find_node(version_name, create=True) + txt_node.rdatasets.append(txt_rdataset) + + return zone + +def _generate_member_identifier() -> None: + return b32encode(uuid4().bytes)[0:26].lower().decode('UTF-8') + +def update_member_identifier(zone: Zone) -> None: + CatalogZoneMemberIdentifier.objects.update_or_create( + zone=zone, + defaults={"name": _generate_member_identifier}, + ) diff --git a/src/netbox_plugin_bind_provisioner/service/endpoint/dns_server.py b/src/netbox_plugin_bind_provisioner/service/endpoint/dns_server.py new file mode 100644 index 0000000..12d640d --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/service/endpoint/dns_server.py @@ -0,0 +1,54 @@ +import logging +import socketserver +import socket + +logger = logging.getLogger("dns-transfer-endpoint") + +class DNSAddressMixin: + def _resolve_address(self, server_address, socktype, proto): + host, port = server_address + + infos = socket.getaddrinfo( + host, + port, + socket.AF_UNSPEC, + socktype, + proto, + socket.AI_PASSIVE + ) + + family, _, _, _, sockaddr = infos[0] + self.address_family = family + return sockaddr + + +class TCPDNSServer(DNSAddressMixin, socketserver.TCPServer): + allow_reuse_address = True + + def __init__(self, server_address, handler_class, keyring, tsig_view_map): + sockaddr = self._resolve_address( + server_address, + socket.SOCK_STREAM, + socket.IPPROTO_TCP + ) + + super().__init__(sockaddr, handler_class) + + self.keyring = keyring + self.tsig_view_map = tsig_view_map + + +class UDPDNSServer(DNSAddressMixin, socketserver.UDPServer): + allow_reuse_address = True + + def __init__(self, server_address, handler_class, keyring, tsig_view_map): + sockaddr = self._resolve_address( + server_address, + socket.SOCK_DGRAM, + socket.IPPROTO_UDP + ) + + super().__init__(sockaddr, handler_class) + + self.keyring = keyring + self.tsig_view_map = tsig_view_map diff --git a/src/netbox_plugin_bind_provisioner/service/endpoint/request_handler.py b/src/netbox_plugin_bind_provisioner/service/endpoint/request_handler.py new file mode 100644 index 0000000..39b0a39 --- /dev/null +++ b/src/netbox_plugin_bind_provisioner/service/endpoint/request_handler.py @@ -0,0 +1,413 @@ +import logging +import socketserver +import socket +import dns.query +import dns.message +import dns.tsigkeyring +import dns.name +import dns.zone +import dns.rdatatype +import dns.rdataclass +import dns.rdtypes +import dns.exception +import dns.renderer +from netbox_dns.models import Zone, Record +from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices +from . import catalog_zone_manager as catzm + +logger = logging.getLogger("dns-transfer-endpoint") + + +class DNSBaseRequestHandler(socketserver.BaseRequestHandler): + def __init__(self, request, client_address, server) -> None: + self.MAX_WIRE = 65535 + self.RESERVED_TSIG = 300 + super().__init__(request, client_address, server) + + # _getZoneFromNB rewritten + def _getZoneFromNB(self, zone_name, view_name) -> dns.zone.Zone: + # Find the zone + try: + nb_zone = Zone.objects.get( + name=zone_name, + view__name=view_name, + status=ZoneStatusChoices.STATUS_ACTIVE, + ) + except Zone.DoesNotExist: + return None + + # Build DNS zone + zone = dns.zone.Zone(zone_name, dns.name.root) + zone.rdclass = dns.rdataclass.IN + + nb_records = Record.objects.filter( + zone=nb_zone, status=RecordStatusChoices.STATUS_ACTIVE + ) + + rdatasets_dict = {} + + for record in nb_records: + rdtype = dns.rdatatype.from_text(record.type) + if not record.name: + name = zone.origin + elif record.name.endswith("."): + name = dns.name.from_text(record.name) + else: + name = dns.name.from_text(record.name, origin=zone.origin) + + # If the record has no TTL, use the zone default + ttl = record.ttl or nb_zone.default_ttl + + # Apply quoting for TXT records to stop tokanizer + # from cutting it up: + value = record.value + if rdtype == dns.rdatatype.TXT: + if value.startswith('"') and value.endswith('"'): + value = value[1:-1].replace('" "', "").replace('"', '"') + + if len(value) > 255: + # This is a bug fix for netbox. If netbox allowed for + # an unquoted value to be larger then 255 characters, + # it misunderstood everything behind a ; as a comment. + chunks = [ + '"{}"'.format(value[i : i + 255]) + for i in range(0, len(value), 255) + ] + value = " ".join(chunks) + else: + value = f'"{value}"' + + rdata = dns.rdata.from_text( + dns.rdataclass.IN, + rdtype, + value, + relativize=False, + origin=zone.origin, + ) + + # Initialize rdataset if it doesn't exist for this name and type + if name not in rdatasets_dict: + rdatasets_dict[name] = {} + if rdtype not in rdatasets_dict[name]: + rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset( + dns.rdataclass.IN, rdtype + ) + + # Add the rdata to the appropriate rdataset + rdatasets_dict[name][rdtype].add(rdata, ttl) + + # Now, add all rdatasets to the zone + for name, rdtypes in rdatasets_dict.items(): + for rdtype, rdataset in rdtypes.items(): + # Ensure rdataset has the same rdclass as the zone + if rdataset.rdclass != zone.rdclass: + raise ValueError( + f"rdataset rdclass {rdataset.rdclass} does not match zone rdclass {zone.rdclass}" + ) + + # Check if the rdataset has any rdata before creating an RRset + if not rdataset: + logger.debug(f"Skipping empty rdataset for {name} {rdtype}") + continue # Skip empty rdataset + + # Replace the rdataset for the given name and type + zone.replace_rdataset(name, rdataset) + return zone + + def _denyRequestBadTSIG(self, wire, tsig_error: dns.rcode) -> None: + # Use empty keyring to parse TSIG without validating + query = dns.message.from_wire( + wire, keyring={}, ignore_trailing=True, continue_on_error=True + ) + + # Make a response + response = dns.message.make_response(query) + response.set_rcode(dns.rcode.REFUSED) + + if query.had_tsig: + # Add TSIG with BADKEY, but do not sign (no MAC) + response.use_tsig( + keyring={}, # empty; we're not signing + keyname=query.keyname, + tsig_error=tsig_error, + ) + self.denyRequest(query) + + def _send_response(self, data) -> None: + raise NotImplementedError + + def _deny_request(self, query, rcode: dns.rcode = dns.rcode.REFUSED) -> None: + response = dns.message.make_response(query) + response.set_rcode(rcode) + wire = response.to_wire(multi=False) + self._send_response(wire) + + def _handle_soa_request(self, query, soa_rrset, zone, peer, nb_view, dname) -> None: + # We assume that the SOA rdataset has at least one record (it usually does). + soa_rdata = soa_rrset[0] # Get the first SOA record + + # Now, create the rrset from the soa_rdata + rrset = dns.rrset.from_rdata(zone.origin, soa_rrset.ttl, soa_rdata) + + response = dns.message.make_response(query) + # Set the Authoritative Answer flag + response.flags |= dns.flags.AA + + # Append the rrset to the response's answer section + response.answer.append(rrset) + + # TSIG response + if query.had_tsig: + # If key was found in DB + if query.keyname in self.server.keyring: + response.use_tsig( + self.server.keyring, keyname=query.keyname, original_id=query.id + ) + else: + # If key is unknown + response.set_rcode(dns.rcode.REFUSED) + # Generate new key for keyname provided + # keyname = query.keyname + # b64 = base64.b64encode(urandom(32)).decode("ascii") + # newkey = dns.tsigkeyring.from_text({ keyname: b64 }) + response.use_tsig( + self.server.keyring, + keyname=query.keyname, + tsig_error=dns.rcode.BADKEY, + ) + + data = response.to_wire(max_size=512) + self._send_response(data) + logger.debug(f"{peer} SOA {nb_view.name}/{dname}") + + def _handle_axfr_request(self, query, zone, peer, nb_view, dname) -> None: + rrsets = [] + soa_rrset = None + for name, rdataset in zone.iterate_rdatasets(): + if not name.is_absolute(): + name = name.concatenate(zone.origin) + rrset = dns.rrset.from_rdata_list(name, rdataset.ttl, rdataset) + if rdataset.rdtype == dns.rdatatype.SOA and soa_rrset is None: + soa_rrset = rrset + else: + rrsets.append(rrset) + + rrsets.insert(0, soa_rrset) # Opening SOA + rrsets.append(soa_rrset) # Closing SOA + + # 2. Create a Renderer for the first message + r = dns.renderer.Renderer( + id=query.id, + flags=(dns.flags.QR | dns.flags.AA), + max_size=self.MAX_WIRE, + origin=None, + ) + r.add_question( + query.question[0].name, + query.question[0].rdtype, + query.question[0].rdclass, + ) + + # 3. Loop through RRsets + tsig_ctx = None + for rrset in rrsets: + # logger.debug(f"Iterating over rrset {rrset}. tsig_ctx: {tsig_ctx}") + try: + # logger.debug(f"Adding {rrset} to renderer object") + r.add_rrset(dns.renderer.ANSWER, rrset) + if r.max_size - len(r.output.getvalue()) < self.RESERVED_TSIG: + raise dns.exception.TooBig("TSIG wont fit") + except dns.exception.TooBig: + # TSIG chain previous message + r.write_header() + tsig_ctx = r.add_multi_tsig( + ctx=tsig_ctx, + secret=self.server.keyring[query.keyname], + keyname=query.keyname, + fudge=300, + id=query.id, + tsig_error=0, + other_data=b"", + request_mac=r.mac if tsig_ctx else query.mac, + ) + wire = r.get_wire() + self.request.sendall(len(wire).to_bytes(2, "big") + wire) + + # Start new renderer + r = dns.renderer.Renderer( + id=query.id, + flags=(dns.flags.QR | dns.flags.AA), + max_size=self.MAX_WIRE, + origin=None, + ) + r.add_question( + query.question[0].name, + query.question[0].rdtype, + query.question[0].rdclass, + ) + r.add_rrset(dns.renderer.ANSWER, rrset) + + # 4. Final message with terminating TSIG + r.write_header() + # logger.debug(f"Final message. tsig_ctx: {tsig_ctx}") + tsig_ctx = r.add_multi_tsig( + ctx=tsig_ctx, + secret=self.server.keyring[query.keyname], + keyname=query.keyname, + fudge=300, + id=query.id, + tsig_error=0, + other_data=b"", + # request_mac=r.mac if tsig_ctx else None, + request_mac=r.mac if tsig_ctx else query.mac, + ) + wire = r.get_wire() + self._send_response(wire) + + logger.debug(f"{peer} AXFR {nb_view.name}/{dname}") + + def _handle_dns_query(self, wire) -> None: + peer = self.client_address[0] + try: + query = dns.message.from_wire( + wire, + keyring=self.server.keyring, + continue_on_error=False, + ignore_trailing=True + ) + + except dns.tsig.BadSignature as e: + logger.warning( + f"Request denied from {peer} failed TSIG verification: {e}" + ) + self._denyRequestBadTSIG(wire, dns.rcode.BADSIG) + return + + except (dns.message.UnknownTSIGKey, dns.tsig.BadAlgorithm) as e: + logger.warning(f"Request denied from {peer} with bad TSIG key: {e}") + self._denyRequestBadTSIG(wire, dns.rcode.BADKEY) + return + + except Exception as e: + logger.error("Error parsing query: ", e) + return + + # If there was no question in the query, refuse + if len(query.question) != 1: + self._deny_request(query) + return + + question = query.question[0] + qname = question.name + qtype = question.rdtype + dname = qname.to_text().rstrip(".") + + # Only process AXFR/SOA queris + if qtype not in (dns.rdatatype.AXFR, dns.rdatatype.SOA): + logger.warning( + f"Request denied from {peer}: Request was not AXFR or SOA (Type: {qtype})" + ) + self._deny_request(query) + return + + # Identify TSIG key used + if not query.had_tsig: + logger.warning(f"Request denied from {peer}: No TSIG key used") + self._deny_request(query) + return + + key_name = query.keyname.canonicalize().to_text() + + # Check if the key matches a view + nb_view = self.server.tsig_view_map.get(key_name) + if not nb_view: + logger.warning( + f"Request denied from {peer}: {key_name} does not match a view" + ) + self._deny_request(query) + return + + # Check if catalog zone + if dname == "catz" or dname == f"{nb_view.name}.catz": + zone = catzm.create_zone(dname, nb_view.name) + else: + zone = self._getZoneFromNB(dname, nb_view.name) + # When zone was not found, let client know + if not zone: + logger.warning(f"Zone {dname} not found in view {nb_view.name}") + self._deny_request(query) + return + + # Retrieve the existing SOA record from the Zone + soa_rrset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA) + if soa_rrset is None: + logger.error(f"Zone {dname} has no SOA — aborting") + return + + if qtype == dns.rdatatype.SOA: + self._handle_soa_request(query, soa_rrset, zone, peer, nb_view, dname) + elif qtype == dns.rdatatype.AXFR: + self._handle_axfr_request(query, zone, peer, nb_view, dname) + + +class UDPRequestHandler(DNSBaseRequestHandler): + def __init__(self, request, client_address, server) -> None: + super().__init__(request, client_address, server) + + def _send_response(self, data) -> None: + sock = self.request[1] + sock.sendto(data, self.client_address) + + def handle(self) -> None: + data, sock = self.request + peer = self.client_address[0] + try: + self._handle_dns_query(data) + except Exception as e: + logger.error(f"Error handling request from {peer}: {e}") + import traceback + traceback.print_exc() + + +class TCPRequestHandler(DNSBaseRequestHandler): + def __init__(self, request, client_address, server) -> None: + super().__init__(request, client_address, server) + + def _send_response(self, data) -> None: + length = len(data).to_bytes(2, byteorder="big") + self.request.sendall(length + data) + + def handle(self) -> None: + peer = self.client_address[0] + sock = self.request # TCP socket + sock.settimeout(10.0) # Default 10 second timeout for inactivity + try: + while True: + # Read 2-byte length prefix + length_data = sock.recv(2) + if not length_data: + return # connection closed by peer + if len(length_data) < 2: + # Incomplete length data, wait for more or fail + while len(length_data) < 2: + chunk = sock.recv(2 - len(length_data)) + if not chunk: + return + length_data += chunk + + length = int.from_bytes(length_data, byteorder="big") + wire = b"" + while len(wire) < length: + chunk = sock.recv(length - len(wire)) + if not chunk: + return # connection closed + wire += chunk + self._handle_dns_query(wire) + + except socket.timeout: + logger.debug(f"Connection from {peer} timed out") + except Exception as e: + logger.error(f"Error handling request from {peer}: {e}") + import traceback + traceback.print_exc() + diff --git a/src/netbox_plugin_bind_provisioner/signals.py b/src/netbox_plugin_bind_provisioner/signals.py index 5dd1b2a..19819b7 100644 --- a/src/netbox_plugin_bind_provisioner/signals.py +++ b/src/netbox_plugin_bind_provisioner/signals.py @@ -1,8 +1,7 @@ from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from netbox_dns.models import Zone -from .models import CatalogZoneMemberIdentifier -from .utils import generate_catz_member_identifier +from .service.endpoint import catalog_zone_manager as catzm @receiver(pre_save, sender=Zone) def zone_pre_save(sender, instance, **kwargs): @@ -28,14 +27,9 @@ def sync_catalog_zone_identifier(sender, instance, created, **kwargs): Ensure CatalogZoneMemberIdentifier exists for each Zone and keep its identifier in sync. """ - identifier = generate_catz_member_identifier() - if created: # Create the related object on Zone creation - CatalogZoneMemberIdentifier.objects.create( - zone=instance, - name=identifier - ) + catzm.update_member_identifier(instance) else: old_name = getattr(instance, "_old_name", None) @@ -43,8 +37,4 @@ def sync_catalog_zone_identifier(sender, instance, created, **kwargs): if old_name == instance.name: return - # Update identifier - CatalogZoneMemberIdentifier.objects.update_or_create( - zone=instance, - defaults={"name": identifier}, - ) + catzm.update_member_identifier(instance) diff --git a/src/netbox_plugin_bind_provisioner/utils.py b/src/netbox_plugin_bind_provisioner/utils.py index 8971bcb..643af44 100644 --- a/src/netbox_plugin_bind_provisioner/utils.py +++ b/src/netbox_plugin_bind_provisioner/utils.py @@ -4,7 +4,6 @@ import dns.rdatatype import dns.rdataclass import dns.exception import netbox_dns.models -import uuid, base64 def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str): @@ -34,6 +33,3 @@ def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str): except IOError as e: raise IOError(f"Failed to write zone file to {file_path}: {e}") - -def generate_catz_member_identifier(): - return base64.b32encode(uuid.uuid4().bytes)[0:26].lower().decode('UTF-8')