SOCKS
Source files: 28 · checked against Etemenanki 596916d
Etemenanki/protocols/src/socks/mod.rsEtemenanki/protocols/src/socks/protocol.rsEtemenanki/protocols/src/socks/handshake.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/src/socks/codec.rsEtemenanki/protocols/src/socks/udp_link.rsEtemenanki/protocols/src/socks/config.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/sniff/collector.rsEtemenanki/concepts/src/relay.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/src/core.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/config.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/tests/unit/socks/protocol.rsEtemenanki/protocols/tests/unit/socks/server.rsEtemenanki/protocols/tests/unit/socks/codec.rsEtemenanki/app/tests/unit/inbound.rsEtemenanki/app/tests/integration/e2e_udp_route.rsEtemenanki/app/tests/integration/e2e_unix.rs
The socks module in etemenanki-protocols implements both ends of SOCKS. The server side is SocksInbound, one driver that serves SOCKS4, SOCKS4a and SOCKS5 on the same connection and relays a CONNECT or a UDP ASSOCIATE. The client side is a SOCKS5 CONNECT codec, SocksConnect, and a datagram link, SocksUdpLink, for UDP ASSOCIATE.
Read this page before you change the handshake, the reply rules, the association relay or the client. The configuration a user writes is on the SOCKS user guide page.
Responsibilities
Section titled “Responsibilities”| Piece | File → symbol | Does | Leaves to others |
|---|---|---|---|
| Wire primitives | protocols/src/socks/protocol.rs |
Constants, the RFC 1929 reader, the UDP relay header codec, the client’s slice builders and parsers. | Address encoding, which is AddressCodec::SOCKS in protocols/src/helpers/address.rs. |
| Server handshake | protocols/src/socks/handshake.rs → handshake, handshake_with_udp_source |
Version detection, method selection, authentication, the request, and every refusal reply. | The reply to a granted request, which depends on the connect. |
| Inbound driver | protocols/src/socks/server.rs → SocksInbound |
The handshake deadline, sniffing, the connect, the success or failure reply, the CONNECT relay, and the UDP ASSOCIATE relay held to one client by ExpectedSender. |
Routing and dialing, which the Connector does. |
Client CONNECT |
protocols/src/socks/codec.rs → SocksConnect |
The SOCKS5 handshake as a sans-I/O codec, then plaintext verbatim. | Dialing and I/O, which the client runtime does. |
Client UDP ASSOCIATE |
protocols/src/socks/udp_link.rs → SocksUdpLink |
The association handshake over a control stream, then packet framing on a local UDP socket. | Binding the socket, which the caller’s bind closure does. |
| Settings | protocols/src/socks/config.rs |
SocksAuth and SocksServerConfig. |
Parsing TOML, which etemenanki-app does. |
Why SOCKS is a bespoke driver
Section titled “Why SOCKS is a bespoke driver”Every other stream protocol in the crate is a ProxyCoreDecode core: a sans-I/O state machine that the server runtime drives over one transport link (see Server cores and Server runtime). SOCKS does not fit that shape. A UDP ASSOCIATE carries no data on the connection that asked for it. The datagrams arrive on a second socket, a UDP “hub” the server binds for the association, and the TCP control connection only keeps the association alive. A core sees exactly one transport, so it has no place to put that second socket.
SocksInbound is therefore an ordinary async function that owns the control stream for the connection’s whole life. In etemenanki-app, app/src/serve.rs → serve_connection has a separate branch for it: StreamProtocol::Socks calls SocksInbound::serve directly, and every other stream protocol goes through drive with its core. The app holds the connection’s session permit across the whole serve call, so an association counts as one live connection until it ends.
Because the driver reads the stream itself, the SOCKS inbound runs only on plain TCP or a Unix socket. app/src/inbound/mod.rs calls reject_stream(cfg, "socks"), and app/src/transport.rs → reject_stream_settings refuses any stream network other than tcp (or empty) and any security other than none (or empty), for example with inbound <tag>: protocol socks does not support stream network "ws".
Key types
Section titled “Key types”Configuration
Section titled “Configuration”pub enum SocksAuth<T> { None(Arc<T>), Password(HashMap<CompactString, (CompactString, Arc<T>)>),}
pub struct SocksServerConfig<T> { pub auth: SocksAuth<T>, pub udp_enabled: bool, pub udp_bind: Option<IpAddr>,}T is the per-user payload that follows the flow to the connector. SocksAuth::None holds the one payload that every anonymous connection gets. SocksAuth::Password maps a username to its password and payload. For T: Default, SocksServerConfig::default() is anonymous, with UDP enabled and no udp_bind.
udp_bind must be in the address family clients connect over, because the relay hears only the address a client’s control connection came from (see UDP ASSOCIATE). On a Unix socket there is no such address. There udp_bind is required, and the client’s UDP ASSOCIATE request must name the exact address and port its datagrams will come from.
The app builds these from [inbound.settings] (app/src/config.rs → SocksInboundSettings, which denies unknown fields): auth = "none" (default) or "password" (anything else fails with inbound <tag>: unknown socks auth "<value>"), accounts, udp (default true) and udp_bind. The app’s payload type T is ().
The inbound
Section titled “The inbound”pub struct SocksInbound<T> { /* auth, udp_enabled, udp_bind, sniff */ }
impl<T> SocksInbound<T> { pub fn new(config: SocksServerConfig<T>, sniff: bool) -> Self}
impl<T: Send + Sync + 'static> SocksInbound<T> { pub async fn serve<S, C>( &self, mut stream: S, local_ip: Option<IpAddr>, source: Option<IpAddr>, mut connector: C, ) -> io::Result<()> where S: AsyncRead + AsyncWrite + Unpin, C: Connector<Flow<T>>, C::Datagram: DatagramLink<Addr = Destination>,}| Argument | Meaning |
|---|---|
stream |
The accepted control connection. |
local_ip |
The server’s own address on that connection. None over a Unix socket. It is the bound address in a CONNECT success reply and the fallback bind address for the UDP hub. |
source |
The client’s address. None over a Unix socket. It goes into every Flow as Flow::source, and it is the one IP a UDP ASSOCIATE on this connection hears. |
connector |
Dials each flow. A CONNECT must come back as Outbound::Stream and an association as Outbound::Datagram. |
The connector traits come from concepts/src/link.rs (see Links and types):
pub trait Connector<Target> { type Stream: AsyncRead + AsyncWrite + Unpin; type Datagram: DatagramLink; type Future: Future<Output = io::Result<Outbound<Self::Stream, Self::Datagram>>>;
fn connect(&mut self, target: Target) -> Self::Future;}
pub trait DatagramLink: Unpin { type Addr;
fn poll_send_to( &mut self, cx: &mut Context<'_>, buf: &[u8], to: &Self::Addr, ) -> Poll<io::Result<usize>>;
fn poll_recv_from( &mut self, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll<io::Result<Self::Addr>>;}The target is protocols/src/flow.rs → Flow<T>, the same type every server core in the crate hands its connector:
pub struct Flow<T> { pub destination: Destination, pub user: NetworkUser<T>, pub sniffed: Option<SniffedBehavior>, pub source: Option<IpAddr>,}
impl<T> Flow<T> { pub fn new(destination: Destination, user: NetworkUser<T>, source: Option<IpAddr>) -> Self}The handshake
Section titled “The handshake”pub enum Version { V4, V5,}
pub enum Request { Connect(Destination), UdpAssociate,}
pub struct Handshake<T> { pub version: Version, pub user: NetworkUser<T>, pub request: Request,}
pub async fn handshake<S, T>( stream: &mut S, auth: &SocksAuth<T>, udp_enabled: bool,) -> io::Result<Handshake<T>>where S: AsyncRead + AsyncWrite + Unpin,
pub(crate) async fn handshake_with_udp_source<S, T>( stream: &mut S, auth: &SocksAuth<T>, udp_enabled: bool,) -> io::Result<(Handshake<T>, Option<Destination>)>where S: AsyncRead + AsyncWrite + Unpin,handshake_with_udp_source runs version detection, authentication and the request, and SocksInbound::serve calls it. The public handshake is a wrapper that drops the second value. Request::UdpAssociate still carries nothing. The DST.ADDR and DST.PORT of a UDP ASSOCIATE request, where the client says its datagrams will come from, come back as the second value, Some(source). It is None for any other request and for SOCKS4. The inbound decides how far to believe it (see UDP ASSOCIATE).
The authenticated user becomes a NetworkUser with UserAuthorization::UsernamePassword. The username is the one the client sent, or empty for an anonymous or SOCKS4 client. The password field is always empty: the password never travels with the flow.
The reply writers are public so that the driver can answer after the connect:
pub async fn write_socks5_response<S: AsyncWrite + Unpin>( stream: &mut S, code: u8, remote: &Remote, port: u16,) -> io::Result<()>
pub async fn write_socks4_response<S: AsyncWrite + Unpin>( stream: &mut S, code: u8,) -> io::Result<()>
pub async fn write_refusal<S: AsyncWrite + Unpin>( stream: &mut S, version: Version, code: u8,) -> io::Result<()>
pub async fn write_granted<S: AsyncWrite + Unpin>( stream: &mut S, version: Version, bound: Option<IpAddr>,) -> io::Result<()>write_refusal sends code with the bound address 0.0.0.0:0 for SOCKS5, and always 91 for SOCKS4. write_granted sends 0x00 with bound (or 0.0.0.0) and port 0 for SOCKS5, and 90 for SOCKS4.
UDP relay header
Section titled “UDP relay header”pub fn decode_udp_packet(packet: &[u8]) -> io::Result<(Destination, Bytes)>pub fn parse_udp_packet(packet: &[u8]) -> io::Result<(Destination, usize)>pub fn encode_udp_packet(remote: &Remote, port: u16, data: &[u8]) -> Bytespub fn encode_udp_packet_into(remote: &Remote, port: u16, data: &[u8], out: &mut BytesMut)Both relays use the non-copying pair, parse_udp_packet and encode_udp_packet_into, with reused buffers. decode_udp_packet and encode_udp_packet are the copying forms.
The association’s client
Section titled “The association’s client”pub(crate) fn endpoint(addr: SocketAddr) -> (IpAddr, u16)endpoint is how both ends of an association compare the sender of a relay datagram: the IP in canonical form (to_canonical, so an IPv4-mapped IPv6 address becomes the IPv4 one) and the port. It ignores IPv6 flow info and scope ID. A dual-stack socket reports an IPv4 peer as IPv4-mapped, and endpoint makes the two forms equal.
// protocols/src/socks/server.rs (private)const STATUS_NOT_ALLOWED: u8 = 0x02;
struct ExpectedSender { ip: IpAddr, port: Option<u16>, client: Option<SocketAddr>,}
impl ExpectedSender { fn new( peer: Option<IpAddr>, declared: Option<&Destination>, hub: IpAddr, ) -> Result<Self, &'static str> fn admits(&self, from: SocketAddr) -> bool fn pin(&mut self, from: SocketAddr) fn client(&self) -> Option<SocketAddr>}
fn hears(hub: IpAddr, ip: IpAddr) -> boolExpectedSender is the one client a UDP ASSOCIATE hears (RFC 1928 §7). ip is the canonical address every datagram must come from. port is set when the request named it alongside ip. client is the first sender a datagram was forwarded for, as the hub saw it, and replies go there. new builds it from the control connection’s peer (None over a Unix socket), the request’s declared source and the hub’s address. Its error names why there is no client the relay could hold to. hears says whether a hub bound to hub receives datagrams from ip. STATUS_NOT_ALLOWED is the SOCKS5 reply “connection not allowed by ruleset”, sent when new fails.
Client side
Section titled “Client side”pub struct SocksConnect { /* dest, auth, stage */ }
impl SocksConnect { pub fn new(dest: &Destination, auth: Option<(&str, &str)>) -> Self}
impl ProxyCoreEncodeHandshake for SocksConnect { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = 528; // start, reply, finish}
impl ProxyCoreEncode for SocksConnect { /* seal, open */ }pub struct SocksUdpLink<S> { /* control, socket, relay, scratch, recv, sink, control_closed */ }
impl<S> SocksUdpLink<S>where S: AsyncRead + AsyncWrite + Unpin,{ pub async fn associate( mut control: S, auth: Option<(&str, &str)>, bind: impl FnOnce(&SocketAddr) -> io::Result<UdpSocket>, ) -> io::Result<Self>
pub fn relay(&self) -> SocketAddr}
impl<S> DatagramLink for SocksUdpLink<S>where S: AsyncRead + AsyncWrite + Unpin,{ type Addr = Destination; // poll_send_to, poll_recv_from}Wire formats
Section titled “Wire formats”All multi-byte integers are big-endian. SOCKS5 addresses use AddressCodec::SOCKS (the constant ADDR in protocol.rs), which puts the port after the address:
ATYP |
Address | Size |
|---|---|---|
0x01 |
IPv4 | 4 bytes |
0x03 |
Domain: a length byte, then the name | 1 + 1 to 255 bytes |
0x04 |
IPv6 | 16 bytes |
Any other ATYP fails with unknown address type: …. A domain must be non-empty, valid UTF-8 and a valid domain name. A name that starts with a digit or [ and parses as an IP literal becomes an IP address instead. The longest encoded address is AddressCodec::MAX_LEN, 259 bytes including the port.
SOCKS4 and SOCKS4a
Section titled “SOCKS4 and SOCKS4a”| Field | Size | Meaning |
|---|---|---|
VN |
1 | 0x04. |
CD |
1 | Command. Only 0x01 (CMD_TCP_CONNECT) is granted. |
DSTPORT |
2 | Destination port. |
DSTIP |
4 | Destination IPv4 address. When its first octet is 0, the request is SOCKS4a and a domain follows the user ID. |
USERID |
variable | Bytes up to a NUL. The server reads it and discards it. |
DOMAIN |
variable | SOCKS4a only: bytes up to a NUL, taken as the destination domain. |
| Field | Size | Meaning |
|---|---|---|
VN |
1 | 0x00. |
CD |
1 | 90 (SOCKS4_REQUEST_GRANTED) or 91 (SOCKS4_REQUEST_REJECTED). |
DSTPORT |
2 | Always 0. |
DSTIP |
4 | Always 0.0.0.0. |
read_until_null reads the NUL-terminated fields and decodes them with lossy UTF-8. It keeps at most 512 bytes: a 513th byte that is not a NUL fails with buffer overrun.
SOCKS5 greeting and method selection
Section titled “SOCKS5 greeting and method selection”| Field | Size | Meaning |
|---|---|---|
VER |
1 | 0x05. |
NMETHODS |
1 | Number of method bytes that follow. |
METHODS |
NMETHODS |
Offered methods. |
| Field | Size | Meaning |
|---|---|---|
VER |
1 | 0x05. |
METHOD |
1 | 0x00 (AUTH_NOT_REQUIRED), 0x02 (AUTH_PASSWORD) or 0xFF (AUTH_NO_MATCHING_METHOD). |
The server does not negotiate. The configured SocksAuth fixes one method: 0x00 for None, 0x02 for Password. If the client’s list contains that method, the server selects it. If not, the server answers 0xFF and closes.
RFC 1929 username/password
Section titled “RFC 1929 username/password”| Field | Size | Meaning |
|---|---|---|
VER |
1 | Sub-negotiation version, 0x01. |
ULEN |
1 | Username length. |
UNAME |
ULEN |
Username. |
PLEN |
1 | Password length. |
PASSWD |
PLEN |
Password. |
| Field | Size | Meaning |
|---|---|---|
VER |
1 | 0x01. |
STATUS |
1 | 0x00 accepted, 0xFF refused. The client treats any non-zero value as a refusal. |
read_username_password decodes both strings with lossy UTF-8 before the account lookup. On the client, encode_userpass truncates the username and the password to 255 bytes each, as the one-byte length fields require.
SOCKS5 request and reply
Section titled “SOCKS5 request and reply”| Field | Size | Meaning |
|---|---|---|
VER |
1 | 0x05. |
CMD |
1 | Command; see the table below. |
RSV |
1 | 0x00. |
ATYP |
1 | Address type. |
DST.ADDR |
variable | The destination for CONNECT. For UDP ASSOCIATE, RFC 1928 makes it the address the client’s datagrams will come from; this server treats it as a hint (see UDP ASSOCIATE). |
DST.PORT |
2 | Port, with the same two meanings. |
| Field | Size | Meaning |
|---|---|---|
VER |
1 | 0x05. |
REP |
1 | Reply code; see Failure replies. |
RSV |
1 | 0x00. |
ATYP |
1 | Address type of the bound address. |
BND.ADDR |
variable | CONNECT: the server’s local IP on the connection, or 0.0.0.0. UDP ASSOCIATE: the hub’s IP. |
BND.PORT |
2 | CONNECT: 0. UDP ASSOCIATE: the hub’s port. |
CMD |
Constant | Server handling |
|---|---|---|
0x01 |
CMD_TCP_CONNECT |
Request::Connect. |
0x02 |
CMD_TCP_BIND |
Refused with 0x07. |
0x03 |
CMD_UDP_ASSOCIATE |
Request::UdpAssociate, or refused with 0x07 when UDP is disabled. |
0xF0 |
CMD_TOR_RESOLVE |
Handled as Request::Connect to the named destination. |
0xF1 |
CMD_TOR_RESOLVE_PTR |
Handled as Request::Connect to the named destination. |
| other | Refused with 0x07. |
UDP relay packet
Section titled “UDP relay packet”| Field | Size | Meaning |
|---|---|---|
RSV |
2 | 0x0000. |
FRAG |
1 | Fragment number. Anything other than 0 is rejected (discarding fragmented payload). |
ATYP |
1 | Address type. |
DST.ADDR |
variable | Client to hub: where the payload goes. Hub to client: where the reply came from. |
DST.PORT |
2 | Port, with the same two meanings. |
DATA |
rest | The payload. |
parse_udp_packet rejects a packet shorter than 5 bytes (insufficient length of packet) and a packet whose address runs past its end.
Data flow
Section titled “Data flow”The handshake
Section titled “The handshake”handshake_with_udp_source, and so handshake, reads two bytes and branches on the first one:
flowchart TB head["read 2 bytes"] head -->|"0x05, NMETHODS"| methods["read METHODS"] head -->|"0x04, CD"| v4["handshake4"] head -->|"other"| badver["error: unknown SOCKS version"] methods -->|"configured method offered"| sel["reply 0x05, method"] methods -->|"not offered"| nomatch["reply 0x05 0xFF, error"] sel -->|"Password"| creds["RFC 1929 round"] sel -->|"None"| req["read VER CMD RSV"] creds -->|"match"| req creds -->|"no match"| authfail["reply 0x01 0xFF, error"] req -->|"CONNECT or Tor resolve"| dst["read DST, Request::Connect"] req -->|"UDP ASSOCIATE, UDP enabled"| udp["read DST, return it as the declared source"] req -->|"BIND, unknown, or UDP disabled"| refuse["reply 0x07, error"]
The SOCKS4 path in handshake4 works like this:
- If
authisPassword, reply91and fail (SOCKS4 not allowed when auth is required). SOCKS4 has no password, so a password-protected inbound refuses every SOCKS4 client. - Read
DSTPORT,DSTIPandUSERID. When the first octet ofDSTIPis0, read the SOCKS4a domain. - If
CDis notCONNECT, reply91and fail (unsupported SOCKS4 command …). The whole request is read before this check.
Every refusal is written before the error returns. A granted request is not answered yet: the driver decides when to answer.
CONNECT
Section titled “CONNECT”sequenceDiagram
participant C as Client
participant S as SocksInbound::serve
participant K as Connector
participant U as Upstream
C->>S: greeting, auth, request CONNECT
alt sniffing on and the target is an IP
S->>C: success reply
C->>S: first bytes, up to 4 KiB or 300 ms
S->>K: connect(Flow with sniffed)
K->>U: dial
S->>U: the collected prefix
else otherwise
S->>K: connect(Flow)
K->>U: dial
S->>C: success reply, or refusal on error
end
loop until both halves end or the idle guard fires
C->>U: bytes via BidirectionalConnection
U->>C: bytes via BidirectionalConnection
end
SocksInbound::connect answers after the connect, so the client learns whether the destination was reachable. The failure reply comes from refusal_code: io::ErrorKind::ConnectionRefused becomes 0x05 (STATUS_CONNECTION_REFUSED) and every other error becomes 0x04 (STATUS_HOST_UNREACHABLE). SOCKS4 always gets 91.
The sniff exception exists because a SOCKS client sends no payload before it gets the reply. When the inbound sniffs (sniff is true) and worth_sniffing(&flow.destination) holds, which means the destination is a bare IP, the driver:
- writes the success reply first;
- collects the client’s first bytes in
collect_prefix: aCollectorreads until a sniffer recognises TLS or HTTP (Verdict::Found),SNIFF_LIMIT(4 KiB) is filled,SNIFF_TIMEOUT(300 ms) passes or the read fails; - puts the result in
Flow::sniffedand connects; - writes the collected prefix to the upstream with
write_all, then relays.
If the connect fails after a prefix was collected, the client already holds a success reply. The driver then returns the error without a refusal, and the client sees the connection close. A destination that names a domain is never sniffed, because it already routes by that name.
If the connector answers a CONNECT with Outbound::Datagram, the driver fails with socks: a CONNECT was answered with a datagram link.
The relay
Section titled “The relay”async fn relay_with_idle_guard<A, B>(a: A, b: B) -> io::Result<Relayed>where A: AsyncRead + AsyncWrite + Unpin, B: AsyncRead + AsyncWrite + Unpin,relay_with_idle_guard wraps concepts/src/relay.rs → BidirectionalConnection:
pub struct BidirectionalConnection<A, B, const BUF_SIZE: usize = 8192> { /* a, b, a_to_b, b_to_a */ }
impl<A, B, const BUF_SIZE: usize> BidirectionalConnection<A, B, BUF_SIZE> { pub fn new(a: A, b: B) -> Self pub fn relayed(&self) -> Relayed}
pub struct Relayed { pub a_to_b: u64, pub b_to_a: u64,}The driver instantiates it with RELAY_BUF (16 KiB), which gives one boxed 16 KiB buffer per direction. BidirectionalConnection is one hand-written future: there is no task per direction, no channel and no allocation after construction. When a reader reaches end of stream, its half drains the buffer and calls poll_shutdown on the other side’s writer, so half-close propagates. The future resolves when both halves are done. A reader error, a writer error or a zero-length write (WriteZero) ends it at once.
The idle guard runs select! between the relay and a RELAY_IDLE_TIMEOUT (300 s) sleep. On each tick it compares relayed() with the previous sample. If neither counter moved, it returns TimedOut with socks: relay idle. Sampling happens once per window, so an idle connection ends between 300 and 600 seconds after its last byte, depending on where the idle period starts inside the window.
UDP ASSOCIATE
Section titled “UDP ASSOCIATE”sequenceDiagram
participant C as Client control
participant D as Client UDP
participant S as SocksInbound::associate
participant H as Hub socket
participant L as Connector link
C->>S: request UDP ASSOCIATE, DST kept as the declared source
break ExpectedSender::new fails
S->>C: reply 0x02, then close
end
S->>H: bind at bind_ip, port 0
S->>C: reply 0x00, BND is bind_ip and hub port
D->>H: RSV FRAG ATYP DST DATA
H->>S: recv_from
S->>S: ExpectedSender::admits, parse header
S->>S: ExpectedSender::pin
opt no link yet
S->>L: connect(Flow for this destination)
end
S->>L: poll_send_to(payload, dest)
L->>S: poll_recv_from gives payload and source
S->>H: header naming the source, then payload
H->>D: send_to the pinned client
Note over C,S: ends when the control stream closes or errors, the link fails, or 300 s pass idle
SocksInbound::associate runs these steps:
- Choose the hub address.
bind_ipisudp_bind, or elselocal_ip. Over a Unix socket there is nolocal_ip, so withoutudp_bindthe driver replies0x07and fails withUDP associate over a unix socket needs udp_bind. The app refuses that configuration at build time withinbound <tag>: socks over a unix socket has no local IP for UDP associate; set udp_bind or udp = false, so this path is a backstop. - Decide whom to hear.
ExpectedSender::new(source, declared, bind_ip)applies the rules in Whom an association hears. On an error the driver writeswrite_refusal(…, 0x02)(STATUS_NOT_ALLOWED) and returnsPermissionDeniedwith the error’s message, before any hub socket is bound. - Bind the hub. The driver binds a fresh
UdpSocketatbind_ipwith port 0, so each association gets its own ephemeral port. If the bind fails, the error returns before any reply is written. The success reply namesbind_ipand that port. - Relay. One
tokio::select!loop in the same future serves four arms:
| Arm | Action |
|---|---|
hub.recv_from into the up buffer |
Drop the datagram when !sender.admits(from), when parse_udp_packet fails or when the payload is empty. Otherwise call sender.pin(from), create the link on first use, send the payload with poll_send_to and reset the idle timer. A send error is logged at debug and the packet is dropped. A recv_from error ends the association with that error. |
recv_reply into the down buffer (only once a link exists) |
Wrap the payload with encode_udp_packet_into, naming the address it came from, and send_to(sender.client()). A send error is ignored. The idle timer is reset. A link error is logged at debug (socks: association link ended: …) and ends the association without an error. |
stream.read into a 256-byte sink |
Bytes the client sends on the control stream are discarded. End of stream or an error ends the association. |
idle |
RELAY_IDLE_TIMEOUT (300 s) without a forwarded datagram in either direction ends the association. |
Only a datagram worth forwarding, one that parses and carries a payload, pins the client, so a neighbour on the client’s IP cannot claim the association by sending junk first.
The link is created once, for the destination of the first forwarded datagram: Flow::new(dest, user, source) with a UDP destination. Later datagrams go through the same link with their own dest in poll_send_to, so the link has to route per packet. In etemenanki-app it does: app/src/connector.rs → AppConnector never dials a UDP flow and returns a FanOutLink that routes packet by packet (see Serving). If the connector returns an error for that first flow, the association ends with the error. If it answers with Outbound::Stream, the association fails with socks: an association was answered with a stream.
There are no queues in the loop. A pending connect, poll_send_to or send_to suspends the whole select, so the hub is not read until the send completes. The kernel’s socket buffer is the only buffer, and the kernel drops excess datagrams.
An association that ends because the control stream closed, the link failed or the idle timer fired returns Ok(()). Dropping the future drops the hub socket and the link with it.
Whom an association hears
Section titled “Whom an association hears”ExpectedSender::new fixes the IP and, when it can, the port before the hub is bound. The peer and the named IP are both compared after to_canonical, so ::ffff:127.0.0.1 names 127.0.0.1. declared counts as naming an address only when it is an IP that is not unspecified. A domain, 0.0.0.0, ::ffff:0.0.0.0 or :: names nothing.
| Control connection | The request names | Heard IP | Port |
|---|---|---|---|
| TCP peer P | P with port N ≠ 0 | P | N from the start |
| TCP peer P | P with port 0 | P | pinned by the first forwarded datagram |
| TCP peer P | anything else: another IP, an unspecified address or a domain | P; the named source is set aside | pinned by the first forwarded datagram |
| Unix socket (no peer) | an IP A that is not unspecified, with port N ≠ 0 | A | N from the start |
| Unix socket (no peer) | anything else | refused: socks: UDP associate over a unix socket must name its source address and port |
A request that names a source other than the peer is set aside rather than refused, because it is not where the datagrams come from: a client behind NAT names its LAN address, sing-box names a loopback address whenever its first target is private, and PySocks names only a port. Naming another address never lets that address in.
new then calls hears(hub, ip). An IPv4 or IPv4-mapped hub hears only IPv4, :: hears both families, and any other IPv6 hub hears only IPv6. If the hub cannot hear ip, the request is refused with socks: UDP associate from an address family the relay is not bound in, instead of an association that would never receive a datagram.
admits(from) holds when the canonical IP of from equals ip, its port equals the request-named port if there is one, and, once a client is pinned, endpoint(from) == endpoint(client). pin uses get_or_insert, so the first pin holds for the life of the association. Replies go to the pinned address in the form the hub saw it, which can be IPv4-mapped on a dual-stack hub.
Client side: SocksConnect
Section titled “Client side: SocksConnect”SocksConnect is a client codec that the client runtime drives (see Client runtime). In etemenanki-app, app/src/outbound/mod.rs → SocksOutbound builds one per flow inside a ProxyClient with SOCKS_BUF (16 KiB) over the outbound’s transport. A SOCKS outbound can therefore run over TLS, WebSocket or gRPC, while the inbound cannot.
stateDiagram-v2 [*] --> Method: start stages 05 01 method Method --> UserPass: method 0x02, stage credentials Method --> Request: method 0x00, stage CONNECT UserPass --> Request: status 0x00, stage CONNECT Request --> Done: REP 0x00 Done --> [*]
| Stage | Parses | Fails with |
|---|---|---|
Method |
parse_method_reply: 2 bytes, VER must be 0x05 |
unexpected server version (InvalidData); auth method not supported (PermissionDenied) when the selected method is not the one offered |
UserPass |
parse_userpass_reply: 2 bytes |
server rejects account (PermissionDenied) |
Request |
parse_reply: VER REP RSV plus an address |
server rejects request: N (ConnectionRefused), where N is the reply code |
Done |
nothing | socks: handshake already done |
The client offers exactly one method: 0x02 when it has credentials, 0x00 otherwise (encode_method_request). Every parser returns Reply::NeedMore until its whole message has arrived. Each Reply::Step reports the bytes it consumed, so bytes after the final reply are the first data from the destination. After the handshake, seal copies plaintext into staging and open hands back every wire byte as one frame. STAGING_RESERVE is 528 bytes, enough for the largest credential message (513 bytes) or a request with a 259-byte address. If the staging area has no room for a message, the codec fails with socks: staging room below the declared reserve.
Client side: SocksUdpLink
Section titled “Client side: SocksUdpLink”SocksUdpLink::associate runs the same rounds directly on the control stream with the async helper round, which reads 512-byte chunks until the parser returns a value. It fails with socks: server closed during the handshake if the stream ends first. The request declares 0.0.0.0:0 (encode_request(CMD_UDP_ASSOCIATE, None)), as RFC 1928 has a client do when it does not know its source: the local socket is bound only after the relay address is known, and a local address would be the wrong one behind NAT anyway. A server that checks sources, as SocksInbound does, holds the association to the control connection’s address and the port of the first datagram. A non-zero reply code fails with server rejects request: N (ConnectionRefused). The reply’s bound address must be an IP; a domain fails with socks: the relay address is a domain. The bind closure receives the relay address so it can choose the family. SocksOutbound::connect_datagram binds the unspecified address of the relay’s family.
| Method | Behaviour |
|---|---|
poll_send_to |
Calls poll_control, wraps the payload in the relay header in the reused scratch buffer and sends it to the relay. |
poll_recv_from |
Calls poll_control, receives into the boxed 64 KiB recv buffer (RECV_BUF), drops datagrams unless endpoint(from) == endpoint(self.relay), drops datagrams that do not parse, and copies the payload into buf. A payload larger than buf is truncated. Returns the source address from the header. |
poll_control |
Drains the control stream into a 256-byte sink. A read error is returned as is, once. After that, and after end of stream, every call returns BrokenPipe with socks: the control connection closed. |
Because the comparison goes through endpoint, a dual-stack client socket, which reports an IPv4 relay’s replies as coming from its IPv4-mapped address, still hears that relay.
The control stream lives inside the link, so dropping the link closes it and ends the association on the server.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
A granted CONNECT is answered only after the connect, except on the sniff path. |
SocksInbound::connect writes write_granted after connector.connect when prefix is empty. |
new_server_refuses_an_unreachable_target_after_trying in protocols/tests/pipeline/socks.rs |
| Every refusal is on the wire before the error returns. | Each refusal branch in handshake5, handshake4 and associate awaits write_refusal, write_socks4_response or the method or status bytes before return Err. |
The 0x02 refusals in associate: udp_association_over_a_unix_socket_needs_its_exact_source, udp_association_refuses_a_relay_that_cannot_hear_the_client |
| The whole handshake has one deadline. | serve wraps handshake_with_udp_source in tokio::time::timeout(HANDSHAKE_TIMEOUT, …). |
|
| An association relays for one client, the control connection’s IP (or, over a Unix socket, the exact source the request named), pinned to one port, and replies go only to it. | ExpectedSender: admits checks each sender, and the first forwarded datagram’s pin holds. |
udp_association_ignores_another_ip, udp_association_ignores_another_port_once_pinned, udp_association_holds_to_the_port_the_request_names, udp_association_is_not_widened_by_the_request in protocols/tests/pipeline/socks.rs; the unit tests in protocols/tests/unit/socks/server.rs |
| An association the relay could never hear is refused before the hub is bound. | ExpectedSender::new and hears, answered with 0x02. |
a_relay_that_cannot_hear_the_client_is_refused in protocols/tests/unit/socks/server.rs; udp_association_refuses_a_relay_that_cannot_hear_the_client in protocols/tests/pipeline/socks.rs |
| The client link accepts replies only from the relay the server named. | SocksUdpLink::poll_recv_from compares endpoint(from) with endpoint(self.relay). |
udp_link_ignores_datagrams_not_from_the_relay, udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay in protocols/tests/pipeline/socks.rs |
| An association lives no longer than its control stream. | The server’s stream.read arm; the client’s poll_control. |
|
| Fragmented relay packets are never forwarded. | parse_udp_packet rejects FRAG ≠ 0, and both relays drop what it rejects. |
|
| No per-connection task, channel or queue. | serve is one future: BidirectionalConnection for CONNECT, one select! loop for an association. |
bidirectional_relays_both_ways_and_half_closes in concepts/src/relay.rs |
Failure paths and cancellation
Section titled “Failure paths and cancellation”Failure replies
Section titled “Failure replies”| Situation | SOCKS5 reply | SOCKS4 reply | Error returned |
|---|---|---|---|
First byte is neither 0x04 nor 0x05 |
none | none | InvalidData: unknown SOCKS version: N |
Handshake not complete within HANDSHAKE_TIMEOUT (10 s) |
none | none | TimedOut: client did not complete its request in time |
| Configured method not offered | method 0xFF |
PermissionDenied: no matching auth method |
|
| Unknown user or wrong password | status 0xFF |
PermissionDenied: invalid username or password |
|
| SOCKS4 on a password inbound | 91 |
PermissionDenied: SOCKS4 not allowed when auth is required |
|
SOCKS4 command other than CONNECT |
91 |
Unsupported: unsupported SOCKS4 command N |
|
BIND |
0x07 |
Unsupported: TCP bind is not supported |
|
| Unknown command | 0x07 |
InvalidData: unknown command Some(N) |
|
UDP ASSOCIATE with UDP disabled |
0x07 |
Unsupported: UDP not enabled |
|
UDP ASSOCIATE over a Unix socket without udp_bind |
0x07 |
Unsupported: UDP associate over a unix socket needs udp_bind |
|
UDP ASSOCIATE over a Unix socket without an exact source (no address, an unspecified address, a domain or port 0) |
0x02 |
PermissionDenied: socks: UDP associate over a unix socket must name its source address and port |
|
UDP ASSOCIATE from an address family the hub does not hear |
0x02 |
PermissionDenied: socks: UDP associate from an address family the relay is not bound in |
|
| The hub socket cannot be bound | none | the bind error | |
| Malformed or truncated address, or the stream ends mid-handshake | none | none | the read error |
Connect refused (ConnectionRefused) |
0x05 |
91 |
the connector’s error |
| Any other connect error | 0x04 |
91 |
the connector’s error |
CONNECT answered with a datagram link |
none | none | Unsupported: socks: a CONNECT was answered with a datagram link |
| Association answered with a stream | none | Unsupported: socks: an association was answered with a stream |
|
CONNECT relay idle |
none, already granted | none, already granted | TimedOut: socks: relay idle |
serve returns every error to its caller. The app logs it at debug as socks connection from Some(<ip>) ended: <error> (None over a Unix socket); the SOCKS module does not log handshake failures itself. Inside an association, per-packet problems (a dropped datagram, a failed send) are logged at debug or ignored and do not end the association.
Cancellation
Section titled “Cancellation”serve spawns nothing. Everything it owns lives in its own future: the control stream, the upstream stream, the hub socket, the datagram link and the buffers. Dropping the future, for example when a generation shuts down, closes all of them together. No task is left behind to clean up.
Limits
Section titled “Limits”| Constant | Value | Where | Governs |
|---|---|---|---|
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
The whole server handshake, from the first byte to the parsed request. |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
CONNECT idle guard window; association idle timer. |
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
How long collect_prefix waits for the first bytes. |
SNIFF_LIMIT |
4 KiB | protocols/src/sniff/mod.rs |
How many bytes collect_prefix collects at most. |
RELAY_BUF |
16 KiB | protocols/src/socks/server.rs |
CONNECT relay buffer per direction. |
HUB_BUF |
64 KiB | protocols/src/socks/server.rs |
Each of the two association buffers (up, down). |
RECV_BUF |
64 KiB | protocols/src/socks/udp_link.rs |
The client link’s receive buffer. |
STAGING_RESERVE |
528 bytes | protocols/src/socks/codec.rs |
Staging room one SocksConnect call may need. |
AddressCodec::MAX_LEN |
259 bytes | protocols/src/helpers/address.rs |
Longest encoded SOCKS address with port. |
read_until_null cap |
512 bytes | protocols/src/socks/protocol.rs |
SOCKS4 USERID and SOCKS4a domain. |
SOCKS_BUF |
16 KiB | app/src/outbound/mod.rs |
The app’s client runtime buffer for a SOCKS outbound. |
The reusable association reply buffer and the client’s scratch buffer start at 2048 bytes and grow as needed. Both control-stream sinks are 256 bytes.
Run the module’s unit tests and pipeline tests from the Etemenanki workspace:
cargo test -p etemenanki-protocols --lib sockscargo test -p etemenanki-protocols --test pipeline sockscargo test -p etemenanki-app --test integration e2e_udp_route| File | Tests | Covers |
|---|---|---|
protocols/tests/unit/socks/protocol.rs |
udp_encoding_roundtrip, read_username_password_ok, read_username_password_err, read_until_null_ok, read_until_null_err |
Wire primitives, ported from Xray-core’s SOCKS tests. |
client_handshake_messages_round_trip_through_slices |
Every client builder and parser, including NeedMore on short input and the all-zero UDP ASSOCIATE request. |
|
endpoint_sees_through_ipv4_mapping_and_ignores_flow_info |
endpoint equates an IPv4-mapped address with the IPv4 one, ignores IPv6 flow info, and still tells ports, IPs and families apart. |
|
protocols/tests/unit/socks/server.rs (mounted from server.rs) |
only_the_control_peer_is_heard_and_its_first_datagram_pins_the_port |
Another IP is never admitted, any port of the peer is admitted until a pin, and the first pin holds. |
an_ipv4_mapped_address_is_the_ipv4_one |
A mapped peer or sender matches its IPv4 form, and the pinned client keeps the form the hub saw. | |
a_request_naming_the_peer_pins_its_port_up_front |
Naming the peer with a port admits only that port; naming it with port 0 pins nothing. |
|
a_request_naming_any_other_source_is_set_aside |
The IPv6 loopback, a LAN address, an unspecified address with a port, another host and a domain all leave only the peer admitted. | |
over_a_unix_socket_the_request_must_name_the_exact_source |
Without a peer, anything short of a specified IP and a non-zero port is refused, and an exact source admits only itself. | |
a_relay_that_cannot_hear_the_client_is_refused |
hears for IPv4, IPv6, ::, 0.0.0.0 and IPv4-mapped hubs, over TCP and over a Unix socket. |
|
protocols/tests/unit/socks/codec.rs |
anonymous_connect_takes_two_rounds |
SocksConnect without credentials: bytes staged, NeedMore, consumed counts, data after the reply. |
credentials_add_a_round_and_a_refusal_is_an_error |
The credential round, a refused request, a refused account and a 0xFF method reply. |
|
protocols/tests/pipeline/socks.rs |
new_server_vs_new_client_tcp |
SocksInbound against SocksConnect with credentials, 100 000 bytes echoed and a clean half-close. Sniffing is on and the destination is an IP, so this runs the sniff path’s early reply with a collected prefix. |
new_server_refuses_an_unreachable_target_after_trying |
With sniffing off, a closed port produces a refusal that the client sees as server rejects request. |
|
new_server_vs_new_client_udp |
SocksInbound against SocksUdpLink, including a 1400-byte payload. |
|
udp_association_ignores_another_ip |
A datagram from 127.0.0.2, sent before the client’s first and again later, is never forwarded or answered. |
|
udp_association_ignores_another_port_once_pinned |
After the first datagram, another socket on the client’s IP is not forwarded. | |
udp_association_holds_to_the_port_the_request_names |
A request naming the client’s address and port shuts out a neighbour on the same IP that sends first. | |
udp_association_sets_aside_a_source_it_cannot_hold_to |
A request naming [::1]:0 from an IPv4 client is granted and relays for the client. |
|
udp_association_is_not_widened_by_the_request |
A request naming another host does not let that host in. | |
udp_association_over_a_unix_socket_needs_its_exact_source |
Over a Unix socket, 0.0.0.0:0 is refused with 0x02 and the connection closes; an exact source is granted and a neighbour is not heard. |
|
udp_association_refuses_a_relay_that_cannot_hear_the_client |
An IPv6 udp_bind with an IPv4 client is refused with 0x02 and the connection closes. |
|
udp_link_ignores_datagrams_not_from_the_relay |
Against a hand-written server, a well-formed datagram from another socket is not taken as the reply. | |
udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay |
A link bound to :: hears an IPv4 relay whose replies arrive IPv4-mapped. |
|
concepts/src/relay.rs |
bidirectional_relays_both_ways_and_half_closes |
BidirectionalConnection with a buffer smaller than the payload. |
app/tests/unit/inbound.rs |
socks_udp_over_a_unix_socket_needs_udp_bind |
The build-time refusal of UDP on a Unix socket without udp_bind. |
app/tests/integration/e2e_udp_route.rs |
one_association_routes_each_peer_separately, replies_from_several_peers_merge_back_correctly |
One association routed per packet by the app’s FanOutLink, with replies attributed to the right peer. |
app/tests/integration/e2e_unix.rs |
socks_over_a_unix_socket_relays_and_cleans_up |
CONNECT over a Unix socket through the real binary. |
udp_association_ignores_another_ip, udp_association_is_not_widened_by_the_request and udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay are #[cfg(target_os = "linux")], because they rely on 127.0.0.2 being a loopback address or on a dual-stack socket. udp_association_over_a_unix_socket_needs_its_exact_source is #[cfg(unix)], and e2e_unix.rs is compiled only on Unix (#![cfg(unix)]). Many other app integration tests use a SOCKS inbound as their entry point, so they exercise it indirectly.
No test pins the handshake deadline, the relay idle guard, the refusal replies written by the handshake, the sniff path with nothing collected, or the reply code a connect error maps to (new_server_refuses_an_unreachable_target_after_trying checks only that a refusal arrives). A change to any of these needs a new test.