Compare commits

..

11 Commits

Author SHA1 Message Date
Michał Kępień
c4389e1685 Use isctest.asyncserver in the "upforwd" test
Replace the custom DNS server used in the "upforwd" system test with new
code based on the isctest.asyncserver module.  The ans4 server currently
used in that test is a copy of bin/tests/system/ans.pl modified to
receive queries over UDP and TCP without ever responding to any of them.
2025-03-24 06:16:19 +01:00
Michał Kępień
8c957388bf Add a response handler for ignoring all queries
Dropping all incoming queries is a typical use case for a custom server
used in BIND 9 system tests.  Add a response handler implementing that
behavior so that it can be reused.
2025-03-24 06:16:19 +01:00
Michał Kępień
220fa70dea Make response handlers global by default
Instead of requiring each class inheriting from ResponseHandler to
define the match() method, make the latter non-abstract and default to
returning True for all queries.  This reduces the amount of boilerplate
code in custom servers.
2025-03-24 06:16:19 +01:00
Michał Kępień
c6e5710846 chg: test: asyncserver.py: TCP improvements
This branch started off as `michal/upforwd-asyncserver`.  It quickly
turned out that the critical `asyncserver.py` change that was needed for
the `upforwd` system test was for the server to be able to read multiple
TCP queries on a single connection.  As currently present in `main`,
`asyncserver.py` closes every client connection after servicing a single
query.  Retaining that behavior would cause the `upforwd` system test to
fail and, in general, capturing all data sent by a client seems more
useful in tests than just closing connections quickly.  `asyncserver.py`
can always be extended in the future (e.g. by adding a new
`ResponseAction` that the networking code would react to) to reinstate
the original behavior, if it turns out to be necessary.

While working on changing that particular `asyncserver.py` behavior, I
noticed a couple of other deficiencies in the TCP connection handling
code, so I started addressing them.  One thing led to another and before
I noticed, enough changes were applied to be worth doing a separate
merge request, particularly given that the actual rewrite of
`upforwd/ans4/ans.pl` using `asyncserver.py` is trivial once the
required changes to `asyncserver.py` itself are applied.

Merge branch 'michal/asyncserver-tcp-improvements' into 'main'

See merge request isc-projects/bind9!10276
2025-03-18 15:30:35 +00:00
Michał Kępień
575a874582 Handle queries indefinitely on each TCP connection
Instead of closing every incoming TCP connection after handling a single
query, continue receiving queries on each TCP connection until the
client disconnects itself.  When coupled with response dropping, this
enables silently receiving all incoming data, simulating an unresponsive
server.
2025-03-18 16:28:18 +01:00
Michał Kępień
68fe9a5df5 Enable receiving chunked TCP DNS messages
A TCP DNS client may send its queries in chunks, causing
StreamReader.read() to return less data than previously declared by the
client as the DNS message length; even the two-octet DNS message length
itself may be split up into two single-octet transmissions.  Sending
data in chunks is valid client behavior that should not be treated as an
error.  Add a new helper method for reading TCP data in a loop, properly
distinguishing between chunked queries and client disconnections.  Use
the new method for reading all TCP data from clients.
2025-03-18 16:28:18 +01:00
Michał Kępień
8c3f673f37 Extend TCP logging
Emit more log messages from TCP connection handling code and extend
existing ones to improve debuggability of servers using asyncserver.py.
2025-03-18 16:28:18 +01:00
Michał Kępień
748ed4259b Handle connection resets during reading
A TCP peer may reset the connection at any point, but asyncserver.py
currently only handles connection resets when it is sending data to the
client.  Handle connection resets during reading in the same way.
2025-03-18 16:28:18 +01:00
Michał Kępień
a956947fba Refactor AsyncDnsServer._handle_tcp()
Split up AsyncDnsServer._handle_tcp() into a set of smaller methods to
improve code readability.
2025-03-18 16:28:18 +01:00
Michał Kępień
e4c3186a7c Gracefully handle TCP client disconnections
Prevent premature client disconnections during reading from triggering
unhandled exceptions in TCP connection handling code.
2025-03-18 16:28:18 +01:00
Michał Kępień
5764a9d660 Simplify peer address formatting
Add a helper class, Peer, which holds the <host, port> tuple of a
connection endpoint and gets pretty-printed when formatted as a string.
This enables passing instances of this new class directly to logging
functions, eliminating the need for the AsyncDnsServer._format_peer()
helper method.
2025-03-18 16:28:18 +01:00
5 changed files with 140 additions and 406 deletions

View File

@@ -63,7 +63,7 @@ options {\n\
geoip-directory \".\";\n"
#endif /* if defined(HAVE_GEOIP2) */
"\
interface-interval 60m;\n\
interface-interval 60;\n\
listen-on {any;};\n\
listen-on-v6 {any;};\n\
match-mapped-addresses no;\n\

View File

@@ -224,6 +224,20 @@ class DnsProtocol(enum.Enum):
TCP = enum.auto()
@dataclass(frozen=True)
class Peer:
"""
Pretty-printed connection endpoint.
"""
host: str
port: int
def __str__(self) -> str:
host = f"[{self.host}]" if ":" in self.host else self.host
return f"{host}:{self.port}"
@dataclass
class QueryContext:
"""
@@ -232,7 +246,7 @@ class QueryContext:
query: dns.message.Message
response: dns.message.Message
peer: Tuple[str, int]
peer: Peer
protocol: DnsProtocol
zone: Optional[dns.zone.Zone] = None
soa: Optional[dns.rrset.RRset] = None
@@ -346,10 +360,13 @@ class ResponseHandler(abc.ABC):
method.
"""
@abc.abstractmethod
# pylint: disable=unused-argument
def match(self, qctx: QueryContext) -> bool:
"""
Matching logic - query is handled when it returns True.
Matching logic - the first handler whose `match()` method returns True
is used for handling the query.
The default for each handler is to handle all queries.
"""
return True
@@ -366,6 +383,17 @@ class ResponseHandler(abc.ABC):
yield DnsResponseSend(qctx.response)
class IgnoreAllQueries(ResponseHandler):
"""
Do not respond to any queries sent to the server.
"""
async def get_responses(
self, qctx: QueryContext
) -> AsyncGenerator[ResponseAction, None]:
yield ResponseDrop()
class DomainHandler(ResponseHandler):
"""
Base class used for deriving custom domain handlers.
@@ -513,56 +541,110 @@ class AsyncDnsServer(AsyncServer):
self._zone_tree.add(zone)
async def _handle_udp(
self, wire: bytes, peer: Tuple[str, int], transport: asyncio.DatagramTransport
self, wire: bytes, addr: Tuple[str, int], transport: asyncio.DatagramTransport
) -> None:
logging.debug("Received UDP message: %s", wire.hex())
peer = Peer(addr[0], addr[1])
responses = self._handle_query(wire, peer, DnsProtocol.UDP)
async for response in responses:
transport.sendto(response, peer)
transport.sendto(response, addr)
async def _handle_tcp(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
wire_length_bytes = await reader.read(2)
(wire_length,) = struct.unpack("!H", wire_length_bytes)
logging.debug("Receiving TCP message (%d octets)...", wire_length)
peer_info = writer.get_extra_info("peername")
peer = Peer(peer_info[0], peer_info[1])
logging.debug("Accepted TCP connection from %s", peer)
wire = await reader.read(wire_length)
full_message = wire_length_bytes + wire
logging.debug("Received complete TCP message: %s", full_message.hex())
peer = writer.get_extra_info("peername")
responses = self._handle_query(wire, peer, DnsProtocol.TCP)
async for response in responses:
writer.write(response)
while True:
try:
await writer.drain()
wire = await self._read_tcp_query(reader, peer)
if not wire:
break
await self._send_tcp_response(writer, peer, wire)
except ConnectionResetError:
logging.error(
"TCP connection from %s reset by peer", self._format_peer(peer)
)
logging.error("TCP connection from %s reset by peer", peer)
return
logging.debug("Closing TCP connection from %s", peer)
writer.close()
await writer.wait_closed()
def _format_peer(self, peer: Tuple[str, int]) -> str:
host = peer[0]
port = peer[1]
if "::" in host:
host = f"[{host}]"
return f"{host}:{port}"
async def _read_tcp_query(
self, reader: asyncio.StreamReader, peer: Peer
) -> Optional[bytes]:
wire_length = await self._read_tcp_query_wire_length(reader, peer)
if not wire_length:
return None
def _log_query(
self, qctx: QueryContext, peer: Tuple[str, int], protocol: DnsProtocol
return await self._read_tcp_query_wire(reader, peer, wire_length)
async def _read_tcp_query_wire_length(
self, reader: asyncio.StreamReader, peer: Peer
) -> Optional[int]:
logging.debug("Receiving TCP message length from %s...", peer)
wire_length_bytes = await self._read_tcp_octets(reader, peer, 2)
if not wire_length_bytes:
return None
(wire_length,) = struct.unpack("!H", wire_length_bytes)
return wire_length
async def _read_tcp_query_wire(
self, reader: asyncio.StreamReader, peer: Peer, wire_length: int
) -> Optional[bytes]:
logging.debug("Receiving TCP message (%d octets) from %s...", wire_length, peer)
wire = await self._read_tcp_octets(reader, peer, wire_length)
if not wire:
return None
logging.debug("Received complete TCP message from %s: %s", peer, wire.hex())
return wire
async def _read_tcp_octets(
self, reader: asyncio.StreamReader, peer: Peer, expected: int
) -> Optional[bytes]:
buffer = b""
while len(buffer) < expected:
chunk = await reader.read(expected - len(buffer))
if not chunk:
if buffer:
logging.debug(
"Received short TCP message (%d octets) from %s: %s",
len(buffer),
peer,
buffer.hex(),
)
else:
logging.debug("Received disconnect from %s", peer)
return None
logging.debug("Received %d TCP octets from %s", len(chunk), peer)
buffer += chunk
return buffer
async def _send_tcp_response(
self, writer: asyncio.StreamWriter, peer: Peer, wire: bytes
) -> None:
responses = self._handle_query(wire, peer, DnsProtocol.TCP)
async for response in responses:
writer.write(response)
await writer.drain()
def _log_query(self, qctx: QueryContext, peer: Peer, protocol: DnsProtocol) -> None:
logging.info(
"Received %s/%s/%s (ID=%d) query from %s (%s)",
qctx.qname.to_text(omit_final_dot=True),
dns.rdataclass.to_text(qctx.qclass),
dns.rdatatype.to_text(qctx.qtype),
qctx.query.id,
self._format_peer(peer),
peer,
protocol.name,
)
logging.debug(
@@ -573,14 +655,14 @@ class AsyncDnsServer(AsyncServer):
self,
qctx: QueryContext,
response: Optional[Union[dns.message.Message, bytes]],
peer: Tuple[str, int],
peer: Peer,
protocol: DnsProtocol,
) -> None:
if not response:
logging.info(
"Not sending a response to query (ID=%d) from %s (%s)",
qctx.query.id,
self._format_peer(peer),
peer,
protocol.name,
)
return
@@ -606,7 +688,7 @@ class AsyncDnsServer(AsyncServer):
len(response.authority),
len(response.additional),
qctx.query.id,
self._format_peer(peer),
peer,
protocol.name,
)
logging.debug(
@@ -618,13 +700,13 @@ class AsyncDnsServer(AsyncServer):
"Sending response (%d bytes) to a query (ID=%d) from %s (%s)",
len(response),
qctx.query.id,
self._format_peer(peer),
peer,
protocol.name,
)
logging.debug("[OUT] %s", response.hex())
async def _handle_query(
self, wire: bytes, peer: Tuple[str, int], protocol: DnsProtocol
self, wire: bytes, peer: Peer, protocol: DnsProtocol
) -> AsyncGenerator[bytes, None]:
"""
Yield wire data to send as a response over the established transport.

View File

@@ -1,365 +0,0 @@
#!/usr/bin/perl
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
#
# This is the name server from hell. It provides canned
# responses based on pattern matching the queries, and
# can be reprogrammed on-the-fly over a TCP connection.
#
# The server listens for control connections on port 5301.
# A control connection is a TCP stream of lines like
#
# /pattern/
# name ttl type rdata
# name ttl type rdata
# ...
# /pattern/
# name ttl type rdata
# name ttl type rdata
# ...
#
# There can be any number of patterns, each associated
# with any number of response RRs. Each pattern is a
# Perl regular expression.
#
# Each incoming query is converted into a string of the form
# "qname qtype" (the printable query domain name, space,
# printable query type) and matched against each pattern.
#
# The first pattern matching the query is selected, and
# the RR following the pattern line are sent in the
# answer section of the response.
#
# Each new control connection causes the current set of
# patterns and responses to be cleared before adding new
# ones.
#
# The server handles UDP and TCP queries. Zone transfer
# responses work, but must fit in a single 64 k message.
#
# Now you can add TSIG, just specify key/key data with:
#
# /pattern <key> <key_data>/
# name ttl type rdata
# name ttl type rdata
#
# Note that this data will still be sent with any request for
# pattern, only this data will be signed. Currently, this is only
# done for TCP.
use IO::File;
use IO::Socket;
use Data::Dumper;
use Net::DNS;
use Net::DNS::Packet;
use strict;
# Ignore SIGPIPE so we won't fail if peer closes a TCP socket early
local $SIG{PIPE} = 'IGNORE';
# Flush logged output after every line
local $| = 1;
my $server_addr = "10.53.0.4";
my $localport = int($ENV{'PORT'});
if (!$localport) { $localport = 5300; }
my $udpsock = IO::Socket::INET->new(LocalAddr => "$server_addr",
LocalPort => $localport, Proto => "udp", Reuse => 1) or die "$!";
my $tcpsock = IO::Socket::INET->new(LocalAddr => "$server_addr",
LocalPort => $localport, Proto => "tcp", Listen => 5, Reuse => 1) or die "$!";
print "listening on $server_addr:$localport.\n";
my $pidf = new IO::File "ans.pid", "w" or die "cannot open pid file: $!";
print $pidf "$$\n" or die "cannot write pid file: $!";
$pidf->close or die "cannot close pid file: $!";;
sub rmpid { unlink "ans.pid"; exit 1; };
$SIG{INT} = \&rmpid;
$SIG{TERM} = \&rmpid;
#my @answers = ();
my @rules;
sub handleUDP {
my ($buf) = @_;
my $packet;
if ($Net::DNS::VERSION > 0.68) {
$packet = new Net::DNS::Packet(\$buf, 0);
$@ and die $@;
} else {
my $err;
($packet, $err) = new Net::DNS::Packet(\$buf, 0);
$err and die $err;
}
$packet->header->qr(1);
$packet->header->aa(1);
my @questions = $packet->question;
my $qname = $questions[0]->qname;
my $qtype = $questions[0]->qtype;
# get the existing signature if any, and clear the additional section
my $prev_tsig;
while (my $rr = $packet->pop("additional")) {
if ($rr->type eq "TSIG") {
$prev_tsig = $rr;
}
}
my $r;
foreach $r (@rules) {
my $pattern = $r->{pattern};
my($dbtype, $key_name, $key_data) = split(/ /,$pattern);
print "[handleUDP] $dbtype, $key_name, $key_data \n";
if ("$qname $qtype" =~ /$dbtype/) {
my $a;
foreach $a (@{$r->{answer}}) {
$packet->push("answer", $a);
}
if(defined($key_name) && defined($key_data)) {
# Sign the packet
print " Signing the response with " .
"$key_name/$key_data\n";
my $tsig = Net::DNS::RR->
new("$key_name TSIG $key_data");
# These kluges are necessary because Net::DNS
# doesn't know how to sign responses. We
# clear compnames so that the TSIG key and
# algorithm name won't be compressed, and
# add one to arcount because the signing
# function will attempt to decrement it,
# which is incorrect in a response. Finally
# we set request_mac to the previous digest.
$packet->{"compnames"} = {};
$packet->{"header"}{"arcount"} += 1;
if (defined($prev_tsig)) {
my $rmac = pack('n H*',
$prev_tsig->mac_size,
$prev_tsig->mac);
$tsig->{"request_mac"} =
unpack("H*", $rmac);
}
$packet->sign_tsig($tsig);
}
last;
}
}
#$packet->print;
return $packet->data;
}
# namelen:
# given a stream of data, reads a DNS-formatted name and returns its
# total length, thus making it possible to skip past it.
sub namelen {
my ($data) = @_;
my $len = 0;
my $label_len = 0;
do {
$label_len = unpack("c", $data);
$data = substr($data, $label_len + 1);
$len += $label_len + 1;
} while ($label_len != 0);
return ($len);
}
# packetlen:
# given a stream of data, reads a DNS wire-format packet and returns
# its total length, making it possible to skip past it.
sub packetlen {
my ($data) = @_;
my $q;
my $rr;
my ($header, $offset) = Net::DNS::Header->parse(\$data);
for (1 .. $header->qdcount) {
($q, $offset) = Net::DNS::Question->parse(\$data, $offset);
}
for (1 .. $header->ancount) {
($rr, $offset) = Net::DNS::RR->parse(\$data, $offset);
}
for (1 .. $header->nscount) {
($rr, $offset) = Net::DNS::RR->parse(\$data, $offset);
}
for (1 .. $header->arcount) {
($rr, $offset) = Net::DNS::RR->parse(\$data, $offset);
}
return $offset;
}
# sign_tcp_continuation:
# This is a hack to correct the problem that Net::DNS has no idea how
# to sign multiple-message TCP responses. Several data that are included
# in the digest when signing a query or the first message of a response are
# omitted when signing subsequent messages in a TCP stream.
#
# Net::DNS::Packet->sign_tsig() has the ability to use a custom signing
# function (specified by calling Packet->sign_func()). We use this
# function as the signing function for TCP continuations, and it removes
# the unwanted data from the digest before calling the default sign_hmac
# function.
sub sign_tcp_continuation {
my ($key, $data) = @_;
# copy out first two bytes: size of the previous MAC
my $rmacsize = unpack("n", $data);
$data = substr($data, 2);
# copy out previous MAC
my $rmac = substr($data, 0, $rmacsize);
$data = substr($data, $rmacsize);
# try parsing out the packet information
my $plen = packetlen($data);
my $pdata = substr($data, 0, $plen);
$data = substr($data, $plen);
# remove the keyname, ttl, class, and algorithm name
$data = substr($data, namelen($data));
$data = substr($data, 6);
$data = substr($data, namelen($data));
# preserve the TSIG data
my $tdata = substr($data, 0, 8);
# prepare a new digest and sign with it
$data = pack("n", $rmacsize) . $rmac . $pdata . $tdata;
return Net::DNS::RR::TSIG::sign_hmac($key, $data);
}
sub handleTCP {
my ($buf) = @_;
my $packet;
if ($Net::DNS::VERSION > 0.68) {
$packet = new Net::DNS::Packet(\$buf, 0);
$@ and die $@;
} else {
my $err;
($packet, $err) = new Net::DNS::Packet(\$buf, 0);
$err and die $err;
}
$packet->header->qr(1);
$packet->header->aa(1);
my @questions = $packet->question;
my $qname = $questions[0]->qname;
my $qtype = $questions[0]->qtype;
# get the existing signature if any, and clear the additional section
my $prev_tsig;
my $signer;
while (my $rr = $packet->pop("additional")) {
if ($rr->type eq "TSIG") {
$prev_tsig = $rr;
}
}
my @results = ();
my $count_these = 0;
my $r;
foreach $r (@rules) {
my $pattern = $r->{pattern};
my($dbtype, $key_name, $key_data) = split(/ /,$pattern);
print "[handleTCP] $dbtype, $key_name, $key_data \n";
if ("$qname $qtype" =~ /$dbtype/) {
$count_these++;
my $a;
foreach $a (@{$r->{answer}}) {
$packet->push("answer", $a);
}
if(defined($key_name) && defined($key_data)) {
# sign the packet
print " Signing the data with " .
"$key_name/$key_data\n";
my $tsig = Net::DNS::RR->
new("$key_name TSIG $key_data");
# These kluges are necessary because Net::DNS
# doesn't know how to sign responses. We
# clear compnames so that the TSIG key and
# algorithm name won't be compressed, and
# add one to arcount because the signing
# function will attempt to decrement it,
# which is incorrect in a response. Finally
# we set request_mac to the previous digest.
$packet->{"compnames"} = {};
$packet->{"header"}{"arcount"} += 1;
if (defined($prev_tsig)) {
my $rmac = pack('n H*',
$prev_tsig->mac_size,
$prev_tsig->mac);
$tsig->{"request_mac"} =
unpack("H*", $rmac);
}
$tsig->sign_func($signer) if defined($signer);
$packet->sign_tsig($tsig);
$signer = \&sign_tcp_continuation;
my $copy =
Net::DNS::Packet->new(\($packet->data));
$prev_tsig = $copy->pop("additional");
}
#$packet->print;
push(@results,$packet->data);
$packet = new Net::DNS::Packet(\$buf, 0);
$packet->header->qr(1);
$packet->header->aa(1);
}
}
print " A total of $count_these patterns matched\n";
return \@results;
}
# Main
my $rin;
my $rout;
for (;;) {
$rin = '';
vec($rin, fileno($tcpsock), 1) = 1;
vec($rin, fileno($udpsock), 1) = 1;
select($rout = $rin, undef, undef, undef);
if (vec($rout, fileno($udpsock), 1)) {
printf "UDP request\n";
my $buf;
$udpsock->recv($buf, 512);
} elsif (vec($rout, fileno($tcpsock), 1)) {
my $conn = $tcpsock->accept;
my $buf;
for (;;) {
my $lenbuf;
my $n = $conn->sysread($lenbuf, 2);
last unless $n == 2;
my $len = unpack("n", $lenbuf);
$n = $conn->sysread($buf, $len);
last unless $n == $len;
}
sleep(1);
$conn->close;
}
}

View File

@@ -0,0 +1,20 @@
"""
Copyright (C) Internet Systems Consortium, Inc. ("ISC")
SPDX-License-Identifier: MPL-2.0
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, you can obtain one at https://mozilla.org/MPL/2.0/.
See the COPYRIGHT file distributed with this work for additional
information regarding copyright ownership.
"""
from isctest.asyncserver import AsyncDnsServer, IgnoreAllQueries
if __name__ == "__main__":
server = AsyncDnsServer()
server.install_response_handler(IgnoreAllQueries())
server.run()

View File

@@ -3969,10 +3969,9 @@ Periodic Task Intervals
:tags: server
:short: Sets the interval at which the server scans the network interface list.
The server scans the network interface list on every interval as specified by
:any:`interface-interval`.
If set to 0, interface scanning only occurs when the configuration
The server scans the network interface list every :any:`interface-interval`
minutes. The default is 60 minutes; the maximum value is 28 days (40320
minutes). If set to 0, interface scanning only occurs when the configuration
file is loaded, or when :any:`automatic-interface-scan` is enabled and supported
by the operating system. After the scan, the server begins listening for
queries on any newly discovered interfaces (provided they are allowed by the
@@ -3980,8 +3979,6 @@ Periodic Task Intervals
gone away. For convenience, TTL-style time-unit suffixes may be used to
specify the value. It also accepts ISO 8601 duration formats.
The default is 60 minutes (1 hour); the maximum value is 28 days.
.. _rrset_ordering:
RRset Ordering