SOCKS
hopf-socks is a SOCKS4, SOCKS4a, and SOCKS5 (RFC 1928) proxy server and client, built as a peer crate to hopf-core (listener/connector infrastructure) and hopf-dns (asynchronous target resolution). It implements CONNECT, BIND, and UDP ASSOCIATE on both sides of the wire.
Contents
Versions and scope
| Version | Reference | Support |
|---|---|---|
| SOCKS4 | No formal RFC — the historical protocol description | CONNECT and BIND, server and client. No credential field on the wire; a configured authenticator rejects SOCKS4 requests outright rather than silently accepting them unauthenticated. |
| SOCKS4a | Same historical description, "magic IP" 0.0.0.x (x != 0) extension |
Hostname targets, resolved by the server via hopf-dns instead of requiring the client to resolve first. |
| SOCKS5 | RFC 1928 | Method negotiation, CONNECT/BIND/UDP ASSOCIATE, IPv4/IPv6/domain-name addressing, server and client. |
| Username/password auth | RFC 1929 | Server (SocksAuthenticator) and client (SocksClientConfig::with_credentials). |
| GSSAPI auth | RFC 1961 | Not implemented. See Limitations. |
UDP ASSOCIATE implements no RFC 1928 §7 fragment reassembly — only standalone datagrams (FRAG = 0x00) are forwarded; a non-standalone fragment is silently dropped. This matches near-universal real-world SOCKS5 server practice.
Architecture
Following this workspace's server/client-as-peers convention (see Architecture → Module layout), hopf-socks keeps server-side and client-side state machines in separate files that share the same wire codec rather than one FSM serving both roles:
| Module | Role |
|---|---|
wire, udp_header |
Shared, incremental, allocation-light request/reply codecs for SOCKS4/4a and SOCKS5, and the RFC 1928 §7 UDP datagram header. Every TCP-side parser returns ParseResult::{Incomplete, Invalid, Complete} against a possibly-partial byte slice. |
handler, service |
Server side: SocksConnectionHandlerFactory builds one SocksConnectionHandler (a ProtocolHandler) per accepted connection; SocksService registers the listener on a Runtime. |
connect, bind, udp_associate |
Server side: per-command setup (resolve/authorize/dial for CONNECT, ephemeral single-use listener for BIND, UDP socket pair for UDP ASSOCIATE), each handing off to relay once established. |
relay |
Server side: shared byte-relay plumbing and idle-timeout tracking used by CONNECT and BIND once a tunnel is up. |
auth, policy, metrics |
Shared seams: SocksAuthenticator (RFC 1929 verification), SocksPolicy (destination authorization), and SocksServerMetrics (atomic counters) — all injected into the server side, and reusable independently of it. |
client, client_bind, client_udp_associate |
Client side: SocksConnectHandler, SocksBindHandler, and SocksUdpAssociateHandler — each a transport decorator that performs its handshake over an already-dialed connection to the proxy, then hands off to (or alongside) an arbitrary caller-supplied inner ProtocolHandler. |
The client handlers are not modeled on hopf-http's protocol-upgrade negotiation (WebSocket, CONNECT-UDP, CONNECT-IP), which operates above an already-established HTTP connection. A SOCKS proxy sits below the client protocol entirely: once CONNECT succeeds, the tunnel is just the same raw TCP stream, so the inner handler talks to the real Endpoint directly with no reframing.
Commands
| Command | SOCKS4/4a | SOCKS5 | Behaviour |
|---|---|---|---|
| CONNECT | Yes | Yes | Resolves (if a hostname), authorizes via SocksPolicy, dials the target, then relays bytes bidirectionally until either side closes or DEFAULT_RELAY_IDLE_TIMEOUT (5 minutes) elapses with no traffic. |
| BIND | Yes | Yes | Opens an ephemeral, loopback-scoped, single-use listener; sends a first reply with the bound address; on the first (and only) accepted connection, checks it against the request's own DST.ADDR (an unspecified address accepts any peer) and the destination policy, then sends a second reply and starts relaying. Times out after DEFAULT_BIND_ACCEPT_TIMEOUT (2 minutes) if no peer ever connects. |
| UDP ASSOCIATE | No SOCKS4 equivalent | Yes | Opens a client-facing and an upstream-facing UDP socket; unwraps/wraps the RFC 1928 §7 header on each datagram; the association's lifetime is tied to the TCP control connection (closing either ends both) and also times out after DEFAULT_UDP_IDLE_TIMEOUT (5 minutes) of no datagrams. |
An unrecognized address type (SOCKS5) gets the specific RFC 1928 §6 AddressTypeNotSupported reply rather than a bare connection close, once the request's version and command framing are already known-good. A destination a hostname resolves to more than one address is checked against every resolved address, not just the first — if any is denied, the whole request is rejected, so a multi-answer DNS response can't bypass the policy by ordering.
Authentication
The server offers no-auth (0x00) by default. Attaching a SocksAuthenticator to SocksConnectionHandlerFactory::with_authenticator flips two things at once: SOCKS5 no longer offers no-auth even if the client asks for it (only 0x02, username/password, is selected), and SOCKS4/4a requests — which carry no credential field to check — are rejected outright rather than treated as an implicit identity claim.
use hopf_socks::SocksAuthenticator;
struct FixedCredential {
username: String,
password: String,
}
impl SocksAuthenticator for FixedCredential {
fn verify(&self, username: &str, password: &str) -> bool {
username == self.username && password == self.password
}
}
There is no built-in CredentialStore/TrustPolicy integration (unlike the mail and directory protocol crates) — SocksAuthenticator is a single-method seam an application implements directly, typically backed by whatever credential source it already has.
On the client side, SocksClientConfig::with_credentials(username, password) offers 0x02 alongside no-auth in the SOCKS5 greeting and drives the RFC 1929 sub-negotiation automatically if the proxy selects it. SOCKS4/4a has no credential field; a configured credential is silently unused when SocksClientVersion::Socks4 is selected.
RFC 1961 GSSAPI authentication is not implemented. There is no GSSAPI method byte, sub-negotiation, or per-message protection in wire, handler, or either client handler. It is planned together with optional GSSAPI/Kerberos SASL in hopf-auth; until then, deployments needing enterprise authentication can terminate TLS in front of the proxy (see SOCKS over TLS) and use RFC 1929 username/password instead.
Destination policy
SocksPolicy approves or denies a relay target and has no permissive default anywhere in this crate — an open SOCKS proxy is not something an application should get by omission:
use std::net::IpAddr;
use hopf_socks::SocksPolicy;
struct PrivateNetworksOnly;
impl SocksPolicy for PrivateNetworksOnly {
fn is_target_allowed(&self, addr: IpAddr, _port: u16) -> bool {
match addr {
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
IpAddr::V6(v6) => v6.is_loopback(),
}
}
}
This governs where an already-accepted client may relay to. It is separate from SocksService::with_acl, which governs who may connect to the proxy at all (enforced at the hopf-core listener level, before any SOCKS byte is read) — see Access control.
Server quick start
use std::net::IpAddr;
use std::sync::Arc;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_dns::DnsResolver;
use hopf_socks::{SocksConnectionHandlerFactory, SocksPolicy, SocksService};
struct AllowAll;
impl SocksPolicy for AllowAll {
fn is_target_allowed(&self, _addr: IpAddr, _port: u16) -> bool {
true
}
}
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let dns = Arc::new(DnsResolver::for_runtime(&rt)?);
let factory = SocksConnectionHandlerFactory::new(dns, Arc::clone(&rt), Arc::new(AllowAll));
let service = SocksService::new("127.0.0.1:1080".parse()?, factory);
let bound = service.start(&rt)?;
println!("SOCKS proxy listening on {bound}");
AllowAll is shown here for brevity only — see Destination policy for why a real deployment should not actually ship it.
Server configuration
| Builder method | Default | Description |
|---|---|---|
SocksConnectionHandlerFactory::new(dns, runtime, policy) |
— | No authentication (SOCKS5 offers no-auth only; SOCKS4/4a accepted as-is) |
.with_authenticator(authenticator) |
none | Require RFC 1929 username/password; see Authentication |
.with_idle_timeout(duration) |
5 minutes | CONNECT/BIND relay idle timeout (DEFAULT_RELAY_IDLE_TIMEOUT) |
.with_bind_accept_timeout(duration) |
2 minutes | How long a BIND request waits for a peer (DEFAULT_BIND_ACCEPT_TIMEOUT) |
.with_udp_idle_timeout(duration) |
5 minutes | UDP ASSOCIATE idle timeout (DEFAULT_UDP_IDLE_TIMEOUT) |
.with_handshake_timeout(duration) |
30 seconds | Time to complete version detection through a parsed request (DEFAULT_HANDSHAKE_TIMEOUT); a silent client is force-closed |
.with_max_relays(n) |
0 (unlimited, DEFAULT_MAX_RELAYS) |
Cap concurrent relays and UDP associations combined, checked against currently established sessions |
SocksService::new(listen, factory) |
— | Bind listen, open to all source addresses, plaintext |
.with_acl(peer_acl) |
open | Restrict which client source addresses may use this listener at all — see Destination policy for how this differs from SocksPolicy |
.with_tls(acceptor) |
none | SOCKS over TLS — see below |
SOCKS over TLS
SocksService::with_tls wraps the listener in TLS-from-accept ("SOCKS over TLS"). The protocol state machine needs no changes for this at all — it only ever sees already-decrypted bytes either way — so this is purely a transport-level listener option, the same shape as every other protocol crate in this workspace:
let acceptor = hopf_core::acceptor_from_pem(&cert_path, &key_path, &[])?;
let service = SocksService::new("0.0.0.0:1080".parse()?, factory).with_tls(acceptor);
service.start(&rt)?;
Client
Each client handler is a ProtocolHandler transport decorator: it performs its SOCKS handshake over a connection already dialed to the proxy, then forwards every subsequent callback to a caller-supplied inner handler as if that handler were talking to the target directly. A handshake failure is reported to the inner handler's error() — it never sees connected() in that case.
CONNECT client
use hopf_core::{Endpoint, ProtocolHandler, Runtime, RuntimeConfig};
use hopf_socks::{socks_connect_config, SocksClientConfig, SocksClientVersion};
use std::sync::Arc;
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let proxy_addr = "127.0.0.1:1080".parse()?;
let config = SocksClientConfig::new(SocksClientVersion::Socks5)
.with_credentials("alice", "secret");
let cfg = socks_connect_config(proxy_addr, config, "example.com", 443, || {
Box::new(MyTargetHandler::default()) as Box<dyn ProtocolHandler>
});
rt.connect(cfg)?;
SocksClientVersion::Socks4 speaks SOCKS4/4a instead (falling back to the SOCKS4a hostname extension automatically when the target doesn't parse as an IP literal). There is deliberately no "auto-detect, prefer 5, fall back to 4" mode — the proxy's version is treated as known configuration.
BIND client
socks_bind_config follows the same shape for BIND's two-reply sequence: on_bound is called once, with the listening address the caller must relay to a remote peer out-of-band (for example, embedded in an FTP PORT command sent over a different connection); the inner handler's connected() fires only once a peer has actually connected (the second reply).
use hopf_socks::{socks_bind_config, SocksAddress};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
let expected_peer = SocksAddress::Ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); // accept any peer
let cfg = socks_bind_config(
proxy_addr,
config.clone(),
expected_peer,
0,
Arc::new(|bound: SocketAddr| {
// Relay `bound` to the remote peer out-of-band, then wait.
}),
|| Box::new(MyTargetHandler::default()) as Box<dyn ProtocolHandler>,
);
rt.connect(cfg)?;
UDP ASSOCIATE client
socks_udp_associate_config is SOCKS5-only (RFC 1928 §7 has no SOCKS4 equivalent). Unlike CONNECT/BIND, the data plane is never the TCP control connection itself — it's a separate UDP socket the client opens once the association is confirmed, exchanging RFC 1928 §7-framed datagrams with the proxy's relay address. The TCP connection carries no further protocol traffic once established; its only remaining job is to anchor the association's lifetime.
use hopf_socks::{socks_udp_associate_config, SocksUdpDatagramHandler, SocksUdpSender};
use std::net::SocketAddr;
use std::sync::Arc;
struct EchoBack;
impl SocksUdpDatagramHandler for EchoBack {
fn on_datagram(&mut self, target: SocketAddr, data: &[u8]) {
println!("{} bytes from {target}", data.len());
}
}
let cfg = socks_udp_associate_config(
proxy_addr,
config,
Arc::clone(&rt),
Arc::new(|sender: SocksUdpSender| {
sender.send_to("198.51.100.1:53".parse().unwrap(), b"hello");
}),
|| Box::new(EchoBack) as Box<dyn SocksUdpDatagramHandler>,
);
rt.connect(cfg)?;
Client options
| Builder method | Default | Description |
|---|---|---|
SocksClientConfig::new(version) |
— | SocksClientVersion::Socks4 or ::Socks5, no authentication |
.with_credentials(username, password) |
none | Offer RFC 1929 username/password (SOCKS5 only; silently unused under SOCKS4/4a) |
.with_handshake_timeout(duration) |
30 seconds (DEFAULT_CLIENT_HANDSHAKE_TIMEOUT) |
A handshake that hasn't completed by this deadline force-fails the connection via Endpoint::fail (reaching the inner handler's error(), unlike a bare close) |
Metrics
Process-local SocksServerMetrics (atomic counters, in hopf-socks), obtained via SocksConnectionHandlerFactory::metrics(): connections, connect_requests, bind_requests, active_bind_waits, active_relays, udp_associate_requests, active_udp_associations, bytes_upstream / bytes_downstream, auth_ok / auth_fail, and destinations_blocked.
There is no OTLP/JSONL exporter for these counters in hopf-otel yet (unlike the mail and directory protocol crates' *ServerMetrics types) — applications that need centralized telemetry currently read the atomics directly.
Limitations
- RFC 1961 GSSAPI authentication is not implemented — see Authentication.
- No RFC 1928 §7 UDP fragment reassembly — only standalone datagrams are relayed, matching near-universal real-world practice, but a client relying on fragmentation will not work.
- No built-in
CredentialStore/TrustPolicyintegration —SocksAuthenticatoris a standalone single-method trait, not wired to this workspace's shared credential-store abstraction. - No OTLP/JSONL metrics export via
hopf-otel— only the in-process atomic counters. - BIND's peer-address check and UDP ASSOCIATE's client-address check are plausibility checks against the RFC's own stated expectations, not a security boundary against a spoofed source on a hostile network.
- There is no "auto-detect SOCKS version" client mode —
SocksClientVersionmust be chosen by the caller.