feat(handler): add configurable UDP port range for SOCKS5 UDP ASSOCIATE

Add udp.bindRange.min/max (udp.minPort/maxPort) metadata to constrain the
client-facing UDP relay socket to a fixed port set, so NAT routers can
forward a small range to the SOCKS server. listenPacketInRange scans
ports from a random offset within [lo, hi] and retries on EADDRINUSE;
with no range configured, the OS chooses (port 0) as before. RFC 1928
leaves relay-port selection to the server, so BND.PORT reports the
actually-bound port.
This commit is contained in:
ginuerzh
2026-07-02 21:04:19 +08:00
parent d024c7786e
commit 91ef66d9e0
3 changed files with 93 additions and 1 deletions
+4
View File
@@ -27,6 +27,8 @@ type metadata struct {
enableUDP bool
udpBufferSize int
udpResolveDomain bool
udpBindMin int
udpBindMax int
compatibilityMode bool
hash string
muxCfg *mux.Config
@@ -60,6 +62,8 @@ func (h *socks5Handler) parseMetadata(md mdata.Metadata) (err error) {
h.md.enableUDP = mdutil.GetBool(md, "udp")
h.md.udpBufferSize = mdutil.GetInt(md, "udp.bufferSize", "udpBufferSize")
h.md.udpResolveDomain = mdutil.GetBool(md, "udp.resolveDomain", "udpResolveDomain")
h.md.udpBindMin = mdutil.GetInt(md, "udp.bindRange.min", "udp.minPort")
h.md.udpBindMax = mdutil.GetInt(md, "udp.bindRange.max", "udp.maxPort")
h.md.compatibilityMode = mdutil.GetBool(md, "comp")
h.md.hash = mdutil.GetString(md, "hash")
+34 -1
View File
@@ -6,8 +6,10 @@ import (
"errors"
"fmt"
"io"
"math/rand/v2"
"net"
"strconv"
"syscall"
"time"
"github.com/go-gost/core/hosts"
@@ -53,7 +55,7 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
Netns: h.options.Netns,
}
cc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(host, "0"))
cc, err := listenPacketInRange(ctx, &lc, network, host, h.md.udpBindMin, h.md.udpBindMax)
if err != nil {
log.Error(err)
reply := gosocks5.NewReply(gosocks5.Failure, nil)
@@ -194,6 +196,37 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
return nil
}
// listenPacketInRange binds the client-facing UDP relay socket. When lo..hi is
// a valid range (lo > 0, hi >= lo) it scans ports from a random offset within
// [lo, hi] and retries on EADDRINUSE, so a NAT router can forward a small,
// fixed port set to the SOCKS server. The random offset avoids every
// association hammering port lo under concurrency; the rotation visits distinct
// ports, so a free port is always found when the range fits the attempt cap.
// RFC 1928 leaves relay-port selection to the server; the caller still reports
// the actually-bound port via BND.PORT. With no range, the OS chooses (port 0).
// A non-EADDRINUSE error fails fast.
func listenPacketInRange(ctx context.Context, lc *xnet.ListenConfig, network, host string, lo, hi int) (net.PacketConn, error) {
if lo > 0 && hi >= lo {
span := hi - lo + 1
attempts := min(span, 64)
offset := rand.IntN(span)
var lastErr error
for i := range attempts {
port := lo + (offset+i)%span
cc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(host, strconv.Itoa(port)))
if err == nil {
return cc, nil
}
lastErr = err
if !errors.Is(err, syscall.EADDRINUSE) {
return nil, err
}
}
return nil, lastErr
}
return lc.ListenPacket(ctx, network, net.JoinHostPort(host, "0"))
}
// filteredPacketConn implements SOCKS5 RFC 1928 UDP relay security by filtering
// incoming packets to only accept those from the client IP that established the TCP control connection.
type filteredPacketConn struct {
+55
View File
@@ -0,0 +1,55 @@
package v5
import (
"context"
"net"
"strconv"
"testing"
xnet "github.com/go-gost/x/internal/net"
)
func TestListenPacketInRange(t *testing.T) {
ctx := context.Background()
// No range configured: OS picks (port 0), always succeeds.
lc := &xnet.ListenConfig{}
cc, err := listenPacketInRange(ctx, lc, "udp", "127.0.0.1", 0, 0)
if err != nil {
t.Fatalf("port-0 bind: %v", err)
}
cc.Close()
// Range configured: bound port must fall within [lo, hi]. With 51
// candidates and attempts = min(span, 64), only a fully-occupied range
// fails — extremely unlikely on a dev/CI host.
const lo, hi = 35000, 35050
cc, err = listenPacketInRange(ctx, lc, "udp", "127.0.0.1", lo, hi)
if err != nil {
t.Fatalf("range bind [%d,%d]: %v", lo, hi, err)
}
defer cc.Close()
_, portStr, _ := net.SplitHostPort(cc.LocalAddr().String())
port, _ := strconv.Atoi(portStr)
if port < lo || port > hi {
t.Errorf("bound port %d outside [%d, %d]", port, lo, hi)
}
}
// A single-port range whose only port is already held must surface the
// EADDRINUSE error (so the caller replies REP 0x01) — no hang, no silent miss.
func TestListenPacketInRange_BusyPort(t *testing.T) {
blocker, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("blocker bind: %v", err)
}
defer blocker.Close()
_, portStr, _ := net.SplitHostPort(blocker.LocalAddr().String())
port, _ := strconv.Atoi(portStr)
lc := &xnet.ListenConfig{}
if _, err := listenPacketInRange(context.Background(), lc, "udp", "127.0.0.1", port, port); err == nil {
t.Fatalf("expected bind on held port %d to fail", port)
}
}