diff --git a/internal/amneziawgnet/relay.go b/internal/amneziawgnet/relay.go index 80982c8a9..be8cb915b 100644 --- a/internal/amneziawgnet/relay.go +++ b/internal/amneziawgnet/relay.go @@ -143,6 +143,10 @@ type socks5UDPSession struct { udpConn *net.UDPConn } +// Bounds dial plus the greeting/auth/associate reads: an accepted-but-silent +// server otherwise parks Handle, and with it the tunnel's delivery path. +var socks5AssociateTimeout = 5 * time.Second + // newSocks5UDPSession performs the SOCKS5 greeting, username/password auth, // and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5 // client (used by RelayTCP above) only implements CONNECT, and xray-core's @@ -150,11 +154,13 @@ type socks5UDPSession struct { // types, not reusable as a standalone dialer -- so this is a small, direct, // from-the-RFC implementation rather than an existing library call. func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) { - dialer := net.Dialer{Timeout: 5 * time.Second} + deadline := time.Now().Add(socks5AssociateTimeout) + dialer := net.Dialer{Deadline: deadline} ctrl, err := dialer.DialContext(context.Background(), "tcp", addr) if err != nil { return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err) } + _ = ctrl.SetDeadline(deadline) if err := socks5Handshake(ctrl, user, password); err != nil { ctrl.Close() return nil, err @@ -171,6 +177,7 @@ func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) ctrl.Close() return nil, err } + _ = ctrl.SetDeadline(time.Time{}) udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind)) if err != nil { diff --git a/internal/amneziawgnet/relay_test.go b/internal/amneziawgnet/relay_test.go index 4437e223d..f5adb8475 100644 --- a/internal/amneziawgnet/relay_test.go +++ b/internal/amneziawgnet/relay_test.go @@ -1,9 +1,11 @@ package amneziawgnet import ( + "errors" "io" "net" "net/netip" + "os" "testing" "time" ) @@ -255,3 +257,43 @@ func TestSocks5ReceiveRejectsTruncatedReplies(t *testing.T) { }) } } + +// TestNewSocks5UDPSessionGivesUpOnSilentServer pins that a control connection +// the kernel accepts but nobody answers returns within the associate deadline +// instead of parking Handle -- and with it the tunnel's delivery path -- forever. +func TestNewSocks5UDPSessionGivesUpOnSilentServer(t *testing.T) { + // Never accepted: the backlog completes the TCP handshake, the greeting + // lands in the socket buffer, and no reply ever comes -- a hung Xray. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + saved := socks5AssociateTimeout + socks5AssociateTimeout = 150 * time.Millisecond + t.Cleanup(func() { socks5AssociateTimeout = saved }) + + type result struct { + sess *socks5UDPSession + err error + } + done := make(chan result, 1) + go func() { + sess, err := newSocks5UDPSession(ln.Addr().String(), "peer@example", "x") + done <- result{sess, err} + }() + + select { + case got := <-done: + if got.err == nil { + got.sess.Close() + t.Fatal("associate succeeded against a server that never answered") + } + if !errors.Is(got.err, os.ErrDeadlineExceeded) { + t.Fatalf("associate error = %v, want one wrapping os.ErrDeadlineExceeded", got.err) + } + case <-time.After(2 * time.Second): + t.Fatal("newSocks5UDPSession still blocked 2s past the associate deadline: a silent SOCKS5 server parks the tunnel's delivery path") + } +}