From 4fa5b6a95a7c8ea61d785ca5a03d160401ec6fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sur=C3=BD?= Date: Fri, 10 Jul 2026 07:57:58 +0200 Subject: [PATCH] Test that a fresh negative cache entry is not refreshed 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 (cherry picked from commit 60b3cc0eca6432d9acc0ae58c6c1b48cc707e227) --- bin/tests/system/serve_stale/ans2/ans.pl | 14 +++ .../serve_stale/tests_serve_stale_ncache.py | 105 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 bin/tests/system/serve_stale/tests_serve_stale_ncache.py diff --git a/bin/tests/system/serve_stale/ans2/ans.pl b/bin/tests/system/serve_stale/ans2/ans.pl index b437f732e1..6dd827cf71 100644 --- a/bin/tests/system/serve_stale/ans2/ans.pl +++ b/bin/tests/system/serve_stale/ans2/ans.pl @@ -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 index 0000000000..975c9c68ac --- /dev/null +++ b/bin/tests/system/serve_stale/tests_serve_stale_ncache.py @@ -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