Merge pull request #12 from spamik/support-tcp-soa

Support SOA request on TCP by merging the dns handling while leaving the transport handling in protocol specific handlers (partially). Further review pending
This commit is contained in:
Sven Luethi authored and GitHub committed 2026-01-31 09:08:42 +01:00
commit 562f54d481
1 file changed
+162 -187
@@ -1,5 +1,6 @@
import logging
import socketserver
import socket
import os
import threading
import dns.query
@@ -61,7 +62,7 @@ class CatalogZone:
cls._serial_obj = 1
cls._serial_obj.save()
logger.debug(
f"Catalog zone SOA serial number incremented to {_serial_obj.value}"
f"Catalog zone SOA serial number incremented to {cls._serial_obj.value}"
)
@classmethod
@@ -193,6 +194,11 @@ class CatalogZone:
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
@@ -302,89 +308,25 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
)
self.denyRequest(query)
def send_response(self, data):
raise NotImplemented
class UDPRequestHandler(DNSBaseRequestHandler):
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
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)
sock = self.request[1]
sock.sendto(wire, self.client_address)
def handle(self):
data, sock = self.request
peer = self.client_address[0]
query = None
try:
query = dns.message.from_wire(
data,
keyring=self.server.keyring,
continue_on_error=False,
ignore_trailing=True,
)
except Exception as e:
logger.error("Error parsing query: ", e)
return
# Create a response
response = dns.message.make_response(query)
# Set the Authoritative Answer flag
response.flags |= dns.flags.AA
question = query.question[0]
# Get the queried record name and type:
qname = question.name
qtype = question.rdtype
dname = qname.to_text().rstrip(".")
# Only process SOA queries
if qtype != dns.rdatatype.SOA:
logger.warning(
f"Request denied from {peer}: Request was not SOA (Type: {qtype})"
)
self.denyRequest(query)
return
# Identify TSIG key used
if not query.had_tsig:
logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query)
return
key_name = query.keyname.canonicalize().to_text()
# Check if 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.denyRequestBadTSIG(wire, dns.rcode.BADKEY)
return
# Check if catalog zone
if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(dname, nb_view.name)
# If generic zone, retreive from NB
else:
zone = self.getZoneFromNB(dname, nb_view.name)
# When zone was not found, let client know
if not zone:
logger.warning(f"Zone {nb_view.name}/{dname} not found in NB")
self.denyRequest(query)
return
# Retrieve the existing SOA record from the Zone
soa_rdataset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA)
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_rdataset[0] # Get the first SOA record
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_rdataset.ttl, 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)
@@ -409,111 +351,13 @@ class UDPRequestHandler(DNSBaseRequestHandler):
tsig_error=dns.rcode.BADKEY,
)
# Send back the response
response_wire = response.to_wire(max_size=512)
sock.sendto(response_wire, self.client_address)
data = response.to_wire(max_size=512)
self.send_response(data)
logger.debug(f"{peer} SOA {nb_view.name}/{dname}")
class TCPRequestHandler(DNSBaseRequestHandler):
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
response = dns.message.make_response(query)
response.set_rcode(rcode)
wire = response.to_wire(multi=False)
length = len(wire).to_bytes(2, byteorder="big")
self.request.sendall(length + wire)
def handle(self):
MAX_WIRE = 65535
# MAX_WIRE = 2000 # For testing fragmentation
RESERVED_TSIG = 300
# create a var for setting the client ip in log messages
peer = self.client_address[0]
try:
# Receive the entire message....
sock = self.request # TCP socket
# Read 2-byte length prefix
length_data = sock.recv(2)
if len(length_data) < 2:
return
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
# Parse the query
query = None
try:
query = dns.message.from_wire(wire, keyring=self.server.keyring)
# If TSIG signature doesnt match our key, refuse query:
except dns.tsig.BadSignature as e:
logger.warning(
f"Request denied from {peer} failed TSIG verification: {e}"
)
self.denyRequestBadTSIG(wire, dns.rcode.BADSIG)
return
# If TSIG Key used is not in our list, refuse query:
except dns.message.UnknownTSIGKey as e:
logger.warning(f"Request denied from {peer} with bad TSIG key: {e}")
self.denyRequestBadTSIG(wire, dns.rcode.BADKEY)
return
# If there was no question in the query, refuse
if len(query.question) != 1:
self.denyRequest(query)
return
# Get the queried record name and type:
qname = query.question[0].name
qtype = query.question[0].rdtype
dname = qname.to_text().rstrip(".")
# Only process AXFR queries
if qtype != dns.rdatatype.AXFR:
# if qtype != dns.rdatatype.AXFR or qtype != dns.rdatatype.SOA:
logger.warning(
f"Request denied from {peer}: Request was not AXFR (Type: {qtype})"
)
self.denyRequest(query)
return
# Identify TSIG key used
if not query.had_tsig:
logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(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.denyRequest(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.denyRequest(query)
return
# 1. Prepare SOA + RRsets (identical to your code)
soa_rrset = None
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)
@@ -523,10 +367,6 @@ class TCPRequestHandler(DNSBaseRequestHandler):
else:
rrsets.append(rrset)
if soa_rrset is None:
logger.error(f"Zone {dname} has no SOA — aborting AXFR")
return
rrsets.insert(0, soa_rrset) # Opening SOA
rrsets.append(soa_rrset) # Closing SOA
@@ -534,7 +374,7 @@ class TCPRequestHandler(DNSBaseRequestHandler):
r = dns.renderer.Renderer(
id=query.id,
flags=(dns.flags.QR | dns.flags.AA),
max_size=MAX_WIRE,
max_size=self.MAX_WIRE,
origin=None,
)
r.add_question(
@@ -550,7 +390,7 @@ class TCPRequestHandler(DNSBaseRequestHandler):
try:
# logger.debug(f"Adding {rrset} to renderer object")
r.add_rrset(dns.renderer.ANSWER, rrset)
if r.max_size - len(r.output.getvalue()) < RESERVED_TSIG:
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
@@ -572,7 +412,7 @@ class TCPRequestHandler(DNSBaseRequestHandler):
r = dns.renderer.Renderer(
id=query.id,
flags=(dns.flags.QR | dns.flags.AA),
max_size=MAX_WIRE,
max_size=self.MAX_WIRE,
origin=None,
)
r.add_question(
@@ -597,15 +437,150 @@ class TCPRequestHandler(DNSBaseRequestHandler):
request_mac=r.mac if tsig_ctx else query.mac,
)
wire = r.get_wire()
self.request.sendall(len(wire).to_bytes(2, "big") + wire)
self.send_response(wire)
# logger.debug(f"Zone transfer request for {nb_view.name}/{dname} from {peer}")
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 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()