feat(client): implement alknet-client crate — AlknetClient, dial methods, error type, SOCKS5 proxy

- Initialize alknet-client crate with Cargo.toml, deps, feature flags
- Implement ClientDialError enum with 5 variants (TlsConfig, Connect, Handshake, NoTransport, Proxy)
- Implement AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy)
- Implement dial_quic — QUIC dial via quinn, producing a Connection
- Implement dial_tcp_tls — TCP+TLS dial via tokio-rustls, producing a Connection
- Implement dial_iroh — iroh dial, producing a Connection
- Implement SOCKS5 proxy support — Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket, proxy integration
- Add into_rustls_config() to TlsClientConfig in alknet-tls
- Add alknet-client to workspace members

Tasks: client/crate-init, client/error-type, client/client-core,
client/dial-quic, client/dial-tcp-tls, client/dial-iroh, client/socks5-proxy
This commit is contained in:
deepseek-v4-pro committed 2026-07-17 12:42:47 +00:00
1 parent b476182d64
commit 82ee37e206
12 files changed
+1013 -8

No files matched your search

Generated
+51 -8
View File
@@ -68,6 +68,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "alknet-client"
version = "0.1.0"
dependencies = [
"alknet-core",
"alknet-tls",
"fast-socks5",
"hex",
"iroh",
"quinn",
"rustls",
"rustls-pki-types",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tracing",
]
[[package]]
name = "alknet-core"
version = "0.1.0"
@@ -1296,6 +1314,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fast-socks5"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9545787d8304a71e1bf1b711705070a4c400cce9b332c4a11800627b7c9a2067"
dependencies = [
"anyhow",
"async-trait",
"log",
"socket2 0.5.10",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
]
[[package]]
name = "fastbloom"
version = "0.14.1"
@@ -1834,7 +1867,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.4",
"tokio",
"tower-service",
"tracing",
@@ -2006,7 +2039,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"socket2 0.6.4",
"widestring",
"windows-registry",
"windows-result",
@@ -2595,7 +2628,7 @@ dependencies = [
"objc2-system-configuration",
"pin-project-lite",
"serde",
"socket2",
"socket2 0.6.4",
"time",
"tokio",
"tokio-util",
@@ -2642,7 +2675,7 @@ dependencies = [
"pin-project-lite",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@@ -2685,7 +2718,7 @@ checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"socket2",
"socket2 0.6.4",
"tracing",
"windows-sys 0.61.2",
]
@@ -3213,7 +3246,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -3253,7 +3286,7 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
"socket2 0.6.4",
"tracing",
"windows-sys 0.60.2",
]
@@ -4093,6 +4126,16 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.4"
@@ -4388,7 +4431,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.4",
"tokio-macros",
"windows-sys 0.61.2",
]
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/alknet-tls",
"crates/alknet-tty",
"crates/alknet-tty-local",
"crates/alknet-client",
]
resolver = "2"
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "alknet-client"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Native client dial seam — multi-transport dialer that produces Connections for protocol take-overs"
repository.workspace = true
[lib]
name = "alknet_client"
[features]
default = []
quinn = ["dep:quinn", "alknet-tls/quinn", "alknet-core/quinn"]
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"]
iroh = ["dep:iroh", "alknet-core/iroh"]
socks5 = ["dep:fast-socks5"]
[dependencies]
alknet-core = { path = "../alknet-core" }
alknet-tls = { path = "../alknet-tls" }
tokio = { version = "1", features = ["full"] }
thiserror = "2"
tracing = "0.1"
quinn = { version = "0.11", optional = true }
tokio-rustls = { version = "0.26", optional = true }
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
fast-socks5 = { version = "1", optional = true }
rustls = "0.23"
rustls-pki-types = "1"
hex = "0.4"
+124
View File
@@ -0,0 +1,124 @@
//! `AlknetClient` — native client dial seam, the client-side analogue of
//! `AlknetEndpoint`. Holds pre-built transport handles, all optional — the
//! client dials with whichever transport the remote endpoint type implies.
use std::fmt;
#[cfg(feature = "quinn")]
use quinn;
#[cfg(feature = "tcp")]
use tokio_rustls;
#[cfg(feature = "iroh")]
use iroh;
#[cfg(feature = "socks5")]
use crate::socks5::Socks5ProxyConfig;
/// Native client dial seam — multi-transport dialer that produces
/// `Connection`s for protocol take-overs.
///
/// Holds pre-built transport handles, all optional — the client dials
/// with whichever transport the remote endpoint type implies. The
/// builder mirrors `AlknetEndpoint`'s `with_quinn` / `with_iroh` /
/// `with_tcp_tls` (ADR-083) — the assembly layer builds the transport
/// handles and hands them to the client via builder methods.
pub struct AlknetClient {
#[cfg(feature = "quinn")]
pub(crate) quinn: Option<quinn::Endpoint>,
#[cfg(feature = "tcp")]
pub(crate) tcp_connector: Option<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
pub(crate) iroh: Option<iroh::Endpoint>,
/// When set, `dial_quic` and `dial_tcp_tls` route through this
/// SOCKS5 proxy (UDP ASSOCIATE / CONNECT respectively). `dial_iroh`
/// forces relay-only via an HTTP-to-SOCKS5 bridge — see ADR-090 §5.
/// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
pub(crate) socks5: Option<Socks5ProxyConfig>,
}
impl AlknetClient {
/// Create a new `AlknetClient` with no transport handles configured.
/// Use the builder methods to add transports.
pub fn new() -> Self {
Self {
#[cfg(feature = "quinn")]
quinn: None,
#[cfg(feature = "tcp")]
tcp_connector: None,
#[cfg(feature = "iroh")]
iroh: None,
#[cfg(feature = "socks5")]
socks5: None,
}
}
/// Set the QUIC transport handle. The assembly layer builds a
/// `quinn::Endpoint` (with or without a SOCKS5 proxy — the proxy
/// is applied inside `dial_quic`, not at construction time) and
/// hands it to the client.
#[cfg(feature = "quinn")]
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self {
self.quinn = Some(endpoint);
self
}
/// Set the TCP+TLS transport handle. The assembly layer builds a
/// `tokio_rustls::TlsConnector` and hands it to the client.
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(mut self, connector: tokio_rustls::TlsConnector) -> Self {
self.tcp_connector = Some(connector);
self
}
/// Set the iroh transport handle. The assembly layer builds an
/// `iroh::Endpoint` and hands it to the client.
#[cfg(feature = "iroh")]
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self {
self.iroh = Some(endpoint);
self
}
/// Set the SOCKS5 proxy for all subsequent dials. When set, every
/// dial routes its transport through this proxy: UDP ASSOCIATE for
/// `dial_quic`, CONNECT for `dial_tcp_tls`, and force-relay-only +
/// HTTP-to-SOCKS5 bridge for `dial_iroh` (ADR-090 §5).
/// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self {
self.socks5 = Some(proxy);
self
}
}
impl Default for AlknetClient {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for AlknetClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
#[allow(unused_mut)]
let mut configured: Vec<&str> = Vec::new();
#[cfg(feature = "quinn")]
if self.quinn.is_some() {
configured.push("quinn");
}
#[cfg(feature = "tcp")]
if self.tcp_connector.is_some() {
configured.push("tcp");
}
#[cfg(feature = "iroh")]
if self.iroh.is_some() {
configured.push("iroh");
}
#[cfg(feature = "socks5")]
if self.socks5.is_some() {
configured.push("socks5");
}
f.debug_struct("AlknetClient")
.field("transports", &configured)
.finish()
}
}
+85
View File
@@ -0,0 +1,85 @@
//! `dial_iroh` — iroh dial, producing a `Connection`.
//!
//! Feature-gated on `iroh`. The iroh path does NOT use `TlsClientConfig` —
//! iroh has its own TLS (shares the `Ed25519SecretKey`, not the rustls config
//! — ADR-087 §3, ADR-089 §3). The local key is extracted from
//! `creds.local_identity`; the remote `EndpointId` is derived from
//! `creds.remote_identity.fingerprint`.
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
use crate::error::ClientDialError;
use crate::client::AlknetClient;
impl AlknetClient {
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
/// §3). The local key is extracted from `creds.local_identity`; the
/// remote `EndpointId` is derived from `creds.remote_identity.fingerprint`
/// (`ed25519:<hex>` → `EndpointId::from_bytes`). The verifier is iroh's
/// `EndpointId` match (fingerprint pin by another name — ADR-034 §3).
/// An unknown iroh remote fails closed (no CA). Feature-gated on
/// `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
let endpoint = self.iroh.as_ref().ok_or(ClientDialError::NoTransport {
transport: "iroh",
})?;
let node_id = match &creds.remote_identity {
Some(ri) => extract_iroh_endpoint_id(&ri.fingerprint).map_err(|e| {
ClientDialError::TlsConfig(alknet_tls::TlsError::Config(e))
})?,
None => {
return Err(ClientDialError::TlsConfig(alknet_tls::TlsError::Config(
"iroh requires a known remote (remote_identity must be Some); \
unknown iroh remotes fail closed (ADR-034 §3)"
.into(),
)));
}
};
let conn = endpoint
.connect(node_id, alpn)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
Ok(Connection::from_iroh(conn))
}
}
/// Extract an `iroh::EndpointId` from a fingerprint string.
///
/// Supports two formats:
/// - `"ed25519:<hex>"` — raw Ed25519 public key (64 hex chars)
/// - `"SHA256:<base64>"` — SHA-256 hash of the cert (for X.509; not valid for iroh)
///
/// For iroh, only the `ed25519:` prefix is valid — iroh uses Ed25519 keys.
fn extract_iroh_endpoint_id(fingerprint: &str) -> Result<iroh::EndpointId, String> {
if let Some(hex_str) = fingerprint.strip_prefix("ed25519:") {
let bytes =
hex::decode(hex_str).map_err(|e| format!("invalid ed25519 fingerprint hex: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"invalid ed25519 fingerprint length: expected 32 bytes, got {}",
bytes.len()
));
}
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| "invalid ed25519 fingerprint length".to_string())?;
iroh::EndpointId::from_bytes(&arr)
.map_err(|e| format!("invalid iroh EndpointId: {e}"))
} else {
Err(format!(
"iroh requires an ed25519: fingerprint, got: {}",
fingerprint
))
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Dial methods for `AlknetClient` — one per transport.
//!
//! Each dial method is feature-gated on the corresponding transport feature.
//! All three are unified on `&ConnectionCredentials` (ADR-091) and return a
//! `Connection` for protocol take-overs to consume.
#[cfg(feature = "quinn")]
pub mod quinn;
#[cfg(feature = "tcp")]
pub mod tcp_tls;
#[cfg(feature = "iroh")]
pub mod iroh;
+83
View File
@@ -0,0 +1,83 @@
//! `dial_quic` — QUIC dial via quinn, producing a `Connection`.
//!
//! Feature-gated on `quinn`. Builds a `TlsClientConfig` from
//! `ConnectionCredentials`, constructs a `quinn::ClientConfig`, dials
//! `addr` on `alpn`, and returns a `Connection` via
//! `Connection::from_quinn_with_alpn`.
use std::net::SocketAddr;
#[cfg(feature = "socks5")]
use std::sync::Arc;
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
use alknet_tls::client::TlsClientConfig;
use crate::error::ClientDialError;
use crate::client::AlknetClient;
impl AlknetClient {
/// QUIC dial. Builds a `TlsClientConfig` from `creds`
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
/// on `alpn`, returns a `Connection` via
/// `Connection::from_quinn_with_alpn`. The `server_name` is the
/// TLS SNI / name (for X.509; ignored for raw-key pinning).
/// Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let client_config = tls_config.for_quinn()?;
#[cfg(feature = "socks5")]
let conn = if let Some(proxy) = &self.socks5 {
let socket = crate::socks5::Socks5UdpSocket::bind(proxy).await?;
let endpoint = quinn::Endpoint::new_with_abstract_socket(
quinn::EndpointConfig::default(),
None,
Arc::new(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()))?
} else {
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()))?
};
#[cfg(not(feature = "socks5"))]
let conn = {
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()))
}
}
+99
View File
@@ -0,0 +1,99 @@
//! `dial_tcp_tls` — TCP+TLS dial via tokio-rustls, producing a `Connection`.
//!
//! Feature-gated on `tcp`. Builds a `TlsClientConfig` from
//! `ConnectionCredentials`, connects a `TcpStream` to `addr`, wraps with
//! `TlsConnector` using `host` as the SNI, and returns a `Connection` via
//! `Connection::from_bidi` (ADR-065).
use std::net::SocketAddr;
use std::sync::Arc;
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
use alknet_tls::client::TlsClientConfig;
use tokio::net::TcpStream;
use crate::error::ClientDialError;
use crate::client::AlknetClient;
impl AlknetClient {
/// TCP+TLS dial. Builds a `TlsClientConfig` from `creds`,
/// connects a `TcpStream` to `addr`, wraps with `TlsConnector`
/// using `host` as the SNI, returns a `Connection` via
/// `Connection::from_bidi` (ADR-065). Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let connector = match &self.tcp_connector {
Some(c) => c.clone(),
None => {
let rustls_config = Arc::new(tls_config.into_rustls_config());
tokio_rustls::TlsConnector::from(rustls_config)
}
};
#[cfg(feature = "socks5")]
let tls_stream = if let Some(proxy) = &self.socks5 {
let mut tcp = TcpStream::connect(proxy.addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
crate::socks5::socks5_connect(&mut tcp, proxy, addr)
.await
.map_err(ClientDialError::Proxy)?;
let server_name: rustls::pki_types::ServerName = host
.to_owned()
.try_into()
.map_err(|e: rustls::pki_types::InvalidDnsNameError| {
ClientDialError::Connect(e.to_string())
})?;
connector
.connect(server_name, tcp)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
} else {
let tcp_stream = TcpStream::connect(addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let server_name: rustls::pki_types::ServerName = host
.to_owned()
.try_into()
.map_err(|e: rustls::pki_types::InvalidDnsNameError| {
ClientDialError::Connect(e.to_string())
})?;
connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
};
#[cfg(not(feature = "socks5"))]
let tls_stream = {
let tcp_stream = TcpStream::connect(addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let server_name: rustls::pki_types::ServerName = host
.to_owned()
.try_into()
.map_err(|e: rustls::pki_types::InvalidDnsNameError| {
ClientDialError::Connect(e.to_string())
})?;
connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
};
Ok(Connection::from_bidi(
tls_stream,
alpn.to_vec(),
Some(addr),
))
}
}
+40
View File
@@ -0,0 +1,40 @@
//! `ClientDialError` — error type for all three dial methods.
use thiserror::Error;
/// Errors produced by `AlknetClient` dial methods.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientDialError {
/// TLS config construction failure — `TlsClientConfig::new` failed
/// (verifier build, cert load, provider init). Wraps `TlsError`
/// from alknet-tls.
#[error("TLS config construction: {0}")]
TlsConfig(#[from] alknet_tls::TlsError),
/// Transport connect failure — quinn connect, TcpStream::connect,
/// or iroh connect. The transport's own error type, stringified.
#[error("transport connect: {0}")]
Connect(String),
/// TLS handshake failure — the handshake started but failed
/// (rejected cert, ALPN mismatch, unknown raw-key remote
/// fail-closed). Distinct from TlsConfig (which is pre-handshake).
#[error("TLS handshake: {0}")]
Handshake(String),
/// No transport handle configured for the requested dial — e.g.,
/// `dial_quic` called but `with_quinn` was not set.
#[error("no transport handle configured for {transport}")]
NoTransport { transport: &'static str },
/// SOCKS5 proxy failure — handshake rejected, UDP ASSOCIATE
/// unsupported, auth failed, or the proxy closed the control
/// connection (ADR-090). The dial did not reach the remote; the
/// caller decides whether to fall back to a direct dial or
/// surface the error. The dial never silently falls back — that
/// would defeat the privacy posture.
#[cfg(feature = "socks5")]
#[error("SOCKS5 proxy: {0}")]
Proxy(String),
}
+23
View File
@@ -0,0 +1,23 @@
//! alknet-client: Native client dial seam — multi-transport dialer that
//! produces `Connection`s for protocol take-overs.
//!
//! `AlknetClient` is the client-side analogue of `AlknetEndpoint`: a
//! multi-transport dialer that takes pre-built transport handles (quinn,
//! TCP+TLS, iroh), dials a remote `AlknetEndpoint` on a chosen ALPN, and
//! produces a `Connection`. The protocol take-overs
//! (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`)
//! consume the `Connection` — the dial is below the protocol.
//!
//! An optional SOCKS5 proxy (ADR-090) routes the dials through a proxy
//! to hide the client's real IP from the hub.
pub mod client;
pub mod dial;
pub mod error;
#[cfg(feature = "socks5")]
pub mod socks5;
pub use client::AlknetClient;
pub use error::ClientDialError;
#[cfg(feature = "socks5")]
pub use socks5::{Socks5Credentials, Socks5ProxyConfig};
+458
View File
@@ -0,0 +1,458 @@
//! SOCKS5 proxy support for `AlknetClient` (ADR-090).
//!
//! 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.
//!
//! Feature-gated on `socks5`. The `Socks5UdpSocket` additionally requires
//! the `quinn` feature (it implements `quinn::AsyncUdpSocket`).
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[cfg(feature = "quinn")]
use std::io;
#[cfg(feature = "quinn")]
use std::pin::Pin;
#[cfg(feature = "quinn")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "quinn")]
use std::task::{Context, Poll, Waker};
#[cfg(feature = "quinn")]
use crate::error::ClientDialError;
/// 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,
}
/// A `quinn::AsyncUdpSocket` implementation that tunnels QUIC datagrams
/// through a SOCKS5 UDP ASSOCIATE tunnel.
///
/// The implementation follows the pattern validated by the quinn-proxy PoC
/// (`docs/research/quinn-quic-proxy/findings.md`).
///
/// Requires both `socks5` and `quinn` features.
#[cfg(feature = "quinn")]
pub struct Socks5UdpSocket {
socket: std::net::UdpSocket,
relay_addr: SocketAddr,
local_addr: SocketAddr,
_control: TcpStream,
}
#[cfg(feature = "quinn")]
impl Socks5UdpSocket {
/// Perform the SOCKS5 UDP ASSOCIATE handshake and return a socket
/// that tunnels QUIC datagrams through the proxy.
pub async fn bind(proxy: &Socks5ProxyConfig) -> Result<Self, ClientDialError> {
let mut control = TcpStream::connect(proxy.addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
socks5_handshake(&mut control, proxy).await?;
let relay_addr = socks5_udp_associate(&mut control).await?;
let socket = std::net::UdpSocket::bind("0.0.0.0:0")
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
socket
.set_nonblocking(true)
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let local_addr = socket
.local_addr()
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
Ok(Self {
socket,
relay_addr,
local_addr,
_control: control,
})
}
}
#[cfg(feature = "quinn")]
impl quinn::AsyncUdpSocket for Socks5UdpSocket {
fn create_io_poller(self: Arc<Self>) -> Pin<Box<dyn quinn::UdpPoller>> {
Box::pin(UdpPollerImpl {
socket: self.socket.try_clone().ok(),
waker: Mutex::new(None),
})
}
fn try_send(&self, transmit: &quinn::udp::Transmit) -> io::Result<()> {
let mut buf = Vec::with_capacity(10 + transmit.contents.len());
buf.extend_from_slice(&[0u8, 0, 0]);
match transmit.destination {
SocketAddr::V4(addr) => {
buf.push(0x01);
buf.extend_from_slice(&addr.ip().octets());
buf.extend_from_slice(&addr.port().to_be_bytes());
}
SocketAddr::V6(addr) => {
buf.push(0x04);
buf.extend_from_slice(&addr.ip().octets());
buf.extend_from_slice(&addr.port().to_be_bytes());
}
}
buf.extend_from_slice(transmit.contents);
let sent = self.socket.send_to(&buf, self.relay_addr)?;
if sent < buf.len() {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"partial send",
));
}
Ok(())
}
fn poll_recv(
&self,
_cx: &mut Context,
bufs: &mut [io::IoSliceMut<'_>],
meta: &mut [quinn::udp::RecvMeta],
) -> Poll<io::Result<usize>> {
let mut buf = [0u8; 65536];
match self.socket.recv_from(&mut buf) {
Ok((n, _src)) => {
if n < 10 {
return Poll::Ready(Ok(0));
}
let header_end = 3;
let atyp = buf[header_end];
let addr_len: usize = match atyp {
0x01 => 4,
0x04 => 16,
_ => return Poll::Ready(Ok(0)),
};
let payload_start = header_end + 1 + addr_len + 2;
if n < payload_start {
return Poll::Ready(Ok(0));
}
let payload = &buf[payload_start..n];
let copy_len = payload.len().min(bufs.iter().map(|b| b.len()).sum());
let mut offset = 0;
for b in bufs.iter_mut() {
let end = (offset + b.len()).min(copy_len);
if offset < end {
b.copy_from_slice(&payload[offset..end]);
}
offset = end;
if offset >= copy_len {
break;
}
}
meta[0] = quinn::udp::RecvMeta {
len: copy_len,
stride: copy_len,
addr: self.relay_addr,
ecn: None,
dst_ip: None,
};
Poll::Ready(Ok(1))
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
Poll::Pending
}
Err(e) => Poll::Ready(Err(e)),
}
}
fn local_addr(&self) -> io::Result<SocketAddr> {
Ok(self.local_addr)
}
fn may_fragment(&self) -> bool {
false
}
}
#[cfg(feature = "quinn")]
struct UdpPollerImpl {
socket: Option<std::net::UdpSocket>,
waker: Mutex<Option<Waker>>,
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for UdpPollerImpl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UdpPollerImpl").finish()
}
}
#[cfg(feature = "quinn")]
impl quinn::UdpPoller for UdpPollerImpl {
fn poll_writable(self: Pin<&mut Self>, cx: &mut Context) -> Poll<io::Result<()>> {
if let Some(ref socket) = self.socket {
match socket.send_to(&[], socket.local_addr().ok().unwrap_or_else(|| "0.0.0.0:0".parse().unwrap())) {
Ok(_) => Poll::Ready(Ok(())),
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
*self.waker.lock().unwrap() = Some(cx.waker().clone());
Poll::Pending
}
Err(e) => Poll::Ready(Err(e)),
}
} else {
Poll::Ready(Ok(()))
}
}
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for Socks5UdpSocket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Socks5UdpSocket")
.field("relay_addr", &self.relay_addr)
.field("local_addr", &self.local_addr)
.finish()
}
}
/// Perform the SOCKS5 handshake (greeting + auth).
#[cfg(feature = "quinn")]
async fn socks5_handshake(
stream: &mut TcpStream,
proxy: &Socks5ProxyConfig,
) -> Result<(), ClientDialError> {
if let Some(creds) = &proxy.credentials {
stream
.write_all(&[0x05, 0x01, 0x02])
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if resp[0] != 0x05 || resp[1] != 0x02 {
return Err(ClientDialError::Proxy(
"SOCKS5 server does not support username/password auth".into(),
));
}
let mut auth_msg = Vec::with_capacity(3 + creds.username.len() + creds.password.len());
auth_msg.push(0x01);
auth_msg.push(creds.username.len() as u8);
auth_msg.extend_from_slice(creds.username.as_bytes());
auth_msg.push(creds.password.len() as u8);
auth_msg.extend_from_slice(creds.password.as_bytes());
stream
.write_all(&auth_msg)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut auth_resp = [0u8; 2];
stream
.read_exact(&mut auth_resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if auth_resp[1] != 0x00 {
return Err(ClientDialError::Proxy(
"SOCKS5 username/password authentication failed".into(),
));
}
} else {
stream
.write_all(&[0x05, 0x01, 0x00])
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if resp[0] != 0x05 || resp[1] != 0x00 {
return Err(ClientDialError::Proxy(
"SOCKS5 server rejected no-auth method".into(),
));
}
}
Ok(())
}
/// Perform the SOCKS5 UDP ASSOCIATE request and return the relay address.
#[cfg(feature = "quinn")]
async fn socks5_udp_associate(stream: &mut TcpStream) -> Result<SocketAddr, ClientDialError> {
let req = vec![0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
stream
.write_all(&req)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
let mut resp = [0u8; 10];
stream
.read_exact(&mut resp)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
if resp[0] != 0x05 {
return Err(ClientDialError::Proxy("invalid SOCKS5 version in reply".into()));
}
if resp[1] != 0x00 {
return Err(ClientDialError::Proxy(format!(
"SOCKS5 UDP ASSOCIATE rejected with code {}",
resp[1]
)));
}
let bind_port = u16::from_be_bytes([resp[8], resp[9]]);
let bind_addr = match resp[3] {
0x01 => {
let mut addr = [0u8; 4];
stream
.read_exact(&mut addr)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
SocketAddr::new(std::net::Ipv4Addr::from(addr).into(), bind_port)
}
0x04 => {
let mut addr = [0u8; 16];
stream
.read_exact(&mut addr)
.await
.map_err(|e| ClientDialError::Proxy(e.to_string()))?;
SocketAddr::new(std::net::Ipv6Addr::from(addr).into(), bind_port)
}
_ => {
return Err(ClientDialError::Proxy(format!(
"unsupported address type in UDP ASSOCIATE reply: {}",
resp[3]
)))
}
};
Ok(bind_addr)
}
/// Perform the SOCKS5 CONNECT handshake to the target address.
pub async fn socks5_connect(
stream: &mut TcpStream,
proxy: &Socks5ProxyConfig,
target: SocketAddr,
) -> Result<(), String> {
socks5_handshake_connect(stream, proxy).await?;
let mut req = vec![0x05, 0x01, 0x00];
match target {
SocketAddr::V4(addr) => {
req.push(0x01);
req.extend_from_slice(&addr.ip().octets());
req.extend_from_slice(&addr.port().to_be_bytes());
}
SocketAddr::V6(addr) => {
req.push(0x04);
req.extend_from_slice(&addr.ip().octets());
req.extend_from_slice(&addr.port().to_be_bytes());
}
}
stream
.write_all(&req)
.await
.map_err(|e| format!("SOCKS5 CONNECT write: {e}"))?;
let mut resp = [0u8; 10];
stream
.read_exact(&mut resp)
.await
.map_err(|e| format!("SOCKS5 CONNECT read: {e}"))?;
if resp[0] != 0x05 {
return Err("invalid SOCKS5 version in CONNECT reply".into());
}
if resp[1] != 0x00 {
return Err(format!("SOCKS5 CONNECT rejected with code {}", resp[1]));
}
match resp[3] {
0x01 => {
let mut _addr = [0u8; 4];
stream
.read_exact(&mut _addr)
.await
.map_err(|e| format!("SOCKS5 CONNECT bind addr read: {e}"))?;
}
0x04 => {
let mut _addr = [0u8; 16];
stream
.read_exact(&mut _addr)
.await
.map_err(|e| format!("SOCKS5 CONNECT bind addr read: {e}"))?;
}
_ => {}
}
Ok(())
}
async fn socks5_handshake_connect(
stream: &mut TcpStream,
proxy: &Socks5ProxyConfig,
) -> Result<(), String> {
if let Some(creds) = &proxy.credentials {
stream
.write_all(&[0x05, 0x01, 0x02])
.await
.map_err(|e| format!("SOCKS5 greeting write: {e}"))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| format!("SOCKS5 greeting read: {e}"))?;
if resp[0] != 0x05 || resp[1] != 0x02 {
return Err("SOCKS5 server does not support username/password auth".into());
}
let mut auth_msg = Vec::with_capacity(3 + creds.username.len() + creds.password.len());
auth_msg.push(0x01);
auth_msg.push(creds.username.len() as u8);
auth_msg.extend_from_slice(creds.username.as_bytes());
auth_msg.push(creds.password.len() as u8);
auth_msg.extend_from_slice(creds.password.as_bytes());
stream
.write_all(&auth_msg)
.await
.map_err(|e| format!("SOCKS5 auth write: {e}"))?;
let mut auth_resp = [0u8; 2];
stream
.read_exact(&mut auth_resp)
.await
.map_err(|e| format!("SOCKS5 auth read: {e}"))?;
if auth_resp[1] != 0x00 {
return Err("SOCKS5 username/password authentication failed".into());
}
} else {
stream
.write_all(&[0x05, 0x01, 0x00])
.await
.map_err(|e| format!("SOCKS5 greeting write: {e}"))?;
let mut resp = [0u8; 2];
stream
.read_exact(&mut resp)
.await
.map_err(|e| format!("SOCKS5 greeting read: {e}"))?;
if resp[0] != 0x05 || resp[1] != 0x00 {
return Err("SOCKS5 server rejected no-auth method".into());
}
}
Ok(())
}
+6
View File
@@ -48,6 +48,12 @@ impl TlsClientConfig {
.map_err(|e| TlsError::Config(e.to_string()))?,
)))
}
/// Consume the config and return the inner `rustls::ClientConfig`.
/// Used by `dial_tcp_tls` to build a `TlsConnector`.
pub fn into_rustls_config(self) -> rustls::ClientConfig {
self.rustls_config
}
}
/// Build the client-auth cert resolver that presents the local node's TLS