[9.20] fix: usr: Do not update the case on unchanged rdatasets

Fix assertion failure on unchanged rdataset during IXFR.

Closes #5759

Merge branch '5759-do-not-set-case-on-unchanged' into 'bind-9.20'

See merge request isc-projects/bind9!11587
This commit is contained in:
Ondřej Surý 2026-02-24 16:54:52 +01:00
commit 8931f82dc8
9 changed files with 634 additions and 4 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()

View file

@ -5347,9 +5347,12 @@ qpzone_update_rdataset(qpzonedb_t *qpdb, qpz_version_t *version,
rds, options, &ardataset, ctx->nsec DNS__DB_FLARG_PASS);
switch (result) {
case ISC_R_SUCCESS:
if (dns_rdataset_isassociated(&ardataset)) {
dns_rdataset_setownercase(&ardataset, name);
}
break;
case DNS_R_UNCHANGED:
case DNS_R_NXRRSET:
dns_rdataset_setownercase(&ardataset, name);
CHECK(result);
break;
default:
@ -5373,9 +5376,12 @@ qpzone_update_rdataset(qpzonedb_t *qpdb, qpz_version_t *version,
op == DNS_DIFFOP_ADDRESIGN);
if (result == ISC_R_SUCCESS && is_resign) {
isc_stdtime_t resign;
resign = dns_rdataset_minresign(&ardataset);
dns_db_setsigningtime((dns_db_t *)qpdb, &ardataset, resign);
if (dns_rdataset_isassociated(&ardataset)) {
isc_stdtime_t resign;
resign = dns_rdataset_minresign(&ardataset);
dns_db_setsigningtime((dns_db_t *)qpdb, &ardataset,
resign);
}
}
cleanup: