modified: catalog_zone_manager.py

modified:   management/commands/dns-export-zone.py
modified:   management/commands/dns-export-zones.py
modified:   management/commands/dns-transfer-endpoint.py
modified:   notify_handler.py
modified:   request_handler.py
Guard all db requests against broken database connections.
This commit is contained in:
Sven Luethi committed 2026-07-10 17:10:19 +02:00
1 parent d18e0fc262
commit 5747b044b6
6 files changed
+337 -307

No files matched your search

+187 -147
View File
@@ -7,7 +7,7 @@ from netbox_dns.models import Zone
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.db import transaction from django.db import transaction, close_old_connections, OperationalError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -17,6 +17,102 @@ def init() -> None:
_create_missing_member_identifiers() _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: def _init_soa_serial() -> None:
try: try:
IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
@@ -26,148 +122,9 @@ def _init_soa_serial() -> None:
logger.debug( logger.debug(
"Catalog zone SOA serial number was not set in the database. Set to 1" "Catalog zone SOA serial number was not set in the database. Set to 1"
) )
except OperationalError as e:
logger.error(f"ERROR: Unable to initialize the Catalog Zone's SOA serial: {e}")
def increment_soa_serial() -> int: close_old_connections()
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
def _generate_member_identifier() -> str: def _generate_member_identifier() -> str:
@@ -175,7 +132,90 @@ def _generate_member_identifier() -> str:
def update_member_identifier(zone: Zone) -> None: def update_member_identifier(zone: Zone) -> None:
CatalogZoneMemberIdentifier.objects.update_or_create( try:
zone=zone, CatalogZoneMemberIdentifier.objects.update_or_create(
defaults={"name": _generate_member_identifier()}, 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
@@ -1,8 +1,3 @@
import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models import netbox_dns.models
from netbox_dns_bridge.utils import export_bind_zone_file from netbox_dns_bridge.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
@@ -24,10 +19,6 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
# if len(options) < 2:
# print("export-zone <zone name> <file path>")
# sys.exit(1)
# Load parameters # Load parameters
view_name = options["view"] view_name = options["view"]
zone_name = options["zone"] zone_name = options["zone"]
@@ -48,13 +39,11 @@ class Command(BaseCommand):
if nb_zone: if nb_zone:
export_bind_zone_file(nb_zone, file_path=file_path) 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: else:
print("Zone not found in Netbox. Aborting") self.stderr.write("Zone not found in Netbox. Aborting")
sys.exit(1)
except Exception as e: except Exception as e:
raise CommandError(f"Failed to export zone: {e}") raise CommandError(f"Failed to export zone: {e}")
self.stdout.write(
self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'")
)
@@ -1,8 +1,4 @@
import os import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models import netbox_dns.models
from netbox_dns_bridge.utils import export_bind_zone_file from netbox_dns_bridge.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
@@ -9,36 +9,24 @@ import dns.rdataclass
import dns.rdtypes import dns.rdtypes
import dns.exception import dns.exception
import dns.renderer import dns.renderer
import logging
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from django.db import close_old_connections, OperationalError
from netbox_dns.models import View from netbox_dns.models import View
from netbox_dns_bridge import catalog_zone_manager as catzm from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler
from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer
from netbox_dns_bridge.models import IntegerKeyValueSetting
from netbox_dns_bridge.logger import get_logger 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): class Command(BaseCommand):
help = "Run a minimal AXFR DNS server using data from NetBox DNS plugin" help = "Run a minimal AXFR DNS server using data from NetBox DNS plugin"
def load_settings(self): def _load_tsig_key_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):
self.keyring = {} self.keyring = {}
self.tsig_view_map = {} self.tsig_view_map = {}
@@ -48,7 +36,7 @@ class Command(BaseCommand):
algorithm_str = data.get("algorithm", "hmac-sha256") algorithm_str = data.get("algorithm", "hmac-sha256")
if not raw_key_name or not secret: if not raw_key_name or not secret:
logger.error( LOGGER.error(
f"Skipping TSIG key for view {view_name}: missing keyname or secret." f"Skipping TSIG key for view {view_name}: missing keyname or secret."
) )
continue continue
@@ -56,10 +44,12 @@ class Command(BaseCommand):
try: try:
nb_view = View.objects.get(name=view_name) nb_view = View.objects.get(name=view_name)
except View.DoesNotExist: except View.DoesNotExist:
logger.error( LOGGER.error(
f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found." f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found."
) )
continue continue
except OperationalError:
close_old_connections()
# Normalize key name to absolute DNS name # Normalize key name to absolute DNS name
key_name_obj = dns.name.from_text(raw_key_name, origin=None).canonicalize() 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 name=key_name_obj, secret=secret, algorithm=algorithm_str
) )
self.tsig_view_map[key_name_str] = nb_view 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: if not self.keyring:
msg = "No TSIG keys found in database." msg = "No TSIG keys found in database."
logger.critical(msg) LOGGER.critical(msg)
raise RuntimeError(msg) raise RuntimeError(msg)
def add_arguments(self, parser): def add_arguments(self, parser):
@@ -92,9 +82,11 @@ class Command(BaseCommand):
address = options["address"] address = options["address"]
catzm.init() catzm.init()
# Initialize settings self.tsig_keys = SETTINGS.get("tsig_keys", None)
self.load_settings() if not self.tsig_keys:
self.load_tsig_key_settings() raise RuntimeError("tsig_keys variable not set in plugin settings.")
self._load_tsig_key_settings()
udp_server = UDPDNSServer( udp_server = UDPDNSServer(
(address, port), UDPRequestHandler, self.keyring, self.tsig_view_map (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 (address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
) )
def run_udp_server(server): def _run_udp_server(server):
logger.info(f"Query endpoint listening on {address} udp/{port}") LOGGER.info(f"Query endpoint listening on {address} udp/{port}")
server.serve_forever() server.serve_forever()
udp_thread = threading.Thread( 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() 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() tcp_server.serve_forever()
+27 -23
View File
@@ -6,6 +6,7 @@ import dns.rdatatype
import dns.tsig import dns.tsig
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone from netbox_dns.models import View, Zone
from .logger import get_logger from .logger import get_logger
@@ -48,31 +49,34 @@ class SendDNSNotify(JobRunner):
view_name = kwargs["view_name"] view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"] zone_name = kwargs["zone_name"]
view = View.objects.get(name=view_name) try:
tsig_key = self._load_tsig_key(view_name) 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) for name in (zone_name, "catz", f"{view_name}.catz"):
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) msg = dns.message.make_query(name, dns.rdatatype.SOA)
clients = SeenTransferClients.objects.filter( msg.flags = 0
view=view, last_seen__gte=seen_cutoff 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"): for client in clients:
msg = dns.message.make_query(name, dns.rdatatype.SOA) try:
msg.flags = 0 dns.query.udp(msg, client.source_ip, timeout=2)
msg.set_opcode(dns.opcode.NOTIFY) self.logger.info(
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name) f"NOTIFY sent to {client.source_ip} for zone {name}"
)
for client in clients: except Exception as e:
try: self.logger.error(
dns.query.udp(msg, client.source_ip, timeout=2) f"NOTIFY failed for {client.source_ip} zone {name}: {e}"
self.logger.info( )
f"NOTIFY sent to {client.source_ip} for zone {name}" except OperationalError as e:
) self.logger.error(f"NOTIFY failed due to unexpected error: {e}")
except Exception as e: close_old_connections()
self.logger.error(
f"NOTIFY failed for {client.source_ip} zone {name}: {e}"
)
def schedule(zone: Zone): def schedule(zone: Zone):
+100 -91
View File
@@ -12,13 +12,16 @@ import dns.exception
import dns.renderer import dns.renderer
from .logger import get_logger from .logger import get_logger
from django.utils import timezone 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.models import Zone, Record, View
from netbox_dns_bridge import catalog_zone_manager as catzm from netbox_dns_bridge import catalog_zone_manager as catzm
from .models import SeenTransferClients from .models import SeenTransferClients
logger = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"] SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
class DNSBaseRequestHandler(socketserver.BaseRequestHandler): class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
def __init__(self, request, client_address, server) -> None: def __init__(self, request, client_address, server) -> None:
self.MAX_WIRE = 65535 self.MAX_WIRE = 65535
@@ -34,87 +37,88 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
view__name=view_name, view__name=view_name,
active=True, active=True,
) )
except Zone.DoesNotExist: # Build DNS zone
return None zone = dns.zone.Zone(zone_name, dns.name.root)
zone.rdclass = dns.rdataclass.IN
# Build DNS zone nb_records = Record.objects.filter(zone=nb_zone, active=True)
zone = dns.zone.Zone(zone_name, dns.name.root)
zone.rdclass = dns.rdataclass.IN
nb_records = Record.objects.filter( rdatasets_dict = {}
zone=nb_zone, active=True
)
rdatasets_dict = {} for record in nb_records:
rdtype = dns.rdatatype.from_text(record.type)
for record in nb_records: if not record.name:
rdtype = dns.rdatatype.from_text(record.type) name = zone.origin
if not record.name: elif record.name.endswith("."):
name = zone.origin name = dns.name.from_text(record.name)
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: else:
value = f'"{value}"' name = dns.name.from_text(record.name, origin=zone.origin)
rdata = dns.rdata.from_text( # If the record has no TTL, use the zone default
dns.rdataclass.IN, ttl = record.ttl or nb_zone.default_ttl
rdtype,
value,
relativize=False,
origin=zone.origin,
)
# Initialize rdataset if it doesn't exist for this name and type # Apply quoting for TXT records to stop tokanizer
if name not in rdatasets_dict: # from cutting it up:
rdatasets_dict[name] = {} value = record.value
if rdtype not in rdatasets_dict[name]: if rdtype == dns.rdatatype.TXT:
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset( if value.startswith('"') and value.endswith('"'):
dns.rdataclass.IN, rdtype 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 # Initialize rdataset if it doesn't exist for this name and type
rdatasets_dict[name][rdtype].add(rdata, ttl) if name not in rdatasets_dict:
rdatasets_dict[name] = {}
# Now, add all rdatasets to the zone if rdtype not in rdatasets_dict[name]:
for name, rdtypes in rdatasets_dict.items(): rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(
for rdtype, rdataset in rdtypes.items(): dns.rdataclass.IN, rdtype
# 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 # Add the rdata to the appropriate rdataset
if not rdataset: rdatasets_dict[name][rdtype].add(rdata, ttl)
logger.debug(f"Skipping empty rdataset for {name} {rdtype}")
continue # Skip empty rdataset
# Replace the rdataset for the given name and type # Now, add all rdatasets to the zone
zone.replace_rdataset(name, rdataset) for name, rdtypes in rdatasets_dict.items():
return zone 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: def _deny_request_bad_tsig(self, wire, tsig_error: dns.rcode) -> None:
# Use empty keyring to parse TSIG without validating # 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: def _track_seen_client(self, source_ip: str, view: View) -> None:
if SETTINGS.get("notify_clients", False): if SETTINGS.get("notify_clients", False):
SeenTransferClients.objects.update_or_create( try:
source_ip=source_ip, SeenTransferClients.objects.update_or_create(
view=view, source_ip=source_ip,
defaults={"last_seen": timezone.now()}, 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: 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). # 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) data = response.to_wire(max_size=512)
self._send_response(data) 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: def _handle_axfr_request(self, query, zone, peer, nb_view, dname) -> None:
if query.keyname not in self.server.keyring: 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) self._deny_request(query)
return return
@@ -262,7 +271,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
try: try:
r.add_rrset(dns.renderer.ANSWER, rrset) r.add_rrset(dns.renderer.ANSWER, rrset)
except dns.exception.TooBig: except dns.exception.TooBig:
logger.error( LOGGER.error(
f"RRset {rrset.name}/{dns.rdatatype.to_text(rrset.rdtype)} " f"RRset {rrset.name}/{dns.rdatatype.to_text(rrset.rdtype)} "
f"exceeds MAX_WIRE ({self.MAX_WIRE}) and cannot be sent; " f"exceeds MAX_WIRE ({self.MAX_WIRE}) and cannot be sent; "
f"skipping." f"skipping."
@@ -283,7 +292,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
) )
wire = r.get_wire() wire = r.get_wire()
self._send_response(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: def _handle_dns_query(self, wire) -> None:
peer = self.client_address[0] peer = self.client_address[0]
@@ -296,17 +305,17 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
) )
except dns.tsig.BadSignature as e: 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) self._deny_request_bad_tsig(wire, dns.rcode.BADSIG)
return return
except (dns.message.UnknownTSIGKey, dns.tsig.BadAlgorithm) as e: 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) self._deny_request_bad_tsig(wire, dns.rcode.BADKEY)
return return
except Exception as e: except Exception as e:
logger.error("Error parsing query: ", e) LOGGER.error("Error parsing query: ", e)
return return
# If there was no question in the query, refuse # If there was no question in the query, refuse
@@ -321,7 +330,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Only process AXFR/SOA queris # Only process AXFR/SOA queris
if qtype not in (dns.rdatatype.AXFR, dns.rdatatype.SOA): 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})" f"Request denied from {peer}: Request was not AXFR or SOA (Type: {qtype})"
) )
self._deny_request(query) self._deny_request(query)
@@ -329,7 +338,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Identify TSIG key used # Identify TSIG key used
if not query.had_tsig: 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) self._deny_request(query)
return return
@@ -338,7 +347,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Check if the key matches a view # Check if the key matches a view
nb_view = self.server.tsig_view_map.get(key_name) nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view: if not nb_view:
logger.warning( LOGGER.warning(
f"Request denied from {peer}: {key_name} does not match a view" f"Request denied from {peer}: {key_name} does not match a view"
) )
self._deny_request(query) self._deny_request(query)
@@ -351,14 +360,14 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
zone = self._getZoneFromNB(dname, nb_view.name) zone = self._getZoneFromNB(dname, nb_view.name)
# When zone was not found, let client know # When zone was not found, let client know
if not zone: 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) self._deny_request(query)
return return
# Retrieve the existing SOA record from the Zone # Retrieve the existing SOA record from the Zone
soa_rrset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA) soa_rrset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA)
if soa_rrset is None: if soa_rrset is None:
logger.error(f"Zone {dname} has no SOA — aborting") LOGGER.error(f"Zone {dname} has no SOA — aborting")
return return
if qtype == dns.rdatatype.SOA: if qtype == dns.rdatatype.SOA:
@@ -383,7 +392,7 @@ class UDPRequestHandler(DNSBaseRequestHandler):
try: try:
self._handle_dns_query(data) self._handle_dns_query(data)
except Exception as e: except Exception as e:
logger.error(f"Error handling request from {peer}: {e}") LOGGER.error(f"Error handling request from {peer}: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -425,9 +434,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
self._handle_dns_query(wire) self._handle_dns_query(wire)
except socket.timeout: except socket.timeout:
logger.debug(f"Connection from {peer} timed out") LOGGER.debug(f"Connection from {peer} timed out")
except Exception as e: except Exception as e:
logger.error(f"Error handling request from {peer}: {e}") LOGGER.error(f"Error handling request from {peer}: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()