Commit 3c32ad224f for bind
commit 3c32ad224f076d685d762ca77a3c97daa2e2a6bb
Author: Štěpán Balážik <stepan@isc.org>
Date: Wed Sep 16 17:31:16 2026 +0200
Test AsyncDnsServer with malformed input and misbehaving clients
Guard the fixes for invalid queries (fd0290c919), TCP clients that
disconnect or reset the connection before sending a whole message
(e4c3186a7c, 748ed4259b), several queries on one TCP connection
(575a874582) and ignored TCP connections outliving a garbage collection
(1acde358ea). The ans2 server ignores every TCP connection and collects
garbage a second after accepting one.
Assisted-by: Claude:claude-fable-5-1
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/ans1/ans.py b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/ans.py
new file mode 120000
index 0000000000..0855b69d50
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/ans.py
@@ -0,0 +1 @@
+../../../../../ans.py
\ No newline at end of file
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/ans1/zones/example.db b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/zones/example.db
new file mode 100644
index 0000000000..9eb19da167
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/ans1/zones/example.db
@@ -0,0 +1,4 @@
+example. 300 IN SOA ns.example. hostmaster.example. 1 3600 600 86400 300
+example. 300 IN NS ns.example.
+ns.example. 300 IN A 10.53.0.1
+bar.example. 300 IN A 192.0.2.2
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/ans2/ans.py b/bin/tests/system/isctest/asyncserver/tests/transport/ans2/ans.py
new file mode 100644
index 0000000000..92e539283c
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/ans2/ans.py
@@ -0,0 +1,41 @@
+# 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.
+
+import asyncio
+import gc
+
+from isctest.asyncserver import AsyncDnsServer
+from isctest.asyncserver.context import Peer
+from isctest.asyncserver.handlers import IgnoreAllConnections
+
+
+class IgnoreAllConnectionsThenCollectGarbage(IgnoreAllConnections):
+ """
+ Ignore every TCP connection and collect garbage a second after accepting
+ it: unless the objects behind an ignored connection are kept alive on
+ purpose, the collection destroys the task handling it.
+ """
+
+ async def handle(
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, peer: Peer
+ ) -> None:
+ await super().handle(reader, writer, peer)
+ asyncio.get_running_loop().call_later(1, gc.collect)
+
+
+def main() -> None:
+ server = AsyncDnsServer()
+ server.install_connection_handler(IgnoreAllConnectionsThenCollectGarbage())
+ server.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bin/tests/system/isctest/asyncserver/tests/transport/tests_transport.py b/bin/tests/system/isctest/asyncserver/tests/transport/tests_transport.py
new file mode 100644
index 0000000000..8a098aaaa6
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/transport/tests_transport.py
@@ -0,0 +1,112 @@
+# 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.
+
+import os
+import socket
+import struct
+import time
+
+import dns.exception
+import dns.message
+import dns.query
+import pytest
+
+from isctest.template import ANS1, ANS2, Nameserver
+
+import isctest
+
+TIMEOUT = 3
+GARBAGE = b"\x00\x03\x00\x01\x02" # three bytes of nonsense, framed for TCP
+
+
+def port() -> int:
+ return int(os.environ["PORT"])
+
+
+def query() -> dns.message.Message:
+ return isctest.query.create("bar.example.", "A", dnssec=False, rd=False)
+
+
+def tcp_exchange(
+ server: Nameserver,
+ *items: dns.message.Message | bytes,
+ reset: bool = False,
+ wait: float = TIMEOUT,
+) -> tuple[list[dns.message.Message], bool]:
+ """
+ Send the items over a new TCP connection, messages framed and bytes as
+ they are, and return the responses that arrive within `wait` seconds,
+ plus whether the server closed the connection. With `reset`, tear the
+ connection down with an RST segment instead of a FIN.
+ """
+ with socket.create_connection((server.ip, port()), timeout=TIMEOUT) as sock:
+ if reset:
+ sock.setsockopt(
+ socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)
+ )
+ for item in items:
+ if isinstance(item, bytes):
+ sock.sendall(item)
+ else:
+ dns.query.send_tcp(sock, item)
+ sock.setblocking(False) # as dns.query.receive_tcp() expects
+ responses = []
+ try:
+ while True:
+ responses.append(dns.query.receive_tcp(sock, time.time() + wait)[0])
+ except dns.exception.Timeout:
+ return responses, False
+ except EOFError:
+ return responses, True
+
+
+def check_still_answering() -> None:
+ for transport in (isctest.query.udp, isctest.query.tcp):
+ res = transport(query(), ANS1.ip, timeout=TIMEOUT, attempts=2)
+ isctest.check.noerror(res)
+ isctest.check.rr_count_eq(res.answer, 1)
+
+
+def test_server_survives_invalid_udp_query():
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.sendto(GARBAGE, (ANS1.ip, port()))
+ check_still_answering()
+
+
+def test_invalid_tcp_query_is_dropped_but_the_connection_kept():
+ msg = query()
+ responses, closed = tcp_exchange(ANS1, GARBAGE, msg)
+ assert [res.id for res in responses] == [msg.id]
+ assert not closed
+ check_still_answering()
+
+
+@pytest.mark.parametrize("reset", [False, True], ids=["fin", "rst"])
+@pytest.mark.parametrize(
+ "partial",
+ [b"", b"\x00", query().to_wire(prepend_length=True)[:-3]],
+ ids=["nothing", "length", "message"],
+)
+def test_server_survives_early_disconnect(partial, reset):
+ tcp_exchange(ANS1, partial, reset=reset, wait=0)
+ check_still_answering()
+
+
+def test_pipelined_tcp_queries_are_all_answered():
+ msgs = [query() for _ in range(3)]
+ responses, _ = tcp_exchange(ANS1, *msgs)
+ assert [res.id for res in responses] == [msg.id for msg in msgs]
+
+
+def test_ignored_tcp_connection_survives_garbage_collection():
+ responses, closed = tcp_exchange(ANS2, query())
+ assert not responses
+ assert not closed