Remove companion crates, scope to smolmix-core

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