mirror of
https://github.com/go-gost/gost.git
synced 2026-10-08 12:05:46 +00:00
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:
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
- name: service-0
|
||||
addr: ":8080"
|
||||
handler:
|
||||
type: socks5
|
||||
metadata:
|
||||
sniffing: true
|
||||
listener:
|
||||
type: tcp
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user