Files
3x-ui/internal/tuic/stream_udp_lifecycle_test.go
T
MHSanaei 815c9c5772 fix(tuic): accept the server's STOP_SENDING when tests close uni streams
The race job failed in TestAudit3ManagerEnsureActualSendersWithPersistentTraffic
with "close called for canceled stream 14". The server parses one command
per uni stream and then calls CancelRead, as the quinn reference server does
on drop, so its STOP_SENDING can reach the client before the client's own
Close and quic-go reports that Close as an error. The data was already read.

Every test that wrote a command on a uni stream and required Close to
succeed shared this race. closeUniStream accepts only a remote StreamError
on the stream's context, so any other Close failure still fails the test.
2026-10-03 15:48:05 +02:00

199 lines
7.1 KiB
Go

package tuic
import (
"bytes"
"context"
"crypto/tls"
"io"
"net"
"testing"
"time"
serverquic "github.com/apernet/quic-go"
"github.com/google/uuid"
clientquic "github.com/quic-go/quic-go"
)
func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *clientquic.Conn, uuid.UUID, string) {
t.Helper()
cert, key := generateTestCert(t)
clientID := uuid.New()
password := "lifecycle-test-password"
server, err := NewServer(Instance{
Id: 99101, Tag: "lifecycle-test", Listen: "127.0.0.1", Port: 0,
Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
AuthenticationTimeout: 2, MaxIdleTime: 30,
Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: email}},
}, &SocksRelay{Addr: relayAddr, Password: "lifecycle-socks-password"})
if err != nil {
t.Fatalf("create TUIC server: %v", err)
}
if err := server.Start(); err != nil {
t.Fatalf("start TUIC server: %v", err)
}
t.Cleanup(func() { _ = server.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := clientquic.DialAddr(ctx, server.packetConn.LocalAddr().String(),
&tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}},
&clientquic.Config{EnableDatagrams: true})
if err != nil {
t.Fatalf("dial TUIC server: %v", err)
}
t.Cleanup(func() { _ = client.CloseWithError(0, "test complete") })
tlsState := client.ConnectionState().TLS
token, err := tlsState.ExportKeyingMaterial(string(clientID[:]), []byte(password), 32)
if err != nil {
t.Fatalf("derive authentication token: %v", err)
}
stream, err := client.OpenUniStreamSync(ctx)
if err != nil {
t.Fatalf("open authentication stream: %v", err)
}
auth := append([]byte{ProtocolVersion, CmdAuthenticate}, clientID[:]...)
auth = append(auth, token...)
if _, err := stream.Write(auth); err != nil {
t.Fatalf("write authentication: %v", err)
}
closeUniStream(t, stream)
return server, client, clientID, password
}
func authenticatedServerConnection(t *testing.T, server *Server, clientID uuid.UUID) (*serverquic.Conn, *User) {
t.Helper()
id := [16]byte(clientID)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
server.activeConnsMu.Lock()
for conn, user := range server.activeConns[id] {
server.activeConnsMu.Unlock()
return conn, user
}
server.activeConnsMu.Unlock()
time.Sleep(time.Millisecond)
}
t.Fatal("server did not register authenticated QUIC connection")
return nil, nil
}
func TestMalformedBiStreamDelayedFINReleasesReceiveCredit(t *testing.T) {
_, client, _, _ := startLifecycleTestServer(t, "127.0.0.1:1", "bidi-lifecycle@x")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for i := 0; i < 110; i++ {
openCtx, openCancel := context.WithTimeout(ctx, 700*time.Millisecond)
stream, err := client.OpenStreamSync(openCtx)
openCancel()
if err != nil {
t.Fatalf("bidirectional stream %d blocked after unsupported commands: %v", i+1, err)
}
if _, err := stream.Write([]byte{ProtocolVersion, 0xff}); err != nil {
t.Fatalf("write unsupported command %d: %v", i+1, err)
}
stream.SetReadDeadline(time.Now().Add(time.Second))
if _, err := io.Copy(io.Discard, stream); err != nil {
t.Fatalf("wait for unsupported stream %d to close: %v", i+1, err)
}
_ = stream.Close()
}
}
func TestDownstreamUDPResponseRefreshesAssociationIdleTime(t *testing.T) {
socksAddr, cleanup := startMockSocks5Server(t, "udp-lifecycle@x", "lifecycle-socks-password")
defer cleanup()
server, client, clientID, _ := startLifecycleTestServer(t, socksAddr, "udp-lifecycle@x")
serverConn, user := authenticatedServerConnection(t, server, clientID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
session, err := server.relay.DialUDP(ctx, user.Email)
if err != nil {
t.Fatalf("open SOCKS UDP session: %v", err)
}
t.Cleanup(func() { _ = session.Close() })
const associationID uint16 = 61244
oldActive := time.Now().Add(-udpAssociationIdleTimeout - time.Second)
association := &udpAssociation{
responseTransport: packetTransportDatagram,
relay: &udpRelaySession{relay: session, responseTransport: packetTransportDatagram},
lastActive: oldActive,
}
registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
registry.associations[associationID] = association
responseDone := make(chan struct{})
go func() {
defer close(responseDone)
server.relayUDPResponses(ctx, serverConn, user, associationID, association, registry, association.relay)
}()
target := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}
if _, err := session.Send(target, []byte("seed")); err != nil {
t.Fatalf("send SOCKS seed datagram: %v", err)
}
response, err := client.ReceiveDatagram(ctx)
if err != nil {
t.Fatalf("receive echoed UDP response: %v", err)
}
if len(response) < 2 || response[0] != ProtocolVersion || response[1] != CmdPacket {
t.Fatalf("unexpected TUIC UDP response: %x", response)
}
reader := bytes.NewReader(response[2:])
header, err := ReadPacketHeader(reader)
if err != nil {
t.Fatalf("read response header: %v", err)
}
got, err := readPacketPayload(reader, header)
if err != nil || !bytes.Equal(got, []byte("seed")) {
t.Fatalf("echo response payload = %q, error=%v", got, err)
}
if header.AssocID != associationID {
t.Fatalf("response association id = %d, want %d", header.AssocID, associationID)
}
registry.mu.Lock()
refreshedAt := association.lastActive
registry.mu.Unlock()
if !refreshedAt.After(oldActive) {
t.Fatal("successful downstream response did not refresh association activity")
}
registry.reapIdle(oldActive.Add(udpAssociationIdleTimeout + time.Second))
registry.mu.Lock()
remaining := registry.associations[associationID]
registry.mu.Unlock()
if remaining != association {
t.Fatal("association was reaped despite a recently delivered downstream response")
}
_ = session.Close()
select {
case <-responseDone:
case <-time.After(time.Second):
t.Fatal("UDP response relay did not stop after SOCKS session closed")
}
}
func TestUdpAssociationTouchDoesNotRefreshReusedID(t *testing.T) {
registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
addr := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}
header := &PacketHeader{AssocID: 17, PktID: 1, FragTotal: 1, FragID: 0, Size: 1, Addr: addr}
old, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("x"))
if !complete {
t.Fatal("failed to create first association generation")
}
oldTime := old.lastActive
if !registry.dissociate(header.AssocID) {
t.Fatal("failed to dissociate first association generation")
}
newGeneration, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("x"))
if !complete || newGeneration == old {
t.Fatal("failed to create replacement association generation")
}
if registry.touch(header.AssocID, old, oldTime.Add(time.Hour)) {
t.Fatal("late response refreshed a replacement association generation")
}
if !newGeneration.lastActive.Before(oldTime.Add(time.Hour)) {
t.Fatal("replacement association timestamp changed after stale touch")
}
}