From a0970f3d0410afd35799306c24f8ed43955ad027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Thu, 30 Oct 2025 13:41:23 +0100 Subject: [PATCH 01/11] Refactor ControllableAsyncDnsServer setup When this class was introduced, the constructor of its base class had no parameters. This was changed in the meantime and these parameters were not accessible by users of the subclass. Don't override the constructor. Move command setup to methods. Move subclass-specific storage to cached properties. Take instances of Command instead of the classes themselves for symmetry with install_response_handler. --- bin/tests/system/chain/ans4/ans.py | 3 +- bin/tests/system/fetchlimit/ans4/ans.py | 3 +- bin/tests/system/forward/ans11/ans.py | 3 +- bin/tests/system/forward/ans6/ans.py | 3 +- bin/tests/system/isctest/asyncserver.py | 40 ++++++++++++++----------- bin/tests/system/xfer/ans9/ans.py | 3 +- 6 files changed, 33 insertions(+), 22 deletions(-) diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py index 3e042ea58c..249da49b9a 100755 --- a/bin/tests/system/chain/ans4/ans.py +++ b/bin/tests/system/chain/ans4/ans.py @@ -473,7 +473,8 @@ class ChainResponseHandler(DomainHandler): def main() -> None: - server = ControllableAsyncDnsServer(commands=[ChainSetupCommand]) + server = ControllableAsyncDnsServer() + server.install_control_command(ChainSetupCommand()) server.run() diff --git a/bin/tests/system/fetchlimit/ans4/ans.py b/bin/tests/system/fetchlimit/ans4/ans.py index 34891fa310..52be94125c 100644 --- a/bin/tests/system/fetchlimit/ans4/ans.py +++ b/bin/tests/system/fetchlimit/ans4/ans.py @@ -39,7 +39,8 @@ class MaybeDelayedAddressAnswerHandler(ResponseHandler): def main() -> None: - server = ControllableAsyncDnsServer([ToggleResponsesCommand]) + server = ControllableAsyncDnsServer() + server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(MaybeDelayedAddressAnswerHandler()) server.run() diff --git a/bin/tests/system/forward/ans11/ans.py b/bin/tests/system/forward/ans11/ans.py index 8d0b3e9b33..0b4ec5682e 100644 --- a/bin/tests/system/forward/ans11/ans.py +++ b/bin/tests/system/forward/ans11/ans.py @@ -49,7 +49,8 @@ class ExtraAnswersHandler(DomainHandler): def main() -> None: - server = ControllableAsyncDnsServer(commands=[ToggleResponsesCommand]) + server = ControllableAsyncDnsServer() + server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(ExtraAnswersHandler()) server.run() diff --git a/bin/tests/system/forward/ans6/ans.py b/bin/tests/system/forward/ans6/ans.py index f63cdcd4d5..a9fd0b8ac6 100644 --- a/bin/tests/system/forward/ans6/ans.py +++ b/bin/tests/system/forward/ans6/ans.py @@ -72,7 +72,8 @@ class ChaseDsHandler(ResponseHandler): def main() -> None: - server = ControllableAsyncDnsServer([ToggleResponsesCommand]) + server = ControllableAsyncDnsServer() + server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(ChaseDsHandler()) server.run() diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index c91f63a123..27a981446b 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -21,7 +21,6 @@ from typing import ( List, Optional, Tuple, - Type, Union, cast, ) @@ -1281,22 +1280,29 @@ class ControllableAsyncDnsServer(AsyncDnsServer): _CONTROL_DOMAIN = "_control." - def __init__(self, commands: List[Type["ControlCommand"]]): - super().__init__() - self._control_domain = dns.name.from_text(self._CONTROL_DOMAIN) - self._commands: Dict[dns.name.Name, "ControlCommand"] = {} - for command_class in commands: - command = command_class() - command_subdomain = dns.name.Name([command.control_subdomain]) - control_subdomain = command_subdomain.concatenate(self._control_domain) - try: - existing_command = self._commands[control_subdomain] - except KeyError: - self._commands[control_subdomain] = command - else: - raise RuntimeError( - f"{control_subdomain} already handled by {existing_command}" - ) + @functools.cached_property + def _control_domain(self) -> dns.name.Name: + return dns.name.from_text(self._CONTROL_DOMAIN) + + @functools.cached_property + def _commands(self) -> Dict[dns.name.Name, "ControlCommand"]: + return {} + + def install_control_commands(self, commands: List["ControlCommand"]) -> None: + for command in commands: + self.install_control_command(command) + + def install_control_command(self, command: "ControlCommand") -> None: + command_subdomain = dns.name.Name([command.control_subdomain]) + control_subdomain = command_subdomain.concatenate(self._control_domain) + try: + existing_command = self._commands[control_subdomain] + except KeyError: + self._commands[control_subdomain] = command + else: + raise RuntimeError( + f"{control_subdomain} already handled by {existing_command}" + ) async def _prepare_responses( self, qctx: QueryContext diff --git a/bin/tests/system/xfer/ans9/ans.py b/bin/tests/system/xfer/ans9/ans.py index 56c80becb9..f1a4bf5437 100644 --- a/bin/tests/system/xfer/ans9/ans.py +++ b/bin/tests/system/xfer/ans9/ans.py @@ -106,6 +106,7 @@ class AXFRServer(DomainHandler): if __name__ == "__main__": - server = ControllableAsyncDnsServer([ToggleResponsesCommand]) + server = ControllableAsyncDnsServer() + server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(AXFRServer()) server.run() From 6e684d44e09f8e41c89c93b48b7e538576f5538c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Wed, 29 Oct 2025 18:59:31 +0100 Subject: [PATCH 02/11] Allow users of AsyncDnsServer to set AA bit for all responses Previously, all responses had to be set as authoritative explicitly using DnsResponseSend(..., authoritative=True). After using this, it became obvious that this is obnoxious. Add an optional keyword-only parameter to AsyncDnsServer that sets the default value of the AA bit on outgoing responses. Make all the other parameters keyword-only as well. --- bin/tests/system/isctest/asyncserver.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 27a981446b..2448155403 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -773,7 +773,9 @@ class AsyncDnsServer(AsyncServer): def __init__( self, + /, default_rcode: dns.rcode.Rcode = dns.rcode.REFUSED, + default_aa: bool = True, acknowledge_manual_dname_handling: bool = False, acknowledge_tsig_dnspython_hacks: bool = False, ) -> None: @@ -783,6 +785,7 @@ class AsyncDnsServer(AsyncServer): self._connection_handler: Optional[ConnectionHandler] = None self._response_handlers: List[ResponseHandler] = [] self._default_rcode = default_rcode + self._default_aa = default_aa self._acknowledge_manual_dname_handling = acknowledge_manual_dname_handling self._acknowledge_tsig_dnspython_hacks = acknowledge_tsig_dnspython_hacks @@ -1101,6 +1104,8 @@ class AsyncDnsServer(AsyncServer): Yield response(s) either from response handlers or zone data. """ qctx.response.set_rcode(self._default_rcode) + if self._default_aa: + qctx.response.flags |= dns.flags.AA self._prepare_response_from_zone_data(qctx) From c7d84dc86a6a14ab0fbf8729545fac43d3df8fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Thu, 30 Oct 2025 13:51:31 +0100 Subject: [PATCH 03/11] Set default_aa for AsyncDnsServer instances where suitable Rule of thumb: If all ResponseHandlers said authoritative=True, it should be default_aa=True instead. --- bin/tests/system/chain/ans3/ans.py | 6 +++--- bin/tests/system/chain/ans4/ans.py | 4 ++-- bin/tests/system/fetchlimit/ans4/ans.py | 4 ++-- bin/tests/system/forward/ans6/ans.py | 4 ++-- bin/tests/system/rpzrecurse/ans5/ans.py | 6 +++--- bin/tests/system/zero/ans5/ans.py | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py index 4a87dfc89c..08dab08b31 100755 --- a/bin/tests/system/chain/ans3/ans.py +++ b/bin/tests/system/chain/ans3/ans.py @@ -69,7 +69,7 @@ class CnameThenDnameHandler(DomainHandler): dname_rrset = get_dname_rrset_at_name(qctx.zone, dname_owner) qctx.response.answer.append(dname_rrset) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class Cve202125215(DomainHandler): @@ -108,11 +108,11 @@ class Cve202125215(DomainHandler): ) qctx.response.answer.append(cname_rrset) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) def main() -> None: - server = AsyncDnsServer(acknowledge_manual_dname_handling=True) + server = AsyncDnsServer(acknowledge_manual_dname_handling=True, default_aa=True) server.install_response_handler(CnameThenDnameHandler()) server.install_response_handler(Cve202125215()) server.run() diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py index 249da49b9a..bec20985f4 100755 --- a/bin/tests/system/chain/ans4/ans.py +++ b/bin/tests/system/chain/ans4/ans.py @@ -445,7 +445,7 @@ class ChainResponseHandler(DomainHandler): qctx.response.set_rcode(dns.rcode.NOERROR) qctx.response.use_edns() - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) def _non_chain_answer(self, qctx: QueryContext) -> List[dns.rrset.RRset]: owner = qctx.qname @@ -473,7 +473,7 @@ class ChainResponseHandler(DomainHandler): def main() -> None: - server = ControllableAsyncDnsServer() + server = ControllableAsyncDnsServer(default_aa=True) server.install_control_command(ChainSetupCommand()) server.run() diff --git a/bin/tests/system/fetchlimit/ans4/ans.py b/bin/tests/system/fetchlimit/ans4/ans.py index 52be94125c..a7f82cb5b2 100644 --- a/bin/tests/system/fetchlimit/ans4/ans.py +++ b/bin/tests/system/fetchlimit/ans4/ans.py @@ -35,11 +35,11 @@ class MaybeDelayedAddressAnswerHandler(ResponseHandler): qctx.response.set_rcode(dns.rcode.NOERROR) delay = 0.05 if qctx.qname.labels[0].startswith(b"latency") else 0.00 - yield DnsResponseSend(qctx.response, delay=delay, authoritative=True) + yield DnsResponseSend(qctx.response, delay=delay) def main() -> None: - server = ControllableAsyncDnsServer() + server = ControllableAsyncDnsServer(default_aa=True) server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(MaybeDelayedAddressAnswerHandler()) server.run() diff --git a/bin/tests/system/forward/ans6/ans.py b/bin/tests/system/forward/ans6/ans.py index a9fd0b8ac6..6eaa5a322d 100644 --- a/bin/tests/system/forward/ans6/ans.py +++ b/bin/tests/system/forward/ans6/ans.py @@ -68,11 +68,11 @@ class ChaseDsHandler(ResponseHandler): ) response_section.append(response_rrset) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) def main() -> None: - server = ControllableAsyncDnsServer() + server = ControllableAsyncDnsServer(default_aa=True) server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(ChaseDsHandler()) server.run() diff --git a/bin/tests/system/rpzrecurse/ans5/ans.py b/bin/tests/system/rpzrecurse/ans5/ans.py index 44de641044..5902cf285d 100644 --- a/bin/tests/system/rpzrecurse/ans5/ans.py +++ b/bin/tests/system/rpzrecurse/ans5/ans.py @@ -36,7 +36,7 @@ class ReplyA(ResponseHandler): ) qctx.response.answer.append(a_rrset) qctx.response.set_rcode(dns.rcode.NOERROR) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class IgnoreNs(ResponseHandler): @@ -54,11 +54,11 @@ class FallbackHandler(ResponseHandler): self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: qctx.response.set_rcode(dns.rcode.NOERROR) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) def main() -> None: - server = AsyncDnsServer() + server = AsyncDnsServer(default_aa=True) server.install_response_handler(ReplyA()) server.install_response_handler(IgnoreNs()) server.install_response_handler(FallbackHandler()) diff --git a/bin/tests/system/zero/ans5/ans.py b/bin/tests/system/zero/ans5/ans.py index 47b4da0ad0..970c175556 100644 --- a/bin/tests/system/zero/ans5/ans.py +++ b/bin/tests/system/zero/ans5/ans.py @@ -49,11 +49,11 @@ class IncrementARecordHandler(ResponseHandler): self._ip_address += 1 qctx.response.set_rcode(dns.rcode.NOERROR) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) def main() -> None: - server = AsyncDnsServer() + server = AsyncDnsServer(default_aa=True) server.install_response_handler(IncrementARecordHandler()) server.run() From e4de7eb4f9c2ed5d4b1b0402d019417455a413ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Thu, 30 Oct 2025 14:07:12 +0100 Subject: [PATCH 04/11] Use default_rcode for AsyncDnsServer where applicable Rule of thumb: If a RCode is set unconditionally in all ResponseHandlers, set it in the server constructor. --- bin/tests/system/chain/ans4/ans.py | 5 +++-- bin/tests/system/dispatch/ans3/ans.py | 3 +-- bin/tests/system/fetchlimit/ans4/ans.py | 5 +++-- bin/tests/system/forward/ans6/ans.py | 5 +++-- bin/tests/system/rpzrecurse/ans5/ans.py | 12 +----------- bin/tests/system/zero/ans5/ans.py | 3 +-- 6 files changed, 12 insertions(+), 21 deletions(-) diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py index bec20985f4..618b36bdf5 100755 --- a/bin/tests/system/chain/ans4/ans.py +++ b/bin/tests/system/chain/ans4/ans.py @@ -443,7 +443,6 @@ class ChainResponseHandler(DomainHandler): for rrset in self._additional_rrsets: qctx.response.additional.append(rrset) - qctx.response.set_rcode(dns.rcode.NOERROR) qctx.response.use_edns() yield DnsResponseSend(qctx.response) @@ -473,7 +472,9 @@ class ChainResponseHandler(DomainHandler): def main() -> None: - server = ControllableAsyncDnsServer(default_aa=True) + server = ControllableAsyncDnsServer( + default_aa=True, default_rcode=dns.rcode.NOERROR + ) server.install_control_command(ChainSetupCommand()) server.run() diff --git a/bin/tests/system/dispatch/ans3/ans.py b/bin/tests/system/dispatch/ans3/ans.py index 653232f991..774f9a5414 100644 --- a/bin/tests/system/dispatch/ans3/ans.py +++ b/bin/tests/system/dispatch/ans3/ans.py @@ -29,13 +29,12 @@ class TruncateOnUdpHandler(ResponseHandler): self, qctx: QueryContext ) -> AsyncGenerator[ResponseAction, None]: assert qctx.protocol == DnsProtocol.UDP, "This server only supports UDP" - qctx.response.set_rcode(dns.rcode.NOERROR) qctx.response.flags |= dns.flags.TC yield DnsResponseSend(qctx.response) def main() -> None: - server = AsyncDnsServer() + server = AsyncDnsServer(default_rcode=dns.rcode.NOERROR) server.install_connection_handler(ConnectionReset(delay=1.0)) server.install_response_handler(TruncateOnUdpHandler()) server.run() diff --git a/bin/tests/system/fetchlimit/ans4/ans.py b/bin/tests/system/fetchlimit/ans4/ans.py index a7f82cb5b2..9797735a82 100644 --- a/bin/tests/system/fetchlimit/ans4/ans.py +++ b/bin/tests/system/fetchlimit/ans4/ans.py @@ -33,13 +33,14 @@ class MaybeDelayedAddressAnswerHandler(ResponseHandler): rrset = dns.rrset.from_text(qctx.qname, 300, qctx.qclass, qctx.qtype, addr) qctx.response.answer.append(rrset) - qctx.response.set_rcode(dns.rcode.NOERROR) delay = 0.05 if qctx.qname.labels[0].startswith(b"latency") else 0.00 yield DnsResponseSend(qctx.response, delay=delay) def main() -> None: - server = ControllableAsyncDnsServer(default_aa=True) + server = ControllableAsyncDnsServer( + default_aa=True, default_rcode=dns.rcode.NOERROR + ) server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(MaybeDelayedAddressAnswerHandler()) server.run() diff --git a/bin/tests/system/forward/ans6/ans.py b/bin/tests/system/forward/ans6/ans.py index 6eaa5a322d..ac1db7ed1e 100644 --- a/bin/tests/system/forward/ans6/ans.py +++ b/bin/tests/system/forward/ans6/ans.py @@ -60,7 +60,6 @@ class ChaseDsHandler(ResponseHandler): response_rdata = ". . 0 0 0 0 0" response_section = qctx.response.authority - qctx.response.set_rcode(dns.rcode.NOERROR) qctx.response.use_edns(None) response_rrset = dns.rrset.from_text( @@ -72,7 +71,9 @@ class ChaseDsHandler(ResponseHandler): def main() -> None: - server = ControllableAsyncDnsServer(default_aa=True) + server = ControllableAsyncDnsServer( + default_rcode=dns.rcode.NOERROR, default_aa=True + ) server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(ChaseDsHandler()) server.run() diff --git a/bin/tests/system/rpzrecurse/ans5/ans.py b/bin/tests/system/rpzrecurse/ans5/ans.py index 5902cf285d..8c2f7dffc0 100644 --- a/bin/tests/system/rpzrecurse/ans5/ans.py +++ b/bin/tests/system/rpzrecurse/ans5/ans.py @@ -35,7 +35,6 @@ class ReplyA(ResponseHandler): qctx.qname, 300, dns.rdataclass.IN, dns.rdatatype.A, "10.53.0.5" ) qctx.response.answer.append(a_rrset) - qctx.response.set_rcode(dns.rcode.NOERROR) yield DnsResponseSend(qctx.response) @@ -49,19 +48,10 @@ class IgnoreNs(ResponseHandler): yield ResponseDrop() -class FallbackHandler(ResponseHandler): - async def get_responses( - self, qctx: QueryContext - ) -> AsyncGenerator[DnsResponseSend, None]: - qctx.response.set_rcode(dns.rcode.NOERROR) - yield DnsResponseSend(qctx.response) - - def main() -> None: - server = AsyncDnsServer(default_aa=True) + server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR) server.install_response_handler(ReplyA()) server.install_response_handler(IgnoreNs()) - server.install_response_handler(FallbackHandler()) server.run() diff --git a/bin/tests/system/zero/ans5/ans.py b/bin/tests/system/zero/ans5/ans.py index 970c175556..e3438bfd73 100644 --- a/bin/tests/system/zero/ans5/ans.py +++ b/bin/tests/system/zero/ans5/ans.py @@ -48,12 +48,11 @@ class IncrementARecordHandler(ResponseHandler): qctx.response.answer.append(rrset) self._ip_address += 1 - qctx.response.set_rcode(dns.rcode.NOERROR) yield DnsResponseSend(qctx.response) def main() -> None: - server = AsyncDnsServer(default_aa=True) + server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR) server.install_response_handler(IncrementARecordHandler()) server.run() From d593af3a5f80547af1650223bae2840cdaff631d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Thu, 30 Oct 2025 14:08:21 +0100 Subject: [PATCH 05/11] Allow adding multiple ResponseHandlers at once Change this at call sites as well. --- bin/tests/system/chain/ans3/ans.py | 3 +-- bin/tests/system/cookie/cookie_ans.py | 16 ++++++++++------ bin/tests/system/dnssec/ans10/ans.py | 3 +-- bin/tests/system/isctest/asyncserver.py | 4 ++++ bin/tests/system/qmin/ans2/ans.py | 14 +++++++++----- bin/tests/system/qmin/ans3/ans.py | 12 ++++++++---- bin/tests/system/qmin/ans4/ans.py | 14 +++++++++----- bin/tests/system/rpzrecurse/ans5/ans.py | 3 +-- bin/tests/system/statistics/ans4/ans.py | 25 +++++++++++++------------ 9 files changed, 56 insertions(+), 38 deletions(-) diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py index 08dab08b31..3309ba286b 100755 --- a/bin/tests/system/chain/ans3/ans.py +++ b/bin/tests/system/chain/ans3/ans.py @@ -113,8 +113,7 @@ class Cve202125215(DomainHandler): def main() -> None: server = AsyncDnsServer(acknowledge_manual_dname_handling=True, default_aa=True) - server.install_response_handler(CnameThenDnameHandler()) - server.install_response_handler(Cve202125215()) + server.install_response_handlers([CnameThenDnameHandler(), Cve202125215()]) server.run() diff --git a/bin/tests/system/cookie/cookie_ans.py b/bin/tests/system/cookie/cookie_ans.py index bd2782d0d6..102832dd3f 100644 --- a/bin/tests/system/cookie/cookie_ans.py +++ b/bin/tests/system/cookie/cookie_ans.py @@ -205,10 +205,14 @@ class FallbackHandler(ResponseHandler): def cookie_server(evil: bool) -> AsyncDnsServer: server = AsyncDnsServer(acknowledge_tsig_dnspython_hacks=True) - server.install_response_handler(NsHandler(evil)) - server.install_response_handler(GlueHandler(evil)) - server.install_response_handler(TcpAHandler()) - server.install_response_handler(WithtsigUdpAHandler()) - server.install_response_handler(UdpAHandler()) - server.install_response_handler(FallbackHandler()) + server.install_response_handlers( + [ + NsHandler(evil), + GlueHandler(evil), + TcpAHandler(), + WithtsigUdpAHandler(), + UdpAHandler(), + FallbackHandler(), + ] + ) return server diff --git a/bin/tests/system/dnssec/ans10/ans.py b/bin/tests/system/dnssec/ans10/ans.py index 7c0798f2f8..24a4a0ea0a 100644 --- a/bin/tests/system/dnssec/ans10/ans.py +++ b/bin/tests/system/dnssec/ans10/ans.py @@ -56,8 +56,7 @@ class AddNsecToTxtHandler(ResponseHandler): def main() -> None: server = AsyncDnsServer() - server.install_response_handler(AddRrsigToAHandler()) - server.install_response_handler(AddNsecToTxtHandler()) + server.install_response_handlers([AddRrsigToAHandler(), AddNsecToTxtHandler()]) server.run() diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 2448155403..ffe83ba61c 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -810,6 +810,10 @@ class AsyncDnsServer(AsyncServer): else: self._response_handlers.append(handler) + def install_response_handlers(self, handlers: List[ResponseHandler]) -> None: + for handler in handlers: + self.install_response_handler(handler) + def uninstall_response_handler(self, handler: ResponseHandler) -> None: """ Remove the specified handler from the list of response handlers. diff --git a/bin/tests/system/qmin/ans2/ans.py b/bin/tests/system/qmin/ans2/ans.py index 18f077781e..673b8ff37a 100644 --- a/bin/tests/system/qmin/ans2/ans.py +++ b/bin/tests/system/qmin/ans2/ans.py @@ -103,11 +103,15 @@ class StaleHandler(DomainHandler): def main() -> None: server = AsyncDnsServer() - server.install_response_handler(QueryLogger()) - server.install_response_handler(BadHandler()) - server.install_response_handler(UglyHandler()) - server.install_response_handler(SlowHandler()) - server.install_response_handler(StaleHandler()) + server.install_response_handlers( + [ + QueryLogger(), + BadHandler(), + UglyHandler(), + SlowHandler(), + StaleHandler(), + ] + ) server.run() diff --git a/bin/tests/system/qmin/ans3/ans.py b/bin/tests/system/qmin/ans3/ans.py index 6547dd2f9b..101ea2a14f 100644 --- a/bin/tests/system/qmin/ans3/ans.py +++ b/bin/tests/system/qmin/ans3/ans.py @@ -39,10 +39,14 @@ class ZoopBoingSlowHandler(DelayedResponseHandler): def main() -> None: server = AsyncDnsServer() - server.install_response_handler(QueryLogger()) - server.install_response_handler(ZoopBoingBadHandler()) - server.install_response_handler(ZoopBoingUglyHandler()) - server.install_response_handler(ZoopBoingSlowHandler()) + server.install_response_handlers( + [ + QueryLogger(), + ZoopBoingBadHandler(), + ZoopBoingUglyHandler(), + ZoopBoingSlowHandler(), + ] + ) server.run() diff --git a/bin/tests/system/qmin/ans4/ans.py b/bin/tests/system/qmin/ans4/ans.py index ebe500bad6..22334372be 100644 --- a/bin/tests/system/qmin/ans4/ans.py +++ b/bin/tests/system/qmin/ans4/ans.py @@ -85,11 +85,15 @@ class IckyPtangZoopBoingSlowHandler(DelayedResponseHandler): def main() -> None: server = AsyncDnsServer() - server.install_response_handler(QueryLogger()) - server.install_response_handler(StaleHandler()) - server.install_response_handler(IckyPtangZoopBoingBadHandler()) - server.install_response_handler(IckyPtangZoopBoingUglyHandler()) - server.install_response_handler(IckyPtangZoopBoingSlowHandler()) + server.install_response_handlers( + [ + QueryLogger(), + StaleHandler(), + IckyPtangZoopBoingBadHandler(), + IckyPtangZoopBoingUglyHandler(), + IckyPtangZoopBoingSlowHandler(), + ] + ) server.run() diff --git a/bin/tests/system/rpzrecurse/ans5/ans.py b/bin/tests/system/rpzrecurse/ans5/ans.py index 8c2f7dffc0..85acf15af5 100644 --- a/bin/tests/system/rpzrecurse/ans5/ans.py +++ b/bin/tests/system/rpzrecurse/ans5/ans.py @@ -50,8 +50,7 @@ class IgnoreNs(ResponseHandler): def main() -> None: server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR) - server.install_response_handler(ReplyA()) - server.install_response_handler(IgnoreNs()) + server.install_response_handlers([ReplyA(), IgnoreNs()]) server.run() diff --git a/bin/tests/system/statistics/ans4/ans.py b/bin/tests/system/statistics/ans4/ans.py index d3de81bc70..f353fc6e95 100644 --- a/bin/tests/system/statistics/ans4/ans.py +++ b/bin/tests/system/statistics/ans4/ans.py @@ -159,18 +159,19 @@ class FallbackHandler(ResponseHandler): def main() -> None: server = AsyncDnsServer(default_rcode=dns.rcode.NOERROR) - for handler in ( - BadGoodCnameHandler, - Cname1Handler, - Cname2Handler, - ExampleHandler, - FooInfoHandler, - NoDataHandler, - NxdomainHandler, - SubHandler, - FallbackHandler, - ): - server.install_response_handler(handler()) + server.install_response_handlers( + [ + BadGoodCnameHandler(), + Cname1Handler(), + Cname2Handler(), + ExampleHandler(), + FooInfoHandler(), + NoDataHandler(), + NxdomainHandler(), + SubHandler(), + FallbackHandler(), + ] + ) server.run() From 5761de5531b4b5349b06baa4f3799bae3e684890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Thu, 30 Oct 2025 14:43:47 +0100 Subject: [PATCH 06/11] Import dnspython modules explicitly in custom servers Previously, the server relied on the modules being imported by the isctest.asyncserver module. This is fragile and confuses tooling. Clean up stray imports in the process. --- bin/tests/system/chain/ans3/ans.py | 1 + bin/tests/system/chain/ans4/ans.py | 2 +- bin/tests/system/cookie/cookie_ans.py | 16 +++++++++------- bin/tests/system/dispatch/ans3/ans.py | 3 ++- bin/tests/system/dnssec/ans10/ans.py | 7 ++++--- bin/tests/system/fetchlimit/ans4/ans.py | 4 +++- bin/tests/system/forward/ans11/ans.py | 1 + bin/tests/system/forward/ans6/ans.py | 5 ++++- bin/tests/system/isctest/asyncserver.py | 3 +++ bin/tests/system/qmin/ans2/ans.py | 10 +++------- bin/tests/system/qmin/ans4/ans.py | 1 + bin/tests/system/qmin/qmin_ans.py | 1 - bin/tests/system/rpzrecurse/ans5/ans.py | 6 ++++-- bin/tests/system/statistics/ans4/ans.py | 4 +++- bin/tests/system/xfer/ans9/ans.py | 7 +++---- bin/tests/system/zero/ans5/ans.py | 5 +---- 16 files changed, 43 insertions(+), 33 deletions(-) diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py index 3309ba286b..299f042354 100755 --- a/bin/tests/system/chain/ans3/ans.py +++ b/bin/tests/system/chain/ans3/ans.py @@ -14,6 +14,7 @@ information regarding copyright ownership. from typing import AsyncGenerator import dns.name +import dns.rcode import dns.rdataclass import dns.rdatatype import dns.rrset diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py index 618b36bdf5..f175a88c1e 100755 --- a/bin/tests/system/chain/ans4/ans.py +++ b/bin/tests/system/chain/ans4/ans.py @@ -19,8 +19,8 @@ import abc import logging import re +import dns.name import dns.rcode -import dns.rdata import dns.rdataclass import dns.rdatatype import dns.rrset diff --git a/bin/tests/system/cookie/cookie_ans.py b/bin/tests/system/cookie/cookie_ans.py index 102832dd3f..d66511f38f 100644 --- a/bin/tests/system/cookie/cookie_ans.py +++ b/bin/tests/system/cookie/cookie_ans.py @@ -11,7 +11,11 @@ from typing import AsyncGenerator -import dns +import dns.edns +import dns.message +import dns.name +import dns.rdatatype +import dns.rrset import dns.tsigkeyring from isctest.asyncserver import ( @@ -68,7 +72,7 @@ def _tld(qctx: QueryContext) -> dns.name.Name: def _soa(qctx: QueryContext) -> dns.rrset.RRset: return dns.rrset.from_text( - _tld(qctx), 2, dns.rdataclass.IN, dns.rdatatype.SOA, ". . 0 0 0 0 2" + _tld(qctx), 2, qctx.qclass, dns.rdatatype.SOA, ". . 0 0 0 0 2" ) @@ -80,21 +84,19 @@ def _ns(qctx: QueryContext) -> dns.rrset.RRset: return dns.rrset.from_text( qctx.qname, 1, - dns.rdataclass.IN, + qctx.qclass, dns.rdatatype.NS, _ns_name(qctx).to_text(), ) def _legit_a(qctx: QueryContext) -> dns.rrset.RRset: - return dns.rrset.from_text( - qctx.qname, 1, dns.rdataclass.IN, dns.rdatatype.A, "10.53.0.9" - ) + return dns.rrset.from_text(qctx.qname, 1, qctx.qclass, dns.rdatatype.A, "10.53.0.9") def _spoofed_a(qctx: QueryContext) -> dns.rrset.RRset: return dns.rrset.from_text( - qctx.qname, 1, dns.rdataclass.IN, dns.rdatatype.A, "10.53.0.10" + qctx.qname, 1, qctx.qclass, dns.rdatatype.A, "10.53.0.10" ) diff --git a/bin/tests/system/dispatch/ans3/ans.py b/bin/tests/system/dispatch/ans3/ans.py index 774f9a5414..97b1f2cf1f 100644 --- a/bin/tests/system/dispatch/ans3/ans.py +++ b/bin/tests/system/dispatch/ans3/ans.py @@ -11,7 +11,8 @@ from typing import AsyncGenerator -import dns +import dns.flags +import dns.rcode from isctest.asyncserver import ( AsyncDnsServer, diff --git a/bin/tests/system/dnssec/ans10/ans.py b/bin/tests/system/dnssec/ans10/ans.py index 24a4a0ea0a..f69d5ebe14 100644 --- a/bin/tests/system/dnssec/ans10/ans.py +++ b/bin/tests/system/dnssec/ans10/ans.py @@ -11,7 +11,8 @@ from typing import AsyncGenerator -import dns +import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( AsyncDnsServer, @@ -33,7 +34,7 @@ class AddRrsigToAHandler(ResponseHandler): "gB+eISXAhSPZU2i/II0W9ZUhC2SCIrb94mlNvP5092WAeXxqN/vG43/1nmDly2Qs7y5VCjSMOGn85bnaMoAc7w==" ) rrsig_rrset = dns.rrset.from_text( - qctx.qname, 1, dns.rdataclass.IN, dns.rdatatype.RRSIG, rrsig + qctx.qname, 1, qctx.qclass, dns.rdatatype.RRSIG, rrsig ) qctx.response.answer.append(rrsig_rrset) yield DnsResponseSend(qctx.response) @@ -48,7 +49,7 @@ class AddNsecToTxtHandler(ResponseHandler): ) -> AsyncGenerator[DnsResponseSend, None]: nsec = f"{qctx.qname.to_text()} A NS SOA RRSIG NSEC" nsec_rrset = dns.rrset.from_text( - qctx.qname, 1, dns.rdataclass.IN, dns.rdatatype.NSEC, nsec + qctx.qname, 1, qctx.qclass, dns.rdatatype.NSEC, nsec ) qctx.response.authority.append(nsec_rrset) yield DnsResponseSend(qctx.response) diff --git a/bin/tests/system/fetchlimit/ans4/ans.py b/bin/tests/system/fetchlimit/ans4/ans.py index 9797735a82..cd7602366b 100644 --- a/bin/tests/system/fetchlimit/ans4/ans.py +++ b/bin/tests/system/fetchlimit/ans4/ans.py @@ -13,7 +13,9 @@ information regarding copyright ownership. from typing import AsyncGenerator -import dns +import dns.rcode +import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( ControllableAsyncDnsServer, diff --git a/bin/tests/system/forward/ans11/ans.py b/bin/tests/system/forward/ans11/ans.py index 0b4ec5682e..b5b590aabf 100644 --- a/bin/tests/system/forward/ans11/ans.py +++ b/bin/tests/system/forward/ans11/ans.py @@ -14,6 +14,7 @@ information regarding copyright ownership. from typing import AsyncGenerator import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( ControllableAsyncDnsServer, diff --git a/bin/tests/system/forward/ans6/ans.py b/bin/tests/system/forward/ans6/ans.py index ac1db7ed1e..fdcbe7d392 100644 --- a/bin/tests/system/forward/ans6/ans.py +++ b/bin/tests/system/forward/ans6/ans.py @@ -13,7 +13,10 @@ information regarding copyright ownership. from typing import AsyncGenerator -import dns +import dns.name +import dns.rcode +import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( ControllableAsyncDnsServer, diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index ffe83ba61c..cd7d7027c7 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -38,12 +38,15 @@ import signal import struct import sys +import dns.exception import dns.flags import dns.message import dns.name import dns.node import dns.rcode +import dns.rdata import dns.rdataclass +import dns.rdataset import dns.rdatatype import dns.rrset import dns.tsig diff --git a/bin/tests/system/qmin/ans2/ans.py b/bin/tests/system/qmin/ans2/ans.py index 673b8ff37a..5625a611fb 100644 --- a/bin/tests/system/qmin/ans2/ans.py +++ b/bin/tests/system/qmin/ans2/ans.py @@ -16,8 +16,8 @@ from typing import AsyncGenerator import dns.message import dns.name import dns.rcode -import dns.rdataclass import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( AsyncDnsServer, @@ -63,12 +63,8 @@ def send_delegation( ADDITIONAL section. """ ns_name = "ns." + zone_cut.to_text() - ns_rrset = dns.rrset.from_text( - zone_cut, 2, dns.rdataclass.IN, dns.rdatatype.NS, ns_name - ) - a_rrset = dns.rrset.from_text( - ns_name, 2, dns.rdataclass.IN, dns.rdatatype.A, target_addr - ) + ns_rrset = dns.rrset.from_text(zone_cut, 2, qctx.qclass, dns.rdatatype.NS, ns_name) + a_rrset = dns.rrset.from_text(ns_name, 2, qctx.qclass, dns.rdatatype.A, target_addr) response = dns.message.make_response(qctx.query) response.set_rcode(dns.rcode.NOERROR) diff --git a/bin/tests/system/qmin/ans4/ans.py b/bin/tests/system/qmin/ans4/ans.py index 22334372be..74b9d9fa80 100644 --- a/bin/tests/system/qmin/ans4/ans.py +++ b/bin/tests/system/qmin/ans4/ans.py @@ -14,6 +14,7 @@ information regarding copyright ownership. from typing import AsyncGenerator import dns.rcode +import dns.rdatatype from isctest.asyncserver import ( AsyncDnsServer, diff --git a/bin/tests/system/qmin/qmin_ans.py b/bin/tests/system/qmin/qmin_ans.py index c610eb5726..b78e723ecb 100644 --- a/bin/tests/system/qmin/qmin_ans.py +++ b/bin/tests/system/qmin/qmin_ans.py @@ -16,7 +16,6 @@ from typing import AsyncGenerator import abc import dns.rcode -import dns.rdataclass import dns.rdatatype from isctest.asyncserver import ( diff --git a/bin/tests/system/rpzrecurse/ans5/ans.py b/bin/tests/system/rpzrecurse/ans5/ans.py index 85acf15af5..3132fca091 100644 --- a/bin/tests/system/rpzrecurse/ans5/ans.py +++ b/bin/tests/system/rpzrecurse/ans5/ans.py @@ -13,7 +13,9 @@ information regarding copyright ownership. from typing import AsyncGenerator -import dns +import dns.rcode +import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( AsyncDnsServer, @@ -32,7 +34,7 @@ class ReplyA(ResponseHandler): self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: a_rrset = dns.rrset.from_text( - qctx.qname, 300, dns.rdataclass.IN, dns.rdatatype.A, "10.53.0.5" + qctx.qname, 300, qctx.qclass, dns.rdatatype.A, "10.53.0.5" ) qctx.response.answer.append(a_rrset) yield DnsResponseSend(qctx.response) diff --git a/bin/tests/system/statistics/ans4/ans.py b/bin/tests/system/statistics/ans4/ans.py index f353fc6e95..a5aa118ade 100644 --- a/bin/tests/system/statistics/ans4/ans.py +++ b/bin/tests/system/statistics/ans4/ans.py @@ -13,7 +13,9 @@ information regarding copyright ownership. from typing import AsyncGenerator -import dns +import dns.rcode +import dns.rdatatype +import dns.rrset from isctest.asyncserver import ( AsyncDnsServer, diff --git a/bin/tests/system/xfer/ans9/ans.py b/bin/tests/system/xfer/ans9/ans.py index f1a4bf5437..a07a214a3b 100644 --- a/bin/tests/system/xfer/ans9/ans.py +++ b/bin/tests/system/xfer/ans9/ans.py @@ -14,7 +14,6 @@ information regarding copyright ownership. from typing import AsyncGenerator import dns.message -import dns.rdataclass import dns.rdatatype import dns.rrset @@ -52,7 +51,7 @@ class AXFRServer(DomainHandler): soa_rrset = dns.rrset.from_text( qctx.qname, 300, - dns.rdataclass.IN, + qctx.qclass, dns.rdatatype.SOA, f". . {self.soa_version} 0 0 0 0", ) @@ -79,7 +78,7 @@ class AXFRServer(DomainHandler): # will already have been done with the mandatory stuff by then. ns_message = dns.message.make_response(qctx.query) ns_rrset = dns.rrset.from_text( - qctx.qname, 300, dns.rdataclass.IN, dns.rdatatype.NS, "." + qctx.qname, 300, qctx.qclass, dns.rdatatype.NS, "." ) ns_message.answer.append(ns_rrset) @@ -90,7 +89,7 @@ class AXFRServer(DomainHandler): txt_rrset = dns.rrset.from_text( qctx.qname, 300, - dns.rdataclass.IN, + qctx.qclass, dns.rdatatype.TXT, "foo bar", ) diff --git a/bin/tests/system/zero/ans5/ans.py b/bin/tests/system/zero/ans5/ans.py index e3438bfd73..a7f63913cf 100644 --- a/bin/tests/system/zero/ans5/ans.py +++ b/bin/tests/system/zero/ans5/ans.py @@ -14,10 +14,7 @@ information regarding copyright ownership. import ipaddress from typing import AsyncGenerator -import dns.flags -import dns.message -import dns.rdata -import dns.rdataclass +import dns.rcode import dns.rdatatype import dns.rrset From 8c2a72143c66b8aa81e0b2c2425fcc106a86cb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Thu, 30 Oct 2025 14:53:44 +0100 Subject: [PATCH 07/11] Remove dnspython<2.0.0 compatibility hacks from custom servers isctest.asyncserver requires dnspython 2+ now. --- bin/tests/system/chain/ans3/ans.py | 2 +- bin/tests/system/chain/ans4/ans.py | 9 ++------- bin/tests/system/isctest/asyncserver.py | 11 ++--------- bin/tests/system/qmin/qmin_ans.py | 4 +--- 4 files changed, 6 insertions(+), 20 deletions(-) diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py index 299f042354..b61cd9d79a 100755 --- a/bin/tests/system/chain/ans3/ans.py +++ b/bin/tests/system/chain/ans3/ans.py @@ -31,7 +31,7 @@ from isctest.asyncserver import ( try: dns_namerelation_equal = dns.name.NameRelation.EQUAL dns_namerelation_subdomain = dns.name.NameRelation.SUBDOMAIN -except AttributeError: # dnspython < 2.0.0 compat +except AttributeError: # dnspython < 2.3.0 compat dns_namerelation_equal = dns.name.NAMERELN_EQUAL # type: ignore dns_namerelation_subdomain = dns.name.NAMERELN_SUBDOMAIN # type: ignore diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py index f175a88c1e..2f0d8c3352 100755 --- a/bin/tests/system/chain/ans4/ans.py +++ b/bin/tests/system/chain/ans4/ans.py @@ -34,11 +34,6 @@ from isctest.asyncserver import ( ResponseAction, ) -try: - RdataType = dns.rdatatype.RdataType -except AttributeError: # dnspython < 2.0.0 compat - RdataType = int # type: ignore - class ChainNameGenerator: """ @@ -105,13 +100,13 @@ class RecordGenerator(abc.ABC): @classmethod def create_rrset( - cls, owner: dns.name.Name, rrtype: RdataType, rdata: str + cls, owner: dns.name.Name, rrtype: dns.rdatatype.RdataType, rdata: str ) -> dns.rrset.RRset: return dns.rrset.from_text(owner, 86400, dns.rdataclass.IN, rrtype, rdata) @classmethod def create_rrset_signature( - cls, owner: dns.name.Name, rrtype: RdataType + cls, owner: dns.name.Name, rrtype: dns.rdatatype.RdataType ) -> dns.rrset.RRset: covers = dns.rdatatype.to_text(rrtype) ttl = "86400" diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index cd7d7027c7..dfaf125709 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -53,13 +53,6 @@ import dns.tsig import dns.version import dns.zone -try: - RdataType = dns.rdatatype.RdataType - RdataClass = dns.rdataclass.RdataClass -except AttributeError: # dnspython < 2.0.0 compat - RdataType = int # type: ignore - RdataClass = int # type: ignore - _UdpHandler = Callable[ [bytes, Tuple[str, int], asyncio.DatagramTransport], Coroutine[Any, Any, None] @@ -291,11 +284,11 @@ class QueryContext: return self.alias or self.qname @property - def qclass(self) -> RdataClass: + def qclass(self) -> dns.rdataclass.RdataClass: return self.query.question[0].rdclass @property - def qtype(self) -> RdataType: + def qtype(self) -> dns.rdatatype.RdataType: return self.query.question[0].rdtype diff --git a/bin/tests/system/qmin/qmin_ans.py b/bin/tests/system/qmin/qmin_ans.py index b78e723ecb..6185e15a10 100644 --- a/bin/tests/system/qmin/qmin_ans.py +++ b/bin/tests/system/qmin/qmin_ans.py @@ -25,8 +25,6 @@ from isctest.asyncserver import ( ResponseAction, ) -from isctest.compat import dns_rcode - def log_query(qctx: QueryContext) -> None: """ @@ -66,7 +64,7 @@ class EntRcodeChanger(DomainHandler): @property @abc.abstractmethod - def rcode(self) -> dns_rcode: + def rcode(self) -> dns.rcode.Rcode: raise NotImplementedError async def get_responses( From de266fff4cf5b76b06ac71ea39fbdc91ebba80b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Wed, 12 Nov 2025 16:02:48 +0100 Subject: [PATCH 08/11] Add TSIG keyring support to AsyncDnsServer Previously, ResponseHandlers had to reparse the queries themselves if they wanted to use TSIG. This led to `default_aa` and `default_rcode` information being lost from the newly created messages. Add support for TSIG keyrings to the AsyncDnsServer class directly. --- bin/tests/system/cookie/cookie_ans.py | 2 +- bin/tests/system/isctest/asyncserver.py | 46 +++++++++++++++---------- bin/tests/system/tsig/ans2/ans.py | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/bin/tests/system/cookie/cookie_ans.py b/bin/tests/system/cookie/cookie_ans.py index d66511f38f..5656faa164 100644 --- a/bin/tests/system/cookie/cookie_ans.py +++ b/bin/tests/system/cookie/cookie_ans.py @@ -206,7 +206,7 @@ class FallbackHandler(ResponseHandler): def cookie_server(evil: bool) -> AsyncDnsServer: - server = AsyncDnsServer(acknowledge_tsig_dnspython_hacks=True) + server = AsyncDnsServer(keyring=None) server.install_response_handlers( [ NsHandler(evil), diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index dfaf125709..8300ff2b01 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -751,6 +751,10 @@ class _DnsMessageWithTsigDisabled(dns.message.Message): return super().to_wire(*args, **kwargs) +class _NoKeyringType: + pass + + class AsyncDnsServer(AsyncServer): """ DNS server which responds to queries based on zone data and/or custom @@ -772,8 +776,10 @@ class AsyncDnsServer(AsyncServer): /, default_rcode: dns.rcode.Rcode = dns.rcode.REFUSED, default_aa: bool = True, + keyring: Union[ + Dict[dns.name.Name, dns.tsig.Key], None, _NoKeyringType + ] = _NoKeyringType(), acknowledge_manual_dname_handling: bool = False, - acknowledge_tsig_dnspython_hacks: bool = False, ) -> None: super().__init__(self._handle_udp, self._handle_tcp, "ans.pid") @@ -782,8 +788,8 @@ class AsyncDnsServer(AsyncServer): self._response_handlers: List[ResponseHandler] = [] self._default_rcode = default_rcode self._default_aa = default_aa + self._keyring = keyring self._acknowledge_manual_dname_handling = acknowledge_manual_dname_handling - self._acknowledge_tsig_dnspython_hacks = acknowledge_tsig_dnspython_hacks self._load_zones() @@ -1062,10 +1068,7 @@ class AsyncDnsServer(AsyncServer): Yield wire data to send as a response over the established transport. """ try: - query = dns.message.from_wire(wire) - except dns.message.UnknownTSIGKey: - self._abort_if_tsig_signed_query_received_unless_acknowledged() - query = _DnsMessageWithTsigDisabled.from_wire(wire) + query = self._parse_message(wire) except dns.exception.DNSException as exc: logging.error("Invalid query from %s (%s): %s", peer, wire.hex(), exc) return @@ -1084,18 +1087,25 @@ class AsyncDnsServer(AsyncServer): response_length = struct.pack("!H", len(response)) yield response_length + response - def _abort_if_tsig_signed_query_received_unless_acknowledged(self) -> None: - if self._acknowledge_tsig_dnspython_hacks: - return - - error = "TSIG-signed query received; " - error += "due to a bug in dnspython, this requires some hacking around; " - error += "you may experience unexpected behavior when dealing with TSIG; " - error += "TSIG validation is disabled, so any TSIG handling must be done " - error += "manually; pass `acknowledge_tsig_dnspython_hacks=True` to the " - error += "AsyncDnsServer constructor to acknowledge this and continue." - - raise ValueError(error) + def _parse_message(self, wire: bytes) -> dns.message.Message: + try: + if isinstance(self._keyring, _NoKeyringType): + keyring = None + else: + keyring = self._keyring + return dns.message.from_wire(wire, keyring=keyring) + except dns.message.UnknownTSIGKey as exc: + if isinstance(self._keyring, _NoKeyringType): + error = "TSIG-signed query received but no `keyring` was provided; " + error += "either provide a keyring (in which case the server will " + error += "ignore any TSIG-invalid queries), or set `keyring=None` " + error += "explicitly to disable TSIG validation altogether. " + error += "This requires some hacking around a dnspython bug, " + error += "so there may be unexpected side effects." + raise ValueError(error) from exc + if self._keyring is None: + return _DnsMessageWithTsigDisabled.from_wire(wire) + raise async def _prepare_responses( self, qctx: QueryContext diff --git a/bin/tests/system/tsig/ans2/ans.py b/bin/tests/system/tsig/ans2/ans.py index 65548e69ef..677a57cf8f 100644 --- a/bin/tests/system/tsig/ans2/ans.py +++ b/bin/tests/system/tsig/ans2/ans.py @@ -40,7 +40,7 @@ class TruncatedWithLastByteDroppedHandler(ResponseHandler): def main() -> None: - server = AsyncDnsServer(acknowledge_tsig_dnspython_hacks=True) + server = AsyncDnsServer(keyring=None) server.install_response_handler(TruncatedWithLastByteDroppedHandler()) server.run() From 5384998ccdd5b6dc57e6821cd4c8b91039c3d8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Wed, 12 Nov 2025 16:19:25 +0100 Subject: [PATCH 09/11] Allow ResponseHandlers to roll back changes made to a response Previously, this was only possible by making a new response by calling make_response on qctx.query. This however ignored the `default_aa` and `default_rcode` parameters of AsyncDnsServer. Add prepare_new_response and save_initialized_response methods to QueryContext. --- bin/tests/system/isctest/asyncserver.py | 36 +++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 8300ff2b01..98fec6b663 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -28,6 +28,7 @@ from typing import ( import abc import asyncio import contextlib +import copy import enum import functools import logging @@ -269,11 +270,17 @@ class QueryContext: response: dns.message.Message peer: Peer protocol: DnsProtocol - zone: Optional[dns.zone.Zone] = None - soa: Optional[dns.rrset.RRset] = None - node: Optional[dns.node.Node] = None - answer: Optional[dns.rdataset.Rdataset] = None - alias: Optional[dns.name.Name] = None + zone: Optional[dns.zone.Zone] = field(default=None, init=False) + soa: Optional[dns.rrset.RRset] = field(default=None, init=False) + node: Optional[dns.node.Node] = field(default=None, init=False) + answer: Optional[dns.rdataset.Rdataset] = field(default=None, init=False) + alias: Optional[dns.name.Name] = field(default=None, init=False) + _initialized_response: Optional[dns.message.Message] = field( + default=None, init=False + ) + _initialized_response_with_zone_data: Optional[dns.message.Message] = field( + default=None, init=False + ) @property def qname(self) -> dns.name.Name: @@ -291,6 +298,23 @@ class QueryContext: def qtype(self) -> dns.rdatatype.RdataType: return self.query.question[0].rdtype + def prepare_new_response( + self, /, with_zone_data: bool = True + ) -> dns.message.Message: + if with_zone_data: + assert self._initialized_response_with_zone_data + self.response = copy.deepcopy(self._initialized_response_with_zone_data) + else: + assert self._initialized_response + self.response = copy.deepcopy(self._initialized_response) + return self.response + + def save_initialized_response(self, /, with_zone_data: bool) -> None: + if with_zone_data: + self._initialized_response_with_zone_data = copy.deepcopy(self.response) + else: + self._initialized_response = copy.deepcopy(self.response) + @dataclass class ResponseAction(abc.ABC): @@ -1116,8 +1140,10 @@ class AsyncDnsServer(AsyncServer): qctx.response.set_rcode(self._default_rcode) if self._default_aa: qctx.response.flags |= dns.flags.AA + qctx.save_initialized_response(with_zone_data=False) self._prepare_response_from_zone_data(qctx) + qctx.save_initialized_response(with_zone_data=True) response_handled = False async for action in self._run_response_handlers(qctx): From f5f84a649b89d095b508950512fcab8f84e168ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Wed, 12 Nov 2025 16:35:40 +0100 Subject: [PATCH 10/11] Use new AsyncDnsServer features in cookie system test Take advantage of `default_aa`, `default_rcode` and `keyring` arguments. --- bin/tests/system/cookie/cookie_ans.py | 38 +++++++++------------------ 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/bin/tests/system/cookie/cookie_ans.py b/bin/tests/system/cookie/cookie_ans.py index 5656faa164..50b06f2c16 100644 --- a/bin/tests/system/cookie/cookie_ans.py +++ b/bin/tests/system/cookie/cookie_ans.py @@ -12,8 +12,8 @@ from typing import AsyncGenerator import dns.edns -import dns.message import dns.name +import dns.rcode import dns.rdatatype import dns.rrset import dns.tsigkeyring @@ -37,16 +37,6 @@ KEYRING = dns.tsigkeyring.from_text( ) -def _reparse_with_keyring(qctx: QueryContext) -> None: - """ - `isctest.asyncserver` doesn't support TSIG signing and validation properly - and hacks around it. However, here we need to be able to sign responses with - TSIG, so we reparse the query and recreate the response stub here. - """ - qctx.query = dns.message.from_wire(qctx.query.to_wire(), keyring=KEYRING) - qctx.response = dns.message.make_response(qctx.query) - - def _first_label(qctx: QueryContext) -> str: return qctx.qname.labels[0].decode("ascii") @@ -112,14 +102,13 @@ class NsHandler(_SpoofableHandler): async def get_responses( self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: - _reparse_with_keyring(qctx) _add_cookie(qctx) qctx.response.answer.append(_ns(qctx)) if self.evil_server: qctx.response.authority.append(_spoofed_a(qctx)) else: qctx.response.authority.append(_legit_a(qctx)) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class GlueHandler(_SpoofableHandler): @@ -129,13 +118,12 @@ class GlueHandler(_SpoofableHandler): async def get_responses( self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: - _reparse_with_keyring(qctx) _add_cookie(qctx) if self.evil_server: qctx.response.answer.append(_spoofed_a(qctx)) else: qctx.response.answer.append(_legit_a(qctx)) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class TcpAHandler(ResponseHandler): @@ -145,11 +133,10 @@ class TcpAHandler(ResponseHandler): async def get_responses( self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: - _reparse_with_keyring(qctx) if _first_label(qctx) != "nocookie": _add_cookie(qctx) qctx.response.answer.append(_legit_a(qctx)) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class WithtsigUdpAHandler(ResponseHandler): @@ -163,16 +150,15 @@ class WithtsigUdpAHandler(ResponseHandler): async def get_responses( self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: - _reparse_with_keyring(qctx) qctx.response.answer.append(_legit_a(qctx)) qctx.response.answer.append(_spoofed_a(qctx)) qctx.response.use_tsig(keyring=KEYRING, keyname="fake") - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) - _reparse_with_keyring(qctx) + qctx.prepare_new_response() _add_cookie(qctx) qctx.response.answer.append(_legit_a(qctx)) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class UdpAHandler(ResponseHandler): @@ -182,31 +168,31 @@ class UdpAHandler(ResponseHandler): async def get_responses( self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: - _reparse_with_keyring(qctx) qctx.response.answer.append(_legit_a(qctx)) if _first_label(qctx) not in ("nocookie", "tcponly"): _add_cookie(qctx) else: qctx.response.answer.append(_spoofed_a(qctx)) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) class FallbackHandler(ResponseHandler): async def get_responses( self, qctx: QueryContext ) -> AsyncGenerator[DnsResponseSend, None]: - _reparse_with_keyring(qctx) _add_cookie(qctx) if qctx.qtype == dns.rdatatype.SOA: qctx.response.answer.append(_soa(qctx)) else: qctx.response.authority.append(_soa(qctx)) - yield DnsResponseSend(qctx.response, authoritative=True) + yield DnsResponseSend(qctx.response) def cookie_server(evil: bool) -> AsyncDnsServer: - server = AsyncDnsServer(keyring=None) + server = AsyncDnsServer( + keyring=KEYRING, default_aa=True, default_rcode=dns.rcode.NOERROR + ) server.install_response_handlers( [ NsHandler(evil), From db212153d93d95387fc932f2b04a592b19acc1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Wed, 12 Nov 2025 16:48:32 +0100 Subject: [PATCH 11/11] Use new AsyncDnsServer features in xfer system test Use `prepare_new_response()`, `default_aa` and `default_rcode`. --- bin/tests/system/xfer/ans9/ans.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bin/tests/system/xfer/ans9/ans.py b/bin/tests/system/xfer/ans9/ans.py index a07a214a3b..2e3a1f8be2 100644 --- a/bin/tests/system/xfer/ans9/ans.py +++ b/bin/tests/system/xfer/ans9/ans.py @@ -13,7 +13,7 @@ information regarding copyright ownership. from typing import AsyncGenerator -import dns.message +import dns.rcode import dns.rdatatype import dns.rrset @@ -47,7 +47,7 @@ class AXFRServer(DomainHandler): # expected to send a SOA query over UDP and then an AXFR query over # TCP. Responses to both of those start with a SOA RRset in the ANSWER # section :-) - soa_message = dns.message.make_response(qctx.query) + soa_message = qctx.response soa_rrset = dns.rrset.from_text( qctx.qname, 300, @@ -57,7 +57,7 @@ class AXFRServer(DomainHandler): ) soa_message.answer.append(soa_rrset) - yield DnsResponseSend(soa_message, authoritative=True) + yield DnsResponseSend(soa_message) if qctx.qtype == dns.rdatatype.SOA: # If QTYPE=SOA, the SOA record is the complete response. @@ -76,16 +76,16 @@ class AXFRServer(DomainHandler): # Send just the obligatory NS RRset at zone apex in the next message. # This is stupidly inefficient, but makes looping below simpler as we # will already have been done with the mandatory stuff by then. - ns_message = dns.message.make_response(qctx.query) + ns_message = qctx.prepare_new_response() ns_rrset = dns.rrset.from_text( qctx.qname, 300, qctx.qclass, dns.rdatatype.NS, "." ) ns_message.answer.append(ns_rrset) - yield DnsResponseSend(ns_message, authoritative=True) + yield DnsResponseSend(ns_message) # Generate the AXFR with a txt rrset. - txt_message = dns.message.make_response(qctx.query) + txt_message = qctx.prepare_new_response() txt_rrset = dns.rrset.from_text( qctx.qname, 300, @@ -95,17 +95,19 @@ class AXFRServer(DomainHandler): ) txt_message.answer.append(txt_rrset) - yield DnsResponseSend(txt_message, authoritative=True) + yield DnsResponseSend(txt_message) # Finish the AXFR transaction by sending the second SOA RRset. - yield DnsResponseSend(soa_message, authoritative=True) + yield DnsResponseSend(soa_message) # This makes sure that the next SOA request causes a new zone transfer self.soa_version += 1 if __name__ == "__main__": - server = ControllableAsyncDnsServer() + server = ControllableAsyncDnsServer( + default_aa=True, default_rcode=dns.rcode.NOERROR + ) server.install_control_command(ToggleResponsesCommand()) server.install_response_handler(AXFRServer()) server.run()