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