Adds a test case that runs IXFR while leaving an rdataset unchanged.
--- /dev/null
+"""
+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()
--- /dev/null
+"""
+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()
--- /dev/null
+/*
+ * 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; };
+};
--- /dev/null
+/*
+ * 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; };
+};
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+# 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()