Max/mixtcp (#6321)

* Add mixtcp crate 

Components:
- NymIprDevice: smoltcp::phy::Device impl using channel-based I/O
- NymIprBridge: async task bridging the device to IpMixStream
- create_device(): helper to set up the complete stack

* - Cleanup
- Add graceful shutdown
- Declutter logging - move a lot of bridge info! -> trace!
- Move rustls, nym-bin-common, bytes to dev-dependencies
- Extract TlsOverTcp to mod.rs
- Make timing more granular
- Update readme

* Add UDP example

* Add UDP example to readme

* rename mixtcp -> smolmix

* Add Tunnel API with TcpStream and UdpSocket over tokio-smoltcp

* Re-export Tunnel API and add init_logging convenience function

* Remove raw smoltcp path, flatten tunnel module

* Clean up bridge, device, and tunnel code

* Consolidate architecture docs, tidy examples and README

- Add src/ARCHITECTURE.md as single source of truth for architecture
- Include in docs.rs via doc = include_str!
- Strip duplicated diagrams from tunnel.rs, device.rs, README
- Extract tls_connector() helper in HTTPS example to match websocket example
- Use consistent 'smolmix' casing in README

* Update smolmix imports for ipr_wrapper API

- stream_wrapper::{IpMixStream, NetworkEnvironment} → ipr_wrapper::
- connect_tunnel() → check_connected()
- disconnect_stream() → disconnect()
- allocated_ips() returns &IpPair directly (no Option)

* Add Tunnel::new_with_ipr, re-export IpPair/Recipient, tidy examples

- Add Tunnel::new_with_ipr() for targeting a specific exit node
- Re-export IpPair and Recipient so users don't need direct deps
- Add DNS leak warning to WebSocket example
- Await hyper connection task in HTTPS example

* Restructure smolmix into multi-crate workspace

- Move core tunnel code to smolmix/core/- Rewrite examples for each crate with clearnet/mixnet comparisons

* Add workspace README with architecture overview

* Update nym-sdk README module descriptions

- Replace stale stream_wrapper description with ipr_wrapper + mixnet::stream
- Remove TODO comment

* Remove companion crates, scope to smolmix-core

* Comment out additional components on -core branch README.md

* Cargo.lock fix for compilation issue

* Downgrade accidentally bumped dependencies in Cargo lock + change
smolmix dependencies to import from workspace

* Fix workspace deps + move nym-bin-common to dev-deps

* PR review changes + fix Sink delegation

* Fix borked merge + update README.md

* Fix up stale docs + rewrite examples to use proper imports and timing
logs

* Update readmes + architecture file

* Impl Drop for BridgeShutdownHandle + update comment
This commit is contained in:
mfahampshire
2026-04-14 20:13:12 +00:00
committed by GitHub
parent 2209e8ac04
commit 7ceaf9a40e
15 changed files with 1390 additions and 2 deletions
Generated
+121
View File
@@ -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"
+7
View File
@@ -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:
-2
View File
@@ -1,7 +1,5 @@
# Nym Rust SDK
<!--TODO MAX stream abstraction + notice on tcpproxy -->
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.
+67
View File
@@ -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<u8>`
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).
+36
View File
@@ -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"] }
+19
View File
@@ -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 <ADDRESS>` 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).
+131
View File
@@ -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 <IPR_ADDRESS>
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<dyn std::error::Error + Send + Sync>;
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<String> = 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::<Bytes>::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(())
}
+96
View File
@@ -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 <IPR_ADDRESS>
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<dyn std::error::Error + Send + Sync>;
#[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<Ipv4Addr> = 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<String> = 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<Ipv4Addr> = 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(())
}
+143
View File
@@ -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 <IPR_ADDRESS>
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<dyn std::error::Error + Send + Sync>;
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<String> = 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(())
}
+70
View File
@@ -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.
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<Vec<u8>>,
/// 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<Vec<u8>>,
shutdown_rx: oneshot::Receiver<()>,
}
/// Handle for signaling the bridge to shut down gracefully.
pub(crate) struct BridgeShutdownHandle {
tx: Option<oneshot::Sender<()>>,
}
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<Vec<u8>>,
incoming_tx: mpsc::UnboundedSender<Vec<u8>>,
) -> (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(())
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<Vec<u8>>,
tx: mpsc::UnboundedSender<Vec<u8>>,
capabilities: DeviceCapabilities,
}
impl NymAsyncDevice {
pub(crate) fn new(
rx: mpsc::UnboundedReceiver<Vec<u8>>,
tx: mpsc::UnboundedSender<Vec<u8>>,
) -> 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<Vec<u8>>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
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<Vec<u8>> for NymAsyncDevice {
type Error = io::Error;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
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<u8>) -> 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<Result<(), Self::Error>> {
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<Result<(), Self::Error>> {
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
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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),
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<dyn std::error::Error>> {
/// # 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<dyn std::error::Error>> {
/// # 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;
+361
View File
@@ -0,0 +1,361 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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<Result<(), SmolmixError>>,
}
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<Option<ShutdownState>>,
}
/// 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<dyn std::error::Error>> {
/// 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<TunnelInner>,
}
/// Builder for configuring and creating a [`Tunnel`].
///
/// Use [`Tunnel::builder()`] to create a new builder.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// 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<Recipient>,
}
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<Tunnel, SmolmixError> {
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<dyn std::error::Error>> {
/// 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<dyn std::error::Error>> {
/// 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, SmolmixError> {
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<dyn std::error::Error>> {
/// 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, SmolmixError> {
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<Self, SmolmixError> {
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<dyn std::error::Error>> {
/// # 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<dyn std::error::Error>> {
/// # 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<TcpStream, SmolmixError> {
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<dyn std::error::Error>> {
/// # 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<UdpSocket, SmolmixError> {
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:<port>` 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<UdpSocket, SmolmixError> {
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<TunnelInner>` 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");
}
}
}