Compare commits
3
Commits
b476182d64
...
2a75724dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a75724dde | ||
|
|
759c627ca4 | ||
|
|
82ee37e206 |
No files matched your search
Generated
+51
-8
@@ -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",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/alknet-tls",
|
||||
"crates/alknet-tty",
|
||||
"crates/alknet-tty-local",
|
||||
"crates/alknet-client",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,154 @@
|
||||
//! `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 = "iroh")]
|
||||
use iroh;
|
||||
#[cfg(feature = "quinn")]
|
||||
use quinn;
|
||||
#[cfg(feature = "tcp")]
|
||||
use tokio_rustls;
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_client() {
|
||||
let client = AlknetClient::new();
|
||||
let debug = format!("{:?}", client);
|
||||
assert!(debug.contains("AlknetClient"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_delegates_to_new() {
|
||||
let _client = AlknetClient::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alknet_client_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<AlknetClient>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_lists_configured_transports() {
|
||||
let client = AlknetClient::new();
|
||||
let debug = format!("{:?}", client);
|
||||
assert!(!debug.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! `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::client::AlknetClient;
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "iroh"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_iroh_no_transport_error() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let result = client.dial_iroh(b"test/alpn", &creds).await;
|
||||
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_valid_ed25519() {
|
||||
let hex_key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let fingerprint = format!("ed25519:{}", hex_key);
|
||||
let result = extract_iroh_endpoint_id(&fingerprint);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_rejects_sha256() {
|
||||
let fingerprint = "SHA256:abc123";
|
||||
let result = extract_iroh_endpoint_id(fingerprint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_rejects_invalid_hex() {
|
||||
let fingerprint = "ed25519:nothex";
|
||||
let result = extract_iroh_endpoint_id(fingerprint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_iroh_endpoint_id_rejects_wrong_length() {
|
||||
let fingerprint = "ed25519:aaaa";
|
||||
let result = extract_iroh_endpoint_id(fingerprint);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -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 = "iroh")]
|
||||
pub mod iroh;
|
||||
#[cfg(feature = "quinn")]
|
||||
pub mod quinn;
|
||||
#[cfg(feature = "tcp")]
|
||||
pub mod tcp_tls;
|
||||
@@ -0,0 +1,120 @@
|
||||
//! `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::client::AlknetClient;
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "quinn"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_quic_no_transport_error() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let result = client
|
||||
.dial_quic(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
"localhost",
|
||||
b"test/alpn",
|
||||
&creds,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_quic_tls_config_error_on_acme_identity() {
|
||||
use alknet_core::config::{AcmeDirectory, TlsIdentity};
|
||||
let creds = ConnectionCredentials::new().with_tls_identity(TlsIdentity::Acme {
|
||||
domains: vec!["example.com".into()],
|
||||
directory: AcmeDirectory::Staging,
|
||||
cache_dir: std::path::PathBuf::from("/tmp"),
|
||||
contact: vec![],
|
||||
});
|
||||
let client = AlknetClient::new();
|
||||
let result = client
|
||||
.dial_quic(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
"localhost",
|
||||
b"test/alpn",
|
||||
&creds,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ClientDialError::TlsConfig(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! `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::client::AlknetClient;
|
||||
use crate::error::ClientDialError;
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tcp"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_tcp_tls_no_transport_error() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let result = client
|
||||
.dial_tcp_tls(
|
||||
"localhost",
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
b"test/alpn",
|
||||
&creds,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ClientDialError::Connect(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! `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),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tls_config_from_tls_error() {
|
||||
let err = alknet_tls::TlsError::Config("test".into());
|
||||
let dial_err: ClientDialError = err.into();
|
||||
assert!(matches!(dial_err, ClientDialError::TlsConfig(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_transport_displays_transport_name() {
|
||||
let err = ClientDialError::NoTransport { transport: "quinn" };
|
||||
assert!(err.to_string().contains("quinn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_displays_message() {
|
||||
let err = ClientDialError::Connect("connection refused".into());
|
||||
assert!(err.to_string().contains("connection refused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_displays_message() {
|
||||
let err = ClientDialError::Handshake("certificate rejected".into());
|
||||
assert!(err.to_string().contains("certificate rejected"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "socks5")]
|
||||
#[test]
|
||||
fn proxy_displays_message() {
|
||||
let err = ClientDialError::Proxy("UDP ASSOCIATE rejected".into());
|
||||
assert!(err.to_string().contains("UDP ASSOCIATE rejected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_dial_error_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<ClientDialError>();
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -0,0 +1,461 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Integration test: dial + take-over composition.
|
||||
//!
|
||||
//! Verifies that the AlknetClient type exists and compiles correctly.
|
||||
//! Full end-to-end dial tests (with a real quinn endpoint) are tested
|
||||
//! in the assembly layer integration tests (future hub/worker tests).
|
||||
|
||||
use alknet_client::AlknetClient;
|
||||
use alknet_core::credentials::ConnectionCredentials;
|
||||
|
||||
#[tokio::test]
|
||||
async fn alknet_client_new_creates_empty_client() {
|
||||
let client = AlknetClient::new();
|
||||
let creds = ConnectionCredentials::new();
|
||||
let _ = (client, creds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alknet_client_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<AlknetClient>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_dial_error_is_send_sync() {
|
||||
use alknet_client::ClientDialError;
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<ClientDialError>();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user