new file mode 100644
@@ -0,0 +1,115 @@
+From 2910d537ac86aa033023678c70ded227ebda1af3 Mon Sep 17 00:00:00 2001
+From: Colin Vidal <colin@isc.org>
+Date: Thu, 5 Feb 2026 09:46:01 +0100
+Subject: [PATCH] Limit the number of addresses returned per ADB find
+
+Add a hard limit on the number of addresses that ADB returns from a
+single NS lookup (dns_adbfind_t). This mitigates a flood attack
+where an attacker controls a zone with many addresses for a
+nameserver, each returning an invalid response. The global
+max-query count (default 50) also limits this, but significant harm
+can be done before that limit is reached.
+
+The default limit is now 6 (v4 and/or v6) addresses for an ADB find (so,
+ADB looking up for A/AAAA addresses of a name server name). It can be
+overridden for testing via 'named -T adbaddrslimit=N'.
+
+CVE: CVE-2026-3592
+Upstream-Status: Backport [https://gitlab.com/isc-projects/bind9/-/commit/695362e3438c832ed0e39e144a77f233113d3431]
+
+(cherry picked from commit 3ec37fc69356ee682bee7f67940613ac31d93d7b)
+(cherry picked from commit 695362e3438c832ed0e39e144a77f233113d3431)
+Signed-off-by: Ashishkumar Parmar <asparmar@cisco.com>
+---
+ bin/named/main.c | 9 +++++++++
+ lib/dns/adb.c | 26 ++++++++++++++++++++++++++
+ 2 files changed, 35 insertions(+)
+
+diff --git a/bin/named/main.c b/bin/named/main.c
+index df47e8d667..ddd43c9b2c 100644
+--- a/bin/named/main.c
++++ b/bin/named/main.c
+@@ -114,6 +114,8 @@ extern unsigned int dns_zone_mkey_hour;
+ extern unsigned int dns_zone_mkey_day;
+ extern unsigned int dns_zone_mkey_month;
+
++extern size_t dns_adb_addrslimit;
++
+ static bool want_stats = false;
+ static char program_name[NAME_MAX] = "named";
+ static char absolute_conffile[PATH_MAX];
+@@ -805,6 +807,13 @@ parse_T_opt(char *option) {
+ transferstuck = true;
+ } else if (!strncmp(option, "tat=", 4)) {
+ named_g_tat_interval = atoi(option + 4);
++ } else if (!strncmp(option, "adbaddrslimit=", 14)) {
++ size_t adb_addrslimit = atoi(option + 14);
++ if (adb_addrslimit < 1) {
++ named_main_earlyfatal("adbaddrslimit must be at "
++ "least 1");
++ }
++ dns_adb_addrslimit = adb_addrslimit;
+ } else {
+ fprintf(stderr, "unknown -T flag '%s'\n", option);
+ }
+diff --git a/lib/dns/adb.c b/lib/dns/adb.c
+index 6ff9069a47..31b489e04a 100644
+--- a/lib/dns/adb.c
++++ b/lib/dns/adb.c
+@@ -86,6 +86,15 @@
+
+ #define DNS_ADB_MINADBSIZE (1024U * 1024U) /*%< 1 Megabyte */
+
++/*
++ * Default and override for the per-find address limit, the sum of the number of
++ * A and AAAA RR from an ADB NS name resolution. When non-zero, this value is
++ * used instead of the default. Can be set via 'named -T adbaddrslimit=N' for
++ * testing.
++ */
++#define DEFAULT_ADDRSLIMIT 6
++size_t dns_adb_addrslimit = 0;
++
+ typedef ISC_LIST(dns_adbname_t) dns_adbnamelist_t;
+ typedef struct dns_adbnamehook dns_adbnamehook_t;
+ typedef ISC_LIST(dns_adbnamehook_t) dns_adbnamehooklist_t;
+@@ -2200,6 +2209,9 @@ copy_namehook_lists(dns_adb_t *adb, dns_adbfind_t *find,
+ dns_adbaddrinfo_t *addrinfo;
+ dns_adbentry_t *entry;
+ int bucket;
++ size_t count = 0;
++ size_t limit = dns_adb_addrslimit != 0 ? dns_adb_addrslimit
++ : DEFAULT_ADDRSLIMIT;
+
+ bucket = DNS_ADB_INVALIDBUCKET;
+
+@@ -2232,6 +2244,13 @@ copy_namehook_lists(dns_adb_t *adb, dns_adbfind_t *find,
+ inc_entry_refcnt(adb, entry, false);
+ ISC_LIST_APPEND(find->list, addrinfo, publink);
+ addrinfo = NULL;
++
++ if (++count >= limit) {
++ DP(ISC_LOG_DEBUG(3), "skipping addresses");
++ UNLOCK(&adb->entrylocks[bucket]);
++ return;
++ }
++
+ nextv4:
+ UNLOCK(&adb->entrylocks[bucket]);
+ bucket = DNS_ADB_INVALIDBUCKET;
+@@ -2267,6 +2286,13 @@ copy_namehook_lists(dns_adb_t *adb, dns_adbfind_t *find,
+ inc_entry_refcnt(adb, entry, false);
+ ISC_LIST_APPEND(find->list, addrinfo, publink);
+ addrinfo = NULL;
++
++ if (++count >= limit) {
++ DP(ISC_LOG_DEBUG(3), "skipping addresses");
++ UNLOCK(&adb->entrylocks[bucket]);
++ return;
++ }
++
+ nextv6:
+ UNLOCK(&adb->entrylocks[bucket]);
+ bucket = DNS_ADB_INVALIDBUCKET;
+--
+2.35.6
+
new file mode 100644
@@ -0,0 +1,334 @@
+From d77197dd24af873e64a5511800a3ca5aa262eed6 Mon Sep 17 00:00:00 2001
+From: Colin Vidal <colin@isc.org>
+Date: Wed, 4 Feb 2026 10:18:42 +0100
+Subject: [PATCH] Remove duplicate addresses from the resolver SLIST
+
+The SLIST (essentially `fctx->finds`, forwarders and dual-stack
+alternatives aside) can have duplicate server addresses when multiple
+in-domain nameservers share the same IP addresses:
+
+ sub.example. NS ns1.sub.example.
+ sub.example. NS ns2.sub.example.
+ ns1.sub.example. A 1.2.3.4
+ ns1.sub.example. A 5.6.7.8
+ ns2.sub.example. A 1.2.3.4
+ ns2.sub.example. A 5.6.7.8
+
+If both 1.2.3.4 and 5.6.7.8 fail to return a valid answer, the resolver
+would query each address twice.
+
+The problem is fixed by replacing the two-phase server selection (sort
+each find list by SRTT, sort finds by head SRTT) with a single linear
+scan in nextaddress() that finds the lowest-SRTT unmarked, non-duplicate
+address across all find lists.
+
+The old approach had a correctness bug: after sorting, the resolver
+picked the next address from the "current" find list rather than
+globally. For example, with find lists [1, 15, 26] and [3, 4, 5], the
+second pick would be SRTT 15 instead of the correct SRTT 3.
+
+The new approach is both simpler and correct: each call to nextaddress()
+walks all addresses, skips marked and duplicate entries, and returns the
+one with the lowest SRTT. While this walk is repeated for each server
+attempt, it operates on a small bounded list and is negligible compared
+to the network I/O of querying the server.
+
+CVE: CVE-2026-3592
+Upstream-Status: Backport [https://gitlab.com/isc-projects/bind9/-/commit/e25eaf9e6e09bbdd826252226144570222d542d8]
+
+(cherry picked from commit b1c5856a3764b4025e93f8baf06c45c8fa029752)
+(cherry picked from commit e25eaf9e6e09bbdd826252226144570222d542d8)
+Signed-off-by: Ashishkumar Parmar <asparmar@cisco.com>
+---
+ lib/dns/resolver.c | 226 +++++++++++++++++++--------------------------
+ 1 file changed, 93 insertions(+), 133 deletions(-)
+
+diff --git a/lib/dns/resolver.c b/lib/dns/resolver.c
+index cc40f862d1..c1584c3399 100644
+--- a/lib/dns/resolver.c
++++ b/lib/dns/resolver.c
+@@ -369,7 +369,16 @@ struct fetchctx {
+ dns_message_t *qmessage;
+ ISC_LIST(resquery_t) queries;
+ dns_adbfindlist_t finds;
+- dns_adbfind_t *find;
++ /*
++ * This is a state to keep track of the latest upstream server which is
++ * being queried. See `nextaddress()`.
++ *
++ * `addrinfo` is basically a copy of `foundaddrinfo` but came from the
++ * response of the query, so fields like the SRTT/timing might have been
++ * altered. So it might be possible (?) to wrap those two in an union
++ * for clarity (and memory saving).
++ */
++ dns_adbaddrinfo_t *foundaddrinfo;
+ /*
+ * altfinds are names and/or addresses of dual stack servers that
+ * should be used when iterative resolution to a server is not
+@@ -1534,7 +1543,7 @@ fctx_cleanup(fetchctx_t *fctx) {
+ dns_adb_destroyfind(&find);
+ fctx_unref(fctx);
+ }
+- fctx->find = NULL;
++ fctx->foundaddrinfo = NULL;
+
+ for (find = ISC_LIST_HEAD(fctx->altfinds); find != NULL;
+ find = next_find)
+@@ -3355,91 +3364,10 @@ add_bad(fetchctx_t *fctx, dns_message_t *rmessage, dns_adbaddrinfo_t *addrinfo,
+ }
+
+ /*
+- * Sort addrinfo list by RTT.
+- */
+-static void
+-sort_adbfind(dns_adbfind_t *find, unsigned int bias) {
+- dns_adbaddrinfo_t *best, *curr;
+- dns_adbaddrinfolist_t sorted;
+- unsigned int best_srtt, curr_srtt;
+-
+- /* Lame N^2 bubble sort. */
+- ISC_LIST_INIT(sorted);
+- while (!ISC_LIST_EMPTY(find->list)) {
+- best = ISC_LIST_HEAD(find->list);
+- best_srtt = best->srtt;
+- if (isc_sockaddr_pf(&best->sockaddr) != AF_INET6) {
+- best_srtt += bias;
+- }
+- curr = ISC_LIST_NEXT(best, publink);
+- while (curr != NULL) {
+- curr_srtt = curr->srtt;
+- if (isc_sockaddr_pf(&curr->sockaddr) != AF_INET6) {
+- curr_srtt += bias;
+- }
+- if (curr_srtt < best_srtt) {
+- best = curr;
+- best_srtt = curr_srtt;
+- }
+- curr = ISC_LIST_NEXT(curr, publink);
+- }
+- ISC_LIST_UNLINK(find->list, best, publink);
+- ISC_LIST_APPEND(sorted, best, publink);
+- }
+- find->list = sorted;
+-}
+-
+-/*
+- * Sort a list of finds by server RTT.
+- */
+-static void
+-sort_finds(dns_adbfindlist_t *findlist, unsigned int bias) {
+- dns_adbfind_t *best, *curr;
+- dns_adbfindlist_t sorted;
+- dns_adbaddrinfo_t *addrinfo, *bestaddrinfo;
+- unsigned int best_srtt, curr_srtt;
+-
+- /* Sort each find's addrinfo list by SRTT. */
+- for (curr = ISC_LIST_HEAD(*findlist); curr != NULL;
+- curr = ISC_LIST_NEXT(curr, publink))
+- {
+- sort_adbfind(curr, bias);
+- }
+-
+- /* Lame N^2 bubble sort. */
+- ISC_LIST_INIT(sorted);
+- while (!ISC_LIST_EMPTY(*findlist)) {
+- best = ISC_LIST_HEAD(*findlist);
+- bestaddrinfo = ISC_LIST_HEAD(best->list);
+- INSIST(bestaddrinfo != NULL);
+- best_srtt = bestaddrinfo->srtt;
+- if (isc_sockaddr_pf(&bestaddrinfo->sockaddr) != AF_INET6) {
+- best_srtt += bias;
+- }
+- curr = ISC_LIST_NEXT(best, publink);
+- while (curr != NULL) {
+- addrinfo = ISC_LIST_HEAD(curr->list);
+- INSIST(addrinfo != NULL);
+- curr_srtt = addrinfo->srtt;
+- if (isc_sockaddr_pf(&addrinfo->sockaddr) != AF_INET6) {
+- curr_srtt += bias;
+- }
+- if (curr_srtt < best_srtt) {
+- best = curr;
+- best_srtt = curr_srtt;
+- }
+- curr = ISC_LIST_NEXT(curr, publink);
+- }
+- ISC_LIST_UNLINK(*findlist, best, publink);
+- ISC_LIST_APPEND(sorted, best, publink);
+- }
+- *findlist = sorted;
+-}
+-
+-/*
+- * Return true iff the ADB find has a pending fetch for 'type'. This is
+- * used to find out whether we're in a loop, where a fetch is waiting for a
+- * find which is waiting for that same fetch.
++ * Return true iff the ADB find has an already pending fetch for 'type'. This
++ * is used to find out whether we're in a loop, where a fetch is waiting for a
++ * find which is waiting for that same fetch. So if the current find actually
++ * started the fetch, we know it can't be a loop, so we returns false.
+ *
+ * Note: This could be done with either an equivalence check (e.g.,
+ * query_pending == DNS_ADBFIND_INET) or with a bit check, as below. If
+@@ -3546,6 +3474,7 @@ findname(fetchctx_t *fctx, const dns_name_t *name, in_port_t port,
+ }
+ }
+ }
++
+ if ((flags & FCTX_ADDRINFO_DUALSTACK) != 0) {
+ ISC_LIST_APPEND(fctx->altfinds, find, publink);
+ } else {
+@@ -3961,8 +3890,6 @@ out:
+ * We've found some addresses. We might still be
+ * looking for more addresses.
+ */
+- sort_finds(&fctx->finds, res->view->v6bias);
+- sort_finds(&fctx->altfinds, 0);
+ result = ISC_R_SUCCESS;
+ }
+
+@@ -4037,6 +3964,80 @@ possibly_mark(fetchctx_t *fctx, dns_adbaddrinfo_t *addr) {
+ }
+ }
+
++static dns_adbaddrinfo_t *
++nextaddress(fetchctx_t *fctx) {
++ dns_adbaddrinfo_t *prevai = fctx->foundaddrinfo, *lowestsrttai = NULL;
++ unsigned int v6bias = fctx->res->view->v6bias, lowestsrtt = 0;
++
++ /*
++ * Let's walk through the list of dns_adbaddrinfo_t to find the best
++ * next server address to query. This is linear on the number of
++ * dns_adbaddrinfo_t which are grouped in find list (for each ADB find).
++ */
++ for (dns_adbfind_t *find = ISC_LIST_HEAD(fctx->finds); find != NULL;
++ find = ISC_LIST_NEXT(find, publink))
++ {
++ for (dns_adbaddrinfo_t *ai = ISC_LIST_HEAD(find->list);
++ ai != NULL; ai = ISC_LIST_NEXT(ai, publink))
++ {
++ /*
++ * This address has been marked already, skip it.
++ */
++ if (!UNMARKED(ai)) {
++ continue;
++ }
++
++ /*
++ * This address is the same as the previously used
++ * address, it's a duplicate, mark it and skip it!
++ */
++ if (prevai != NULL) {
++ if (prevai->entry == ai->entry) {
++ ai->flags |= FCTX_ADDRINFO_MARK;
++ continue;
++ }
++ }
++
++ /*
++ * Mark and skip this address if incompatible (i.e. IPv6
++ * address on a v4 only server, or for ACL reason, etc.)
++ */
++ possibly_mark(fctx, ai);
++ if (!UNMARKED(ai)) {
++ continue;
++ }
++
++ /*
++ * This address hasn't been tried yet and is a
++ * good candidate. Let's keep track of it if it
++ * has the lowest SRTT so far (or if there is no
++ * address with lowest SRTT found yet).
++ */
++ unsigned int aisrtt = ai->srtt;
++
++ if (isc_sockaddr_pf(&ai->sockaddr) != AF_INET6) {
++ aisrtt += v6bias;
++ }
++
++ if (lowestsrttai == NULL || aisrtt < lowestsrtt) {
++ lowestsrttai = ai;
++ lowestsrtt = aisrtt;
++ continue;
++ }
++ }
++ }
++
++ /*
++ * This is the next address to query. If this is NULL, we're done.
++ */
++ if (lowestsrttai != NULL) {
++ lowestsrttai->flags |= FCTX_ADDRINFO_MARK;
++ }
++ fctx->foundaddrinfo = lowestsrttai;
++
++ return lowestsrttai;
++}
++
+ static dns_adbaddrinfo_t *
+ fctx_nextaddress(fetchctx_t *fctx) {
+ dns_adbfind_t *find, *start;
+@@ -4059,7 +4060,6 @@ fctx_nextaddress(fetchctx_t *fctx) {
+ possibly_mark(fctx, addrinfo);
+ if (UNMARKED(addrinfo)) {
+ addrinfo->flags |= FCTX_ADDRINFO_MARK;
+- fctx->find = NULL;
+ fctx->forwarding = true;
+
+ /*
+@@ -4080,49 +4080,9 @@ fctx_nextaddress(fetchctx_t *fctx) {
+ fctx->forwarding = false;
+ FCTX_ATTR_SET(fctx, FCTX_ATTR_TRIEDFIND);
+
+- find = fctx->find;
+- if (find == NULL) {
+- find = ISC_LIST_HEAD(fctx->finds);
+- } else {
+- find = ISC_LIST_NEXT(find, publink);
+- if (find == NULL) {
+- find = ISC_LIST_HEAD(fctx->finds);
+- }
+- }
+-
+- /*
+- * Find the first unmarked addrinfo.
+- */
+- addrinfo = NULL;
+- if (find != NULL) {
+- start = find;
+- do {
+- for (addrinfo = ISC_LIST_HEAD(find->list);
+- addrinfo != NULL;
+- addrinfo = ISC_LIST_NEXT(addrinfo, publink))
+- {
+- if (!UNMARKED(addrinfo)) {
+- continue;
+- }
+- possibly_mark(fctx, addrinfo);
+- if (UNMARKED(addrinfo)) {
+- addrinfo->flags |= FCTX_ADDRINFO_MARK;
+- break;
+- }
+- }
+- if (addrinfo != NULL) {
+- break;
+- }
+- find = ISC_LIST_NEXT(find, publink);
+- if (find == NULL) {
+- find = ISC_LIST_HEAD(fctx->finds);
+- }
+- } while (find != start);
+- }
+-
+- fctx->find = find;
+- if (addrinfo != NULL) {
+- return addrinfo;
++ faddrinfo = nextaddress(fctx);
++ if (faddrinfo != NULL) {
++ return faddrinfo;
+ }
+
+ /*
+--
+2.35.6
+
new file mode 100644
@@ -0,0 +1,629 @@
+From 1d837ed1dfb8a25cb12cf18a3ce998745adcfbd9 Mon Sep 17 00:00:00 2001
+From: Colin Vidal <colin@isc.org>
+Date: Thu, 5 Feb 2026 11:20:11 +0100
+Subject: [PATCH] Add system test for self-pointed glue deduplication
+
+Test the resolver's behavior with self-pointed glue where each NS
+has the same set of addresses. Verify that addresses are
+deduplicated and each unique IP is only queried once.
+
+Also test the ADB address limit knob (-T adbaddrslimit=).
+
+CVE: CVE-2026-3592
+Upstream-Status: Backport [https://gitlab.com/isc-projects/bind9/-/commit/d2a67ba22246029192d8072d31b97e3bfd235f64]
+
+(cherry picked from commit c21fc6cb95d77312d6fb891f17ce9df41a25af6d)
+(cherry picked from commit d2a67ba22246029192d8072d31b97e3bfd235f64)
+Signed-off-by: Ashishkumar Parmar <asparmar@cisco.com>
+---
+ .../system/selfpointedglue/ns1/named.conf.j2 | 28 ++++
+ bin/tests/system/selfpointedglue/ns1/root.db | 24 +++
+ .../system/selfpointedglue/ns2/named.conf.j2 | 28 ++++
+ bin/tests/system/selfpointedglue/ns2/tld.db | 27 +++
+ .../system/selfpointedglue/ns3/example.tld.db | 155 ++++++++++++++++++
+ .../selfpointedglue/ns3/example2.tld.db | 33 ++++
+ .../system/selfpointedglue/ns3/named.conf.j2 | 44 +++++
+ .../system/selfpointedglue/ns4/named.args.j2 | 3 +
+ .../system/selfpointedglue/ns4/named.conf.j2 | 59 +++++++
+ .../system/selfpointedglue/ns4/root.hint | 14 ++
+ bin/tests/system/selfpointedglue/prereq.sh | 20 +++
+ .../selfpointedglue/tests_selfpointedglue.py | 75 +++++++++
+ 12 files changed, 510 insertions(+)
+ create mode 100644 bin/tests/system/selfpointedglue/ns1/named.conf.j2
+ create mode 100644 bin/tests/system/selfpointedglue/ns1/root.db
+ create mode 100644 bin/tests/system/selfpointedglue/ns2/named.conf.j2
+ create mode 100644 bin/tests/system/selfpointedglue/ns2/tld.db
+ create mode 100644 bin/tests/system/selfpointedglue/ns3/example.tld.db
+ create mode 100644 bin/tests/system/selfpointedglue/ns3/example2.tld.db
+ create mode 100644 bin/tests/system/selfpointedglue/ns3/named.conf.j2
+ create mode 100644 bin/tests/system/selfpointedglue/ns4/named.args.j2
+ create mode 100644 bin/tests/system/selfpointedglue/ns4/named.conf.j2
+ create mode 100644 bin/tests/system/selfpointedglue/ns4/root.hint
+ create mode 100644 bin/tests/system/selfpointedglue/prereq.sh
+ create mode 100644 bin/tests/system/selfpointedglue/tests_selfpointedglue.py
+
+diff --git a/bin/tests/system/selfpointedglue/ns1/named.conf.j2 b/bin/tests/system/selfpointedglue/ns1/named.conf.j2
+new file mode 100644
+index 0000000000..fd83fc3c19
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns1/named.conf.j2
+@@ -0,0 +1,28 @@
++/*
++ * 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; };
++ recursion no;
++ dnssec-validation no;
++};
++
++zone "." {
++ type primary;
++ file "root.db";
++};
+diff --git a/bin/tests/system/selfpointedglue/ns1/root.db b/bin/tests/system/selfpointedglue/ns1/root.db
+new file mode 100644
+index 0000000000..bfbf049b80
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns1/root.db
+@@ -0,0 +1,24 @@
++; 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.
++
++$TTL 300
++. IN SOA owner.root-servers.nil. a.root.servers.nil. (
++ 2010 ; serial
++ 600 ; refresh
++ 600 ; retry
++ 1200 ; expire
++ 600 ; minimum
++ )
++. NS a.root-servers.nil.
++a.root-servers.nil. A 10.53.0.1
++
++tld. NS ns.tld.
++ns.tld. A 10.53.0.2
+diff --git a/bin/tests/system/selfpointedglue/ns2/named.conf.j2 b/bin/tests/system/selfpointedglue/ns2/named.conf.j2
+new file mode 100644
+index 0000000000..2993832da2
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns2/named.conf.j2
+@@ -0,0 +1,28 @@
++/*
++ * 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.2;
++ notify-source 10.53.0.2;
++ transfer-source 10.53.0.2;
++ port @PORT@;
++ pid-file "named.pid";
++ listen-on { 10.53.0.2; };
++ recursion no;
++ dnssec-validation no;
++};
++
++zone "tld." {
++ type primary;
++ file "tld.db";
++};
+diff --git a/bin/tests/system/selfpointedglue/ns2/tld.db b/bin/tests/system/selfpointedglue/ns2/tld.db
+new file mode 100644
+index 0000000000..5935fd841c
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns2/tld.db
+@@ -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.
++
++$TTL 300
++tld. IN SOA owner.tld. ns.tld. (
++ 2010 ; serial
++ 600 ; refresh
++ 600 ; retry
++ 1200 ; expire
++ 600 ; minimum
++ )
++tld. NS ns.tld.
++ns.tld. A 10.53.0.2
++
++example.tld. NS ns.example.tld.
++ns.example.tld. A 10.53.0.3
++
++example2.tld. NS ns.example2.tld.
++ns.example2.tld. A 10.53.0.3
+diff --git a/bin/tests/system/selfpointedglue/ns3/example.tld.db b/bin/tests/system/selfpointedglue/ns3/example.tld.db
+new file mode 100644
+index 0000000000..83ea4d37ec
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns3/example.tld.db
+@@ -0,0 +1,155 @@
++; 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.
++
++$TTL 300
++example.tld. IN SOA owner.dnshoster.tld. ns.dnshoster.tld. (
++ 2010 ; serial
++ 600 ; refresh
++ 600 ; retry
++ 1200 ; expire
++ 600 ; minimum
++ )
++
++example.tld. NS ns.example.tld.
++ns.example.tld. A 10.53.0.3
++
++sub.example.tld. NS ns01.sub.example.tld.
++sub.example.tld. NS ns02.sub.example.tld.
++sub.example.tld. NS ns03.sub.example.tld.
++sub.example.tld. NS ns04.sub.example.tld.
++sub.example.tld. NS ns05.sub.example.tld.
++sub.example.tld. NS ns06.sub.example.tld.
++sub.example.tld. NS ns07.sub.example.tld.
++sub.example.tld. NS ns08.sub.example.tld.
++sub.example.tld. NS ns09.sub.example.tld.
++sub.example.tld. NS ns10.sub.example.tld.
++
++ns01.sub.example.tld. A 10.53.0.5
++ns01.sub.example.tld. A 10.53.0.6
++ns01.sub.example.tld. A 10.53.0.7
++ns01.sub.example.tld. A 10.53.0.8
++ns01.sub.example.tld. A 10.53.0.9
++ns01.sub.example.tld. A 10.53.0.10
++ns01.sub.example.tld. A 10.53.1.1
++ns01.sub.example.tld. A 10.53.1.2
++ns01.sub.example.tld. A 10.53.2.1
++ns01.sub.example.tld. A 10.53.0.3
++; Those RR (same below) pointing to 127.0.0.1 won't ever be used as they
++; exceeded the ADB limit.
++ns01.sub.example.tld. A 127.0.0.1
++
++ns02.sub.example.tld. A 10.53.0.5
++ns02.sub.example.tld. A 10.53.0.6
++ns02.sub.example.tld. A 10.53.0.7
++ns02.sub.example.tld. A 10.53.0.8
++ns02.sub.example.tld. A 10.53.0.9
++ns02.sub.example.tld. A 10.53.0.10
++ns02.sub.example.tld. A 10.53.1.1
++ns02.sub.example.tld. A 10.53.1.2
++ns02.sub.example.tld. A 10.53.2.1
++ns02.sub.example.tld. A 10.53.0.3
++ns02.sub.example.tld. A 127.0.0.1
++
++ns03.sub.example.tld. A 10.53.0.5
++ns03.sub.example.tld. A 10.53.0.6
++ns03.sub.example.tld. A 10.53.0.7
++ns03.sub.example.tld. A 10.53.0.8
++ns03.sub.example.tld. A 10.53.0.9
++ns03.sub.example.tld. A 10.53.0.10
++ns03.sub.example.tld. A 10.53.1.1
++ns03.sub.example.tld. A 10.53.1.2
++ns03.sub.example.tld. A 10.53.2.1
++ns03.sub.example.tld. A 10.53.0.3
++ns03.sub.example.tld. A 127.0.0.1
++
++ns04.sub.example.tld. A 10.53.0.5
++ns04.sub.example.tld. A 10.53.0.6
++ns04.sub.example.tld. A 10.53.0.7
++ns04.sub.example.tld. A 10.53.0.8
++ns04.sub.example.tld. A 10.53.0.9
++ns04.sub.example.tld. A 10.53.0.10
++ns04.sub.example.tld. A 10.53.1.1
++ns04.sub.example.tld. A 10.53.1.2
++ns04.sub.example.tld. A 10.53.2.1
++ns04.sub.example.tld. A 10.53.0.3
++ns04.sub.example.tld. A 127.0.0.1
++
++ns05.sub.example.tld. A 10.53.0.5
++ns05.sub.example.tld. A 10.53.0.6
++ns05.sub.example.tld. A 10.53.0.7
++ns05.sub.example.tld. A 10.53.0.8
++ns05.sub.example.tld. A 10.53.0.9
++ns05.sub.example.tld. A 10.53.0.10
++ns05.sub.example.tld. A 10.53.1.1
++ns05.sub.example.tld. A 10.53.1.2
++ns05.sub.example.tld. A 10.53.2.1
++ns05.sub.example.tld. A 10.53.0.3
++ns05.sub.example.tld. A 127.0.0.1
++
++ns06.sub.example.tld. A 10.53.0.5
++ns06.sub.example.tld. A 10.53.0.6
++ns06.sub.example.tld. A 10.53.0.7
++ns06.sub.example.tld. A 10.53.0.8
++ns06.sub.example.tld. A 10.53.0.9
++ns06.sub.example.tld. A 10.53.0.10
++ns06.sub.example.tld. A 10.53.1.1
++ns06.sub.example.tld. A 10.53.1.2
++ns06.sub.example.tld. A 10.53.2.1
++ns06.sub.example.tld. A 10.53.0.3
++ns06.sub.example.tld. A 127.0.0.1
++
++ns07.sub.example.tld. A 10.53.0.5
++ns07.sub.example.tld. A 10.53.0.6
++ns07.sub.example.tld. A 10.53.0.7
++ns07.sub.example.tld. A 10.53.0.8
++ns07.sub.example.tld. A 10.53.0.9
++ns07.sub.example.tld. A 10.53.0.10
++ns07.sub.example.tld. A 10.53.1.1
++ns07.sub.example.tld. A 10.53.1.2
++ns07.sub.example.tld. A 10.53.2.1
++ns07.sub.example.tld. A 10.53.0.3
++ns07.sub.example.tld. A 127.0.0.1
++
++ns08.sub.example.tld. A 10.53.0.5
++ns08.sub.example.tld. A 10.53.0.6
++ns08.sub.example.tld. A 10.53.0.7
++ns08.sub.example.tld. A 10.53.0.8
++ns08.sub.example.tld. A 10.53.0.9
++ns08.sub.example.tld. A 10.53.0.10
++ns08.sub.example.tld. A 10.53.1.1
++ns08.sub.example.tld. A 10.53.1.2
++ns08.sub.example.tld. A 10.53.2.1
++ns08.sub.example.tld. A 10.53.0.3
++ns08.sub.example.tld. A 127.0.0.1
++
++ns09.sub.example.tld. A 10.53.0.5
++ns09.sub.example.tld. A 10.53.0.6
++ns09.sub.example.tld. A 10.53.0.7
++ns09.sub.example.tld. A 10.53.0.8
++ns09.sub.example.tld. A 10.53.0.9
++ns09.sub.example.tld. A 10.53.0.10
++ns09.sub.example.tld. A 10.53.1.1
++ns09.sub.example.tld. A 10.53.1.2
++ns09.sub.example.tld. A 10.53.2.1
++ns09.sub.example.tld. A 10.53.0.3
++ns09.sub.example.tld. A 127.0.0.1
++
++ns10.sub.example.tld. A 10.53.0.5
++ns10.sub.example.tld. A 10.53.0.6
++ns10.sub.example.tld. A 10.53.0.7
++ns10.sub.example.tld. A 10.53.0.8
++ns10.sub.example.tld. A 10.53.0.9
++ns10.sub.example.tld. A 10.53.0.10
++ns10.sub.example.tld. A 10.53.1.1
++ns10.sub.example.tld. A 10.53.1.2
++ns10.sub.example.tld. A 10.53.2.1
++ns10.sub.example.tld. A 10.53.0.3
++ns10.sub.example.tld. A 127.0.0.1
+diff --git a/bin/tests/system/selfpointedglue/ns3/example2.tld.db b/bin/tests/system/selfpointedglue/ns3/example2.tld.db
+new file mode 100644
+index 0000000000..bcab6e38c1
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns3/example2.tld.db
+@@ -0,0 +1,33 @@
++; 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.
++
++$TTL 300
++example2.tld. IN SOA owner.dnshoster.tld. ns.dnshoster.tld. (
++ 2010 ; serial
++ 600 ; refresh
++ 600 ; retry
++ 1200 ; expire
++ 600 ; minimum
++ )
++
++example2.tld. NS ns.example2.tld.
++ns.example2.tld. A 10.53.0.3
++
++sub.example2.tld. NS ns01.sub.example2.tld.
++sub.example2.tld. NS ns02.sub.example2.tld.
++sub.example2.tld. NS ns03.sub.example2.tld.
++
++ns01.sub.example2.tld. A 10.53.1.1
++ns01.sub.example2.tld. A 10.53.0.5
++ns02.sub.example2.tld. A 10.53.1.2
++ns02.sub.example2.tld. A 10.53.0.6
++ns03.sub.example2.tld. A 10.53.2.1
++ns03.sub.example2.tld. A 10.53.0.7
+diff --git a/bin/tests/system/selfpointedglue/ns3/named.conf.j2 b/bin/tests/system/selfpointedglue/ns3/named.conf.j2
+new file mode 100644
+index 0000000000..b5c8bfcf33
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns3/named.conf.j2
+@@ -0,0 +1,44 @@
++/*
++ * 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;
++ 10.53.0.5;
++ 10.53.0.6;
++ 10.53.0.7;
++ 10.53.0.8;
++ 10.53.0.9;
++ 10.53.0.10;
++ 10.53.1.1;
++ 10.53.1.2;
++ 10.53.2.1;
++ };
++ recursion no;
++ dnssec-validation no;
++};
++
++zone "example.tld." {
++ type primary;
++ file "example.tld.db";
++};
++
++zone "example2.tld." {
++ type primary;
++ file "example2.tld.db";
++};
+diff --git a/bin/tests/system/selfpointedglue/ns4/named.args.j2 b/bin/tests/system/selfpointedglue/ns4/named.args.j2
+new file mode 100644
+index 0000000000..071508fd70
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns4/named.args.j2
+@@ -0,0 +1,3 @@
++{% set adblimit = adblimit | default("") %}
++
++-D selfpointedglue-ns4 -m record -c named.conf -d 99 -g -T maxcachesize=2097152 -4 @adblimit@
+diff --git a/bin/tests/system/selfpointedglue/ns4/named.conf.j2 b/bin/tests/system/selfpointedglue/ns4/named.conf.j2
+new file mode 100644
+index 0000000000..09fbdd4e70
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns4/named.conf.j2
+@@ -0,0 +1,59 @@
++/*
++ * 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 maxdelegationservers = maxdelegationservers | default(None) %}
++
++options {
++ query-source address 10.53.0.4;
++ notify-source 10.53.0.4;
++ transfer-source 10.53.0.4;
++ port @PORT@;
++ pid-file "named.pid";
++ listen-on { 10.53.0.4; };
++ recursion yes;
++ dnssec-validation no;
++ dnstap { resolver query; };
++ dnstap-output file "dnstap.out";
++ {% if maxdelegationservers %}
++ @maxdelegationservers@
++ {% endif %}
++};
++
++/*
++ * Forcing TCP ensures that ADDITIONAL won't be truncated (responses won't have
++ * the TC flag, hence the resolver won't retry using TCP by itself, see
++ * https://datatracker.ietf.org/doc/html/rfc2181#section-9)
++ */
++server 10.53.0.3 { tcp-only true; };
++server 10.53.0.5 { tcp-only true; };
++server 10.53.0.6 { tcp-only true; };
++server 10.53.0.7 { tcp-only true; };
++server 10.53.0.8 { tcp-only true; };
++server 10.53.0.9 { tcp-only true; };
++server 10.53.0.10 { tcp-only true; };
++server 10.53.1.1 { tcp-only true; };
++server 10.53.1.2 { tcp-only true; };
++server 10.53.2.1 { tcp-only true; };
++
++zone "." {
++ type hint;
++ file "root.hint";
++};
++
++key rndc_key {
++ secret "1234abcd8765";
++ algorithm @DEFAULT_HMAC@;
++};
++
++controls {
++ inet 10.53.0.4 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
++};
+diff --git a/bin/tests/system/selfpointedglue/ns4/root.hint b/bin/tests/system/selfpointedglue/ns4/root.hint
+new file mode 100644
+index 0000000000..d7d0e1faba
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/ns4/root.hint
+@@ -0,0 +1,14 @@
++; 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.
++
++$TTL 999999
++. IN NS a.root-servers.nil.
++a.root-servers.nil. IN A 10.53.0.1
+diff --git a/bin/tests/system/selfpointedglue/prereq.sh b/bin/tests/system/selfpointedglue/prereq.sh
+new file mode 100644
+index 0000000000..747f448982
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/prereq.sh
+@@ -0,0 +1,20 @@
++#!/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
++
++$FEATURETEST --enable-dnstap || {
++ echo_i "This test requires dnstap support." >&2
++ exit 255
++}
++exit 0
+diff --git a/bin/tests/system/selfpointedglue/tests_selfpointedglue.py b/bin/tests/system/selfpointedglue/tests_selfpointedglue.py
+new file mode 100644
+index 0000000000..03a71cf545
+--- /dev/null
++++ b/bin/tests/system/selfpointedglue/tests_selfpointedglue.py
+@@ -0,0 +1,75 @@
++# 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 os
++
++import isctest
++
++
++def line_to_ips_and_queries(line):
++ # dnstap-read output line example
++ # 05-Feb-2026 11:00:57.853 RQ 10.53.0.4:38507 -> 10.53.0.3:22047 TCP 56b sub.example.tld/IN/NS
++ _, _, _, _, _, dst, _, _, query = line.split(" ", 9)
++ ip, _ = dst.split(":", 1)
++ return (ip, query)
++
++
++def extract_dnstap(ns, nsid, expectedlen):
++ ns.rndc("dnstap -roll 1")
++ path = os.path.join(nsid, "dnstap.out.0")
++ dnstapread = isctest.run.cmd(
++ [os.getenv("DNSTAPREAD"), path],
++ )
++
++ lines = dnstapread.out.splitlines()
++ assert expectedlen == len(lines)
++ return list(map(line_to_ips_and_queries, lines))
++
++
++# Because DNSTAP doesn't have ordering guarantee, the order doesn't matter here.
++def expect_ip_and_query(expected_ips_and_queries, ips_and_queries):
++ found_count = 0
++ for expected_ip, expected_query in expected_ips_and_queries:
++ found = False
++ for ip, query in ips_and_queries:
++ if ip == expected_ip and query == expected_query:
++ found = True
++ found_count += 1
++ break
++ assert found
++ assert found_count == len(expected_ips_and_queries)
++
++
++def test_selfpointedglue(ns4):
++ msg = isctest.query.create("a.sub.example.tld.", "A")
++ res = isctest.query.tcp(msg, ns4.ip)
++ isctest.check.servfail(res)
++
++ ips_and_queries = extract_dnstap(ns4, "ns4", 10)
++
++ # Thanks to the de-duplication, only the first 6 NS IPs are
++ # queried (once sub.example.tld. NS is found) instead of 60
++ # (60 per NS, with 10 NS).
++ expect_ip_and_query(
++ [
++ ("10.53.0.1", "./IN/NS"),
++ ("10.53.0.1", "tld/IN/NS"),
++ ("10.53.0.2", "example.tld/IN/NS"),
++ ("10.53.0.3", "sub.example.tld/IN/NS"),
++ ("10.53.0.3", "a.sub.example.tld/IN/A"),
++ ("10.53.0.5", "a.sub.example.tld/IN/A"),
++ ("10.53.0.6", "a.sub.example.tld/IN/A"),
++ ("10.53.0.7", "a.sub.example.tld/IN/A"),
++ ("10.53.0.8", "a.sub.example.tld/IN/A"),
++ ("10.53.0.9", "a.sub.example.tld/IN/A"),
++ ],
++ ips_and_queries,
++ )
+--
+2.35.6
+
new file mode 100644
@@ -0,0 +1,891 @@
+From 46fe5e321b6e4bf8a27df2f7659e6e4f233c94d9 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Micha=C5=82=20K=C4=99pie=C5=84?= <michal@isc.org>
+Date: Fri, 17 Apr 2026 17:57:05 +0200
+Subject: [PATCH] Sync asyncserver.py with the development branch
+
+Import bin/tests/system/isctest/asyncserver.py as present in commit
+ced002c4ab7b920c9528d315a611a477cb4a9409 on the "main" branch. This
+enables using newer asyncserver.py infrastructure code in system tests
+that need to be backported to maintenance branches.
+
+CVE: CVE-2026-3592
+Upstream-Status: Backport [https://gitlab.com/isc-projects/bind9/-/commit/b0e8966647e744482edc06e48bc9ff5079a1c541]
+
+Backport Changes:
+- Regenerated against the BIND 9.18.44 system-test helper
+ context, which already carries dnspython compatibility code not
+ present in the upstream patch base.
+
+(cherry picked from commit b0e8966647e744482edc06e48bc9ff5079a1c541)
+Signed-off-by: Ashishkumar Parmar <asparmar@cisco.com>
+---
+ bin/tests/system/isctest/asyncserver.py | 477 ++++++++++++++++++------
+ 1 file changed, 370 insertions(+), 107 deletions(-)
+
+diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py
+index d2b22d7c12..e560ad10c0 100644
+--- a/bin/tests/system/isctest/asyncserver.py
++++ b/bin/tests/system/isctest/asyncserver.py
+@@ -11,20 +11,9 @@ See the COPYRIGHT file distributed with this work for additional
+ information regarding copyright ownership.
+ """
+
++from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
+ from dataclasses import dataclass, field
+-from typing import (
+- Any,
+- AsyncGenerator,
+- Callable,
+- Coroutine,
+- Dict,
+- List,
+- Optional,
+- Set,
+- Tuple,
+- Union,
+- cast,
+-)
++from typing import Any, cast
+
+ import abc
+ import asyncio
+@@ -52,12 +41,11 @@ import dns.rdataset
+ import dns.rdatatype
+ import dns.rrset
+ import dns.tsig
+-import dns.version
+ import dns.zone
+
+
+ _UdpHandler = Callable[
+- [bytes, Tuple[str, int], asyncio.DatagramTransport], Coroutine[Any, Any, None]
++ [bytes, tuple[str, int], asyncio.DatagramTransport], Coroutine[Any, Any, None]
+ ]
+
+
+@@ -75,7 +63,7 @@ class _AsyncUdpHandler(asyncio.DatagramProtocol):
+ self,
+ handler: _UdpHandler,
+ ) -> None:
+- self._transport: Optional[asyncio.DatagramTransport] = None
++ self._transport: asyncio.DatagramTransport | None = None
+ self._handler: _UdpHandler = handler
+
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
+@@ -84,7 +72,7 @@ class _AsyncUdpHandler(asyncio.DatagramProtocol):
+ """
+ self._transport = cast(asyncio.DatagramTransport, transport)
+
+- def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
++ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
+ """
+ Called by asyncio when a datagram is received.
+ """
+@@ -109,9 +97,9 @@ class AsyncServer:
+
+ def __init__(
+ self,
+- udp_handler: Optional[_UdpHandler],
+- tcp_handler: Optional[_TcpHandler],
+- pidfile: Optional[str] = None,
++ udp_handler: _UdpHandler | None,
++ tcp_handler: _TcpHandler | None,
++ pidfile: str | None = None,
+ ) -> None:
+ self._abort_if_on_dnspython_version_less_than_2_0_0()
+ logging.basicConfig(
+@@ -134,12 +122,12 @@ class AsyncServer:
+ logging.info("Setting up IPv4 listener at %s:%d", ipv4_address, port)
+ logging.info("Setting up IPv6 listener at [%s]:%d", ipv6_address, port)
+
+- self._ip_addresses: Tuple[str, str] = (ipv4_address, ipv6_address)
++ self._ip_addresses: tuple[str, str] = (ipv4_address, ipv6_address)
+ self._port: int = port
+- self._udp_handler: Optional[_UdpHandler] = udp_handler
+- self._tcp_handler: Optional[_TcpHandler] = tcp_handler
+- self._pidfile: Optional[str] = pidfile
+- self._work_done: Optional[asyncio.Future] = None
++ self._udp_handler: _UdpHandler | None = udp_handler
++ self._tcp_handler: _TcpHandler | None = tcp_handler
++ self._pidfile: str | None = pidfile
++ self._work_done: asyncio.Future | None = None
+
+ @classmethod
+ def _abort_if_on_dnspython_version_less_than_2_0_0(cls) -> None:
+@@ -195,7 +183,7 @@ class AsyncServer:
+ loop.set_exception_handler(self._handle_exception)
+
+ def _handle_exception(
+- self, _: asyncio.AbstractEventLoop, context: Dict[str, Any]
++ self, _: asyncio.AbstractEventLoop, context: dict[str, Any]
+ ) -> None:
+ assert self._work_done
+ exception = context.get("exception", RuntimeError(context["message"]))
+@@ -275,17 +263,16 @@ class QueryContext:
+
+ query: dns.message.Message
+ response: dns.message.Message
++ socket: Peer
+ peer: Peer
+ protocol: DnsProtocol
+- zone: Optional[dns.zone.Zone] = field(default=None, init=False)
+- soa: Optional[dns.rrset.RRset] = field(default=None, init=False)
+- node: Optional[dns.node.Node] = field(default=None, init=False)
+- answer: Optional[dns.rdataset.Rdataset] = field(default=None, init=False)
+- alias: Optional[dns.name.Name] = field(default=None, init=False)
+- _initialized_response: Optional[dns.message.Message] = field(
+- default=None, init=False
+- )
+- _initialized_response_with_zone_data: Optional[dns.message.Message] = field(
++ zone: dns.zone.Zone | None = field(default=None, init=False)
++ soa: dns.rrset.RRset | None = field(default=None, init=False)
++ node: dns.node.Node | None = field(default=None, init=False)
++ answer: dns.rdataset.Rdataset | None = field(default=None, init=False)
++ alias: dns.name.Name | None = field(default=None, init=False)
++ _initialized_response: dns.message.Message | None = field(default=None, init=False)
++ _initialized_response_with_zone_data: dns.message.Message | None = field(
+ default=None, init=False
+ )
+
+@@ -330,7 +317,7 @@ class ResponseAction(abc.ABC):
+ """
+
+ @abc.abstractmethod
+- async def perform(self) -> Optional[Union[dns.message.Message, bytes]]:
++ async def perform(self) -> dns.message.Message | bytes | None:
+ """
+ This method is expected to carry out arbitrary actions (e.g. wait for a
+ specific amount of time, modify the answer, etc.) and then return the
+@@ -353,14 +340,30 @@ class DnsResponseSend(ResponseAction):
+ """
+
+ response: dns.message.Message
+- authoritative: Optional[bool] = None
++ authoritative: bool | None = None
+ delay: float = 0.0
++ acknowledge_hand_rolled_response: bool = False
+
+- async def perform(self) -> Optional[Union[dns.message.Message, bytes]]:
++ async def perform(self) -> dns.message.Message | bytes | None:
+ """
+ Yield a potentially delayed response that is a dns.message.Message.
+ """
+ assert isinstance(self.response, dns.message.Message)
++ if not (
++ _is_asyncserver_response(self.response)
++ or self.acknowledge_hand_rolled_response
++ ):
++ error = "The response you are trying to send was not created using "
++ error += "AsyncDnsServer's response preparation methods. "
++ error += "This will break features such as automatic AA flag "
++ error += "and RCODE handling. If you need a fresh copy of a "
++ error += "response, use `QueryContext.prepare_new_response` "
++ error += "instead of `dns.message.make_response`. "
++ error += "To acknowledge this and proceed anyway, set "
++ error += "`acknowledge_hand_rolled_response=True` in "
++ error += "DnsResponseSend's constructor."
++ raise RuntimeError(error)
++
+ if self.authoritative is not None:
+ if self.authoritative:
+ self.response.flags |= dns.flags.AA
+@@ -387,7 +390,7 @@ class BytesResponseSend(ResponseAction):
+ response: bytes
+ delay: float = 0.0
+
+- async def perform(self) -> Optional[Union[dns.message.Message, bytes]]:
++ async def perform(self) -> dns.message.Message | bytes | None:
+ """
+ Yield a potentially delayed response that is a sequence of bytes.
+ """
+@@ -404,7 +407,7 @@ class ResponseDrop(ResponseAction):
+ Action which does nothing - as if a packet was dropped.
+ """
+
+- async def perform(self) -> Optional[Union[dns.message.Message, bytes]]:
++ async def perform(self) -> dns.message.Message | bytes | None:
+ return None
+
+
+@@ -413,17 +416,16 @@ class _ConnectionTeardownRequested(Exception):
+
+
+ @dataclass
+-class ResponseDropAndCloseConnection(ResponseAction):
++class CloseConnection(ResponseAction):
+ """
+- Action which makes the server close the connection after the DNS query is
+- received by the server (TCP only).
++ Action which makes the server close the connection (TCP only).
+
+ The connection may be closed with a delay if requested.
+ """
+
+ delay: float = 0.0
+
+- async def perform(self) -> Optional[Union[dns.message.Message, bytes]]:
++ async def perform(self) -> dns.message.Message | bytes | None:
+ if self.delay > 0:
+ logging.info("Waiting %.1fs before closing TCP connection", self.delay)
+ await asyncio.sleep(self.delay)
+@@ -505,7 +507,7 @@ class IgnoreAllConnections(ConnectionHandler):
+ client socket, effectively ignoring all incoming connections.
+ """
+
+- _connections: Set[asyncio.StreamWriter] = field(default_factory=set)
++ _connections: set[asyncio.StreamWriter] = field(default_factory=set)
+
+ async def handle(
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, peer: Peer
+@@ -539,8 +541,8 @@ class ConnectionReset(ConnectionHandler):
+ make the server send an RST segment; this happens when the server closes a
+ client's socket while there is still unread data in that socket's buffer.
+ If closing the connection _after_ the query is read by the server is enough
+- for a given use case, the ResponseDropAndCloseConnection response handler
+- should be used instead.
++ for a given use case, the CloseConnection response handler should be used
++ instead.
+ """
+
+ delay: float = 0.0
+@@ -616,14 +618,14 @@ class QnameHandler(ResponseHandler):
+
+ @property
+ @abc.abstractmethod
+- def qnames(self) -> List[str]:
++ def qnames(self) -> list[str]:
+ """
+ A list of QNAMEs handled by this class.
+ """
+ raise NotImplementedError
+
+ def __init__(self) -> None:
+- self._qnames: List[dns.name.Name] = [dns.name.from_text(d) for d in self.qnames]
++ self._qnames: list[dns.name.Name] = [dns.name.from_text(d) for d in self.qnames]
+
+ def __str__(self) -> str:
+ return f"{self.__class__.__name__}(QNAMEs: {', '.join(self.qnames)})"
+@@ -636,6 +638,105 @@ class QnameHandler(ResponseHandler):
+ return qctx.qname in self._qnames
+
+
++class QnameQtypeHandler(QnameHandler):
++ """
++ Handle queries for which both of the following conditions are true:
++
++ - the query's QNAME is present in `self.qnames`,
++ - the query's QTYPE is present in `self.qtypes`.
++ """
++
++ @property
++ @abc.abstractmethod
++ def qtypes(self) -> list[dns.rdatatype.RdataType]:
++ """
++ A list of QTYPEs handled by this class.
++ """
++ raise NotImplementedError
++
++ def __init__(self) -> None:
++ super().__init__()
++ self._qtypes: list[dns.rdatatype.RdataType] = self.qtypes
++
++ def __str__(self) -> str:
++ return f"{self.__class__.__name__}(QNAMEs: {', '.join(self.qnames)}; QTYPEs: {', '.join(map(str, self.qtypes))})"
++
++ def match(self, qctx: QueryContext) -> bool:
++ """
++ Handle queries whose QNAME and QTYPE match any of the QNAMEs and
++ QTYPEs handled by this class.
++ """
++ return qctx.qtype in self._qtypes and super().match(qctx)
++
++
++class StaticResponseHandler(ResponseHandler):
++ """
++ Base class used for deriving custom static response handlers.
++
++ The derived class can specify the RRsets to be included in the answer,
++ authority, and additional sections of the response, whether to set the AA
++ bit in the response, and a delay before sending the response.
++
++ The default implementation of `get_responses()` uses these properties to
++ prepare and yield a single response.
++ """
++
++ @property
++ def rcode(self) -> dns.rcode.Rcode | None:
++ """
++ Optional RCODE to be set in the response.
++ """
++ return None
++
++ @property
++ def answer(self) -> Sequence[dns.rrset.RRset]:
++ """
++ RRsets to be included in the answer section of the response.
++ """
++ return []
++
++ @property
++ def authority(self) -> Sequence[dns.rrset.RRset]:
++ """
++ RRsets to be included in the authority section of the response.
++ """
++ return []
++
++ @property
++ def additional(self) -> Sequence[dns.rrset.RRset]:
++ """
++ RRsets to be included in the additional section of the response.
++ """
++ return []
++
++ @property
++ def authoritative(self) -> bool | None:
++ """
++ Whether to set the AA bit in the response.
++ """
++ return None
++
++ @property
++ def delay(self) -> float:
++ """
++ Delay before sending the response.
++ """
++ return 0.0
++
++ async def get_responses(
++ self, qctx: QueryContext
++ ) -> AsyncGenerator[DnsResponseSend, None]:
++ qctx.prepare_new_response(with_zone_data=False)
++ qctx.response.answer.extend(self.answer)
++ qctx.response.authority.extend(self.authority)
++ qctx.response.additional.extend(self.additional)
++ if self.rcode is not None:
++ qctx.response.set_rcode(self.rcode)
++ yield DnsResponseSend(
++ qctx.response, authoritative=self.authoritative, delay=self.delay
++ )
++
++
+ class DomainHandler(ResponseHandler):
+ """
+ Base class used for deriving custom domain handlers.
+@@ -643,20 +744,28 @@ class DomainHandler(ResponseHandler):
+ The derived class must specify a list of `domains` that it wants to handle.
+ Queries for any of these domains (and their subdomains) will then be passed
+ to the `get_response()` method in the derived class.
++
++ The most specific matching domain is stored in the `matched_domain` attribute.
+ """
+
+ @property
+ @abc.abstractmethod
+- def domains(self) -> List[str]:
++ def domains(self) -> list[str]:
+ """
+ A list of domain names handled by this class.
+ """
+ raise NotImplementedError
+
+ def __init__(self) -> None:
+- self._domains: List[dns.name.Name] = [
+- dns.name.from_text(d) for d in self.domains
+- ]
++ self._domains: list[dns.name.Name] = sorted(
++ [dns.name.from_text(d) for d in self.domains], reverse=True
++ )
++ self._matched_domain: dns.name.Name | None = None
++
++ @property
++ def matched_domain(self) -> dns.name.Name:
++ assert self._matched_domain is not None
++ return self._matched_domain
+
+ def __str__(self) -> str:
+ return f"{self.__class__.__name__}(domains: {', '.join(self.domains)})"
+@@ -666,20 +775,124 @@ class DomainHandler(ResponseHandler):
+ Handle queries whose QNAME matches any of the domains handled by this
+ class.
+ """
++ self._matched_domain = None
+ for domain in self._domains:
+ if qctx.qname.is_subdomain(domain):
++ self._matched_domain = domain
+ return True
+ return False
+
+
++class ForwarderHandler(ResponseHandler):
++ """
++ A handler forwarding all received queries to another DNS server with an
++ optional delay and then relaying the responses back to the original client.
++
++ Queries are currently always forwarded via UDP.
++ """
++
++ @property
++ @abc.abstractmethod
++ def target(self) -> str:
++ """
++ The address of the DNS server to forward queries to.
++ """
++ raise NotImplementedError
++
++ @property
++ def port(self) -> int:
++ """
++ The port of the DNS server to forward queries to.
++
++ The default value of 0 causes the same port as the one used by this
++ server for listening to be used.
++ """
++ return 0
++
++ @property
++ def delay(self) -> float:
++ """
++ The number of seconds to wait before forwarding each query.
++ """
++ return 0.0
++
++ def __str__(self) -> str:
++ return f"{self.__class__.__name__}(target: {self.target}:{self.port})"
++
++ class ForwarderProtocol(asyncio.DatagramProtocol):
++ def __init__(self, query: bytes, response: asyncio.Future) -> None:
++ self._query = query
++ self._response = response
++
++ def connection_made(self, transport: asyncio.BaseTransport) -> None:
++ logging.debug("[OUT] %s", self._query.hex())
++ cast(asyncio.DatagramTransport, transport).sendto(self._query)
++
++ def datagram_received(self, data: bytes, _: tuple[str, int]) -> None:
++ logging.debug("[IN] %s", data.hex())
++ self._response.set_result(data)
++
++ async def get_responses(
++ self, qctx: QueryContext
++ ) -> AsyncGenerator[ResponseAction, None]:
++ loop = asyncio.get_running_loop()
++ response = loop.create_future()
++ forwarding_target = f"{self.target}:{self.port or qctx.socket.port}"
++
++ if self.delay > 0:
++ logging.info(
++ "Waiting %.1fs before forwarding %s query from %s to %s over UDP",
++ self.delay,
++ qctx.protocol.name,
++ qctx.peer,
++ forwarding_target,
++ )
++ await asyncio.sleep(self.delay)
++
++ logging.info(
++ "Forwarding %s query from %s to %s over UDP",
++ qctx.protocol.name,
++ qctx.peer,
++ forwarding_target,
++ )
++
++ transport, _ = await loop.create_datagram_endpoint(
++ lambda: self.ForwarderProtocol(qctx.query.to_wire(), response),
++ local_addr=(qctx.socket.host, 0),
++ remote_addr=(self.target, self.port or qctx.socket.port),
++ )
++
++ try:
++ await response
++ finally:
++ transport.close()
++
++ logging.info(
++ "Relaying UDP response from %s to %s over %s",
++ forwarding_target,
++ qctx.peer,
++ qctx.protocol.name,
++ )
++
++ try:
++ message = _DnsMessageWithTsigDisabled.from_wire(response.result())
++ yield DnsResponseSend(message, acknowledge_hand_rolled_response=True)
++ except dns.exception.DNSException:
++ logging.warning(
++ "Failed to parse response from %s as a DNS message, relaying it as raw bytes",
++ forwarding_target,
++ )
++ yield BytesResponseSend(response.result())
++
++
+ @dataclass
+ class _ZoneTreeNode:
+ """
+ A node representing a zone with one origin.
+ """
+
+- zone: Optional[dns.zone.Zone]
+- children: List["_ZoneTreeNode"] = field(default_factory=list)
++ zone: dns.zone.Zone | None
++ children: list["_ZoneTreeNode"] = field(default_factory=list)
+
+
+ class _ZoneTree:
+@@ -729,7 +942,7 @@ class _ZoneTree:
+ node_from.children.remove(child)
+ node_to.children.append(child)
+
+- def find_best_zone(self, name: dns.name.Name) -> Optional[dns.zone.Zone]:
++ def find_best_zone(self, name: dns.name.Name) -> dns.zone.Zone | None:
+ """
+ Return the closest matching zone (if any) for the domain name.
+ """
+@@ -747,7 +960,7 @@ class _DnsMessageWithTsigDisabled(dns.message.Message):
+ """
+
+ class _DisableTsigHandling(contextlib.ContextDecorator):
+- def __init__(self, message: Optional[dns.message.Message] = None) -> None:
++ def __init__(self, message: dns.message.Message | None = None) -> None:
+ self.original_tsig_sign = dns.tsig.sign
+ self.original_tsig_validate = dns.tsig.validate
+ if message:
+@@ -759,7 +972,7 @@ class _DnsMessageWithTsigDisabled(dns.message.Message):
+ from failing on messages initialized with `dns.message.from_wire(keyring=False)`.
+ """
+
+- def sign(*_: Any, **__: Any) -> Tuple[dns.rdata.Rdata, None]:
++ def sign(*_: Any, **__: Any) -> tuple[dns.rdata.Rdata, None]:
+ assert self.tsig
+ return self.tsig[0], None
+
+@@ -802,6 +1015,19 @@ class _NoKeyringType:
+ pass
+
+
++_ASYNCSERVER_RESPONSE_MARKER = "__is_asyncserver_response__"
++
++
++def _make_asyncserver_response(query: dns.message.Message) -> dns.message.Message:
++ response = dns.message.make_response(query)
++ setattr(response, _ASYNCSERVER_RESPONSE_MARKER, True)
++ return response
++
++
++def _is_asyncserver_response(message: dns.message.Message) -> bool:
++ return getattr(message, _ASYNCSERVER_RESPONSE_MARKER, False)
++
++
+ class AsyncDnsServer(AsyncServer):
+ """
+ DNS server which responds to queries based on zone data and/or custom
+@@ -822,17 +1048,17 @@ class AsyncDnsServer(AsyncServer):
+ self,
+ /,
+ default_rcode: dns.rcode.Rcode = dns.rcode.REFUSED,
+- default_aa: bool = True,
+- keyring: Union[
+- Dict[dns.name.Name, dns.tsig.Key], None, _NoKeyringType
+- ] = _NoKeyringType(),
++ default_aa: bool = False,
++ keyring: (
++ dict[dns.name.Name, dns.tsig.Key] | None | _NoKeyringType
++ ) = _NoKeyringType(),
+ acknowledge_manual_dname_handling: bool = False,
+ ) -> None:
+ super().__init__(self._handle_udp, self._handle_tcp, "ans.pid")
+
+ self._zone_tree: _ZoneTree = _ZoneTree()
+- self._connection_handler: Optional[ConnectionHandler] = None
+- self._response_handlers: List[ResponseHandler] = []
++ self._connection_handler: ConnectionHandler | None = None
++ self._response_handlers: list[ResponseHandler] = []
+ self._default_rcode = default_rcode
+ self._default_aa = default_aa
+ self._keyring = keyring
+@@ -859,10 +1085,18 @@ class AsyncDnsServer(AsyncServer):
+ else:
+ self._response_handlers.append(handler)
+
+- def install_response_handlers(self, handlers: List[ResponseHandler]) -> None:
++ def install_response_handlers(self, *handlers: ResponseHandler) -> None:
+ for handler in handlers:
+ self.install_response_handler(handler)
+
++ def replace_response_handlers(self, *new_handlers: ResponseHandler) -> None:
++ """
++ Uninstall all currently installed handlers and install the provided ones.
++ """
++ logging.info("Uninstalling response handlers: %s", str(self._response_handlers))
++ self._response_handlers.clear()
++ self.install_response_handlers(*new_handlers)
++
+ def uninstall_response_handler(self, handler: ResponseHandler) -> None:
+ """
+ Remove the specified handler from the list of response handlers.
+@@ -933,11 +1167,13 @@ class AsyncDnsServer(AsyncServer):
+ raise ValueError(error)
+
+ async def _handle_udp(
+- self, wire: bytes, addr: Tuple[str, int], transport: asyncio.DatagramTransport
++ self, wire: bytes, addr: tuple[str, int], transport: asyncio.DatagramTransport
+ ) -> None:
+ logging.debug("Received UDP message: %s", wire.hex())
++ socket_info = transport.get_extra_info("sockname")
++ socket = Peer(socket_info[0], socket_info[1])
+ peer = Peer(addr[0], addr[1])
+- responses = self._handle_query(wire, peer, DnsProtocol.UDP)
++ responses = self._handle_query(wire, socket, peer, DnsProtocol.UDP)
+ async for response in responses:
+ logging.debug("Sending UDP message: %s", response.hex())
+ transport.sendto(response, addr)
+@@ -974,7 +1210,7 @@ class AsyncDnsServer(AsyncServer):
+
+ async def _read_tcp_query(
+ self, reader: asyncio.StreamReader, peer: Peer
+- ) -> Optional[bytes]:
++ ) -> bytes | None:
+ wire_length = await self._read_tcp_query_wire_length(reader, peer)
+ if not wire_length:
+ return None
+@@ -983,7 +1219,7 @@ class AsyncDnsServer(AsyncServer):
+
+ async def _read_tcp_query_wire_length(
+ self, reader: asyncio.StreamReader, peer: Peer
+- ) -> Optional[int]:
++ ) -> int | None:
+ logging.debug("Receiving TCP message length from %s...", peer)
+
+ wire_length_bytes = await self._read_tcp_octets(reader, peer, 2)
+@@ -996,7 +1232,7 @@ class AsyncDnsServer(AsyncServer):
+
+ async def _read_tcp_query_wire(
+ self, reader: asyncio.StreamReader, peer: Peer, wire_length: int
+- ) -> Optional[bytes]:
++ ) -> bytes | None:
+ logging.debug("Receiving TCP message (%d octets) from %s...", wire_length, peer)
+
+ wire = await self._read_tcp_octets(reader, peer, wire_length)
+@@ -1009,7 +1245,7 @@ class AsyncDnsServer(AsyncServer):
+
+ async def _read_tcp_octets(
+ self, reader: asyncio.StreamReader, peer: Peer, expected: int
+- ) -> Optional[bytes]:
++ ) -> bytes | None:
+ buffer = b""
+
+ while len(buffer) < expected:
+@@ -1034,39 +1270,39 @@ class AsyncDnsServer(AsyncServer):
+ async def _send_tcp_response(
+ self, writer: asyncio.StreamWriter, peer: Peer, wire: bytes
+ ) -> None:
+- responses = self._handle_query(wire, peer, DnsProtocol.TCP)
++ socket_info = writer.get_extra_info("sockname")
++ socket = Peer(socket_info[0], socket_info[1])
++ responses = self._handle_query(wire, socket, peer, DnsProtocol.TCP)
+ async for response in responses:
+ logging.debug("Sending TCP response: %s", response.hex())
+ writer.write(response)
+ await writer.drain()
+
+- def _log_query(self, qctx: QueryContext, peer: Peer, protocol: DnsProtocol) -> None:
++ def _log_query(self, qctx: QueryContext) -> None:
+ logging.info(
+- "Received %s/%s/%s (ID=%d) query from %s (%s)",
++ "Received %s/%s/%s (ID=%d) query from %s on %s (%s)",
+ qctx.qname.to_text(omit_final_dot=True),
+ dns.rdataclass.to_text(qctx.qclass),
+ dns.rdatatype.to_text(qctx.qtype),
+ qctx.query.id,
+- peer,
+- protocol.name,
++ qctx.peer,
++ qctx.socket,
++ qctx.protocol.name,
+ )
+ logging.debug(
+ "\n".join([f"[IN] {l}" for l in [""] + str(qctx.query).splitlines()])
+ )
+
+ def _log_response(
+- self,
+- qctx: QueryContext,
+- response: Optional[Union[dns.message.Message, bytes]],
+- peer: Peer,
+- protocol: DnsProtocol,
++ self, qctx: QueryContext, response: dns.message.Message | bytes | None
+ ) -> None:
+ if not response:
+ logging.info(
+- "Not sending a response to query (ID=%d) from %s (%s)",
++ "Not sending a response to query (ID=%d) from %s on %s (%s)",
+ qctx.query.id,
+- peer,
+- protocol.name,
++ qctx.peer,
++ qctx.socket,
++ qctx.protocol.name,
+ )
+ return
+
+@@ -1081,7 +1317,7 @@ class AsyncDnsServer(AsyncServer):
+ qtype = "-"
+
+ logging.info(
+- "Sending %s/%s/%s (ID=%d) response (%d/%d/%d/%d) to a query (ID=%d) from %s (%s)",
++ "Sending %s/%s/%s (ID=%d) response (%d/%d/%d/%d) to a query (ID=%d) from %s on %s (%s)",
+ qname,
+ qclass,
+ qtype,
+@@ -1091,8 +1327,9 @@ class AsyncDnsServer(AsyncServer):
+ len(response.authority),
+ len(response.additional),
+ qctx.query.id,
+- peer,
+- protocol.name,
++ qctx.peer,
++ qctx.socket,
++ qctx.protocol.name,
+ )
+ logging.debug(
+ "\n".join([f"[OUT] {l}" for l in [""] + str(response).splitlines()])
+@@ -1100,16 +1337,17 @@ class AsyncDnsServer(AsyncServer):
+ return
+
+ logging.info(
+- "Sending response (%d bytes) to a query (ID=%d) from %s (%s)",
++ "Sending response (%d bytes) to a query (ID=%d) from %s on %s (%s)",
+ len(response),
+ qctx.query.id,
+- peer,
+- protocol.name,
++ qctx.peer,
++ qctx.socket,
++ qctx.protocol.name,
+ )
+ logging.debug("[OUT] %s", response.hex())
+
+ async def _handle_query(
+- self, wire: bytes, peer: Peer, protocol: DnsProtocol
++ self, wire: bytes, socket: Peer, peer: Peer, protocol: DnsProtocol
+ ) -> AsyncGenerator[bytes, None]:
+ """
+ Yield wire data to send as a response over the established transport.
+@@ -1119,12 +1357,12 @@ class AsyncDnsServer(AsyncServer):
+ except dns.exception.DNSException as exc:
+ logging.error("Invalid query from %s (%s): %s", peer, wire.hex(), exc)
+ return
+- response_stub = dns.message.make_response(query)
+- qctx = QueryContext(query, response_stub, peer, protocol)
+- self._log_query(qctx, peer, protocol)
++ response_stub = _make_asyncserver_response(query)
++ qctx = QueryContext(query, response_stub, socket, peer, protocol)
++ self._log_query(qctx)
+ responses = self._prepare_responses(qctx)
+ async for response in responses:
+- self._log_response(qctx, response, peer, protocol)
++ self._log_response(qctx, response)
+ if response:
+ if isinstance(response, dns.message.Message):
+ response = response.to_wire(max_size=65535)
+@@ -1156,7 +1394,7 @@ class AsyncDnsServer(AsyncServer):
+
+ async def _prepare_responses(
+ self, qctx: QueryContext
+- ) -> AsyncGenerator[Optional[Union[dns.message.Message, bytes]], None]:
++ ) -> AsyncGenerator[dns.message.Message | bytes | None, None]:
+ """
+ Yield response(s) either from response handlers or zone data.
+ """
+@@ -1349,10 +1587,10 @@ class ControllableAsyncDnsServer(AsyncDnsServer):
+ return dns.name.from_text(self._CONTROL_DOMAIN)
+
+ @functools.cached_property
+- def _commands(self) -> Dict[dns.name.Name, "ControlCommand"]:
++ def _commands(self) -> dict[dns.name.Name, "ControlCommand"]:
+ return {}
+
+- def install_control_commands(self, commands: List["ControlCommand"]) -> None:
++ def install_control_commands(self, *commands: "ControlCommand") -> None:
+ for command in commands:
+ self.install_control_command(command)
+
+@@ -1370,7 +1608,7 @@ class ControllableAsyncDnsServer(AsyncDnsServer):
+
+ async def _prepare_responses(
+ self, qctx: QueryContext
+- ) -> AsyncGenerator[Optional[Union[dns.message.Message, bytes]], None]:
++ ) -> AsyncGenerator[dns.message.Message | bytes | None, None]:
+ """
+ Detect and handle control queries, falling back to normal processing
+ for non-control queries.
+@@ -1383,9 +1621,7 @@ class ControllableAsyncDnsServer(AsyncDnsServer):
+ async for response in super()._prepare_responses(qctx):
+ yield response
+
+- def _handle_control_command(
+- self, qctx: QueryContext
+- ) -> Optional[dns.message.Message]:
++ def _handle_control_command(self, qctx: QueryContext) -> dns.message.Message | None:
+ """
+ Detect and handle control queries.
+
+@@ -1460,8 +1696,8 @@ class ControlCommand(abc.ABC):
+
+ @abc.abstractmethod
+ def handle(
+- self, args: List[str], server: ControllableAsyncDnsServer, qctx: QueryContext
+- ) -> Optional[str]:
++ self, args: list[str], server: ControllableAsyncDnsServer, qctx: QueryContext
++ ) -> str | None:
+ """
+ This method is expected to carry out arbitrary actions in response to a
+ control query. Note that it is invoked synchronously (it is not a
+@@ -1499,11 +1735,11 @@ class ToggleResponsesCommand(ControlCommand):
+ control_subdomain = "send-responses"
+
+ def __init__(self) -> None:
+- self._current_handler: Optional[IgnoreAllQueries] = None
++ self._current_handler: IgnoreAllQueries | None = None
+
+ def handle(
+- self, args: List[str], server: ControllableAsyncDnsServer, qctx: QueryContext
+- ) -> Optional[str]:
++ self, args: list[str], server: ControllableAsyncDnsServer, qctx: QueryContext
++ ) -> str | None:
+ if len(args) != 1:
+ logging.error("Invalid %s query %s", self, qctx.qname)
+ qctx.response.set_rcode(dns.rcode.SERVFAIL)
+@@ -1528,3 +1764,30 @@ class ToggleResponsesCommand(ControlCommand):
+ logging.error("Unrecognized response sending mode '%s'", mode)
+ qctx.response.set_rcode(dns.rcode.SERVFAIL)
+ return f"unrecognized response sending mode '{mode}'"
++
++
++class SwitchControlCommand(ControlCommand):
++ """
++ Switch the server's response handlers based on the control query.
++
++ A sequence of response handlers is associated with each key. When a
++ control query is received, the server's response handlers are replaced
++ with the sequence associated with the key extracted from the control
++ query.
++ """
++
++ control_subdomain = "switch"
++
++ def __init__(self, handler_mapping: dict[str, Sequence[ResponseHandler]]):
++ self._handler_mapping = handler_mapping
++
++ def handle(
++ self, args: list[str], server: ControllableAsyncDnsServer, qctx: QueryContext
++ ) -> str | None:
++ if len(args) != 1 or args[0] not in self._handler_mapping:
++ logging.error("Invalid %s query %s", self, qctx.qname)
++ qctx.response.set_rcode(dns.rcode.SERVFAIL)
++ return f"invalid query; exactly one of {list(self._handler_mapping.keys())} is expected in QNAME"
++
++ server.replace_response_handlers(*self._handler_mapping[args[0]])
++ return f"switched to handler set '{args[0]}'"
+--
+2.35.6
+
new file mode 100644
@@ -0,0 +1,554 @@
+From 1e0b72f5cee9fce3c4beb782fd5e4a17efa84223 Mon Sep 17 00:00:00 2001
+From: Colin Vidal <colin@isc.org>
+Date: Wed, 4 Mar 2026 18:25:32 +0100
+Subject: [PATCH] Add SRTT-based server selection system test
+
+Verify that the resolver selects authoritative servers in increasing
+SRTT order. Four servers are configured with increasing response
+delays. 100 queries are sent, expecting most to go to the fastest
+server (ns2). Then ns2 stops responding, another 100 queries are
+sent and should go to ns3 (the next fastest), and so on through
+ns4 and ns5. Each query uses a unique name to avoid cache hits.
+
+CVE: CVE-2026-3592
+Upstream-Status: Backport [https://gitlab.com/isc-projects/bind9/-/commit/d5cd9b71ebadf7c0c76f09c5bbb65b6a7b944d0d]
+
+(cherry picked from commit a8d11e14f5b4e4d53219ba751d1b741162b0b84b)
+(cherry picked from commit d5cd9b71ebadf7c0c76f09c5bbb65b6a7b944d0d)
+Signed-off-by: Ashishkumar Parmar <asparmar@cisco.com>
+---
+ bin/tests/system/srtt/README | 18 ++++++
+ bin/tests/system/srtt/ans2/ans.py | 36 +++++++++++
+ bin/tests/system/srtt/ans3/ans.py | 36 +++++++++++
+ bin/tests/system/srtt/ans4/ans.py | 36 +++++++++++
+ bin/tests/system/srtt/ans5/ans.py | 36 +++++++++++
+ bin/tests/system/srtt/ns1/named.conf.j2 | 29 +++++++++
+ bin/tests/system/srtt/ns1/root.db | 36 +++++++++++
+ bin/tests/system/srtt/ns6/named.args | 1 +
+ bin/tests/system/srtt/ns6/named.conf.j2 | 41 ++++++++++++
+ bin/tests/system/srtt/prereq.sh | 20 ++++++
+ bin/tests/system/srtt/srtt_ans.py | 59 +++++++++++++++++
+ bin/tests/system/srtt/tests_srtt.py | 86 +++++++++++++++++++++++++
+ 12 files changed, 434 insertions(+)
+ create mode 100644 bin/tests/system/srtt/README
+ create mode 100644 bin/tests/system/srtt/ans2/ans.py
+ create mode 100644 bin/tests/system/srtt/ans3/ans.py
+ create mode 100644 bin/tests/system/srtt/ans4/ans.py
+ create mode 100644 bin/tests/system/srtt/ans5/ans.py
+ create mode 100644 bin/tests/system/srtt/ns1/named.conf.j2
+ create mode 100644 bin/tests/system/srtt/ns1/root.db
+ create mode 100644 bin/tests/system/srtt/ns6/named.args
+ create mode 100644 bin/tests/system/srtt/ns6/named.conf.j2
+ create mode 100644 bin/tests/system/srtt/prereq.sh
+ create mode 100644 bin/tests/system/srtt/srtt_ans.py
+ create mode 100644 bin/tests/system/srtt/tests_srtt.py
+
+diff --git a/bin/tests/system/srtt/README b/bin/tests/system/srtt/README
+new file mode 100644
+index 0000000000..c86a697931
+--- /dev/null
++++ b/bin/tests/system/srtt/README
+@@ -0,0 +1,18 @@
++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.
++
++ns1 is root
++
++ans{2-5} simulates four NS servers making authority on the same domain
++`example.`. ans2 is the quickest to answer, followed by ans3, then ans4, with
++ans5 being the slowest.
++
++ns6 is a resolver
+diff --git a/bin/tests/system/srtt/ans2/ans.py b/bin/tests/system/srtt/ans2/ans.py
+new file mode 100644
+index 0000000000..f7c6f8e71b
+--- /dev/null
++++ b/bin/tests/system/srtt/ans2/ans.py
+@@ -0,0 +1,36 @@
++"""
++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 dns.rcode
++
++from isctest.asyncserver import AsyncDnsServer, IgnoreAllQueries
++
++from srtt_ans import DelayedQnameRangeHandler
++
++
++class Foo1ToFoo99Handler(DelayedQnameRangeHandler):
++ max_qname = 99
++ delay = 0.0
++
++
++def main() -> None:
++ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
++ server.install_response_handlers(
++ Foo1ToFoo99Handler(),
++ IgnoreAllQueries(),
++ )
++ server.run()
++
++
++if __name__ == "__main__":
++ main()
+diff --git a/bin/tests/system/srtt/ans3/ans.py b/bin/tests/system/srtt/ans3/ans.py
+new file mode 100644
+index 0000000000..5f61e19cd5
+--- /dev/null
++++ b/bin/tests/system/srtt/ans3/ans.py
+@@ -0,0 +1,36 @@
++"""
++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 dns.rcode
++
++from isctest.asyncserver import AsyncDnsServer, IgnoreAllQueries
++
++from srtt_ans import DelayedQnameRangeHandler
++
++
++class Foo1ToFoo199Handler(DelayedQnameRangeHandler):
++ max_qname = 199
++ delay = 0.03
++
++
++def main() -> None:
++ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
++ server.install_response_handlers(
++ Foo1ToFoo199Handler(),
++ IgnoreAllQueries(),
++ )
++ server.run()
++
++
++if __name__ == "__main__":
++ main()
+diff --git a/bin/tests/system/srtt/ans4/ans.py b/bin/tests/system/srtt/ans4/ans.py
+new file mode 100644
+index 0000000000..2e12b0ba7d
+--- /dev/null
++++ b/bin/tests/system/srtt/ans4/ans.py
+@@ -0,0 +1,36 @@
++"""
++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 dns.rcode
++
++from isctest.asyncserver import AsyncDnsServer, IgnoreAllQueries
++
++from srtt_ans import DelayedQnameRangeHandler
++
++
++class Foo1ToFoo299Handler(DelayedQnameRangeHandler):
++ max_qname = 299
++ delay = 0.08
++
++
++def main() -> None:
++ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
++ server.install_response_handlers(
++ Foo1ToFoo299Handler(),
++ IgnoreAllQueries(),
++ )
++ server.run()
++
++
++if __name__ == "__main__":
++ main()
+diff --git a/bin/tests/system/srtt/ans5/ans.py b/bin/tests/system/srtt/ans5/ans.py
+new file mode 100644
+index 0000000000..b40306908c
+--- /dev/null
++++ b/bin/tests/system/srtt/ans5/ans.py
+@@ -0,0 +1,36 @@
++"""
++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 dns.rcode
++
++from isctest.asyncserver import AsyncDnsServer, IgnoreAllQueries
++
++from srtt_ans import DelayedQnameRangeHandler
++
++
++class Foo1ToFoo399Handler(DelayedQnameRangeHandler):
++ max_qname = 399
++ delay = 0.15
++
++
++def main() -> None:
++ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
++ server.install_response_handlers(
++ Foo1ToFoo399Handler(),
++ IgnoreAllQueries(),
++ )
++ server.run()
++
++
++if __name__ == "__main__":
++ main()
+diff --git a/bin/tests/system/srtt/ns1/named.conf.j2 b/bin/tests/system/srtt/ns1/named.conf.j2
+new file mode 100644
+index 0000000000..eb079c95ab
+--- /dev/null
++++ b/bin/tests/system/srtt/ns1/named.conf.j2
+@@ -0,0 +1,29 @@
++/*
++ * 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; };
++ recursion no;
++ notify yes;
++};
++
++zone "." {
++ type primary;
++ file "root.db";
++};
+diff --git a/bin/tests/system/srtt/ns1/root.db b/bin/tests/system/srtt/ns1/root.db
+new file mode 100644
+index 0000000000..29ecd1d89d
+--- /dev/null
++++ b/bin/tests/system/srtt/ns1/root.db
+@@ -0,0 +1,36 @@
++; 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.
++
++$TTL 300
++. IN SOA owner.root-servers.nil. a.root-servers.nil. (
++ 2000042100 ; serial
++ 600 ; refresh
++ 600 ; retry
++ 1200 ; expire
++ 600 ; minimum
++ )
++. NS a.root-servers.nil.
++a.root-servers.nil. A 10.53.0.1
++
++; The idea is that the resolver would do 2 ADB lookups, so there would be 2
++; find list, both with 2 IPs in it. ns1 (which is actually ans2 and ans5) would
++; have both the slowest and fastest addresses. ns2 (which is actually ans3 and
++; ans4) would have two addresses in the middle.
++
++example. NS ns1.example.
++example. NS ns1.example.
++example. NS ns2.example.
++example. NS ns2.example.
++
++ns1.example. A 10.53.0.2 ; delay is 0
++ns1.example. A 10.53.0.5 ; delay is 0.15
++ns2.example. A 10.53.0.4 ; delay is 0.08
++ns2.example. A 10.53.0.3 ; delay is 0.03
+diff --git a/bin/tests/system/srtt/ns6/named.args b/bin/tests/system/srtt/ns6/named.args
+new file mode 100644
+index 0000000000..b5de5874ec
+--- /dev/null
++++ b/bin/tests/system/srtt/ns6/named.args
+@@ -0,0 +1 @@
++-D srtt-ns6 -m record -c named.conf -d 99 -g -T maxcachesize=2097152 -4
+diff --git a/bin/tests/system/srtt/ns6/named.conf.j2 b/bin/tests/system/srtt/ns6/named.conf.j2
+new file mode 100644
+index 0000000000..1d27505a8e
+--- /dev/null
++++ b/bin/tests/system/srtt/ns6/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.6;
++ notify-source 10.53.0.6;
++ transfer-source 10.53.0.6;
++ port @PORT@;
++ pid-file "named.pid";
++ listen-on { 10.53.0.6; };
++ listen-on-v6 { none; };
++ recursion yes;
++ dnssec-validation no;
++ dnstap { resolver query; };
++ dnstap-output file "dnstap.out";
++};
++
++key rndc_key {
++ secret "1234abcd8765";
++ algorithm @DEFAULT_HMAC@;
++};
++
++controls {
++ inet 10.53.0.6 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
++};
++
++zone "." {
++ type hint;
++ file "../../_common/root.hint";
++};
+diff --git a/bin/tests/system/srtt/prereq.sh b/bin/tests/system/srtt/prereq.sh
+new file mode 100644
+index 0000000000..747f448982
+--- /dev/null
++++ b/bin/tests/system/srtt/prereq.sh
+@@ -0,0 +1,20 @@
++#!/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
++
++$FEATURETEST --enable-dnstap || {
++ echo_i "This test requires dnstap support." >&2
++ exit 255
++}
++exit 0
+diff --git a/bin/tests/system/srtt/srtt_ans.py b/bin/tests/system/srtt/srtt_ans.py
+new file mode 100644
+index 0000000000..9387486993
+--- /dev/null
++++ b/bin/tests/system/srtt/srtt_ans.py
+@@ -0,0 +1,59 @@
++"""
++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.
++"""
++
++from collections.abc import AsyncGenerator
++
++import abc
++
++import dns.rdataclass
++import dns.rdatatype
++import dns.rrset
++
++from isctest.asyncserver import DnsResponseSend, QnameQtypeHandler, QueryContext
++
++
++class DelayedQnameRangeHandler(QnameQtypeHandler):
++ """
++ Respond to queries for QNAMEs "foo1.example." through "foo<N>.example."
++ with QTYPE=A, where <N> must be defined by the subclass. Every response is
++ delayed by a fixed amount of time, which must also be defined (in seconds)
++ by the subclass.
++ """
++
++ @property
++ def qnames(self) -> list[str]:
++ return [f"foo{x}.example." for x in range(1, self.max_qname + 1)]
++
++ qtypes = [dns.rdatatype.A]
++
++ @property
++ @abc.abstractmethod
++ def max_qname(self) -> int:
++ raise NotImplementedError
++
++ @property
++ @abc.abstractmethod
++ def delay(self) -> float:
++ raise NotImplementedError
++
++ def __str__(self) -> str:
++ return f"{self.__class__.__name__}(foo[1-{self.max_qname}].example/A)"
++
++ async def get_responses(
++ self, qctx: QueryContext
++ ) -> AsyncGenerator[DnsResponseSend, None]:
++ a_rrset = dns.rrset.from_text(
++ qctx.qname, 300, dns.rdataclass.IN, dns.rdatatype.A, "10.53.9.9"
++ )
++ qctx.response.answer.append(a_rrset)
++ yield DnsResponseSend(qctx.response, delay=self.delay)
+diff --git a/bin/tests/system/srtt/tests_srtt.py b/bin/tests/system/srtt/tests_srtt.py
+new file mode 100644
+index 0000000000..55611922a7
+--- /dev/null
++++ b/bin/tests/system/srtt/tests_srtt.py
+@@ -0,0 +1,86 @@
++# 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 os
++
++import isctest
++
++
++def line_to_dst_ips(line):
++ # dnstap-read output line example
++ # 05-Feb-2026 11:00:57.853 RQ 10.53.0.6:38507 -> 10.53.0.3:22047 TCP 56b fooXXX.example./IN/NS
++ _, _, _, _, _, dst, _, _, _ = line.split(" ", 9)
++ ip, _ = dst.split(":", 1)
++ return ip
++
++
++def extract_dnstap(ns, nsid):
++ ns.rndc("dnstap -roll 1")
++ path = os.path.join(nsid, "dnstap.out.0")
++ dnstapread = isctest.run.cmd(
++ [os.getenv("DNSTAPREAD"), path],
++ )
++
++ lines = dnstapread.out.splitlines()
++ return map(line_to_dst_ips, lines)
++
++
++def assert_used_auth(ns, nsid, authip):
++ ips = extract_dnstap(ns, nsid)
++ queries = 0
++ matches = 0
++ for ip in ips:
++ queries += 1
++ if ip == authip:
++ matches += 1
++ assert matches > 85
++ assert queries <= 115
++
++
++def test_srtt(ns6):
++ for i in range(1, 100):
++ msg = isctest.query.create(f"foo{i}.example.", "A")
++ res = isctest.query.udp(msg, ns6.ip)
++ isctest.check.noerror(res)
++ assert len(res.answer[0]) == 1
++ res.answer[0].ttl = 300
++ assert str(res.answer[0]) == f"foo{i}.example. 300 IN A 10.53.9.9"
++
++ assert_used_auth(ns6, "ns6", "10.53.0.2")
++
++ for i in range(100, 200):
++ msg = isctest.query.create(f"foo{i}.example.", "A")
++ res = isctest.query.udp(msg, ns6.ip)
++ isctest.check.noerror(res)
++ assert len(res.answer[0]) == 1
++ res.answer[0].ttl = 300
++ assert str(res.answer[0]) == f"foo{i}.example. 300 IN A 10.53.9.9"
++
++ assert_used_auth(ns6, "ns6", "10.53.0.3")
++
++ for i in range(200, 300):
++ msg = isctest.query.create(f"foo{i}.example.", "A")
++ res = isctest.query.udp(msg, ns6.ip)
++ isctest.check.noerror(res)
++ assert len(res.answer[0]) == 1
++ res.answer[0].ttl = 300
++ assert str(res.answer[0]) == f"foo{i}.example. 300 IN A 10.53.9.9"
++
++ assert_used_auth(ns6, "ns6", "10.53.0.4")
++
++ for i in range(300, 400):
++ msg = isctest.query.create(f"foo{i}.example.", "A")
++ res = isctest.query.udp(msg, ns6.ip)
++ isctest.check.noerror(res)
++ assert len(res.answer[0]) == 1
++ res.answer[0].ttl = 300
++ assert str(res.answer[0]) == f"foo{i}.example. 300 IN A 10.53.9.9"
++ assert_used_auth(ns6, "ns6", "10.53.0.5")
+--
+2.35.6
+
new file mode 100644
@@ -0,0 +1,41 @@
+From 4edafe63a0dfa9142e88ba66b429c35ee286d4dd Mon Sep 17 00:00:00 2001
+From: Colin Vidal <colin@isc.org>
+Date: Thu, 30 Apr 2026 19:02:47 +0100
+Subject: [PATCH] Fix `resend_loop` system test
+
+Commit `c78016ff91ed33221831b4723108d69639430913` backported asyncserver
+features to 9.18 branches, but the `resend_loop` test was still using
+the previous API to install handlers (passing a list of handlers rather
+than a varags). This is now fixed.
+
+CVE: CVE-2026-3592
+Upstream-Status: Backport [https://gitlab.com/isc-projects/bind9/-/commit/cb13dcabdb64bdb5f8f7ed33980aaf470a90e877]
+
+(cherry picked from commit cb13dcabdb64bdb5f8f7ed33980aaf470a90e877)
+Signed-off-by: Ashishkumar Parmar <asparmar@cisco.com>
+---
+ bin/tests/system/resend_loop/ans3/ans.py | 8 +++-----
+ 1 file changed, 3 insertions(+), 5 deletions(-)
+
+diff --git a/bin/tests/system/resend_loop/ans3/ans.py b/bin/tests/system/resend_loop/ans3/ans.py
+index 90a3f2f9cc..217bae0301 100644
+--- a/bin/tests/system/resend_loop/ans3/ans.py
++++ b/bin/tests/system/resend_loop/ans3/ans.py
+@@ -111,11 +111,9 @@ class NoErrorHandler(ResponseHandler):
+ def resend_server() -> AsyncDnsServer:
+ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
+ server.install_response_handlers(
+- [
+- PrimeHandler(),
+- CookieHandler(),
+- NoErrorHandler(),
+- ]
++ PrimeHandler(),
++ CookieHandler(),
++ NoErrorHandler(),
+ )
+ return server
+
+--
+2.35.6
+
@@ -26,6 +26,12 @@ SRC_URI = "https://ftp.isc.org/isc/bind9/${PV}/${BPN}-${PV}.tar.xz \
file://CVE-2026-5950_p1.patch \
file://CVE-2026-5950_p2.patch \
file://CVE-2026-5950_p3.patch \
+ file://CVE-2026-3592_p1.patch \
+ file://CVE-2026-3592_p2.patch \
+ file://CVE-2026-3592_p3.patch \
+ file://CVE-2026-3592_p4.patch \
+ file://CVE-2026-3592_p5.patch \
+ file://CVE-2026-3592_p6.patch \
"
SRC_URI[sha256sum] = "81f5035a25c576af1a93f0061cf70bde6d00a0c7bd1274abf73f5b5389a6f82d"