test(e2e): add QUIC cipherKey invalid-datagram regression

Add an e2e suite covering go-gost/x#125: a malformed UDP datagram sent
to a cipherKey-enabled QUIC listener must not close the shared transport.
Uses the canonical http-over-quic chaining pattern and verifies the
forward still works after the invalid datagram.

Also bumps go.mod/go.sum (plugin v0.6.1, otel, x/net, etc.).
This commit is contained in:
ginuerzh
2026-09-07 14:53:36 +08:00
parent 27ac16439e
commit ee61f28688
5 changed files with 203 additions and 37 deletions
+135
View File
@@ -0,0 +1,135 @@
package e2e
import (
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
)
// QUICSuite covers the QUIC listener/dialer pair (listener type: quic, dialer
// type: quic) with the cipherKey obfuscation enabled. It uses the canonical
// GOST chaining pattern: a client container exposes a plain HTTP proxy that
// tunnels through a quic chain to a quic server (HTTP over QUIC).
type QUICSuite struct {
suite.Suite
ctx context.Context
echoC testcontainers.Container
echoIP string
}
func (s *QUICSuite) 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
}
func (s *QUICSuite) TearDownSuite() {
if s.echoC != nil {
s.echoC.Terminate(s.ctx)
}
}
// startChain brings up a quic server (alias "quic-server", cipherKey enabled,
// http handler) and a client container that chains to it through a quic
// dialer. The client exposes port 8080 for curl.
func (s *QUICSuite) startChain() (testcontainers.Container, testcontainers.Container) {
s.T().Helper()
serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName,
"testdata/quic/server.yaml", []string{"quic-server"}, []string{"18843/udp"})
s.Require().NoError(err)
rendered, err := RenderConfig("testdata/quic/client.yaml", ConfigData{ServerAddr: "quic-server:18843"})
s.Require().NoError(err)
s.T().Cleanup(func() { os.Remove(rendered) })
clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, rendered, "8080/tcp")
s.Require().NoError(err)
return serverC, clientC
}
// curlEcho runs curl through the client's local http proxy and returns the
// exit code plus the captured body.
func (s *QUICSuite) curlEcho(clientC testcontainers.Container) (int, string) {
s.T().Helper()
cmd := []string{"curl", "-s", "--max-time", "20", "-x", "http://127.0.0.1:8080",
fmt.Sprintf("http://%s:5678/", s.echoIP)}
code, out, _ := clientC.Exec(s.ctx, cmd)
body, _ := io.ReadAll(out)
return code, string(body)
}
// sendInvalidDatagram sends a single malformed (non-encrypted) UDP datagram to
// the quic server's listener, reproducing the issue's repro packet.
func (s *QUICSuite) sendInvalidDatagram(clientC testcontainers.Container) {
s.T().Helper()
cmd := []string{"python3", "-c",
"import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.sendto(b\"x\", (\"quic-server\", 18843))"}
code, out, err := clientC.Exec(s.ctx, cmd)
s.Require().NoError(err)
if code != 0 {
b, _ := io.ReadAll(out)
s.T().Fatalf("send invalid datagram failed (%d): %s", code, string(b))
}
}
// TestQUICForwardCipherKeySurvivesInvalidDatagram verifies that a malformed
// UDP datagram does not close the shared QUIC transport (go-gost/x#125).
//
// With the bug, cipherConn.ReadFrom returned the decrypt error straight to
// quic-go, which treats any packet-socket error as fatal and closes the shared
// transport: a single invalid datagram kills the listener and correct clients
// subsequently time out. With the fix the invalid datagram is discarded and
// the listener keeps serving.
//
// Sequence: baseline forward works -> malformed datagram -> forward still works.
func (s *QUICSuite) TestQUICForwardCipherKeySurvivesInvalidDatagram() {
serverC, clientC := s.startChain()
defer serverC.Terminate(s.ctx)
defer clientC.Terminate(s.ctx)
// Baseline: a valid QUIC forward must work before we poke the listener.
code, body := s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("quic-baseline logs", clientC, serverC)
}
s.Require().Equal(0, code, "baseline QUIC forward should work")
s.Require().Contains(body, "hello-gost")
// Send one malformed UDP datagram to the quic listener's cipher socket.
s.sendInvalidDatagram(clientC)
// The listener must still be alive: a fresh QUIC forward must succeed.
code, body = s.curlEcho(clientC)
if code != 0 || !strings.Contains(body, "hello-gost") {
s.dump("quic-after-invalid logs", clientC, serverC)
}
s.Require().Equal(0, code,
"QUIC forward should still work after an invalid datagram (issue #125)")
s.Require().Contains(body, "hello-gost",
"listener must not close on an invalid datagram (issue #125)")
}
func (s *QUICSuite) dump(label string, cs ...testcontainers.Container) {
for _, c := range cs {
DumpLogs(s.T(), s.ctx, fmt.Sprintf("%s: %s", label, c.GetContainerID()), c)
}
}
func TestQUICSuite(t *testing.T) {
suite.Run(t, new(QUICSuite))
}