chore: bump x to v0.15.1, add e2e tests for chainGroup and sniffing

- Bump github.com/go-gost/x v0.15.0 → v0.15.1
- ChainGroupSuite: matcher routing by host, probe dead-chain pre-marking
- SniffingSuite: SOCKS5+HTTPS w/o SNI, debug-only log on empty SNI
- Add RunHTTPSEchoContainer and https_echo.py test helper
This commit is contained in:
ginuerzh
2026-07-29 23:06:15 +08:00
parent 555dacd244
commit 55bb214a94
9 changed files with 406 additions and 3 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.3
require ( require (
github.com/go-gost/core v0.6.0 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/judwhite/go-svc v1.2.1
github.com/moby/moby/client v0.4.0 github.com/moby/moby/client v0.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
+2 -2
View File
@@ -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/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 h1:gvOteWog5pjY/HCpc8l+gngmSi8Q6zl5rRrfK8gwRKA=
github.com/go-gost/tls-dissector v0.3.1/go.mod h1:vGfog053fIm93iXBtvmVzMQqEJo5YwbbNaPNVDjbiOc= 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.1 h1:S8rP1HsU943GracSrok81SlsbTkHXJxkhXncXpxxDqU=
github.com/go-gost/x v0.15.0/go.mod h1:YQdy2hZ9nZ1w5GDacGKWrC4jIYXzthAKO/Er/K65+c4= 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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+106
View File
@@ -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))
}
+42
View File
@@ -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()
+115
View File
@@ -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))
}
+48
View File
@@ -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
+52
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
services:
- name: service-0
addr: ":8080"
handler:
type: socks5
metadata:
sniffing: true
listener:
type: tcp
+31
View File
@@ -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) { func RunGostContainer(ctx context.Context, networkName, yamlPath string) (testcontainers.Container, error) {
return runGostContainer(ctx, networkName, yamlPath, nil, nil, nil) return runGostContainer(ctx, networkName, yamlPath, nil, nil, nil)
} }