Hi,

This reg-test script must at least be tagged as "slow" and not "bug". But honestly, adding a python script is a bit annying. The script will be ignored if python3 is not installed but how can we be sure it will always work as expected. Maybe it should be tagged as "devel" instead.


Le 18/08/2026 à 7:24 PM, Manu Nicolas a écrit :
Add a Python DNS fixture that listens on UDP and TCP on the same port.
It returns TC=1 with no answers over UDP, then a complete A response
larger than 1232 bytes over TCP. It can also return complete UDP
answers and SRV answers with an additional address record.

Verify that fallback succeeds with the default 512-byte UDP limit and
that the fixture observes UDP followed by TCP. Also cover a mismatched
question that must not trigger fallback, complete UDP answers,
explicit UDP-only and TCP-only nameservers, an unavailable TCP
endpoint, SRV server-template updates, resolver counters and the
Prometheus metric.
---
  reg-tests/contrib/prometheus.vtc     |   1 +
  reg-tests/resolvers/tcp_fallback.py  | 152 +++++++++++++++++++++++++++
  reg-tests/resolvers/tcp_fallback.vtc | 136 ++++++++++++++++++++++++
  3 files changed, 289 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..e27a35887
--- /dev/null
+++ b/reg-tests/resolvers/tcp_fallback.py
@@ -0,0 +1,152 @@
+#!/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", "unavailable.test"}
+WRONG_QUESTION_NAME = "wrong-question.test"
+SRV_NAME = "_service._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 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):
+    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")
+    header = query[:2] + struct.pack("!HHHHH", 0x8180, 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 response(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)
+            response = truncated_response(query, wrong_question)
+        elif transport == "udp" and (
+            name in TRUNCATED_NAMES or name == SRV_NAME
+        ):
+            response = truncated_response(query, question)
+        elif name == SRV_NAME and qtype == 33:
+            response = srv_response(query, question, self.srv_port)
+        elif name == "fallback.test":
+            response = a_response(query, question, 80)
+        else:
+            response = a_response(query, question, 1)
+        self.log(transport, name, qtype, response)
+        return response
+
+    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
+                response = self.response("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)
+            self.udp.sendto(self.response("udp", query), 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..f947c4952
--- /dev/null
+++ b/reg-tests/resolvers/tcp_fallback.vtc
@@ -0,0 +1,136 @@
+varnishtest "DNS UDP to TCP fallback"
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+#REGTEST_TYPE=bug
+
+feature ignore_unknown_macro
+feaqture 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 [email protected]:${s10_port}
+        accepted_payload_size 8192
+        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 [email protected]:${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 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,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 
!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
+} -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: 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: 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 ~ "snd_error: +[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"
+} -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 "! grep -q '^tcp direct.test ' ${tmpdir}/dns.log"
+shell "! grep -q '^tcp wrong-question.test ' ${tmpdir}/dns.log"
+shell "! grep -q '^tcp disabled.test ' ${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 '^udp unavailable.test 1 ' ${tmpdir}/dns-udp-only.log"

--
Christopher Faulet



Reply via email to