From 5284dfd4fefdd04c2e605090c90f118269c267ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Wed, 17 Dec 2025 14:08:03 +0100 Subject: [PATCH 1/3] Use variadic positional parameters for plural install_* methods It saves an indent and brackets on the call sites. Also sort the handlers alphabetically where their order doesn't matter and split the fallback handlers into a separate call to signify that their position in the end matters. (cherry picked from commit 7e587201a4e49c88cfb4a46662d819c3a35b703b) --- bin/tests/system/chain/ans3/ans.py | 2 +- bin/tests/system/cookie/cookie_ans.py | 14 ++++++-------- bin/tests/system/digdelv/ans7/ans.py | 8 +++----- bin/tests/system/dnssec/ans10/ans.py | 2 +- bin/tests/system/isctest/asyncserver.py | 4 ++-- bin/tests/system/qmin/ans2/ans.py | 12 +++++------- bin/tests/system/qmin/ans3/ans.py | 10 ++++------ bin/tests/system/qmin/ans4/ans.py | 12 +++++------- bin/tests/system/rpzrecurse/ans5/ans.py | 2 +- bin/tests/system/statistics/ans4/ans.py | 20 +++++++++----------- 10 files changed, 37 insertions(+), 49 deletions(-) diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py index b61cd9d79a..2b0c41ccb7 100755 --- a/bin/tests/system/chain/ans3/ans.py +++ b/bin/tests/system/chain/ans3/ans.py @@ -114,7 +114,7 @@ class Cve202125215(DomainHandler): def main() -> None: server = AsyncDnsServer(acknowledge_manual_dname_handling=True, default_aa=True) - server.install_response_handlers([CnameThenDnameHandler(), 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 50b06f2c16..72f0e0ab46 100644 --- a/bin/tests/system/cookie/cookie_ans.py +++ b/bin/tests/system/cookie/cookie_ans.py @@ -194,13 +194,11 @@ def cookie_server(evil: bool) -> AsyncDnsServer: keyring=KEYRING, default_aa=True, default_rcode=dns.rcode.NOERROR ) server.install_response_handlers( - [ - NsHandler(evil), - GlueHandler(evil), - TcpAHandler(), - WithtsigUdpAHandler(), - UdpAHandler(), - FallbackHandler(), - ] + NsHandler(evil), + GlueHandler(evil), + TcpAHandler(), + WithtsigUdpAHandler(), + UdpAHandler(), ) + server.install_response_handler(FallbackHandler()) return server diff --git a/bin/tests/system/digdelv/ans7/ans.py b/bin/tests/system/digdelv/ans7/ans.py index d959b597e2..768c01e224 100644 --- a/bin/tests/system/digdelv/ans7/ans.py +++ b/bin/tests/system/digdelv/ans7/ans.py @@ -63,11 +63,9 @@ class SilentThenServfailHandler(DomainHandler): def main() -> None: server = AsyncDnsServer() server.install_response_handlers( - [ - CloseHandler(), - SilentHandler(), - SilentThenServfailHandler(), - ] + CloseHandler(), + SilentHandler(), + SilentThenServfailHandler(), ) server.run() diff --git a/bin/tests/system/dnssec/ans10/ans.py b/bin/tests/system/dnssec/ans10/ans.py index f69d5ebe14..d5d22e621e 100644 --- a/bin/tests/system/dnssec/ans10/ans.py +++ b/bin/tests/system/dnssec/ans10/ans.py @@ -57,7 +57,7 @@ class AddNsecToTxtHandler(ResponseHandler): def main() -> None: server = AsyncDnsServer() - server.install_response_handlers([AddRrsigToAHandler(), AddNsecToTxtHandler()]) + server.install_response_handlers(AddNsecToTxtHandler(), AddRrsigToAHandler()) server.run() diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 86de57cf71..1eaf2a4ec7 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -887,7 +887,7 @@ class AsyncDnsServer(AsyncServer): else: self._response_handlers.append(handler) - def install_response_handlers(self, handlers: List[ResponseHandler]) -> None: + def install_response_handlers(self, *handlers: ResponseHandler) -> None: for handler in handlers: self.install_response_handler(handler) @@ -1380,7 +1380,7 @@ class ControllableAsyncDnsServer(AsyncDnsServer): def _commands(self) -> Dict[dns.name.Name, "ControlCommand"]: return {} - def install_control_commands(self, commands: List["ControlCommand"]) -> None: + def install_control_commands(self, *commands: "ControlCommand") -> None: for command in commands: self.install_control_command(command) diff --git a/bin/tests/system/qmin/ans2/ans.py b/bin/tests/system/qmin/ans2/ans.py index e4f7611f64..4708dc6cf1 100644 --- a/bin/tests/system/qmin/ans2/ans.py +++ b/bin/tests/system/qmin/ans2/ans.py @@ -100,13 +100,11 @@ class StaleHandler(DomainHandler): def main() -> None: server = AsyncDnsServer() server.install_response_handlers( - [ - QueryLogger(), - BadHandler(), - UglyHandler(), - SlowHandler(), - StaleHandler(), - ] + 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 101ea2a14f..ec720fd228 100644 --- a/bin/tests/system/qmin/ans3/ans.py +++ b/bin/tests/system/qmin/ans3/ans.py @@ -40,12 +40,10 @@ class ZoopBoingSlowHandler(DelayedResponseHandler): def main() -> None: server = AsyncDnsServer() server.install_response_handlers( - [ - QueryLogger(), - ZoopBoingBadHandler(), - ZoopBoingUglyHandler(), - ZoopBoingSlowHandler(), - ] + 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 74b9d9fa80..b766017de0 100644 --- a/bin/tests/system/qmin/ans4/ans.py +++ b/bin/tests/system/qmin/ans4/ans.py @@ -87,13 +87,11 @@ class IckyPtangZoopBoingSlowHandler(DelayedResponseHandler): def main() -> None: server = AsyncDnsServer() server.install_response_handlers( - [ - QueryLogger(), - StaleHandler(), - IckyPtangZoopBoingBadHandler(), - IckyPtangZoopBoingUglyHandler(), - IckyPtangZoopBoingSlowHandler(), - ] + 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 3132fca091..6eed86d7f4 100644 --- a/bin/tests/system/rpzrecurse/ans5/ans.py +++ b/bin/tests/system/rpzrecurse/ans5/ans.py @@ -52,7 +52,7 @@ class IgnoreNs(ResponseHandler): def main() -> None: server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR) - server.install_response_handlers([ReplyA(), 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 a5aa118ade..0a9d05e1b8 100644 --- a/bin/tests/system/statistics/ans4/ans.py +++ b/bin/tests/system/statistics/ans4/ans.py @@ -162,18 +162,16 @@ class FallbackHandler(ResponseHandler): def main() -> None: server = AsyncDnsServer(default_rcode=dns.rcode.NOERROR) server.install_response_handlers( - [ - BadGoodCnameHandler(), - Cname1Handler(), - Cname2Handler(), - ExampleHandler(), - FooInfoHandler(), - NoDataHandler(), - NxdomainHandler(), - SubHandler(), - FallbackHandler(), - ] + BadGoodCnameHandler(), + Cname1Handler(), + Cname2Handler(), + ExampleHandler(), + FooInfoHandler(), + NoDataHandler(), + NxdomainHandler(), + SubHandler(), ) + server.install_response_handler(FallbackHandler()) server.run() From 8a088183e6e05a63eddbdb39f7e0d2d5d29b5b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Tue, 23 Dec 2025 14:36:56 +0100 Subject: [PATCH 2/3] Add SwitchControlCommand for ControllableAsyncServer To provide feature parity with `bin/tests/system/ans.pl` add a control command to allow easy switching between different sequences of ResponseHandlers. (cherry picked from commit 2302fe1235f64691620834922eda33397e1f0157) --- bin/tests/system/isctest/asyncserver.py | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 1eaf2a4ec7..85254800e9 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -21,6 +21,7 @@ from typing import ( List, Optional, Set, + Sequence, Tuple, Union, cast, @@ -891,6 +892,14 @@ class AsyncDnsServer(AsyncServer): for handler in handlers: self.install_response_handler(handler) + def replace_response_handlers(self, *new_handlers: ResponseHandler) -> None: + """ + Uninstall all currently installed handlers and install the provided ones. + """ + logging.info("Uninstalling response handlers: %s", str(self._response_handlers)) + self._response_handlers.clear() + self.install_response_handlers(*new_handlers) + def uninstall_response_handler(self, handler: ResponseHandler) -> None: """ Remove the specified handler from the list of response handlers. @@ -1556,3 +1565,30 @@ class ToggleResponsesCommand(ControlCommand): logging.error("Unrecognized response sending mode '%s'", mode) qctx.response.set_rcode(dns.rcode.SERVFAIL) return f"unrecognized response sending mode '{mode}'" + + +class SwitchControlCommand(ControlCommand): + """ + Switch the server's response handlers based on the control query. + + A sequence of response handlers is associated with each key. When a + control query is received, the server's response handlers are replaced + with the sequence associated with the key extracted from the control + query. + """ + + control_subdomain = "switch" + + def __init__(self, handler_mapping: Dict[str, Sequence[ResponseHandler]]): + self._handler_mapping = handler_mapping + + def handle( + self, args: List[str], server: ControllableAsyncDnsServer, qctx: QueryContext + ) -> Optional[str]: + if len(args) != 1 or args[0] not in self._handler_mapping: + logging.error("Invalid %s query %s", self, qctx.qname) + qctx.response.set_rcode(dns.rcode.SERVFAIL) + return f"invalid query; exactly one of {list(self._handler_mapping.keys())} is expected in QNAME" + + server.replace_response_handlers(*self._handler_mapping[args[0]]) + return f"switched to handler set '{args[0]}'" From ecbce1079022939f72bbeaef47c519fd27e90424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0t=C4=9Bp=C3=A1n=20Bal=C3=A1=C5=BEik?= Date: Tue, 23 Dec 2025 14:41:18 +0100 Subject: [PATCH 3/3] Use isctest.asyncserver in the "ixfr" system test Replace the usage of the `bin/tests/system/ans.pl` server with an instance of ControllableAsyncServer. (cherry picked from commit 46ecbbed0a1eea6da600255de72e66f889b8f62c) --- bin/tests/system/ixfr/ans2/ans.py | 265 +++++++++++++++++++++++++ bin/tests/system/ixfr/ans2/startme | 0 bin/tests/system/ixfr/tests.sh | 96 +-------- bin/tests/system/ixfr/tests_sh_ixfr.py | 3 + 4 files changed, 276 insertions(+), 88 deletions(-) create mode 100644 bin/tests/system/ixfr/ans2/ans.py delete mode 100644 bin/tests/system/ixfr/ans2/startme diff --git a/bin/tests/system/ixfr/ans2/ans.py b/bin/tests/system/ixfr/ans2/ans.py new file mode 100644 index 0000000000..8659244426 --- /dev/null +++ b/bin/tests/system/ixfr/ans2/ans.py @@ -0,0 +1,265 @@ +""" +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 abc + +import dns.rcode +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +from typing import AsyncGenerator, Collection, Iterable + +from isctest.asyncserver import ( + ControllableAsyncDnsServer, + DnsResponseSend, + QueryContext, + ResponseHandler, + SwitchControlCommand, +) + + +def rrset(owner: str, rdtype: dns.rdatatype.RdataType, rdata: str) -> dns.rrset.RRset: + return dns.rrset.from_text( + owner, + 300, + dns.rdataclass.IN, + rdtype, + rdata, + ) + + +def soa(serial: int, *, owner: str = "nil.") -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.SOA, + f"ns.nil. root.nil. {serial} 300 300 604800 300", + ) + + +def ns() -> dns.rrset.RRset: + return rrset( + "nil.", + dns.rdatatype.NS, + "ns.nil.", + ) + + +def a(address: str, *, owner: str) -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.A, + address, + ) + + +def txt(data: str, *, owner: str = "nil.") -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.TXT, + f'"{data}"', + ) + + +class SoaHandler(ResponseHandler): + def __init__(self, serial: int): + self._serial = serial + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.SOA + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + qctx.response.answer.append(soa(self._serial)) + yield DnsResponseSend(qctx.response) + + +class AxfrHandler(ResponseHandler): + @property + @abc.abstractmethod + def answers(self) -> Iterable[Collection[dns.rrset.RRset]]: + """ + Answer sections of response packets sent in response to + AXFR queries. + """ + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.AXFR + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + for answer in self.answers: + response = qctx.prepare_new_response() + for rrset_ in answer: + response.answer.append(rrset_) + yield DnsResponseSend(response) + + +class IxfrHandler(ResponseHandler): + @property + @abc.abstractmethod + def answer(self) -> Collection[dns.rrset.RRset]: + """ + Answer section of a response packet sent in response to + IXFR queries. + """ + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.IXFR + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + for rrset_ in self.answer: + qctx.response.answer.append(rrset_) + yield DnsResponseSend(qctx.response) + + +class InitialAfxrHandler(AxfrHandler): + answers = ( + (soa(1),), + ( + ns(), + txt("initial AXFR"), + a("10.0.0.61", owner="a.nil."), + a("10.0.0.62", owner="b.nil."), + ), + (soa(1),), + ) + + +class SuccessfulIfxrHandler(IxfrHandler): + answer = ( + soa(3), + soa(1), + a("10.0.0.61", owner="a.nil."), + txt("initial AXFR"), + soa(2), + txt("successful IXFR"), + a("10.0.1.61", owner="a.nil."), + soa(2), + soa(3), + soa(3), + ) + + +class NotExactIxfrHandler(IxfrHandler): + answer = ( + soa(4), + soa(3), + txt("delete-nonexistent-txt-record"), + soa(4), + txt("this-txt-record-would-be-added"), + soa(4), + ) + + +class FallbackNotExactAxfrHandler(AxfrHandler): + answers = ( + (soa(3),), + ( + ns(), + txt("fallback AXFR"), + ), + (soa(3),), + ) + + +class TooManyRecordsIxfrHandler(IxfrHandler): + answer = ( + soa(4), + soa(3), + soa(4), + txt("text 1"), + txt("text 2"), + txt("text 3"), + txt("text 4"), + txt("text 5"), + txt("text 6: causing too many records"), + soa(4), + ) + + +class FallbackTooManyRecordsAxfrHandler(AxfrHandler): + answers = ( + ( + soa(3), + ns(), + txt("fallback AXFR on too many records"), + ), + (soa(3),), + ) + + +class BadSoaOwnerIxfrHandler(IxfrHandler): + answer = ( + soa(4), + soa(3), + soa(4, owner="bad-owner."), + txt("serial 4, malformed IXFR", owner="test.nil."), + soa(4), + ) + + +class FallbackBadSoaOwnerAxfrHandler(AxfrHandler): + answers = ( + (soa(4),), + ( + ns(), + txt("serial 4, fallback AXFR", owner="test.nil."), + ), + (soa(4),), + ) + + +def main() -> None: + server = ControllableAsyncDnsServer( + default_aa=True, default_rcode=dns.rcode.NOERROR + ) + switch_command = SwitchControlCommand( + { + "initial_axfr": ( + SoaHandler(1), + InitialAfxrHandler(), + ), + "successful_ixfr": ( + SoaHandler(3), + SuccessfulIfxrHandler(), + ), + "not_exact": ( + SoaHandler(4), + NotExactIxfrHandler(), + FallbackNotExactAxfrHandler(), + ), + "too_many_records": ( + SoaHandler(4), + TooManyRecordsIxfrHandler(), + FallbackTooManyRecordsAxfrHandler(), + ), + "bad_soa_owner": ( + SoaHandler(4), + BadSoaOwnerIxfrHandler(), + FallbackBadSoaOwnerAxfrHandler(), + ), + } + ) + server.install_control_command(switch_command) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/ixfr/ans2/startme b/bin/tests/system/ixfr/ans2/startme deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/bin/tests/system/ixfr/tests.sh b/bin/tests/system/ixfr/tests.sh index 864adefb09..ef783ab6de 100644 --- a/bin/tests/system/ixfr/tests.sh +++ b/bin/tests/system/ixfr/tests.sh @@ -32,27 +32,16 @@ n=0 DIGOPTS="+tcp +noadd +nosea +nostat +noquest +nocomm +nocmd -p ${PORT}" RNDCCMD="$RNDC -p ${CONTROLPORT} -c ../_common/rndc.conf -s" -sendcmd() { - send 10.53.0.2 "${EXTRAPORT1}" +switch_responses() { + RESPONSES_KEY="${1}" + $DIG $DIGOPTS "@10.53.0.2" "${RESPONSES_KEY}.switch._control." TXT +time=5 +tries=1 +tcp >/dev/null 2>&1 } n=$((n + 1)) echo_i "testing initial AXFR ($n)" ret=0 -sendcmd </dev/null # Provide a broken IXFR response and a working fallback AXFR response. -sendcmd <= 2.0.0 +pytest.importorskip("dns", minversion="2.0.0") + pytestmark = pytest.mark.extra_artifacts( [ "dig.out*",