diff --git a/smolmix/README.md b/smolmix/README.md index 57205311d7..0cb2e9bdb2 100644 --- a/smolmix/README.md +++ b/smolmix/README.md @@ -35,6 +35,116 @@ over a TCP stream works over smolmix — with zero adaptation. └──────────────────────────────────────────────────────────────────┘ ``` +Concrete example: the Nym SDK's libp2p integration (`libp2p_shared/`) is +~2400 lines — custom `Connection` (StreamMuxer), custom `Substream` +(AsyncRead/Write), nonce-based ordering, and a custom handshake. With +smolmix, the same integration is ~175 lines: parse a multiaddr, resolve DNS +through the tunnel, and call `tunnel.tcp_connect()`. libp2p's built-in noise +and yamux work natively because they only need `AsyncRead + AsyncWrite`. + +Each companion crate is a thin wrapper — typically under 200 lines — that +adapts an existing library to use tunnel I/O instead of OS sockets. The key +insight is that `tokio_smoltcp::TcpStream` implements tokio's `AsyncRead + +AsyncWrite`, so the only work needed is bridging to whatever I/O trait the +target library expects: + +| Crate | Lines | Trait implemented | I/O bridge | +|-------|------:|-------------------|------------| +| `smolmix-dns` | ~110 | hickory `RuntimeProvider` | `AsyncIoTokioAsStd` (tokio→futures) | +| `smolmix-tls` | ~60 | *(none — factory only)* | direct (tokio-rustls takes `AsyncRead/Write`) | +| `smolmix-hyper` | ~170 | `tower::Service` | `TokioIo` + `pin_project!` enum | +| `smolmix-tungstenite` | ~140 | *(none — composition)* | direct (tungstenite takes `AsyncRead/Write`) | +| `smolmix-libp2p` | ~175 | libp2p `Transport` | `Compat` (tokio→futures) | + +### smolmix-dns + +hickory-resolver's extension point is the `RuntimeProvider` trait — it +controls how the resolver creates TCP connections and UDP sockets. +`SmolmixRuntimeProvider` implements this trait, routing all I/O through the +tunnel: + +```text +RuntimeProvider::connect_tcp() → tunnel.tcp_connect() → AsyncIoTokioAsStd +RuntimeProvider::bind_udp() → tunnel.udp_socket() → SmolmixUdpSocket (newtype) +``` + +hickory expects `futures_io::AsyncRead/Write` for TCP, not tokio's version. +`AsyncIoTokioAsStd` (from hickory-proto) adapts between them — and because +hickory's `DnsTcpStream` has a blanket impl for any `futures_io::AsyncRead + +AsyncWrite`, the wrapped stream satisfies it automatically. Zero glue code. + +For UDP, `SmolmixUdpSocket` is a thin newtype over `tokio_smoltcp::UdpSocket` +that implements hickory's `DnsUdpSocket` — just delegates `poll_recv_from` +and `poll_send_to`, whose signatures already match. + +### smolmix-tls + +No trait adaptation needed. `tokio-rustls` works directly with anything that +implements tokio's `AsyncRead + AsyncWrite` — which `TcpStream` does. This +crate is pure configuration: build a `ClientConfig` with webpki roots, wrap +it in a `TlsConnector`, expose `connect()` and `connect_with()`. + +Re-exports `TlsStream` and `TlsConnector` so downstream crates don't need +`tokio-rustls` in their Cargo.toml. + +### smolmix-hyper + +hyper-util's `Client` uses the `tower::Service` trait to open +connections. `SmolmixConnector` implements this: given a URI, it resolves the +hostname, connects TCP, and optionally wraps in TLS. + +The interesting part is the return type. hyper needs a stream that implements +`hyper::rt::Read + Write + Connection`. `TokioIo` (from hyper-util) +provides this, but it needs a single type — not "sometimes TLS, sometimes +plain". `MaybeTlsStream` solves this with a two-variant enum and +`pin_project_lite` for safe pin projection through `AsyncRead`/`AsyncWrite`: + +```text +SmolmixConnector::call(uri) + → resolve + tcp_connect + optional TLS + → MaybeTlsStream::Plain { TcpStream } + or MaybeTlsStream::Tls { TlsStream } + → TokioIo (implements hyper's Read/Write/Connection) +``` + +### smolmix-tungstenite + +No new types or trait impls — pure function composition. +`tokio_tungstenite::client_async()` takes any `AsyncRead + AsyncWrite` +stream, and `TlsStream` qualifies. The `connect()` function +chains four steps: + +```text +connect(tunnel, request) + → smolmix_dns::resolve(host, port) DNS through tunnel + → tunnel.tcp_connect(addr) TCP through mixnet + → smolmix_tls::connect(tcp, host) TLS handshake + → client_async(request, tls_stream) WebSocket upgrade +``` + +### smolmix-libp2p + +libp2p's extension point is the `Transport` trait. `SmolmixTransport` +implements it for dial-only connections (no inbound — that would require IPR +listener support). + +libp2p uses `futures_io::AsyncRead/Write`, not tokio's. The bridge is +`tokio_util::compat::Compat` — called via `.compat()` on the TcpStream. +This is zero-cost trait delegation (no buffering, no copying): + +```text +SmolmixTransport::dial(multiaddr) + → parse /ip4/.../tcp/... or /dns4/.../tcp/... + → smolmix_dns::resolve() if hostname + → tunnel.tcp_connect(addr) + → tcp.compat() → Compat (futures_io AsyncRead/Write) +``` + +libp2p's built-in upgrade pipeline — noise (encryption) → yamux +(multiplexing) — works over any `futures_io::AsyncRead + AsyncWrite`, so +the standard `.upgrade(V1).authenticate(noise).multiplex(yamux)` chain +works without modification. + ## Quick start ```rust @@ -52,12 +162,46 @@ let udp = tunnel.udp_socket().await?; udp.send_to(&packet, "1.1.1.1:53".parse()?).await?; ``` +## Crates + +| Crate | What it provides | +|-------|-----------------| +| [`smolmix`](core/) | `Tunnel`, `TcpStream`, `UdpSocket` | +| [`smolmix-dns`](dns/) | `Resolver` newtype wrapping hickory-resolver | +| [`smolmix-tls`](tls/) | Shared `TlsConnector` and `connect()` with webpki roots | +| [`smolmix-hyper`](hyper/) | `Client` newtype wrapping hyper-util | +| [`smolmix-tungstenite`](tungstenite/) | `connect()` for WebSocket over TLS | +| [`smolmix-libp2p`](libp2p/) | `SmolmixTransport` implementing libp2p `Transport` | + +`smolmix-hyper` and `smolmix-tungstenite` depend on `smolmix-dns` (hostname +resolution through the tunnel) and `smolmix-tls` (shared TLS setup). + +```toml +[dependencies] +smolmix = { workspace = true } +smolmix-dns = { workspace = true } # DNS resolution +smolmix-tls = { workspace = true } # TLS (used by hyper + tungstenite) +smolmix-hyper = { workspace = true } # HTTP client +smolmix-tungstenite = { workspace = true } # WebSocket client +smolmix-libp2p = { workspace = true } # libp2p transport +``` + ## Examples +Each crate has its own examples with clearnet-vs-mixnet comparisons: + ```sh -cargo run -p smolmix --example tcp # raw TCP -cargo run -p smolmix --example udp # raw UDP -cargo run -p smolmix --example websocket # WebSocket over TLS +cargo run -p smolmix --example tcp # raw TCP +cargo run -p smolmix --example udp # raw UDP +cargo run -p smolmix --example websocket # WebSocket over TLS +cargo run -p smolmix-dns --example resolve # DNS resolution +cargo run -p smolmix-hyper --example get # HTTPS GET +cargo run -p smolmix-hyper --example post # HTTP POST +cargo run -p smolmix-tungstenite --example echo # WebSocket echo + +# libp2p: start the clearnet listener, then dial through the mixnet +cargo run -p smolmix-libp2p --example listener +cargo run -p smolmix-libp2p --example ping -- ``` ## Architecture diff --git a/smolmix/core/Cargo.toml b/smolmix/core/Cargo.toml index a33265b542..8e498891a5 100644 --- a/smolmix/core/Cargo.toml +++ b/smolmix/core/Cargo.toml @@ -29,6 +29,7 @@ nym-ip-packet-requests = { workspace = true } thiserror.workspace = true [dev-dependencies] +smolmix-dns = { workspace = true } futures = { workspace = true } tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "time", "net"] } tokio-tungstenite.workspace = true diff --git a/smolmix/core/examples/https.rs b/smolmix/core/examples/https.rs new file mode 100644 index 0000000000..a6f1ee2113 --- /dev/null +++ b/smolmix/core/examples/https.rs @@ -0,0 +1,115 @@ +//! HTTPS GET: clearnet vs Nym mixnet. +//! +//! Fetches Cloudflare's `/cdn-cgi/trace` endpoint via clearnet and through the +//! mixnet. The same `https_get` function handles both — the only difference is +//! the TCP stream passed in: +//! +//! ```text +//! hyper (HTTP/1.1 framing) +//! └─ tokio-rustls (TLS) +//! └─ tokio::net::TcpStream (clearnet) +//! └─ smolmix::TcpStream (mixnet) +//! ``` +//! +//! Run with: +//! cargo run -p smolmix --example https +//! cargo run -p smolmix --example https -- --ipr + +use std::sync::Arc; + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty}; +use hyper::client::conn::http1; +use hyper_util::rt::TokioIo; +use rustls::pki_types::ServerName; +use smolmix::{Recipient, Tunnel}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tracing::info; + +type BoxError = Box; + +const HOST: &str = "cloudflare.com"; +const ADDR: &str = "1.1.1.1:443"; // cloudflare.com — hardcoded to skip DNS +const PATH: &str = "/cdn-cgi/trace"; + +fn tls_connector() -> tokio_rustls::TlsConnector { + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +/// HTTPS GET over any TCP stream — works identically with clearnet and mixnet. +async fn https_get( + tcp: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, + tls: &tokio_rustls::TlsConnector, + host: &str, + path: &str, +) -> Result<(hyper::StatusCode, String), BoxError> { + let domain = ServerName::try_from(host)?.to_owned(); + let tls_stream = tls.connect(domain, tcp).await?; + let (mut sender, conn) = http1::handshake(TokioIo::new(tls_stream)).await?; + tokio::spawn(conn); + + let req = hyper::Request::get(path) + .header("host", host) + .body(Empty::::new())?; + let resp = sender.send_request(req).await?; + let status = resp.status(); + let body = resp.into_body().collect().await?.to_bytes(); + Ok((status, String::from_utf8_lossy(&body).to_string())) +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + smolmix::init_logging(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + let tls = tls_connector(); + let addr = ADDR.parse()?; + + // --- Clearnet baseline --- + info!("Fetching https://{HOST}{PATH} via clearnet..."); + let clearnet_start = tokio::time::Instant::now(); + let clearnet_tcp = tokio::net::TcpStream::connect(addr).await?; + let (clearnet_status, clearnet_body) = https_get(clearnet_tcp, &tls, HOST, PATH).await?; + let clearnet_duration = clearnet_start.elapsed(); + info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration); + + // --- Mixnet via smolmix --- + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let tunnel = if let Some(addr) = ipr_addr { + let recipient: Recipient = addr.parse().expect("invalid IPR address"); + Tunnel::new_with_ipr(recipient).await? + } else { + Tunnel::new().await? + }; + + info!("Fetching https://{HOST}{PATH} via mixnet..."); + let mixnet_start = tokio::time::Instant::now(); + let mixnet_tcp = tunnel.tcp_connect(addr).await?; + let (_mixnet_status, mixnet_body) = https_get(mixnet_tcp, &tls, HOST, PATH).await?; + let mixnet_duration = mixnet_start.elapsed(); + + // --- Compare --- + info!("=== Results ==="); + let clearnet_ip = clearnet_body.lines().find(|l| l.starts_with("ip=")); + let mixnet_ip = mixnet_body.lines().find(|l| l.starts_with("ip=")); + info!("Clearnet IP: {}", clearnet_ip.unwrap_or("?")); + info!("Mixnet IP: {}", mixnet_ip.unwrap_or("?")); + + let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!("Slowdown: {slowdown:.1}x"); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/dns/Cargo.toml b/smolmix/dns/Cargo.toml new file mode 100644 index 0000000000..9d9a26f1d8 --- /dev/null +++ b/smolmix/dns/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "smolmix-dns" +version = "0.0.1" +edition = "2021" +license.workspace = true + +[dependencies] +smolmix = { workspace = true } +hickory-proto = { workspace = true, features = ["tokio"] } +hickory-resolver = { workspace = true, features = ["tokio"] } +tokio-smoltcp = "0.5" + +[dev-dependencies] +# Clearnet baseline in example +hickory-resolver = { workspace = true, features = ["tokio", "system-config"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tracing = { workspace = true } diff --git a/smolmix/dns/README.md b/smolmix/dns/README.md new file mode 100644 index 0000000000..a530192d50 --- /dev/null +++ b/smolmix/dns/README.md @@ -0,0 +1,52 @@ +# smolmix-dns + +DNS resolution through the Nym mixnet. Wraps [hickory-resolver](https://docs.rs/hickory-resolver) with a `Resolver` newtype that routes all DNS queries through a smolmix `Tunnel`, preventing hostname leaks to the local network. + +## Quick start + +```rust +use smolmix_dns::Resolver; + +let tunnel = smolmix::Tunnel::new().await?; +let resolver = Resolver::new(&tunnel); + +// Full hickory-resolver API via Deref: +let lookup = resolver.lookup_ip("example.com").await?; +for ip in lookup.iter() { println!("{ip}"); } + +// Convenience one-shot: +let addrs = resolver.resolve("example.com", 443).await?; +``` + +## API + +- **`Resolver::new(&tunnel)`** — Cloudflare upstream (`1.1.1.1`) +- **`Resolver::with_config(&tunnel, config)`** — custom upstream DNS +- **`Resolver::resolve(&self, host, port)`** — convenience one-shot returning `Vec` +- **`Deref` to hickory's `Resolver`** — full `lookup_ip()`, `lookup()`, etc. +- **`resolve(&tunnel, host, port)`** — free function for quick one-shots +- **`resolver(&tunnel)`** — free function returning a `Resolver` + +### Re-exports + +Commonly-used hickory types are re-exported so you don't need `hickory-resolver` in your `Cargo.toml`: + +- `ResolverConfig`, `LookupIp`, `ResolveError` + +## Example + +Clearnet-vs-mixnet DNS comparison with timing: + +```sh +cargo run -p smolmix-dns --example resolve +cargo run -p smolmix-dns --example resolve -- --ipr +``` + +## Dependencies + +```toml +[dependencies] +smolmix-dns = { workspace = true } +``` + +This crate depends on `smolmix` (for the `Tunnel` type), `hickory-proto`, and `hickory-resolver`. diff --git a/smolmix/dns/examples/resolve.rs b/smolmix/dns/examples/resolve.rs new file mode 100644 index 0000000000..0ca5fa82e9 --- /dev/null +++ b/smolmix/dns/examples/resolve.rs @@ -0,0 +1,85 @@ +//! DNS resolution: clearnet vs Nym mixnet comparison. +//! +//! Resolves a hostname via clearnet (hickory-resolver) and via the mixnet +//! (smolmix-dns), then compares resolved IPs and timing. +//! +//! Run with: +//! cargo run -p smolmix-dns --example resolve +//! cargo run -p smolmix-dns --example resolve -- --ipr + +use std::net::Ipv4Addr; + +use hickory_resolver::TokioResolver; +use smolmix::{Recipient, Tunnel}; +use smolmix_dns::Resolver; +use tracing::info; + +type BoxError = Box; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + smolmix::init_logging(); + + let domain = "example.com"; + + // --- Clearnet baseline via hickory-resolver --- + info!("Clearnet DNS lookup for '{domain}'..."); + let clearnet_resolver = TokioResolver::builder_tokio()?.build(); + let clearnet_start = tokio::time::Instant::now(); + let lookup = clearnet_resolver.lookup_ip(domain).await?; + let clearnet_ips: Vec = lookup + .iter() + .filter_map(|ip| match ip { + std::net::IpAddr::V4(v4) => Some(v4), + _ => None, + }) + .collect(); + let clearnet_duration = clearnet_start.elapsed(); + info!("Clearnet: {:?} in {:?}", clearnet_ips, clearnet_duration); + + // --- Mixnet via smolmix-dns --- + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let tunnel = if let Some(addr) = ipr_addr { + let recipient: Recipient = addr.parse().expect("invalid IPR address"); + Tunnel::new_with_ipr(recipient).await? + } else { + Tunnel::new().await? + }; + + let resolver = Resolver::new(&tunnel); + + // Full hickory API via Deref: + let mixnet_start = tokio::time::Instant::now(); + let lookup = resolver.lookup_ip(domain).await?; + let mixnet_ips: Vec = lookup + .iter() + .filter_map(|ip| match ip { + std::net::IpAddr::V4(v4) => Some(v4), + _ => None, + }) + .collect(); + let mixnet_duration = mixnet_start.elapsed(); + + // Convenience method for a second lookup: + let addrs = resolver.resolve("nymtech.net", 443).await?; + info!("Resolved nymtech.net:443 → {addrs:?}"); + + // --- Compare --- + info!("=== Results ==="); + info!("Clearnet: {:?} ({:?})", clearnet_ips, clearnet_duration); + info!("Mixnet: {:?} ({:?})", mixnet_ips, mixnet_duration); + + let ips_match = !mixnet_ips.is_empty() && mixnet_ips.iter().all(|ip| clearnet_ips.contains(ip)); + info!("IPs match: {ips_match}"); + + let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!("Slowdown: {slowdown:.1}x"); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/dns/src/lib.rs b/smolmix/dns/src/lib.rs new file mode 100644 index 0000000000..979db915e1 --- /dev/null +++ b/smolmix/dns/src/lib.rs @@ -0,0 +1,109 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! DNS resolution through the Nym mixnet. +//! +//! This crate wraps [hickory-resolver] with a newtype [`Resolver`] that routes +//! all DNS traffic through a smolmix [`Tunnel`], ensuring hostnames are resolved +//! via the mixnet rather than leaking queries to the local network. +//! +//! # Quick start +//! +//! ```ignore +//! use smolmix_dns::Resolver; +//! +//! let tunnel = smolmix::Tunnel::new().await?; +//! let resolver = Resolver::new(&tunnel); +//! +//! // Full hickory API via Deref: +//! let lookup = resolver.lookup_ip("example.com").await?; +//! for ip in lookup.iter() { println!("{ip}"); } +//! +//! // Convenience one-shot: +//! let addrs = resolver.resolve("example.com", 443).await?; +//! ``` + +mod provider; + +use std::io; +use std::net::SocketAddr; +use std::ops::Deref; + +use hickory_resolver::name_server::GenericConnector; + +use hickory_proto::runtime::TokioHandle; +use smolmix::Tunnel; + +// Re-exports so users don't need hickory-resolver in their Cargo.toml +pub use hickory_resolver::config::ResolverConfig; +pub use hickory_resolver::lookup_ip::LookupIp; +pub use hickory_resolver::ResolveError; +pub use provider::SmolmixRuntimeProvider; + +/// Inner resolver type alias for readability. +type HickoryResolver = hickory_resolver::Resolver>; + +/// A DNS resolver that routes all queries through a smolmix [`Tunnel`]. +/// +/// Wraps a hickory-resolver `Resolver` and exposes its full API via [`Deref`]. +/// All DNS traffic (both TCP and UDP) travels through the mixnet. +pub struct Resolver { + inner: HickoryResolver, +} + +impl Resolver { + /// Create a resolver using Cloudflare (`1.1.1.1`) as upstream DNS. + pub fn new(tunnel: &Tunnel) -> Self { + Self::with_config(tunnel, ResolverConfig::cloudflare()) + } + + /// Create a resolver with a custom upstream DNS configuration. + pub fn with_config(tunnel: &Tunnel, config: ResolverConfig) -> Self { + let provider = SmolmixRuntimeProvider { + tunnel: tunnel.clone(), + handle: TokioHandle::default(), + }; + let connector = GenericConnector::new(provider); + Self { + inner: hickory_resolver::Resolver::builder_with_config(config, connector).build(), + } + } + + /// Resolve a hostname to socket addresses through the tunnel. + /// + /// Convenience method for one-shot lookups. Returns all resolved addresses + /// paired with the given `port`. + pub async fn resolve(&self, host: &str, port: u16) -> io::Result> { + let lookup = self + .inner + .lookup_ip(host) + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + Ok(lookup.iter().map(|ip| SocketAddr::new(ip, port)).collect()) + } +} + +impl Deref for Resolver { + type Target = HickoryResolver; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Create a hickory [`Resolver`] that routes all DNS through the tunnel. +/// +/// Uses Cloudflare (`1.1.1.1`) as the upstream DNS server. Equivalent to +/// [`Resolver::new()`]. +pub fn resolver(tunnel: &Tunnel) -> Resolver { + Resolver::new(tunnel) +} + +/// Resolve a hostname through the tunnel. +/// +/// Convenience wrapper for one-shot lookups. Equivalent to +/// `Resolver::new(tunnel).resolve(host, port)`. +pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> io::Result> { + let r = resolver(tunnel); + r.resolve(host, port).await +} diff --git a/smolmix/dns/src/provider.rs b/smolmix/dns/src/provider.rs new file mode 100644 index 0000000000..60b0997203 --- /dev/null +++ b/smolmix/dns/src/provider.rs @@ -0,0 +1,96 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! hickory-resolver [`RuntimeProvider`] routing all DNS I/O through a [`Tunnel`]. + +use std::future::Future; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use hickory_proto::runtime::iocompat::AsyncIoTokioAsStd; +use hickory_proto::runtime::{RuntimeProvider, TokioHandle, TokioTime}; +use hickory_proto::udp::DnsUdpSocket; + +use smolmix::Tunnel; + +/// UDP socket wrapper routing DNS queries through the tunnel. +/// +/// Thin newtype around [`tokio_smoltcp::UdpSocket`] implementing hickory's +/// [`DnsUdpSocket`] trait. The poll methods delegate directly since the +/// signatures are identical. +pub struct SmolmixUdpSocket(tokio_smoltcp::UdpSocket); + +impl DnsUdpSocket for SmolmixUdpSocket { + type Time = TokioTime; + + fn poll_recv_from( + &self, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + self.0.poll_recv_from(cx, buf) + } + + fn poll_send_to( + &self, + cx: &mut Context<'_>, + buf: &[u8], + target: SocketAddr, + ) -> Poll> { + self.0.poll_send_to(cx, buf, target) + } +} + +/// Runtime provider that routes all DNS I/O through a [`Tunnel`]. +/// +/// Implements hickory's [`RuntimeProvider`] so the resolver sends TCP and UDP DNS +/// traffic over the mixnet instead of the local network. +#[derive(Clone)] +pub struct SmolmixRuntimeProvider { + pub(crate) tunnel: Tunnel, + pub(crate) handle: TokioHandle, +} + +impl RuntimeProvider for SmolmixRuntimeProvider { + type Handle = TokioHandle; + type Timer = TokioTime; + type Udp = SmolmixUdpSocket; + type Tcp = AsyncIoTokioAsStd; + + fn create_handle(&self) -> Self::Handle { + self.handle.clone() + } + + fn connect_tcp( + &self, + server_addr: SocketAddr, + _bind_addr: Option, + _timeout: Option, + ) -> Pin>>> { + let tunnel = self.tunnel.clone(); + Box::pin(async move { + let tcp = tunnel + .tcp_connect(server_addr) + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + Ok(AsyncIoTokioAsStd(tcp)) + }) + } + + fn bind_udp( + &self, + _local_addr: SocketAddr, + _server_addr: SocketAddr, + ) -> Pin>>> { + let tunnel = self.tunnel.clone(); + Box::pin(async move { + let udp = tunnel + .udp_socket() + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + Ok(SmolmixUdpSocket(udp)) + }) + } +} diff --git a/smolmix/hyper/Cargo.toml b/smolmix/hyper/Cargo.toml new file mode 100644 index 0000000000..a3f401dde0 --- /dev/null +++ b/smolmix/hyper/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "smolmix-hyper" +version = "0.0.1" +edition = "2021" +license.workspace = true + +[dependencies] +smolmix = { workspace = true } +smolmix-dns = { workspace = true } +bytes = { workspace = true } +hyper = { workspace = true, features = ["client", "http1"] } +hyper-util = { workspace = true, features = ["tokio", "client-legacy"] } +http-body-util = { workspace = true } +smolmix-tls = { workspace = true } +tokio-smoltcp = "0.5" +pin-project-lite = "0.2" +tower = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +# Clearnet baseline in example +reqwest = { workspace = true } +rustls = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tracing = { workspace = true } diff --git a/smolmix/hyper/README.md b/smolmix/hyper/README.md new file mode 100644 index 0000000000..c238374e10 --- /dev/null +++ b/smolmix/hyper/README.md @@ -0,0 +1,72 @@ +# smolmix-hyper + +HTTP client routing all traffic through the Nym mixnet. Wraps [hyper-util](https://docs.rs/hyper-util)'s `Client` with a newtype that handles DNS resolution, TCP connections, and TLS — all through a smolmix `Tunnel`. + +## Quick start + +```rust +use smolmix_hyper::{Client, Request, EmptyBody, BodyExt}; +use bytes::Bytes; + +let tunnel = smolmix::Tunnel::new().await?; +let client = Client::new(&tunnel); + +let req = Request::get("https://example.com") + .header("Host", "example.com") + .body(EmptyBody::::new())?; +let resp = client.request(req).await?; +let body = resp.into_body().collect().await?.to_bytes(); +``` + +## API + +- **`Client::new(&tunnel)`** — create an HTTP client (body type: `Empty`, suitable for GET) +- **`Deref` to hyper-util's `Client`** — full `request()`, `get()`, etc. +- **`SmolmixConnector::new(&tunnel)`** — for custom body types (e.g. POST with `Full`) +- **`client(&tunnel)`** — free function returning a `Client` + +### Re-exports + +Commonly-used types are re-exported so you don't need `hyper`, `http-body-util`, or `bytes` in your `Cargo.toml`: + +- `Request`, `Response`, `StatusCode`, `Uri` +- `BodyExt`, `EmptyBody` (alias for `http_body_util::Empty`) +- `bytes` (the crate, for `Bytes`) + +### POST and custom body types + +The `Client` newtype is typed for `Empty` (GET requests). For POST, use `SmolmixConnector` directly: + +```rust +use smolmix_hyper::SmolmixConnector; +use hyper_util::client::legacy::Client; +use http_body_util::Full; + +let connector = SmolmixConnector::new(&tunnel); +let client: Client> = + Client::builder(TokioExecutor::new()).build(connector); + +let req = Request::post("https://httpbin.org/post") + .header("Content-Type", "application/json") + .body(Full::new(Bytes::from(r#"{"key": "value"}"#)))?; +let resp = client.request(req).await?; +``` + +## Examples + +Clearnet-vs-mixnet HTTP comparisons with timing: + +```sh +cargo run -p smolmix-hyper --example get # HTTPS GET +cargo run -p smolmix-hyper --example post # HTTP POST with JSON body +cargo run -p smolmix-hyper --example get -- --ipr +``` + +## Dependencies + +```toml +[dependencies] +smolmix-hyper = { workspace = true } +``` + +This crate depends on `smolmix` and `smolmix-dns` (DNS resolution goes through the tunnel automatically). diff --git a/smolmix/hyper/examples/get.rs b/smolmix/hyper/examples/get.rs new file mode 100644 index 0000000000..5e67c09500 --- /dev/null +++ b/smolmix/hyper/examples/get.rs @@ -0,0 +1,85 @@ +//! HTTPS GET: clearnet vs Nym mixnet comparison. +//! +//! Fetches Cloudflare's `/cdn-cgi/trace` endpoint over clearnet (reqwest) and +//! through the mixnet (smolmix-hyper), then compares responses and timing. +//! +//! Run with: +//! cargo run -p smolmix-hyper --example get +//! cargo run -p smolmix-hyper --example get -- --ipr + +use smolmix::{Recipient, Tunnel}; +use smolmix_hyper::{BodyExt, Client, EmptyBody, Request}; +use tracing::info; + +type BoxError = Box; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + smolmix::init_logging(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + let host = "cloudflare.com"; + let path = "/cdn-cgi/trace"; + + // --- Clearnet baseline via reqwest --- + info!("Fetching via clearnet..."); + let clearnet_start = tokio::time::Instant::now(); + let clearnet_resp = reqwest::get(format!("https://{host}{path}")).await?; + let clearnet_status = clearnet_resp.status(); + let clearnet_body = clearnet_resp.text().await?; + let clearnet_duration = clearnet_start.elapsed(); + info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration); + + // --- Mixnet via smolmix-hyper --- + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let tunnel = if let Some(addr) = ipr_addr { + let recipient: Recipient = addr.parse().expect("invalid IPR address"); + Tunnel::new_with_ipr(recipient).await? + } else { + Tunnel::new().await? + }; + + let client = Client::new(&tunnel); + let mixnet_start = tokio::time::Instant::now(); + + let req = Request::get(format!("https://{host}{path}")) + .header("Host", host) + .body(EmptyBody::::new())?; + let resp = client.request(req).await?; + + let mixnet_status = resp.status(); + let body_bytes = resp.into_body().collect().await?.to_bytes(); + let mixnet_body = String::from_utf8_lossy(&body_bytes); + let mixnet_duration = mixnet_start.elapsed(); + + // --- Compare --- + info!("=== Results ==="); + info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration); + info!("Mixnet: {} in {:?}", mixnet_status, mixnet_duration); + info!("Status match: {}", clearnet_status == mixnet_status); + + let fields = ["fl=", "visit_scheme=https", "uag="]; + for field in fields { + let clearnet_has = clearnet_body.contains(field); + let mixnet_has = mixnet_body.contains(field); + info!(" {field:<25} clearnet={clearnet_has} mixnet={mixnet_has}"); + } + + let clearnet_ip = clearnet_body.lines().find(|l| l.starts_with("ip=")); + let mixnet_ip = mixnet_body.lines().find(|l| l.starts_with("ip=")); + info!(" Clearnet IP: {}", clearnet_ip.unwrap_or("?")); + info!(" Mixnet IP: {}", mixnet_ip.unwrap_or("?")); + + let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!(" Slowdown: {slowdown:.1}x"); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/hyper/examples/post.rs b/smolmix/hyper/examples/post.rs new file mode 100644 index 0000000000..5e6fedd213 --- /dev/null +++ b/smolmix/hyper/examples/post.rs @@ -0,0 +1,98 @@ +//! HTTP POST: clearnet vs Nym mixnet comparison. +//! +//! Sends a POST request with a JSON body to httpbin.org via clearnet (reqwest) +//! and through the mixnet (smolmix-hyper with SmolmixConnector), then compares +//! responses and timing. +//! +//! Demonstrates using `SmolmixConnector` directly for requests that carry a body +//! (the `Client` newtype uses `Empty` — for POST you build a custom client). +//! +//! Run with: +//! cargo run -p smolmix-hyper --example post +//! cargo run -p smolmix-hyper --example post -- --ipr + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::Request; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use smolmix::{Recipient, Tunnel}; +use smolmix_hyper::SmolmixConnector; +use tracing::info; + +type BoxError = Box; + +const URL: &str = "https://httpbin.org/post"; +const JSON_BODY: &str = r#"{"message": "hello from the Nym mixnet!"}"#; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + smolmix::init_logging(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + // --- Clearnet baseline via reqwest --- + info!("POST via clearnet..."); + let clearnet_start = tokio::time::Instant::now(); + let clearnet_resp = reqwest::Client::new() + .post(URL) + .header("Content-Type", "application/json") + .body(JSON_BODY) + .send() + .await?; + let clearnet_status = clearnet_resp.status(); + let clearnet_body = clearnet_resp.text().await?; + let clearnet_duration = clearnet_start.elapsed(); + info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration); + + // --- Mixnet via smolmix-hyper --- + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let tunnel = if let Some(addr) = ipr_addr { + let recipient: Recipient = addr.parse().expect("invalid IPR address"); + Tunnel::new_with_ipr(recipient).await? + } else { + Tunnel::new().await? + }; + + // For POST requests, use SmolmixConnector directly with a Full body + let connector = SmolmixConnector::new(&tunnel); + let client: Client> = + Client::builder(TokioExecutor::new()).build(connector); + + info!("POST via mixnet..."); + let mixnet_start = tokio::time::Instant::now(); + let req = Request::post(URL) + .header("Host", "httpbin.org") + .header("Content-Type", "application/json") + .body(Full::new(Bytes::from(JSON_BODY)))?; + let resp = client.request(req).await?; + + let mixnet_status = resp.status(); + let body_bytes = resp.into_body().collect().await?.to_bytes(); + let mixnet_body = String::from_utf8_lossy(&body_bytes); + let mixnet_duration = mixnet_start.elapsed(); + + // --- Compare --- + info!("=== Results ==="); + info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration); + info!("Mixnet: {} in {:?}", mixnet_status, mixnet_duration); + info!("Status match: {}", clearnet_status == mixnet_status); + + // Check that the echo'd body contains our message + let clearnet_has_msg = clearnet_body.contains("hello from the Nym mixnet!"); + let mixnet_has_msg = mixnet_body.contains("hello from the Nym mixnet!"); + info!("Body echo clearnet: {clearnet_has_msg}"); + info!("Body echo mixnet: {mixnet_has_msg}"); + + let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!("Slowdown: {slowdown:.1}x"); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/hyper/src/connector.rs b/smolmix/hyper/src/connector.rs new file mode 100644 index 0000000000..0bc240eaa5 --- /dev/null +++ b/smolmix/hyper/src/connector.rs @@ -0,0 +1,86 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! [`tower::Service`] connector routing TCP + TLS through a [`Tunnel`]. + +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use hyper::Uri; +use hyper_util::rt::TokioIo; +use tower::Service; + +use smolmix::Tunnel; +use smolmix_tls::TlsConnector; + +use crate::tls_stream::MaybeTlsStream; + +/// A hyper connector that routes TCP (and optionally TLS) through a [`Tunnel`]. +/// +/// Implements [`tower::Service`] so it plugs directly into hyper-util's `Client`. +/// DNS resolution also goes through the tunnel, preventing hostname leaks. +#[derive(Clone)] +pub struct SmolmixConnector { + tunnel: Tunnel, + tls: TlsConnector, +} + +impl SmolmixConnector { + /// Create a new connector for the given tunnel. + /// + /// Sets up a TLS connector with the standard webpki root certificates. + pub fn new(tunnel: &Tunnel) -> Self { + Self { + tunnel: tunnel.clone(), + tls: smolmix_tls::connector(), + } + } +} + +impl Service for SmolmixConnector { + type Response = TokioIo; + type Error = io::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, uri: Uri) -> Self::Future { + let tunnel = self.tunnel.clone(); + let tls = self.tls.clone(); + + Box::pin(async move { + let scheme = uri.scheme_str().unwrap_or("https"); + let host = uri + .host() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "URI missing host"))? + .to_owned(); + let port = uri + .port_u16() + .unwrap_or(if scheme == "https" { 443 } else { 80 }); + + let addrs = smolmix_dns::resolve(&tunnel, &host, port).await?; + let addr = addrs + .into_iter() + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::AddrNotAvailable, "no addresses"))?; + + let tcp = tunnel + .tcp_connect(addr) + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + let stream = if scheme == "https" { + let tls_stream = smolmix_tls::connect_with(&tls, tcp, &host).await?; + MaybeTlsStream::Tls { inner: tls_stream } + } else { + MaybeTlsStream::Plain { inner: tcp } + }; + + Ok(TokioIo::new(stream)) + }) + } +} diff --git a/smolmix/hyper/src/lib.rs b/smolmix/hyper/src/lib.rs new file mode 100644 index 0000000000..554b61a569 --- /dev/null +++ b/smolmix/hyper/src/lib.rs @@ -0,0 +1,84 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! HTTP client routing all traffic through the Nym mixnet. +//! +//! This crate wraps [hyper-util]'s `Client` with a newtype [`Client`] that +//! routes DNS, TCP, and TLS through a smolmix [`Tunnel`]. It feels like a +//! drop-in replacement for `hyper_util::client::legacy::Client`. +//! +//! # Quick start +//! +//! ```ignore +//! use smolmix_hyper::{Client, Request, EmptyBody, BodyExt}; +//! use bytes::Bytes; +//! +//! let tunnel = smolmix::Tunnel::new().await?; +//! let client = Client::new(&tunnel); +//! +//! let req = Request::get("https://example.com") +//! .header("Host", "example.com") +//! .body(EmptyBody::::new())?; +//! let resp = client.request(req).await?; +//! let body = resp.into_body().collect().await?.to_bytes(); +//! ``` + +mod connector; +pub mod tls_stream; + +use std::ops::Deref; + +use bytes::Bytes; +use http_body_util::Empty; +use hyper_util::client::legacy; +use hyper_util::rt::TokioExecutor; + +use smolmix::Tunnel; + +// Re-exports so users don't need hyper/http-body-util/bytes in their Cargo.toml +pub use bytes; +pub use http_body_util::{BodyExt, Empty as EmptyBody}; +pub use hyper::{Request, Response, StatusCode, Uri}; + +pub use connector::SmolmixConnector; +pub use tls_stream::MaybeTlsStream; + +/// Inner hyper-util client type alias for readability. +type HyperClient = legacy::Client>; + +/// An HTTP client that routes all traffic through a smolmix [`Tunnel`]. +/// +/// Wraps a hyper-util `Client` and exposes its full API via [`Deref`]. DNS +/// resolution, TCP connections, and TLS all travel through the mixnet. +/// +/// The body type is [`Empty`], suitable for GET requests. For requests +/// that carry a body, construct a [`SmolmixConnector`] directly and pass it +/// to [`hyper_util::client::legacy::Client::builder`]. +pub struct Client { + inner: HyperClient, +} + +impl Client { + /// Create a new HTTP client for the given tunnel. + pub fn new(tunnel: &Tunnel) -> Self { + let connector = SmolmixConnector::new(tunnel); + Self { + inner: legacy::Client::builder(TokioExecutor::new()).build(connector), + } + } +} + +impl Deref for Client { + type Target = HyperClient; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Create a hyper-util [`Client`] that routes all traffic through the tunnel. +/// +/// Equivalent to [`Client::new()`]. +pub fn client(tunnel: &Tunnel) -> Client { + Client::new(tunnel) +} diff --git a/smolmix/hyper/src/tls_stream.rs b/smolmix/hyper/src/tls_stream.rs new file mode 100644 index 0000000000..8d3ae44ecc --- /dev/null +++ b/smolmix/hyper/src/tls_stream.rs @@ -0,0 +1,67 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! TLS stream abstraction for plain and encrypted connections. + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use hyper_util::client::legacy::connect::{Connected, Connection}; +use pin_project_lite::pin_project; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +pin_project! { + /// A stream that may or may not be wrapped in TLS. + #[project = MaybeTlsProj] + pub enum MaybeTlsStream { + Plain { #[pin] inner: tokio_smoltcp::TcpStream }, + Tls { #[pin] inner: smolmix_tls::TlsStream }, + } +} + +impl AsyncRead for MaybeTlsStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.project() { + MaybeTlsProj::Plain { inner } => inner.poll_read(cx, buf), + MaybeTlsProj::Tls { inner } => inner.poll_read(cx, buf), + } + } +} + +impl AsyncWrite for MaybeTlsStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.project() { + MaybeTlsProj::Plain { inner } => inner.poll_write(cx, buf), + MaybeTlsProj::Tls { inner } => inner.poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + MaybeTlsProj::Plain { inner } => inner.poll_flush(cx), + MaybeTlsProj::Tls { inner } => inner.poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + MaybeTlsProj::Plain { inner } => inner.poll_shutdown(cx), + MaybeTlsProj::Tls { inner } => inner.poll_shutdown(cx), + } + } +} + +impl Connection for MaybeTlsStream { + fn connected(&self) -> Connected { + Connected::new() + } +} diff --git a/smolmix/libp2p/Cargo.toml b/smolmix/libp2p/Cargo.toml new file mode 100644 index 0000000000..91264d5637 --- /dev/null +++ b/smolmix/libp2p/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "smolmix-libp2p" +version = "0.0.1" +edition = "2021" +license.workspace = true + +[dependencies] +smolmix = { workspace = true } +smolmix-dns = { workspace = true } +libp2p = { version = "0.56", features = ["tokio", "noise", "yamux", "ping", "macros"] } +tokio-smoltcp = "0.5" +tokio-util = { workspace = true, features = ["compat"] } +tracing = { workspace = true } + +[dev-dependencies] +# tcp feature only needed for the clearnet listener example +libp2p = { version = "0.56", features = ["tcp"] } +futures = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tracing-subscriber = { workspace = true } diff --git a/smolmix/libp2p/README.md b/smolmix/libp2p/README.md new file mode 100644 index 0000000000..b2f37c4be5 --- /dev/null +++ b/smolmix/libp2p/README.md @@ -0,0 +1,79 @@ +# smolmix-libp2p + +libp2p `Transport` implementation that routes connections through the Nym mixnet. Because smolmix provides real TCP streams (via a user-space smoltcp stack), libp2p's standard noise encryption and yamux multiplexing work out of the box -- no custom `Connection`, `Substream`, or message ordering needed. + +## Quick start + +```rust +use libp2p::{noise, yamux, SwarmBuilder}; +use libp2p::core::upgrade::Version; +use libp2p::core::Transport; +use smolmix_libp2p::SmolmixTransport; + +let tunnel = smolmix::Tunnel::new().await?; + +let mut swarm = SwarmBuilder::with_new_identity() + .with_tokio() + .with_other_transport(|keypair| { + SmolmixTransport::new(&tunnel) + .upgrade(Version::V1) + .authenticate(noise::Config::new(keypair).expect("noise config")) + .multiplex(yamux::Config::default()) + .boxed() + })? + .with_behaviour(|_| libp2p::ping::Behaviour::default())? + .build(); + +swarm.dial("/ip4/1.2.3.4/tcp/12345".parse::()?)?; +``` + +## API + +- **`SmolmixTransport::new(&tunnel)`** -- create a transport backed by a smolmix `Tunnel` + +### Transport trait + +- **`dial(addr, opts)`** -- parse multiaddr, resolve DNS if needed, TCP connect through the tunnel +- **`listen_on(id, addr)`** -- returns `MultiaddrNotSupported` (dial-only, no inbound) +- **`poll(cx)`** -- always `Pending` (no listener events) + +### Supported multiaddrs + +| Multiaddr | Behaviour | +|---|---| +| `/ip4//tcp/` | Direct TCP connect through tunnel | +| `/ip6//tcp/` | Direct TCP connect through tunnel | +| `/dns4//tcp/` | DNS resolved through tunnel, then TCP connect | +| `/dns//tcp/` | DNS resolved through tunnel, then TCP connect | + +Trailing components (like `/p2p/`) are ignored by the transport and handled by the swarm. + +## Examples + +Two examples demonstrate the full workflow — a clearnet listener and a mixnet dialer: + +```sh +# Terminal 1: start a standard libp2p node (clearnet TCP, noise + yamux) +cargo run -p smolmix-libp2p --example listener +# → prints: /ip4/127.0.0.1/tcp/12345/p2p/12D3KooW... + +# Terminal 2: dial it through the Nym mixnet +cargo run -p smolmix-libp2p --example ping -- /ip4//tcp//p2p/ +cargo run -p smolmix-libp2p --example ping -- --ipr /ip4/.../tcp/.../p2p/... +``` + +The listener sees a connection arriving from the exit gateway's IP — it has no idea the dialer is on the mixnet. + +## Limitations + +- **Dial-only** -- listening requires IPR listener support (future work) +- **No TLS in transport** -- libp2p uses noise for encryption; TLS would be redundant + +## Dependencies + +```toml +[dependencies] +smolmix-libp2p = { workspace = true } +``` + +This crate depends on `smolmix`, `smolmix-dns`, `libp2p` (0.56), and `tokio-util` (for the `Compat` bridge between tokio and futures I/O traits). diff --git a/smolmix/libp2p/examples/listener.rs b/smolmix/libp2p/examples/listener.rs new file mode 100644 index 0000000000..05a2d57bf1 --- /dev/null +++ b/smolmix/libp2p/examples/listener.rs @@ -0,0 +1,60 @@ +//! Clearnet libp2p listener that accepts pings. +//! +//! Run this first, then pass the printed address to the `ping` example which +//! dials through the Nym mixnet. +//! +//! Run with: +//! cargo run -p smolmix-libp2p --example listener +//! +//! Then in another terminal: +//! cargo run -p smolmix-libp2p --example ping -- + +use futures::StreamExt; +use libp2p::swarm::SwarmEvent; +use libp2p::{noise, ping, yamux, SwarmBuilder}; + +type BoxError = Box; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + tracing_subscriber::fmt().with_env_filter("info").init(); + + let mut swarm = SwarmBuilder::with_new_identity() + .with_tokio() + .with_tcp( + Default::default(), + noise::Config::new, + yamux::Config::default, + )? + .with_behaviour(|_| ping::Behaviour::default())? + .build(); + + let local_peer_id = *swarm.local_peer_id(); + swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?; + + println!("Peer ID: {local_peer_id}"); + println!("Waiting for listen address..."); + + loop { + match swarm.select_next_some().await { + SwarmEvent::NewListenAddr { address, .. } => { + let full_addr = format!("{address}/p2p/{local_peer_id}"); + println!(); + println!("Listening! Pass this address to the ping example:"); + println!(" {full_addr}"); + println!(); + } + SwarmEvent::ConnectionEstablished { peer_id, .. } => { + println!("Connection established from {peer_id}"); + } + SwarmEvent::Behaviour(ping::Event { peer, result, .. }) => match result { + Ok(rtt) => println!("Ping from {peer}: {rtt:?}"), + Err(e) => println!("Ping error from {peer}: {e}"), + }, + SwarmEvent::ConnectionClosed { peer_id, .. } => { + println!("Connection closed: {peer_id}"); + } + _ => {} + } + } +} diff --git a/smolmix/libp2p/examples/ping.rs b/smolmix/libp2p/examples/ping.rs new file mode 100644 index 0000000000..da705821c5 --- /dev/null +++ b/smolmix/libp2p/examples/ping.rs @@ -0,0 +1,106 @@ +//! libp2p ping through the Nym mixnet. +//! +//! Dials a libp2p peer through the mixnet and exchanges pings. Start the +//! `listener` example first to get an address, then pass it here. +//! +//! Run with: +//! # Terminal 1: start the clearnet listener +//! cargo run -p smolmix-libp2p --example listener +//! +//! # Terminal 2: dial through the mixnet +//! cargo run -p smolmix-libp2p --example ping -- +//! cargo run -p smolmix-libp2p --example ping -- --ipr + +use futures::StreamExt; +use libp2p::core::upgrade::Version; +use libp2p::core::Transport; +use libp2p::swarm::SwarmEvent; +use libp2p::{noise, ping, yamux, Multiaddr, SwarmBuilder}; +use smolmix::{Recipient, Tunnel}; +use smolmix_libp2p::SmolmixTransport; +use tracing::info; + +type BoxError = Box; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + smolmix::init_logging(); + + let args: Vec = std::env::args().collect(); + + // Parse optional --ipr flag + let ipr_pos = args.iter().position(|a| a == "--ipr"); + let ipr_addr = ipr_pos.and_then(|i| args.get(i + 1)); + + // The multiaddr is the last positional argument (skip --ipr and its value) + let multiaddr_str = args + .iter() + .enumerate() + .filter(|(i, _)| *i != 0 && Some(*i) != ipr_pos && Some(*i) != ipr_pos.map(|p| p + 1)) + .map(|(_, s)| s) + .last() + .expect( + "Usage: ping [--ipr ] \n\ + \n\ + Start the listener first:\n\ + \x20 cargo run -p smolmix-libp2p --example listener", + ); + let remote: Multiaddr = multiaddr_str.parse()?; + + // Create the tunnel + let tunnel = if let Some(addr) = ipr_addr { + let recipient: Recipient = addr.parse().expect("invalid IPR address"); + Tunnel::new_with_ipr(recipient).await? + } else { + Tunnel::new().await? + }; + + // Build the swarm with our mixnet transport + let mut swarm = SwarmBuilder::with_new_identity() + .with_tokio() + .with_other_transport(|keypair| { + SmolmixTransport::new(&tunnel) + .upgrade(Version::V1) + .authenticate(noise::Config::new(keypair).expect("noise config")) + .multiplex(yamux::Config::default()) + .boxed() + })? + .with_behaviour(|_| ping::Behaviour::default())? + .build(); + + info!("Local peer ID: {}", swarm.local_peer_id()); + info!("Dialing {remote} through the Nym mixnet..."); + swarm.dial(remote)?; + + // Drive the swarm and print ping results + let mut pings = 0u32; + loop { + match swarm.select_next_some().await { + SwarmEvent::Behaviour(ping::Event { peer, result, .. }) => match result { + Ok(rtt) => { + pings += 1; + info!("Ping #{pings} to {peer}: {rtt:?}"); + if pings >= 5 { + info!("Done — {pings} pings completed."); + break; + } + } + Err(e) => { + info!("Ping error from {peer}: {e}"); + break; + } + }, + SwarmEvent::ConnectionEstablished { peer_id, .. } => { + info!("Connected to {peer_id}"); + } + SwarmEvent::OutgoingConnectionError { error, .. } => { + info!("Connection error: {error}"); + break; + } + _ => {} + } + } + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/libp2p/src/lib.rs b/smolmix/libp2p/src/lib.rs new file mode 100644 index 0000000000..e349422681 --- /dev/null +++ b/smolmix/libp2p/src/lib.rs @@ -0,0 +1,174 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! libp2p [`Transport`] that routes connections through the Nym mixnet. +//! +//! [`SmolmixTransport`] implements the libp2p [`Transport`] trait by parsing +//! multiaddrs, optionally resolving DNS through the tunnel, and opening TCP +//! connections via smolmix. Because smolmix provides real TCP streams (via a +//! user-space smoltcp stack), libp2p's standard noise encryption and yamux +//! multiplexing work out of the box. +//! +//! # Supported multiaddrs +//! +//! - `/ip4//tcp/` +//! - `/ip6//tcp/` +//! - `/dns4//tcp/` (resolved through the tunnel) +//! - `/dns//tcp/` (resolved through the tunnel) +//! +//! # Quick start +//! +//! ```ignore +//! use libp2p::{noise, yamux, SwarmBuilder}; +//! use libp2p::core::{upgrade::Version, Transport}; +//! use smolmix_libp2p::SmolmixTransport; +//! +//! let tunnel = smolmix::Tunnel::new().await?; +//! +//! let swarm = SwarmBuilder::with_new_identity() +//! .with_tokio() +//! .with_other_transport(|keypair| { +//! SmolmixTransport::new(&tunnel) +//! .upgrade(Version::V1) +//! .authenticate(noise::Config::new(keypair).expect("noise config")) +//! .multiplex(yamux::Config::default()) +//! .boxed() +//! })? +//! .with_behaviour(|_| libp2p::ping::Behaviour::default())? +//! .build(); +//! ``` +//! +//! # Limitations +//! +//! - **Dial-only**: listening is not supported (no inbound connections through +//! the mixnet). `listen_on()` returns [`TransportError::MultiaddrNotSupported`]. +//! - **No TLS in transport**: libp2p uses noise for encryption, so adding TLS +//! would be redundant. + +use std::future::Future; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use libp2p::core::multiaddr::Protocol; +use libp2p::core::transport::{DialOpts, ListenerId, TransportError, TransportEvent}; +use libp2p::core::Transport; +use libp2p::Multiaddr; +use tokio_util::compat::{Compat, TokioAsyncReadCompatExt}; +use tracing::debug; + +use smolmix::Tunnel; + +/// A libp2p transport that routes TCP connections through a smolmix [`Tunnel`]. +/// +/// Supports dialing peers by IP or hostname (DNS resolved through the tunnel). +/// Does not support listening — the mixnet doesn't provide inbound TCP yet. +pub struct SmolmixTransport { + tunnel: Tunnel, +} + +impl SmolmixTransport { + /// Create a new transport backed by the given tunnel. + pub fn new(tunnel: &Tunnel) -> Self { + Self { + tunnel: tunnel.clone(), + } + } +} + +/// Parsed dial target from a multiaddr. +enum DialTarget { + Ip(SocketAddr), + Dns { host: String, port: u16 }, +} + +/// Extract a dial target from a multiaddr. +/// +/// Accepts `/ip4/.../tcp/N`, `/ip6/.../tcp/N`, `/dns4/.../tcp/N`, and +/// `/dns/.../tcp/N`. Trailing components (like `/p2p/...`) are ignored. +fn parse_multiaddr(addr: &Multiaddr) -> Option { + let mut iter = addr.iter(); + match iter.next()? { + Protocol::Ip4(ip) => { + if let Some(Protocol::Tcp(port)) = iter.next() { + Some(DialTarget::Ip(SocketAddr::new(ip.into(), port))) + } else { + None + } + } + Protocol::Ip6(ip) => { + if let Some(Protocol::Tcp(port)) = iter.next() { + Some(DialTarget::Ip(SocketAddr::new(ip.into(), port))) + } else { + None + } + } + Protocol::Dns4(host) | Protocol::Dns(host) => { + if let Some(Protocol::Tcp(port)) = iter.next() { + Some(DialTarget::Dns { + host: host.to_string(), + port, + }) + } else { + None + } + } + _ => None, + } +} + +impl Transport for SmolmixTransport { + type Output = Compat; + type Error = io::Error; + type ListenerUpgrade = Pin> + Send>>; + type Dial = Pin> + Send>>; + + fn listen_on( + &mut self, + _id: ListenerId, + addr: Multiaddr, + ) -> Result<(), TransportError> { + Err(TransportError::MultiaddrNotSupported(addr)) + } + + fn remove_listener(&mut self, _id: ListenerId) -> bool { + false + } + + fn dial( + &mut self, + addr: Multiaddr, + _opts: DialOpts, + ) -> Result> { + let target = parse_multiaddr(&addr).ok_or(TransportError::MultiaddrNotSupported(addr))?; + let tunnel = self.tunnel.clone(); + + Ok(Box::pin(async move { + let socket_addr = match target { + DialTarget::Ip(addr) => addr, + DialTarget::Dns { host, port } => { + debug!(host = %host, port, "resolving DNS through tunnel"); + let addrs = smolmix_dns::resolve(&tunnel, &host, port).await?; + addrs.into_iter().next().ok_or_else(|| { + io::Error::new(io::ErrorKind::AddrNotAvailable, "DNS returned no addresses") + })? + } + }; + + debug!(addr = %socket_addr, "connecting TCP through tunnel"); + let tcp = tunnel + .tcp_connect(socket_addr) + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + Ok(tcp.compat()) + })) + } + + fn poll( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} diff --git a/smolmix/tls/Cargo.toml b/smolmix/tls/Cargo.toml new file mode 100644 index 0000000000..c8b58f280a --- /dev/null +++ b/smolmix/tls/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smolmix-tls" +version = "0.0.1" +edition = "2021" +license.workspace = true + +[dependencies] +tokio-smoltcp = "0.5" +tokio-rustls = "0.26" +rustls = { workspace = true } +webpki-roots = { workspace = true } diff --git a/smolmix/tls/README.md b/smolmix/tls/README.md new file mode 100644 index 0000000000..3029dd666b --- /dev/null +++ b/smolmix/tls/README.md @@ -0,0 +1,42 @@ +# smolmix-tls + +Shared TLS setup for smolmix tunneled connections. Provides a pre-configured `TlsConnector` with webpki root certificates and convenience functions for TLS over `TcpStream`, used internally by `smolmix-hyper` and `smolmix-tungstenite`. + +## Quick start + +```rust +use smolmix_tls::{connect, connector, connect_with}; + +let tunnel = smolmix::Tunnel::new().await?; +let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?; + +// One-shot: TLS handshake over an existing TCP stream. +let tls_stream = connect(tcp, "example.com").await?; + +// Reusable: create a connector once, use for many connections. +let tls = connector(); +let stream1 = connect_with(&tls, tcp1, "a.example.com").await?; +let stream2 = connect_with(&tls, tcp2, "b.example.com").await?; +``` + +## API + +- **`connector()`** -- create a reusable `TlsConnector` with webpki root certificates (clones cheaply via `Arc`) +- **`connect(tcp, hostname)`** -- one-shot TLS handshake (creates a fresh connector internally) +- **`connect_with(&connector, tcp, hostname)`** -- TLS handshake with a pre-built connector (avoids rebuilding the root store) + +### Re-exports + +Commonly-used types are re-exported so you don't need `tokio-rustls` in your `Cargo.toml`: + +- `TlsStream` -- the connected TLS stream type (`tokio_rustls::client::TlsStream`) +- `TlsConnector` -- the connector type (`tokio_rustls::TlsConnector`) + +## Dependencies + +```toml +[dependencies] +smolmix-tls = { workspace = true } +``` + +This crate depends on `tokio-rustls`, `rustls`, and `webpki-roots`. diff --git a/smolmix/tls/src/lib.rs b/smolmix/tls/src/lib.rs new file mode 100644 index 0000000000..f90afe57ed --- /dev/null +++ b/smolmix/tls/src/lib.rs @@ -0,0 +1,62 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! Shared TLS setup for smolmix tunneled connections. +//! +//! Provides a pre-configured [`TlsConnector`] with webpki root certificates +//! and convenience functions for TLS over [`TcpStream`]. +//! +//! # Quick start +//! +//! ```ignore +//! // One-shot: TLS handshake over an existing TCP stream. +//! let tls_stream = smolmix_tls::connect(tcp, "example.com").await?; +//! +//! // Reusable: create a connector once, use it for many connections. +//! let tls = smolmix_tls::connector(); +//! let stream1 = smolmix_tls::connect_with(&tls, tcp1, "a.com").await?; +//! let stream2 = smolmix_tls::connect_with(&tls, tcp2, "b.com").await?; +//! ``` + +use std::io; +use std::sync::Arc; + +use rustls::pki_types::ServerName; +use tokio_smoltcp::TcpStream; + +pub use tokio_rustls::client::TlsStream; +pub use tokio_rustls::TlsConnector; + +/// Create a [`TlsConnector`] configured with the standard webpki root certificates. +/// +/// The returned connector can be cloned cheaply (it wraps an `Arc`) +/// and reused across many connections. +pub fn connector() -> TlsConnector { + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + TlsConnector::from(Arc::new(config)) +} + +/// Perform a TLS handshake over an existing TCP stream. +/// +/// Creates a fresh [`TlsConnector`] with webpki roots. For repeated connections, +/// prefer [`connect_with()`] to avoid rebuilding the root store each time. +pub async fn connect(tcp: TcpStream, hostname: &str) -> io::Result> { + connect_with(&connector(), tcp, hostname).await +} + +/// Perform a TLS handshake using a pre-built connector. +/// +/// Extracts the SNI hostname from `hostname` and connects. +pub async fn connect_with( + tls: &TlsConnector, + tcp: TcpStream, + hostname: &str, +) -> io::Result> { + let domain = ServerName::try_from(hostname.to_owned()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + tls.connect(domain, tcp).await +} diff --git a/smolmix/tungstenite/Cargo.toml b/smolmix/tungstenite/Cargo.toml new file mode 100644 index 0000000000..2e3a000ab6 --- /dev/null +++ b/smolmix/tungstenite/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "smolmix-tungstenite" +version = "0.0.1" +edition = "2021" +license.workspace = true + +[dependencies] +smolmix = { workspace = true } +smolmix-dns = { workspace = true } +smolmix-tls = { workspace = true } +tokio-smoltcp = "0.5" +tokio-tungstenite = { workspace = true } + +[dev-dependencies] +futures = { workspace = true } +rustls = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "net"] } +tracing = { workspace = true } diff --git a/smolmix/tungstenite/README.md b/smolmix/tungstenite/README.md new file mode 100644 index 0000000000..9c9a8134e5 --- /dev/null +++ b/smolmix/tungstenite/README.md @@ -0,0 +1,55 @@ +# smolmix-tungstenite + +WebSocket connections through the Nym mixnet. Wraps [tokio-tungstenite](https://docs.rs/tokio-tungstenite) with a `connect()` function that handles DNS resolution, TCP, TLS, and the WebSocket upgrade — all through a smolmix `Tunnel`. + +## Quick start + +```rust +use smolmix_tungstenite::{connect, Message}; +use futures::{SinkExt, StreamExt}; + +let tunnel = smolmix::Tunnel::new().await?; +let (mut ws, _resp) = connect(&tunnel, "wss://echo.websocket.org").await?; + +ws.send(Message::Text("hello".into())).await?; +let reply = ws.next().await.ok_or("no reply")??; +println!("{}", reply.into_text()?); +ws.close(None).await?; +``` + +## API + +- **`connect(&tunnel, url)`** — connect to a `wss://` WebSocket server through the mixnet + - Handles DNS, TCP, TLS, and WebSocket upgrade in one call + - Returns `(WsStream, Response)` — the stream implements `Sink + Stream` + - Only `wss://` is supported (use raw `TcpStream` for unencrypted WebSocket) + +### Re-exports + +Commonly-used types are re-exported so you don't need `tokio-tungstenite` or `tungstenite` in your `Cargo.toml`: + +- `Message` — the WebSocket message type +- `tungstenite` — the underlying tungstenite crate (for `Error`, `protocol::WebSocketConfig`, etc.) + +### Types + +- **`WsStream`** — type alias for `WebSocketStream>` +- **`Error`** — wraps `io::Error` and `tungstenite::Error` + +## Example + +Clearnet-vs-mixnet WebSocket echo comparison with timing: + +```sh +cargo run -p smolmix-tungstenite --example echo +cargo run -p smolmix-tungstenite --example echo -- --ipr +``` + +## Dependencies + +```toml +[dependencies] +smolmix-tungstenite = { workspace = true } +``` + +This crate depends on `smolmix` and `smolmix-dns` (DNS resolution goes through the tunnel automatically). diff --git a/smolmix/tungstenite/examples/echo.rs b/smolmix/tungstenite/examples/echo.rs new file mode 100644 index 0000000000..991289438a --- /dev/null +++ b/smolmix/tungstenite/examples/echo.rs @@ -0,0 +1,75 @@ +//! WebSocket echo: clearnet vs Nym mixnet comparison. +//! +//! Sends a message to a public echo server over clearnet (tokio-tungstenite +//! directly) and through the mixnet (smolmix-tungstenite), then compares +//! responses and timing. +//! +//! Run with: +//! cargo run -p smolmix-tungstenite --example echo +//! cargo run -p smolmix-tungstenite --example echo -- --ipr + +use futures::{SinkExt, StreamExt}; +use smolmix::{Recipient, Tunnel}; +use smolmix_tungstenite::Message; +use tracing::info; + +type BoxError = Box; + +const WS_URL: &str = "wss://ws.postman-echo.com/raw"; +const ECHO_MSG: &str = "Hello from the Nym mixnet!"; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + smolmix::init_logging(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + // --- Clearnet baseline via tokio-tungstenite --- + info!("Connecting via clearnet..."); + let clearnet_start = tokio::time::Instant::now(); + + let (mut clearnet_ws, _) = tokio_tungstenite::connect_async(WS_URL).await?; + clearnet_ws.send(Message::Text(ECHO_MSG.into())).await?; + let clearnet_reply = clearnet_ws.next().await.ok_or("no clearnet reply")??; + let clearnet_duration = clearnet_start.elapsed(); + let clearnet_text = clearnet_reply.into_text()?; + clearnet_ws.close(None).await?; + + info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}"); + + // --- Mixnet via smolmix-tungstenite --- + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let tunnel = if let Some(addr) = ipr_addr { + let recipient: Recipient = addr.parse().expect("invalid IPR address"); + Tunnel::new_with_ipr(recipient).await? + } else { + Tunnel::new().await? + }; + + let mixnet_start = tokio::time::Instant::now(); + let (mut mixnet_ws, _) = smolmix_tungstenite::connect(&tunnel, WS_URL).await?; + mixnet_ws.send(Message::Text(ECHO_MSG.into())).await?; + let mixnet_reply = mixnet_ws.next().await.ok_or("no mixnet reply")??; + let mixnet_duration = mixnet_start.elapsed(); + let mixnet_text = mixnet_reply.into_text()?; + mixnet_ws.close(None).await?; + + // --- Compare --- + info!("=== Results ==="); + info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}"); + info!("Mixnet: \"{mixnet_text}\" in {mixnet_duration:?}"); + info!("Clearnet echo match: {}", clearnet_text == ECHO_MSG); + info!("Mixnet echo match: {}", mixnet_text == ECHO_MSG); + + let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!("Slowdown: {slowdown:.1}x"); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/tungstenite/src/lib.rs b/smolmix/tungstenite/src/lib.rs new file mode 100644 index 0000000000..eeb014a214 --- /dev/null +++ b/smolmix/tungstenite/src/lib.rs @@ -0,0 +1,142 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! WebSocket connections through the Nym mixnet. +//! +//! This crate wraps [tokio-tungstenite] with a convenience [`connect()`] +//! function that handles DNS resolution, TCP, TLS, and the WebSocket upgrade +//! — all through a smolmix [`Tunnel`]. +//! +//! # Quick start +//! +//! ```ignore +//! use smolmix_tungstenite::connect; +//! use futures::{SinkExt, StreamExt}; +//! +//! let tunnel = smolmix::Tunnel::new().await?; +//! let (mut ws, _resp) = connect(&tunnel, "wss://echo.websocket.org").await?; +//! +//! ws.send(Message::Text("hello".into())).await?; +//! let reply = ws.next().await.ok_or("no reply")??; +//! ``` + +use std::io; + +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::error::Error as WsError; +use tokio_tungstenite::tungstenite::http::Response as WsResponse; +use tokio_tungstenite::WebSocketStream; + +use smolmix::Tunnel; + +// Re-exports so users don't need tokio-tungstenite or tungstenite in their Cargo.toml +pub use tokio_tungstenite::tungstenite; +pub use tokio_tungstenite::tungstenite::Message; + +/// The WebSocket stream type returned by [`connect()`]. +/// +/// This is a `WebSocketStream` over a TLS-wrapped TCP connection through the +/// tunnel. Use it with `futures::SinkExt` and `futures::StreamExt`. +pub type WsStream = WebSocketStream>; + +/// Connect to a WebSocket server through the tunnel. +/// +/// Handles DNS resolution, TCP connection, TLS handshake, and WebSocket +/// upgrade — all through the mixnet. Only `wss://` URLs are supported. +/// +/// Returns the WebSocket stream and the HTTP upgrade response. +pub async fn connect( + tunnel: &Tunnel, + request: R, +) -> Result<(WsStream, WsResponse>>), Error> +where + R: IntoClientRequest + Unpin, +{ + let request = request.into_client_request().map_err(Error::WebSocket)?; + + let uri = request.uri(); + let scheme = uri.scheme_str().unwrap_or("wss"); + if scheme != "wss" { + return Err(Error::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "only wss:// URLs are supported (use raw TCP for unencrypted WebSocket)", + ))); + } + + let host = uri + .host() + .ok_or_else(|| { + Error::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "URI missing host", + )) + })? + .to_owned(); + let port = uri.port_u16().unwrap_or(443); + + // DNS through the tunnel + let addrs = smolmix_dns::resolve(tunnel, &host, port) + .await + .map_err(Error::Io)?; + let addr = addrs.into_iter().next().ok_or_else(|| { + Error::Io(io::Error::new( + io::ErrorKind::AddrNotAvailable, + "no addresses", + )) + })?; + + // TCP through the tunnel + let tcp = tunnel + .tcp_connect(addr) + .await + .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::Other, e)))?; + + // TLS handshake + let tls_stream = smolmix_tls::connect(tcp, &host).await.map_err(Error::Io)?; + + // WebSocket upgrade + let (ws, resp) = tokio_tungstenite::client_async(request, tls_stream) + .await + .map_err(Error::WebSocket)?; + + Ok((ws, resp)) +} + +/// Error type for WebSocket connections through the tunnel. +#[derive(Debug)] +pub enum Error { + /// I/O error (DNS, TCP, or TLS). + Io(io::Error), + /// WebSocket protocol error. + WebSocket(WsError), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::WebSocket(e) => write!(f, "WebSocket error: {e}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) => Some(e), + Error::WebSocket(e) => Some(e), + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +impl From for Error { + fn from(e: WsError) -> Self { + Error::WebSocket(e) + } +}