diff --git a/go.mod b/go.mod index 99317f4..8b3518d 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.6 + github.com/go-gost/x v0.15.7 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 3e1bd38..1fdd474 100644 --- a/go.sum +++ b/go.sum @@ -99,8 +99,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.6 h1:QVIRNhH4PB61KYwLs0nkKIO7c7TiKaevE2afmqq2eUo= -github.com/go-gost/x v0.15.6/go.mod h1:ZSKcbDLe6UulbWkDNCsqmVIHzda2CrxxzslIdmNt5O0= +github.com/go-gost/x v0.15.7 h1:yuiEs7CoUO4FDFDMH11q2XgdUMJ97HXlqaPZjIY8k3I= +github.com/go-gost/x v0.15.7/go.mod h1:ZSKcbDLe6UulbWkDNCsqmVIHzda2CrxxzslIdmNt5O0= 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/http_cache_test.go b/tests/e2e/http_cache_test.go index fb04d42..521e8f9 100644 --- a/tests/e2e/http_cache_test.go +++ b/tests/e2e/http_cache_test.go @@ -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-") { diff --git a/tests/e2e/mtls_test.go b/tests/e2e/mtls_test.go index bad7202..3fc53c5 100644 --- a/tests/e2e/mtls_test.go +++ b/tests/e2e/mtls_test.go @@ -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) diff --git a/tests/e2e/rtcpfilter_test.go b/tests/e2e/rtcpfilter_test.go new file mode 100644 index 0000000..77e7c24 --- /dev/null +++ b/tests/e2e/rtcpfilter_test.go @@ -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)) +} diff --git a/tests/e2e/testdata/rtcpfilter/client.yaml b/tests/e2e/testdata/rtcpfilter/client.yaml new file mode 100644 index 0000000..fe5a078 --- /dev/null +++ b/tests/e2e/testdata/rtcpfilter/client.yaml @@ -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 diff --git a/tests/e2e/testdata/rtcpfilter/server.yaml b/tests/e2e/testdata/rtcpfilter/server.yaml new file mode 100644 index 0000000..dc3bfd3 --- /dev/null +++ b/tests/e2e/testdata/rtcpfilter/server.yaml @@ -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 diff --git a/tests/e2e/testdata/sniffing/no_sni.yaml b/tests/e2e/testdata/sniffing/no_sni.yaml index 652e46d..a82a1ba 100644 --- a/tests/e2e/testdata/sniffing/no_sni.yaml +++ b/tests/e2e/testdata/sniffing/no_sni.yaml @@ -7,3 +7,6 @@ services: sniffing: true listener: type: tcp + +log: + level: debug diff --git a/tests/e2e/utils.go b/tests/e2e/utils.go index 3fba626..2743287 100644 --- a/tests/e2e/utils.go +++ b/tests/e2e/utils.go @@ -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,