Phase 3 complete: alknet-client crate implemented with: - AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy) - ClientDialError enum with 5 variants - dial_quic, dial_tcp_tls, dial_iroh methods - SOCKS5 proxy support (Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket) - Unit tests (18) + integration test (3) - All feature combos compile and test clean - Workspace build and tests pass
13 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| client/socks5-proxy | Implement SOCKS5 proxy support — Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket, and proxy integration in dial_quic/dial_tcp_tls | completed |
|
broad | high | component | implementation |
Description
Phase 3, Task 7. Implement the SOCKS5 proxy support for AlknetClient per ADR-090.
This is the privacy posture: when a proxy is configured via with_socks5_proxy, the
rustls dials route their transport through the proxy — the hub sees the proxy's IP,
not the client's.
This task has four parts:
Socks5ProxyConfigandSocks5Credentialstypes insrc/socks5.rsSocks5UdpSocket— aquinn::AsyncUdpSocketimpl that tunnels QUIC datagrams through SOCKS5 UDP ASSOCIATE- Proxy integration in
dial_quic(UDP ASSOCIATE path) - Proxy integration in
dial_tcp_tls(CONNECT path)
The iroh proxy path (force relay-only + HTTP-to-SOCKS5 bridge) is deferred — it's applied at endpoint construction time by the assembly layer, not at dial time (ADR-090 §5).
Part 1: Socks5ProxyConfig and Socks5Credentials
// src/socks5.rs
/// Configuration for a SOCKS5 proxy (ADR-090).
///
/// When set on `AlknetClient` via `with_socks5_proxy`, all rustls dials
/// route their transport through this proxy: UDP ASSOCIATE for `dial_quic`,
/// CONNECT for `dial_tcp_tls`. The proxy config comes from `Capabilities` /
/// the assembly layer (ADR-014), never from environment variables.
#[derive(Debug, Clone)]
pub struct Socks5ProxyConfig {
/// The proxy's TCP address (where the SOCKS5 control connection
/// connects). For UDP ASSOCIATE (the QUIC dial), the proxy replies
/// with a UDP relay address that may differ; the dial uses that.
pub addr: SocketAddr,
/// Optional username/password auth (RFC 1929). None = no-auth.
pub credentials: Option<Socks5Credentials>,
}
/// SOCKS5 username/password credentials (RFC 1929).
#[derive(Debug, Clone)]
pub struct Socks5Credentials {
pub username: String,
pub password: String,
}
Part 2: Socks5UdpSocket — QUIC over SOCKS5 UDP ASSOCIATE
The central technical piece: a quinn::AsyncUdpSocket implementation that tunnels
QUIC datagrams through a SOCKS5 UDP ASSOCIATE tunnel. This is the integration glue
between quinn's socket abstraction and the SOCKS5 proxy.
The implementation (~250 lines) follows the pattern validated by the quinn-proxy PoC
(docs/research/quinn-quic-proxy/findings.md):
#[cfg(feature = "socks5")]
struct Socks5UdpSocket {
// The UDP socket to the proxy's relay address
socket: tokio::net::UdpSocket,
// The proxy's UDP relay address (where datagrams are sent)
relay_addr: SocketAddr,
// Keep the TCP control connection alive (dropping it tears down the UDP association)
_control: tokio::net::TcpStream,
}
#[cfg(feature = "socks5")]
impl Socks5UdpSocket {
/// Perform the SOCKS5 UDP ASSOCIATE handshake and return a socket
/// that tunnels QUIC datagrams through the proxy.
async fn bind(proxy: &Socks5ProxyConfig) -> Result<Self, ClientDialError> {
// 1. TCP control connection to proxy.addr
// 2. SOCKS5 handshake (no-auth or username/password per RFC 1929)
// 3. UDP ASSOCIATE request (CMD = 0x03)
// 4. Receive the proxy's UDP relay address
// 5. Bind a local UDP socket
// 6. Return Socks5UdpSocket with the relay address
}
}
#[cfg(feature = "socks5")]
impl quinn::AsyncUdpSocket for Socks5UdpSocket {
fn poll_send(
&self,
state: &quinn::udp::UdpState,
cx: &mut std::task::Context,
transmits: &[quinn::udp::Transmit],
) -> std::task::Poll<Result<usize, io::Error>> {
// For each transmit:
// 1. Prepend the SOCKS5 UDP header (RSV + FRAG + ATYP + DST.ADDR + DST.PORT)
// 2. Send the wrapped datagram to the proxy's relay address
}
fn poll_recv(
&self,
cx: &mut std::task::Context,
bufs: &[std::io::IoSliceMut],
meta: &[quinn::udp::RecvMeta],
) -> std::task::Poll<io::Result<usize>> {
// 1. Receive a datagram from the proxy
// 2. Strip the SOCKS5 UDP header
// 3. Fill in RecvMeta (addr, len, stride)
}
fn local_addr(&self) -> io::Result<SocketAddr> {
self.socket.local_addr()
}
fn may_fragment(&self) -> bool {
false // SOCKS5 UDP has no fragmentation support
}
}
Key limitations (accepted, documented):
- ECN is lost. The SOCKS5 UDP header carries no ECN bits, so the proxied QUIC path falls back to non-ECN congestion control. A performance cost on congested links, not a correctness issue.
may_fragment() == false. SOCKS5 UDP has no fragmentation mechanism.- The proxy must support UDP ASSOCIATE.
ssh -Ddoes not work; a UDP-capable SOCKS5 daemon is needed. The dial surfaces a clear error when the proxy lacks UDP support.
Part 3: Proxy integration in dial_quic
When self.socks5 is Some, dial_quic does NOT use the pre-built quinn endpoint.
Instead, it:
- Builds the
TlsClientConfigandquinn::ClientConfigas usual - Creates a
Socks5UdpSocketviaSocks5UdpSocket::bind(&proxy) - Builds a temporary quinn endpoint via
quinn::Endpoint::new_with_abstract_socket - Connects through that endpoint
#[cfg(feature = "quinn")]
pub async fn dial_quic(...) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let client_config = tls_config.for_quinn()?;
let conn = if let Some(proxy) = &self.socks5 {
#[cfg(feature = "socks5")]
{
let socket = Socks5UdpSocket::bind(proxy).await?;
let mut endpoint = quinn::Endpoint::new_with_abstract_socket(
quinn::EndpointConfig::default(),
Some(socket.local_addr()?),
socket,
Arc::new(quinn::TokioRuntime),
).map_err(|e| ClientDialError::Connect(e.to_string()))?;
endpoint
.connect_with(client_config, addr, server_name)
.map_err(|e| ClientDialError::Connect(e.to_string()))?
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?
}
#[cfg(not(feature = "socks5"))]
unreachable!()
} else {
// Direct path (implemented in client/dial-quic)
let endpoint = self.quinn.as_ref()
.ok_or(ClientDialError::NoTransport { transport: "quinn" })?;
endpoint
.connect_with(client_config, addr, server_name)
.map_err(|e| ClientDialError::Connect(e.to_string()))?
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?
};
Ok(Connection::from_quinn_with_alpn(conn, alpn.to_vec()))
}
Part 4: Proxy integration in dial_tcp_tls
When self.socks5 is Some, dial_tcp_tls:
- Connects a
TcpStreamto the proxy's address - Performs the SOCKS5 CONNECT handshake (RFC 1928 §3) to the target
addr - Wraps the resulting stream in
TlsConnectoras before
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(...) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let connector = /* build or use pre-built TlsConnector */;
let tls_stream = if let Some(proxy) = &self.socks5 {
#[cfg(feature = "socks5")]
{
// 1. Connect to proxy
let mut tcp = TcpStream::connect(proxy.addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
// 2. SOCKS5 CONNECT handshake to target addr
socks5_connect(&mut tcp, proxy, addr)
.await
.map_err(|e| ClientDialError::Proxy(e))?;
// 3. TLS over proxied stream
let server_name = rustls::pki_types::ServerName::try_from(host)
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
connector
.connect(server_name, tcp)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
}
#[cfg(not(feature = "socks5"))]
unreachable!()
} else {
// Direct path (implemented in client/dial-tcp-tls)
// ...
};
Ok(Connection::from_bidi(tls_stream, alpn.to_vec(), Some(addr)))
}
SOCKS5 CONNECT helper
#[cfg(feature = "socks5")]
async fn socks5_connect(
stream: &mut tokio::net::TcpStream,
proxy: &Socks5ProxyConfig,
target: SocketAddr,
) -> Result<(), String> {
// 1. Greeting: send version + auth methods
// 2. Auth: no-auth or username/password (RFC 1929)
// 3. CONNECT request: CMD=0x01, ATYP=0x01 (IPv4) or 0x03 (domain), DST.ADDR, DST.PORT
// 4. Read reply: version, REP (0x00 = success), ATYP, BND.ADDR, BND.PORT
// 5. If REP != 0x00, return error with the REP code
}
What this does NOT include
- iroh proxy path (force relay-only + HTTP-to-SOCKS5 bridge): Applied at endpoint
construction time by the assembly layer (ADR-090 §5), not at dial time. The iroh
endpoint is built with
clear_ip_transports()+addr_filter(relay_only)+proxy_urlby the assembly layer when a proxy is configured. The dial itself doesn't change. - No silent fallback: When a proxy is configured and the proxy rejects the
command, the dial returns
ClientDialError::Proxy. The dial does not silently fall back to a direct connection — that would defeat the privacy posture. - No HTTP CONNECT support: SOCKS5 only. SOCKS5 supports both TCP (CONNECT) and UDP (UDP ASSOCIATE) within one protocol. HTTP CONNECT is TCP-only with no UDP equivalent.
Dependency: fast-socks5
The fast-socks5 crate (v1.0.0) is used for the SOCKS5 client handshake. The
client-side surface used is small (Socks5Datagram::bind / bind_with_password,
new_udp_header, the header parsing). Whether to keep fast-socks5 as a dep or
vendor the ~100 lines of SOCKS5 handshake + header framing is a two-way-door
implementation detail — this task uses fast-socks5 as the starting choice
(validated by the PoC).
Acceptance Criteria
Socks5ProxyConfigstruct defined incrates/alknet-client/src/socks5.rswithaddrandcredentialsfieldsSocks5Credentialsstruct defined withusernameandpasswordfields- Both types derive
Debug,Clone - Both types feature-gated on
#[cfg(feature = "socks5")] Socks5UdpSocketimplementsquinn::AsyncUdpSocket(feature-gated onsocks5)Socks5UdpSocket::bind(proxy)performs SOCKS5 UDP ASSOCIATE handshakeSocks5UdpSocket::poll_sendprepends SOCKS5 UDP header to each datagramSocks5UdpSocket::poll_recvstrips SOCKS5 UDP header from received datagramsSocks5UdpSocket::may_fragment()returnsfalsedial_quicusesSocks5UdpSocket+new_with_abstract_socketwhen proxy is configureddial_quicreturnsProxyerror when UDP ASSOCIATE failsdial_tcp_tlsperforms SOCKS5 CONNECT handshake when proxy is configureddial_tcp_tlsreturnsProxyerror when CONNECT fails- No silent fallback to direct connection when proxy is configured
cargo check -p alknet-client --features quinn,socks5succeedscargo check -p alknet-client --features tcp,socks5succeedscargo clippy -p alknet-client --features quinn,tcp,socks5succeeds with no warningscargo build --workspacestill succeeds (old code untouched)
References
- docs/architecture/crates/client/README.md — SOCKS5 proxy section (lines 238-325)
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090 (full rationale, PoC grounding, limitations)
- docs/research/quinn-quic-proxy/findings.md — quinn-over-SOCKS5 PoC findings
- docs/research/iroh-proxy-poc/findings.md — iroh-proxy PoC findings (iroh path is deferred)
- crates/alknet-tls/src/client.rs —
TlsClientConfig(the TLS config the dial consumes) - crates/alknet-core/src/credentials.rs —
ConnectionCredentials(the credential bundle) - RFC 1928 (SOCKS5) §3 (CONNECT), §6/§7 (UDP ASSOCIATE + UDP request header)
- RFC 1929 (SOCKS5 username/password auth)
Notes
This is the most complex task in Phase 3 — the
Socks5UdpSocketis ~250 lines of integration glue between quinn'sAsyncUdpSockettrait and the SOCKS5 UDP ASSOCIATE protocol. The implementation is grounded in the quinn-proxy PoC (docs/research/quinn-quic-proxy/findings.md), which validated the approach end-to-end (5/5 runs clean). Thefast-socks5crate handles the SOCKS5 handshake; theSocks5UdpSocketis alknet's own code. The iroh proxy path (force relay-only + HTTP-to-SOCKS5 bridge) is deferred — it's applied at endpoint construction time by the assembly layer, not at dial time. Thesocks5feature andfast-socks5dep are opt-in; deployments that don't use a proxy pay nothing.
Summary
To be filled on completion