diff --git a/netbox_dns_bridge/catalog_zone_manager.py b/netbox_dns_bridge/catalog_zone_manager.py index 1670133..bf99dce 100644 --- a/netbox_dns_bridge/catalog_zone_manager.py +++ b/netbox_dns_bridge/catalog_zone_manager.py @@ -7,7 +7,7 @@ from netbox_dns.models import Zone from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier from uuid import uuid4 from base64 import b32encode -from django.db import transaction +from django.db import transaction, close_old_connections, OperationalError logger = get_logger(__name__) @@ -17,6 +17,102 @@ def init() -> None: _create_missing_member_identifiers() +def increment_soa_serial() -> int: + with transaction.atomic(): + try: + soa_serial = IntegerKeyValueSetting.objects.select_for_update().get( + key="catalog-zone-soa-serial" + ) + + new_soa_serial = soa_serial.value + 1 + if new_soa_serial > 0xFFFFFFFF: + logger.warning( + f"Catalog serial {soa_serial.value} reached max — wrapping back to 1" + ) + new_soa_serial = 1 + soa_serial.value = new_soa_serial + soa_serial.save() + soa_serial.refresh_from_db() + logger.debug(f"Catalog zone SOA serial number is now {soa_serial.value}") + + return soa_serial.value + except OperationalError as e: + logger.error( + f"ERROR: Unable to increment the Catalog Zone's SOA serial: {e}" + ) + close_old_connections() + + +def create_zone(name, view_name) -> dns.zone.Zone: + # 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 + + try: + # get zones from netbox + nb_zones = Zone.objects.filter( + view__name=view_name, active=True + ).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 = nb_zone.catz_identifier.name + + ptr_name = dns.name.from_text(p_name, ptr_base) + if not ptr_name.is_subdomain(origin): + raise ValueError( + f"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: + 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) + + zone_root_node = zone.find_node(origin, create=True) + + # SOA record + soa_rdataset = _create_soa_rdataset() + zone_root_node.rdatasets.append(soa_rdataset) + + # NS record + ns_rdataset = _create_ns_rdataset() + zone_root_node.rdatasets.append(ns_rdataset) + + # version.catz. record + version_rdataset = _create_version_rdataset() + zone_version_node = zone.find_node( + dns.name.from_text("version", origin), create=True + ) + zone_version_node.rdatasets.append(version_rdataset) + + return zone + + except OperationalError as e: + logger.error(f"ERROR: Unable to create Catalog Zone: {e}") + close_old_connections() + return None + + def _init_soa_serial() -> None: try: IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") @@ -26,148 +122,9 @@ def _init_soa_serial() -> None: logger.debug( "Catalog zone SOA serial number was not set in the database. Set to 1" ) - - -def increment_soa_serial() -> int: - with transaction.atomic(): - soa_serial = IntegerKeyValueSetting.objects.select_for_update().get( - key="catalog-zone-soa-serial" - ) - - new_soa_serial = soa_serial.value + 1 - if new_soa_serial > 0xFFFFFFFF: - logger.warning( - f"Catalog serial {soa_serial.value} reached max — wrapping back to 1" - ) - new_soa_serial = 1 - soa_serial.value = new_soa_serial - soa_serial.save() - soa_serial.refresh_from_db() - logger.debug(f"Catalog zone SOA serial number is now {soa_serial.value}") - - return soa_serial.value - - -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} missing catz member identifier. Creating..." - ) - - CatalogZoneMemberIdentifier.objects.bulk_create( - new_objects, - ignore_conflicts=False, - ) - - -def create_zone(name, view_name) -> dns.zone.Zone: - soa_serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value - - # 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, active=True - ).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 = nb_zone.catz_identifier.name - - ptr_name = dns.name.from_text(p_name, ptr_base) - if not ptr_name.is_subdomain(origin): - raise ValueError( - f"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: - 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) - - # 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) - refresh = 60 - retry = 10 - expire = 1209600 - minimum = 0 - - # Create SOA rdata object - soa_rdata = dns.rdata.from_text( - rclass, - rtype, - f"{mname} {rname} {soa_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) - 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 + except OperationalError as e: + logger.error(f"ERROR: Unable to initialize the Catalog Zone's SOA serial: {e}") + close_old_connections() def _generate_member_identifier() -> str: @@ -175,7 +132,90 @@ def _generate_member_identifier() -> str: def update_member_identifier(zone: Zone) -> None: - CatalogZoneMemberIdentifier.objects.update_or_create( - zone=zone, - defaults={"name": _generate_member_identifier()}, - ) + try: + CatalogZoneMemberIdentifier.objects.update_or_create( + zone=zone, + defaults={"name": _generate_member_identifier()}, + ) + except OperationalError as e: + logger.error(f"ERROR: Unable to update Catalog Zone member identifier: {e}") + close_old_connections() + + +def _create_missing_member_identifiers() -> None: + try: + existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list( + "zone_id", flat=True + ) + + missing_zones = Zone.objects.exclude(id__in=existing_zone_ids) + + new_objects = [ + CatalogZoneMemberIdentifier( + zone=zone, + name=_generate_member_identifier(), + ) + for zone in missing_zones + ] + + for identifier in new_objects: + logger.debug( + f"Zone {identifier.zone} missing catz member identifier. Creating..." + ) + + CatalogZoneMemberIdentifier.objects.bulk_create( + new_objects, + ignore_conflicts=False, + ) + + except OperationalError as e: + logger.error(f"ERROR: Could not create catz member identifier: {e}") + close_old_connections() + + +def _create_soa_rdataset() -> dns.rdataset: + try: + serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value + + # 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) + refresh = 60 + retry = 10 + expire = 1209600 + minimum = 0 + + # Create SOA rdata object + rdata = dns.rdata.from_text( + rclass, + rtype, + f"{mname} {rname} {serial} {refresh} {retry} {expire} {minimum}", + ) + + # Create Rdataset and add the RDATA to it + rdataset = dns.rdataset.Rdataset(rclass, rtype) + rdataset.add(rdata, ttl) + return rdataset + + except OperationalError as e: + logger.error(f"ERROR: Unable to access catz SOA record in database: {e}") + close_old_connections() + + +def _create_ns_rdataset() -> dns.rdataset: + # NS record for catz. + name = dns.name.from_text("invalid", dns.name.root) + rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, str(name)) + rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS) + rdataset.add(rdata, 0) + return rdataset + + +def _create_version_rdataset() -> dns.rdataset: + rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"') + rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT) + rdataset.add(rdata, 0) + return rdataset diff --git a/netbox_dns_bridge/management/commands/dns-export-zone.py b/netbox_dns_bridge/management/commands/dns-export-zone.py index ef75baf..e2f3d9c 100644 --- a/netbox_dns_bridge/management/commands/dns-export-zone.py +++ b/netbox_dns_bridge/management/commands/dns-export-zone.py @@ -1,8 +1,3 @@ -import os -import dns.zone -import dns.rdatatype -import dns.rdataclass -import dns.exception import netbox_dns.models from netbox_dns_bridge.utils import export_bind_zone_file from django.core.management.base import BaseCommand, CommandError @@ -24,10 +19,6 @@ class Command(BaseCommand): ) def handle(self, *args, **options): - # if len(options) < 2: - # print("export-zone ") - # sys.exit(1) - # Load parameters view_name = options["view"] zone_name = options["zone"] @@ -48,13 +39,11 @@ class Command(BaseCommand): if nb_zone: export_bind_zone_file(nb_zone, file_path=file_path) + self.stdout.write( + self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'") + ) else: - print("Zone not found in Netbox. Aborting") - sys.exit(1) + self.stderr.write("Zone not found in Netbox. Aborting") except Exception as e: raise CommandError(f"Failed to export zone: {e}") - - self.stdout.write( - self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'") - ) diff --git a/netbox_dns_bridge/management/commands/dns-export-zones.py b/netbox_dns_bridge/management/commands/dns-export-zones.py index de725a5..871662d 100644 --- a/netbox_dns_bridge/management/commands/dns-export-zones.py +++ b/netbox_dns_bridge/management/commands/dns-export-zones.py @@ -1,8 +1,4 @@ import os -import dns.zone -import dns.rdatatype -import dns.rdataclass -import dns.exception import netbox_dns.models from netbox_dns_bridge.utils import export_bind_zone_file from django.core.management.base import BaseCommand, CommandError diff --git a/netbox_dns_bridge/management/commands/dns-transfer-endpoint.py b/netbox_dns_bridge/management/commands/dns-transfer-endpoint.py index 46bb204..e6a1c4e 100644 --- a/netbox_dns_bridge/management/commands/dns-transfer-endpoint.py +++ b/netbox_dns_bridge/management/commands/dns-transfer-endpoint.py @@ -9,36 +9,24 @@ import dns.rdataclass import dns.rdtypes import dns.exception import dns.renderer -import logging from django.core.management.base import BaseCommand from django.conf import settings +from django.db import close_old_connections, OperationalError from netbox_dns.models import View from netbox_dns_bridge import catalog_zone_manager as catzm from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer -from netbox_dns_bridge.models import IntegerKeyValueSetting from netbox_dns_bridge.logger import get_logger -logger = get_logger(__name__) +LOGGER = get_logger(__name__) +SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) 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_dns_bridge", 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): + def _load_tsig_key_settings(self): self.keyring = {} self.tsig_view_map = {} @@ -48,7 +36,7 @@ class Command(BaseCommand): algorithm_str = data.get("algorithm", "hmac-sha256") if not raw_key_name or not secret: - logger.error( + LOGGER.error( f"Skipping TSIG key for view {view_name}: missing keyname or secret." ) continue @@ -56,10 +44,12 @@ class Command(BaseCommand): try: nb_view = View.objects.get(name=view_name) except View.DoesNotExist: - logger.error( + LOGGER.error( f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found." ) continue + except OperationalError: + close_old_connections() # Normalize key name to absolute DNS name key_name_obj = dns.name.from_text(raw_key_name, origin=None).canonicalize() @@ -71,11 +61,11 @@ class Command(BaseCommand): name=key_name_obj, secret=secret, algorithm=algorithm_str ) self.tsig_view_map[key_name_str] = nb_view - logger.info(f"Loaded TSIG key {key_name_str} for view {nb_view.name}") + LOGGER.info(f"Loaded TSIG key {key_name_str} for view {nb_view.name}") if not self.keyring: msg = "No TSIG keys found in database." - logger.critical(msg) + LOGGER.critical(msg) raise RuntimeError(msg) def add_arguments(self, parser): @@ -92,9 +82,11 @@ class Command(BaseCommand): address = options["address"] catzm.init() - # Initialize settings - self.load_settings() - self.load_tsig_key_settings() + self.tsig_keys = SETTINGS.get("tsig_keys", None) + if not self.tsig_keys: + raise RuntimeError("tsig_keys variable not set in plugin settings.") + + self._load_tsig_key_settings() udp_server = UDPDNSServer( (address, port), UDPRequestHandler, self.keyring, self.tsig_view_map @@ -104,15 +96,15 @@ class Command(BaseCommand): (address, port), TCPRequestHandler, self.keyring, self.tsig_view_map ) - def run_udp_server(server): - logger.info(f"Query endpoint listening on {address} udp/{port}") + def _run_udp_server(server): + LOGGER.info(f"Query endpoint listening on {address} udp/{port}") server.serve_forever() udp_thread = threading.Thread( - target=run_udp_server, args=(udp_server,), daemon=True + target=_run_udp_server, args=(udp_server,), daemon=True ) udp_thread.start() - logger.info(f"Query endpoint listening on {address} tcp/{port}") + LOGGER.info(f"Query endpoint listening on {address} tcp/{port}") tcp_server.serve_forever() diff --git a/netbox_dns_bridge/notify_handler.py b/netbox_dns_bridge/notify_handler.py index f1f27a5..ff463f3 100644 --- a/netbox_dns_bridge/notify_handler.py +++ b/netbox_dns_bridge/notify_handler.py @@ -6,6 +6,7 @@ import dns.rdatatype import dns.tsig from django.conf import settings from django.utils import timezone +from django.db import close_old_connections, OperationalError from netbox.jobs import JobRunner from netbox_dns.models import View, Zone from .logger import get_logger @@ -48,31 +49,34 @@ class SendDNSNotify(JobRunner): view_name = kwargs["view_name"] zone_name = kwargs["zone_name"] - view = View.objects.get(name=view_name) - tsig_key = self._load_tsig_key(view_name) + try: + view = View.objects.get(name=view_name) + tsig_key = self._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 = SeenTransferClients.objects.filter( + view=view, last_seen__gte=seen_cutoff + ) - cutoff_hours = SETTINGS.get("notify_client_dead_after_hours", 24) - seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) - clients = SeenTransferClients.objects.filter( - view=view, last_seen__gte=seen_cutoff - ) + for name in (zone_name, "catz", f"{view_name}.catz"): + msg = dns.message.make_query(name, dns.rdatatype.SOA) + msg.flags = 0 + msg.set_opcode(dns.opcode.NOTIFY) + msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name) - for name in (zone_name, "catz", f"{view_name}.catz"): - msg = dns.message.make_query(name, dns.rdatatype.SOA) - msg.flags = 0 - msg.set_opcode(dns.opcode.NOTIFY) - msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name) - - for client in clients: - try: - dns.query.udp(msg, client.source_ip, timeout=2) - self.logger.info( - f"NOTIFY sent to {client.source_ip} for zone {name}" - ) - except Exception as e: - self.logger.error( - f"NOTIFY failed for {client.source_ip} zone {name}: {e}" - ) + for client in clients: + try: + dns.query.udp(msg, client.source_ip, timeout=2) + self.logger.info( + f"NOTIFY sent to {client.source_ip} for zone {name}" + ) + except Exception as e: + self.logger.error( + f"NOTIFY failed for {client.source_ip} zone {name}: {e}" + ) + except OperationalError as e: + self.logger.error(f"NOTIFY failed due to unexpected error: {e}") + close_old_connections() def schedule(zone: Zone): diff --git a/netbox_dns_bridge/request_handler.py b/netbox_dns_bridge/request_handler.py index 39000dd..e6d111e 100644 --- a/netbox_dns_bridge/request_handler.py +++ b/netbox_dns_bridge/request_handler.py @@ -12,13 +12,16 @@ import dns.exception import dns.renderer from .logger import get_logger from django.utils import timezone +from django.db import close_old_connections, OperationalError +from django.conf import settings from netbox_dns.models import Zone, Record, View from netbox_dns_bridge import catalog_zone_manager as catzm from .models import SeenTransferClients -logger = get_logger(__name__) +LOGGER = get_logger(__name__) SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"] + class DNSBaseRequestHandler(socketserver.BaseRequestHandler): def __init__(self, request, client_address, server) -> None: self.MAX_WIRE = 65535 @@ -34,87 +37,88 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): view__name=view_name, active=True, ) - except Zone.DoesNotExist: - return None + # Build DNS zone + zone = dns.zone.Zone(zone_name, dns.name.root) + zone.rdclass = dns.rdataclass.IN - # 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, active=True) - nb_records = Record.objects.filter( - zone=nb_zone, active=True - ) + rdatasets_dict = {} - 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) + 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: - value = f'"{value}"' + name = dns.name.from_text(record.name, origin=zone.origin) - rdata = dns.rdata.from_text( - dns.rdataclass.IN, - rdtype, - value, - relativize=False, - origin=zone.origin, - ) + # If the record has no TTL, use the zone default + ttl = record.ttl or nb_zone.default_ttl - # 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 + # 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, ) - # 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 " - f"zone rdclass {zone.rdclass}" + # 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 ) - # 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 + # Add the rdata to the appropriate rdataset + rdatasets_dict[name][rdtype].add(rdata, ttl) - # Replace the rdataset for the given name and type - zone.replace_rdataset(name, rdataset) - return zone + # 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 " + f"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 + + except Zone.DoesNotExist: + return None + except OperationalError as e: + LOGGER.error(f"DB ERROR: {e}") + close_old_connections() def _deny_request_bad_tsig(self, wire, tsig_error: dns.rcode) -> None: # Use empty keyring to parse TSIG without validating @@ -146,11 +150,16 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): def _track_seen_client(self, source_ip: str, view: View) -> None: if SETTINGS.get("notify_clients", False): - SeenTransferClients.objects.update_or_create( - source_ip=source_ip, - view=view, - defaults={"last_seen": timezone.now()}, - ) + try: + SeenTransferClients.objects.update_or_create( + source_ip=source_ip, + view=view, + defaults={"last_seen": timezone.now()}, + ) + + except OperationalError as e: + LOGGER.error(f"DB ERROR: {e}") + close_old_connections() 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). @@ -185,11 +194,11 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): data = response.to_wire(max_size=512) self._send_response(data) - logger.info(f"{peer} SOA {nb_view.name}/{dname}") + LOGGER.info(f"{peer} SOA {nb_view.name}/{dname}") def _handle_axfr_request(self, query, zone, peer, nb_view, dname) -> None: if query.keyname not in self.server.keyring: - logger.error(f"AXFR aborted: keyname {query.keyname} not in keyring") + LOGGER.error(f"AXFR aborted: keyname {query.keyname} not in keyring") self._deny_request(query) return @@ -262,7 +271,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): try: r.add_rrset(dns.renderer.ANSWER, rrset) except dns.exception.TooBig: - logger.error( + LOGGER.error( f"RRset {rrset.name}/{dns.rdatatype.to_text(rrset.rdtype)} " f"exceeds MAX_WIRE ({self.MAX_WIRE}) and cannot be sent; " f"skipping." @@ -283,7 +292,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): ) wire = r.get_wire() self._send_response(wire) - logger.info(f"{peer} AXFR {nb_view.name}/{dname}") + LOGGER.info(f"{peer} AXFR {nb_view.name}/{dname}") def _handle_dns_query(self, wire) -> None: peer = self.client_address[0] @@ -296,17 +305,17 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): ) except dns.tsig.BadSignature as e: - logger.warning(f"Request denied from {peer} failed TSIG verification: {e}") + LOGGER.warning(f"Request denied from {peer} failed TSIG verification: {e}") self._deny_request_bad_tsig(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}") + LOGGER.warning(f"Request denied from {peer} with bad TSIG key: {e}") self._deny_request_bad_tsig(wire, dns.rcode.BADKEY) return except Exception as e: - logger.error("Error parsing query: ", e) + LOGGER.error("Error parsing query: ", e) return # If there was no question in the query, refuse @@ -321,7 +330,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): # Only process AXFR/SOA queris if qtype not in (dns.rdatatype.AXFR, dns.rdatatype.SOA): - logger.warning( + LOGGER.warning( f"Request denied from {peer}: Request was not AXFR or SOA (Type: {qtype})" ) self._deny_request(query) @@ -329,7 +338,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): # Identify TSIG key used if not query.had_tsig: - logger.warning(f"Request denied from {peer}: No TSIG key used") + LOGGER.warning(f"Request denied from {peer}: No TSIG key used") self._deny_request(query) return @@ -338,7 +347,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): # Check if the key matches a view nb_view = self.server.tsig_view_map.get(key_name) if not nb_view: - logger.warning( + LOGGER.warning( f"Request denied from {peer}: {key_name} does not match a view" ) self._deny_request(query) @@ -351,14 +360,14 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler): 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}") + 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") + LOGGER.error(f"Zone {dname} has no SOA — aborting") return if qtype == dns.rdatatype.SOA: @@ -383,7 +392,7 @@ class UDPRequestHandler(DNSBaseRequestHandler): try: self._handle_dns_query(data) except Exception as e: - logger.error(f"Error handling request from {peer}: {e}") + LOGGER.error(f"Error handling request from {peer}: {e}") import traceback traceback.print_exc() @@ -425,9 +434,9 @@ class TCPRequestHandler(DNSBaseRequestHandler): self._handle_dns_query(wire) except socket.timeout: - logger.debug(f"Connection from {peer} timed out") + LOGGER.debug(f"Connection from {peer} timed out") except Exception as e: - logger.error(f"Error handling request from {peer}: {e}") + LOGGER.error(f"Error handling request from {peer}: {e}") import traceback traceback.print_exc()