Files
alknet/tasks/client/socks5-proxy.md
T
deepseek-v4-pro 83a94ec03f chore(client): mark all client tasks as completed
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
2026-07-17 12:49:50 +00:00

312 lines
13 KiB
Markdown

---
id: client/socks5-proxy
name: Implement SOCKS5 proxy support — Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket, and proxy integration in dial_quic/dial_tcp_tls
status: completed
depends_on: [client/dial-quic, client/dial-tcp-tls]
scope: broad
risk: high
impact: component
level: 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:
1. `Socks5ProxyConfig` and `Socks5Credentials` types in `src/socks5.rs`
2. `Socks5UdpSocket` — a `quinn::AsyncUdpSocket` impl that tunnels QUIC datagrams through SOCKS5 UDP ASSOCIATE
3. Proxy integration in `dial_quic` (UDP ASSOCIATE path)
4. 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`
```rust
// 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`):
```rust
#[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 -D` does 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:
1. Builds the `TlsClientConfig` and `quinn::ClientConfig` as usual
2. Creates a `Socks5UdpSocket` via `Socks5UdpSocket::bind(&proxy)`
3. Builds a temporary quinn endpoint via `quinn::Endpoint::new_with_abstract_socket`
4. Connects through that endpoint
```rust
#[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`:
1. Connects a `TcpStream` to the proxy's address
2. Performs the SOCKS5 CONNECT handshake (RFC 1928 §3) to the target `addr`
3. Wraps the resulting stream in `TlsConnector` as before
```rust
#[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
```rust
#[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_url` by 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
- [ ] `Socks5ProxyConfig` struct defined in `crates/alknet-client/src/socks5.rs` with `addr` and `credentials` fields
- [ ] `Socks5Credentials` struct defined with `username` and `password` fields
- [ ] Both types derive `Debug`, `Clone`
- [ ] Both types feature-gated on `#[cfg(feature = "socks5")]`
- [ ] `Socks5UdpSocket` implements `quinn::AsyncUdpSocket` (feature-gated on `socks5`)
- [ ] `Socks5UdpSocket::bind(proxy)` performs SOCKS5 UDP ASSOCIATE handshake
- [ ] `Socks5UdpSocket::poll_send` prepends SOCKS5 UDP header to each datagram
- [ ] `Socks5UdpSocket::poll_recv` strips SOCKS5 UDP header from received datagrams
- [ ] `Socks5UdpSocket::may_fragment()` returns `false`
- [ ] `dial_quic` uses `Socks5UdpSocket` + `new_with_abstract_socket` when proxy is configured
- [ ] `dial_quic` returns `Proxy` error when UDP ASSOCIATE fails
- [ ] `dial_tcp_tls` performs SOCKS5 CONNECT handshake when proxy is configured
- [ ] `dial_tcp_tls` returns `Proxy` error when CONNECT fails
- [ ] No silent fallback to direct connection when proxy is configured
- [ ] `cargo check -p alknet-client --features quinn,socks5` succeeds
- [ ] `cargo check -p alknet-client --features tcp,socks5` succeeds
- [ ] `cargo clippy -p alknet-client --features quinn,tcp,socks5` succeeds with no warnings
- [ ] `cargo build --workspace` still 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 `Socks5UdpSocket` is ~250 lines of
> integration glue between quinn's `AsyncUdpSocket` trait 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). The `fast-socks5` crate handles the SOCKS5 handshake;
> the `Socks5UdpSocket` is 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. The `socks5` feature and `fast-socks5` dep
> are opt-in; deployments that don't use a proxy pay nothing.
## Summary
> To be filled on completion