From cfd4f64a79214a4ad1b06342c9e97b3a81297459 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Sun, 13 Sep 2026 23:29:40 +0200 Subject: [PATCH] fix(amneziawg): bound the SOCKS5 UDP associate exchange newSocks5UDPSession only bounded the dial. The greeting, auth and UDP ASSOCIATE reads on the control connection had no deadline, and they run inline in UDPRelay.Handle -- on the peer's receive goroutine that delivers decrypted packets into gVisor. A SOCKS5 server the kernel accepts for but that never answers (a wedged Xray: its listen backlog still completes TCP handshakes) therefore parked that goroutine, and every later packet from the peer behind it, TCP included, for as long as Xray stayed wedged. One deadline now covers the dial plus the whole exchange and is cleared once the association is up, since after that the control connection is only held open. The test drives the exchange against a listener that is never accepted, which is exactly the hung-server shape. This was the last of the three defects confirmed on the issue: the header protection key that could not be cleared went with cfd596a4, the missing PersistentKeepalive with 8f162994, and the manager lock inversion the same thread flagged with e95fe80f. The session death the issue was opened for is not a panel defect. The reporter's own capture on the host NIC shows the client's packets stop reaching the VPS after the first burst, the server never sees a second handshake initiation from it, and nothing the panel sends is outside what a stock amneziawg-go 3.1 server sends (the Apple client embeds the same library build). That is a client- or path-side stop, which no server-side change can address. Closes #6323 --- internal/amneziawgnet/relay.go | 9 ++++++- internal/amneziawgnet/relay_test.go | 42 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) 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") + } +}