From: Ondřej Surý Date: Thu, 14 May 2026 15:10:19 +0000 (+0200) Subject: Add pytest serve_stale TCP-fallback regression tests X-Git-Tag: v9.21.23~52^2~2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=0c0e9056155ffd3c318689ffb5f7c9c7940748ec;p=thirdparty%2Fbind9.git Add pytest serve_stale TCP-fallback regression tests The serve_stale shell suite uses a UDP-only perl mock as its authoritative server. Now that the resolver escalates to TCP after repeated UDP timeouts, three steps in serve_stale/tests.sh that exercise resolver-query-timeout behaviour no longer reach the timeout — the TCP fallback short-circuits to SERVFAIL via `connection refused` on the perl mock. Move those scenarios to a new system test directory `bin/tests/system/serve_stale_tcp/` that uses a ControllableAsyncDnsServer mock listening on both UDP and TCP, so the resolver's TCP path is exercised end-to-end and the original timing semantics are preserved. Remove the corresponding shell steps from serve_stale/tests.sh. Assisted-by: Claude:claude-opus-4-7 --- diff --git a/bin/tests/system/serve_stale/tests.sh b/bin/tests/system/serve_stale/tests.sh index fe862b2949e..92826cfd1f4 100755 --- a/bin/tests/system/serve_stale/tests.sh +++ b/bin/tests/system/serve_stale/tests.sh @@ -1394,23 +1394,16 @@ status=$((status + ret)) sleep 2 -# Check that if we don't have stale data for a domain name, we will -# not answer anything until the resolver query timeout. -n=$((n + 1)) -echo_i "check notincache.example TXT times out (max-stale-ttl default) ($n)" -ret=0 -$DIG -p ${PORT} +tries=1 +timeout=2 @10.53.0.3 notfound.example TXT >dig.out.test$n 2>&1 && ret=1 -grep "timed out" dig.out.test$n >/dev/null || ret=1 -grep ";; no servers could be reached" dig.out.test$n >/dev/null || ret=1 -if [ $ret != 0 ]; then echo_i "failed"; fi -status=$((status + ret)) +# Note: the "notincache.example TXT times out" step (the original test +# 120) has been moved to the pytest suite in serve_stale_tcp/, since +# the resolver now legitimately escalates to TCP after repeated UDP +# timeouts and the perl mock ans2 only listens on UDP. echo_i "sending queries for tests $((n + 1))-$((n + 4))..." $DIG -p ${PORT} @10.53.0.3 data.example TXT >dig.out.test$((n + 1)) & $DIG -p ${PORT} @10.53.0.3 othertype.example CAA >dig.out.test$((n + 2)) & $DIG -p ${PORT} @10.53.0.3 nodata.example TXT >dig.out.test$((n + 3)) & $DIG -p ${PORT} @10.53.0.3 nxdomain.example TXT >dig.out.test$((n + 4)) & -$DIG -p ${PORT} @10.53.0.3 notfound.example TXT >dig.out.test$((n + 5)) & wait @@ -1452,18 +1445,9 @@ grep "ANSWER: 0," dig.out.test$n >/dev/null || ret=1 if [ $ret != 0 ]; then echo_i "failed"; fi status=$((status + ret)) -# The notfound.example check is different than nxdomain.example because -# we didn't send a prime query to add notfound.example to the cache. -# Independently, EDE 22 is sent as the authoritative server doesn't respond. -n=$((n + 1)) -echo_i "check notfound.example TXT (max-stale-ttl default) ($n)" -ret=0 -grep "status: SERVFAIL" dig.out.test$n >/dev/null || ret=1 -grep "EDE: 22 (No Reachable Authority)" dig.out.test$n >/dev/null || ret=1 -grep "EDE: 3 (Stale Answer)" dig.out.test$n >/dev/null && ret=1 -grep "ANSWER: 0," dig.out.test$n >/dev/null || ret=1 -if [ $ret != 0 ]; then echo_i "failed"; fi -status=$((status + ret)) +# Note: the "notfound.example TXT" SERVFAIL+EDE 22 step (the original +# test 125) has been moved to the pytest suite in serve_stale_tcp/; +# see the comment above where test 120 was removed. # # Now test server with serve-stale answers disabled. @@ -1922,10 +1906,19 @@ grep -F "#!TXT" ns5/named.stats.$n.cachedb >/dev/null && ret=1 status=$((status + ret)) if [ $ret != 0 ]; then echo_i "failed"; fi -############################################# -# Test for stale-answer-client-timeout off. # -############################################# -echo_i "test stale-answer-client-timeout (off)" +check_server_responds() { + $DIG -p ${PORT} @10.53.0.3 version.bind txt ch >dig.out.test$n || return 1 + grep "status: NOERROR" dig.out.test$n >/dev/null || return 1 +} + +############################################################## +# Test for stale-answer-client-timeout off and CNAME record. # +############################################################## +# The standalone "stale-answer-client-timeout off" test (the original +# test 163) has been moved to the pytest suite in serve_stale_tcp/; +# see the comment where test 120 was removed. Its configuration +# (named3.conf) is still used as the base for the CNAME case below. +echo_i "test stale-answer-client-timeout (0) and CNAME record" n=$((n + 1)) echo_i "updating ns3/named3.conf ($n)" @@ -1941,14 +1934,6 @@ rndc_reload ns3 10.53.0.3 if [ $ret != 0 ]; then echo_i "failed"; fi status=$((status + ret)) -# Send a query, auth server is disabled, we will enable it after a while in -# order to receive an answer before resolver-query-timeout expires. Since -# stale-answer-client-timeout is disabled we must receive an answer from -# authoritative server. -echo_i "sending query for test $((n + 2))" -$DIG -p ${PORT} @10.53.0.3 data.example TXT >dig.out.test$((n + 2)) & -sleep 1 - n=$((n + 1)) echo_i "enable responses from authoritative server ($n)" ret=0 @@ -1958,28 +1943,6 @@ grep "TXT.\"1\"" dig.out.test$n >/dev/null || ret=1 if [ $ret != 0 ]; then echo_i "failed"; fi status=$((status + ret)) -# Wait until dig is done. -wait - -n=$((n + 1)) -echo_i "check data.example TXT comes from authoritative server (stale-answer-client-timeout off) ($n)" -grep "status: NOERROR" dig.out.test$n >/dev/null || ret=1 -grep "EDE" dig.out.test$n >/dev/null && ret=1 -grep "ANSWER: 1," dig.out.test$n >/dev/null || ret=1 -grep "data\.example\..*[12].*IN.*TXT.*A text record with a 2 second ttl" dig.out.test$n >/dev/null || ret=1 -if [ $ret != 0 ]; then echo_i "failed"; fi -status=$((status + ret)) - -check_server_responds() { - $DIG -p ${PORT} @10.53.0.3 version.bind txt ch >dig.out.test$n || return 1 - grep "status: NOERROR" dig.out.test$n >/dev/null || return 1 -} - -############################################################## -# Test for stale-answer-client-timeout off and CNAME record. # -############################################################## -echo_i "test stale-answer-client-timeout (0) and CNAME record" - n=$((n + 1)) echo_i "prime cache shortttl.cname.example (stale-answer-client-timeout off) ($n)" ret=0 diff --git a/bin/tests/system/serve_stale_tcp/ans3/ans.py b/bin/tests/system/serve_stale_tcp/ans3/ans.py new file mode 100644 index 00000000000..7224942ac3e --- /dev/null +++ b/bin/tests/system/serve_stale_tcp/ans3/ans.py @@ -0,0 +1,22 @@ +# 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 ControllableAsyncDnsServer, ToggleResponsesCommand + + +def main() -> None: + server = ControllableAsyncDnsServer() + server.install_control_command(ToggleResponsesCommand()) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/serve_stale_tcp/ans3/example.db b/bin/tests/system/serve_stale_tcp/ans3/example.db new file mode 100644 index 00000000000..198cbf6d3b2 --- /dev/null +++ b/bin/tests/system/serve_stale_tcp/ans3/example.db @@ -0,0 +1,15 @@ +; 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. + +@ 300 SOA ns.example. root.example. 1 3600 1800 604800 300 +@ 300 NS ns.example. +ns.example. 300 A 10.53.0.3 +data 2 TXT "A text record with a 2 second ttl" diff --git a/bin/tests/system/serve_stale_tcp/ns1/named.conf.j2 b/bin/tests/system/serve_stale_tcp/ns1/named.conf.j2 new file mode 100644 index 00000000000..de5a836e486 --- /dev/null +++ b/bin/tests/system/serve_stale_tcp/ns1/named.conf.j2 @@ -0,0 +1,40 @@ +/* + * 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. + */ + +key rndc_key { + secret "1234abcd8765"; + algorithm @DEFAULT_HMAC@; +}; + +controls { + inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +options { + port @PORT@; + pid-file "named.pid"; + + listen-on { 10.53.0.1; }; + listen-on-v6 { none; }; + query-source address 10.53.0.1; + notify-source 10.53.0.1; + transfer-source 10.53.0.1; + + recursion no; + dnssec-validation no; +}; + +zone "." { + type primary; + file "root.db"; +}; diff --git a/bin/tests/system/serve_stale_tcp/ns1/root.db b/bin/tests/system/serve_stale_tcp/ns1/root.db new file mode 100644 index 00000000000..97e3fec66bf --- /dev/null +++ b/bin/tests/system/serve_stale_tcp/ns1/root.db @@ -0,0 +1,16 @@ +; 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. + +. 300 SOA . . 0 0 0 0 0 +. 300 NS ns.nil. +ns.nil. 300 A 10.53.0.1 +example. 300 NS ns.example. +ns.example. 300 A 10.53.0.3 diff --git a/bin/tests/system/serve_stale_tcp/ns2/named.conf.j2 b/bin/tests/system/serve_stale_tcp/ns2/named.conf.j2 new file mode 100644 index 00000000000..32ed90cf4c4 --- /dev/null +++ b/bin/tests/system/serve_stale_tcp/ns2/named.conf.j2 @@ -0,0 +1,48 @@ +/* + * 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. + */ + +key rndc_key { + secret "1234abcd8765"; + algorithm @DEFAULT_HMAC@; +}; + +controls { + inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +options { + port @PORT@; + pid-file "named.pid"; + + listen-on { 10.53.0.2; }; + listen-on-v6 { none; }; + query-source address 10.53.0.2; + notify-source 10.53.0.2; + transfer-source 10.53.0.2; + + recursion yes; + dnssec-validation no; + qname-minimization off; + + stale-answer-enable yes; + stale-cache-enable yes; + stale-answer-ttl 3; + stale-refresh-time 0; + max-stale-ttl 3600; + stale-answer-client-timeout off; +}; + +zone "." { + type hint; + file "../../_common/root.hint"; +}; diff --git a/bin/tests/system/serve_stale_tcp/tests_serve_stale_tcp.py b/bin/tests/system/serve_stale_tcp/tests_serve_stale_tcp.py new file mode 100644 index 00000000000..57d6be55747 --- /dev/null +++ b/bin/tests/system/serve_stale_tcp/tests_serve_stale_tcp.py @@ -0,0 +1,108 @@ +# 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 threading +import time + +import dns.edns +import dns.exception +import dns.name +import dns.rdataclass +import dns.rdatatype +import pytest + +import isctest + +pytestmark = pytest.mark.extra_artifacts( + [ + "ans*/ans.run", + ] +) + + +def _toggle(mode: str) -> None: + msg = isctest.query.create(f"{mode}.send-responses._control.", "TXT", dnssec=False) + isctest.query.udp(msg, "10.53.0.3", attempts=1) + + +def test_no_stale_data_times_out(): + """Verify the resolver does not answer until the query timeout. + + With the authoritative server unresponsive and the queried name + absent from the cache, dig must time out instead of receiving a + fast SERVFAIL (the original test 120 in serve_stale/tests.sh). + """ + + _toggle("disable") + msg = isctest.query.create("notincache.example.", "TXT", dnssec=False) + start = time.monotonic() + with pytest.raises(dns.exception.Timeout): + isctest.query.udp(msg, "10.53.0.2", timeout=3, attempts=1) + assert time.monotonic() - start >= 3 + + +def test_servfail_with_ede22(): + """Verify SERVFAIL carries EDE 22 (and not EDE 3) when auth is unreachable. + + With the authoritative server unresponsive and no cached data to + serve stale, the resolver must return SERVFAIL with EDE 22 (No + Reachable Authority) and must not attach EDE 3 (Stale Answer) + (the original test 125 in serve_stale/tests.sh). + """ + + _toggle("disable") + msg = isctest.query.create("notfound.example.", "TXT", dnssec=False) + res = isctest.query.udp(msg, "10.53.0.2", timeout=15, attempts=1) + isctest.check.servfail(res) + isctest.check.ede(res, dns.edns.EDECode.NO_REACHABLE_AUTHORITY) + assert not any( + opt.otype == dns.edns.OptionType.EDE + and opt.code == dns.edns.EDECode.STALE_ANSWER + for opt in res.options + ), "unexpected stale-answer EDE in SERVFAIL response" + assert len(res.answer) == 0 + + +def test_authoritative_answer_after_reenable(): + """Verify the resolver waits for auth to recover instead of failing fast. + + Prime the cache, let the TTL expire, disable the authoritative + server, issue a query, and re-enable the authoritative server + while the query is still in flight. The resolver must return an + authoritative NOERROR answer with no EDE attached, not a stale + answer or SERVFAIL (the original test 163 in serve_stale/tests.sh). + """ + + _toggle("enable") + msg = isctest.query.create("data.example.", "TXT", dnssec=False) + isctest.check.noerror(isctest.query.udp(msg, "10.53.0.2", timeout=5)) + + # allow the 2s TTL to expire + time.sleep(3) + + _toggle("disable") + + timer = threading.Timer(1.0, _toggle, args=("enable",)) + timer.start() + try: + res = isctest.query.udp(msg, "10.53.0.2", timeout=15, attempts=1) + finally: + timer.join() + + isctest.check.noerror(res) + isctest.check.noede(res) + answer = res.find_rrset( + res.answer, + dns.name.from_text("data.example."), + dns.rdataclass.IN, + dns.rdatatype.TXT, + ) + assert "A text record with a 2 second ttl" in str(answer[0])