Files
gost/tests/e2e/forward_test.go
T
ginuerzh 0bb9109c71 feat: add comprehensive forward handler e2e tests
Add TCP/UDP forward handler e2e tests covering basic forwarding, alias
(forward/tcp), sniffing, raw pipe, idleTimeout, multi-node protocol
filtering, sniffing bypass (403), stateful UDP, and stateless UDP.

New files:
- forward_test.go: ForwardSuite with 9 test methods
- testdata/forward/: server configs (tcp, udp, stateless, sniffing,
  bypass, multi-node, idleTimeout)
- scripts/tcp_idle_timeout.py, udp_forward_test.py: Python harnesses
  for in-container idle timeout and UDP echo verification
2026-06-27 13:59:22 +08:00

292 lines
11 KiB
Go

package e2e
import (
"context"
"encoding/base64"
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
type ForwardSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
udpC testcontainers.Container
}
func (s *ForwardSuite) SetupSuite() {
s.ctx = context.Background()
s.T().Logf("start tcp echo container...")
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
s.T().Logf("start udp echo container...")
udpC, err := RunUDPEchoContainer(s.ctx, SharedNetworkName)
s.Require().NoError(err)
s.udpC = udpC
}
// sendRaw sends a raw HTTP request via netcat and returns the response.
// Uses base64 to avoid shell quoting issues with CRLF bytes.
func (s *ForwardSuite) sendRaw(gostC testcontainers.Container, host, port, data string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(data))
cmd := []string{"sh", "-c",
fmt.Sprintf("echo %s | base64 -d | nc -w 5 %s %s", encoded, host, port)}
_, out, _ := gostC.Exec(s.ctx, cmd)
b, _ := io.ReadAll(out)
return string(b)
}
func (s *ForwardSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
if s.udpC != nil {
s.udpC.Terminate(s.ctx)
}
}
// TestTCPForward verifies basic TCP forward handler (handler type: tcp).
// The forward handler pipes raw TCP connections to the configured forwarder
// node (tcp-echo:5678). curl connects directly to the handler port and sends
// an HTTP request, expecting the echo server's "hello-gost" response.
func (s *ForwardSuite) TestTCPForward() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl directly to the handler port (not via -x proxy flag).
// The TCP forward handler pipes our connection to tcp-echo:5678.
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "tcp-forward logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestForwardAlias verifies that the "forward" handler type (alias for "tcp")
// works identically to the "tcp" handler type.
func (s *ForwardSuite) TestForwardAlias() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "forward-alias logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestTCPForwardSniffing verifies TCP forward handler with sniffing enabled.
// When sniffing is enabled and the connection starts with HTTP data, the handler
// detects the protocol via sniffing.Sniff and delegates to the HTTP sniffer
// for protocol-aware forwarding. The result is the same as raw forwarding:
// "hello-gost" from the echo server.
func (s *ForwardSuite) TestTCPForwardSniffing() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server_sniffing.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl directly — sniffing detects HTTP and handles via sniffer.
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "tcp-forward-sniffing logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost")
}
// TestTCPForwardRaw verifies raw TCP forwarding by sending an HTTP request
// via netcat through the forward handler. This tests the handleRawForwarding
// code path (no sniffing), proving that raw bytes are piped through correctly.
func (s *ForwardSuite) TestTCPForwardRaw() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// Send raw HTTP request via nc (not curl) to exercise the raw pipe path.
resp := s.sendRaw(gostC, "127.0.0.1", "8000",
"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n")
s.Assert().Contains(resp, "hello-gost",
"raw request through TCP forward should reach echo server")
}
// TestTCPForwardIdleTimeout verifies that idleTimeout closes the pipe after
// a period of inactivity. The forward handler's xnet.Pipe uses idleTimeout
// as a read deadline on the upstream connection — if no data flows for
// that duration, the pipe closes both directions.
func (s *ForwardSuite) TestTCPForwardIdleTimeout() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_idle_timeout.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/tcp_idle_timeout.py", ContainerFilePath: "/scripts/tcp_idle_timeout.py", FileMode: 0644},
},
"8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// The Python script:
// 1. Connects to gost TCP forward port
// 2. Sends HTTP GET → expects "hello-gost" confirm pipe is alive
// 3. Waits > idleTimeout (3s + 2s buffer)
// 4. Sends more data — expects connection to be closed
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/tcp_idle_timeout.py",
"127.0.0.1", "8000", "3",
})
output, _ := io.ReadAll(out)
s.T().Logf("idle timeout output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "tcp-idle-timeout logs", gostC)
}
s.Require().Equal(0, code, "idle timeout test script should exit 0")
}
// TestUDPForward verifies basic UDP forwarding (handler: udp, listener: udp).
// The handler uses handleRawDatagram via the stateful UDP listener's
// per-client session conns (which implement net.PacketConn).
// A Python script inside the gost container sends a UDP datagram through
// the forward handler and verifies the echo response from udp-echo:5679.
func (s *ForwardSuite) TestUDPForward() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_udp.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/udp_forward_test.py", ContainerFilePath: "/scripts/udp_forward_test.py", FileMode: 0644},
},
"9000/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/udp_forward_test.py",
"127.0.0.1", "9000",
})
output, _ := io.ReadAll(out)
s.T().Logf("udp forward output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "udp-forward logs", gostC)
}
s.Require().Equal(0, code, "udp forward test script should exit 0")
}
// TestUDPForwardStateless verifies UDP forwarding with stateless mode.
// Both listener and handler use stateless: true, so each datagram is a
// single request-response cycle with no per-client session tracking.
func (s *ForwardSuite) TestUDPForwardStateless() {
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
"testdata/forward/server_udp_stateless.yaml",
[]testcontainers.ContainerFile{
{HostFilePath: "scripts/udp_forward_test.py", ContainerFilePath: "/scripts/udp_forward_test.py", FileMode: 0644},
},
"9000/udp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
code, out, err := gostC.Exec(s.ctx, []string{
"python3", "/scripts/udp_forward_test.py",
"127.0.0.1", "9000",
})
output, _ := io.ReadAll(out)
s.T().Logf("udp forward stateless output:\n%s", string(output))
if code != 0 {
DumpLogs(s.T(), s.ctx, "udp-forward-stateless logs", gostC)
}
s.Require().Equal(0, code, "udp forward stateless test script should exit 0")
}
// TestTCPForwardSniffingBypass verifies that when sniffing is enabled and a
// bypass rule blocks the target, the HTTP sniffer returns 403 Forbidden.
// The forward handler delegates to the HTTP sniffer (via handleSniffedProtocol),
// and the sniffer's resolveHTTPNode checks h.options.Bypass and returns 403
// when the destination is matched.
func (s *ForwardSuite) TestTCPForwardSniffingBypass() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server_bypass_sniffing.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl connects directly; sniffing detects HTTP. The sniffer bypass
// check matches 0.0.0.0/0 and returns 403 Forbidden.
cmd := []string{"curl", "-v", "-s", "-D", "-", "-o", "/dev/null",
"http://127.0.0.1:8000/"}
_, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, _ := io.ReadAll(out)
output := string(body)
s.Assert().Contains(output, "403",
"bypass should return 403 Forbidden from sniffer")
}
// TestTCPForwardMultiNodeProtocol verifies that sniffing + protocol-filtered
// forwarder nodes correctly routes an HTTP request to the node with matching
// protocol (http), rather than the one with protocol: tls.
//
// Config has two nodes:
// - echo-http (protocol: http) → tcp-echo:5678 (works, returns "hello-gost")
// - echo-tls (protocol: tls) → tcp-echo:1 (closed port, would fail)
//
// With sniffing enabled, an HTTP request is detected as protocol "http",
// Select("http") filters to only the echo-http node. If protocol filtering
// fails and the tls node is selected instead, the connection to port 1
// fails and the test fails.
func (s *ForwardSuite) TestTCPForwardMultiNodeProtocol() {
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName,
"testdata/forward/server_multi_node.yaml", "8000/tcp")
s.Require().NoError(err)
defer gostC.Terminate(s.ctx)
// curl sends an HTTP request → sniffed as "http" → Select("http")
// filters to echo-http (protocol: http) → pipes to tcp-echo:5678
cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8000/"}
code, out, err := gostC.Exec(s.ctx, cmd)
s.Require().NoError(err)
body, err := io.ReadAll(out)
s.Require().NoError(err)
if code != 0 || !strings.Contains(string(body), "hello-gost") {
DumpLogs(s.T(), s.ctx, "tcp-forward-multi-node logs", gostC)
}
s.Require().Equal(0, code)
s.Require().Contains(string(body), "hello-gost",
"HTTP request should route to the http-protocol node via protocol filtering")
}
func TestForwardSuite(t *testing.T) {
suite.Run(t, new(ForwardSuite))
}