]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add AXFR regression test for SIG covers preservation
authorOndřej Surý <ondrej@isc.org>
Thu, 16 Apr 2026 11:25:03 +0000 (13:25 +0200)
committerOndřej Surý <ondrej@sury.org>
Mon, 20 Apr 2026 09:21:14 +0000 (11:21 +0200)
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.

(cherry picked from commit e9f880c78ff3c2f799763628060eca0f0d64e70f)

bin/tests/system/nsupdate/ans11/ans.py [new file with mode: 0644]
bin/tests/system/nsupdate/ns6/named.conf.in
bin/tests/system/nsupdate/tests_tcp_self_sig.py

diff --git a/bin/tests/system/nsupdate/ans11/ans.py b/bin/tests/system/nsupdate/ans11/ans.py
new file mode 100644 (file)
index 0000000..177a889
--- /dev/null
@@ -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()
index e2950aa3b7ecc192911b37414303324196897b06..54f9c8ff8604f217d9ca4c25d9dc746a9d4de3a5 100644 (file)
@@ -49,3 +49,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
+};
index 574497338c6d1a5f821b2ef01f263265bb90a144..6e83aded29110aa20bac065c69f67ffd07fe15a5 100644 (file)
@@ -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:
+    #   <owner>. <ttl> IN SIG <covered> <alg> <labels> ...
+    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.