Skip to content

AeonDave/go-s5

Repository files navigation

go-s5

A minimal, fast, and extensible SOCKS5 library written in Go.

CI CodeQL Go Report Card GitHub License

Features

  • Full RFC 1928: CONNECT, BIND, and UDP ASSOCIATE
  • Pluggable authentication: NoAuth and Username/Password; mTLS via TLS listener
  • Rules/ACL: rules.RuleSet interface (default PermitAll)
  • Custom DNS resolver (resolver.NameResolver) and address rewriter (handler.AddressRewriter)
  • Per-command middleware and optional full command-handler replacement
  • Flexible dialing: WithDial, WithDialAndRequest, WithDialer, WithDialFQDN (Happy Eyeballs)
  • First-class client with multi-hop chaining (DialChain) over a single stream
  • Graceful shutdown: Shutdown(ctx) drains connections; Close() tears down immediately
  • UDP ASSOCIATE: batched syscalls on Linux (recvmmsg/sendmmsg), per-flow DNS caching, peer limits, idle GC
  • Admission control: WithMaxConnections cap and WithConnectionRateLimit per-source token bucket
  • Observability: WithMetrics hook (connections, requests, relayed bytes), log/slog adapter, link quality monitoring via linkquality.Tracker
  • Fuzz-tested wire parsers with committed regression corpus; race-clean test suite

Performance

Measured on loopback with the benchmarks in test/bench_test.go (go test -bench . -benchmem -run xxx ./test/), Go 1.25, 16 hardware threads.

Metric Linux Windows
TCP relay throughput, single stream ~3.8 GB/s ~1.5 GB/s
TCP relay throughput, 16 concurrent streams ~11.4 GB/s ~9.4 GB/s
Relay-path allocations 0 /op 0 /op
UDP proxied round trip (256 B payload) ~28 µs ~40 µs
UDP sustained relay (200 B datagram flood) ~200–260k pps ~75–110k pps

How it gets there:

  • TCP: the relay preserves Go's io.Copy fast paths in both directions, so on Linux data moves kernel-side via splice(2) and never enters user space. The relay adds single-digit % overhead over the theoretical loopback floor.
  • UDP: on Linux up to 8 datagrams move per syscall (recvmmsg/sendmmsg); the posted buffer set starts at 2 and doubles only under sustained bursts, so sparse flows stay at the memory footprint of unbatched code. The per-flow SOCKS header is pre-written into each buffer and payloads land directly after it — zero per-packet copies on the return path.
  • Hot paths allocate nothing: flow keys are netip.AddrPort structs (no per-packet strings), peer-limit checks are O(1) against an atomic counter, source validation is precomputed per association, FQDN destinations resolve once per flow within a 30 s TTL (stale entries serve as fallback during DNS blips), and buffers come from a shared sync.Pool.
  • Per connection: handshake readers are pooled, command handler chains are prebuilt at New(), and an idle UDP association pins ~64 KiB.

Behavior under congestion

The server is designed to degrade predictably — shedding load instead of growing memory:

  • TCP backpressure is end-to-end. The relay never queues internally: a slow receiver propagates flow control back to the sender through the tunnel, and per-tunnel memory stays constant regardless of how far ahead the fast side gets.
  • UDP sheds at the kernel boundary. There is no userspace packet queue; if a flood exceeds relay capacity, excess datagrams drop in the socket buffer and memory does not grow. In the flood benchmark above the relay sustains 84–97% delivery on Linux at peak ingest rates.
  • Hostile traffic is bounded. WithMaxConnections rejects connections beyond the cap with an O(1) check before any SOCKS traffic, and WithConnectionRateLimit throttles per-source handshake floods with token buckets whose table is swept under spoofed-source pressure, so its memory stays bounded too. WithHandshakeTimeout kills stalled handshakes, WithUDPAssociateLimits caps peers per association with an O(1) check and reaps idle flows, fragmented datagrams are dropped, and datagram sources are validated against the expected client address.
  • The accept loop survives resource exhaustion. Temporary accept errors (e.g. out of file descriptors) trigger exponential backoff from 5 ms up to 1 s instead of busy-looping or exiting.
  • DNS instability does not kill flows. Per-flow resolutions are cached with a 30 s TTL and stale entries keep serving while the resolver is failing.

Comparison

How go-s5 compares with the Go SOCKS5 libraries it is most often weighed against (based on upstream repositories as of June 2026):

go-s5 things-go/go-socks5 armon/go-socks5 txthinking/socks5 x/net/proxy
Server
Client
CONNECT
BIND
UDP ASSOCIATE
Username/Password auth
mTLS client identity → rules
Multi-hop chaining (one stream) ✅¹
Batched UDP syscalls (recvmmsg/sendmmsg)
Zero-alloc relay hot paths n/a
splice(2) TCP fast path (Linux) ✅² ✅² n/a
Connection cap / per-IP rate limiting ✅³
Metrics hook
Graceful shutdown (Shutdown(ctx)) n/a
Per-command middleware
Link quality scoring
Fuzz-tested protocol parsers
Committed reproducible benchmarks
Actively maintained — (archived-style, last commits ~2016)

¹ via repeated proxy.SOCKS5 dialer wrapping (one TCP hop per dialer, client only). ² inherited implicitly when io.Copy sees raw *net.TCPConns; not preserved through wrappers. ³ txthinking/socks5 exposes a global TCP timeout/limit, not per-source token buckets.

Install

Requires Go 1.25+.

go get github.com/AeonDave/go-s5/server \
       github.com/AeonDave/go-s5/client \
       github.com/AeonDave/go-s5/protocol

Quick start

package main

import (
    "log"
    "time"

    socks5 "github.com/AeonDave/go-s5/server"
)

func main() {
    s := socks5.New(
        socks5.WithHandshakeTimeout(5*time.Second),
        socks5.WithTCPKeepAlive(30*time.Second),
    )
    log.Fatal(s.ListenAndServe("tcp", ":1080"))
}

Graceful shutdown

ServeContext binds a context to the accept loop; canceling it tears down every active connection immediately. For a gentler stop, use Shutdown:

srv := socks5.New()
ln, _ := net.Listen("tcp", ":1080")

// Start serving in the background.
go srv.ServeContext(context.Background(), ln)

// ... later, stop accepting and wait for in-flight connections to finish.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    // ctx expired before all conns drained — force them down.
    _ = srv.Close()
}
  • Shutdown(ctx) closes all listeners so no new connections are accepted, then polls until active connections reach zero or ctx expires.
  • Close() closes all listeners and cancels every active connection immediately.
  • Serve, ServeContext, ListenAndServe, and ListenAndServeTLS return server.ErrServerClosed after either call.

Server options

Category Option Notes
Auth WithAuthMethods([]auth.Authenticator) Append custom authenticators
WithCredential(auth.CredentialStore) Enable User/Pass with a credential store
Rules / Resolver / Rewriter WithRule(rules.RuleSet) ACL evaluated before dialing
WithResolver(resolver.NameResolver) Custom DNS resolver
WithRewriter(handler.AddressRewriter) Mutate destination before dialing
Dialing WithDial(func(ctx, network, addr) (net.Conn, error)) Custom dial function
WithDialAndRequest(func(ctx, network, addr, *handler.Request) (net.Conn, error)) Dial with full request context
WithDialer(net.Dialer) Custom net.Dialer for outbound connections
WithDialFQDN(bool) CONNECT dials hostnames directly (Happy Eyeballs, RFC 8305) instead of pre-resolving
Admission control WithMaxConnections(int) Cap concurrent connections; excess closed before handshake (O(1))
WithConnectionRateLimit(perSecond float64, burst int) Per-source-IP token bucket; excess closed before handshake
TCP / BIND / UDP WithHandshakeTimeout(time.Duration) Deadline for negotiation + request parsing
WithTCPKeepAlive(time.Duration) TCP keepalive period on accepted connections
WithBindIP(net.IP) Bind IP for BIND and UDP sockets
WithBindAcceptTimeout(time.Duration) Max wait for peer during BIND
WithBindPeerCheckIPOnly(bool) Validate BIND peer by IP only (ignore port)
WithUseBindIpBaseResolveAsUdpAddr(bool) Advertise bind IP in UDP ASSOCIATE reply
WithUDPAssociateLimits(maxPeers int, idleTimeout time.Duration) Peer cap and idle GC
Lifecycle hooks WithBaseContext(func(net.Listener) context.Context) Base context factory per listener
WithConnContext(func(context.Context, net.Conn) context.Context) Decorate per-connection context
WithConnState(func(net.Conn, server.ConnState)) Observe StateNew / StateActive / StateClosed
WithConnMetadata(func(net.Conn) map[string]string) Attach static metadata to handler.Request.Metadata
Infra WithGPool(GPool) Goroutine pool for request handling
WithLogger(Logger) Replace the server logger (NewSlogLogger adapts log/slog)
WithBufferPool(buffer.BufPool) Replace the proxy I/O buffer pool
WithConnectionLogging(bool) Log accept/close events with peer addresses
WithMetrics(server.Metrics) Receive connection/request/relay-bytes events
WithLinkQuality(*linkquality.Tracker) Attach a tracker for outbound hop quality

Authentication

NoAuth is the default when no credentials are configured.

Username/Password — pass a auth.StaticCredentials map (implements auth.CredentialStore), or supply a custom CredentialStore to WithCredential:

creds := auth.StaticCredentials{"alice": "secret", "bob": "p@ss"}
s := socks5.New(socks5.WithCredential(creds))
log.Fatal(s.ListenAndServe("tcp", ":1080"))

Mutual TLS — start the server on a TLS listener and set ClientAuth: tls.RequireAndVerifyClientCert. When TLS is active, the server automatically populates auth.AContext.Payload with the following keys from the client leaf certificate:

  • tls.subject, tls.issuer
  • tls.san.dns, tls.san.ip
  • tls.fingerprint.sha256

These keys are available to rules, middleware, and custom handlers.

tlsCfg := &tls.Config{
    Certificates: []tls.Certificate{cert},
    ClientAuth:   tls.RequireAndVerifyClientCert,
    ClientCAs:    caPool,
}
s := socks5.New(socks5.WithHandshakeTimeout(5 * time.Second))
log.Fatal(s.ListenAndServeTLS("tcp", ":1080", tlsCfg))

Client

Single-hop CONNECT:

conn, _ := net.Dial("tcp", "127.0.0.1:1080")
cli := client.New(
    client.WithHandshakeTimeout(5*time.Second),
    client.WithIOTimeout(10*time.Second),
)
_, _ = cli.Handshake(ctx, conn, nil) // NoAuth; pass *client.Credentials for user/pass
dst, _ := protocol.ParseAddrSpec("example.com:80")
_, _ = cli.Connect(ctx, conn, dst)
// conn is now tunneled to example.com:80

Use cli.ConnectStream to get a *tcp.Stream with helpers like WriteString, ReadFull, CopyTo/CopyFrom, and Relay (bidirectional proxy with context cancellation). Use cli.UDPAssociate to get a *client.UDPAssociation with WriteTo/ReadFrom/PacketConn(). Use cli.Bind (or BindStart/BindWait) for BIND.

Multi-hop DialChain:

chain := []client.Hop{
    {Address: "10.0.0.2:1080", Creds: &client.Credentials{Username: "alice", Password: "secret"}},
    {Address: "hop2.example:1080", TLSConfig: &tls.Config{ServerName: "hop2.example", MinVersion: tls.VersionTLS12}},
}
cli := client.New(client.WithHandshakeTimeout(5*time.Second), client.WithIOTimeout(10*time.Second))
conn, err := cli.DialChain(ctx, chain, "example.org:443", 5*time.Second)
if err != nil { /* handle */ }
defer conn.Close()
// conn speaks to example.org:443 through 2 SOCKS hops over a single stream.

Per-hop Creds and TLSConfig are optional. Pass an empty finalTarget to stop at the last hop and issue UDPAssociate or Bind directly. The client/tcp and client/udp packages provide focused APIs for stream and datagram workloads; the root client package re-exports them for backwards compatibility.

CLI

Build:

go build -o s5 ./cmd/s5

Server examples:

# NoAuth, plain TCP
s5 server -listen :1080

# User/Pass with tuning
s5 server -listen :1080 -user alice -pass secret -handshake-timeout 5s -tcp-keepalive 30s

# TLS + optional mTLS
s5 server -listen :1080 -tls-cert cert.pem -tls-key key.pem -mtls-ca ca.pem

# Chain through an upstream SOCKS5 hop
s5 server -listen :1080 -upstream 1.2.3.4:1080 -upstream-user alice -upstream-pass secret

# Log connections and track outbound link quality
s5 server -listen :1080 -log-connections -linkquality -linkquality-interval 3s

Dial examples:

# Send an HTTP request and print response
s5 dial -socks 127.0.0.1:1080 -dest example.com:80 -send $'GET / HTTP/1.0\r\n\r\n' -io-timeout 5s

# Stdio tunnel (e.g. for SSH ProxyCommand)
s5 dial -socks 127.0.0.1:1080 -dest example.com:22 -stdio

# Show live link quality during a session
s5 dial -socks 127.0.0.1:1080 -dest example.com:443 -linkquality -stdio

Link quality monitoring

The linkquality package provides a thread-safe Tracker that passively observes existing traffic — it never sends additional probes or alters socket options.

tracker := linkquality.NewTracker(linkquality.Metadata{
    Name: "exit-eu-1",
    Kind: linkquality.EndpointSOCKS5,
    TLS:  true,
})

// Record an existing handshake latency — no extra packets sent.
start := time.Now()
_, err := cli.Handshake(ctx, conn, creds)
tracker.RecordProbe(time.Since(start), err)

// Passively track throughput on the established stream.
measured := linkquality.WrapConn(conn, tracker)

score := tracker.Score()           // composite 0–100
info := tracker.ConnectionInfo()   // RTT, jitter, throughput, uptime, etc.

Key entry points:

  • NewTracker(Metadata) — creates a tracker
  • RecordProbe(rtt, err) — register a probe result (RTT + success/failure)
  • WrapConn(net.Conn, *Tracker) net.Conn — passively record throughput
  • Score() int — composite score 0–100
  • ConnectionInfo() ConnectionInfo — full snapshot (RTT min/avg/max, jitter, throughput, uptime)
  • ProbeTCP / ProbeSOCKSHandshake — optional active health-check helpers

On the server side, pass server.WithLinkQuality(tracker) and read srv.LinkQualityTracker() to monitor outbound hop health. The CLI flags -linkquality / -linkquality-interval emit periodic snapshots to stderr for both s5 server and s5 dial.

Observability

WithMetrics installs a hook that receives connection, request and relay events. server.CounterMetrics is a ready-made atomic implementation; any backend (Prometheus, OpenTelemetry, expvar) can implement the same interface. When no hook is installed the only cost on the serving path is a nil check — byte counts are taken from the relay's own return values, so the splice(2) and batched-UDP fast paths are untouched.

metrics := &server.CounterMetrics{}
s := socks5.New(
    socks5.WithMetrics(metrics),
    socks5.WithMaxConnections(4096),
    socks5.WithConnectionRateLimit(10, 20), // per source IP: 20 burst, 10/s sustained
    socks5.WithLogger(socks5.NewSlogLogger(slog.Default())),
)
// ... later
snap := metrics.Snapshot()
log.Printf("active=%d relayed=%dB rejected=%d",
    snap.Accepted-snap.Closed, snap.RelayedBytes, snap.Rejected)

Rejections (connection cap, rate limit) happen before any SOCKS byte is exchanged and are reported with their reason.

Middleware

handler.Middleware is func(ctx context.Context, w io.Writer, req *handler.Request) error. Returning a non-nil error aborts the request before the command handler runs.

logMW := handler.Middleware(func(ctx context.Context, w io.Writer, r *handler.Request) error {
    log.Printf("cmd=%d src=%s dst=%s", r.Command, r.RemoteAddr, r.DestAddr)
    return nil
})

s := socks5.New(
    socks5.WithConnectMiddleware(logMW),
    socks5.WithBindMiddleware(logMW),
    socks5.WithAssociateMiddleware(logMW),
)

Full handler replacement is available via WithConnectHandle, WithBindHandle, and WithAssociateHandle.

Compatibility

  • Conforms to SOCKS5 (RFC 1928) for CONNECT, BIND, and UDP ASSOCIATE.
  • REP codes are mapped accurately from typical dial errors.
  • UDP: fragmented datagrams (FRAG != 0) are dropped and not reassembled.
  • BIND: the incoming peer is validated against the expected address; set WithBindPeerCheckIPOnly(true) to match by IP only (ignore port).

Testing

go test ./...

The test suite is race-clean (-race) and runs on every push via CI.

Every wire-format parser (ParseRequest, ParseReply, ParseMethodRequest, ParseUserPassRequest, ParseDatagram, ParseAddrSpec) has a native Go fuzz target asserting no-panic and encode/decode round-trip stability:

go test -fuzz=FuzzParseDatagram -fuzztime=30s ./internal/protocol/

The seed corpus is replayed on every plain go test run, so past fuzz findings act as permanent regression tests.

About

A minimal, fast, and extensible SOCKS5 lib written in Go.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Contributors

Languages