diff --git a/Cargo.lock b/Cargo.lock index 6ca431e70e..bd00183414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,6 +2350,47 @@ dependencies = [ "x25519-dalek", ] +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.12", +] + [[package]] name = "delegate-display" version = "3.0.0" @@ -3398,6 +3439,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3523,6 +3573,16 @@ dependencies = [ "http 1.3.1", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -5047,6 +5107,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "maplit" version = "1.0.2" @@ -11074,6 +11140,47 @@ dependencies = [ "serde_core", ] +[[package]] +name = "smolmix" +version = "0.0.1" +dependencies = [ + "futures", + "hickory-proto", + "hickory-resolver", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "nym-bin-common", + "nym-ip-packet-requests", + "nym-sdk", + "reqwest 0.13.1", + "rustls 0.23.37", + "smoltcp", + "thiserror 2.0.12", + "tokio", + "tokio-rustls 0.26.2", + "tokio-smoltcp", + "tokio-tungstenite", + "tracing", + "webpki-roots 0.26.11", +] + +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "libc", + "log", + "managed", +] + [[package]] name = "snafu" version = "0.7.5" @@ -12040,6 +12147,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-smoltcp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f5d53da1c3095663a8900d86c2abb0ffe02d3f6aa86527b066148fcb33e65e" +dependencies = [ + "futures", + "parking_lot", + "pin-project-lite", + "smoltcp", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-stream" version = "0.1.17" diff --git a/Cargo.toml b/Cargo.toml index f559bae048..66ba465d80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,6 +147,7 @@ members = [ "sdk/ffi/go", "sdk/ffi/shared", "sdk/rust/nym-sdk", + "smolmix/core", "service-providers/common", "service-providers/ip-packet-router", "service-providers/network-requester", @@ -279,6 +280,7 @@ getrandom03 = { package = "getrandom", version = "=0.3.3" } glob = "0.3" handlebars = "3.5.5" hex = "0.4.3" +hickory-proto = "0.25.2" hickory-resolver = "0.25.2" hkdf = "0.12.3" hmac = "0.12.1" @@ -347,6 +349,8 @@ serde_yaml = "0.9.25" serde_plain = "1.0.2" sha2 = "0.10.3" si-scale = "0.2.3" +smolmix = { version = "0.0.1", path = "smolmix/core" } +smoltcp = "0.12" snow = "0.9.6" sphinx-packet = "=0.6.0" sqlx = "0.8.6" @@ -367,6 +371,8 @@ tokio-postgres = "0.7" tokio-stream = "0.1.17" tokio-test = "0.4.4" tokio-tun = "0.11.5" +tokio-rustls = "0.26" +tokio-smoltcp = "0.5" tokio-tungstenite = { version = "0.20.1" } tokio-util = "0.7.15" toml = "0.8.22" @@ -559,6 +565,7 @@ wasm-bindgen = "0.2.99" wasm-bindgen-futures = "0.4.49" wasm-bindgen-test = "0.3.49" wasmtimer = "0.4.1" +webpki-roots = "0.26" web-sys = "0.3.76" # for local development: diff --git a/sdk/rust/nym-sdk/README.md b/sdk/rust/nym-sdk/README.md index be8bdab8ed..16f1989414 100644 --- a/sdk/rust/nym-sdk/README.md +++ b/sdk/rust/nym-sdk/README.md @@ -1,7 +1,5 @@ # Nym Rust SDK - - This repo contains several components: - `mixnet`: exposes Nym Client builders and methods. This is useful if you want to interact directly with the Client, or build transport abstractions. - `tcp_proxy`: exposes functionality to set up client/server instances that expose a localhost TcpSocket to read/write to like a 'normal' socket connection. `tcp_proxy/bin/` contains standalone `nym-proxy-client` and `nym-proxy-server` binaries. diff --git a/smolmix/README.md b/smolmix/README.md new file mode 100644 index 0000000000..3bd4e861b7 --- /dev/null +++ b/smolmix/README.md @@ -0,0 +1,67 @@ +# smolmix + +TCP/UDP tunnel over the Nym mixnet. Uses a userspace network stack (smoltcp) +to provide real `TcpStream` and `UdpSocket` types that work transparently +with the async Rust ecosystem — tokio-rustls, hyper, tokio-tungstenite, +libp2p, and anything else built on `AsyncRead + AsyncWrite`. + +## Why IP, not messages + +The Nym SDK works at the **message layer**: you send and receive `Vec` +payloads through the mixnet. Every protocol must be hand-adapted — you need +custom framing, ordering, connection state, and flow control. + +`smolmix` operates at the **IP layer**. A userspace smoltcp stack manages +real TCP state machines (retransmits, windowing, port allocation) and UDP +datagram delivery, and the mixnet becomes a transparent transport underneath. +Any protocol that works over TCP or UDP works over smolmix — with zero +adaptation. + +```text +┌──────────────────────────────────────────────────────────────────┐ +│ Application protocols that "just work" over smolmix │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ TLS │ │ HTTP/1.1 │ │ WebSocket │ │ libp2p │ │ +│ │ (rustls) │ │ (hyper) │ │ (tungstenite)│ │ (noise+yamux) │ │ +│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ └───────┬────────┘ │ +│ │ │ │ │ │ +│ └─────────────┴──────────────┴─────────────────┘ │ +│ │ │ +│ tokio_smoltcp::TcpStream │ +│ (AsyncRead + AsyncWrite, Send, Unpin) │ +├──────────────────────────────────────────────────────────────────┤ +│ smolmix Tunnel │ +│ (smoltcp → mixnet → IPR) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Quick start + +```rust +use smolmix::Tunnel; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +let tunnel = Tunnel::new().await?; + +// Raw TCP — works with any protocol +let mut tcp = tunnel.tcp_connect("1.1.1.1:80".parse()?).await?; +tcp.write_all(b"GET / HTTP/1.1\r\nHost: 1.1.1.1\r\nConnection: close\r\n\r\n").await?; + +// Raw UDP — datagrams over the mixnet +let udp = tunnel.udp_socket().await?; +udp.send_to(&packet, "1.1.1.1:53".parse()?).await?; +``` + +## Examples + +```sh +cargo run -p smolmix --example tcp # HTTPS via hyper +cargo run -p smolmix --example udp # DNS via hickory-proto +cargo run -p smolmix --example websocket # WebSocket via tungstenite +``` + +## Architecture + +See [`core/src/ARCHITECTURE.md`](core/src/ARCHITECTURE.md) for the internal +stack (smoltcp, device adapter, bridge, mixnet client). diff --git a/smolmix/core/Cargo.toml b/smolmix/core/Cargo.toml new file mode 100644 index 0000000000..173e519178 --- /dev/null +++ b/smolmix/core/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "smolmix" +version = "0.0.1" +edition = "2021" +license.workspace = true + +[dependencies] +smoltcp = { workspace = true, features = [ + "std", + "medium-ip", + "proto-ipv4", + "socket-tcp", + "socket-udp", +] } +tokio = { workspace = true } +tokio-smoltcp = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +nym-sdk = { workspace = true } +nym-ip-packet-requests = { workspace = true } +thiserror.workspace = true + +[dev-dependencies] +futures = { workspace = true } +tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "time", "net"] } +tokio-tungstenite.workspace = true +webpki-roots.workspace = true +rustls = { workspace = true, features = ["std", "ring"] } +tokio-rustls = { workspace = true } +nym-bin-common = { workspace = true, features = ["basic_tracing"] } +hickory-proto = { workspace = true } +hickory-resolver = { workspace = true, features = ["tokio", "system-config"] } +hyper = { workspace = true, features = ["client", "http1"] } +hyper-util = { workspace = true, features = ["tokio"] } +http-body-util = { workspace = true } +reqwest = { workspace = true, features = ["rustls"] } diff --git a/smolmix/core/README.md b/smolmix/core/README.md new file mode 100644 index 0000000000..500941938a --- /dev/null +++ b/smolmix/core/README.md @@ -0,0 +1,19 @@ +# smolmix + +A TCP/UDP tunnel over the Nym mixnet. Uses smoltcp as a userspace network stack and connects to an Exit Gateway's IP Packet Router, so the exit IP is the gateway's — not yours. + +`Tunnel` gives you standard `TcpStream` and `UdpSocket` types (from tokio-smoltcp) that work transparently with the async Rust ecosystem: tokio-rustls for TLS, hyper for HTTP, tokio-tungstenite for WebSockets, etc. + +## Examples + +All examples include a clearnet-vs-mixnet comparison with timing and accept `--ipr
` for targeting a specific exit node. + +```sh +cargo run -p smolmix --example tcp # raw TCP connection +cargo run -p smolmix --example udp # raw UDP datagram +cargo run -p smolmix --example websocket # WebSocket over TLS (raw TcpStream composability) +``` + +## Architecture + +See [`src/ARCHITECTURE.md`](src/ARCHITECTURE.md). diff --git a/smolmix/core/examples/tcp.rs b/smolmix/core/examples/tcp.rs new file mode 100644 index 0000000000..884346a85c --- /dev/null +++ b/smolmix/core/examples/tcp.rs @@ -0,0 +1,131 @@ +//! HTTPS request through the Nym mixnet. +//! +//! Fetches Cloudflare's `/cdn-cgi/trace` diagnostic endpoint over clearnet +//! (reqwest) and through the mixnet (hyper over tokio-rustls over smolmix), +//! then compares the responses. The exit IP should differ — the mixnet path +//! exits through an IPR gateway. +//! +//! Run with: +//! cargo run -p smolmix --example tcp +//! cargo run -p smolmix --example tcp -- --ipr + +use std::sync::Arc; + +use http_body_util::BodyExt; +use hyper::body::Bytes; +use hyper::Request; +use hyper_util::rt::TokioIo; +use rustls::pki_types::ServerName; +use smolmix::Tunnel; +use tokio_rustls::TlsConnector; +use tracing::info; + +type BoxError = Box; + +const HOST: &str = "cloudflare.com"; +const PATH: &str = "/cdn-cgi/trace"; + +fn tls_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)) +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + nym_bin_common::logging::setup_tracing_logger(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + // 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: smolmix TCP -> tokio-rustls -> 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 mut builder = Tunnel::builder(); + if let Some(addr) = ipr_addr { + builder = builder.ipr_address(addr.parse().expect("invalid IPR address")); + } + let tunnel = builder.build().await?; + + // Phase 1: Setup (TCP + TLS + HTTP handshakes) + let setup_start = tokio::time::Instant::now(); + + info!("TCP connecting to 1.1.1.1:443 via mixnet..."); + let tcp = tunnel.tcp_connect("1.1.1.1:443".parse()?).await?; + info!("TCP connected ({:?})", setup_start.elapsed()); + + info!("TLS handshake..."); + let connector = tls_connector(); + let domain = ServerName::try_from(HOST)?.to_owned(); + let tls = connector.connect(domain, tcp).await?; + info!("TLS established ({:?})", setup_start.elapsed()); + + info!("HTTP/1.1 handshake..."); + let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls)).await?; + tokio::spawn(conn); + + let setup_duration = setup_start.elapsed(); + info!("Setup complete ({:?})", setup_duration); + + // Phase 2: Request/response + let request_start = tokio::time::Instant::now(); + + info!("Sending GET {PATH}..."); + let req = Request::get(PATH) + .header("Host", HOST) + .body(http_body_util::Empty::::new())?; + let resp = sender.send_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 request_duration = request_start.elapsed(); + info!( + "Response: {} ({} bytes, {:?})", + mixnet_status, + body_bytes.len(), + request_duration + ); + + // 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: {} in {:?}", clearnet_status, clearnet_duration); + info!( + "Mixnet: {} (setup {:?} + request {:?} = {:?})", + mixnet_status, + setup_duration, + request_duration, + setup_duration + request_duration + ); + info!("Clearnet IP: {}", clearnet_ip.unwrap_or("?")); + info!("Mixnet IP: {}", mixnet_ip.unwrap_or("?")); + + let total = setup_duration + request_duration; + let slowdown = total.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!( + "Slowdown: {slowdown:.1}x (setup: {:.1}x, request: {:.1}x)", + setup_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64, + request_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64 + ); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/core/examples/udp.rs b/smolmix/core/examples/udp.rs new file mode 100644 index 0000000000..6600fe6e8b --- /dev/null +++ b/smolmix/core/examples/udp.rs @@ -0,0 +1,96 @@ +//! DNS lookup through the Nym mixnet. +//! +//! Resolves `example.com` via clearnet (hickory-resolver) and via the mixnet +//! (hickory-proto UDP query to Cloudflare 1.1.1.1), then compares resolved +//! IPs and timing. +//! +//! +//! Run with: +//! cargo run -p smolmix --example udp +//! cargo run -p smolmix --example udp -- --ipr + +use std::net::Ipv4Addr; + +use hickory_proto::op::{Message, Query}; +use hickory_proto::rr::{Name, RData, RecordType}; +use hickory_resolver::TokioResolver; +use smolmix::Tunnel; +use tracing::info; + +type BoxError = Box; + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + nym_bin_common::logging::setup_tracing_logger(); + + let domain = "example.com"; + + // Clearnet baseline via hickory-resolver + info!("Clearnet DNS lookup for '{domain}'..."); + let resolver = TokioResolver::builder_tokio()?.build(); + let clearnet_start = tokio::time::Instant::now(); + let lookup = 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: hickory-proto query over smolmix UDP + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let mut builder = Tunnel::builder(); + if let Some(addr) = ipr_addr { + builder = builder.ipr_address(addr.parse().expect("invalid IPR address")); + } + let tunnel = builder.build().await?; + + let udp = tunnel.udp_socket().await?; + + let mut query = Message::new(); + query.set_recursion_desired(true); + query.add_query(Query::query(Name::from_ascii(domain)?, RecordType::A)); + let query_bytes = query.to_vec()?; + + // UDP is connectionless — no setup phase, just send/recv + info!("Sending DNS query via mixnet..."); + let mixnet_start = tokio::time::Instant::now(); + udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?; + info!("Query sent ({:?})", mixnet_start.elapsed()); + + let mut buf = vec![0u8; 1500]; + let (n, _from) = udp.recv_from(&mut buf).await?; + let mixnet_duration = mixnet_start.elapsed(); + info!("Response received ({} bytes, {:?})", n, mixnet_duration); + + let response = Message::from_vec(&buf[..n])?; + let mixnet_ips: Vec = response + .answers() + .iter() + .filter_map(|r| match r.data() { + RData::A(a) => Some(a.0), + _ => None, + }) + .collect(); + + // 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/core/examples/websocket.rs b/smolmix/core/examples/websocket.rs new file mode 100644 index 0000000000..e5f3070e72 --- /dev/null +++ b/smolmix/core/examples/websocket.rs @@ -0,0 +1,143 @@ +//! WebSocket echo over the Nym mixnet. +//! +//! Demonstrates stacking tokio-tungstenite on top of tokio-rustls on top of +//! smolmix TcpStream. Sends a message to a public echo server via clearnet +//! and via the mixnet, then compares responses and timing. +//! +//! The clearnet and mixnet paths use the *exact same* TLS + WebSocket stack — +//! only the underlying TCP transport differs: +//! +//! ```text +//! tokio-tungstenite (WebSocket framing) +//! └─ tokio-rustls (TLS encryption) +//! └─ tokio::net::TcpStream (clearnet) +//! └─ smolmix::TcpStream (mixnet) +//! ``` +//! +//! Run with: +//! cargo run -p smolmix --example websocket +//! cargo run -p smolmix --example websocket -- --ipr + +use std::sync::Arc; + +use futures::{SinkExt, StreamExt}; +use rustls::pki_types::ServerName; +use smolmix::Tunnel; +use tokio_tungstenite::tungstenite::Message; +use tracing::info; + +type BoxError = Box; + +const WS_HOST: &str = "ws.postman-echo.com"; +const WS_PATH: &str = "/raw"; +const ECHO_MSG: &str = "Hello from the Nym mixnet!"; + +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)) +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + nym_bin_common::logging::setup_tracing_logger(); + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + // Resolve hostname via clearnet DNS - you can resolve via the Mixnet (see UDP example) but for this test its not necessary + let addr = tokio::net::lookup_host(format!("{WS_HOST}:443")) + .await? + .next() + .ok_or("DNS resolution failed")?; + info!("Resolved {WS_HOST} -> {addr}"); + + let connector = tls_connector(); + let domain = ServerName::try_from(WS_HOST)?.to_owned(); + + // Clearnet baseline: tokio TCP -> rustls -> tungstenite + info!("Connecting via clearnet..."); + let clearnet_start = tokio::time::Instant::now(); + + let clearnet_tcp = tokio::net::TcpStream::connect(addr).await?; + let clearnet_tls = connector.connect(domain.clone(), clearnet_tcp).await?; + let (mut clearnet_ws, _) = + tokio_tungstenite::client_async(format!("wss://{WS_HOST}{WS_PATH}"), clearnet_tls).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: smolmix TCP -> rustls -> tungstenite (same stack) + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let mut builder = Tunnel::builder(); + if let Some(addr) = ipr_addr { + builder = builder.ipr_address(addr.parse().expect("invalid IPR address")); + } + let tunnel = builder.build().await?; + info!("Allocated IP: {}", tunnel.allocated_ips().ipv4); + + // Phase 1: Setup (TCP + TLS + WebSocket handshakes) + let setup_start = tokio::time::Instant::now(); + + info!("TCP connecting via mixnet..."); + let mixnet_tcp = tunnel.tcp_connect(addr).await?; + info!("TCP connected ({:?})", setup_start.elapsed()); + + info!("TLS handshake..."); + let mixnet_tls = connector.connect(domain, mixnet_tcp).await?; + info!("TLS established ({:?})", setup_start.elapsed()); + + info!("WebSocket upgrade..."); + let (mut mixnet_ws, _) = + tokio_tungstenite::client_async(format!("wss://{WS_HOST}{WS_PATH}"), mixnet_tls).await?; + + let setup_duration = setup_start.elapsed(); + info!("Setup complete ({:?})", setup_duration); + + // Phase 2: Echo request/response + let request_start = tokio::time::Instant::now(); + + mixnet_ws.send(Message::Text(ECHO_MSG.into())).await?; + let mixnet_reply = mixnet_ws.next().await.ok_or("no mixnet reply")??; + + let request_duration = request_start.elapsed(); + let mixnet_text = mixnet_reply.into_text()?; + mixnet_ws.close(None).await?; + + info!("Echo: \"{mixnet_text}\" ({:?})", request_duration); + + // Results + info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}"); + info!( + "Mixnet: \"{mixnet_text}\" (setup {:?} + echo {:?} = {:?})", + setup_duration, + request_duration, + setup_duration + request_duration + ); + info!("Clearnet echo match: {}", clearnet_text == ECHO_MSG); + info!("Mixnet echo match: {}", mixnet_text == ECHO_MSG); + + let total = setup_duration + request_duration; + let slowdown = total.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64; + info!( + "Slowdown: {slowdown:.1}x (setup: {:.1}x, echo: {:.1}x)", + setup_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64, + request_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64 + ); + + tunnel.shutdown().await; + Ok(()) +} diff --git a/smolmix/core/src/ARCHITECTURE.md b/smolmix/core/src/ARCHITECTURE.md new file mode 100644 index 0000000000..5425ad8b47 --- /dev/null +++ b/smolmix/core/src/ARCHITECTURE.md @@ -0,0 +1,70 @@ +# Architecture + +smolmix is a TCP/UDP tunnel over the Nym mixnet. It gives you standard +`TcpStream` and `UdpSocket` types that work transparently with the async Rust +ecosystem (tokio-rustls, hyper, tokio-tungstenite, etc.) while routing all +traffic through the mixnet for metadata privacy. + +## Stack + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ User code │ +│ tunnel.tcp_connect() → TcpStream (AsyncRead + AsyncWrite) │ +│ tunnel.udp_socket() → UdpSocket (send_to / recv_from) │ +├─────────────────────────────────────────────────────────────────┤ +│ tokio-smoltcp::Net │ +│ Owns the smoltcp Interface + SocketSet + async poll loop. │ +│ Manages TCP state machines, retransmits, port allocation. │ +├─────────────────────────────────────────────────────────────────┤ +│ NymAsyncDevice (device.rs) │ +│ Stream + Sink adapter for raw IP packets over mpsc channels. │ +├─────────────────────────────────────────────────────────────────┤ +│ NymIprBridge (bridge.rs) │ +│ Background task shuttling packets between channels and the │ +│ mixnet. Bundles outgoing packets with MultiIpPacketCodec │ +│ (required by the IPR protocol). │ +├─────────────────────────────────────────────────────────────────┤ +│ IpMixStream → MixnetClient → Nym mixnet → IPR exit node │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Data flow + +```text +outgoing: smoltcp → NymAsyncDevice (Sink) → channel → NymIprBridge → IpMixStream → mixnet +incoming: mixnet → IpMixStream → NymIprBridge → channel → NymAsyncDevice (Stream) → smoltcp +``` + +tokio-smoltcp handles all the hard parts (smoltcp polling, TCP state machines, +port allocation, waker management). We just give it a device that produces and +consumes raw IP packets — `NymAsyncDevice` wraps the mpsc channel ends in the +`Stream`/`Sink` traits that tokio-smoltcp requires. + +## Key design decisions + +- **Single async device adapter.** All traffic flows through one + `NymAsyncDevice`. If you need a new transport type (e.g. ICMP), add a method + to `Tunnel` rather than introducing a separate device — the device and bridge + don't need to change. smoltcp already supports ICMP sockets; you'd enable + the `socket-icmp` feature in `Cargo.toml`, add a method like + `Tunnel::icmp_socket()` that calls the appropriate `Net` method, and expose + the socket type via a re-export in `lib.rs`. + +- **Tokio-only.** The bridge, SDK (`IpMixStream`, `MixnetClient`), and shutdown + signaling are tokio-based. The data-plane channels use `futures::channel::mpsc` + because `UnboundedSender` implements `Sink` — required by tokio-smoltcp's + `AsyncDevice` trait. An earlier version had a sync smoltcp `Device` adapter + for use without tokio-smoltcp, but it still required a tokio runtime + underneath (for the bridge and SDK), so it provided no real runtime + independence — just duplicated the bridging logic. If alternative-runtime + support is ever needed, it would require swapping out the bridge, SDK, and + channel layers — a separate crate, not a feature flag on this one. + +- **Unbounded channels.** The channels between the device and bridge are + unbounded. Backpressure is handled at the mixnet layer (IPR protocol), not + at the channel level. If that assumption changes, consider bounded channels + with a drop policy. + +- **`Medium::Ip` (no Ethernet framing).** Raw IP packets go in and out, + matching what the IPR protocol expects. diff --git a/smolmix/core/src/bridge.rs b/smolmix/core/src/bridge.rs new file mode 100644 index 0000000000..39577aba71 --- /dev/null +++ b/smolmix/core/src/bridge.rs @@ -0,0 +1,164 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +use crate::error::SmolmixError; +use futures::channel::mpsc; +use futures::StreamExt; +use nym_ip_packet_requests::codec::MultiIpPacketCodec; +use nym_sdk::ipr_wrapper::IpMixStream; +use tokio::sync::oneshot; +use tracing::{debug, error, info, trace, warn}; + +/// Asynchronous bridge between the smoltcp device and the Nym mixnet. +/// +/// Runs as a background task, shuttling raw IP packets in both directions: +/// +/// **Outgoing** (smoltcp → mixnet): receives packets from the device via channel, +/// bundles them with [`MultiIpPacketCodec`] (required by the IPR protocol), and +/// sends them through the mixnet. +/// +/// **Incoming** (mixnet → smoltcp): polls the mixnet for packets and forwards +/// them to the device via channel for smoltcp consumption. +pub(crate) struct NymIprBridge { + stream: IpMixStream, + /// Receives outgoing packets from the device (smoltcp → bridge → mixnet). + outgoing_rx: mpsc::UnboundedReceiver>, + /// Sends incoming packets to the device (mixnet → bridge → smoltcp). + /// + /// Unbounded: backpressure is handled at the mixnet layer (IPR protocol), + /// not here. If that changes, consider bounded channels with a drop policy. + incoming_tx: mpsc::UnboundedSender>, + shutdown_rx: oneshot::Receiver<()>, +} + +/// Handle for signaling the bridge to shut down gracefully. +pub(crate) struct BridgeShutdownHandle { + tx: Option>, +} + +impl BridgeShutdownHandle { + /// Signal the bridge to shut down gracefully. + /// + /// Sends a one-shot signal that breaks the bridge event loop. The bridge + /// then calls `IpMixStream::disconnect()` before returning. Consumes + /// `self` — can only be called once. + /// + /// If this handle is dropped without calling `shutdown()`, the `Drop` + /// impl sends the signal automatically. + pub(crate) fn shutdown(mut self) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(()); + } + } +} + +impl Drop for BridgeShutdownHandle { + fn drop(&mut self) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(()); + } + } +} + +impl NymIprBridge { + /// Create a new bridge and its associated shutdown handle. + /// + /// Returns `(bridge, shutdown_handle)`. + /// + /// # Parameters + /// - `stream` — the connected `IpMixStream` (owns the mixnet client) + /// - `outgoing_rx` — receives raw IP packets from the smoltcp device + /// - `incoming_tx` — sends raw IP packets to the smoltcp device + pub(crate) fn new( + stream: IpMixStream, + outgoing_rx: mpsc::UnboundedReceiver>, + incoming_tx: mpsc::UnboundedSender>, + ) -> (Self, BridgeShutdownHandle) { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + ( + Self { + stream, + outgoing_rx, + incoming_tx, + shutdown_rx, + }, + BridgeShutdownHandle { + tx: Some(shutdown_tx), + }, + ) + } + + /// Runs the bridge event loop. + /// + /// Should be spawned via `tokio::spawn`. The loop exits when a shutdown + /// signal is received, channels close, or an unrecoverable error occurs. + /// + /// # Cancel safety + /// + /// `IpMixStream::handle_incoming()` is **not** cancel-safe — its internal + /// `FramedRead` buffers partial frames, and it mutates connection state after + /// awaiting. In `tokio::select!`, the shutdown branch can cancel a pending + /// `handle_incoming()` call, potentially losing buffered data. This is + /// acceptable during shutdown but worth noting for future changes. + pub(crate) async fn run(mut self) -> Result<(), SmolmixError> { + info!("Starting bridge"); + let mut packets_sent: u64 = 0; + let mut packets_received: u64 = 0; + + loop { + tokio::select! { + _ = &mut self.shutdown_rx => { + info!(packets_sent, packets_received, "Bridge received shutdown signal"); + break; + } + + Some(packet) = self.outgoing_rx.next() => { + trace!(len = packet.len(), "Sending packet to mixnet"); + + // IPR expects packets wrapped in MultiIpPacketCodec framing. + let bundled = MultiIpPacketCodec::bundle_one_packet(packet.into()); + if let Err(e) = self.stream.send_ip_packet(&bundled).await { + error!("Failed to send packet through mixnet: {e}"); + } else { + packets_sent += 1; + debug!(packets_sent, "Packet sent"); + } + } + + result = self.stream.handle_incoming() => { + match result { + Ok(packets) if !packets.is_empty() => { + trace!(count = packets.len(), "Received packets from mixnet"); + for packet in packets { + if self.incoming_tx.unbounded_send(packet.to_vec()).is_err() { + error!("Device channel closed"); + return Err(SmolmixError::ChannelClosed); + } + packets_received += 1; + } + debug!(packets_received, "Packets received"); + } + Ok(_) => {} // empty batch, keep polling + Err(e) => { + // handle_incoming() internally uses a 10-second timeout, + // so this won't busy-loop on persistent errors. + warn!("Mixnet receive error: {e}"); + } + } + } + + else => { + info!(packets_sent, packets_received, "All channels closed, shutting down"); + break; + } + } + } + + // disconnect() internally waits for all SDK tasks via TaskTracker. + info!("Disconnecting from mixnet..."); + self.stream.disconnect().await; + info!("Disconnected"); + + Ok(()) + } +} diff --git a/smolmix/core/src/device.rs b/smolmix/core/src/device.rs new file mode 100644 index 0000000000..012c3fa768 --- /dev/null +++ b/smolmix/core/src/device.rs @@ -0,0 +1,94 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! Async device adapter for tokio-smoltcp. +//! +//! Wraps mpsc channel ends (connected to [`NymIprBridge`](crate::bridge::NymIprBridge)) +//! in the [`Stream`]/[`Sink`] traits that tokio-smoltcp requires. See the +//! [crate-level docs](crate) for how this fits into the full stack. + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures::channel::mpsc; +use futures::{Sink, Stream}; +use smoltcp::phy::{DeviceCapabilities, Medium}; +use tokio_smoltcp::device::AsyncDevice; + +/// Async adapter bridging mpsc channels to tokio-smoltcp's [`AsyncDevice`] trait. +/// +/// Incoming packets (mixnet → smoltcp) arrive via the `rx` channel as a [`Stream`]. +/// Outgoing packets (smoltcp → mixnet) are sent via the `tx` channel as a [`Sink`]. +pub(crate) struct NymAsyncDevice { + rx: mpsc::UnboundedReceiver>, + tx: mpsc::UnboundedSender>, + capabilities: DeviceCapabilities, +} + +impl NymAsyncDevice { + pub(crate) fn new( + rx: mpsc::UnboundedReceiver>, + tx: mpsc::UnboundedSender>, + ) -> Self { + let mut capabilities = DeviceCapabilities::default(); + capabilities.medium = Medium::Ip; + capabilities.max_transmission_unit = 1500; + capabilities.max_burst_size = Some(1); + + Self { + rx, + tx, + capabilities, + } + } +} + +// tokio-smoltcp calls poll_next() in its reactor loop to feed packets into the +// smoltcp Interface for processing. +impl Stream for NymAsyncDevice { + type Item = io::Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.rx).poll_next(cx).map(|opt| opt.map(Ok)) + } +} + +// When smoltcp produces a packet (e.g. TCP SYN, data segment, UDP datagram), +// tokio-smoltcp sends it here and we forward it to the bridge for mixnet delivery. +// +// Delegates to the built-in Sink impl on futures::channel::mpsc::UnboundedSender, +// which handles channel liveness checks (poll_ready) and disconnect (poll_close). +impl Sink> for NymAsyncDevice { + type Error = io::Error; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tx) + .poll_ready(cx) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed")) + } + + fn start_send(mut self: Pin<&mut Self>, item: Vec) -> Result<(), Self::Error> { + Pin::new(&mut self.tx) + .start_send(item) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed")) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tx) + .poll_flush(cx) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed")) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tx) + .poll_close(cx) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed")) + } +} + +impl AsyncDevice for NymAsyncDevice { + fn capabilities(&self) -> &DeviceCapabilities { + &self.capabilities + } +} diff --git a/smolmix/core/src/error.rs b/smolmix/core/src/error.rs new file mode 100644 index 0000000000..226fcb9efd --- /dev/null +++ b/smolmix/core/src/error.rs @@ -0,0 +1,19 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SmolmixError { + #[error("Channel closed")] + ChannelClosed, + + #[error("Not connected to IPR")] + NotConnected, + + #[error("Nym SDK error: {0}")] + NymSdk(#[from] nym_sdk::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/smolmix/core/src/lib.rs b/smolmix/core/src/lib.rs new file mode 100644 index 0000000000..45a0392ab4 --- /dev/null +++ b/smolmix/core/src/lib.rs @@ -0,0 +1,62 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +#![doc = include_str!("ARCHITECTURE.md")] + +mod bridge; +mod device; +mod error; +mod tunnel; + +/// Error type for all fallible smolmix operations. +pub use error::SmolmixError; + +/// The IPv4/IPv6 address pair allocated to this tunnel by the IPR. +pub use tunnel::IpPair; + +/// A Nym mixnet address, used to target a specific IPR exit node. +pub use tunnel::Recipient; + +/// A TCP stream routed through the mixnet. Implements `AsyncRead + AsyncWrite`. +/// +/// Obtained via [`Tunnel::tcp_connect`]. Works as a drop-in replacement for +/// `tokio::net::TcpStream` with tokio-rustls, hyper, tokio-tungstenite, etc. +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// # let tunnel = smolmix::Tunnel::new().await?; +/// use tokio::io::{AsyncReadExt, AsyncWriteExt}; +/// +/// let mut stream = tunnel.tcp_connect("1.1.1.1:80".parse()?).await?; +/// stream.write_all(b"GET / HTTP/1.1\r\nHost: 1.1.1.1\r\nConnection: close\r\n\r\n").await?; +/// let mut buf = Vec::new(); +/// stream.read_to_end(&mut buf).await?; +/// # Ok(()) +/// # } +/// ``` +pub use tunnel::TcpStream; + +/// A mixnet tunnel providing TCP and UDP socket access. +pub use tunnel::Tunnel; + +/// Builder for configuring and creating a [`Tunnel`]. +/// +/// See [`Tunnel::builder()`] for usage. +pub use tunnel::TunnelBuilder; + +/// A UDP socket routed through the mixnet. Supports `send_to` / `recv_from`. +/// +/// Obtained via [`Tunnel::udp_socket`] or [`Tunnel::udp_socket_on`]. +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// # let tunnel = smolmix::Tunnel::new().await?; +/// let udp = tunnel.udp_socket().await?; +/// udp.send_to(b"hello", "1.1.1.1:9999".parse()?).await?; +/// +/// let mut buf = [0u8; 1024]; +/// let (len, _src) = udp.recv_from(&mut buf).await?; +/// # Ok(()) +/// # } +/// ``` +pub use tunnel::UdpSocket; diff --git a/smolmix/core/src/tunnel.rs b/smolmix/core/src/tunnel.rs new file mode 100644 index 0000000000..b59b86953b --- /dev/null +++ b/smolmix/core/src/tunnel.rs @@ -0,0 +1,361 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-2.0-only + +//! High-level tunnel providing TCP and UDP sockets over the Nym mixnet. +//! +//! See the [crate-level docs](crate) for the full architecture diagram. +//! +//! The returned [`TcpStream`] implements `tokio::io::AsyncRead + AsyncWrite`, so it +//! works transparently with the entire async Rust ecosystem: tokio-rustls for TLS, +//! tokio-tungstenite for WebSockets, hyper for HTTP, etc. + +use std::net::SocketAddr; +use std::sync::Arc; + +use futures::channel::mpsc; +pub use nym_ip_packet_requests::IpPair; +use nym_sdk::ipr_wrapper::IpMixStream; +use smoltcp::iface::Config; +use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tracing::info; + +use crate::bridge::{BridgeShutdownHandle, NymIprBridge}; +use crate::device::NymAsyncDevice; +use crate::SmolmixError; +use tokio_smoltcp::{Net, NetConfig}; + +pub use nym_sdk::mixnet::Recipient; +pub use tokio_smoltcp::{TcpStream, UdpSocket}; + +struct ShutdownState { + bridge_shutdown: BridgeShutdownHandle, + bridge_handle: JoinHandle>, +} + +struct TunnelInner { + /// tokio-smoltcp network stack. Its methods take &self, so multiple tasks can + /// open sockets concurrently without locking. + net: Net, + allocated_ips: IpPair, + /// Mutex only protects shutdown — called once, not on the hot path. + shutdown: Mutex>, +} + +/// A mixnet tunnel providing TCP and UDP socket access. +/// +/// `Tunnel` manages a smoltcp network stack connected to the Nym mixnet via an IPR +/// (Internet Packet Router). It spawns a background bridge task and a network reactor, +/// then provides familiar socket APIs on top. +/// +/// +/// # Shutdown +/// +/// Call [`shutdown()`](Self::shutdown) for a clean disconnect. Rust has no async `Drop`, +/// so dropping without calling `shutdown()` triggers a fire-and-forget cleanup via the +/// oneshot channel — the bridge will still shut down, but the caller can't await it. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use smolmix::Tunnel; +/// use tokio::io::{AsyncReadExt, AsyncWriteExt}; +/// +/// let tunnel = Tunnel::new().await?; +/// +/// // TCP — connect and use like any async stream +/// let mut tcp = tunnel.tcp_connect("1.1.1.1:80".parse()?).await?; +/// tcp.write_all(b"GET / HTTP/1.1\r\nHost: 1.1.1.1\r\nConnection: close\r\n\r\n").await?; +/// let mut buf = Vec::new(); +/// tcp.read_to_end(&mut buf).await?; +/// +/// // UDP — datagrams over the mixnet +/// let udp = tunnel.udp_socket().await?; +/// udp.send_to(b"hello", "1.1.1.1:9999".parse()?).await?; +/// +/// // Share across tasks (cheap Arc-based clone) +/// let t2 = tunnel.clone(); +/// tokio::spawn(async move { +/// let _tcp2 = t2.tcp_connect("93.184.216.34:80".parse().unwrap()).await.unwrap(); +/// }); +/// +/// tunnel.shutdown().await; +/// # Ok(()) +/// # } +/// ``` +/// +/// See also the repository examples: `tcp`, `udp`, `websocket`. +#[derive(Clone)] +pub struct Tunnel { + inner: Arc, +} + +/// Builder for configuring and creating a [`Tunnel`]. +/// +/// Use [`Tunnel::builder()`] to create a new builder. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use smolmix::Tunnel; +/// +/// // Auto-discover the best IPR: +/// let tunnel = Tunnel::builder().build().await?; +/// +/// // Or specify an IPR exit node: +/// use smolmix::Recipient; +/// let ipr: Recipient = "gateway-address...".parse()?; +/// let tunnel = Tunnel::builder().ipr_address(ipr).build().await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// For full control over the mixnet client (credentials, gateway selection, +/// storage, etc.), configure an [`IpMixStream`] directly and pass it to +/// [`Tunnel::from_stream()`]. Deeper builder integration with `MixnetClientBuilder` +/// will require upstream SDK changes to expose `IpMixStream` internals (TODO). +pub struct TunnelBuilder { + ipr_address: Option, +} + +impl TunnelBuilder { + /// Target a specific IPR exit node instead of auto-discovering one. + pub fn ipr_address(mut self, addr: Recipient) -> Self { + self.ipr_address = Some(addr); + self + } + + /// Build and connect the tunnel. + pub async fn build(self) -> Result { + let stream = match self.ipr_address { + Some(addr) => IpMixStream::new_with_ipr(addr).await?, + None => IpMixStream::new().await?, + }; + Tunnel::from_stream(stream).await + } +} + +impl Tunnel { + /// Create a [`TunnelBuilder`] for configuring the tunnel before connecting. + /// + /// ```no_run + /// # async fn example() -> Result<(), Box> { + /// use smolmix::Tunnel; + /// let tunnel = Tunnel::builder().build().await?; + /// # Ok(()) + /// # } + /// ``` + pub fn builder() -> TunnelBuilder { + TunnelBuilder { ipr_address: None } + } + + /// Create a new tunnel, automatically discovering the best IPR exit node. + /// + /// Shorthand for `Tunnel::builder().build().await`. + /// + /// ```no_run + /// # async fn example() -> Result<(), Box> { + /// use smolmix::Tunnel; + /// let tunnel = Tunnel::new().await?; + /// let tcp = tunnel.tcp_connect("1.1.1.1:443".parse()?).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn new() -> Result { + Self::builder().build().await + } + + /// Create a new tunnel connected to a specific IPR exit node. + /// + /// Shorthand for `Tunnel::builder().ipr_address(addr).build().await`. + /// + /// ```no_run + /// # async fn example() -> Result<(), Box> { + /// use smolmix::{Recipient, Tunnel}; + /// let ipr: Recipient = "gateway-address...".parse()?; + /// let tunnel = Tunnel::new_with_ipr(ipr).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn new_with_ipr(ipr_address: Recipient) -> Result { + Self::builder().ipr_address(ipr_address).build().await + } + + /// Create a tunnel from a pre-configured [`IpMixStream`]. + /// + /// Use this for full control over the mixnet client (credentials, gateway + /// selection, storage, etc.) — configure the `IpMixStream` upstream and + /// pass it in directly. + pub async fn from_stream(ipr_stream: IpMixStream) -> Result { + ipr_stream + .check_connected() + .map_err(|_| SmolmixError::NotConnected)?; + + let allocated_ips = *ipr_stream.allocated_ips(); + + // Wire up two channel pairs connecting the bridge (async mixnet I/O) to the + // async device adapter (which tokio-smoltcp polls for raw IP packets): + // + // outgoing: smoltcp → NymAsyncDevice.Sink → outgoing_tx → outgoing_rx → Bridge → mixnet + // incoming: mixnet → Bridge → incoming_tx → incoming_rx → NymAsyncDevice.Stream → smoltcp + let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); + let (incoming_tx, incoming_rx) = mpsc::unbounded(); + + // Bridge runs as a background task, shuttling packets between channels and IpMixStream. + let (bridge, bridge_shutdown) = NymIprBridge::new(ipr_stream, outgoing_rx, incoming_tx); + let bridge_handle = tokio::spawn(bridge.run()); + + // NymAsyncDevice wraps the channel ends as Stream + Sink, which is all + // tokio-smoltcp needs to drive the smoltcp Interface internally. + let device = NymAsyncDevice::new(incoming_rx, outgoing_tx); + + // Configure smoltcp: raw IP mode (no Ethernet), /32 for our allocated IP, + // default route via unspecified (the IPR handles actual routing). + let iface_config = Config::new(HardwareAddress::Ip); + let net_config = NetConfig::new( + iface_config, + IpCidr::new(IpAddress::from(allocated_ips.ipv4), 32), + vec![IpAddress::from(Ipv4Address::UNSPECIFIED)], + ); + + // Net::new spawns the smoltcp reactor as a background task. From here on, + // tcp_connect/udp_bind create sockets managed by that reactor. + let net = Net::new(device, net_config); + + info!("Tunnel ready, allocated IP: {}", allocated_ips.ipv4); + + Ok(Self { + inner: Arc::new(TunnelInner { + net, + allocated_ips, + shutdown: Mutex::new(Some(ShutdownState { + bridge_shutdown, + bridge_handle, + })), + }), + }) + } + + /// Open a TCP connection to `addr` through the mixnet. + /// + /// The returned [`TcpStream`] implements `tokio::io::AsyncRead + AsyncWrite`, + /// so it works transparently with tokio-rustls, hyper, tokio-tungstenite, and + /// any other async I/O consumer. + /// + /// # Errors + /// + /// Returns [`SmolmixError::Io`] if the TCP handshake fails (connection + /// refused, timeout, etc.) or if the tunnel has been shut down. + /// + /// # Examples + /// + /// Raw HTTP request: + /// ```no_run + /// # async fn example() -> Result<(), Box> { + /// # let tunnel = smolmix::Tunnel::new().await?; + /// use tokio::io::{AsyncReadExt, AsyncWriteExt}; + /// + /// let mut tcp = tunnel.tcp_connect("1.1.1.1:80".parse()?).await?; + /// tcp.write_all(b"GET / HTTP/1.1\r\nHost: 1.1.1.1\r\nConnection: close\r\n\r\n").await?; + /// + /// let mut response = Vec::new(); + /// tcp.read_to_end(&mut response).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// TLS via tokio-rustls (the stream is a drop-in for `tokio::net::TcpStream`): + /// ```no_run + /// # async fn example() -> Result<(), Box> { + /// # let tunnel = smolmix::Tunnel::new().await?; + /// use rustls::pki_types::ServerName; + /// use tokio_rustls::TlsConnector; + /// + /// let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?; + /// # let connector: TlsConnector = todo!(); + /// let tls = connector.connect(ServerName::try_from("example.com")?.to_owned(), tcp).await?; + /// // `tls` implements AsyncRead + AsyncWrite — use with hyper, tungstenite, etc. + /// # Ok(()) + /// # } + /// ``` + pub async fn tcp_connect(&self, addr: SocketAddr) -> Result { + Ok(self.inner.net.tcp_connect(addr).await?) + } + + /// Create a UDP socket bound to an ephemeral port. + /// + /// The port is chosen by smoltcp's allocator. Use [`udp_socket_on`](Self::udp_socket_on) + /// if you need a specific port (e.g. for a protocol that expects replies on + /// a known port). + /// + /// The returned [`UdpSocket`] supports `send_to` / `recv_from` for datagram I/O. + /// + /// # Examples + /// + /// Send a DNS query and read the response: + /// ```no_run + /// # async fn example() -> Result<(), Box> { + /// # let tunnel = smolmix::Tunnel::new().await?; + /// let udp = tunnel.udp_socket().await?; + /// + /// // Send a raw DNS query to Cloudflare + /// let query = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\ + /// \x07example\x03com\x00\x00\x01\x00\x01"; + /// udp.send_to(query, "1.1.1.1:53".parse()?).await?; + /// + /// let mut buf = [0u8; 512]; + /// let (len, _src) = udp.recv_from(&mut buf).await?; + /// println!("Got {} bytes back", len); + /// # Ok(()) + /// # } + /// ``` + pub async fn udp_socket(&self) -> Result { + let addr: SocketAddr = ([0, 0, 0, 0], 0).into(); + Ok(self.inner.net.udp_bind(addr).await?) + } + + /// Create a UDP socket bound to a specific local port. + /// + /// Binds to `0.0.0.0:` on the tunnel's virtual interface. Use this when + /// the remote side expects replies on a well-known port, or when you need + /// multiple sockets on distinct ports. + pub async fn udp_socket_on(&self, port: u16) -> Result { + let addr: SocketAddr = ([0, 0, 0, 0], port).into(); + Ok(self.inner.net.udp_bind(addr).await?) + } + + /// The IPv4/IPv6 address pair allocated to this tunnel by the IPR. + /// + /// Available immediately after construction. The IPv4 address is assigned as + /// a /32 on the tunnel's virtual interface — all traffic to/from external + /// hosts appears to originate from this IP at the exit gateway. + pub fn allocated_ips(&self) -> IpPair { + self.inner.allocated_ips + } + + /// Gracefully shut down the tunnel. + /// + /// Signals the bridge to disconnect from the mixnet and waits for it to finish. + /// The smoltcp reactor stops when all `Tunnel` clones are dropped. + /// + /// If the `Tunnel` is dropped without calling `shutdown()`, cleanup still happens: + /// dropping the `Arc` drops the oneshot sender inside `ShutdownState`, + /// which resolves the bridge's `shutdown_rx` and triggers its shutdown path. However, + /// the drop path is fire-and-forget — call `shutdown()` explicitly if you need to + /// wait for the mixnet disconnect to complete. + /// + /// After shutdown, new socket operations (`tcp_connect`, `udp_socket`) will fail + /// with IO errors — the bridge channels are closed. + pub async fn shutdown(&self) { + let mut state = self.inner.shutdown.lock().await; + if let Some(s) = state.take() { + info!("Shutting down tunnel"); + s.bridge_shutdown.shutdown(); + let _ = s.bridge_handle.await; + info!("Tunnel shut down"); + } + } +}