From: Alessio Podda Date: Mon, 23 Feb 2026 10:04:47 +0000 (+0100) Subject: Add reproducer for #5759 X-Git-Tag: v9.20.20~10^2~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=0041c5756ee9003a817009bc03d7932f0ef5d552;p=thirdparty%2Fbind9.git Add reproducer for #5759 Adds a test case that runs IXFR while leaving an rdataset unchanged. --- diff --git a/bin/tests/system/ixfr-nonminimal/ans2/ans.py b/bin/tests/system/ixfr-nonminimal/ans2/ans.py new file mode 100644 index 00000000000..207b5e49ffd --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/ans2/ans.py @@ -0,0 +1,200 @@ +""" +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 abc + +import dns.rcode +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +from typing import AsyncGenerator, Collection, Iterable + +from isctest.asyncserver import ( + ControllableAsyncDnsServer, + DnsResponseSend, + QueryContext, + ResponseHandler, + SwitchControlCommand, +) + + +def rrset(owner: str, rdtype: dns.rdatatype.RdataType, rdata: str) -> dns.rrset.RRset: + return dns.rrset.from_text( + owner, + 300, + dns.rdataclass.IN, + rdtype, + rdata, + ) + + +def soa(serial: int, *, owner: str = "nil.") -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.SOA, + f"ns.nil. root.nil. {serial} 300 300 604800 300", + ) + + +def ns() -> dns.rrset.RRset: + return rrset( + "nil.", + dns.rdatatype.NS, + "ns.nil.", + ) + + +def a(address: str, *, owner: str) -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.A, + address, + ) + + +def txt(data: str, *, owner: str = "nil.") -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.TXT, + f'"{data}"', + ) + + +class SoaHandler(ResponseHandler): + def __init__(self, serial: int): + self._serial = serial + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.SOA + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + qctx.response.answer.append(soa(self._serial)) + yield DnsResponseSend(qctx.response) + + +class AxfrHandler(ResponseHandler): + @property + @abc.abstractmethod + def answers(self) -> Iterable[Collection[dns.rrset.RRset]]: + """ + Answer sections of response packets sent in response to + AXFR queries. + """ + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.AXFR + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + for answer in self.answers: + response = qctx.prepare_new_response() + for rrset_ in answer: + response.answer.append(rrset_) + yield DnsResponseSend(response) + + +class IxfrHandler(ResponseHandler): + @property + @abc.abstractmethod + def answer(self) -> Collection[dns.rrset.RRset]: + """ + Answer section of a response packet sent in response to + IXFR queries. + """ + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.IXFR + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + for rrset_ in self.answer: + qctx.response.answer.append(rrset_) + yield DnsResponseSend(qctx.response) + + +class InitialAxfrHandler(AxfrHandler): + answers = ( + (soa(1),), + ( + ns(), + txt("initial AXFR"), + a("10.0.0.61", owner="a.nil."), + a("10.0.0.62", owner="b.nil."), + ), + (soa(1),), + ) + + +class InitialIxfrHandler(IxfrHandler): + answer = ( + soa(1), + ns(), + txt("initial AXFR"), + a("10.0.0.61", owner="a.nil."), + a("10.0.0.62", owner="b.nil."), + soa(1), + ) + + +class UnchangedIxfrHandler(IxfrHandler): + """ + IXFR from serial 1 -> 2. + + The diff deletes nothing for the A rrset at a.nil., but re-adds + "a.nil. A 10.0.0.61" which already exists. This causes the merge + to find no new records, triggering DNS_R_UNCHANGED. + + We also add a new TXT record so the IXFR has at least one real + change (the SOA serial bump + TXT addition). + """ + + answer = ( + soa(2), + soa(1), + soa(2), + txt("unchanged ixfr test"), + a("10.0.0.61", owner="a.nil."), # already exists -> DNS_R_UNCHANGED + soa(2), + ) + + +def main() -> None: + server = ControllableAsyncDnsServer( + default_aa=True, default_rcode=dns.rcode.NOERROR + ) + switch_command = SwitchControlCommand( + { + "initial_axfr": ( + SoaHandler(1), + InitialIxfrHandler(), + InitialAxfrHandler(), + ), + "unchanged_ixfr": ( + SoaHandler(2), + UnchangedIxfrHandler(), + ), + } + ) + server.install_control_command(switch_command) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/ixfr-nonminimal/ans4/ans.py b/bin/tests/system/ixfr-nonminimal/ans4/ans.py new file mode 100644 index 00000000000..769600ec94c --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/ans4/ans.py @@ -0,0 +1,199 @@ +""" +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 abc + +import dns.rcode +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +from typing import AsyncGenerator, Collection, Iterable + +from isctest.asyncserver import ( + ControllableAsyncDnsServer, + DnsResponseSend, + QueryContext, + ResponseHandler, + SwitchControlCommand, +) + + +def rrset(owner: str, rdtype: dns.rdatatype.RdataType, rdata: str) -> dns.rrset.RRset: + return dns.rrset.from_text( + owner, + 300, + dns.rdataclass.IN, + rdtype, + rdata, + ) + + +def soa(serial: int, *, owner: str = "nil.") -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.SOA, + f"ns.nil. root.nil. {serial} 300 300 604800 300", + ) + + +def ns() -> dns.rrset.RRset: + return rrset( + "nil.", + dns.rdatatype.NS, + "ns.nil.", + ) + + +def a(address: str, *, owner: str) -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.A, + address, + ) + + +def txt(data: str, *, owner: str = "nil.") -> dns.rrset.RRset: + return rrset( + owner, + dns.rdatatype.TXT, + f'"{data}"', + ) + + +class SoaHandler(ResponseHandler): + def __init__(self, serial: int): + self._serial = serial + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.SOA + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + qctx.response.answer.append(soa(self._serial)) + yield DnsResponseSend(qctx.response) + + +class AxfrHandler(ResponseHandler): + @property + @abc.abstractmethod + def answers(self) -> Iterable[Collection[dns.rrset.RRset]]: + """ + Answer sections of response packets sent in response to + AXFR queries. + """ + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.AXFR + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + for answer in self.answers: + response = qctx.prepare_new_response() + for rrset_ in answer: + response.answer.append(rrset_) + yield DnsResponseSend(response) + + +class IxfrHandler(ResponseHandler): + @property + @abc.abstractmethod + def answer(self) -> Collection[dns.rrset.RRset]: + """ + Answer section of a response packet sent in response to + IXFR queries. + """ + raise NotImplementedError + + def match(self, qctx: QueryContext) -> bool: + return qctx.qtype == dns.rdatatype.IXFR + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + for rrset_ in self.answer: + qctx.response.answer.append(rrset_) + yield DnsResponseSend(qctx.response) + + +class InitialAxfrHandler(AxfrHandler): + answers = ( + (soa(1),), + ( + ns(), + txt("initial AXFR"), + a("10.0.0.62", owner="b.nil."), + ), + (soa(1),), + ) + + +class InitialIxfrHandler(IxfrHandler): + answer = ( + soa(1), + ns(), + txt("initial AXFR"), + a("10.0.0.62", owner="b.nil."), + soa(1), + ) + + +class NxrrsetIxfrHandler(IxfrHandler): + """ + IXFR from serial 1 -> 2. + + Deletes the only A record at b.nil. (10.0.0.62). Since this is + the last record in that rdataset, subtractrdataset() returns + DNS_R_NXRRSET. + + Also adds a TXT record so the zone has a real change beyond the + SOA serial bump. + """ + + answer = ( + soa(2), + soa(1), + a("10.0.0.62", owner="b.nil."), + txt("initial AXFR"), + soa(2), + txt("nxrrset ixfr test"), + soa(2), + ) + + +def main() -> None: + server = ControllableAsyncDnsServer( + default_aa=True, default_rcode=dns.rcode.NOERROR + ) + switch_command = SwitchControlCommand( + { + "initial_axfr": ( + SoaHandler(1), + InitialIxfrHandler(), + InitialAxfrHandler(), + ), + "nxrrset_ixfr": ( + SoaHandler(2), + NxrrsetIxfrHandler(), + ), + } + ) + server.install_control_command(switch_command) + server.run() + + +if __name__ == "__main__": + main() diff --git a/bin/tests/system/ixfr-nonminimal/ns1/named.conf.j2 b/bin/tests/system/ixfr-nonminimal/ns1/named.conf.j2 new file mode 100644 index 00000000000..76181472766 --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/ns1/named.conf.j2 @@ -0,0 +1,41 @@ +/* + * 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. + */ + +options { + query-source address 10.53.0.1; + notify-source 10.53.0.1; + transfer-source 10.53.0.1; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.1; }; + listen-on-v6 { none; }; + allow-transfer { any; }; + recursion no; + notify yes; + dnssec-validation no; +}; + +key rndc_key { + secret "1234abcd8765"; + algorithm @DEFAULT_HMAC@; +}; + +controls { + inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +zone "nil" { + type secondary; + file "nil.db"; + primaries { 10.53.0.2; }; +}; diff --git a/bin/tests/system/ixfr-nonminimal/ns3/named.conf.j2 b/bin/tests/system/ixfr-nonminimal/ns3/named.conf.j2 new file mode 100644 index 00000000000..500cc81d22d --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/ns3/named.conf.j2 @@ -0,0 +1,41 @@ +/* + * 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. + */ + +options { + query-source address 10.53.0.3; + notify-source 10.53.0.3; + transfer-source 10.53.0.3; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.3; }; + listen-on-v6 { none; }; + allow-transfer { any; }; + recursion no; + notify yes; + dnssec-validation no; +}; + +key rndc_key { + secret "1234abcd8765"; + algorithm @DEFAULT_HMAC@; +}; + +controls { + inet 10.53.0.3 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +zone "nil" { + type secondary; + file "nil.db"; + primaries { 10.53.0.4; }; +}; diff --git a/bin/tests/system/ixfr-nonminimal/prereq.sh b/bin/tests/system/ixfr-nonminimal/prereq.sh new file mode 100644 index 00000000000..4190753f517 --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/prereq.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# 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. + +. ../conf.sh + +exit 0 diff --git a/bin/tests/system/ixfr-nonminimal/setup.sh b/bin/tests/system/ixfr-nonminimal/setup.sh new file mode 100644 index 00000000000..fd733eff3d9 --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/setup.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# 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. + +. ../conf.sh diff --git a/bin/tests/system/ixfr-nonminimal/tests.sh b/bin/tests/system/ixfr-nonminimal/tests.sh new file mode 100644 index 00000000000..bd8d77a8c8f --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/tests.sh @@ -0,0 +1,86 @@ +#!/bin/sh + +# 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. + +set -e + +. ../conf.sh + +wait_for_serial() ( + $DIG $DIGOPTS "@$1" "$2" SOA >"$4" + serial=$(awk '$4 == "SOA" { print $7 }' "$4") + [ "$3" -eq "${serial:--1}" ] +) + +status=0 +n=0 + +DIGOPTS="+tcp +noadd +nosea +nostat +noquest +nocomm +nocmd -p ${PORT}" +RNDCCMD="$RNDC -p ${CONTROLPORT} -c ../_common/rndc.conf -s" + +switch_responses() { + $DIG $DIGOPTS "@$1" "${2}.switch._control." TXT +time=5 +tries=1 +tcp >/dev/null 2>&1 +} + +# Set up initial_axfr handlers and trigger transfers +switch_responses 10.53.0.2 "initial_axfr" +switch_responses 10.53.0.4 "initial_axfr" +$RNDCCMD 10.53.0.1 refresh nil | sed 's/^/ns1 /' | cat_i +$RNDCCMD 10.53.0.3 refresh nil | sed 's/^/ns3 /' | cat_i + +# Wait for initial AXFRs to complete +retry_quiet 10 wait_for_serial 10.53.0.1 nil. 1 dig.out.ns1.axfr || { + echo_i "ns1 initial AXFR failed" + exit 1 +} +retry_quiet 10 wait_for_serial 10.53.0.3 nil. 1 dig.out.ns3.axfr || { + echo_i "ns3 initial AXFR failed" + exit 1 +} + +# Test 1: IXFR that re-adds an existing record -> DNS_R_UNCHANGED +n=$((n + 1)) +echo_i "testing IXFR with unchanged rdataset ($n)" +ret=0 + +switch_responses 10.53.0.2 "unchanged_ixfr" +sleep 1 + +$RNDCCMD 10.53.0.1 refresh nil | sed 's/^/ns1 /' | cat_i +sleep 2 + +$DIG $DIGOPTS @10.53.0.1 nil. TXT | grep 'unchanged ixfr test' >/dev/null || ret=1 +$DIG $DIGOPTS @10.53.0.1 a.nil. A | grep '10.0.0.61' >/dev/null || ret=1 +grep "dns_diff_apply: update with no effect" ns1/named.run >/dev/null || ret=1 + +if [ $ret != 0 ]; then echo_i "failed"; fi +status=$((status + ret)) + +# Test 2: IXFR that deletes last record in rdataset -> DNS_R_NXRRSET +n=$((n + 1)) +echo_i "testing IXFR with nxrrset ($n)" +ret=0 + +switch_responses 10.53.0.4 "nxrrset_ixfr" +sleep 1 + +$RNDCCMD 10.53.0.3 refresh nil | sed 's/^/ns3 /' | cat_i +sleep 2 + +$DIG $DIGOPTS @10.53.0.3 nil. TXT | grep 'nxrrset ixfr test' >/dev/null || ret=1 +$DIG $DIGOPTS @10.53.0.3 b.nil. A | grep '10.0.0.62' >/dev/null && ret=1 + +if [ $ret != 0 ]; then echo_i "failed"; fi +status=$((status + ret)) + +echo_i "exit status: $status" +[ $status -eq 0 ] || exit 1 diff --git a/bin/tests/system/ixfr-nonminimal/tests_sh_ixfr_nonminimal.py b/bin/tests/system/ixfr-nonminimal/tests_sh_ixfr_nonminimal.py new file mode 100644 index 00000000000..a4b7495a3ce --- /dev/null +++ b/bin/tests/system/ixfr-nonminimal/tests_sh_ixfr_nonminimal.py @@ -0,0 +1,27 @@ +# 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 pytest + +pytestmark = pytest.mark.extra_artifacts( + [ + "dig.out*", + "ans*/ans.run", + "ns1/nil.db", + "ns1/*.jnl", + "ns3/nil.db", + "ns3/*.jnl", + ] +) + + +def test_my_ixfr_nonminimal(run_tests_sh): + run_tests_sh()