From 59c00a6f311108a6fe5dcf58a3bea51cc4f9224c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sur=C3=BD?= Date: Thu, 14 May 2026 11:19:42 +0200 Subject: [PATCH] Force TCP after repeated UDP timeouts to the same authoritative 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 | 10 +++--- bin/tests/system/dispatch/tests_tcponly.py | 36 ++++++++++++++++++---- lib/dns/resolver.c | 20 ++++++++++++ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/bin/tests/system/dispatch/ans4/ans.py b/bin/tests/system/dispatch/ans4/ans.py index 5ec4985a7b..d4b4affda7 100644 --- a/bin/tests/system/dispatch/ans4/ans.py +++ b/bin/tests/system/dispatch/ans4/ans.py @@ -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() diff --git a/bin/tests/system/dispatch/tests_tcponly.py b/bin/tests/system/dispatch/tests_tcponly.py index f87919eb2c..373ee56017 100644 --- a/bin/tests/system/dispatch/tests_tcponly.py +++ b/bin/tests/system/dispatch/tests_tcponly.py @@ -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}" diff --git a/lib/dns/resolver.c b/lib/dns/resolver.c index 71bc2ac11e..8d4430ddc0 100644 --- a/lib/dns/resolver.c +++ b/lib/dns/resolver.c @@ -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