diff --git a/tests/e2e/resolver_test.go b/tests/e2e/resolver_test.go new file mode 100644 index 0000000..5ae5cf7 --- /dev/null +++ b/tests/e2e/resolver_test.go @@ -0,0 +1,91 @@ +package e2e + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +// ResolverSuite verifies that a configured resolver drives the proxy's +// outbound DNS resolution. A gost HTTP proxy uses a resolver whose only +// nameserver is a test responder: it answers echo.test with the real echo +// server IP and NXDOMAIN for everything else. A request to echo.test is +// resolved by the custom resolver and reaches the echo server; a request to an +// unmapped host fails to resolve. +type ResolverSuite struct { + suite.Suite + ctx context.Context + echoC testcontainers.Container + echoIP string + dnsC testcontainers.Container + proxyC testcontainers.Container +} + +func (s *ResolverSuite) SetupSuite() { + s.ctx = context.Background() + + echoC, err := RunEchoContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.echoC = echoC + + echoIP, err := echoC.ContainerIP(s.ctx) + s.Require().NoError(err) + s.echoIP = echoIP + + dnsC, err := RunResolverResponderContainer(s.ctx, SharedNetworkName, s.echoIP) + s.Require().NoError(err) + s.dnsC = dnsC + + proxyC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, + "testdata/resolver/server.yaml", []string{"gost-proxy"}, []string{"8080/tcp"}) + s.Require().NoError(err) + s.proxyC = proxyC +} + +func (s *ResolverSuite) TearDownSuite() { + for _, c := range []testcontainers.Container{s.proxyC, s.dnsC, s.echoC} { + if c != nil { + c.Terminate(s.ctx) + } + } +} + +// proxyRequest sends an HTTP GET to the given host through the gost proxy. When +// waitResolve is true it retries until the resolver/responder is ready (the +// resolved-host happy path); otherwise it makes a single attempt. Returns the +// response body. +func (s *ResolverSuite) proxyRequest(host string, waitResolve bool) string { + loop := "for i in $(seq 1 30); do " + if !waitResolve { + loop = "for i in 1; do " + } + cmd := []string{ + "sh", "-c", + fmt.Sprintf("%sbody=$(curl -s -x http://gost-proxy:8080 http://%s:5678/); echo \"$body\" | grep -q hello-gost && { echo \"$body\"; exit 0; }; sleep 1; done; echo \"$body\"", loop, host), + } + _, out, err := s.echoC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body, err := io.ReadAll(out) + s.Require().NoError(err) + return string(body) +} + +// TestResolvedHostname verifies that the custom resolver resolves echo.test to +// the echo server IP, so the proxied request reaches the echo server. +func (s *ResolverSuite) TestResolvedHostname() { + s.Require().Contains(s.proxyRequest("echo.test", true), "hello-gost") +} + +// TestUnresolvedHostname verifies that a host absent from the resolver gets +// NXDOMAIN and never reaches the echo server. +func (s *ResolverSuite) TestUnresolvedHostname() { + s.Require().NotContains(s.proxyRequest("nomapped.test", false), "hello-gost") +} + +func TestResolverSuite(t *testing.T) { + suite.Run(t, new(ResolverSuite)) +} diff --git a/tests/e2e/scripts/dns_resolver_responder.py b/tests/e2e/scripts/dns_resolver_responder.py new file mode 100644 index 0000000..029209c --- /dev/null +++ b/tests/e2e/scripts/dns_resolver_responder.py @@ -0,0 +1,74 @@ +"""DNS responder for resolver e2e tests. + +Returns the target IP (argv[1]) for an A query of "echo.test" and +NXDOMAIN for every other query. This lets a gost proxy configured with +this server as its resolver resolve a made-up hostname to a reachable +echo server, proving the resolver drives outbound dialing. + +Listens on UDP port argv[2] (default 5353). +""" +import socketserver +import struct +import socket +import sys + +TARGET_IP = sys.argv[1] if len(sys.argv) > 1 else "10.0.0.1" +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 5353 +MATCH_NAME = "echo.test" + + +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 + + +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] + + if qname == MATCH_NAME and qtype == 1: + rcode, ancount = 0, 1 + rtype, ttl, rdata = 1, 300, socket.inet_aton(TARGET_IP) + else: + rcode, ancount = 3, 0 # NXDOMAIN + 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__": + socketserver.ThreadingUDPServer.allow_reuse_address = True + with socketserver.ThreadingUDPServer(("0.0.0.0", PORT), DNSResponder) as srv: + srv.serve_forever() diff --git a/tests/e2e/testdata/resolver/server.yaml b/tests/e2e/testdata/resolver/server.yaml new file mode 100644 index 0000000..8a1a53a --- /dev/null +++ b/tests/e2e/testdata/resolver/server.yaml @@ -0,0 +1,19 @@ +# HTTP proxy whose outbound dialing uses a custom resolver. The resolver's +# only nameserver is the test DNS responder, which answers echo.test with the +# real echo server IP and NXDOMAIN for everything else. A request to +# echo.test:5678 is therefore resolved by the custom resolver and reaches the +# echo server; a request to an unmapped host fails to resolve. +services: + - name: proxy + addr: ":8080" + resolver: resolver-0 + handler: + type: http + listener: + type: tcp + +resolvers: + - name: resolver-0 + nameservers: + - addr: dns-responder:5353 + timeout: 3s diff --git a/tests/e2e/utils.go b/tests/e2e/utils.go index 9efef9d..9dfaf85 100644 --- a/tests/e2e/utils.go +++ b/tests/e2e/utils.go @@ -192,6 +192,41 @@ func RunTCPDNSResponderContainer(ctx context.Context, networkName string) (testc }) } +// RunResolverResponderContainer starts a UDP DNS responder that answers A +// queries for "echo.test" with targetIP (an echo server's address) and returns +// NXDOMAIN for all other names. It is used to prove the resolver module drives +// outbound dialing: a gost proxy pointed at this responder resolves echo.test +// to a reachable address. The container is aliased "dns-responder". +func RunResolverResponderContainer(ctx context.Context, networkName, targetIP 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-responder"}, + }, + Files: []testcontainers.ContainerFile{ + {HostFilePath: "scripts/dns_resolver_responder.py", ContainerFilePath: "/scripts/dns_server.py", FileMode: 0644}, + }, + ExposedPorts: []string{"5353/udp"}, + Cmd: []string{"python3", "/scripts/dns_server.py", targetIP, "5353"}, + WaitingFor: wait.ForExposedPort().SkipInternalCheck(), + } + + 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) }