]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Test that a fresh negative cache entry is not refreshed
authorOndřej Surý <ondrej@isc.org>
Fri, 10 Jul 2026 05:57:58 +0000 (07:57 +0200)
committerOndřej Surý <ondrej@isc.org>
Fri, 10 Jul 2026 08:41:24 +0000 (10:41 +0200)
The existing serve-stale tests all use negative answers with a two
second TTL, because they are there to exercise stale data.  Nothing
covered the far more common case of a negative answer that is still
fresh, which is how the needless refresh went unnoticed.

ans2 grows a NODATA and an NXDOMAIN name backed by a SOA with a 600
second TTL and MINIMUM, so the cached entry cannot go stale while the
test runs, and the test counts the queries that reach ans2: priming the
cache may send one, the repeated client queries must send none.

Assisted-by: Claude:claude-opus-4-8
bin/tests/system/serve_stale/ans2/ans.pl
bin/tests/system/serve_stale/tests_serve_stale_ncache.py [new file with mode: 0644]

index b437f732e1f595e3b3d0e4f7bdbdb23352c87d5c..6dd827cf714322ebde179bbb478e23edae57f50b 100644 (file)
@@ -70,6 +70,12 @@ my $LONGTXT = "longttl.example 600 IN TXT \"A text record with a 600 second ttl\
 my $CAA = "othertype.example 2 IN CAA 0 issue \"ca1.example.net\"";
 my $negSOA = "example 2 IN SOA . . 0 0 0 0 300";
 my $ssnegSOA = "delegated.serve.stale 2 IN SOA . . 0 0 0 0 300";
+#
+# A negative answer that stays fresh for the whole run of a test, so that a
+# resolver refreshing it can only be doing so because it wrongly considers
+# the cached entry stale.
+#
+my $LONGNEGSOA = "example 600 IN SOA . . 0 0 0 0 600";
 my $CNAME = "cname.example 7 IN CNAME target.example";
 my $TARGET = "target.example 9 IN A $localaddr";
 my $SHORTCNAME = "shortttl.cname.example 1 IN CNAME longttl.target.example";
@@ -157,6 +163,14 @@ sub reply_handler {
        my $rr = new Net::DNS::RR($negSOA);
        push @auth, $rr;
        $rcode = "NOERROR";
+    } elsif ($qname eq "longttl-nodata.example") {
+       my $rr = new Net::DNS::RR($LONGNEGSOA);
+       push @auth, $rr;
+       $rcode = "NOERROR";
+    } elsif ($qname eq "longttl-nxdomain.example") {
+       my $rr = new Net::DNS::RR($LONGNEGSOA);
+       push @auth, $rr;
+       $rcode = "NXDOMAIN";
     } elsif ($qname eq "data.example") {
        if ($qtype eq "TXT") {
            my $rr = new Net::DNS::RR($TXT);
diff --git a/bin/tests/system/serve_stale/tests_serve_stale_ncache.py b/bin/tests/system/serve_stale/tests_serve_stale_ncache.py
new file mode 100644 (file)
index 0000000..975c9c6
--- /dev/null
@@ -0,0 +1,105 @@
+# 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.
+
+"""
+With "stale-answer-client-timeout 0" a negative cache entry which is still
+within its TTL must be answered straight from the cache.  Only a stale entry
+may trigger a refresh of the RRset.
+"""
+
+import time
+
+import dns.message
+import dns.rcode
+import pytest
+
+from isctest.instance import AnsInstance, NamedInstance
+
+import isctest
+
+pytestmark = pytest.mark.extra_artifacts(
+    [
+        "ans*/ans.run",
+        "ns*/named.run",
+        "ns*/root.bk",
+    ]
+)
+
+# ans2 answers both of these from a SOA with a 600 second TTL and a 600
+# second MINIMUM, so the negative answer stays fresh for the whole test.
+NODATA_NAME = "longttl-nodata.example."
+NXDOMAIN_NAME = "longttl-nxdomain.example."
+
+
+def upstream_queries(ans2: AnsInstance, qname: str) -> int:
+    """Number of TXT queries for `qname` which reached the authoritative server."""
+    return len(ans2.log.grep(f"request: {qname.rstrip('.')}/TXT"))
+
+
+def enable_ans2_responses(ans2: AnsInstance) -> None:
+    isctest.query.udp(dns.message.make_query("enable.", "TXT"), ans2.ip)
+
+
+@pytest.fixture(name="resolver")
+def resolver_fixture(servers: dict[str, NamedInstance]) -> NamedInstance:
+    ns3 = servers["ns3"]
+    enable_ans2_responses(servers["ans2"])
+    ns3.rndc("serve-stale on")
+    ns3.rndc("flush")
+    return ns3
+
+
+@pytest.mark.parametrize(
+    "qname,expected_rcode",
+    [
+        pytest.param(NODATA_NAME, dns.rcode.NOERROR, id="nodata"),
+        pytest.param(NXDOMAIN_NAME, dns.rcode.NXDOMAIN, id="nxdomain"),
+    ],
+)
+def test_fresh_ncache_entry_is_not_refreshed(
+    servers: dict[str, NamedInstance],
+    resolver: NamedInstance,
+    qname: str,
+    expected_rcode: dns.rcode.Rcode,
+) -> None:
+    ans2 = servers["ans2"]
+    msg = dns.message.make_query(qname, "TXT")
+
+    # Prime the cache; this is the one query the authoritative server is
+    # allowed to see.
+    res = isctest.query.udp(msg, resolver.ip)
+    isctest.check.rcode(res, expected_rcode)
+    assert not res.answer
+
+    primed = upstream_queries(ans2, qname)
+    assert primed == 1, "priming the cache should send exactly one upstream query"
+
+    # Repeat the query.  The negative answer is nowhere near its expiry, so
+    # every one of these has to be a cache hit, and none of them may mark the
+    # answer as stale.
+    for _ in range(3):
+        res = isctest.query.udp(msg, resolver.ip)
+        isctest.check.rcode(res, expected_rcode)
+        isctest.check.noede(res)
+
+    # Refreshing stale data is detached from the client query, so give it a
+    # moment to attempt to reach ans2 before concluding it never happened.
+    time.sleep(1)
+
+    assert (
+        upstream_queries(ans2, qname) == primed
+    ), "a negative cache entry within its TTL was refreshed upstream"
+
+    prohibited_log = (
+        f"{qname.rstrip('.')} TXT stale answer used, "
+        "an attempt to refresh the RRset will still be made"
+    )
+    assert prohibited_log not in resolver.log