Add reproducer for #5759

Adds a test case that runs IXFR while leaving an rdataset unchanged.
This commit is contained in:
Alessio Podda 2026-02-23 11:04:47 +01:00
parent 2e1971873a
commit 0041c5756e
8 changed files with 624 additions and 0 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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; };
};

View file

@ -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; };
};

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()