diff --git a/Cargo.lock b/Cargo.lock index f0d7f88..fa4905d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 6621f7d..aca2dc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/alknet-tls", "crates/alknet-tty", "crates/alknet-tty-local", + "crates/alknet-client", ] resolver = "2" diff --git a/crates/alknet-client/Cargo.toml b/crates/alknet-client/Cargo.toml new file mode 100644 index 0000000..b980fdc --- /dev/null +++ b/crates/alknet-client/Cargo.toml @@ -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" diff --git a/crates/alknet-client/src/client.rs b/crates/alknet-client/src/client.rs new file mode 100644 index 0000000..bafd213 --- /dev/null +++ b/crates/alknet-client/src/client.rs @@ -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, + #[cfg(feature = "tcp")] + pub(crate) tcp_connector: Option, + #[cfg(feature = "iroh")] + pub(crate) iroh: Option, + /// 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, +} + +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() + } +} diff --git a/crates/alknet-client/src/dial/iroh.rs b/crates/alknet-client/src/dial/iroh.rs new file mode 100644 index 0000000..1b4a838 --- /dev/null +++ b/crates/alknet-client/src/dial/iroh.rs @@ -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:` → `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 { + 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:"` — raw Ed25519 public key (64 hex chars) +/// - `"SHA256:"` — 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 { + 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 + )) + } +} diff --git a/crates/alknet-client/src/dial/mod.rs b/crates/alknet-client/src/dial/mod.rs new file mode 100644 index 0000000..71c86de --- /dev/null +++ b/crates/alknet-client/src/dial/mod.rs @@ -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; diff --git a/crates/alknet-client/src/dial/quinn.rs b/crates/alknet-client/src/dial/quinn.rs new file mode 100644 index 0000000..cd1fa90 --- /dev/null +++ b/crates/alknet-client/src/dial/quinn.rs @@ -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 { + 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())) + } +} diff --git a/crates/alknet-client/src/dial/tcp_tls.rs b/crates/alknet-client/src/dial/tcp_tls.rs new file mode 100644 index 0000000..9adb977 --- /dev/null +++ b/crates/alknet-client/src/dial/tcp_tls.rs @@ -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 { + 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), + )) + } +} diff --git a/crates/alknet-client/src/error.rs b/crates/alknet-client/src/error.rs new file mode 100644 index 0000000..c304a5e --- /dev/null +++ b/crates/alknet-client/src/error.rs @@ -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), +} diff --git a/crates/alknet-client/src/lib.rs b/crates/alknet-client/src/lib.rs new file mode 100644 index 0000000..b295cb7 --- /dev/null +++ b/crates/alknet-client/src/lib.rs @@ -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}; diff --git a/crates/alknet-client/src/socks5.rs b/crates/alknet-client/src/socks5.rs new file mode 100644 index 0000000..c5fefe1 --- /dev/null +++ b/crates/alknet-client/src/socks5.rs @@ -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, +} + +/// 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 { + 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) -> Pin> { + 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> { + 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 { + Ok(self.local_addr) + } + + fn may_fragment(&self) -> bool { + false + } +} + +#[cfg(feature = "quinn")] +struct UdpPollerImpl { + socket: Option, + waker: Mutex>, +} + +#[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> { + 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 { + 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(()) +} diff --git a/crates/alknet-tls/src/client.rs b/crates/alknet-tls/src/client.rs index 80e0e91..a728698 100644 --- a/crates/alknet-tls/src/client.rs +++ b/crates/alknet-tls/src/client.rs @@ -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