Skip to content

SOCKS

Source files: 28 · checked against Etemenanki 596916d
  • Etemenanki/protocols/src/socks/mod.rs
  • Etemenanki/protocols/src/socks/protocol.rs
  • Etemenanki/protocols/src/socks/handshake.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/socks/codec.rs
  • Etemenanki/protocols/src/socks/udp_link.rs
  • Etemenanki/protocols/src/socks/config.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/sniff/collector.rs
  • Etemenanki/concepts/src/relay.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/codec.rs
  • Etemenanki/app/tests/unit/inbound.rs
  • Etemenanki/app/tests/integration/e2e_udp_route.rs
  • Etemenanki/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.

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.

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".

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 ().

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
}
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.

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]) -> Bytes
pub 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.

protocols/src/socks/protocol.rs
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) -> bool

ExpectedSender 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.

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
}

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.

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.

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.

Field Size Meaning
VER 1 0x05.
NMETHODS 1 Number of method bytes that follow.
METHODS NMETHODS Offered methods.

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.

Field Size Meaning
VER 1 Sub-negotiation version, 0x01.
ULEN 1 Username length.
UNAME ULEN Username.
PLEN 1 Password length.
PASSWD PLEN Password.

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.

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.
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.
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.

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:

  1. If auth is Password, reply 91 and fail (SOCKS4 not allowed when auth is required). SOCKS4 has no password, so a password-protected inbound refuses every SOCKS4 client.
  2. Read DSTPORT, DSTIP and USERID. When the first octet of DSTIP is 0, read the SOCKS4a domain.
  3. If CD is not CONNECT, reply 91 and 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.

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:

  1. writes the success reply first;
  2. collects the client’s first bytes in collect_prefix: a Collector reads until a sniffer recognises TLS or HTTP (Verdict::Found), SNIFF_LIMIT (4 KiB) is filled, SNIFF_TIMEOUT (300 ms) passes or the read fails;
  3. puts the result in Flow::sniffed and connects;
  4. 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.

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.

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:

  1. Choose the hub address. bind_ip is udp_bind, or else local_ip. Over a Unix socket there is no local_ip, so without udp_bind the driver replies 0x07 and fails with UDP associate over a unix socket needs udp_bind. The app refuses that configuration at build time with inbound <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.
  2. Decide whom to hear. ExpectedSender::new(source, declared, bind_ip) applies the rules in Whom an association hears. On an error the driver writes write_refusal(…, 0x02) (STATUS_NOT_ALLOWED) and returns PermissionDenied with the error’s message, before any hub socket is bound.
  3. Bind the hub. The driver binds a fresh UdpSocket at bind_ip with 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 names bind_ip and that port.
  4. 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.

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.

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.

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.

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
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.

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.

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:

Terminal window
cargo test -p etemenanki-protocols --lib socks
cargo test -p etemenanki-protocols --test pipeline socks
cargo 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.