From: Ondřej Surý Date: Thu, 16 Apr 2026 11:25:03 +0000 (+0200) Subject: Add AXFR regression test for SIG covers preservation X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=e9f880c78ff3c2f799763628060eca0f0d64e70f;p=thirdparty%2Fbind9.git Add AXFR regression test for SIG covers preservation diff.c rdata_covers() runs on both dns_diff_apply (IXFR, ns/update.c dynamic updates) and dns_diff_load (AXFR). After the previous commit refused SIG and NXT in dynamic updates, the AXFR path remains the most natural way to drive legacy SIG records into a secondary's zone DB and regression-gate the rdata_covers() fix. The test adds ans11 as an AsyncDnsServer primary for a small zone whose AXFR carries two SIG rdatas at the same owner with different covered types (A, MX) and different TTLs (600, 1200), and declares ns6 a secondary of that zone. With the bug present, dns_diff_load groups both tuples at typepair (SIG, 0) and the MX-covering record inherits the first-seen TTL (600); the fix keeps them at (SIG, A) and (SIG, MX) with their original TTLs. rndc dumpdb -zones on the secondary is used to inspect stored state directly, because the wire-level SIG query response merges same-(owner,type,class) RRs and masks the per-rdataset TTLs. --- diff --git a/bin/tests/system/nsupdate/ans11/ans.py b/bin/tests/system/nsupdate/ans11/ans.py new file mode 100644 index 00000000000..177a889e3c1 --- /dev/null +++ b/bin/tests/system/nsupdate/ans11/ans.py @@ -0,0 +1,117 @@ +# 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. + +""" +GL#5818 Finding 1 regression support — AsyncDnsServer primary. + +Serves a minimal zone "sigaxfr.nil." whose AXFR carries two SIG records +at the same owner with different covered types (A and MX) and different +TTLs (600 and 1200). A buggy secondary running dns_diff_load() with +rdata_covers() that only recognises RRSIG will file both rdatas under +typepair (SIG, 0) with the first tuple's TTL; a fixed secondary keeps +them under (SIG, A) and (SIG, MX) with their distinct TTLs. +""" + +from collections.abc import AsyncGenerator + +import dns.name +import dns.rcode +import dns.rdata +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +from isctest.asyncserver import ( + AsyncDnsServer, + DnsResponseSend, + DomainHandler, + QueryContext, + ResponseAction, +) + +ZONE = dns.name.from_text("sigaxfr.nil.") +NS_NAME = dns.name.from_text("ns.sigaxfr.nil.") +HOST = dns.name.from_text("host.sigaxfr.nil.") + +SOA_TEXT = "ns.sigaxfr.nil. hostmaster.sigaxfr.nil. 1 3600 1200 604800 3600" + + +def _make_sig_rdata(covered_text): + """Produce a legacy SIG (24) rdata via RRSIG (46) round-trip.""" + rrsig = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.RRSIG, covered_text) + wire = rrsig.to_digestable() + return dns.rdata.from_wire(dns.rdataclass.IN, dns.rdatatype.SIG, wire, 0, len(wire)) + + +class SigAxfrServer(DomainHandler): + """Serve SOA and AXFR for sigaxfr.nil.; other qtypes get NOERROR/NODATA.""" + + domains = ["sigaxfr.nil."] + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[ResponseAction, None]: + soa_rrset = dns.rrset.from_text( + ZONE, 3600, dns.rdataclass.IN, dns.rdatatype.SOA, SOA_TEXT + ) + + if qctx.qtype == dns.rdatatype.SOA: + resp = qctx.response + resp.answer.append(soa_rrset) + yield DnsResponseSend(resp) + return + + if qctx.qtype != dns.rdatatype.AXFR: + # Other types: empty NOERROR response. + yield DnsResponseSend(qctx.response) + return + + # AXFR: opening SOA, NS, NS's A, two SIG RRs at the same owner + # with distinct covered types and TTLs, closing SOA. + resp = qctx.response + resp.answer.append(soa_rrset) + + ns_rrset = dns.rrset.from_text( + ZONE, 3600, dns.rdataclass.IN, dns.rdatatype.NS, str(NS_NAME) + ) + resp.answer.append(ns_rrset) + + a_rrset = dns.rrset.from_text( + NS_NAME, 3600, dns.rdataclass.IN, dns.rdatatype.A, "10.53.0.11" + ) + resp.answer.append(a_rrset) + + sig_a = _make_sig_rdata("A 6 2 600 20260331170000 20260318160000 21831 . 0000") + sig_a_rrset = dns.rrset.RRset(HOST, dns.rdataclass.IN, dns.rdatatype.SIG) + sig_a_rrset.add(sig_a, ttl=600) + resp.answer.append(sig_a_rrset) + + sig_mx = _make_sig_rdata( + "MX 6 2 1200 20260331170000 20260318160000 21831 . 0000" + ) + sig_mx_rrset = dns.rrset.RRset(HOST, dns.rdataclass.IN, dns.rdatatype.SIG) + sig_mx_rrset.add(sig_mx, ttl=1200) + resp.answer.append(sig_mx_rrset) + + # Closing SOA terminates the AXFR. + resp.answer.append(soa_rrset) + + yield DnsResponseSend(resp) + + +def main() -> None: + server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR) + server.install_response_handler(SigAxfrServer()) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/nsupdate/ns6/named.conf.j2 b/bin/tests/system/nsupdate/ns6/named.conf.j2 index 16d77b98325..808bba4bb73 100644 --- a/bin/tests/system/nsupdate/ns6/named.conf.j2 +++ b/bin/tests/system/nsupdate/ns6/named.conf.j2 @@ -36,3 +36,10 @@ zone "2.0.0.2.ip6.arpa" { file "2.0.0.2.ip6.addr.db"; update-policy { grant * 6to4-self . NS(10) DS(4); }; }; + +zone "sigaxfr.nil" { + type secondary; + primaries { 10.53.0.11; }; + file "sigaxfr.bk"; + request-ixfr no; # ans11 serves AXFR only +}; diff --git a/bin/tests/system/nsupdate/tests_tcp_self_sig.py b/bin/tests/system/nsupdate/tests_tcp_self_sig.py index 574497338c6..6e83aded291 100644 --- a/bin/tests/system/nsupdate/tests_tcp_self_sig.py +++ b/bin/tests/system/nsupdate/tests_tcp_self_sig.py @@ -31,6 +31,8 @@ diff.c:rdata_covers() bug via inbound zone transfer is covered separately by the AXFR-based regression test in this file. """ +import time + import dns.rcode import dns.rdata import dns.rdataclass @@ -102,6 +104,74 @@ def test_tcp_self_sig_record(ns6): assert not stored, "SIG record was stored despite REFUSED response" +def test_sig_covers_preserved_via_axfr(ns6): + """Regression test for GL#5818 Finding 1, reached via AXFR. + + ans11 serves an AXFR for sigaxfr.nil. containing two SIG rdatas at + the same owner with different covered types (A, MX) and different + TTLs (600, 1200). ns6 pulls the zone via dns_diff_load(), which + calls diff.c rdata_covers(); before the fix that helper returned 0 + for SIG, so both tuples were grouped and filed under typepair + (SIG, 0) with the first TTL (600) — the MX-covering record's TTL + (1200) was silently dropped. With the fix the records land in + distinct typepairs and both TTLs survive. + + rndc dumpdb is used to inspect the secondary's stored state + directly; the wire-level response can merge same-(owner,type,class) + RRs and mask the difference. + """ + zone = "sigaxfr.nil" + owner = f"host.{zone}." + dump_path = ns6.directory / "named_dump.db" + + # ns6 may have tried to SOA-poll ans11 before it was listening; force + # a fresh refresh attempt and wait for the transfer to complete. + with ns6.watch_log_from_here() as watcher: + ns6.rndc(f"refresh {zone}") + watcher.wait_for_line(f"zone {zone}/IN: transferred serial 1") + + # Remove any stale dump and ask named for a fresh one. + if dump_path.exists(): + dump_path.unlink() + ns6.rndc("dumpdb -zones") + + # rndc dumpdb is asynchronous; wait for the file and for its + # trailing "Dump complete" marker. + deadline_marker = "; Dump complete" + for _ in range(50): + if dump_path.exists(): + text = dump_path.read_text() + if deadline_marker in text: + break + time.sleep(0.1) + else: + raise AssertionError(f"{dump_path} never contained {deadline_marker!r}") + + # Collect every SIG line for the owner from the dump. Format is: + # . IN SIG ... + sig_lines = [] + for line in text.splitlines(): + fields = line.split() + if len(fields) < 6: + continue + if not fields[0].lower().startswith("host.sigaxfr.nil"): + continue + if fields[2] != "IN" or fields[3] != "SIG": + continue + sig_lines.append(fields) + + assert ( + len(sig_lines) == 2 + ), f"expected 2 SIG records at {owner}, got {len(sig_lines)}: {sig_lines}" + + ttl_by_covers = {fields[4]: int(fields[1]) for fields in sig_lines} + assert ttl_by_covers == {"A": 600, "MX": 1200}, ( + f"SIG records lost their covers/TTL binding: {ttl_by_covers}. With " + "the Finding 1 bug both records are filed under typepair (SIG, 0) " + "and share the first-seen TTL (600)." + ) + + def test_tcp_self_nxt_record(ns6): """NXT (type 30) updates must be refused at the front door.