From d7656ef3deafec6bf97107b20a332b938153fd54 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sat, 3 Oct 2026 16:18:00 +0800 Subject: [PATCH] tests/e2e: concurrent UDP endpoints must share a reverse tunnel intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers gost#911 at the wire level: four UDP source sockets send through one RUDP reverse tunnel bound on a relay server, and every datagram must come back byte-identical to its own endpoint. A frame torn by an interleaved writer shows up twice — as a corrupted payload, and as the torn frame leaving half a header in the stream, which the far end reads as "unexpected EOF" and answers by rebuilding the whole tunnel. So the suite asserts both: no endpoint sees a corrupted datagram, and no endpoint sees a receive gap long enough to be a teardown and rebind. Verified against a binary built before the fix (4 endpoints, 35% loss, 1.4s gaps, suite fails) and after it (0% loss, no corruption, 0.2s gaps, passes). --- tests/e2e/rudpbind_test.go | 147 +++++++++++++++++++++++ tests/e2e/scripts/udp_rudp_concurrent.py | 105 ++++++++++++++++ tests/e2e/testdata/rudpbind/client.yaml | 34 ++++++ tests/e2e/testdata/rudpbind/host.yaml | 44 +++++++ tests/e2e/testdata/rudpbind/server.yaml | 16 +++ 5 files changed, 346 insertions(+) create mode 100644 tests/e2e/rudpbind_test.go create mode 100644 tests/e2e/scripts/udp_rudp_concurrent.py create mode 100644 tests/e2e/testdata/rudpbind/client.yaml create mode 100644 tests/e2e/testdata/rudpbind/host.yaml create mode 100644 tests/e2e/testdata/rudpbind/server.yaml diff --git a/tests/e2e/rudpbind_test.go b/tests/e2e/rudpbind_test.go new file mode 100644 index 0000000..6fc8ff3 --- /dev/null +++ b/tests/e2e/rudpbind_test.go @@ -0,0 +1,147 @@ +package e2e + +import ( + "context" + "io" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +// RUDPBindSuite reproduces go-gost/gost#911: concurrent UDP source sockets +// sharing ONE reverse UDP tunnel over a relay server. +// +// Topology (all three gost processes plus a UDP echo server): +// +// UDP source sockets (N) -> client udp service -> relay server +// -> reverse-bound UDP port -> host rudp listener (ONE shared stream) +// -> udp-echo +// +// A datagram frame is written to that shared stream as three separate Write +// calls (RSV/FRAG, SOCKS5 address, payload). Before the fix, with N endpoints +// writing concurrently, frames interleaved between those calls: replies came +// back corrupted, and a torn frame left half a header in the stream, which the +// far end read as "unexpected EOF" and answered by tearing down and rebinding +// the whole reverse tunnel every second. +// +// The host is the side that binds the tunnel, so its log is where a reset +// shows up as "unexpected EOF, retrying in 1s" followed by a rebind. +type RUDPBindSuite struct { + suite.Suite + ctx context.Context + udpC testcontainers.Container + serverC testcontainers.Container + hostC testcontainers.Container + clientC testcontainers.Container +} + +func (s *RUDPBindSuite) SetupSuite() { + s.ctx = context.Background() + + udpC, err := RunUDPEchoContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.udpC = udpC + + serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, + "testdata/rudpbind/server.yaml", []string{"gost-server"}, []string{"8430/tcp"}) + s.Require().NoError(err) + s.serverC = serverC + + // The host's rudp listener opens no local port, so it exposes a dummy TCP + // port (service-1) purely for the readiness wait. + hostC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, + "testdata/rudpbind/host.yaml", []string{"gost-host"}, []string{"8431/tcp"}) + s.Require().NoError(err) + s.hostC = hostC + + clientC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, + "testdata/rudpbind/client.yaml", + []testcontainers.ContainerFile{ + {HostFilePath: "scripts/udp_rudp_concurrent.py", ContainerFilePath: "/scripts/udp_rudp_concurrent.py", FileMode: 0644}, + }) + s.Require().NoError(err) + s.clientC = clientC + + // The reverse tunnel binds remotely on first traffic, not at startup. + up := assert.Eventually(s.T(), func() bool { + out := s.sendConcurrent(1, 1) + if !strings.Contains(out, "PASS") { + s.T().Logf("waiting for tunnel, sender said:\n%s", out) + } + return out != "" && strings.Contains(out, "PASS") + }, 30*time.Second, time.Second, "reverse UDP tunnel never came up") + if !up { + DumpLogs(s.T(), s.ctx, "host logs", s.hostC) + DumpLogs(s.T(), s.ctx, "server logs", s.serverC) + DumpLogs(s.T(), s.ctx, "client logs", s.clientC) + s.FailNow("reverse UDP tunnel never came up") + } +} + +func (s *RUDPBindSuite) TearDownSuite() { + for _, c := range []testcontainers.Container{s.clientC, s.hostC, s.serverC, s.udpC} { + if c != nil { + c.Terminate(s.ctx) + } + } +} + +// sendConcurrent runs the concurrent sender inside the client container and +// returns its combined stdout+stderr. +func (s *RUDPBindSuite) sendConcurrent(endpoints, seconds int) string { + cmd := []string{ + "python3", "/scripts/udp_rudp_concurrent.py", + // The client service listens in this same container, so loopback is the + // correct target — no network alias needed for the sender. + "127.0.0.1", "8432", + strconv.Itoa(endpoints), strconv.Itoa(seconds), + } + _, out, err := s.clientC.Exec(s.ctx, cmd) + if err != nil { + return "" + } + var sb strings.Builder + buf := make([]byte, 4096) + for { + n, err := out.Read(buf) + sb.Write(buf[:n]) + if err != nil { + break + } + } + return sb.String() +} + +// TestConcurrentEndpointsShareTunnel is the regression assertion: several UDP +// source sockets sharing one reverse tunnel must each get their own datagrams +// back unchanged, and the tunnel must not be rebuilt underneath them. +func (s *RUDPBindSuite) TestConcurrentEndpointsShareTunnel() { + out := s.sendConcurrent(4, 8) + s.T().Logf("concurrent send:\n%s", out) + + if strings.Contains(out, "FAIL") { + DumpLogs(s.T(), s.ctx, "host logs", s.hostC) + DumpLogs(s.T(), s.ctx, "server logs", s.serverC) + } + s.Require().Contains(out, "PASS", "concurrent endpoints over one reverse tunnel failed:\n%s", out) + + // Belt and braces: the host log is where the reset appears directly. + hostLogs, err := s.hostC.Logs(s.ctx) + if err == nil { + body, readErr := io.ReadAll(hostLogs) + hostLogs.Close() + if readErr == nil { + s.Require().NotContains(string(body), "unexpected EOF", + "reverse tunnel was torn down while endpoints were sending") + } + } +} + +func TestRUDPBindSuite(t *testing.T) { + suite.Run(t, new(RUDPBindSuite)) +} \ No newline at end of file diff --git a/tests/e2e/scripts/udp_rudp_concurrent.py b/tests/e2e/scripts/udp_rudp_concurrent.py new file mode 100644 index 0000000..aac9096 --- /dev/null +++ b/tests/e2e/scripts/udp_rudp_concurrent.py @@ -0,0 +1,105 @@ +"""Concurrent UDP sender for the gost#911 reverse-tunnel regression test. + +Each socket is an independent source endpoint sharing ONE reverse tunnel +stream. Every datagram is a run of its own endpoint id, so a reply that does +not match the id byte exactly is a frame corrupted by an interleaved writer +rather than plain loss. + +Usage: udp_rudp_concurrent.py +""" + +import socket +import sys +import threading +import time + + +def main(): + host = sys.argv[1] + port = int(sys.argv[2]) + count = int(sys.argv[3]) + duration = float(sys.argv[4]) + + payload_len = 1200 + end = time.monotonic() + duration + results = {} + lock = threading.Lock() + + def endpoint(endpoint_id): + payload = bytes([endpoint_id]) * payload_len + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("0.0.0.0", 0)) + sock.settimeout(0.2) + + sent = received = damaged = 0 + largest_gap = 0.0 + last = time.monotonic() + + while time.monotonic() < end: + try: + sock.sendto(payload, (host, port)) + sent += 1 + except OSError: + pass + while True: + try: + data, _ = sock.recvfrom(65535) + except socket.timeout: + break + except OSError: + break + received += 1 + if data != payload: + damaged += 1 + now = time.monotonic() + largest_gap = max(largest_gap, now - last) + last = now + + largest_gap = max(largest_gap, time.monotonic() - last) + with lock: + results[endpoint_id] = (sent, received, damaged, round(largest_gap, 3)) + sock.close() + + threads = [ + threading.Thread(target=endpoint, args=(i + 1,)) for i in range(count) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + failures = [] + for endpoint_id in sorted(results): + sent, received, damaged, gap = results[endpoint_id] + loss_pct = 100.0 * (1.0 - received / sent) if sent else 100.0 + print( + f"endpoint {endpoint_id}: sent={sent} received={received} " + f"damaged={damaged} loss={loss_pct:.1f}% max_gap={gap}" + ) + # Unsent datagrams are outside our control (the UDP socket's own + # buffer); anything that comes BACK must be byte-identical. + if damaged: + failures.append( + f"endpoint {endpoint_id}: {damaged} datagram(s) came back " + f"corrupted — frames interleaved on the shared tunnel stream" + ) + # A torn frame makes the far end read io.ErrUnexpectedEOF, which + # rebuilds the whole reverse tunnel. That shows up as a receive gap of + # seconds, not milliseconds. + if gap >= 1.0: + failures.append( + f"endpoint {endpoint_id}: {gap}s receive gap — the reverse " + f"tunnel was torn down and rebound under active traffic" + ) + + if failures: + for f in failures: + print(f"FAIL: {f}", file=sys.stderr) + sys.exit(1) + + print(f"PASS: {count} concurrent endpoints, no corruption, no tunnel reset") + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/e2e/testdata/rudpbind/client.yaml b/tests/e2e/testdata/rudpbind/client.yaml new file mode 100644 index 0000000..5e759a0 --- /dev/null +++ b/tests/e2e/testdata/rudpbind/client.yaml @@ -0,0 +1,34 @@ +# Internal client behind NAT for gost#911: a UDP forward service that sends all +# traffic to the port the host bound through the reverse tunnel. +# +# The forwarder target is gost-server:8432 — that is where the reverse tunnel is +# actually bound (the host's rudp listener asked the relay server to bind it, so +# the port lives on the relay server, not on the host). Datagrams go there and +# are carried back to the internal host over the one shared tunnel stream. +services: + - name: service-0 + addr: ":8432" + handler: + type: udp + listener: + type: udp + metadata: + keepalive: true + ttl: 5m + forwarder: + nodes: + - name: tunnel + addr: gost-server:8432 + +chains: + - name: chain-0 + hops: + - nodes: + - addr: gost-server:8430 + connector: + type: relay + dialer: + type: tcp + +log: + level: debug \ No newline at end of file diff --git a/tests/e2e/testdata/rudpbind/host.yaml b/tests/e2e/testdata/rudpbind/host.yaml new file mode 100644 index 0000000..770306c --- /dev/null +++ b/tests/e2e/testdata/rudpbind/host.yaml @@ -0,0 +1,44 @@ +# Internal host behind NAT for gost#911: binds a reverse UDP tunnel (rudp) +# through the relay server and forwards it to the local UDP echo server. +# +# The rudp listener opens no local port — it binds the UDP port remotely through +# the chain. service-1 is a dummy TCP listener purely so the e2e harness has a +# stable port to wait on for container readiness. +services: + - name: service-0 + addr: "0.0.0.0:8432" + handler: + type: rudp + listener: + type: rudp + chain: chain-0 + metadata: + ttl: 5m + forwarder: + nodes: + - name: echo + addr: udp-echo:5679 + + # Dummy TCP service on a fixed port: the rudp 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. Nothing ever targets it — the harness + # only opens and immediately closes the connection. + - name: service-1 + addr: ":8431" + handler: + type: tcp + listener: + type: tcp + +chains: + - name: chain-0 + hops: + - nodes: + - addr: gost-server:8430 + connector: + type: relay + dialer: + type: tcp + +log: + level: debug \ No newline at end of file diff --git a/tests/e2e/testdata/rudpbind/server.yaml b/tests/e2e/testdata/rudpbind/server.yaml new file mode 100644 index 0000000..c29f87d --- /dev/null +++ b/tests/e2e/testdata/rudpbind/server.yaml @@ -0,0 +1,16 @@ +# Public-facing relay server for gost#911: allows UDP BIND so an internal host +# can bind a reverse UDP tunnel through it. The host asks the server to bind +# the tunnel's UDP port, so that port lives here — on the relay server, not on +# the host behind NAT. +services: + - name: service-0 + addr: ":8430" + handler: + type: relay + metadata: + bind: true + listener: + type: tcp + +log: + level: debug \ No newline at end of file