From a0427be0867bd57b2f3d43f3987a80c0527789e7 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sat, 27 Jun 2026 00:21:37 +0800 Subject: [PATCH] feat: add comprehensive DNS handler e2e tests Adds 22 DNS e2e subtests across 8 test methods covering: upstream resolution (A, AAAA, multi-A), TCP mode, bypass rules, host mapper, exchange failure (graceful error recovery), rate limiter wiring, invalid query handling, and DNS over TLS (mode: tls). Includes two authoritative DNS responders (UDP/TCP), a standalone DNS query client, and a TLS DNS query script. --- tests/e2e/dns_test.go | 357 ++++++++++++++++++ tests/e2e/scripts/dns_query.py | 162 ++++++++ tests/e2e/scripts/dns_responder.py | 88 +++++ tests/e2e/scripts/dns_responder_tcp.py | 83 ++++ tests/e2e/scripts/dns_tls_query.py | 140 +++++++ tests/e2e/testdata/dns/server_bypass.yaml | 16 + .../testdata/dns/server_exchange_failure.yaml | 10 + tests/e2e/testdata/dns/server_hosts.yaml | 19 + tests/e2e/testdata/dns/server_rlimiter.yaml | 16 + tests/e2e/testdata/dns/server_tcp.yaml | 12 + tests/e2e/testdata/dns/server_tls.yaml | 12 + tests/e2e/testdata/dns/server_upstream.yaml | 10 + tests/e2e/utils.go | 64 ++++ 13 files changed, 989 insertions(+) create mode 100644 tests/e2e/dns_test.go create mode 100644 tests/e2e/scripts/dns_query.py create mode 100644 tests/e2e/scripts/dns_responder.py create mode 100644 tests/e2e/scripts/dns_responder_tcp.py create mode 100644 tests/e2e/scripts/dns_tls_query.py create mode 100644 tests/e2e/testdata/dns/server_bypass.yaml create mode 100644 tests/e2e/testdata/dns/server_exchange_failure.yaml create mode 100644 tests/e2e/testdata/dns/server_hosts.yaml create mode 100644 tests/e2e/testdata/dns/server_rlimiter.yaml create mode 100644 tests/e2e/testdata/dns/server_tcp.yaml create mode 100644 tests/e2e/testdata/dns/server_tls.yaml create mode 100644 tests/e2e/testdata/dns/server_upstream.yaml diff --git a/tests/e2e/dns_test.go b/tests/e2e/dns_test.go new file mode 100644 index 0000000..0d3d586 --- /dev/null +++ b/tests/e2e/dns_test.go @@ -0,0 +1,357 @@ +package e2e + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +type DNSSuite struct { + suite.Suite + ctx context.Context +} + +func (s *DNSSuite) SetupSuite() { + s.ctx = context.Background() +} + +// dnsQuery sends a DNS query via the Python client and retries on failure. +// expected can be an IP string, "empty" (expect zero answer records), or "" (no check). +func (s *DNSSuite) dnsQuery(gostC testcontainers.Container, mode, host, port, qname, qtype, expected string) { + args := []string{"python3", "/scripts/dns_query.py", mode, host, port, qname, qtype} + if expected != "" { + args = append(args, expected) + } + + for i := range 5 { + code, out, err := gostC.Exec(s.ctx, args) + if err != nil { + s.T().Logf("query attempt %d exec error: %v", i+1, err) + time.Sleep(time.Second) + continue + } + body, err := io.ReadAll(out) + if err != nil { + s.T().Logf("query attempt %d read error: %v", i+1, err) + time.Sleep(time.Second) + continue + } + output := string(body) + s.T().Logf("query attempt %d: %s", i+1, strings.TrimSpace(output)) + + if code == 0 { + return + } + time.Sleep(time.Second) + } + + s.T().Fatalf("DNS query %s %s %s %s failed after 5 retries", mode, qname, qtype, host) +} + +// dnsQueryOnce sends a single DNS query with no retries. Returns exit code and output. +func (s *DNSSuite) dnsQueryOnce(gostC testcontainers.Container, mode, host, port, qname, qtype string) (int, string) { + args := []string{"python3", "/scripts/dns_query.py", mode, host, port, qname, qtype} + code, out, err := gostC.Exec(s.ctx, args) + if err != nil { + return 1, fmt.Sprintf("exec error: %v", err) + } + body, _ := io.ReadAll(out) + return code, string(body) +} + +// dnsQueryWithDelay calls dnsQuery with a small wait before the first attempt. +func (s *DNSSuite) dnsQueryWithDelay(gostC testcontainers.Container, mode, host, port, qname, qtype, expected string) { + time.Sleep(500 * time.Millisecond) + s.dnsQuery(gostC, mode, host, port, qname, qtype, expected) +} + +// sendRaw sends raw data via base64+nc and returns the response. +func (s *DNSSuite) sendRaw(gostC testcontainers.Container, host, port, data string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(data)) + cmd := []string{"sh", "-c", + fmt.Sprintf("echo %s | base64 -d | nc -w 3 -u %s %s", encoded, host, port)} + _, out, _ := gostC.Exec(s.ctx, cmd) + b, _ := io.ReadAll(out) + return string(b) +} + +// startDNSResponder starts the UDP DNS responder and returns the container. +func (s *DNSSuite) startDNSResponder() testcontainers.Container { + dnsC, err := RunDNSResponderContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + return dnsC +} + +// startGostWithQueryScript starts a gost container with dns_query.py mounted. +func (s *DNSSuite) startGostWithQueryScript(yamlPath, exposedPort string) testcontainers.Container { + gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, + yamlPath, + []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644}, + }, + exposedPort) + s.Require().NoError(err) + return gostC +} + +// --------------------------------------------------------------------------- +// Upstream resolution +// --------------------------------------------------------------------------- + +func (s *DNSSuite) TestDNSUpstream() { + dnsC := s.startDNSResponder() + defer dnsC.Terminate(s.ctx) + + gostC := s.startGostWithQueryScript("testdata/dns/server_upstream.yaml", "1053/udp") + defer gostC.Terminate(s.ctx) + + s.T().Run("a-record", func(t *testing.T) { + s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053", "test.example.com", "A", "10.0.0.1") + }) + s.T().Run("aaaa-record", func(t *testing.T) { + s.dnsQuery(gostC, "udp", "127.0.0.1", "1053", "example.com", "AAAA", "::1") + }) + s.T().Run("second-a-record", func(t *testing.T) { + s.dnsQuery(gostC, "udp", "127.0.0.1", "1053", "test2.example.com", "A", "10.0.0.2") + }) +} + +// --------------------------------------------------------------------------- +// TCP mode +// --------------------------------------------------------------------------- + +func (s *DNSSuite) TestDNSTCP() { + s.T().Log("start TCP DNS responder container...") + dnsC, err := RunTCPDNSResponderContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + defer dnsC.Terminate(s.ctx) + + gostC := s.startGostWithQueryScript("testdata/dns/server_tcp.yaml", "1053/tcp") + defer gostC.Terminate(s.ctx) + + s.dnsQueryWithDelay(gostC, "tcp", "127.0.0.1", "1053", "test.example.com", "A", "10.0.0.1") + s.dnsQuery(gostC, "tcp", "127.0.0.1", "1053", "test2.example.com", "A", "10.0.0.2") +} + +// --------------------------------------------------------------------------- +// Bypass rules +// --------------------------------------------------------------------------- + +func (s *DNSSuite) TestDNSBypass() { + dnsC := s.startDNSResponder() + defer dnsC.Terminate(s.ctx) + + gostC := s.startGostWithQueryScript("testdata/dns/server_bypass.yaml", "1053/udp") + defer gostC.Terminate(s.ctx) + + s.T().Run("blocked-domain-empty-answer", func(t *testing.T) { + s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053", + "test.example.com", "A", "empty") + }) + s.T().Run("non-blocked-domain", func(t *testing.T) { + s.dnsQuery(gostC, "udp", "127.0.0.1", "1053", + "test2.example.com", "A", "10.0.0.2") + }) +} + +// --------------------------------------------------------------------------- +// Host mapper +// --------------------------------------------------------------------------- + +// TestDNSHostMapper verifies DNS resolution via the host mapper before +// reaching the upstream exchanger. +// +// Config: server_hosts.yaml maps mapped.example.com → 10.0.0.100 with no +// working upstream. The handler checks the host mapper before the exchange +// path, so mapped names resolve without needing any upstream DNS. +func (s *DNSSuite) TestDNSHostMapper() { + gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, + "testdata/dns/server_hosts.yaml", + []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644}, + }, + "1053/udp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + s.T().Run("mapped-a-record", func(t *testing.T) { + // mapped.example.com → host mapper returns 10.0.0.100 + // without contacting any upstream. + s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053", + "mapped.example.com", "A", "10.0.0.100") + }) + + s.T().Run("unmapped-domain", func(t *testing.T) { + // test.example.com is not in the hosts mapping. The handler + // will fall through to the exchanger path, which fails because + // no upstream is configured. Query should return empty/NXDOMAIN. + code, output := s.dnsQueryOnce(gostC, "udp", "127.0.0.1", "1053", + "test.example.com", "A") + s.T().Logf("unmapped query: code=%d, %s", code, strings.TrimSpace(output)) + // Non-zero exit indicates no valid response — expected since + // there's no working upstream. + s.Assert().NotEqual(0, code, + "unmapped domain should fail with no upstream") + }) +} + +// --------------------------------------------------------------------------- +// Exchange failure (unreachable upstream) +// --------------------------------------------------------------------------- + +// TestDNSExchangeFailure verifies graceful handling when the upstream DNS +// exchanger is unreachable. +// +// Config: server_exchange_failure.yaml points to udp://127.0.0.1:1 which +// is unreachable. The handler must not crash or leak goroutines when the +// upstream exchange fails. +func (s *DNSSuite) TestDNSExchangeFailure() { + gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, + "testdata/dns/server_exchange_failure.yaml", + []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644}, + }, + "1053/udp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + s.T().Run("unreachable-upstream", func(t *testing.T) { + // Send query once — the exchange will fail (timeout / ICMP + // unreachable). No response is expected. + code, output := s.dnsQueryOnce(gostC, "udp", "127.0.0.1", "1053", + "test.example.com", "A") + s.T().Logf("exchange failure result: code=%d, %s", + code, strings.TrimSpace(output)) + // Non-zero exit is expected since the exchange fails. + s.Assert().NotEqual(0, code, + "exchange failure should return non-zero exit") + }) + + s.T().Run("container-alive-after-failure", func(t *testing.T) { + // Verify the gost container is still running after the + // failed exchange — proves no crash or hang. + aliveCode, _, aliveErr := gostC.Exec(s.ctx, []string{"true"}) + s.Require().NoError(aliveErr, + "container exec should succeed after exchange failure") + s.Require().Equal(0, aliveCode, + "gost container should be alive after exchange failure") + }) +} + +// --------------------------------------------------------------------------- +// Rate limiter +// --------------------------------------------------------------------------- + +// TestDNSRateLimiter verifies that rate limiting configuration is accepted +// and the handler processes queries through the rate limiter path without +// crashing. Uses a generous global limit (1000/s) so queries pass. +func (s *DNSSuite) TestDNSRateLimiter() { + dnsC := s.startDNSResponder() + defer dnsC.Terminate(s.ctx) + + gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, + "testdata/dns/server_rlimiter.yaml", + []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_query.py", ContainerFilePath: "/scripts/dns_query.py", FileMode: 0644}, + }, + "1053/udp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + s.T().Run("query-with-limiter", func(t *testing.T) { + s.dnsQueryWithDelay(gostC, "udp", "127.0.0.1", "1053", + "test.example.com", "A", "10.0.0.1") + }) + s.T().Run("second-query-with-limiter", func(t *testing.T) { + s.dnsQuery(gostC, "udp", "127.0.0.1", "1053", + "test2.example.com", "A", "10.0.0.2") + }) +} + +// --------------------------------------------------------------------------- +// Invalid query +// --------------------------------------------------------------------------- + +// TestDNSInvalidQuery verifies the DNS listener gracefully handles malformed +// DNS messages. Sends garbage bytes and checks the gost container is +// unaffected (no crash, no hang). +func (s *DNSSuite) TestDNSInvalidQuery() { + gostC := s.startGostWithQueryScript("testdata/dns/server_upstream.yaml", "1053/udp") + defer gostC.Terminate(s.ctx) + + s.T().Run("send-garbage-bytes", func(t *testing.T) { + // Send 5 bytes of garbage via UDP. The miekg/dns server + // will fail to parse them and discard the packet. + s.sendRaw(gostC, "127.0.0.1", "1053", "garbage!") + }) + + s.T().Run("container-alive-after-garbage", func(t *testing.T) { + // Verify the gost container is still alive after receiving + // invalid data. + aliveCode, _, aliveErr := gostC.Exec(s.ctx, []string{"true"}) + s.Require().NoError(aliveErr, + "container exec should succeed after invalid query") + s.Require().Equal(0, aliveCode, + "gost container should be alive after invalid query") + }) +} + +// --------------------------------------------------------------------------- +// DNS over TLS +// --------------------------------------------------------------------------- + +// TestDNSTLS verifies the DNS listener in TLS mode (DNS over TLS). +// Starts the UDP DNS responder (unencrypted), a gost DNS server with +// listener mode: tls, and queries through the TLS endpoint. +func (s *DNSSuite) TestDNSTLS() { + dnsC := s.startDNSResponder() + defer dnsC.Terminate(s.ctx) + + gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, + "testdata/dns/server_tls.yaml", + []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_tls_query.py", ContainerFilePath: "/scripts/dns_tls_query.py", FileMode: 0644}, + }, + "1053/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + // Query via TLS + args := []string{"python3", "/scripts/dns_tls_query.py", + "127.0.0.1", "1053", "test.example.com", "A", "10.0.0.1"} + + for i := range 5 { + code, out, err := gostC.Exec(s.ctx, args) + if err != nil { + s.T().Logf("tls query attempt %d exec error: %v", i+1, err) + time.Sleep(time.Second) + continue + } + body, err := io.ReadAll(out) + if err != nil { + s.T().Logf("tls query attempt %d read error: %v", i+1, err) + time.Sleep(time.Second) + continue + } + output := string(body) + s.T().Logf("tls query attempt %d: %s", i+1, strings.TrimSpace(output)) + + if code == 0 { + return + } + time.Sleep(time.Second) + } + + s.T().Fatal("TLS query failed after 5 retries") +} + +func TestDNSSuite(t *testing.T) { + suite.Run(t, new(DNSSuite)) +} diff --git a/tests/e2e/scripts/dns_query.py b/tests/e2e/scripts/dns_query.py new file mode 100644 index 0000000..28ab624 --- /dev/null +++ b/tests/e2e/scripts/dns_query.py @@ -0,0 +1,162 @@ +"""DNS query client for e2e tests. + +Usage: + python3 dns_query.py udp host port qname qtype [expected_ip|empty] + python3 dns_query.py tcp host port qname qtype [expected_ip|empty] + +Sends a DNS query and optionally checks the response: + - expected_ip: verify response contains this IP address + - "empty": verify response has zero answer records (NXDOMAIN or blocked) + - omitted: exit 0 on any valid DNS response + +Exits 0 on success, 1 on failure. +""" +import struct +import socket +import sys + + +def encode_name(name): + parts = name.rstrip(".").split(".") + return b"".join(bytes([len(p)]) + p.encode() for p in parts) + b"\x00" + + +def skip_name(data, pos): + """Skip one DNS name at pos, return position after it.""" + while True: + length = data[pos] + if length == 0: + return pos + 1 + if length & 0xC0: + return pos + 2 + pos += length + 1 + + +def build_query(qname, qtype): + tid = 0x1234 + flags = 0x0100 # RD=1 + qdcount = 1 + header = struct.pack(">HHHHHH", tid, flags, qdcount, 0, 0, 0) + qname_enc = encode_name(qname) + question = qname_enc + struct.pack(">HH", qtype, 1) # QTYPE, QCLASS=IN + return header + question + + +def parse_response(data): + if len(data) < 12: + return [], -1 + flags = struct.unpack(">H", data[2:4])[0] + rcode = flags & 0x0F + qdcount = struct.unpack(">H", data[4:6])[0] + ancount = struct.unpack(">H", data[6:8])[0] + + # Skip question section + pos = 12 + for _ in range(qdcount): + pos = skip_name(data, pos) + 4 # QTYPE + QCLASS + + answers = [] + for _ in range(ancount): + pos = skip_name(data, pos) + rtype, rclass, ttl, rdlength = struct.unpack(">HHIH", data[pos:pos + 10]) + pos += 10 + rdata = data[pos:pos + rdlength] + pos += rdlength + answers.append((rtype, rclass, ttl, rdata)) + + return answers, rcode + + +def query_udp(host, port, query): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(5) + sock.sendto(query, (host, port)) + data, _ = sock.recvfrom(4096) + sock.close() + return data + + +def query_tcp(host, port, query): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((host, port)) + sock.sendall(struct.pack(">H", len(query)) + query) + raw = sock.recv(2) + if len(raw) < 2: + sock.close() + return b"" + msglen = struct.unpack(">H", raw)[0] + data = b"" + while len(data) < msglen: + chunk = sock.recv(msglen - len(data)) + if not chunk: + break + data += chunk + sock.close() + return data + + +def format_ip(rtype, rdata): + if rtype == 1 and len(rdata) == 4: + return socket.inet_ntoa(rdata) + if rtype == 28 and len(rdata) == 16: + return socket.inet_ntop(socket.AF_INET6, rdata) + return repr(rdata) + + +def main(): + if len(sys.argv) < 5: + print("Usage: dns_query.py [expected_ip|empty]") + sys.exit(1) + + mode = sys.argv[1] + host = sys.argv[2] + port = int(sys.argv[3]) + qname = sys.argv[4] + qtype_str = sys.argv[5] + expected = sys.argv[6] if len(sys.argv) > 6 else None + + qtype_map = {"A": 1, "AAAA": 28} + qtype = qtype_map.get(qtype_str, 1) + + query = build_query(qname, qtype) + + try: + if mode == "tcp": + data = query_tcp(host, port, query) + else: + data = query_udp(host, port, query) + except Exception as e: + print(f"ERROR: {e}") + sys.exit(1) + + if not data: + print("ERROR: empty response") + sys.exit(1) + + answers, rcode = parse_response(data) + print(f"Got {len(answers)} answer(s), rcode={rcode}") + + for rtype, _, _, rdata in answers: + print(f" {format_ip(rtype, rdata)}") + + if expected == "empty": + if len(answers) == 0: + print("MATCH: empty answer (expected)") + sys.exit(0) + print(f"NO MATCH: expected empty, got {len(answers)} answers") + sys.exit(1) + elif expected: + for rtype, _, _, rdata in answers: + ip = format_ip(rtype, rdata) + if ip == expected: + print(f"MATCH: expected {expected}") + sys.exit(0) + print(f"NO MATCH: expected {expected}") + sys.exit(1) + else: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/scripts/dns_responder.py b/tests/e2e/scripts/dns_responder.py new file mode 100644 index 0000000..c2cdbcb --- /dev/null +++ b/tests/e2e/scripts/dns_responder.py @@ -0,0 +1,88 @@ +"""Simple authoritative DNS responder for e2e tests. + +Listens on UDP port 5353 and responds with static records: + + test.example.com. IN A 10.0.0.1 + test2.example.com. IN A 10.0.0.2 + example.com. IN AAAA ::1 + +All other queries receive NXDOMAIN. +""" +import socketserver +import struct +import socket + + +def decode_name(data, offset): + labels = [] + while True: + length = data[offset] + if length == 0: + offset += 1 + break + if length & 0xC0: + offset += 2 + break + offset += 1 + labels.append(data[offset:offset + length].decode()) + offset += length + return '.'.join(labels), offset + + +def encode_name(name): + parts = name.rstrip(".").split(".") + return b"".join(bytes([len(p)]) + p.encode() for p in parts) + b"\x00" + + +RECORDS = { + ("test.example.com", 1): (1, 300, socket.inet_aton("10.0.0.1")), + ("test2.example.com", 1): (1, 300, socket.inet_aton("10.0.0.2")), + ("example.com", 28): (28, 300, socket.inet_pton(socket.AF_INET6, "::1")), +} + + +class DNSResponder(socketserver.DatagramRequestHandler): + def handle(self): + data = self.rfile.read(512) + if len(data) < 12: + return + + tid = struct.unpack(">H", data[:2])[0] + qdcount = struct.unpack(">H", data[4:6])[0] + + if qdcount == 0: + return + + qname, pos = decode_name(data, 12) + qtype = struct.unpack(">H", data[pos:pos + 2])[0] + qclass = struct.unpack(">H", data[pos + 2:pos + 4])[0] + + key = (qname, qtype) + if key in RECORDS: + rcode = 0 + ancount = 1 + rtype, ttl, rdata = RECORDS[key] + else: + rcode = 3 # NXDOMAIN + ancount = 0 + rtype, ttl, rdata = 0, 0, b"" + + flags = 0x8000 | 0x0400 | rcode # QR=1, AA=1, rcode + header = struct.pack(">HHHHHH", tid, flags, qdcount, ancount, 0, 0) + + question = data[12:pos + 4] + + answer = b"" + if ancount: + answer = ( + struct.pack(">HH", 0xC00C, rtype) # NAME pointer + TYPE + + struct.pack(">HI", qclass, ttl) # CLASS + TTL + + struct.pack(">H", len(rdata)) + rdata # RDLENGTH + RDATA + ) + + self.wfile.write(header + question + answer) + + +if __name__ == "__main__": + with socketserver.UDPServer(("0.0.0.0", 5353), DNSResponder) as srv: + srv.serve_forever() diff --git a/tests/e2e/scripts/dns_responder_tcp.py b/tests/e2e/scripts/dns_responder_tcp.py new file mode 100644 index 0000000..c47205e --- /dev/null +++ b/tests/e2e/scripts/dns_responder_tcp.py @@ -0,0 +1,83 @@ +"""Simple authoritative DNS responder for TCP mode e2e tests. + +Listens on TCP port 5353 and responds with the same static records +as dns_responder.py, but over TCP (RFC 5966). +""" +import socketserver +import struct +import socket + + +def decode_name(data, offset): + labels = [] + while True: + length = data[offset] + if length == 0: + offset += 1 + break + if length & 0xC0: + offset += 2 + break + offset += 1 + labels.append(data[offset:offset + length].decode()) + offset += length + return '.'.join(labels), offset + + +RECORDS = { + ("test.example.com", 1): (1, 300, socket.inet_aton("10.0.0.1")), + ("test2.example.com", 1): (1, 300, socket.inet_aton("10.0.0.2")), + ("example.com", 28): (28, 300, socket.inet_pton(socket.AF_INET6, "::1")), +} + + +class DNSResponder(socketserver.StreamRequestHandler): + def handle(self): + # TCP DNS: 2-byte length prefix + raw = self.rfile.read(2) + if len(raw) < 2: + return + msglen = struct.unpack(">H", raw)[0] + data = self.rfile.read(msglen) + if len(data) < 12: + return + + tid = struct.unpack(">H", data[:2])[0] + qdcount = struct.unpack(">H", data[4:6])[0] + + if qdcount == 0: + return + + qname, pos = decode_name(data, 12) + qtype = struct.unpack(">H", data[pos:pos + 2])[0] + qclass = struct.unpack(">H", data[pos + 2:pos + 4])[0] + + key = (qname, qtype) + if key in RECORDS: + rcode = 0 + ancount = 1 + rtype, ttl, rdata = RECORDS[key] + else: + rcode = 3 # NXDOMAIN + ancount = 0 + rtype, ttl, rdata = 0, 0, b"" + + flags = 0x8000 | 0x0400 | rcode + header = struct.pack(">HHHHHH", tid, flags, qdcount, ancount, 0, 0) + question = data[12:pos + 4] + + answer = b"" + if ancount: + answer = ( + struct.pack(">HH", 0xC00C, rtype) + + struct.pack(">HI", qclass, ttl) + + struct.pack(">H", len(rdata)) + rdata + ) + + dnsmsg = header + question + answer + self.wfile.write(struct.pack(">H", len(dnsmsg)) + dnsmsg) + + +if __name__ == "__main__": + with socketserver.TCPServer(("0.0.0.0", 5353), DNSResponder) as srv: + srv.serve_forever() diff --git a/tests/e2e/scripts/dns_tls_query.py b/tests/e2e/scripts/dns_tls_query.py new file mode 100644 index 0000000..49d9245 --- /dev/null +++ b/tests/e2e/scripts/dns_tls_query.py @@ -0,0 +1,140 @@ +"""DNS query client over TLS for e2e tests. + +Connects to the DNS-over-TLS endpoint and performs a DNS query. + +Usage: + python3 dns_tls_query.py host port qname qtype [expected_ip] +""" +import struct +import socket +import ssl +import sys + + +def encode_name(name): + parts = name.rstrip(".").split(".") + return b"".join(bytes([len(p)]) + p.encode() for p in parts) + b"\x00" + + +def build_query(tid, qname, qtype): + flags = 0x0100 + qdcount = 1 + header = struct.pack(">HHHHHH", tid, flags, qdcount, 0, 0, 0) + qname_enc = encode_name(qname) + question = qname_enc + struct.pack(">HH", qtype, 1) + return header + question + + +def parse_response(data): + if len(data) < 12: + return [], -1 + flags = struct.unpack(">H", data[2:4])[0] + rcode = flags & 0x0F + qdcount = struct.unpack(">H", data[4:6])[0] + ancount = struct.unpack(">H", data[6:8])[0] + + pos = 12 + for _ in range(qdcount): + while data[pos] != 0: + if data[pos] & 0xC0: + pos += 2 + break + pos += data[pos] + 1 + else: + pos += 1 + pos += 4 + + answers = [] + for _ in range(ancount): + if data[pos] & 0xC0: + pos += 2 + else: + while data[pos] != 0: + pos += data[pos] + 1 + pos += 1 + rtype, rclass, ttl, rdlength = struct.unpack(">HHIH", data[pos:pos + 10]) + pos += 10 + rdata = data[pos:pos + rdlength] + pos += rdlength + answers.append((rtype, rdata)) + + return answers, rcode + + +def main(): + if len(sys.argv) < 5: + print(f"Usage: {sys.argv[0]} host port qname qtype [expected_ip]") + sys.exit(1) + + host = sys.argv[1] + port = int(sys.argv[2]) + qname = sys.argv[3] + qtype_str = sys.argv[4] + expected = sys.argv[5] if len(sys.argv) > 5 else None + + qtype_map = {"A": 1, "AAAA": 28} + qtype = qtype_map.get(qtype_str, 1) + + query = build_query(0x1234, qname, qtype) + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + try: + with socket.create_connection((host, port), timeout=5) as sock: + with ctx.wrap_socket(sock, server_hostname=host) as tls_sock: + # TCP DNS: 2-byte length prefix + tls_sock.sendall(struct.pack(">H", len(query)) + query) + raw = tls_sock.recv(2) + if len(raw) < 2: + print("ERROR: short response header") + sys.exit(1) + msglen = struct.unpack(">H", raw)[0] + data = b"" + while len(data) < msglen: + chunk = tls_sock.recv(msglen - len(data)) + if not chunk: + break + data += chunk + except Exception as e: + print(f"ERROR: {e}") + sys.exit(1) + + if not data: + print("ERROR: empty response") + sys.exit(1) + + answers, rcode = parse_response(data) + print(f"Got {len(answers)} answer(s), rcode={rcode}") + + for rtype, rdata in answers: + if rtype == 1 and len(rdata) == 4: + ip = socket.inet_ntoa(rdata) + print(f" A {ip}") + elif rtype == 28 and len(rdata) == 16: + ip6 = socket.inet_ntop(socket.AF_INET6, rdata) + print(f" AAAA {ip6}") + else: + print(f" TYPE={rtype} RDATA={rdata.hex()}") + + if expected: + for rtype, rdata in answers: + if rtype == 1 and len(rdata) == 4: + ip = socket.inet_ntoa(rdata) + if ip == expected: + print(f"MATCH: expected {expected}") + sys.exit(0) + elif rtype == 28 and len(rdata) == 16: + ip6 = socket.inet_ntop(socket.AF_INET6, rdata) + if ip6 == expected: + print(f"MATCH: expected {expected}") + sys.exit(0) + print(f"NO MATCH: expected {expected}") + sys.exit(1) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/testdata/dns/server_bypass.yaml b/tests/e2e/testdata/dns/server_bypass.yaml new file mode 100644 index 0000000..0055cb5 --- /dev/null +++ b/tests/e2e/testdata/dns/server_bypass.yaml @@ -0,0 +1,16 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + dns: udp://dns-server:5353 + timeout: 5s + listener: + type: dns + bypass: block-example + +bypasses: +- name: block-example + matchers: + - test.example.com diff --git a/tests/e2e/testdata/dns/server_exchange_failure.yaml b/tests/e2e/testdata/dns/server_exchange_failure.yaml new file mode 100644 index 0000000..9bfb697 --- /dev/null +++ b/tests/e2e/testdata/dns/server_exchange_failure.yaml @@ -0,0 +1,10 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + dns: udp://127.0.0.1:1 + timeout: 2s + listener: + type: dns diff --git a/tests/e2e/testdata/dns/server_hosts.yaml b/tests/e2e/testdata/dns/server_hosts.yaml new file mode 100644 index 0000000..163c9b1 --- /dev/null +++ b/tests/e2e/testdata/dns/server_hosts.yaml @@ -0,0 +1,19 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + # No upstream — host mapper resolves before exchanger is ever reached. + timeout: 2s + listener: + type: dns + hosts: my-hosts + +hosts: +- name: my-hosts + mappings: + - ip: 10.0.0.100 + hostname: mapped.example.com + - ip: 192.168.1.200 + hostname: aaaa.mapped.example.com diff --git a/tests/e2e/testdata/dns/server_rlimiter.yaml b/tests/e2e/testdata/dns/server_rlimiter.yaml new file mode 100644 index 0000000..d2fcc4c --- /dev/null +++ b/tests/e2e/testdata/dns/server_rlimiter.yaml @@ -0,0 +1,16 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + dns: udp://dns-server:5353 + timeout: 5s + listener: + type: dns + rlimiter: limiter-0 + +rlimiters: +- name: limiter-0 + limits: + - "$ 1000" diff --git a/tests/e2e/testdata/dns/server_tcp.yaml b/tests/e2e/testdata/dns/server_tcp.yaml new file mode 100644 index 0000000..9355f64 --- /dev/null +++ b/tests/e2e/testdata/dns/server_tcp.yaml @@ -0,0 +1,12 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + dns: tcp://dns-server:5353 + timeout: 5s + listener: + type: dns + metadata: + mode: tcp diff --git a/tests/e2e/testdata/dns/server_tls.yaml b/tests/e2e/testdata/dns/server_tls.yaml new file mode 100644 index 0000000..931b9ba --- /dev/null +++ b/tests/e2e/testdata/dns/server_tls.yaml @@ -0,0 +1,12 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + dns: udp://dns-server:5353 + timeout: 5s + listener: + type: dns + metadata: + mode: tls diff --git a/tests/e2e/testdata/dns/server_upstream.yaml b/tests/e2e/testdata/dns/server_upstream.yaml new file mode 100644 index 0000000..dd83d3a --- /dev/null +++ b/tests/e2e/testdata/dns/server_upstream.yaml @@ -0,0 +1,10 @@ +services: +- name: dns + addr: :1053 + handler: + type: dns + metadata: + dns: udp://dns-server:5353 + timeout: 5s + listener: + type: dns diff --git a/tests/e2e/utils.go b/tests/e2e/utils.go index f77abe2..9efef9d 100644 --- a/tests/e2e/utils.go +++ b/tests/e2e/utils.go @@ -128,6 +128,70 @@ func udpEchoContainerRequest(_ context.Context, networkName string) testcontaine } } +// RunDNSResponderContainer starts a UDP-based DNS responder for e2e DNS tests. +// The container is registered with the network alias "dns-server". +func RunDNSResponderContainer(ctx context.Context, networkName string) (testcontainers.Container, error) { + req := testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: ".", + Dockerfile: "Dockerfile", + Repo: "gost-e2e", + Tag: "latest", + KeepImage: true, + BuildOptionsModifier: func(opts *client.ImageBuildOptions) { + opts.NetworkMode = "host" + }, + }, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"dns-server"}, + }, + Files: []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_responder.py", ContainerFilePath: "/scripts/dns_server.py", FileMode: 0644}, + }, + ExposedPorts: []string{"5353/udp"}, + Cmd: []string{"python3", "/scripts/dns_server.py"}, + WaitingFor: wait.ForExposedPort().SkipInternalCheck(), + } + + return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) +} + +// RunTCPDNSResponderContainer starts a TCP-based DNS responder for e2e DNS tests. +// The container is registered with the network alias "dns-server". +func RunTCPDNSResponderContainer(ctx context.Context, networkName string) (testcontainers.Container, error) { + req := testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: ".", + Dockerfile: "Dockerfile", + Repo: "gost-e2e", + Tag: "latest", + KeepImage: true, + BuildOptionsModifier: func(opts *client.ImageBuildOptions) { + opts.NetworkMode = "host" + }, + }, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"dns-server"}, + }, + Files: []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_responder_tcp.py", ContainerFilePath: "/scripts/dns_server.py", FileMode: 0644}, + }, + ExposedPorts: []string{"5353/tcp"}, + Cmd: []string{"python3", "/scripts/dns_server.py"}, + WaitingFor: wait.ForExposedPort(), + } + + return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) +} + func RunGostContainer(ctx context.Context, networkName, yamlPath string) (testcontainers.Container, error) { return runGostContainer(ctx, networkName, yamlPath, nil, nil, nil) }