diff --git a/go.mod b/go.mod index ce9dd9a..a2d6a53 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/go-gost/core v0.6.0 - github.com/go-gost/x v0.15.0 + github.com/go-gost/x v0.15.1 github.com/judwhite/go-svc v1.2.1 github.com/moby/moby/client v0.4.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index a94156e..8d539c7 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/go-gost/relay v0.7.0 h1:J8e3Sba6DtBJQotXY5j5EaZNWwtv6Z9CoCFQsnrHNcE= github.com/go-gost/relay v0.7.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI= github.com/go-gost/tls-dissector v0.3.1 h1:gvOteWog5pjY/HCpc8l+gngmSi8Q6zl5rRrfK8gwRKA= github.com/go-gost/tls-dissector v0.3.1/go.mod h1:vGfog053fIm93iXBtvmVzMQqEJo5YwbbNaPNVDjbiOc= -github.com/go-gost/x v0.15.0 h1:ugklRGp3fg8lveJ5JOp0wpnF2xxmP3qzb1TyWkEGDGc= -github.com/go-gost/x v0.15.0/go.mod h1:YQdy2hZ9nZ1w5GDacGKWrC4jIYXzthAKO/Er/K65+c4= +github.com/go-gost/x v0.15.1 h1:S8rP1HsU943GracSrok81SlsbTkHXJxkhXncXpxxDqU= +github.com/go-gost/x v0.15.1/go.mod h1:jQE7dktg2aMxfOiNmN3hqnbQb2K6KecMVBtglL+/H14= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/tests/e2e/chaingroup_test.go b/tests/e2e/chaingroup_test.go new file mode 100644 index 0000000..7eb8441 --- /dev/null +++ b/tests/e2e/chaingroup_test.go @@ -0,0 +1,106 @@ +package e2e + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +type ChainGroupSuite struct { + suite.Suite + ctx context.Context + echoC testcontainers.Container + echoIP string +} + +func (s *ChainGroupSuite) 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 +} + +func (s *ChainGroupSuite) TearDownSuite() { + if s.echoC != nil { + s.echoC.Terminate(s.ctx) + } +} + +// proxyRequest sends a request through the gost proxy, using the given +// target hostname (not IP) so that Host() matchers at the chain-group level +// can match against it. +func (s *ChainGroupSuite) proxyRequestHost(gostC testcontainers.Container, proxyPort, targetHost string) (int, string) { + cmd := []string{ + "curl", "-s", + "-x", fmt.Sprintf("http://127.0.0.1:%s", proxyPort), + fmt.Sprintf("http://%s:5678", targetHost), + } + code, out, err := gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + + body, err := io.ReadAll(out) + s.Require().NoError(err) + return code, string(body) +} + +// proxyRequestIP sends a request via IP (like existing tests do). +func (s *ChainGroupSuite) proxyRequestIP(gostC testcontainers.Container, port string) (int, string) { + return s.proxyRequestHost(gostC, port, s.echoIP) +} + +// TestMatcherRoutesByHost verifies that a chainGroup with per-entry Host() matchers +// routes traffic to the chain whose matcher matches the target hostname. The +// non-matching chain has a dead relay, so if the matcher fails to filter, requests +// would fail. +func (s *ChainGroupSuite) TestMatcherRoutesByHost() { + gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/chaingroup/matcher.yaml", "8080/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + // Requests with hostname "tcp-echo" (the echo container's network alias) + // must match Host("tcp-echo") → chain-target → live relay → echo server. + for range 10 { + code, body := s.proxyRequestHost(gostC, "8080", "tcp-echo") + s.Require().Equal(0, code, "Host('tcp-echo') must match the target chain") + s.Require().Contains(body, "hello-gost") + } +} + +// TestProbeMarksDeadChain verifies that a TCP probe detects a dead chain entry +// and marks it before real traffic, so the FailFilter excludes it. With the dead +// entry pre-marked, every request succeeds. +func (s *ChainGroupSuite) TestProbeMarksDeadChain() { + gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/chaingroup/probe.yaml", "8080/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + // The probe fires immediately at startup; by the time we send requests + // the dead chain entry is already marked. All requests converge to the + // live chain. + failures := 0 + for range 10 { + code, body := s.proxyRequestIP(gostC, "8080") + if code != 0 || !strings.Contains(body, "hello-gost") { + failures++ + } + } + + // At most one failure is acceptable (the first request might race with + // the probe's first Mark). Every request after must succeed. + s.Require().LessOrEqual(failures, 0, + "probe must pre-mark the dead chain entry; all requests must succeed") +} + +func TestChainGroupSuite(t *testing.T) { + suite.Run(t, new(ChainGroupSuite)) +} diff --git a/tests/e2e/scripts/https_echo.py b/tests/e2e/scripts/https_echo.py new file mode 100644 index 0000000..c54e079 --- /dev/null +++ b/tests/e2e/scripts/https_echo.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import os +import subprocess +import ssl +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(os.environ.get("PORT", "8443")) + +cert_dir = "/tmp/certs" +os.makedirs(cert_dir, exist_ok=True) +cert_file = os.path.join(cert_dir, "cert.pem") +key_file = os.path.join(cert_dir, "key.pem") + +if not os.path.exists(cert_file) or not os.path.exists(key_file): + subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", + "-keyout", key_file, "-out", cert_file, + "-days", "365", "-nodes", + "-subj", "/CN=localhost", + ], + check=True, + ) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + body = b"hello-gost" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + +server = HTTPServer(("0.0.0.0", PORT), Handler) +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain(cert_file, key_file) +server.socket = ctx.wrap_socket(server.socket, server_side=True) +server.serve_forever() diff --git a/tests/e2e/sniffing_test.go b/tests/e2e/sniffing_test.go new file mode 100644 index 0000000..6b310e6 --- /dev/null +++ b/tests/e2e/sniffing_test.go @@ -0,0 +1,115 @@ +package e2e + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +// SniffingSuite covers protocol sniffing behavior. +type SniffingSuite struct { + suite.Suite + ctx context.Context + httpsEchoC testcontainers.Container + httpsIP string +} + +func (s *SniffingSuite) SetupSuite() { + s.ctx = context.Background() + + c, err := RunHTTPSEchoContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.httpsEchoC = c + + ip, err := c.ContainerIP(s.ctx) + s.Require().NoError(err) + s.httpsIP = ip +} + +func (s *SniffingSuite) TearDownSuite() { + if s.httpsEchoC != nil { + s.httpsEchoC.Terminate(s.ctx) + } +} + +// curlThroughSOCKS5 runs curl through the GOST SOCKS5 proxy on the given +// container address and port, connecting to the HTTPS echo server by IP. +// Connecting by IP makes curl omit the TLS SNI extension. +func (s *SniffingSuite) curlThroughSOCKS5(c testcontainers.Container, proxyPort string) string { + s.T().Helper() + // curl -k: don't verify the self-signed cert on the echo server + // curl -x socks5://...: route through GOST SOCKS5 proxy + // Using the container IP as the host makes curl skip SNI entirely. + cmd := []string{"curl", "-k", "-s", + "-x", "socks5://127.0.0.1:" + proxyPort, + "https://" + s.httpsIP + ":8443", + } + code, out, err := c.Exec(s.ctx, cmd) + s.Require().NoError(err) + s.Require().Zero(code, "curl should exit 0") + b, _ := io.ReadAll(out) + return string(b) +} + +// TestSOCKS5NoSNI verifies that a SOCKS5 proxy with sniffing enabled can +// successfully forward TLS connections that lack an SNI extension — such as +// when a client connects to an HTTPS server by IP address. +func (s *SniffingSuite) TestSOCKS5NoSNI() { + gostC, err := RunGostContainer(s.ctx, SharedNetworkName, + "testdata/sniffing/no_sni.yaml", + ) + s.Require().NoError(err) + defer func() { + DumpLogs(s.T(), s.ctx, "gost-no-sni", gostC) + gostC.Terminate(s.ctx) + }() + + body := s.curlThroughSOCKS5(gostC, "8080") + s.Require().Contains(body, "hello-gost", + "HTTPS response should pass through when SNI is empty") +} + +// TestSOCKS5NoSNI_Callback verifies the same behavior a second time, serving +// as a sanity check that state doesn't leak between connections. +func (s *SniffingSuite) TestSOCKS5NoSNI_Callback() { + s.TestSOCKS5NoSNI() +} + +// TestSOCKS5NoSNI_LogsOnlyDebug verifies the proxy logs a debug message +// rather than an error when SNI is missing. +func (s *SniffingSuite) TestSOCKS5NoSNI_LogsOnlyDebug() { + gostC, err := RunGostContainer(s.ctx, SharedNetworkName, + "testdata/sniffing/no_sni.yaml", + ) + s.Require().NoError(err) + defer func() { + // Do NOT dump logs before the grep — DumpLogs consumes the reader + gostC.Terminate(s.ctx) + }() + + body := s.curlThroughSOCKS5(gostC, "8080") + s.Require().Contains(body, "hello-gost") + + // Verify the debug log message exists and no error-level SNI message. + logs, err := gostC.Logs(s.ctx) + s.Require().NoError(err) + defer logs.Close() + logBody, _ := io.ReadAll(logs) + logStr := string(logBody) + + s.Require().Contains(logStr, "no sni in clienthello", + "should log a debug message when SNI is empty") + s.Require().NotContains(logStr, "tls: sni is empty", + "must not error on empty SNI") + // The log level for "no sni" should be debug, not error. + s.Require().False(strings.Contains(logStr, `"level":"error"`), + "should not log any error-level message") +} + +func TestSniffingSuite(t *testing.T) { + suite.Run(t, new(SniffingSuite)) +} diff --git a/tests/e2e/testdata/chaingroup/matcher.yaml b/tests/e2e/testdata/chaingroup/matcher.yaml new file mode 100644 index 0000000..e705c25 --- /dev/null +++ b/tests/e2e/testdata/chaingroup/matcher.yaml @@ -0,0 +1,48 @@ +services: +- name: proxy + addr: :8080 + handler: + type: http + chainGroup: + chains: + - chain: chain-target + matcher: + rule: Host(`tcp-echo`) + - chain: chain-other + matcher: + rule: Host(`no-match`) + selector: + strategy: round + maxFails: 1 + failTimeout: 10s + listener: + type: tcp + +# Live relay: forwards to echo server. +- name: relay + addr: 127.0.0.1:18081 + handler: + type: http + listener: + type: tcp + +chains: +- name: chain-target + hops: + - name: hop-target + nodes: + - name: n-live + addr: 127.0.0.1:18081 + connector: + type: http + +# This chain has a dead relay — but the matcher Host("no-match") ensures it is +# never selected for requests targeting tcp-echo. +- name: chain-other + hops: + - name: hop-other + nodes: + - name: n-dead + addr: 127.0.0.1:18082 + connector: + type: http diff --git a/tests/e2e/testdata/chaingroup/probe.yaml b/tests/e2e/testdata/chaingroup/probe.yaml new file mode 100644 index 0000000..cf50ce2 --- /dev/null +++ b/tests/e2e/testdata/chaingroup/probe.yaml @@ -0,0 +1,52 @@ +services: +- name: proxy + addr: :8080 + handler: + type: http + chainGroup: + chains: + - chain: chain-live + probe: + type: tcp + addr: 127.0.0.1:18081 + interval: 3s + - chain: chain-dead + probe: + type: tcp + addr: 127.0.0.1:18082 + interval: 3s + selector: + strategy: round + maxFails: 1 + failTimeout: 10s + listener: + type: tcp + +# Live relay: forwards to echo server. +- name: relay + addr: 127.0.0.1:18081 + handler: + type: http + listener: + type: tcp + +chains: +- name: chain-live + hops: + - name: hop-live + nodes: + - name: n-live + addr: 127.0.0.1:18081 + connector: + type: http + +# Dead chain: relay never listens here. TCP probe marks the chain entry so +# FailFilter excludes it before real traffic reaches it. +- name: chain-dead + hops: + - name: hop-dead + nodes: + - name: n-dead + addr: 127.0.0.1:18082 + connector: + type: http diff --git a/tests/e2e/testdata/sniffing/no_sni.yaml b/tests/e2e/testdata/sniffing/no_sni.yaml new file mode 100644 index 0000000..652e46d --- /dev/null +++ b/tests/e2e/testdata/sniffing/no_sni.yaml @@ -0,0 +1,9 @@ +services: + - name: service-0 + addr: ":8080" + handler: + type: socks5 + metadata: + sniffing: true + listener: + type: tcp diff --git a/tests/e2e/utils.go b/tests/e2e/utils.go index 9dfaf85..3fba626 100644 --- a/tests/e2e/utils.go +++ b/tests/e2e/utils.go @@ -227,6 +227,37 @@ func RunResolverResponderContainer(ctx context.Context, networkName, targetIP st }) } +// RunHTTPSEchoContainer starts an HTTPS echo server (self-signed cert) that +// responds with "hello-gost" on port 8443. The container is registered with +// the network alias "https-echo". +func RunHTTPSEchoContainer(ctx context.Context, networkName string) (testcontainers.Container, error) { + return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: 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: {"https-echo"}, + }, + Files: []testcontainers.ContainerFile{ + {HostFilePath: "scripts/https_echo.py", ContainerFilePath: "/scripts/https_echo.py", FileMode: 0644}, + }, + ExposedPorts: []string{"8443/tcp"}, + Cmd: []string{"python3", "/scripts/https_echo.py"}, + WaitingFor: wait.ForExposedPort(), + }, + Started: true, + }) +} + func RunGostContainer(ctx context.Context, networkName, yamlPath string) (testcontainers.Container, error) { return runGostContainer(ctx, networkName, yamlPath, nil, nil, nil) }