]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add pytest serve_stale TCP-fallback regression tests
authorOndřej Surý <ondrej@isc.org>
Thu, 14 May 2026 15:10:19 +0000 (17:10 +0200)
committerOndřej Surý <ondrej@isc.org>
Tue, 19 May 2026 09:18:30 +0000 (11:18 +0200)
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
bin/tests/system/serve_stale/tests.sh
bin/tests/system/serve_stale_tcp/ans3/ans.py [new file with mode: 0644]
bin/tests/system/serve_stale_tcp/ans3/example.db [new file with mode: 0644]
bin/tests/system/serve_stale_tcp/ns1/named.conf.j2 [new file with mode: 0644]
bin/tests/system/serve_stale_tcp/ns1/root.db [new file with mode: 0644]
bin/tests/system/serve_stale_tcp/ns2/named.conf.j2 [new file with mode: 0644]
bin/tests/system/serve_stale_tcp/tests_serve_stale_tcp.py [new file with mode: 0644]

index fe862b2949e1edf1da3f8cdd7a1732908e88dfbd..92826cfd1f413126d636f53fbf959a023a0f7a1c 100755 (executable)
@@ -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 (file)
index 0000000..7224942
--- /dev/null
@@ -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 (file)
index 0000000..198cbf6
--- /dev/null
@@ -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 (file)
index 0000000..de5a836
--- /dev/null
@@ -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 (file)
index 0000000..97e3fec
--- /dev/null
@@ -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 (file)
index 0000000..32ed90c
--- /dev/null
@@ -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 (file)
index 0000000..57d6be5
--- /dev/null
@@ -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])