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 also exercises the error
path 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  | 182 +++++++++++++++++++++++++++
 reg-tests/resolvers/tcp_fallback.vtc | 174 +++++++++++++++++++++++++
 3 files changed, 357 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..d799987f3
--- /dev/null
+++ b/reg-tests/resolvers/tcp_fallback.py
@@ -0,0 +1,182 @@
+#!/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):
+    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 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 = [truncated_response(query, question)]
+        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..b7feb937d
--- /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 [email protected]:${s10_port}
+        accepted_payload_size 8192
+        timeout resolve 30s
+        timeout retry 200ms
+        resolve_retries 1
+
+    resolvers family_prefix
+        nameserver dns [email protected]:${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 [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 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 resolvers tcp_only"
+    expect ~ "truncated: +[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 "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.55.0

Reply via email to