diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e80c644..bf5ee38 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -244,6 +244,17 @@ behavior and limits are in [WEB_COVER.md](WEB_COVER.md). Default local endpoints are loopback-only SOCKS5 `127.0.0.1:1080` and HTTP `127.0.0.1:8080`. Use `socks5h://` when the relay should resolve names. +In source builds, closing the SOCKS5 UDP ASSOCIATE control connection also +cancels that request's pending endpoint lookup or packet setup, releasing its +local connection slot without waiting for the full dial timeout. A successful +setup cancels its setup timer without terminating the established UDP session; +the control connection must remain open for that session. Native QUIC's shared +physical dial is client-owned and is not canceled just because one caller +leaves. This does not change H3's existing cold-dial retry policy, graceful +shutdown semantics or TCP CONNECT handling, and is not included in v1.0.1. +Cancellation requires context-aware resolvers/dialers; custom implementations +that ignore cancellation cannot be forcibly interrupted by the proxy. + For SOCKS5 TCP and HTTP CONNECT tunnels, `--idle-timeout` (default `5m`) measures inactivity across both directions: an active download or upload does not need reverse-direction application traffic to stay open. A blocked write diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index ecfd46a..ee9ce64 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -163,52 +163,80 @@ func (s *SOCKS5Server) serveConnect(client net.Conn, request socksRequest) { } func (s *SOCKS5Server) serveUDPAssociate(client net.Conn, request socksRequest) { - ctx := context.Background() - cancel := func() {} + var ctx context.Context + var cancel context.CancelFunc if s.cfg.dialTimeout > 0 { - ctx, cancel = context.WithTimeout(ctx, s.cfg.dialTimeout) + ctx, cancel = context.WithTimeout(context.Background(), s.cfg.dialTimeout) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + // The full request has already been parsed. UDP control bytes carry no + // payload, so one reader can now monitor closure during DNS, packet setup + // and the established association without consuming another reader's data. + controlDone := watchSOCKSUDPControl(client, cancel) + defer func() { + cancel() + // Join the reader before serveConn releases the tracked connection. + // Only interrupt reads: setup replies have their own write deadline. + _ = client.SetReadDeadline(time.Now()) + <-controlDone + }() + reply := func(code byte, address net.Addr) error { + if s.cfg.handshakeTimeout > 0 { + _ = client.SetWriteDeadline(time.Now().Add(s.cfg.handshakeTimeout)) + } + return writeSOCKSReply(client, code, address) } - defer cancel() peerIP, err := addressIP(client.RemoteAddr()) if err != nil { - _ = writeSOCKSReply(client, socksReplyGeneralFailure, nil) + _ = reply(socksReplyGeneralFailure, nil) return } requestedPort, err := validateUDPAssociateRequest(ctx, request, peerIP, net.DefaultResolver.LookupIPAddr) if err != nil { - reply := byte(socksReplyGeneralFailure) + code := byte(socksReplyGeneralFailure) var protocolErr *socksProtocolError if errors.As(err, &protocolErr) { - reply = protocolErr.reply + code = protocolErr.reply } - _ = writeSOCKSReply(client, reply, nil) + _ = reply(code, nil) + return + } + // Literal-IP validation need not perform a context-aware operation. Avoid + // starting a shared upstream dial when cancellation is already known. + if err := ctx.Err(); err != nil { + _ = reply(socksReplyForError(err), nil) return } udpConn, err := listenSOCKSUDP(client) if err != nil { - _ = writeSOCKSReply(client, socksReplyGeneralFailure, nil) + _ = reply(socksReplyGeneralFailure, nil) return } defer udpConn.Close() upstream, err := s.cfg.packetDialer.DialPacket(ctx) + if upstream != nil { + // Own any returned connection, including a late success after control + // closure or a custom dialer returning both a connection and an error. + upstream = &closeOncePacketConn{PacketConn: upstream} + defer upstream.Close() + } if err != nil || upstream == nil { if err == nil { err = errors.New("socks5: packet dialer returned a nil connection") } - _ = writeSOCKSReply(client, socksReplyForError(err), nil) + _ = reply(socksReplyForError(err), nil) return } - upstream = &closeOncePacketConn{PacketConn: upstream} - defer upstream.Close() - cancel() - - if s.cfg.handshakeTimeout > 0 { - _ = client.SetWriteDeadline(time.Now().Add(s.cfg.handshakeTimeout)) + if err := ctx.Err(); err != nil { + _ = reply(socksReplyForError(err), nil) + return } - if err := writeSOCKSReply(client, socksReplySucceeded, udpConn.LocalAddr()); err != nil { + cancel() // Setup is done; the packet session and control reader live on. + if err := reply(socksReplySucceeded, udpConn.LocalAddr()); err != nil { return } _ = client.SetWriteDeadline(time.Time{}) @@ -217,7 +245,25 @@ func (s *SOCKS5Server) serveUDPAssociate(client net.Conn, request socksRequest) peerIP: peerIP, requestedPort: requestedPort, } - runSOCKSUDPAssociation(client, udpConn, upstream, endpoint, s.cfg.idleTimeout) + runSOCKSUDPAssociation(controlDone, udpConn, upstream, endpoint, s.cfg.idleTimeout) +} + +// watchSOCKSUDPControl is the sole control reader after parsing UDP ASSOCIATE. +// Successful setup cancels its context too, so the reader must not use that +// context as its lifetime. The caller interrupts and joins it on every exit. +func watchSOCKSUDPControl(control net.Conn, cancelSetup context.CancelFunc) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + defer cancelSetup() + var buffer [1]byte + for { + if _, err := control.Read(buffer[:]); err != nil { + return + } + } + }() + return done } type lookupIPFunc func(context.Context, string) ([]net.IPAddr, error) @@ -394,7 +440,7 @@ func (e *socksUDPClientEndpoint) current() *net.UDPAddr { } func runSOCKSUDPAssociation( - control net.Conn, + controlDone <-chan struct{}, local *net.UDPConn, upstream transport.PacketConn, endpoint *socksUDPClientEndpoint, @@ -406,7 +452,7 @@ func runSOCKSUDPAssociation( maxPayloadSize = limit } } - finished := make(chan struct{}, 3) + finished := make(chan struct{}, 2) activity := make(chan struct{}, 1) signalActivity := func() { select { @@ -467,16 +513,6 @@ func runSOCKSUDPAssociation( } }() - go func() { - defer finish() - buffer := make([]byte, 1) - for { - if _, err := control.Read(buffer); err != nil { - return - } - } - }() - var timer *time.Timer var idle <-chan time.Time if idleTimeout > 0 { @@ -491,6 +527,8 @@ wait: case <-finished: completed++ break wait + case <-controlDone: + break wait case <-activity: if timer != nil { if !timer.Stop() { @@ -506,13 +544,11 @@ wait: } } - // Closing both packet endpoints interrupts their blocking reads. A read - // deadline interrupts the control watcher without removing the connection - // from lifecycle tracking before all association goroutines have exited. + // Join packet workers before the caller joins its control reader and + // releases the tracked connection. Closing endpoints interrupts their I/O. _ = local.Close() _ = upstream.Close() - _ = control.SetReadDeadline(time.Now()) - for completed < 3 { + for completed < 2 { <-finished completed++ } diff --git a/internal/proxy/socks5_udp_control_test.go b/internal/proxy/socks5_udp_control_test.go new file mode 100644 index 0000000..b92a2ff --- /dev/null +++ b/internal/proxy/socks5_udp_control_test.go @@ -0,0 +1,207 @@ +package proxy + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +func TestSOCKSUDPControlReaderOutlivesSetupContext(t *testing.T) { + for _, stop := range []string{"peer close", "cleanup deadline"} { + t.Run(stop, func(t *testing.T) { + control, peer := net.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + done := watchSOCKSUDPControl(control, cancel) + t.Cleanup(func() { + cancel() + _ = control.Close() + _ = peer.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("control reader did not join") + } + }) + + // Successful setup cancels the timer, not the control reader. Pipe + // writes complete only after all extra control bytes are consumed. + cancel() + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatal("setup context was not canceled") + } + if err := peer.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + mustWrite(t, peer, []byte("ignored UDP control bytes")) + select { + case <-done: + t.Fatal("setup cancellation or extra bytes stopped the reader") + default: + } + if stop == "peer close" { + _ = peer.Close() + } else { + _ = control.SetReadDeadline(time.Now()) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("control reader did not stop") + } + }) + } +} + +func TestSOCKSUDPControlCancelsEndpointLookup(t *testing.T) { + control, peer := net.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + controlDone := watchSOCKSUDPControl(control, cancel) + lookupStarted := make(chan struct{}) + lookupDone := make(chan struct{}) + var lookupErr error + go func() { + defer close(lookupDone) + _, lookupErr = validateUDPAssociateRequest(ctx, socksRequest{ + addressType: socksAddressDomain, host: "client.invalid", + }, net.IPv4(127, 0, 0, 1), func(ctx context.Context, _ string) ([]net.IPAddr, error) { + close(lookupStarted) + <-ctx.Done() + return nil, ctx.Err() + }) + }() + t.Cleanup(func() { + cancel() + _ = control.Close() + _ = peer.Close() + for _, done := range []<-chan struct{}{controlDone, lookupDone} { + select { + case <-done: + case <-time.After(time.Second): + t.Error("control/lookup worker did not join") + } + } + }) + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("endpoint lookup did not start") + } + _ = peer.Close() + select { + case <-lookupDone: + if !errors.Is(lookupErr, context.Canceled) { + t.Fatalf("lookup error = %v, want context canceled", lookupErr) + } + case <-time.After(time.Second): + t.Fatal("control EOF did not interrupt the context-aware endpoint lookup") + } +} + +func TestSOCKS5UDPControlReaderStartsAfterCompleteRequest(t *testing.T) { + packet := newRecordingPacketConn() + dialer := testPacketDialer{ + Dialer: directDialer(), + dialPacket: func(context.Context) (transport.PacketConn, error) { + return packet, nil + }, + } + server, address, stop := startSOCKS5(t, Config{Dialer: dialer}) + defer stop(server) + control := dialTCP(t, address) + defer control.Close() + socksGreeting(t, control, nil) + // Fragment both the address and port. An earlier reader must not steal + // these bytes from the parser. No timing/scheduler assumptions are needed. + request := ipv4SOCKSRequest(socksCommandUDP, net.IPv4(127, 0, 0, 1), 53001) + for _, value := range request { + mustWrite(t, control, []byte{value}) + } + if reply, _ := readSOCKSReplyAddress(t, control); reply != socksReplySucceeded { + t.Fatalf("fragmented request reply = %d, want success", reply) + } + mustWrite(t, control, []byte("extra control bytes")) + _ = control.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.lifecycle.tracker.wait(ctx); err != nil { + t.Fatalf("control EOF did not release the association: %v", err) + } + select { + case <-packet.closed: + default: + t.Fatal("upstream packet connection remained open") + } +} + +func TestSOCKSUDPSetupReplyStallJoinsControlReader(t *testing.T) { + for _, mode := range []string{"validation failure", "dial failure", "success"} { + t.Run(mode, func(t *testing.T) { + packet := newRecordingPacketConn() + t.Cleanup(func() { _ = packet.Close() }) + server, err := NewSOCKS5Server(Config{ + Dialer: testPacketDialer{ + Dialer: directDialer(), + dialPacket: func(context.Context) (transport.PacketConn, error) { + if mode == "dial failure" { + return packet, errors.New("fixture dial failure") + } + return packet, nil + }, + }, + HandshakeTimeout: 50 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + control, peer := net.Pipe() + done := make(chan struct{}) + request := socksRequest{addressType: socksAddressIPv4, host: "0.0.0.0"} + if mode == "validation failure" { + request.host = "192.0.2.1" + } + go func() { + defer close(done) + server.serveUDPAssociate(socksUDPControlAddressConn{control}, request) + }() + t.Cleanup(func() { + _ = control.Close() + _ = peer.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("setup handler did not join") + } + }) + // The peer never reads. Only a real write deadline can release the + // pipe reply; returning also requires joining the still-open reader. + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("stalled setup reply retained the handler/control reader") + } + if mode != "validation failure" { + select { + case <-packet.closed: + default: + t.Fatal("stalled reply retained the returned packet connection") + } + } + }) + } +} + +// Only addressing is supplied by the fixture; reads, writes and deadlines +// are the real pipe operations. No transport error is injected. +type socksUDPControlAddressConn struct{ net.Conn } + +func (socksUDPControlAddressConn) LocalAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1080} +} + +func (socksUDPControlAddressConn) RemoteAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 53001} +} diff --git a/internal/proxy/socks5_udp_setup_cancel_test.go b/internal/proxy/socks5_udp_setup_cancel_test.go new file mode 100644 index 0000000..b8f5c7c --- /dev/null +++ b/internal/proxy/socks5_udp_setup_cancel_test.go @@ -0,0 +1,345 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +func TestSOCKSUDPSetupControlCloseCancelsDial(t *testing.T) { + for _, halfClose := range []bool{false, true} { + t.Run(fmt.Sprintf("half-close=%v", halfClose), func(t *testing.T) { + entered := make(chan context.Context, 1) + returned := make(chan struct{}) + abort := make(chan struct{}) + dial := func(ctx context.Context) (transport.PacketConn, error) { + defer close(returned) + entered <- ctx + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-abort: + return nil, errors.New("fixture cleanup") + } + } + server, control, address := startSOCKSUDPSetup(t, dial, 5*time.Second, nil) + t.Cleanup(func() { close(abort); awaitSOCKSUDPSetupSignal(t, returned, "dial cleanup") }) + ctx := awaitSOCKSUDPSetupContext(t, entered) + if halfClose { + if err := control.(*net.TCPConn).CloseWrite(); err != nil { + t.Fatal(err) + } + } else { + _ = control.Close() + } + awaitSOCKSUDPSetupSignal(t, ctx.Done(), "control EOF cancellation") + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("setup error = %v, want canceled", ctx.Err()) + } + awaitSOCKSUDPSetupSignal(t, returned, "canceled dial return") + awaitSOCKSUDPSetupIdle(t, server) + if halfClose { + _ = control.SetReadDeadline(time.Now().Add(time.Second)) + reply, err := io.ReadAll(control) + if err != nil { + t.Fatal(err) + } + if len(reply) >= 2 && reply[1] == socksReplySucceeded { + t.Fatal("canceled setup returned success") + } + } + // MaxConnections=1: completion, not merely a canceled dial context, + // must release admission for the next legitimate SOCKS greeting. + fresh := dialTCP(t, address) + defer fresh.Close() + socksGreeting(t, fresh, nil) + }) + } +} + +func TestSOCKSUDPSetupLatePacketClosesExactlyOnce(t *testing.T) { + for _, ordering := range []string{"canceled first", "success first", "race"} { + for iteration := range 4 { + t.Run(fmt.Sprintf("%s/%d", ordering, iteration), func(t *testing.T) { + packet := newSOCKSUDPSetupCountedPacket(t) + entered := make(chan context.Context, 1) + gate := make(chan struct{}) + release := sync.OnceFunc(func() { close(gate) }) + dial := func(ctx context.Context) (transport.PacketConn, error) { + entered <- ctx + <-gate + // Deliberately allow success to win a cancellation race. + return packet, nil + } + server, control, _ := startSOCKSUDPSetup(t, dial, 5*time.Second, nil) + t.Cleanup(release) + ctx := awaitSOCKSUDPSetupContext(t, entered) + switch ordering { + case "canceled first": + _ = control.Close() + awaitSOCKSUDPSetupSignal(t, ctx.Done(), "late-success cancellation") + release() + case "success first": + release() + if reply, _ := readSOCKSReplyAddress(t, control); reply != socksReplySucceeded { + t.Fatalf("reply = %d", reply) + } + _ = control.Close() + case "race": + closed := make(chan struct{}) + go func() { _ = control.Close(); close(closed) }() + release() + awaitSOCKSUDPSetupSignal(t, closed, "control closer") + } + awaitSOCKSUDPSetupIdle(t, server) + if got := packet.closes.Load(); got != 1 { + t.Fatalf("packet Close calls = %d, want 1", got) + } + }) + } + } +} + +func TestSOCKSUDPSetupSuccessDetachesContext(t *testing.T) { + target, stopEcho := startUDPEcho(t) + t.Cleanup(stopEcho) + raw, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = raw.Close() }) + packet := &socksUDPSetupCountedPacket{PacketConn: &directPacketConn{UDPConn: raw}} + entered := make(chan context.Context, 1) + server, control, _ := startSOCKSUDPSetup(t, func(ctx context.Context) (transport.PacketConn, error) { + entered <- ctx + return packet, nil + }, 300*time.Millisecond, nil) + ctx := awaitSOCKSUDPSetupContext(t, entered) + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("setup context has no dial deadline") + } + reply, relay := readSOCKSReplyAddress(t, control) + if reply != socksReplySucceeded { + t.Fatalf("reply = %d", reply) + } + awaitSOCKSUDPSetupSignal(t, ctx.Done(), "successful setup timer cancellation") + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("setup ended by %v instead of prompt cancellation", ctx.Err()) + } + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = udp.Close() }) + assertSOCKSUDPSetupEcho(t, udp, relay, target, "before original setup deadline") + if remaining := time.Until(deadline.Add(50 * time.Millisecond)); remaining > 0 { + time.Sleep(remaining) + } + if packet.closes.Load() != 0 { + t.Fatal("successful setup cancellation closed the packet connection") + } + assertSOCKSUDPSetupEcho(t, udp, relay, target, "after original setup deadline") + _ = control.Close() + awaitSOCKSUDPSetupIdle(t, server) + if got := packet.closes.Load(); got != 1 { + t.Fatalf("packet Close calls = %d, want 1", got) + } +} + +func TestSOCKSUDPSetupFailureWithLiveControl(t *testing.T) { + for _, mode := range []string{"error", "nil", "packet and error"} { + t.Run(mode, func(t *testing.T) { + packet := newSOCKSUDPSetupCountedPacket(t) + var calls atomic.Int32 + dial := func(context.Context) (transport.PacketConn, error) { + calls.Add(1) + switch mode { + case "error": + return nil, errors.New("fixture packet dial failed") + case "nil": + return nil, nil + default: + return packet, errors.New("fixture packet dial failed after allocation") + } + } + wantReply, wantCalls, wantCloses := byte(socksReplyGeneralFailure), int32(1), int32(0) + if mode == "packet and error" { + wantCloses = 1 + } + server, control, _ := startSOCKSUDPSetup(t, dial, 5*time.Second, nil) + if reply, _ := readSOCKSReplyAddress(t, control); reply != wantReply { + t.Fatalf("reply=%d, want %d", reply, wantReply) + } + // Keep the peer open. The server must stop and join its early reader + // on this failure path without needing the client to close TCP. + awaitSOCKSUDPSetupIdle(t, server) + _ = control.SetReadDeadline(time.Now().Add(time.Second)) + if n, err := control.Read(make([]byte, 1)); n != 0 || !errors.Is(err, io.EOF) { + t.Fatalf("failure connection read=%d,%v, want EOF", n, err) + } + if calls.Load() != wantCalls || packet.closes.Load() != wantCloses || packet.receives.Load() != 0 { + t.Fatalf("dial/close/Receive calls=%d/%d/%d, want %d/%d/0", calls.Load(), packet.closes.Load(), packet.receives.Load(), wantCalls, wantCloses) + } + }) + } +} + +func TestSOCKSUDPSetupOrdinaryCleanupJoinsPacketWorker(t *testing.T) { + packet := &socksUDPSetupSlowReceive{ + recordingPacketConn: newRecordingPacketConn(), + receiving: make(chan struct{}), stopping: make(chan struct{}), release: make(chan struct{}), + } + t.Cleanup(func() { _ = packet.Close() }) + release := sync.OnceFunc(func() { close(packet.release) }) + server, control, _ := startSOCKSUDPSetup(t, func(context.Context) (transport.PacketConn, error) { + return packet, nil + }, 5*time.Second, nil) + t.Cleanup(release) + if reply, _ := readSOCKSReplyAddress(t, control); reply != socksReplySucceeded { + t.Fatalf("reply = %d", reply) + } + awaitSOCKSUDPSetupSignal(t, packet.receiving, "packet reader entry") + _ = control.Close() + awaitSOCKSUDPSetupSignal(t, packet.stopping, "packet reader interruption") + // Ordinary control EOF must not release admission while an interrupted + // packet worker is still unwinding. Forced Shutdown has a different contract. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := server.lifecycle.tracker.wait(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("admission removed before packet worker joined: %v", err) + } + release() + awaitSOCKSUDPSetupIdle(t, server) +} + +func TestSOCKSUDPSetupForcedShutdownCancelsDial(t *testing.T) { + entered := make(chan context.Context, 1) + returned, abort := make(chan struct{}), make(chan struct{}) + server, _, _ := startSOCKSUDPSetup(t, func(ctx context.Context) (transport.PacketConn, error) { + defer close(returned) + entered <- ctx + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-abort: + return nil, errors.New("fixture cleanup") + } + }, 5*time.Second, nil) + t.Cleanup(func() { close(abort); awaitSOCKSUDPSetupSignal(t, returned, "shutdown dial cleanup") }) + ctx := awaitSOCKSUDPSetupContext(t, entered) + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if err := server.Shutdown(shutdownCtx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Shutdown = %v, want deadline exceeded", err) + } + awaitSOCKSUDPSetupSignal(t, ctx.Done(), "forced shutdown cancellation") + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("setup error = %v, want canceled", ctx.Err()) + } + // Forced shutdown removes tracking before the handler necessarily exits. + // Check the controlled dial's own receipt, not tracker-zero alone. + awaitSOCKSUDPSetupSignal(t, returned, "forced shutdown dial return") +} + +func startSOCKSUDPSetup(t *testing.T, dial func(context.Context) (transport.PacketConn, error), timeout time.Duration, request []byte) (*SOCKS5Server, net.Conn, string) { + t.Helper() + server, address, stop := startSOCKS5(t, Config{ + Dialer: testPacketDialer{Dialer: directDialer(), dialPacket: dial}, + DialTimeout: timeout, MaxConnections: 1, + }) + t.Cleanup(func() { stop(server) }) + control := dialTCP(t, address) + t.Cleanup(func() { _ = control.Close() }) + socksGreeting(t, control, nil) + if request == nil { + request = ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0) + } + mustWrite(t, control, request) + return server, control, address +} + +func awaitSOCKSUDPSetupSignal(t *testing.T, done <-chan struct{}, name string) { + t.Helper() + select { + case <-done: + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %s", name) + } +} + +func awaitSOCKSUDPSetupContext(t *testing.T, entered <-chan context.Context) context.Context { + t.Helper() + select { + case ctx := <-entered: + return ctx + case <-time.After(time.Second): + t.Fatal("packet dialer was not entered") + return nil + } +} + +func awaitSOCKSUDPSetupIdle(t *testing.T, server *SOCKS5Server) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.lifecycle.tracker.wait(ctx); err != nil { + t.Fatalf("setup retained admission: %v", err) + } +} + +func assertSOCKSUDPSetupEcho(t *testing.T, client *net.UDPConn, relay *net.UDPAddr, target, payload string) { + t.Helper() + if err := client.SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + writeTargetErrorDatagram(t, client, relay, target, payload) + buffer := make([]byte, 512) + n, source, err := client.ReadFromUDP(buffer) + if err != nil { + t.Fatal(err) + } + got, address, err := parseSOCKSUDPDatagram(buffer[:n]) + if err != nil || string(got) != payload || address != target || !source.IP.Equal(relay.IP) || source.Port != relay.Port { + t.Fatalf("UDP echo source=%v payload=%q target=%q error=%v", source, got, address, err) + } +} + +type socksUDPSetupCountedPacket struct { + transport.PacketConn + closes, receives atomic.Int32 +} + +func newSOCKSUDPSetupCountedPacket(t *testing.T) *socksUDPSetupCountedPacket { + t.Helper() + inner := newRecordingPacketConn() + t.Cleanup(func() { _ = inner.Close() }) + return &socksUDPSetupCountedPacket{PacketConn: inner} +} + +func (c *socksUDPSetupCountedPacket) Close() error { c.closes.Add(1); return c.PacketConn.Close() } +func (c *socksUDPSetupCountedPacket) Receive() ([]byte, string, error) { + c.receives.Add(1) + return c.PacketConn.Receive() +} + +type socksUDPSetupSlowReceive struct { + *recordingPacketConn + receiving, stopping, release chan struct{} +} + +func (c *socksUDPSetupSlowReceive) Receive() ([]byte, string, error) { + close(c.receiving) + <-c.closed + close(c.stopping) + <-c.release + return nil, "", net.ErrClosed +} diff --git a/internal/proxy/socks5_udp_setup_native_test.go b/internal/proxy/socks5_udp_setup_native_test.go new file mode 100644 index 0000000..6aa2bf9 --- /dev/null +++ b/internal/proxy/socks5_udp_setup_native_test.go @@ -0,0 +1,101 @@ +package proxy + +import ( + "context" + "crypto/tls" + "errors" + "net" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" + "github.com/cppla/autocar/internal/tunnel" +) + +func TestSOCKS5UDPAbortedNativeSetupReleasesAdmission(t *testing.T) { + // The real native client sends to a bound loopback UDP socket that never + // answers. No fabricated transport error or shared-dial cancellation is + // used to release the SOCKS caller; only its control TCP connection closes. + blackhole, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = blackhole.Close() }) + const upstreamTimeout = 5 * time.Second + native, err := tunnel.NewClient(tunnel.ClientConfig{ + ServerAddress: blackhole.LocalAddr().String(), + Token: "local-native-setup-regression-token", + TLSConfig: &tls.Config{ServerName: "localhost"}, + QUICDialTimeout: upstreamTimeout, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = native.Close() }) + started, done := make(chan struct{}), make(chan struct{}) + result := make(chan error, 1) + dialer := testPacketDialer{ + Dialer: native, + dialPacket: func(ctx context.Context) (transport.PacketConn, error) { + close(started) + defer close(done) + packet, err := native.DialPacket(ctx) + result <- err + return packet, err + }, + } + server, address, stopProxy := startSOCKS5(t, Config{ + Dialer: dialer, MaxConnections: 1, DialTimeout: 2 * upstreamTimeout, + }) + t.Cleanup(func() { + // Only fixture teardown closes the native client and its client-owned + // shared physical dial. Join the observed caller and proxy Serve too. + _ = native.Close() + stopProxy(server) + select { + case <-started: + select { + case <-done: + case <-time.After(time.Second): + t.Error("native packet setup did not finish during cleanup") + } + default: + } + }) + control := dialTCP(t, address) + t.Cleanup(func() { _ = control.Close() }) + socksGreeting(t, control, nil) + mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0)) + if err := blackhole.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + initial := make([]byte, 4096) + n, _, err := blackhole.ReadFromUDP(initial) + if err != nil { + t.Fatalf("native QUIC Initial did not reach the loopback peer: %v", err) + } + if n < 1200 || initial[0]&0xc0 != 0xc0 { + t.Fatalf("unexpected first native QUIC datagram: size=%d header=%x", n, initial[:1]) + } + if err := control.Close(); err != nil { + t.Fatal(err) + } + // One completion budget covers both caller cancellation and normal + // admission cleanup. It is well below the untouched 5s upstream timeout. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("aborted SOCKS native setup error=%v, want caller cancellation", err) + } + case <-ctx.Done(): + t.Fatal("closed SOCKS control did not cancel its native setup wait") + } + if err := server.lifecycle.tracker.wait(ctx); err != nil { + t.Fatal("aborted native setup retained the only SOCKS admission slot") + } + replacement := dialTCP(t, address) + t.Cleanup(func() { _ = replacement.Close() }) + socksGreeting(t, replacement, nil) +} diff --git a/internal/proxy/socks5_udp_test.go b/internal/proxy/socks5_udp_test.go index 43a9a70..1e49ba0 100644 --- a/internal/proxy/socks5_udp_test.go +++ b/internal/proxy/socks5_udp_test.go @@ -289,6 +289,13 @@ func TestSOCKS5UDPAssociateRejectsMismatchedRequestedAddressBeforeUpstream(t *te if reply != socksReplyNotAllowed { t.Fatalf("reply = %d, want not allowed", reply) } + // A validation failure must interrupt and join the early control reader + // even though this peer deliberately leaves its control connection open. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.lifecycle.tracker.wait(ctx); err != nil { + t.Fatalf("invalid request retained its admission: %v", err) + } select { case <-dialCalled: t.Fatal("invalid UDP ASSOCIATE request allocated an upstream session")