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
+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 [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"
-- 
2.54.0

Reply via email to