]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Force TCP after repeated UDP timeouts to the same authoritative
authorOndřej Surý <ondrej@isc.org>
Thu, 14 May 2026 09:19:42 +0000 (11:19 +0200)
committerOndřej Surý <ondrej@isc.org>
Tue, 19 May 2026 09:18:30 +0000 (11:18 +0200)
Make the decision in fctx_query() before the dispatch is bound so the
chosen transport and the DNS_FETCHOPT_TCP flag agree.  The previous
location in resquery_send() ran after the UDP dispatch had already been
attached, so the flag flip had no effect on the wire.

Moving the decision earlier also means FCTX_ADDRINFO_NOEDNS0 servers,
previously exempt, now escalate to TCP too.  TCP works regardless of
EDNS state, so this is the intended behaviour.

Assisted-by: Claude:claude-opus-4-7
bin/tests/system/dispatch/ans4/ans.py
bin/tests/system/dispatch/tests_tcponly.py
lib/dns/resolver.c

index 5ec4985a7b335dcd17ae46aed3fcb966830765ec..d4b4affda774ead7f252a30fe0ee8a27e1463043 100644 (file)
@@ -22,19 +22,19 @@ from isctest.asyncserver import (
 )
 
 
-class TcpOnlyHandler(ResponseHandler):
+class DropUdpHandler(ResponseHandler):
     async def get_responses(
         self, qctx: QueryContext
     ) -> AsyncGenerator[ResponseAction, None]:
-        if qctx.protocol == DnsProtocol.TCP:
-            yield DnsResponseSend(qctx.response)
-        else:
+        if qctx.protocol == DnsProtocol.UDP:
             yield ResponseDrop()
+        else:
+            yield DnsResponseSend(qctx.response)
 
 
 def main() -> None:
     server = AsyncDnsServer()
-    server.install_response_handler(TcpOnlyHandler())
+    server.install_response_handler(DropUdpHandler())
     server.run()
 
 
index f87919eb2c1f5d7ec63e3d26278a2b105c88b22a..373ee56017c83aa936dc862055620bbf28c4726c 100644 (file)
@@ -9,7 +9,13 @@
 # See the COPYRIGHT file distributed with this work for additional
 # information regarding copyright ownership.
 
+from re import compile as Re
+from re import escape
+
 import dns.message
+import dns.name
+import dns.rdataclass
+import dns.rdatatype
 import pytest
 
 import isctest
@@ -21,13 +27,31 @@ pytestmark = pytest.mark.extra_artifacts(
 )
 
 
-def test_tcponly_not_resolved():
+def _count_received(path, qname, protocol):
+    pattern = Re(rf"Received {escape(qname)}/IN/A .* \({protocol}\)$")
+    with open(path, encoding="utf-8") as fh:
+        return sum(1 for line in fh if pattern.search(line.rstrip()))
+
+
+def test_tcponly_fallback():
     """
-    An authoritative server that only answers over TCP is unreachable
-    when its zone is queried over UDP: the resolver does not transparently
-    fall back to TCP after UDP timeouts. (This confirms the expected behavior
-    for this commit; TCP fallback will be restored in the next.)
+    A resolver must fall back to TCP after repeated UDP timeouts to the
+    same authoritative server.  ans4 drops every UDP query and answers
+    only over TCP; the resolver must reach the answer via the TCP
+    fallback path, after at least two UDP attempts have been dropped.
     """
     msg = dns.message.make_query("foo.tcp-only.", "A")
     res = isctest.query.udp(msg, "10.53.0.2", timeout=15)
-    isctest.check.servfail(res)
+    isctest.check.noerror(res)
+    rdataset = res.find_rrset(
+        res.answer,
+        dns.name.from_text("foo.tcp-only."),
+        dns.rdataclass.IN,
+        dns.rdatatype.A,
+    )
+    assert str(rdataset[0]) == "127.0.0.1"
+
+    udp = _count_received("ans4/ans.run", "foo.tcp-only", "UDP")
+    tcp = _count_received("ans4/ans.run", "foo.tcp-only", "TCP")
+    assert udp == 2, f"expected exactly 2 UDP queries, got {udp}"
+    assert tcp == 1, f"expected exactly 1 TCP query, got {tcp}"
index 71bc2ac11e56e53b9c5e2ae4118ca7c58180ba13..8d4430ddc0f491f33dc36fa6c854fd6290cd404b 100644 (file)
@@ -2033,6 +2033,9 @@ fctx_setretryinterval(fetchctx_t *fctx, unsigned int rtt) {
        isc_interval_set(&fctx->interval, seconds, us * NS_PER_US);
 }
 
+static struct tried *
+triededns(fetchctx_t *fctx, isc_sockaddr_t *address);
+
 static isc_result_t
 fctx_query(fetchctx_t *fctx, dns_adbaddrinfo_t *addrinfo,
           unsigned int options) {
@@ -2126,6 +2129,23 @@ fctx_query(fetchctx_t *fctx, dns_adbaddrinfo_t *addrinfo,
                }
        }
 
+       /*
+        * If this server has already been tried at least twice in this
+        * fetch context after the previous attempt timed out, force TCP
+        * for this attempt.  The decision must be made here, before the
+        * dispatch type is chosen below, so that the dispatch and the
+        * DNS_FETCHOPT_TCP flag agree.
+        */
+       if (fctx->timeout && fctx->timeouts >= 2U &&
+           (options & DNS_FETCHOPT_NOEDNS0) == 0 &&
+           (options & DNS_FETCHOPT_TCP) == 0)
+       {
+               struct tried *tried = triededns(fctx, &sockaddr);
+               if (tried != NULL && tried->count >= 2U) {
+                       options |= DNS_FETCHOPT_TCP;
+               }
+       }
+
        /*
         * Allow an additional second for the kernel to resend the SYN
         * (or SYN without ECN in the case of stupid firewalls blocking