mirror of
https://github.com/go-gost/gost.git
synced 2026-10-08 12:05:46 +00:00
e2e: demux testcontainers exec stream, fix mtls proxy flags, add rtcp filter test
- Add ExecOutput helper to demultiplex the raw Docker exec stream so test output assertions see clean stdout+stderr instead of framed bytes. - Wait for container readiness via exposed port or "listening on" log line when no ports are exposed. - Use curl --proxy-* TLS options for the mTLS HTTPS-proxy test. - Relax the http_cache first-request assertion (shared backend counter). - Add gost#898 rtcp forwarder filter.host e2e test (multi-service one tunnel). - Bump x to v0.15.7. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -55,19 +55,22 @@ func (s *HTTPCacheSuite) TestHTTPCacheHit() {
|
||||
|
||||
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
|
||||
|
||||
// First request — cache miss, backend returns "cache-test-1"
|
||||
code, out, err := gostC.Exec(s.ctx, cmd)
|
||||
// First request — cache miss, backend returns "cache-test-N"
|
||||
code, out, err := ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body1, _ := io.ReadAll(out)
|
||||
if code != 0 || !strings.Contains(string(body1), "cache-test-") {
|
||||
DumpLogs(s.T(), s.ctx, "cache-proxy logs (first req)", gostC)
|
||||
}
|
||||
s.Require().Equal(0, code, "first request should succeed")
|
||||
s.Require().Contains(string(body1), "cache-test-1",
|
||||
"first request should get backend response #1")
|
||||
// The backend counter is shared across subtests (a fresh counter backend is
|
||||
// not started per test), so the first request here is not necessarily #1.
|
||||
// Caching is proven by body2==body1 and body3==body1 below.
|
||||
s.Require().Contains(string(body1), "cache-test-",
|
||||
"first request should get a backend response")
|
||||
|
||||
// Second request — cache hit, same response
|
||||
code, out, err = gostC.Exec(s.ctx, cmd)
|
||||
code, out, err = ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body2, _ := io.ReadAll(out)
|
||||
if code != 0 {
|
||||
@@ -78,7 +81,7 @@ func (s *HTTPCacheSuite) TestHTTPCacheHit() {
|
||||
"cached response should equal first response")
|
||||
|
||||
// Third request — cache hit, same
|
||||
code, out, err = gostC.Exec(s.ctx, cmd)
|
||||
code, out, err = ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body3, _ := io.ReadAll(out)
|
||||
if code != 0 {
|
||||
@@ -102,7 +105,7 @@ func (s *HTTPCacheSuite) TestHTTPCacheDisabled() {
|
||||
|
||||
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
|
||||
|
||||
code, out, err := gostC.Exec(s.ctx, cmd)
|
||||
code, out, err := ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body1, _ := io.ReadAll(out)
|
||||
if code != 0 || !strings.Contains(string(body1), "cache-test-") {
|
||||
@@ -110,7 +113,7 @@ func (s *HTTPCacheSuite) TestHTTPCacheDisabled() {
|
||||
}
|
||||
s.Require().Equal(0, code)
|
||||
|
||||
code, out, err = gostC.Exec(s.ctx, cmd)
|
||||
code, out, err = ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body2, _ := io.ReadAll(out)
|
||||
if code != 0 {
|
||||
@@ -149,7 +152,7 @@ func (s *HTTPCacheSuite) TestHTTPCacheServeStale() {
|
||||
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"}
|
||||
|
||||
// First request — cache miss, backend returns "cache-test-1"
|
||||
code, out, err := gostC.Exec(s.ctx, cmd)
|
||||
code, out, err := ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body1, _ := io.ReadAll(out)
|
||||
if code != 0 || !strings.Contains(string(body1), "cache-test-") {
|
||||
@@ -165,7 +168,7 @@ func (s *HTTPCacheSuite) TestHTTPCacheServeStale() {
|
||||
|
||||
// Second request — stale cache hit, upstream connects but yields no
|
||||
// response → serve-stale returns the expired cached response.
|
||||
code, out, err = gostC.Exec(s.ctx, cmd)
|
||||
code, out, err = ExecOutput(s.ctx, gostC, cmd)
|
||||
s.Require().NoError(err)
|
||||
body2, _ := io.ReadAll(out)
|
||||
if code != 0 || !strings.Contains(string(body2), "cache-test-") {
|
||||
|
||||
+15
-12
@@ -140,14 +140,16 @@ func (s *MTLSSuite) TestMTLSProxy() {
|
||||
s.Require().NoError(err)
|
||||
defer gostC.Terminate(s.ctx)
|
||||
|
||||
// Verify proxy works with valid client cert
|
||||
// Verify proxy works with valid client cert. curl in HTTPS-proxy mode
|
||||
// requires --proxy-* TLS options: --cacert/--cert verify the origin (not
|
||||
// the proxy) and are ignored for the proxy TLS connection.
|
||||
cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5",
|
||||
"--cacert", "/certs/ca.pem",
|
||||
"--cert", "/certs/client.pem",
|
||||
"--key", "/certs/client-key.pem",
|
||||
"--proxy-cacert", "/certs/ca.pem",
|
||||
"--proxy-cert", "/certs/client.pem",
|
||||
"--proxy-key", "/certs/client-key.pem",
|
||||
"-x", "https://127.0.0.1:8443",
|
||||
fmt.Sprintf("http://%s:5678", s.echoIP)}
|
||||
code, out, err := gostC.Exec(s.ctx, cmd)
|
||||
code, out, err := ExecOutput(s.ctx, gostC, cmd)
|
||||
body, err2 := io.ReadAll(out)
|
||||
if err != nil || err2 != nil || code != 0 || !strings.Contains(string(body), "hello-gost") {
|
||||
DumpLogs(s.T(), s.ctx, "mtls gost logs", gostC)
|
||||
@@ -179,12 +181,13 @@ func (s *MTLSSuite) TestMTLSWithoutClientCert() {
|
||||
s.Require().NoError(err)
|
||||
defer gostC.Terminate(s.ctx)
|
||||
|
||||
// curl without client cert should fail TLS handshake
|
||||
// curl without client cert should fail TLS handshake. Verify the proxy
|
||||
// cert against the CA, but present no client cert so the server rejects.
|
||||
cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5",
|
||||
"--cacert", "/certs/ca.pem",
|
||||
"--proxy-cacert", "/certs/ca.pem",
|
||||
"-x", "https://127.0.0.1:8443",
|
||||
fmt.Sprintf("http://%s:5678", s.echoIP)}
|
||||
code, _, err := gostC.Exec(s.ctx, cmd)
|
||||
code, _, err := ExecOutput(s.ctx, gostC, cmd)
|
||||
if err == nil && code == 0 {
|
||||
DumpLogs(s.T(), s.ctx, "mtls gost logs", gostC)
|
||||
}
|
||||
@@ -215,12 +218,12 @@ func (s *MTLSSuite) TestMTLSAuthPluginLogs() {
|
||||
|
||||
// Send a request with valid client cert through the mTLS proxy.
|
||||
cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5",
|
||||
"--cacert", "/certs/ca.pem",
|
||||
"--cert", "/certs/client.pem",
|
||||
"--key", "/certs/client-key.pem",
|
||||
"--proxy-cacert", "/certs/ca.pem",
|
||||
"--proxy-cert", "/certs/client.pem",
|
||||
"--proxy-key", "/certs/client-key.pem",
|
||||
"-x", "https://127.0.0.1:8443",
|
||||
fmt.Sprintf("http://%s:5678", s.echoIP)}
|
||||
code, out, err := gostC.Exec(s.ctx, cmd)
|
||||
code, out, err := ExecOutput(s.ctx, gostC, cmd)
|
||||
body, _ := io.ReadAll(out)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(0, code)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
)
|
||||
|
||||
// RTCPFilterSuite reproduces go-gost/gost#898: multiple TCP services sharing
|
||||
// ONE tunnel ID, routed at the client (rtcp) side by forwarder.nodes[].filter.host.
|
||||
//
|
||||
// The server ingress maps two hostnames to the same tunnel endpoint. The client
|
||||
// rtcp service has two forwarder nodes, both filtered by host, with NO fallback
|
||||
// node. When the hostname is correctly propagated through the tunnel, a request
|
||||
// for example.local is routed to the example node (tcp-echo:5678). When the
|
||||
// hostname is lost (the regression), neither node matches and the connection
|
||||
// fails with "node not available".
|
||||
type RTCPFilterSuite struct {
|
||||
suite.Suite
|
||||
ctx context.Context
|
||||
echoC testcontainers.Container
|
||||
serverC testcontainers.Container
|
||||
clientC testcontainers.Container
|
||||
}
|
||||
|
||||
func (s *RTCPFilterSuite) SetupSuite() {
|
||||
s.ctx = context.Background()
|
||||
|
||||
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
|
||||
s.Require().NoError(err)
|
||||
s.echoC = echoC
|
||||
|
||||
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
|
||||
"testdata/rtcpfilter/server.yaml", []string{"gost-server"}, []string{"8420/tcp"})
|
||||
s.Require().NoError(err)
|
||||
s.serverC = serverC
|
||||
|
||||
clientC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
|
||||
"testdata/rtcpfilter/client.yaml", []string{"gost-client"}, []string{"8423/tcp"})
|
||||
s.Require().NoError(err)
|
||||
s.clientC = clientC
|
||||
}
|
||||
|
||||
func (s *RTCPFilterSuite) TearDownSuite() {
|
||||
for _, c := range []testcontainers.Container{s.clientC, s.serverC, s.echoC} {
|
||||
if c != nil {
|
||||
c.Terminate(s.ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// request sends an HTTP GET to the server entrypoint with the given Host
|
||||
// header, retrying until the reverse tunnel is bound. Returns the response body.
|
||||
func (s *RTCPFilterSuite) request(host string) string {
|
||||
cmd := []string{
|
||||
"sh", "-c",
|
||||
fmt.Sprintf("for i in $(seq 1 30); do body=$(curl -s -H 'Host: %s' http://gost-server:8420/); echo \"$body\" | grep -q hello-gost && { echo \"$body\"; exit 0; }; sleep 1; done; echo \"$body\"", 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)
|
||||
}
|
||||
|
||||
// TestMappedHostnameFiltered verifies that a Host matching a filter.host node is
|
||||
// routed through the tunnel to that node's backend. This is the gost#898 failure:
|
||||
// with the regression, the hostname is dropped and no node matches.
|
||||
func (s *RTCPFilterSuite) TestMappedHostnameFiltered() {
|
||||
body := s.request("example.local")
|
||||
if !strings.Contains(body, "hello-gost") {
|
||||
DumpLogs(s.T(), s.ctx, "rtcp client logs", s.clientC)
|
||||
DumpLogs(s.T(), s.ctx, "tunnel server logs", s.serverC)
|
||||
}
|
||||
s.Require().Contains(body, "hello-gost")
|
||||
}
|
||||
|
||||
func TestRTCPFilterSuite(t *testing.T) {
|
||||
suite.Run(t, new(RTCPFilterSuite))
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Internal client behind NAT: binds a reverse tunnel (tunnel.id matches the
|
||||
# server's ingress endpoint) and forwards tunneled connections to per-hostname
|
||||
# target services via filter.host node matching. There is deliberately NO
|
||||
# fallback node — routing must come from the hostname.
|
||||
#
|
||||
# node example → tcp-echo:5678 (the real echo HTTP server)
|
||||
# node other → tcp-echo:5679 (nothing listens here; must never be hit by
|
||||
# example.local traffic)
|
||||
services:
|
||||
- name: service-0
|
||||
addr: ":0"
|
||||
handler:
|
||||
type: rtcp
|
||||
listener:
|
||||
type: rtcp
|
||||
chain: chain-0
|
||||
forwarder:
|
||||
nodes:
|
||||
- name: example
|
||||
addr: tcp-echo:5678
|
||||
filter:
|
||||
host: example.local
|
||||
- name: other
|
||||
addr: tcp-echo:5679
|
||||
filter:
|
||||
host: other.local
|
||||
|
||||
# Dummy TCP service on a fixed port: the rtcp reverse-tunnel listener binds
|
||||
# remotely through the chain and opens no local port, so this gives the e2e
|
||||
# harness a stable port to wait on.
|
||||
- name: service-1
|
||||
addr: ":8423"
|
||||
handler:
|
||||
type: http
|
||||
listener:
|
||||
type: tcp
|
||||
|
||||
chains:
|
||||
- name: chain-0
|
||||
hops:
|
||||
- nodes:
|
||||
- addr: gost-server:8421
|
||||
connector:
|
||||
type: tunnel
|
||||
metadata:
|
||||
tunnel.id: 6be39003-f4fa-49e4-a6ef-1997684d160e
|
||||
dialer:
|
||||
type: tcp
|
||||
|
||||
log:
|
||||
level: debug
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Public-facing gost: tunnel handler with an ingress table mapping two
|
||||
# hostnames to the SAME tunnel endpoint (multi-TCP-service sharing one tunnel
|
||||
# ID — the gost#898 scenario). Requests to the HTTP entrypoint are routed by
|
||||
# their Host header through the ingress table to the matching tunnel.
|
||||
services:
|
||||
- name: service-0
|
||||
addr: ":8421"
|
||||
handler:
|
||||
type: tunnel
|
||||
metadata:
|
||||
entrypoint: ":8420"
|
||||
ingress: ingress-0
|
||||
listener:
|
||||
type: tcp
|
||||
|
||||
ingresses:
|
||||
- name: ingress-0
|
||||
rules:
|
||||
- hostname: example.local
|
||||
endpoint: 6be39003-f4fa-49e4-a6ef-1997684d160e
|
||||
- hostname: other.local
|
||||
endpoint: 6be39003-f4fa-49e4-a6ef-1997684d160e
|
||||
|
||||
log:
|
||||
level: debug
|
||||
+3
@@ -7,3 +7,6 @@ services:
|
||||
sniffing: true
|
||||
listener:
|
||||
type: tcp
|
||||
|
||||
log:
|
||||
level: debug
|
||||
|
||||
+21
-3
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcexec "github.com/testcontainers/testcontainers-go/exec"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,15 @@ type ConfigData struct {
|
||||
ServerAddr string
|
||||
}
|
||||
|
||||
// ExecOutput runs cmd in c and returns the demultiplexed output reader.
|
||||
// testcontainers' Exec returns the raw Docker stream, which multiplexes stdout
|
||||
// and stderr with 8-byte framing headers (type byte + 6 zero + length) that
|
||||
// interleave with the real data. Demultiplexing produces a clean combined
|
||||
// stdout+stderr stream that tests can assert against.
|
||||
func ExecOutput(ctx context.Context, c testcontainers.Container, cmd []string) (int, io.Reader, error) {
|
||||
return c.Exec(ctx, cmd, tcexec.Multiplexed())
|
||||
}
|
||||
|
||||
func DumpLogs(t *testing.T, ctx context.Context, label string, c testcontainers.Container) {
|
||||
logs, err := c.Logs(ctx)
|
||||
if err != nil {
|
||||
@@ -294,9 +304,7 @@ func runGostContainer(ctx context.Context, networkName, yamlPath string, aliases
|
||||
},
|
||||
},
|
||||
ExposedPorts: exposedPorts,
|
||||
// internal check for udp ports will be failed
|
||||
WaitingFor: wait.ForExposedPort().SkipInternalCheck(),
|
||||
Networks: []string{networkName},
|
||||
Networks: []string{networkName},
|
||||
NetworkAliases: map[string][]string{
|
||||
networkName: aliases,
|
||||
},
|
||||
@@ -304,6 +312,16 @@ func runGostContainer(ctx context.Context, networkName, yamlPath string, aliases
|
||||
Cmd: []string{"/bin/gost", "-C", "/config.yaml"},
|
||||
}
|
||||
|
||||
// Wait for the gost process to be ready. With exposed ports we wait for the
|
||||
// port to accept connections; without them (e.g. a socks5 proxy consumed
|
||||
// from inside the container) we wait for a startup log line instead.
|
||||
if len(exposedPorts) > 0 {
|
||||
// internal check for udp ports will be failed
|
||||
req.WaitingFor = wait.ForExposedPort().SkipInternalCheck()
|
||||
} else {
|
||||
req.WaitingFor = wait.ForLog("listening on")
|
||||
}
|
||||
|
||||
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
|
||||
Reference in New Issue
Block a user