From 672010ec2fcb643efcc9d0445190fdabb6e98e04 Mon Sep 17 00:00:00 2001 From: Manu Nicolas Date: Wed, 2 Sep 2026 10:09:24 +0000 Subject: [PATCH] REGTEST: resolvers: cover UDP to TCP fallback Add a Python DNS fixture listening on UDP and TCP on the same port. It returns an empty truncated response over UDP and a complete response larger than 1232 bytes over TCP. The fixture only uses the Python standard library. Tag the test as devel because it depends on an external interpreter. The feature check skips it when python3 is unavailable. Cover default and family-prefixed fallback, late A responses after an A/AAAA switch, complete UDP replies, invalid questions, explicit UDP-only and TCP-only nameservers, TCP connection failures, and SRV updates. A TCP-only truncated SRV response contains usable records and exercises partial SRV processing without another fallback. Check the resolver CLI and statistics counters as well as the Prometheus metric. --- reg-tests/contrib/prometheus.vtc | 1 + reg-tests/resolvers/tcp_fallback.py | 183 +++++++++++++++++++++++++++ reg-tests/resolvers/tcp_fallback.vtc | 174 +++++++++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 reg-tests/resolvers/tcp_fallback.py create mode 100644 reg-tests/resolvers/tcp_fallback.vtc diff --git a/reg-tests/contrib/prometheus.vtc b/reg-tests/contrib/prometheus.vtc index 702a2e983..4ddc38140 100644 --- a/reg-tests/contrib/prometheus.vtc +++ b/reg-tests/contrib/prometheus.vtc @@ -55,6 +55,7 @@ client c1 -connect ${h1_stats_sock} { expect resp.body ~ ".*haproxy_server.*" expect resp.body ~ ".*haproxy_sticktable.*" expect resp.body ~ ".*haproxy_resolver.*" + expect resp.body ~ ".*haproxy_resolver_tcp_fallback.*" # test well known labels presence expect resp.body ~ ".*haproxy_process_build_info{version=\".*\"} 1.*" diff --git a/reg-tests/resolvers/tcp_fallback.py b/reg-tests/resolvers/tcp_fallback.py new file mode 100644 index 000000000..d273ff765 --- /dev/null +++ b/reg-tests/resolvers/tcp_fallback.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later + +# DNS UDP/TCP fallback regression fixture. + +import argparse +import socket +import struct +import threading + + +TRUNCATED_NAMES = { + "fallback.test", + "disabled.test", + "family-prefix.test", + "unavailable.test", +} +LATE_A_NAME = "late-a.test" +WRONG_QUESTION_NAME = "wrong-question.test" +WRONG_TYPE_NAME = "wrong-type.test" +WRONG_CLASS_NAME = "wrong-class.test" +SRV_NAME = "_service._tcp.srv.test" +TRUNCATED_SRV_NAME = "_truncated._tcp.srv.test" +SRV_TARGET = "node.srv.test" +TEST_ADDRESS = "192.0.2.123" + + +def encode_name(name): + return b"".join(bytes((len(label),)) + label.encode() for label in name.split(".")) + b"\0" + + +def parse_question(query): + offset = 12 + labels = [] + while query[offset]: + length = query[offset] + offset += 1 + labels.append(query[offset : offset + length].decode()) + offset += length + offset += 1 + qtype, qclass = struct.unpack("!HH", query[offset : offset + 4]) + return ".".join(labels), qtype, qclass, query[12 : offset + 4] + + +def truncated_response(query, question): + return query[:2] + struct.pack("!HHHHH", 0x8380, 1, 0, 0, 0) + question + + +def empty_response(query, question): + return query[:2] + struct.pack("!HHHHH", 0x8180, 1, 0, 0, 0) + question + + +def a_response(query, question, answer_count): + header = query[:2] + struct.pack("!HHHHH", 0x8180, 1, answer_count, 0, 0) + address = socket.inet_aton(TEST_ADDRESS) + answer = b"\xc0\x0c" + struct.pack("!HHIH", 1, 1, 30, len(address)) + address + response = header + question + answer * answer_count + if answer_count > 1: + assert len(response) > 1232 + return response + + +def srv_response(query, question, port, truncated=False): + target = encode_name(SRV_TARGET) + rdata = struct.pack("!HHH", 0, 10, port) + target + answer = b"\xc0\x0c" + struct.pack("!HHIH", 33, 1, 30, len(rdata)) + rdata + additional = encode_name(SRV_TARGET) + additional += struct.pack("!HHIH", 1, 1, 30, 4) + additional += socket.inet_aton("127.0.0.1") + flags = 0x8380 if truncated else 0x8180 + header = query[:2] + struct.pack("!HHHHH", flags, 1, 1, 0, 1) + return header + question + answer + additional + + +class Fixture: + def __init__(self, host, port, log_path, srv_port, udp_only): + self.host = host + self.port = port + self.log_path = log_path + self.srv_port = srv_port + self.log_lock = threading.Lock() + self.udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.udp.bind((host, port)) + self.tcp = None + if not udp_only: + self.tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.tcp.bind((host, port)) + self.tcp.listen() + + def log(self, transport, name, qtype, response): + with self.log_lock: + with open(self.log_path, "a", encoding="ascii") as stream: + stream.write(f"{transport} {name} {qtype} {len(response)}\n") + + def responses(self, transport, query): + name, qtype, qclass, question = parse_question(query) + if transport == "udp" and name == WRONG_QUESTION_NAME: + wrong_question = encode_name("wrong.test") + wrong_question += struct.pack("!HH", qtype, qclass) + responses = [truncated_response(query, wrong_question)] + elif transport == "udp" and name == WRONG_TYPE_NAME: + wrong_question = question[:-4] + struct.pack("!HH", 33, qclass) + responses = [truncated_response(query, wrong_question)] + elif transport == "udp" and name == WRONG_CLASS_NAME: + wrong_question = question[:-4] + struct.pack("!HH", qtype, 3) + responses = [truncated_response(query, wrong_question)] + elif transport == "udp" and name == LATE_A_NAME and qtype == 1: + responses = [ + empty_response(query, question), + truncated_response(query, question), + ] + elif transport == "udp" and name == LATE_A_NAME: + responses = [] + elif transport == "udp" and ( + name in TRUNCATED_NAMES or name == SRV_NAME + ): + responses = [truncated_response(query, question)] + elif name == TRUNCATED_SRV_NAME and qtype == 33: + responses = [srv_response(query, question, self.srv_port, True)] + elif name == SRV_NAME and qtype == 33: + responses = [srv_response(query, question, self.srv_port)] + elif name == "fallback.test": + responses = [a_response(query, question, 80)] + else: + responses = [a_response(query, question, 1)] + for response in responses: + self.log(transport, name, qtype, response) + return responses + + def serve_tcp_client(self, conn): + with conn: + while True: + length = self.recv_exact(conn, 2) + if not length: + return + query = self.recv_exact(conn, struct.unpack("!H", length)[0]) + if not query: + return + for response in self.responses("tcp", query): + conn.sendall(struct.pack("!H", len(response)) + response) + + @staticmethod + def recv_exact(conn, length): + data = b"" + while len(data) < length: + chunk = conn.recv(length - len(data)) + if not chunk: + return None + data += chunk + return data + + def accept_tcp(self): + while True: + conn, _addr = self.tcp.accept() + thread = threading.Thread(target=self.serve_tcp_client, args=(conn,), daemon=True) + thread.start() + + def run(self): + if self.tcp: + threading.Thread(target=self.accept_tcp, daemon=True).start() + while True: + query, addr = self.udp.recvfrom(65535) + for response in self.responses("udp", query): + self.udp.sendto(response, addr) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--log", required=True) + parser.add_argument("--srv-port", type=int, default=80) + parser.add_argument("--udp-only", action="store_true") + args = parser.parse_args() + fixture = Fixture(args.host, args.port, args.log, args.srv_port, args.udp_only) + print("READY", flush=True) + fixture.run() + + +if __name__ == "__main__": + main() diff --git a/reg-tests/resolvers/tcp_fallback.vtc b/reg-tests/resolvers/tcp_fallback.vtc new file mode 100644 index 000000000..5d6fa275a --- /dev/null +++ b/reg-tests/resolvers/tcp_fallback.vtc @@ -0,0 +1,174 @@ +varnishtest "DNS UDP to TCP fallback" +# SPDX-License-Identifier: GPL-2.0-or-later + +#REGTEST_TYPE=devel + +feature ignore_unknown_macro +feature cmd "python3 --version" + +# Reserve two random local ports, then release their TCP listeners so the +# Python fixtures can bind UDP and TCP to the same port. +server s10 {} -start +server s10 -break + +server s11 {} -start +server s11 -break + +server s1 { + rxreq + txresp +} -start + +process p1 "python3 ${testdir}/tcp_fallback.py --port ${s10_port} --log ${tmpdir}/dns.log --srv-port ${s1_port}" -start +process p1 -expect-text 0 0 "READY" + +process p2 "python3 ${testdir}/tcp_fallback.py --port ${s11_port} --log ${tmpdir}/dns-udp-only.log --udp-only" -start +process p2 -expect-text 0 0 "READY" + +haproxy h1 -conf { + defaults + mode http + timeout connect "${HAPROXY_TEST_TIMEOUT-5s}" + timeout client "${HAPROXY_TEST_TIMEOUT-5s}" + timeout server "${HAPROXY_TEST_TIMEOUT-5s}" + + resolvers fallback + nameserver dns 127.0.0.1:${s10_port} + timeout resolve 30s + timeout retry 200ms + resolve_retries 2 + hold valid 30s + + resolvers disabled + nameserver dns udp@127.0.0.1:${s10_port} + accepted_payload_size 8192 + timeout resolve 30s + timeout retry 200ms + resolve_retries 1 + + resolvers family_prefix + nameserver dns ipv4@127.0.0.1:${s10_port} + timeout resolve 30s + timeout retry 200ms + resolve_retries 1 + + resolvers unavailable + nameserver dns 127.0.0.1:${s11_port} + accepted_payload_size 8192 + timeout resolve 30s + timeout retry 200ms + resolve_retries 2 + + resolvers tcp_only + nameserver dns tcp@127.0.0.1:${s10_port} + accepted_payload_size 8192 + timeout resolve 30s + timeout retry 200ms + resolve_retries 1 + + frontend fe + bind "fd@${fe}" + acl disabled hdr(host) -i disabled.test + acl family_prefix hdr(host) -i family-prefix.test + acl unavailable hdr(host) -i unavailable.test + acl tcp_only hdr(host) -i tcp-only.test + http-request do-resolve(txn.ip,disabled,ipv4) hdr(host) if disabled + http-request do-resolve(txn.ip,family_prefix,ipv4) hdr(host) if family_prefix + http-request do-resolve(txn.ip,unavailable,ipv4) hdr(host) if unavailable + http-request do-resolve(txn.ip,tcp_only,ipv4) hdr(host) if tcp_only + http-request do-resolve(txn.ip,fallback,ipv4) hdr(host) if !disabled !family_prefix !unavailable !tcp_only + http-request return status 200 hdr x-ip %[var(txn.ip)] if { var(txn.ip) -m found } + http-request return status 503 + + backend srv + server-template srv 1 _service._tcp.srv.test resolvers fallback init-addr none + + backend truncated_srv + server-template truncated 1 _truncated._tcp.srv.test resolvers tcp_only init-addr none +} -start + +client c1 -connect ${h1_fe_sock} { + txreq -hdr "Host: fallback.test" + rxresp + expect resp.status == 200 + expect resp.http.x-ip == "192.0.2.123" + + txreq -hdr "Host: late-a.test" + rxresp + expect resp.status == 200 + expect resp.http.x-ip == "192.0.2.123" + + txreq -hdr "Host: direct.test" + rxresp + expect resp.status == 200 + expect resp.http.x-ip == "192.0.2.123" + + txreq -hdr "Host: wrong-question.test" + rxresp + expect resp.status == 503 + + txreq -hdr "Host: wrong-type.test" + rxresp + expect resp.status == 503 + + txreq -hdr "Host: wrong-class.test" + rxresp + expect resp.status == 503 + + txreq -hdr "Host: family-prefix.test" + rxresp + expect resp.status == 200 + expect resp.http.x-ip == "192.0.2.123" + + txreq -hdr "Host: disabled.test" + rxresp + expect resp.status == 503 + + txreq -hdr "Host: unavailable.test" + rxresp + expect resp.status == 503 + + txreq -hdr "Host: tcp-only.test" + rxresp + expect resp.status == 200 + expect resp.http.x-ip == "192.0.2.123" +} -run + +delay 1.2 + +haproxy h1 -cli { + send "show resolvers fallback" + expect ~ "truncated: +[1-9][0-9]*" + send "show resolvers fallback" + expect ~ "tcp_fallback: +[1-9][0-9]*" + send "show resolvers fallback" + expect ~ "invalid: +[1-9][0-9]*" + send "show stat domain resolvers" + expect ~ "tcp_fallback" + send "show resolvers disabled" + expect ~ "truncated: +[1-9][0-9]*" + send "show resolvers unavailable" + expect ~ "tcp_fallback: +[1-9][0-9]*" + send "show servers state srv" + expect ~ "srv 1 srv1 127\\.0\\.0\\.1 .* node\\.srv\\.test ${s1_port} _service\\._tcp\\.srv\\.test" + send "show servers state truncated_srv" + expect ~ "truncated_srv 1 truncated1 127\\.0\\.0\\.1 .* node\\.srv\\.test ${s1_port} _truncated\\._tcp\\.srv\\.test" +} -wait + +shell "awk '$1 == \"tcp\" && $2 == \"fallback.test\" && $4 > 1232 { found = 1 } END { exit !found }' ${tmpdir}/dns.log" +shell "test \"$(awk '$2 == \"fallback.test\" { print $1 }' ${tmpdir}/dns.log | head -n 2 | tr '\\n' ' ')\" = \"udp tcp \"" +shell "test \"$(awk '$2 == \"late-a.test\" { print $1 \":\" $3 }' ${tmpdir}/dns.log | tr '\\n' ' ')\" = \"udp:1 udp:1 tcp:1 \"" +shell "! grep -q '^tcp direct.test ' ${tmpdir}/dns.log" +shell "! grep -q '^tcp wrong-question.test ' ${tmpdir}/dns.log" +shell "! grep -q '^tcp wrong-type.test ' ${tmpdir}/dns.log" +shell "! grep -q '^tcp wrong-class.test ' ${tmpdir}/dns.log" +shell "! grep -q '^tcp disabled.test ' ${tmpdir}/dns.log" +shell "grep -q '^udp family-prefix.test 1 ' ${tmpdir}/dns.log" +shell "grep -q '^tcp family-prefix.test 1 ' ${tmpdir}/dns.log" +shell "awk '$1 == \"tcp\" && $2 == \"tcp-only.test\" { count++ } END { exit count != 1 }' ${tmpdir}/dns.log" +shell "! grep -q '^udp tcp-only.test ' ${tmpdir}/dns.log" +shell "grep -q '^udp _service._tcp.srv.test 33 ' ${tmpdir}/dns.log" +shell "grep -q '^tcp _service._tcp.srv.test 33 ' ${tmpdir}/dns.log" +shell "grep -q '^tcp _truncated._tcp.srv.test 33 ' ${tmpdir}/dns.log" +shell "! grep -q '^udp _truncated._tcp.srv.test 33 ' ${tmpdir}/dns.log" +shell "grep -q '^udp unavailable.test 1 ' ${tmpdir}/dns-udp-only.log" -- 2.47.3