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
This commit is contained in:
Ondřej Surý 2026-05-14 11:19:42 +02:00
parent 01523a078a
commit 59c00a6f31
No known key found for this signature in database
GPG key ID: 2820F37E873DEA41
3 changed files with 55 additions and 11 deletions

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

View 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}"

View 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