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:
ginuerzh
2026-08-26 22:27:52 +08:00
co-authored by Claude
parent ecf14b8459
commit 5e40415ac9
9 changed files with 216 additions and 28 deletions
+13 -10
View File
@@ -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
View File
@@ -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)
+85
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -7,3 +7,6 @@ services:
sniffing: true
listener:
type: tcp
log:
level: debug
+21 -3
View File
@@ -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,