Files
gost/tests/e2e/dns_test.go
T
ginuerzh 43724a5c40 test: add http2 handler e2e suite and tighten host-mapper + idle-timeout tests
Add tests/e2e/http2_test.go covering the http2 handler/listener/connector/dialer
end-to-end through the canonical gost-as-client pattern (8 subtests: forward,
auth, bypass, probeResist/metadata, h2 stream multiplexing). Include
testdata/http2/{server,server_auth,server_bypass,server_proberesist,client,
client_auth}.yaml. Note that h2/h2c listeners pair with the tunnel handler
and only http2 listener pairs with handler http2 (reads r/w from metadata).

Tighten TestDNSHostMapper by pointing the handler at an unreachable upstream
so unmapped names fail at the exchanger, confirming the host-mapper path was
the sole reason mapped names resolved. Fix http_idle_timeout.py to speak HTTP
through the CONNECT tunnel rather than a raw ping, matching the echo backend.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 20:15:31 +08:00

361 lines
13 KiB
Go

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 and
// points the handler at an unreachable upstream (udp://127.0.0.1:1).
// The handler checks the host mapper before the exchange path, so
// mapped names resolve without needing any upstream DNS. Unmapped
// names must fall through to the exchanger, which fails — confirming
// the host-mapper path was the only reason mapped names worked.
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))
}