Userspace UDP relay for QUIC/HTTP3 through SOCKS5 proxies
Cuz UDP deserves to chill in tunnel too.
Why • How It Works • Install • Usage • API
So here's the deal, QUIC and HTTP/3 run over UDP. SOCKS5 proxies technically support UDP through this thing called UDP ASSOCIATE. In theory, tunneling QUIC through SOCKS5 should be straightforward. In practice? Literally nobody does this. And honestly, fair enough:
- quic-go is picky fr: it doesn't just want any
net.PacketConn. It needs a real*net.UDPConnbecause it's doing OOB messages, ECN bits, platform-specific socket ops, basically the whole deal. Hand it a fake conn and watch things break in the most confusing ways possible. - SOCKS5 UDP ASSOCIATE is unhinged: it binds some random relay address, wraps every single packet with its own headers, and straight up kills your session if the TCP control connection drops. Most SOCKS5 libraries looked at this part of the spec and said "nah".
- The hostname-vs-IP nightmare: residential/ISP proxies auth based on the hostname in your SOCKS5 request. Resolve DNS locally and send raw IPs? Auth fails silently. Send hostnames? Cool, but now the relay responds with IPs and your dispatcher has zero idea which connection those packets belong to.
udpbara deals with all of this. It spins up a real local UDP socket pair per connection and quic-go gets its beloved *net.UDPConn with full OOB/ECN support, while udpbara handles the SOCKS5 wrapping and dispatch behind the scenes.
No TUN interfaces. No root. No iptables. No kernel modules. Just pure userspace Go doing its thing.
┌─────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ quic-go │ UDP │ udpbara │ UDP │ SOCKS5 │
│ │◄───────►│ │◄───────►│ Proxy │
│ (real UDP │ local │ appConn ↔ relayConn │ inet │ (UDP relay) │
│ socket) │ pair │ + SOCKS5 wrapping │ │ │
└─────────────┘ └──────────────────────┘ └──────────────┘
│ │
│ TCP (control) │
└────────────────────────────────┘
-
TCP Handshake — udpbara connects to the SOCKS5 proxy over TCP, authenticates (username/password), and sends
UDP ASSOCIATEto get a relay address. -
Local Socket Pair — For each target, it creates two
*net.UDPConnsockets on localhost:appConn— handed to quic-go (or whatever UDP client you're using)relayConn— internal side that wraps/unwraps SOCKS5 UDP headers
-
Outbound (app → proxy) — Packets from
appConnhitrelayConn, get wrapped with the SOCKS5 UDP header (preserving the hostname for proxy auth), and are sent to the proxy's relay address. -
Inbound (proxy → app) — Packets from the proxy are read on the tunnel's shared UDP socket, stripped of the SOCKS5 header, and dispatched to the correct
relayConn→appConnbased on source address. Dual-key lookup (hostname + resolved IPs) with fallback broadcast handles the hostname-vs-IP mismatch. -
Lifecycle — The TCP control connection is monitored. If it drops, udpbara auto-reconnects with exponential backoff (up to 5 attempts).
go get github.com/sardanioss/udpbaraimport "github.com/sardanioss/udpbara"
// Creates tunnel + connection in one call
conn, err := udpbara.Dial("socks5h://user:pass@proxy:10000", "target.com:443")
if err != nil {
log.Fatal(err)
}
defer conn.Close() // cleans up tunnel too
// Use with quic-go
transport := &quic.Transport{
Conn: conn.PacketConn(), // real *net.UDPConn
}
quicConn, err := transport.Dial(ctx, conn.RelayAddr(), tlsConfig, quicConfig)// Create and connect tunnel
tunnel, err := udpbara.NewTunnel("socks5h://user:pass@proxy:10000")
if err != nil {
log.Fatal(err)
}
if err := tunnel.Connect(); err != nil {
log.Fatal(err)
}
defer tunnel.Close()
// Dial multiple targets through the same proxy session
conn1, _ := tunnel.Dial("api.example.com:443")
conn2, _ := tunnel.Dial("cdn.example.com:443")
defer conn1.Close()
defer conn2.Close()
// Each gets its own *net.UDPConn
fmt.Println(conn1.PacketConn().LocalAddr()) // 127.0.0.1:xxxxx
fmt.Println(conn2.PacketConn().LocalAddr()) // 127.0.0.1:yyyyymgr := udpbara.NewManager()
// Add tunnels for different proxies
mgr.AddTunnel("us-east", "socks5h://user:pass@us-east-proxy:10000")
mgr.AddTunnel("eu-west", "socks5h://user:pass@eu-west-proxy:10000")
// Dial through specific tunnels
conn, _ := mgr.Dial("us-east", "", "target.com:443")
defer conn.Close()
// Cleanup
mgr.CloseAll()| Function | Description |
|---|---|
Dial(proxyURL, target, ...Config) |
One-shot: creates tunnel + connection, caller owns both |
NewTunnel(proxyURL, ...Config) |
Creates a reusable tunnel (call Connect() next) |
NewManager(...Config) |
Creates a multi-tunnel manager |
| Method | Description |
|---|---|
Connect() |
Establishes SOCKS5 UDP ASSOCIATE session |
Dial(target) |
Creates a new connection to host:port through this tunnel |
Stats() |
Returns packet/byte counters |
Close() |
Shuts down tunnel and all connections |
| Method | Description |
|---|---|
PacketConn() |
Returns *net.UDPConn for quic-go |
RelayAddr() |
Returns *net.UDPAddr to pass to quic.Transport.Dial() |
Tunnel() |
Returns the parent *Tunnel |
Close() |
Closes this connection (and tunnel if created via top-level Dial) |
| Field | Default | Description |
|---|---|---|
ReadBufferSize |
7 MB | UDP socket read buffer |
WriteBufferSize |
7 MB | UDP socket write buffer |
TCPKeepAlive |
true |
Keepalive on SOCKS5 control connection |
TCPKeepAlivePeriod |
30s | Keepalive probe interval |
ConnectTimeout |
10s | Proxy connection timeout |
AutoReconnect |
true |
Auto-reconnect on control drop |
MIT
