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

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
client/dial-quic
client/dial-tcp-tls
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:

  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

// 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 -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
#[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
#[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_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