Compare commits

...

11 Commits

Author SHA1 Message Date
mfahampshire 71bff333db remove libp2p stub 2026-06-08 22:55:12 +01:00
mfahampshire 2b0aaed774 Merge branch 'develop' into max/smolmix-companions-clean 2026-06-08 22:50:55 +01:00
mfahampshire e748cb4f14 Fix root cargo.toml 2026-05-13 22:51:30 +01:00
mfahampshire 028e60d0ca Generated components 2026-05-13 22:48:56 +01:00
mfahampshire 46974ffa2a Re-delete examples (not included in previous commit erroneously) 2026-05-13 22:48:56 +01:00
mfahampshire a2ef02e512 Landing page tiles 2026-05-13 22:48:56 +01:00
mfahampshire b19f56bd74 Links to smolmix-family 2026-05-13 22:48:56 +01:00
mfahampshire 8300915c26 Smolmix-family docs pages + sidebar dropdown 2026-05-13 22:48:56 +01:00
mfahampshire 0599726bff smolmix-family readme + ARCHITECTURE.md 2026-05-13 22:48:56 +01:00
mfahampshire 99063f372f Consolidate examples + cut old libp2p 2026-05-13 22:48:55 +01:00
mfahampshire 95a46600fd pull in companion crates from previous old branch 2026-05-13 22:48:54 +01:00
44 changed files with 2261 additions and 3067 deletions
+90 -41
View File
@@ -285,7 +285,14 @@ const AnimationBlock = ({ type }: { type: string }) => {
}
};
const sdks = [
type Sdk = {
name: string;
description: string;
href: string;
children?: { name: string; href: string }[];
};
const sdks: Sdk[] = [
{
name: "Rust SDK",
description:
@@ -293,10 +300,17 @@ const sdks = [
href: "/developers/rust",
},
{
name: "smolmix",
name: "smolmix & connectors",
description:
"TCP/UDP tunnel over the Mixnet. Userspace smoltcp stack exposing AsyncRead/AsyncWrite TcpStream and UdpSocket types.",
"Rust crate family for routing networking through the Mixnet: TCP/UDP tunnels, DNS, TLS, and HTTP. Pick the layer you need.",
href: "/developers/smolmix",
children: [
{ name: "smolmix-tunnel", href: "/developers/smolmix/tunnel" },
{ name: "smolmix-dns", href: "/developers/smolmix/dns" },
{ name: "smolmix-tls", href: "/developers/smolmix/tls" },
{ name: "smolmix-hyper", href: "/developers/smolmix/hyper" },
{ name: "Building on smolmix", href: "/developers/smolmix/extending" },
],
},
{
name: "TypeScript SDK",
@@ -427,46 +441,81 @@ export const LandingPage = () => {
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0" }}>
{sdks.map((sdk, i) => (
<Link key={i} href={sdk.href} style={{ textDecoration: "none" }}>
<div
className="landing-card"
style={{
padding: "1rem 1.2rem",
border: "1px solid var(--border)",
marginTop: i > 0 ? "-1px" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "background-color 0.15s",
cursor: "pointer",
}}
>
<div>
<span
className="landing-heading"
style={{
fontFamily: "var(--font-mono)",
fontSize: "1rem",
fontWeight: 600,
}}
>
{sdk.name}
<div
key={i}
style={{
border: "1px solid var(--border)",
marginTop: i > 0 ? "-1px" : undefined,
}}
>
<Link href={sdk.href} style={{ textDecoration: "none" }}>
<div
className="landing-card"
style={{
padding: "1rem 1.2rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "background-color 0.15s",
cursor: "pointer",
}}
>
<div>
<span
className="landing-heading"
style={{
fontFamily: "var(--font-mono)",
fontSize: "1rem",
fontWeight: 600,
}}
>
{sdk.name}
</span>
<p
style={{
fontSize: "0.8rem",
color: "var(--textMuted)",
margin: "0.25rem 0 0 0",
}}
>
{sdk.description}
</p>
</div>
<span style={{ color: "var(--textMuted)", fontSize: "1rem" }}>
&rsaquo;
</span>
<p
style={{
fontSize: "0.8rem",
color: "var(--textMuted)",
margin: "0.25rem 0 0 0",
}}
>
{sdk.description}
</p>
</div>
<span style={{ color: "var(--textMuted)", fontSize: "1rem" }}>
&rsaquo;
</span>
</div>
</Link>
</Link>
{sdk.children && (
<div
style={{
padding: "0 1.2rem 0.8rem 1.2rem",
display: "flex",
gap: "0.4rem",
flexWrap: "wrap",
fontFamily: "var(--font-mono)",
fontSize: "0.78rem",
}}
>
{sdk.children.map((c) => (
<Link
key={c.href}
href={c.href}
className="landing-chip"
style={{
color: "var(--textMuted)",
textDecoration: "none",
padding: "0.2rem 0.55rem",
border: "1px solid var(--border)",
transition: "background-color 0.15s",
}}
>
{c.name}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
@@ -7,7 +7,7 @@
"type": "separator",
"title": "Rust"
},
"smolmix": "smolmix",
"smolmix": "smolmix & connectors",
"rust": "nym-sdk",
"-": {
@@ -19,7 +19,7 @@ If you're new, read **[Choosing an Approach](/developers/integrations)** first.
| Crate/library | Language | Use it for |
|---|---|---|
| [`nym-sdk`](/developers/rust) | Rust | E2E messaging, `AsyncRead`/`AsyncWrite` streams, client pooling. Start with the [Tour](/developers/rust/tour). |
| [`smolmix`](/developers/smolmix) | Rust | `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Compatible with `tokio-rustls`, `hyper`, `tungstenite`. |
| [`smolmix` & connectors](/developers/smolmix) | Rust | `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack, plus companion crates [`smolmix-dns`](/developers/smolmix/dns), [`smolmix-tls`](/developers/smolmix/tls), [`smolmix-hyper`](/developers/smolmix/hyper). |
| [`mix-fetch`](/developers/mix-fetch) | TypeScript | `fetch()`-compatible API for browser HTTP(S) requests over the Mixnet. |
| [TypeScript SDK](/developers/typescript) | TypeScript | Browser-side Mixnet Client (raw messaging) and Nyx Smart Contracts. |
| [Standalone Clients](/developers/clients) | Language-agnostic | SOCKS5 and WebSocket binaries for piping traffic through the Mixnet without an SDK. |
@@ -16,7 +16,7 @@ Any application that integrates with Nym sends its traffic through the Mixnet vi
| | **End-to-end** (both sides run Nym) | **Proxy mode** (Nym → clearnet exit) |
|---|---|---|
| **Rust** (native / desktop / CLI) | [`nym-sdk`](/developers/rust) (Stream, Mixnet, Client Pool) | [`smolmix`](/developers/smolmix) (TCP / UDP) · [`nym-sdk`](/developers/rust) SOCKS client |
| **Rust** (native / desktop / CLI) | [`nym-sdk`](/developers/rust) (Stream, Mixnet, Client Pool) | [`smolmix` & connectors](/developers/smolmix) (TCP / UDP, plus DNS / TLS / HTTP via companion crates) · [`nym-sdk`](/developers/rust) SOCKS client |
| **TypeScript** (browser) | [TypeScript SDK](/developers/typescript) (WASM Mixnet Client, messaging only) | [`mix-fetch`](/developers/mix-fetch) (HTTP) |
| **Mobile** (iOS / Android) | via [`nym-vpn-client`](#mobile) (uniffi + cargo-swift / cargo-ndk) | via [`nym-vpn-client`](#mobile) (uniffi + cargo-swift / cargo-ndk) |
@@ -24,7 +24,7 @@ Any application that integrates with Nym sends its traffic through the Mixnet vi
Different runtimes have different transport constraints: a browser cannot open raw sockets or access the filesystem, while a desktop app can.
- **Native / Desktop / CLI**: full access to system networking and persistent storage. Use [`nym-sdk`](/developers/rust) (the Rust SDK) for E2E messaging or byte streams, or [`smolmix`](/developers/smolmix) for TCP/UDP socket-shaped access in proxy mode.
- **Native / Desktop / CLI**: full access to system networking and persistent storage. Use [`nym-sdk`](/developers/rust) (the Rust SDK) for E2E messaging or byte streams, or the [`smolmix` family](/developers/smolmix) for socket-level (TCP/UDP), DNS, TLS, or HTTP access in proxy mode.
- **Browser**: restricted to WebSockets, Web Transport, and `fetch`; HTTPS-only mixed-content rules; no filesystem access. Use [`mix-fetch`](/developers/mix-fetch) for HTTP(S) requests, or the [TypeScript SDK](/developers/typescript)'s WASM Mixnet Client for raw message passing.
### Mobile
@@ -57,7 +57,9 @@ Once traffic leaves the Exit Gateway, it travels over the public internet to the
## Where to go next
- **Rust, E2E messaging or byte streams**: [`nym-sdk`](/developers/rust)
- **Rust, TCP/UDP socket replacements**: [`smolmix`](/developers/smolmix)
- **Rust, TCP/UDP socket replacements**: [`smolmix`](/developers/smolmix/tunnel)
- **Rust, HTTP(S) requests**: [`smolmix-hyper`](/developers/smolmix/hyper)
- **Rust, hostname resolution / TLS without HTTP**: [`smolmix-dns`](/developers/smolmix/dns), [`smolmix-tls`](/developers/smolmix/tls)
- **Browser, HTTP(S) requests**: [`mix-fetch`](/developers/mix-fetch)
- **Browser, raw mixnet messaging or Nyx smart contracts**: [TypeScript SDK](/developers/typescript)
- **Background on Sphinx, gateways, and the mixnet itself**: [Key Concepts](/developers/concepts)
@@ -95,12 +95,12 @@ The first call bootstraps the WASM Nym client (gateway handshake, key generation
## When to use mix-fetch
| | mix-fetch | WASM Mixnet Client | smolmix | Plain fetch (no mixnet) |
| | mix-fetch | WASM Mixnet Client | [smolmix family](/developers/smolmix) | Plain fetch (no mixnet) |
|---|---|---|---|---|
| **Runtime** | Browser, Node.js | Browser | Native (Rust) | Anywhere |
| **Architecture** | Proxy (Network Requester → destination) | E2E (both sides Nym) | Proxy | Direct |
| **API shape** | `fetch()` replacement | Send/recv text or binary messages | `TcpStream` / `UdpSocket` | `fetch()` |
| **HTTP support** | Yes | No | Yes (via `hyper` over `TcpStream`) | Yes |
| **API shape** | `fetch()` replacement | Send/recv text or binary messages | `TcpStream` / `UdpSocket`, or `hyper`-style HTTP via [`smolmix-hyper`](/developers/smolmix/hyper) | `fetch()` |
| **HTTP support** | Yes | No | Yes (via [`smolmix-hyper`](/developers/smolmix/hyper)) | Yes |
| **Sender unlinkability** | Strong (mixnet) | Strong (mixnet) | Strong (mixnet) | None |
| **Concurrency** | 10 per host | Unlimited | Unlimited | Unlimited |
+1 -1
View File
@@ -49,5 +49,5 @@ For an overview of what the SDK can do, see the **[Tour](./rust/tour)**. For set
For proxy-mode integrations (reaching third-party services through an Exit Gateway), see also:
- [**`smolmix`**](/developers/smolmix): `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Compatible with `tokio-rustls`, `hyper`, `tokio-tungstenite`, and the rest of the async Rust ecosystem.
- [**`smolmix` & connectors**](/developers/smolmix): `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack, with companion crates ([`smolmix-dns`](/developers/smolmix/dns), [`smolmix-tls`](/developers/smolmix/tls), [`smolmix-hyper`](/developers/smolmix/hyper)) for DNS, TLS, and HTTP. Compatible with `tokio-rustls`, `hyper`, `tokio-tungstenite`, and the rest of the async Rust ecosystem.
- [**SOCKS Client**](./rust/mixnet): SOCKS4/4a/5 proxy via the Exit Gateway's Network Requester. Works with any SOCKS-capable application without code changes.
+16 -119
View File
@@ -1,130 +1,27 @@
---
title: "smolmix: TCP/UDP Over the Nym Mixnet"
description: "A userspace IP tunnel that provides standard TcpStream and UdpSocket types over the Nym mixnet. Compatible with the async tokio Rust ecosystem."
title: "smolmix & connectors"
description: "Rust crate family for routing networking through the Nym mixnet. TCP/UDP tunnels, DNS, TLS, and HTTP."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-29"
lastUpdated: "2026-05-13"
---
# smolmix
# smolmix & connectors
import { Callout } from 'nextra/components'
import { RUST_MSRV } from '../../components/versions'
A family of Rust crates for routing standard networking through the Nym mixnet. Each crate handles one concern, so you only pull in what you need.
`smolmix` is a TCP/UDP tunnel over the Nym mixnet. It uses a userspace network stack [`smoltcp`](https://docs.rs/smoltcp/latest/smoltcp/) to provide `TcpStream` and `UdpSocket` types that work with the async [`tokio`](https://docs.rs/tokio) Rust ecosystem e.g. [`tokio-rustls`](https://docs.rs/tokio-rustls), [`hyper`](https://docs.rs/hyper), [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite), etc.
| Crate | Use it for |
|---|---|
| [`smolmix`](/developers/smolmix/tunnel) | TCP/UDP tunnel. The base layer everything else builds on, exposing `TcpStream` and `UdpSocket` types compatible with the async tokio ecosystem. |
| [`smolmix-dns`](/developers/smolmix/dns) | Tunneled hostname resolution. Pairs with `smolmix` to avoid leaking DNS queries to the local network. |
| [`smolmix-tls`](/developers/smolmix/tls) | TLS over a `smolmix` `TcpStream`, with webpki root certificates pre-configured. |
| [`smolmix-hyper`](/developers/smolmix/hyper) | Drop-in HTTP client wrapping `hyper-util`. Bundles DNS, TLS, and HTTP through the mixnet. |
| [Building on smolmix](/developers/smolmix/extending) | Patterns for integrating libraries we don't ship a connector for (WebSocket, libp2p, custom protocols). |
The `TcpStream` type implements tokio's `AsyncRead`/`AsyncWrite` traits and `UdpSocket` provides `send_to`/`recv_from` for datagrams.
All crates share the workspace version (`1.21.0`). Full API on docs.rs: [`smolmix`](https://docs.rs/smolmix), [`smolmix-dns`](https://docs.rs/smolmix-dns), [`smolmix-tls`](https://docs.rs/smolmix-tls), [`smolmix-hyper`](https://docs.rs/smolmix-hyper).
```text
┌──────────────────────────────────────────────────────────────┐
│ Your application (TLS, HTTP, WebSocket, DNS, etc.) │
│ └─ smolmix::TcpStream / UdpSocket │
│ └─ smoltcp (userspace TCP/IP) │
│ └─ Nym mixnet → IPR exit gateway → internet │
└──────────────────────────────────────────────────────────────┘
```
## Picking the right layer
Traffic exits the mixnet at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) exit gateway. The exit IP is the gateway's, not yours.
Most users want [`smolmix-hyper`](/developers/smolmix/hyper): a familiar HTTP client that quietly routes DNS, TCP, and TLS through the mixnet. Reach for the lower-level crates when you need something hyper doesn't cover, e.g. WebSocket, raw UDP, libp2p, or a custom protocol over TCP.
## Runtime and platform support
### Other runtimes
`smolmix` currently requires `tokio`. The internal pipeline is tokio-based: the bridge task that shuttles packets to the mixnet, the Nym SDK's `MixnetClient`, and the [`tokio-smoltcp`](https://docs.rs/tokio-smoltcp) reactor that drives the userspace TCP/IP stack all run on the tokio runtime.
This means `smolmix` is not directly compatible with alternative async runtimes like [`smol`](https://docs.rs/smol) or [`async-std`](https://docs.rs/async-std). If you need to use `smolmix` from another runtime, the [`async-compat`](https://docs.rs/async-compat) crate can bridge the gap.
### Non-native `smolmix`
A WASM version of `smolmix` is planned for a future release.
## Installation
Add `smolmix` to your `Cargo.toml`:
```toml
[dependencies]
smolmix = "1.21.0"
nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```
`tokio` is a transitive dependency of smolmix, but you need to enable `rt-multi-thread` (the default runtime spun up by `#[tokio::main]`) and `macros` (for the `#[tokio::main]` attribute itself).
`nym-bin-common` is optional but recommended: it sets up [`tracing`](https://docs.rs/tracing) logging so you can see mixnet connection progress.
**Minimum Rust version:** {RUST_MSRV}+
### From Git
For unreleased changes, import directly from the repository:
```toml
smolmix = { git = "https://github.com/nymtech/nym", branch = "develop" }
```
## When to use smolmix
| | smolmix | Stream module | mixFetch | SOCKS client |
|---|---|---|---|---|
| **Layer** | Transport (TCP/UDP) | Message (multiplexed streams) | HTTP | TCP (SOCKS proxy) |
| **Controls both sides?** | No (proxy mode) | Yes (E2E) | No (proxy mode) | No (proxy mode) |
| **API** | `TcpStream`, `UdpSocket` | `AsyncRead + AsyncWrite` | `fetch()` replacement | SOCKS4/5 protocol |
| **Composability** | Full: TLS, HTTP, WebSocket, DNS, etc. stack on top | Byte streams only | HTTP(S) only | Application-dependent |
| **Best for** | Reaching external services from Rust with standard networking | Peer-to-peer / E2E protocols between Nym clients | Browser HTTP requests | Legacy apps with SOCKS support |
## Security model
<Callout type="warning">
Traffic is Sphinx-encrypted inside the mixnet. Past the Exit Gateway, it travels over the public internet to the destination, the same as any other server-initiated connection. Protect the payload at the application layer with TLS ([`rustls`](https://docs.rs/rustls)), Noise Protocol ([`snow`](https://docs.rs/snow)), or equivalent, as you would on a direct connection.
</Callout>
### What's protected
| Segment | Mixnet encryption | What's visible |
|---|---|---|
| Your machine → mixnet entry | Sphinx (layered) | Entry gateway sees your IP but not the destination |
| Inside the mixnet (entry + 3 mix layers + exit) | Sphinx (layered) | Each node only knows prev/next hop |
| Exit gateway (IPR) | Sphinx removed, raw IP packet exposed | IPR sees destination IP + port. Payload depends on your application layer (see below). |
| IPR → remote host | None (Sphinx is mixnet-only) | Remote host sees IPR's IP, not yours |
The Sphinx encryption is the **mixnet transport layer**: it protects packets as they traverse the mix nodes. At the exit gateway, the Sphinx layers are stripped and the original IP packet is forwarded to the destination. This is analogous to how a Tor exit node or VPN endpoint unwraps its tunnel.
What's inside that IP packet is up to you. If you connect with TLS (as in the [TCP example](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs)), the IPR sees encrypted TLS ciphertext going to a destination IP: it knows where but not what. If you send plaintext HTTP, the IPR can read the full request and response.
### Trust boundaries
- You trust the mixnet to provide unlinkability between sender and receiver. This is enforced cryptographically by the Sphinx packet format and mixing.
- You trust the IPR exit gateway in the same way you trust a VPN exit or Tor exit node: it can inspect your raw IP packets. The difference is that the IPR doesn't know who is sending the traffic (the mixnet hides your identity).
- **Application-layer encryption closes the gap.** TLS, Noise Protocol, or any authenticated encryption keeps the payload as ciphertext to the IPR. It can see destination IP and port, but not payload content.
### Comparison with other privacy tools
| | smolmix | Tor | VPN |
|---|---|---|---|
| **Exit node sees traffic?** | Yes (encrypt it) | Yes (encrypt it) | Yes (encrypt it) |
| **Exit node knows sender?** | No (mixnet hides identity) | No (onion routing) | Yes (VPN provider knows) |
| **Timing analysis resistance** | Strong (mixing, cover traffic) | Weak (low-latency) | None |
| **UDP support** | Yes | No (TCP only) | Yes |
## Examples
Runnable examples in [`smolmix/core/examples/`](https://github.com/nymtech/nym/tree/develop/smolmix/core/examples). Each is self-contained; read the `//!` doc comments at the top of each file for a walkthrough.
```sh
cargo run -p smolmix --example <name>
```
All examples accept `--ipr <ADDRESS>` to target a specific exit node (pass a `Recipient` address to `Tunnel::builder().ipr_address()`).
| Example | Source | What it demonstrates |
|---|---|---|
| UDP | [`udp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp.rs) | DNS lookup via [`hickory-proto`](https://docs.rs/hickory-proto), sending a raw UDP query to `1.1.1.1:53` through the mixnet. Runs a clearnet [`hickory-resolver`](https://docs.rs/hickory-resolver) lookup alongside it to compare resolved IPs and latency |
| TCP | [`tcp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs) | HTTPS request via [`hyper`](https://docs.rs/hyper) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Fetches Cloudflare's `/cdn-cgi/trace` to show that the exit IP differs from clearnet |
| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | WebSocket echo against `ws.postman-echo.com` via [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Runs the same stack over clearnet first, so the only thing that changes between runs is the underlying TCP stream |
| TCP download | [`tcp_download.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp_download.rs) | DNS-over-mixnet + multi-request HTTP/1.1 download over a single keep-alive connection, the full real-world pattern |
## Architecture
The internal stack (smoltcp reactor, device adapter, bridge task, packet flow) is documented in [`ARCHITECTURE.md`](https://github.com/nymtech/nym/blob/develop/smolmix/core/src/ARCHITECTURE.md). This is also the crate-level documentation on docs.rs.
## API reference
Full API documentation is available on [docs.rs/smolmix](https://docs.rs/smolmix).
If you're integrating a library not listed above, the [Building on smolmix](/developers/smolmix/extending) guide shows the patterns: most tokio-native libraries plug into `smolmix::TcpStream` directly, with no glue.
@@ -0,0 +1,7 @@
{
"tunnel": "smolmix-tunnel",
"dns": "smolmix-dns",
"tls": "smolmix-tls",
"hyper": "smolmix-hyper",
"extending": "Building on smolmix"
}
@@ -0,0 +1,89 @@
---
title: "smolmix-dns: DNS Resolution Through the Mixnet"
description: "Resolve hostnames privately using smolmix-dns. Routes all DNS queries through the Nym mixnet to prevent hostname leaks."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# smolmix-dns
import { Callout } from 'nextra/components'
If you resolve hostnames using the OS resolver or a clearnet DNS library, the queries travel over your local network, leaking which domains you're visiting even though the TCP traffic goes through the mixnet. `smolmix-dns` routes all DNS queries (both UDP and TCP fallback) through the tunnel.
## Installation
```toml
[dependencies]
smolmix = "1.21.0"
smolmix-dns = "1.21.0"
```
Both crates share the workspace version. Full API docs: [docs.rs/smolmix-dns](https://docs.rs/smolmix-dns).
## Usage
```rust
use smolmix::Tunnel;
use smolmix_dns::Resolver;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tunnel = 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 method returning SocketAddr with port:
let addrs = resolver.resolve("nymtech.net", 443).await?;
println!("nymtech.net:443 → {addrs:?}");
tunnel.shutdown().await;
Ok(())
}
```
## Caching
<Callout type="info">
**Reuse your `Resolver` across requests.** hickory-resolver maintains an internal LRU cache for DNS responses. Creating a new `Resolver` per lookup (or using the free function `smolmix_dns::resolve()`) bypasses caching entirely: every lookup triggers a full round-trip through the mixnet.
</Callout>
```rust
// Good: resolver created once, cache shared across lookups
let resolver = Resolver::new(&tunnel);
let a = resolver.resolve("example.com", 443).await?;
let b = resolver.resolve("example.com", 80).await?; // cache hit
// Avoid: creates a fresh resolver (and empty cache) each time
let a = smolmix_dns::resolve(&tunnel, "example.com", 443).await?;
let b = smolmix_dns::resolve(&tunnel, "example.com", 80).await?; // cache miss
```
## Custom upstream DNS
By default, queries go to Quad9 (`9.9.9.9`). Use `Resolver::with_config()` for other upstreams:
```rust
use smolmix_dns::{Resolver, ResolverConfig};
// Cloudflare
let resolver = Resolver::with_config(&tunnel, ResolverConfig::cloudflare());
// Google
let resolver = Resolver::with_config(&tunnel, ResolverConfig::google());
```
## Example
```sh
cargo run -p smolmix-dns --example resolve
cargo run -p smolmix-dns --example resolve -- --ipr <IPR_ADDRESS>
```
Source: [`dns/examples/resolve.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/dns/examples/resolve.rs)
@@ -0,0 +1,93 @@
---
title: "Building on smolmix"
description: "How to build your own protocol adapters on top of smolmix. Patterns for WebSocket, libp2p, and any AsyncRead+AsyncWrite library."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# Building on smolmix
import { Callout } from 'nextra/components'
The published companion crates ([`smolmix-dns`](/developers/smolmix/dns), [`smolmix-tls`](/developers/smolmix/tls), [`smolmix-hyper`](/developers/smolmix/hyper)) cover the most common use cases. For other protocols, the same principle applies: if a library accepts `AsyncRead + AsyncWrite` (or can be adapted to it), it works over smolmix with minimal glue.
## The pattern
Every companion crate follows the same recipe:
1. Find the library's I/O extension point (trait or type parameter)
2. Bridge smolmix's `TcpStream` to whatever I/O trait the library expects
3. Expose a thin wrapper that hides the bridging
```text
┌─────────────────────────────────────────────────────┐
│ Library's extension point I/O bridge │
├─────────────────────────────────────────────────────┤
│ hickory RuntimeProvider → AsyncIoTokioAsStd │
│ tokio-rustls TlsConnector → (direct, same │
│ AsyncRead/Write) │
│ tower::Service<Uri> → TokioIo + pin enum │
│ tokio-tungstenite → (direct) │
│ libp2p Transport → tokio_util::compat │
└─────────────────────────────────────────────────────┘
```
Most tokio-native libraries need zero bridging: `smolmix::TcpStream` already implements `tokio::io::AsyncRead + AsyncWrite`. Libraries expecting `futures_io::AsyncRead + AsyncWrite` (like hickory and libp2p) need a one-line adapter.
## Examples in the repository
These self-contained examples demonstrate building protocol integration without a dedicated crate:
| Pattern | Example | Lines of glue | What it demonstrates |
|---|---|---:|---|
| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | ~10 | Composing DNS + TCP + TLS + protocol upgrade. `tokio-tungstenite` accepts any `AsyncRead + AsyncWrite` directly. |
| libp2p Transport | [`libp2p_ping.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/libp2p_ping.rs) | ~80 | Implementing libp2p's `Transport` trait. Dial-only (no listening). Uses `tokio_util::compat` to bridge tokio↔futures I/O. |
Run them with:
```sh
cargo run -p smolmix --example websocket
cargo run -p smolmix --example libp2p_ping
```
## WebSocket: the minimal case
`tokio-tungstenite` takes any stream implementing `AsyncRead + AsyncWrite`. Since `smolmix-tls` returns a `TlsStream<TcpStream>` which implements those traits, no adapter is needed:
```rust
use smolmix::Tunnel;
use smolmix_dns::Resolver;
use smolmix_tls::connect;
use tokio_tungstenite::client_async;
let tunnel = Tunnel::new().await?;
let resolver = Resolver::new(&tunnel);
let addrs = resolver.resolve("echo.websocket.org", 443).await?;
let tcp = tunnel.tcp_connect(addrs[0]).await?;
let tls = connect(tcp, "echo.websocket.org").await?;
let (mut ws, _) = client_async("wss://echo.websocket.org", tls).await?;
// ws is a fully functional WebSocket connection over the mixnet
```
## libp2p: implementing a Transport
libp2p requires a `Transport` trait implementation. The key insight: libp2p uses `futures_io::AsyncRead + AsyncWrite`, not tokio's version. The bridge is `tokio_util::compat::TokioAsyncReadCompatExt`:
```rust
use tokio_util::compat::TokioAsyncReadCompatExt;
let tcp = tunnel.tcp_connect(addr).await?;
let compat_stream = tcp.compat(); // tokio → futures_io bridge
// Now pass compat_stream to libp2p's noise/yamux upgrade
```
See the full implementation in [`libp2p_ping.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/libp2p_ping.rs): it handles multiaddr parsing, DNS resolution through the tunnel, and the noise+yamux handshake.
## When to publish a crate vs use an example
<Callout type="info">
Publish a companion crate when the integration involves non-obvious trait implementations or configuration that users would struggle to get right on their own (e.g. hickory's `RuntimeProvider`). Keep it as an example when the glue is straightforward copy-paste (e.g. WebSocket, where the library already accepts the right traits).
</Callout>
@@ -0,0 +1,104 @@
---
title: "smolmix-hyper: HTTP Client Over the Mixnet"
description: "Make HTTP and HTTPS requests through the Nym mixnet using smolmix-hyper. Full hyper API with DNS, TCP, and TLS routed through the tunnel."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# smolmix-hyper
import { Callout } from 'nextra/components'
`smolmix-hyper` is the highest-level companion crate: a familiar HTTP client that routes DNS resolution, TCP connections, and TLS handshakes through the mixnet. This is what most users want: "fetch a URL anonymously".
## Installation
```toml
[dependencies]
smolmix = "1.21.0"
smolmix-hyper = "1.21.0"
```
Both crates share the workspace version. Full API docs: [docs.rs/smolmix-hyper](https://docs.rs/smolmix-hyper).
You also need a rustls crypto provider installed. Add this at the start of `main()`:
```rust
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
```
## GET request
```rust
use smolmix::Tunnel;
use smolmix_hyper::{Client, Request, EmptyBody, BodyExt};
use bytes::Bytes;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let tunnel = Tunnel::new().await?;
let client = Client::new(&tunnel);
let req = Request::get("https://cloudflare.com/cdn-cgi/trace")
.header("Host", "cloudflare.com")
.body(EmptyBody::<Bytes>::new())?;
let resp = client.request(req).await?;
println!("Status: {}", resp.status());
let body = resp.into_body().collect().await?.to_bytes();
println!("{}", String::from_utf8_lossy(&body));
tunnel.shutdown().await;
Ok(())
}
```
The `ip=` line in the response shows the IPR exit gateway's IP. Your real IP is hidden by the mixnet.
## POST request
The convenience `Client` wrapper uses `Empty<Bytes>` as its body type (suitable for GET). For POST/PUT, construct a client with a different body type using `SmolmixConnector` directly:
```rust
use http_body_util::Full;
use hyper_util::{client::legacy, rt::TokioExecutor};
use bytes::Bytes;
use smolmix_hyper::SmolmixConnector;
let tunnel = smolmix::Tunnel::new().await?;
let connector = SmolmixConnector::new(&tunnel);
let client = legacy::Client::builder(TokioExecutor::new())
.build::<_, Full<Bytes>>(connector);
let body = Full::new(Bytes::from(r#"{"key": "value"}"#));
let req = hyper::Request::post("https://httpbin.org/post")
.header("Host", "httpbin.org")
.header("Content-Type", "application/json")
.body(body)?;
let resp = client.request(req).await?;
```
## Performance notes
<Callout type="info">
The first request takes several seconds (mixnet connection setup + multi-hop TCP handshake). Subsequent requests on the same client reuse the tunnel and are significantly faster. hyper-util's connection pooling also helps: HTTP keep-alive connections avoid repeated TCP/TLS setup.
</Callout>
## Examples
```sh
cargo run -p smolmix-hyper --example get
cargo run -p smolmix-hyper --example post
cargo run -p smolmix-hyper --example get -- --ipr <IPR_ADDRESS>
```
Source: [`hyper/examples/get.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/hyper/examples/get.rs), [`hyper/examples/post.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/hyper/examples/post.rs)
@@ -0,0 +1,85 @@
---
title: "smolmix-tls: TLS Over the Mixnet"
description: "Encrypt the final hop between the exit gateway and remote host using smolmix-tls. Pre-configured TLS with webpki roots for smolmix TCP streams."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# smolmix-tls
import { Callout } from 'nextra/components'
The mixnet protects your identity but not the content of traffic between the exit gateway and remote host. `smolmix-tls` provides a pre-configured `TlsConnector` with webpki root certificates, wrapping a smolmix `TcpStream` in TLS with one function call.
<Callout type="warning">
**Always use TLS** (or another encryption layer) when connecting to external services through the mixnet. Without it, the exit gateway can read your traffic in plaintext.
</Callout>
## Installation
```toml
[dependencies]
smolmix = "1.21.0"
smolmix-tls = "1.21.0"
```
Both crates share the workspace version. Full API docs: [docs.rs/smolmix-tls](https://docs.rs/smolmix-tls).
You also need a rustls crypto provider installed. Add this at the start of `main()`:
```rust
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
```
## Usage
```rust
use smolmix::Tunnel;
use smolmix_tls::{connect, connector, connect_with};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let tunnel = 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 mut tls = connect(tcp, "example.com").await?;
tls.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n").await?;
let mut buf = Vec::new();
tls.read_to_end(&mut buf).await?;
println!("{}", String::from_utf8_lossy(&buf));
tunnel.shutdown().await;
Ok(())
}
```
## Reusing the connector
If you're making multiple TLS connections, build the connector once and reuse it. This avoids rebuilding the root certificate store each time:
```rust
let tls = connector();
let stream1 = connect_with(&tls, tcp1, "a.example.com").await?;
let stream2 = connect_with(&tls, tcp2, "b.example.com").await?;
```
The `TlsConnector` clones cheaply via `Arc`.
## Example
```sh
cargo run -p smolmix-tls --example connect
cargo run -p smolmix-tls --example connect -- --ipr <IPR_ADDRESS>
```
Source: [`tls/examples/connect.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/tls/examples/connect.rs)
@@ -0,0 +1,132 @@
---
title: "smolmix: TCP/UDP Over the Nym Mixnet"
description: "A userspace IP tunnel that provides standard TcpStream and UdpSocket types over the Nym mixnet. Drop-in compatible with async tokio Rust ecosystem."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-23"
---
# smolmix
import { Callout } from 'nextra/components'
import { RUST_MSRV } from '../../../components/versions'
`smolmix` is a TCP/UDP tunnel over the Nym mixnet. It uses a userspace network stack [`smoltcp`](https://docs.rs/smoltcp/latest/smoltcp/) to provide `TcpStream` and `UdpSocket` types that work with the async [`tokio`](https://docs.rs/tokio) Rust ecosystem e.g. [`tokio-rustls`](https://docs.rs/tokio-rustls), [`hyper`](https://docs.rs/hyper), [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite), etc.
The `TcpStream` type implements tokio's `AsyncRead`/`AsyncWrite` traits and `UdpSocket` provides `send_to`/`recv_from` for datagrams.
```text
┌──────────────────────────────────────────────────────────────────┐
│ Your application (TLS, HTTP, WebSocket, DNS, etc.) │
│ └─ smolmix::TcpStream / UdpSocket │
│ └─ smoltcp (userspace TCP/IP) │
│ └─ Nym mixnet → IPR exit gateway → internet │
└──────────────────────────────────────────────────────────────────┘
```
Traffic exits the mixnet at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) exit gateway. The exit IP is the gateway's, not yours.
## Runtime and platform support
### Other runtimes
`smolmix` currently requires `tokio`. The internal pipeline is tokio-based: the bridge task that shuttles packets to the mixnet, the Nym SDK's `MixnetClient`, and the [`tokio-smoltcp`](https://docs.rs/tokio-smoltcp) reactor that drives the userspace TCP/IP stack all run on the tokio runtime.
This means `smolmix` is not compatible with alternative async runtimes like [`smol`](https://docs.rs/smol) or [`async-std`](https://docs.rs/async-std) out of the box. If you need to use `smolmix` from another runtime, the [`async-compat`](https://docs.rs/async-compat) crate can bridge the gap.
### Non-native `smolmix`
A WASM version of `smolmix` is planned for a future release.
## Installation
Add `smolmix` to your `Cargo.toml`:
```toml
[dependencies]
smolmix = "1.21.0"
nym-bin-common = { version = "1.20.4", features = ["basic_tracing"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
blake3 = "=1.7.0" # required pin, see note below
```
<Callout type="warning">
**Temporary pin required.** You must pin [`blake3`](https://docs.rs/blake3) `= "=1.7.0"` to avoid a build failure caused by a transitive [`digest`](https://docs.rs/digest) version conflict. This will be resolved in a future release.
</Callout>
`tokio` is a transitive dependency of smolmix, but you need to enable `rt-multi-thread` (smolmix spawns multiple tasks internally) and `macros` (for `#[tokio::main]`).
`nym-bin-common` is optional but recommended: it sets up [`tracing`](https://docs.rs/tracing) logging so you can see mixnet connection progress.
**Minimum Rust version:** {RUST_MSRV}+
### From Git
For unreleased changes, import directly from the repository:
```toml
smolmix = { git = "https://github.com/nymtech/nym", branch = "develop" }
```
## When to use smolmix
| | smolmix | Stream module | mixFetch | SOCKS client |
|---|---|---|---|---|
| **Layer** | Transport (TCP/UDP) | Message (multiplexed streams) | HTTP | TCP (SOCKS proxy) |
| **Controls both sides?** | No (proxy mode) | Yes (E2E) | No (proxy mode) | No (proxy mode) |
| **API** | `TcpStream`, `UdpSocket` | `AsyncRead + AsyncWrite` | `fetch()` drop-in | SOCKS4/5 protocol |
| **Composability** | Full: TLS, HTTP, WebSocket, DNS, etc. stack on top | Byte streams only | HTTP(S) only | Application-dependent |
| **Best for** | Reaching external services from Rust with standard networking | Peer-to-peer / E2E protocols between Nym clients | Browser HTTP requests | Legacy apps with SOCKS support |
## Security model
<Callout type="warning">
Traffic is Sphinx-encrypted inside the mixnet, but between the exit gateway and the remote host it travels as **normal internet traffic**. Always encrypt the final hop with TLS ([`rustls`](https://docs.rs/rustls)), Noise Protocol ([`snow`](https://docs.rs/snow)), etc.
</Callout>
### What's protected
| Segment | Mixnet encryption | What's visible |
|---|---|---|
| Your machine → mixnet entry | Sphinx (layered) | Entry gateway sees your IP but not the destination |
| Inside the mixnet (entry + 3 mix layers + exit) | Sphinx (layered) | Each node only knows prev/next hop |
| Exit gateway (IPR) | Sphinx removed, raw IP packet exposed | IPR sees destination IP + port. Payload depends on your application layer (see below). |
| IPR → remote host | None (Sphinx is mixnet-only) | Remote host sees IPR's IP, not yours |
The Sphinx encryption is the **mixnet transport layer**: it protects packets as they traverse the mix nodes. At the exit gateway, the Sphinx layers are stripped and the original IP packet is forwarded to the destination. This is analogous to how a Tor exit node or VPN endpoint unwraps its tunnel.
**What's inside that IP packet is entirely up to you.** If you connect with TLS (as in the [TCP example](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs)), the IPR sees encrypted TLS ciphertext going to a destination IP: it knows *where* but not *what*. If you send plaintext HTTP, the IPR can read the full request and response.
### Trust boundaries
- **You trust the mixnet** to provide unlinkability between sender and receiver. This is enforced cryptographically by the Sphinx packet format and mixing.
- **You trust the IPR exit gateway** in the same way you trust a VPN exit or Tor exit node: it can inspect your raw IP packets. The difference is that the IPR doesn't know *who* is sending the traffic (the mixnet hides your identity).
- **Application-layer encryption closes the gap.** TLS, Noise Protocol, or any authenticated encryption ensures the IPR only sees ciphertext. It can see destination IP and port, but not payload content.
### Comparison with other privacy tools
| | smolmix | Tor | VPN |
|---|---|---|---|
| **Exit node sees traffic?** | Yes (encrypt it) | Yes (encrypt it) | Yes (encrypt it) |
| **Exit node knows sender?** | No (mixnet hides identity) | No (onion routing) | Yes (VPN provider knows) |
| **Timing analysis resistance** | Strong (mixing, cover traffic) | Weak (low-latency) | None |
| **UDP support** | Yes | No (TCP only) | Yes |
## Examples
Runnable examples in [`smolmix/core/examples/`](https://github.com/nymtech/nym/tree/develop/smolmix/core/examples). Each is self-contained: read the `//!` doc comments at the top of each file for a walkthrough.
```sh
cargo run -p smolmix --example <name>
```
All examples accept `--ipr <ADDRESS>` to target a specific exit node (pass a `Recipient` address to `Tunnel::builder().ipr_address()`).
| Example | Source | What it demonstrates |
|---|---|---|
| TCP | [`tcp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs) | HTTPS request via [`hyper`](https://docs.rs/hyper) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Fetches Cloudflare's `/cdn-cgi/trace` to show that the exit IP differs from clearnet |
| TCP download | [`tcp_download.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp_download.rs) | DNS-over-mixnet + multi-request HTTP/1.1 download over a single keep-alive connection. The full real-world pattern |
| UDP | [`udp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp.rs) | DNS lookup via [`hickory-proto`](https://docs.rs/hickory-proto). Sends a raw UDP query to `1.1.1.1:53` through the mixnet |
| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | WebSocket echo via [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Full TCP → TLS → WebSocket stack composing over smolmix |
| libp2p ping | [`libp2p_ping.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/libp2p_ping.rs) | Custom libp2p `Transport` over the mixnet (noise+yamux handshake, dial-only, no listening) |
## API reference
Full API documentation including internal architecture is available on [docs.rs/smolmix](https://docs.rs/smolmix). For the companion crates and other connectors, see the [smolmix & connectors overview](/developers/smolmix).
+5
View File
@@ -351,6 +351,11 @@ input:focus-visible {
background-color: rgba(255, 255, 255, 0.03) !important;
}
.landing-chip:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
color: var(--colorPrimary) !important;
}
/* ── Invert diagrams in dark mode ── */
html.dark .nextra-content img:not([src*="landing"]) {
@@ -1,64 +0,0 @@
# rust-libp2p-nym
This repo contains an example implementation of a libp2p transport using the Nym mixnet. It relies on the ChainSafe's fork of libp2p: https://github.com/ChainSafe/rust-libp2p
## Requirements
- Rust 1.68.2
- `Protoc` protobuf compiler. On Debian/Ubuntu distributed via `apt` as `protobuf-compiler` & on Arch/Manjaro via AUR as `[python-protobuf-compiler](https://aur.archlinux.org/packages/python-protobuf-compiler)`.
## Usage
To instantiate a libp2p swarm using the transport:
```rust
use libp2p::core::{muxing::StreamMuxerBox, transport::Transport};
use libp2p::swarm::{keep_alive::Behaviour, SwarmBuilder};
use libp2p::{identity, PeerId};
use nym_sdk::mixnet::MixnetClient;
use rust_libp2p_nym::transport::NymTransport;
use rust_libp2p_nym::test_utils::create_nym_client;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let local_key = identity::Keypair::generate_ed25519();
let local_peer_id = PeerId::from(local_key.public());
info!("Local peer id: {local_peer_id:?}");
let nym_id = rand::random::<u64>().to_string();
let nym_client = MixnetClient::connect_new().await.unwrap();
let transport = NymTransport::new(nym_client, local_key.clone()).await?;
let _swarm = SwarmBuilder::with_tokio_executor(
transport
.map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
.boxed(),
Behaviour::default(),
local_peer_id,
)
.build();
Ok(())
}
```
## Chat example
To run the libp2p chat example, run the following in one terminal:
```bash
cargo run --example libp2p_chat
# Local peer id: PeerId("12D3KooWLukBu6q2FerWPFhFFhiYaJkhn2sBmceh9UCaXe6hJf5D")
# Listening on "/nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve"
```
In another terminal, run ping again, passing the Nym multiaddress printed previously:
```bash
cargo run --example libp2p_chat -- /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Local peer id: PeerId("12D3KooWNsuRwG6DHnFJCDR8B3zdvja6xLcfnbtKCsQWJ8eppyWC")
# Dialed /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Listening on "/nym/2oiRW5C9ivyF3Bo3Gpm4H9EqSKH7A6GpcrRRwVSDVUQ9.EajgCnhzimsP6KskUwKcEj8VFCmHR78s2J6FHWcZ4etR@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf"
```
You should see that the nodes connected and sent messages to each other:
```bash
# 2023-08-10T14:06:28.116Z INFO libp2p_chat > Got message: 'hello world' with id: 37393732353836333838333537303637303237 from peer: 12D3KooWB6k8ZGDF44N4FMRhgVBNihwk1wMYSumosxiZq9pUTbAz
```
@@ -1,173 +0,0 @@
// Copyright 2018 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! A basic chat application with logs demonstrating libp2p and the gossipsub protocol
//! combined with mDNS for the discovery of peers to gossip with.
//!
//! Using two terminal windows, start two instances, typing the following in each:
//!
//! ```sh
//! cargo run
//! ```
//!
//! Mutual mDNS discovery may take a few seconds. When each peer does discover the other
//! it will print a message like:
//!
//! ```sh
//! mDNS discovered a new peer: {peerId}
//! ```
//!
//! Type a message and hit return: the message is sent and printed in the other terminal.
//! Close with Ctrl-c.
//!
//! You can open more terminal windows and add more peers using the same line above.
//!
//! Once an additional peer is mDNS discovered it can participate in the conversation
//! and all peers will receive messages sent from it.
//!
//! If a participant exits (Control-C or otherwise) the other peers will receive an mDNS expired
//! event and remove the expired peer from the list of known peers.
// use crate::rust_libp2p_nym::transport::NymTransport;
// use futures::{prelude::*, select};
// use libp2p::Multiaddr;
// use libp2p::{
// core::muxing::StreamMuxerBox,
// gossipsub, identity,
// swarm::NetworkBehaviour,
// swarm::{SwarmBuilder, SwarmEvent},
// PeerId, Transport,
// };
// use log::{error, info, LevelFilter};
// use nym_sdk::mixnet::MixnetClient;
// use std::collections::hash_map::DefaultHasher;
use std::error::Error;
// use std::hash::{Hash, Hasher};
// use std::time::Duration;
// use tokio::io;
// use tokio_util::codec;
// #[path = "../libp2p_shared/lib.rs"]
// mod rust_libp2p_nym;
//
// // We create a custom network behaviour that uses Gossipsub
// #[derive(NetworkBehaviour)]
// struct Behaviour {
// gossipsub: gossipsub::Behaviour,
// }
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
unimplemented!("temporarily disabled")
// pretty_env_logger::formatted_timed_builder()
// .filter_level(LevelFilter::Warn)
// .filter(Some("libp2p_chat"), LevelFilter::Info)
// .init();
//
// // Create a random PeerId
// let id_keys = identity::Keypair::generate_ed25519();
// let local_peer_id = PeerId::from(id_keys.public());
// info!("Local peer id: {local_peer_id}");
//
// // To content-address message, we can take the hash of message and use it as an ID.
// let message_id_fn = |message: &gossipsub::Message| {
// let mut s = DefaultHasher::new();
// message.data.hash(&mut s);
// gossipsub::MessageId::from(s.finish().to_string())
// };
//
// // Set a custom gossipsub configuration
// let gossipsub_config = gossipsub::ConfigBuilder::default()
// .heartbeat_interval(Duration::from_secs(10)) // This is set to aid debugging by not cluttering the log space
// .validation_mode(gossipsub::ValidationMode::Strict) // This sets the kind of message validation. The default is Strict (enforce message signing)
// .message_id_fn(message_id_fn) // content-address messages. No two messages of the same content will be propagated.
// .build()
// .expect("Valid config");
//
// // build a gossipsub network behaviour
// let mut gossipsub = gossipsub::Behaviour::new(
// gossipsub::MessageAuthenticity::Signed(id_keys),
// gossipsub_config,
// )
// .expect("Correct configuration");
// // Create a Gossipsub topic
// let topic = gossipsub::IdentTopic::new("test-net");
// // subscribes to our topic
// gossipsub.subscribe(&topic)?;
//
// let client = MixnetClient::connect_new().await.unwrap();
// info!("client address: {}", client.nym_address());
//
// let local_key = identity::Keypair::generate_ed25519();
// let local_peer_id = PeerId::from(local_key.public());
// info!("Local peer id: {local_peer_id:?}");
//
// let transport = NymTransport::new(client, local_key).await?;
//
// let mut swarm = SwarmBuilder::with_tokio_executor(
// transport
// .map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
// .boxed(),
// Behaviour { gossipsub },
// local_peer_id,
// )
// .build();
//
// if let Some(addr) = std::env::args().nth(1) {
// let remote: Multiaddr = addr.parse()?;
// swarm.dial(remote)?;
// info!("Dialed {addr}")
// }
//
// // Read full lines from stdin
// let mut stdin = codec::FramedRead::new(io::stdin(), codec::LinesCodec::new()).fuse();
//
// info!("Enter messages via STDIN and they will be sent to connected peers using Gossipsub");
//
// // Kick it off
// loop {
// select! {
// line = stdin.select_next_some() => {
// if let Err(e) = swarm
// .behaviour_mut().gossipsub
// .publish(topic.clone(), line.expect("Stdin not to close").as_bytes()) {
// error!("Publish error: {e:?}");
// }
// },
// event = swarm.select_next_some() => {
// match event {
// SwarmEvent::Behaviour(BehaviourEvent::Gossipsub(gossipsub::Event::Message {
// propagation_source: peer_id,
// message_id: id,
// message,
// })) => info!(
// "Got message: '{}' with id: {id} from peer: {peer_id}",
// String::from_utf8_lossy(&message.data),
// ),
// SwarmEvent::NewListenAddr { address, .. } => {
// info!("Local node is listening on {address}");
// }
// other => {info!("other event: {:?}", other)}
// }
// }
// }
// }
}
@@ -1,79 +0,0 @@
# rust-libp2p-nym
This repo contains an example implementation of a libp2p transport using the Nym mixnet. It relies on the ChainSafe's fork of libp2p: https://github.com/ChainSafe/rust-libp2p
## Requirements
- Rust 1.68.2
- `Protoc` protobuf compiler. On Debian/Ubuntu distributed via `apt` as `protobuf-compiler` & on Arch/Manjaro via AUR as `[python-protobuf-compiler](https://aur.archlinux.org/packages/python-protobuf-compiler)`.
## Usage
To instantiate a libp2p swarm using the transport:
```rust
use libp2p::core::{muxing::StreamMuxerBox, transport::Transport};
use libp2p::swarm::{keep_alive::Behaviour, SwarmBuilder};
use libp2p::{identity, PeerId};
use nym_sdk::mixnet::MixnetClient;
use rust_libp2p_nym::transport::NymTransport;
use rust_libp2p_nym::test_utils::create_nym_client;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let local_key = identity::Keypair::generate_ed25519();
let local_peer_id = PeerId::from(local_key.public());
info!("Local peer id: {local_peer_id:?}");
let nym_id = rand::random::<u64>().to_string();
let nym_client = MixnetClient::connect_new().await.unwrap();
let transport = NymTransport::new(nym_client, local_key.clone()).await?;
let _swarm = SwarmBuilder::with_tokio_executor(
transport
.map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
.boxed(),
Behaviour::default(),
local_peer_id,
)
.build();
Ok(())
}
```
## Ping example
To run the libp2p ping example, run the following in one terminal:
```bash
cargo run --example libp2p_ping
# Local peer id: PeerId("12D3KooWLukBu6q2FerWPFhFFhiYaJkhn2sBmceh9UCaXe6hJf5D")
# Listening on "/nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve"
```
In another terminal, run ping again, passing the Nym multiaddress printed previously:
```bash
cargo run --example libp2p_ping -- /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Local peer id: PeerId("12D3KooWNsuRwG6DHnFJCDR8B3zdvja6xLcfnbtKCsQWJ8eppyWC")
# Dialed /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Listening on "/nym/2oiRW5C9ivyF3Bo3Gpm4H9EqSKH7A6GpcrRRwVSDVUQ9.EajgCnhzimsP6KskUwKcEj8VFCmHR78s2J6FHWcZ4etR@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf"
```
You should see that the nodes connected and pinged each other:
```bash
# Mar 30 22:56:36.400 INFO ping: BehaviourEvent: Event { peer: PeerId("12D3KooWGf2oYd6U2nrLzfDrN9zxsjSQjPsMh2oDJPUQ9hiHMNtf"), result: Ok(Ping { rtt: 1.06836675s }) }
```
```bash
# Mar 30 22:56:35.595 INFO ping: BehaviourEvent: Event { peer: PeerId("12D3KooWMd5ak31DXuZq7x1JuFSR6toA5RDQrPaHrfXEhy7vqqpC"), result: Ok(Pong) }
```
In order to run the ping example with vanilla libp2p, which uses tcp, pass the
`--features libp2p-vanilla` flag to the example and follow the instructions on the
rust-libp2p project as usual.
```bash
RUST_LOG=ping=debug cargo run --example ping --features libp2p-vanilla
```
```bash
RUST_LOG=ping=debug cargo run --example ping --features libp2p-vanilla -- "/ip4/127.0.0.1/tcp/$PORT"
```
@@ -1,143 +0,0 @@
// Copyright 2018 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! Ping example
//!
//! See ../src/tutorial.rs for a step-by-step guide building the example below.
//!
//! In the first terminal window, run:
//!
//! ```sh
//! cargo run --example ping --features=full
//! ```
//!
//! It will print the PeerId and the listening addresses, e.g. `Listening on
//! "/ip4/0.0.0.0/tcp/24915"`
//!
//! In the second terminal window, start a new instance of the example with:
//!
//! ```sh
//! cargo run --example ping --features=full -- /ip4/127.0.0.1/tcp/24915
//! ```
//!
//! The two nodes establish a connection, negotiate the ping protocol
//! and begin pinging each other.
// use libp2p::futures::StreamExt;
// use libp2p::ping::Success;
// use libp2p::swarm::{keep_alive, NetworkBehaviour, SwarmEvent};
// use libp2p::{identity, ping, Multiaddr, PeerId};
// use log::{debug, info, LevelFilter};
use std::error::Error;
// use std::time::Duration;
//
// #[path = "../libp2p_shared/lib.rs"]
// mod rust_libp2p_nym;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
unimplemented!("temporarily disabled")
//
// pretty_env_logger::formatted_timed_builder()
// .filter_level(LevelFilter::Warn)
// .filter(Some("libp2p_ping"), LevelFilter::Debug)
// .init();
//
// let local_key = identity::Keypair::generate_ed25519();
// let local_peer_id = PeerId::from(local_key.public());
// info!("Local peer id: {local_peer_id:?}");
//
// #[cfg(not(feature = "libp2p-vanilla"))]
// let mut swarm = {
// debug!("Running `ping` example using NymTransport");
// use libp2p::core::{muxing::StreamMuxerBox, transport::Transport};
// use libp2p::swarm::SwarmBuilder;
// use rust_libp2p_nym::transport::NymTransport;
//
// let client = nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap();
//
// let transport = NymTransport::new(client, local_key.clone()).await?;
// SwarmBuilder::with_tokio_executor(
// transport
// .map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
// .boxed(),
// Behaviour::default(),
// local_peer_id,
// )
// .build()
// };
//
// #[cfg(feature = "libp2p-vanilla")]
// let mut swarm = {
// debug!("Running `ping` example using the vanilla libp2p tokio_development_transport");
// let transport = libp2p::tokio_development_transport(local_key)?;
// let mut swarm =
// libp2p::Swarm::with_tokio_executor(transport, Behaviour::default(), local_peer_id);
// swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?;
// swarm
// };
//
// // Dial the peer identified by the multi-address given as the second
// // command-line argument, if any.
// if let Some(addr) = std::env::args().nth(1) {
// let remote: Multiaddr = addr.parse()?;
// swarm.dial(remote)?;
// info!("Dialed {addr}")
// }
//
// let mut total_ping_rtt: Duration = Duration::from_micros(0);
// let mut counter: u128 = 0;
// loop {
// match swarm.select_next_some().await {
// SwarmEvent::NewListenAddr { address, .. } => info!("Listening on {address:?}"),
// SwarmEvent::Behaviour(event) => {
// // Get the round-trip duration for the pings.
// // This value is already captured in the BehaviourEvent::Ping's `Success::Ping`
// // field.
// debug!("{event:?}");
// if let BehaviourEvent::Ping(ping_event) = event {
// let result: Success = ping_event.result?;
// match result {
// Success::Ping { rtt } => {
// counter += 1;
// total_ping_rtt += rtt;
// let average_ping_rtt = Duration::from_micros(
// (total_ping_rtt.as_micros() / counter).try_into().unwrap(),
// );
// info!("Ping RTT: {rtt:?} AVERAGE RTT: ({counter} pings): {average_ping_rtt:?}");
// }
// Success::Pong => info!("Pong Event"),
// }
// }
// }
// _ => {}
// }
// }
}
//
// /// Our network behaviour.
// ///
// /// For illustrative purposes, this includes the [`KeepAlive`](behaviour::KeepAlive) behaviour so a continuous sequence of
// /// pings can be observed.
// #[derive(NetworkBehaviour, Default)]
// struct Behaviour {
// keep_alive: keep_alive::Behaviour,
// ping: ping::Behaviour,
// }
@@ -1,418 +0,0 @@
use libp2p::core::{muxing::StreamMuxerEvent, PeerId, StreamMuxer};
use log::debug;
use nym_sphinx::addressing::clients::Recipient;
use std::{
collections::{HashMap, HashSet},
pin::Pin,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
task::{Context, Poll, Waker},
};
use tokio::sync::{
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
oneshot,
};
use super::error::Error;
use super::message::{
ConnectionId, Message, OutboundMessage, SubstreamId, SubstreamMessage, SubstreamMessageType,
TransportMessage,
};
use super::substream::Substream;
/// Connection represents the result of a connection setup process.
/// It implements `StreamMuxer` and thus has stream multiplexing built in.
#[derive(Debug)]
pub struct Connection {
pub(crate) peer_id: PeerId,
pub(crate) remote_recipient: Recipient,
pub(crate) id: ConnectionId,
/// receive inbound messages from the `InnerConnection`
pub(crate) inbound_rx: UnboundedReceiver<SubstreamMessage>,
/// substream ID -> outbound pending substream exists
/// the key is deleted when the response is received, or the request times out
pending_substreams: HashSet<SubstreamId>,
/// substream ID -> substream's inbound_tx channel
substream_inbound_txs: HashMap<SubstreamId, UnboundedSender<Vec<u8>>>,
/// substream ID -> substream's close_tx channel
substream_close_txs: HashMap<SubstreamId, oneshot::Sender<()>>,
/// send messages to the mixnet
/// used for sending `SubstreamMessageType::OpenRequest` messages
/// also passed to each substream so they can write to the mixnet
pub(crate) mixnet_outbound_tx: UnboundedSender<OutboundMessage>,
/// inbound substream open requests; used in poll_inbound
inbound_open_tx: UnboundedSender<Substream>,
inbound_open_rx: UnboundedReceiver<Substream>,
/// closed substream IDs; used in poll_close
close_tx: UnboundedSender<SubstreamId>,
close_rx: UnboundedReceiver<SubstreamId>,
/// message nonce contains the next nonce that should be used when
/// sending a message over the connection
pub(crate) message_nonce: Arc<AtomicU64>,
waker: Option<Waker>,
}
impl Connection {
pub(crate) fn new(
peer_id: PeerId,
remote_recipient: Recipient,
id: ConnectionId,
inbound_rx: UnboundedReceiver<SubstreamMessage>,
mixnet_outbound_tx: UnboundedSender<OutboundMessage>,
) -> Self {
let (inbound_open_tx, inbound_open_rx) = unbounded_channel();
let (close_tx, close_rx) = unbounded_channel();
Connection {
peer_id,
remote_recipient,
id,
inbound_rx,
pending_substreams: HashSet::new(),
substream_inbound_txs: HashMap::new(),
substream_close_txs: HashMap::new(),
mixnet_outbound_tx,
inbound_open_tx,
inbound_open_rx,
close_tx,
close_rx,
message_nonce: Arc::new(AtomicU64::new(1)),
waker: None,
}
}
fn new_outbound_substream(&mut self) -> Result<Substream, Error> {
let substream_id = SubstreamId::generate();
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
// send the substream open request that requests to open a substream with the given ID
self.mixnet_outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.id.clone(),
message: SubstreamMessage {
substream_id: substream_id.clone(),
message_type: SubstreamMessageType::OpenRequest,
},
}),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
// track pending outbound substreams
// TODO we should probably lock this? storing map values should be atomic
let res = self.new_substream(substream_id.clone());
if res.is_ok() {
self.pending_substreams.insert(substream_id);
}
res
}
// creates a new substream instance with the given ID.
fn new_substream(&mut self, id: SubstreamId) -> Result<Substream, Error> {
// check we don't already have a substream with this ID
if self.substream_inbound_txs.contains_key(&id) {
return Err(Error::SubstreamIdExists(id));
}
let (inbound_tx, inbound_rx) = unbounded_channel::<Vec<u8>>();
let (close_tx, close_rx) = oneshot::channel::<()>();
self.substream_inbound_txs.insert(id.clone(), inbound_tx);
self.substream_close_txs.insert(id.clone(), close_tx);
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(Substream::new(
self.remote_recipient,
self.id.clone(),
id,
inbound_rx,
self.mixnet_outbound_tx.clone(),
close_rx,
self.message_nonce.clone(),
))
}
fn handle_close(&mut self, substream_id: SubstreamId) -> Result<(), Error> {
if self.substream_inbound_txs.remove(&substream_id).is_none() {
return Err(Error::SubstreamIdDoesNotExist(substream_id));
}
// notify substream that it's closed
let close_tx = self.substream_close_txs.remove(&substream_id);
close_tx.unwrap().send(()).unwrap();
// notify poll_close that the substream is closed
self.close_tx
.send(substream_id)
.map_err(|e| Error::InboundSendFailure(e.to_string()))
}
}
impl StreamMuxer for Connection {
type Substream = Substream;
type Error = Error;
fn poll_inbound(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<Self::Substream, Self::Error>> {
if let Poll::Ready(Some(substream)) = self.inbound_open_rx.poll_recv(cx) {
return Poll::Ready(Ok(substream));
}
Poll::Pending
}
fn poll_outbound(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Self::Substream, Self::Error>> {
Poll::Ready(self.new_outbound_substream())
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
if let Poll::Ready(Some(_)) = self.close_rx.poll_recv(cx) {
return Poll::Ready(Ok(()));
}
Poll::Pending
}
fn poll(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<StreamMuxerEvent, Self::Error>> {
while let Poll::Ready(Some(msg)) = self.inbound_rx.poll_recv(cx) {
match msg.message_type {
SubstreamMessageType::OpenRequest => {
// create a new substream with the given ID
let substream = self.new_substream(msg.substream_id.clone())?;
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
// send the response to the remote peer
self.mixnet_outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.id.clone(),
message: SubstreamMessage {
substream_id: msg.substream_id.clone(),
message_type: SubstreamMessageType::OpenResponse,
},
}),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
debug!("wrote OpenResponse for substream: {:?}", &msg.substream_id);
// send the substream to our own channel to be returned in poll_inbound
self.inbound_open_tx
.send(substream)
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
debug!("new inbound substream: {:?}", &msg.substream_id);
}
SubstreamMessageType::OpenResponse => {
if !self.pending_substreams.remove(&msg.substream_id) {
debug!(
"SubstreamMessageType::OpenResponse no substream pending for ID: {:?}",
&msg.substream_id
);
}
}
SubstreamMessageType::Close => {
self.handle_close(msg.substream_id)?;
}
SubstreamMessageType::Data(data) => {
debug!("SubstreamMessageType::Data: {:?}", &data);
let inbound_tx = self
.substream_inbound_txs
.get_mut(&msg.substream_id)
.expect("must have a substream channel for substream");
// NOTE: this ignores channel closed errors, which is fine because the substream
// might have been closed/dropped
inbound_tx.send(data).ok();
}
}
}
self.waker = Some(cx.waker().clone());
Poll::Pending
}
}
/// PendingConnection represents a connection that's been initiated, but not completed.
pub(crate) struct PendingConnection {
pub(crate) remote_recipient: Recipient,
pub(crate) connection_tx: oneshot::Sender<Connection>,
}
impl PendingConnection {
pub(crate) fn new(
remote_recipient: Recipient,
connection_tx: oneshot::Sender<Connection>,
) -> Self {
PendingConnection {
remote_recipient,
connection_tx,
}
}
}
#[cfg(test)]
mod test {
use super::super::message::InboundMessage;
use super::super::mixnet::initialize_mixnet;
use super::*;
use futures::future::poll_fn;
use futures::{AsyncReadExt, AsyncWriteExt, FutureExt};
use nym_sdk::mixnet::MixnetClient;
async fn inbound_receive_and_send(
connection_id: ConnectionId,
mixnet_inbound_rx: &mut UnboundedReceiver<InboundMessage>,
inbound_tx: &UnboundedSender<SubstreamMessage>,
expected_nonce: u64,
) {
let recv_msg = mixnet_inbound_rx.recv().await.unwrap();
match recv_msg.0 {
Message::TransportMessage(TransportMessage {
nonce,
id,
message: msg,
}) => {
assert_eq!(nonce, expected_nonce);
assert_eq!(id, connection_id);
inbound_tx.send(msg).unwrap();
}
_ => panic!("unexpected message"),
}
}
#[tokio::test]
async fn test_connection_stream_muxer() {
let client = MixnetClient::connect_new().await.unwrap();
let (sender_address, mut sender_mixnet_inbound_rx, sender_outbound_tx) =
initialize_mixnet(client, None).await.unwrap();
let client2 = MixnetClient::connect_new().await.unwrap();
let (recipient_address, mut recipient_mixnet_inbound_rx, recipient_outbound_tx) =
initialize_mixnet(client2, None).await.unwrap();
let connection_id = ConnectionId::generate();
let recipient_peer_id = PeerId::random();
let sender_peer_id = PeerId::random();
// create the connections
let (sender_inbound_tx, sender_inbound_rx) = unbounded_channel::<SubstreamMessage>();
let mut sender_connection = Connection::new(
recipient_peer_id,
recipient_address,
connection_id.clone(),
sender_inbound_rx,
sender_outbound_tx,
);
let (recipient_inbound_tx, recipient_inbound_rx) = unbounded_channel::<SubstreamMessage>();
let mut recipient_connection = Connection::new(
sender_peer_id,
sender_address,
connection_id.clone(),
recipient_inbound_rx,
recipient_outbound_tx,
);
// send the substream OpenRequest to the mixnet
let mut sender_substream = sender_connection.new_outbound_substream().unwrap();
assert!(sender_connection
.pending_substreams
.contains(&sender_substream.substream_id));
assert_eq!(sender_connection.message_nonce.load(Ordering::SeqCst), 2);
// poll the recipient inbound stream; should receive the OpenRequest and create the substream
inbound_receive_and_send(
connection_id.clone(),
&mut recipient_mixnet_inbound_rx,
&recipient_inbound_tx,
1,
)
.await;
poll_fn(|cx| Pin::new(&mut recipient_connection).as_mut().poll(cx)).now_or_never();
assert_eq!(recipient_connection.message_nonce.load(Ordering::SeqCst), 2);
// poll recipient's poll_inbound to receive the substream
let maybe_recipient_substream = poll_fn(|cx| {
Pin::new(&mut recipient_connection)
.as_mut()
.poll_inbound(cx)
})
.now_or_never();
let mut recipient_substream = maybe_recipient_substream.unwrap().unwrap();
// poll sender's connection to receive the OpenResponse and send it to the Connection inbound channel
inbound_receive_and_send(
connection_id.clone(),
&mut sender_mixnet_inbound_rx,
&sender_inbound_tx,
1,
)
.await;
// poll sender's poll_outbound to get the substream
poll_fn(|cx| Pin::new(&mut sender_connection).as_mut().poll(cx)).now_or_never();
assert!(sender_connection.pending_substreams.is_empty());
// finally, write message to the substream
let data = b"hello world";
sender_substream.write_all(data).await.unwrap();
assert_eq!(sender_connection.message_nonce.load(Ordering::SeqCst), 3);
// receive message from the mixnet, push to the recipient Connection inbound channel
inbound_receive_and_send(
connection_id.clone(),
&mut recipient_mixnet_inbound_rx,
&recipient_inbound_tx,
2,
)
.await;
// poll the sender's connection to send the msg from the connection inbound channel to the substream's
poll_fn(|cx| Pin::new(&mut sender_connection).as_mut().poll(cx)).now_or_never();
// poll the recipient's connection to read the msg from the mixnet and mux it into the substream
poll_fn(|cx| Pin::new(&mut recipient_connection).as_mut().poll(cx)).now_or_never();
let mut buf = [0u8; 11];
let n = recipient_substream.read(&mut buf).await.unwrap();
assert_eq!(n, 11);
assert_eq!(buf, data[..]);
// test closing the stream; assert the stream is closed on both sides
sender_substream.close().await.unwrap();
assert_eq!(sender_connection.message_nonce.load(Ordering::SeqCst), 4);
inbound_receive_and_send(
connection_id.clone(),
&mut recipient_mixnet_inbound_rx,
&recipient_inbound_tx,
3,
)
.await;
}
}
@@ -1,64 +0,0 @@
use libp2p::core::multiaddr;
use nym_sphinx::addressing::clients::RecipientFormattingError;
use super::message::SubstreamId;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unimplemented")]
Unimplemented,
#[error("failed to format multiaddress from nym address")]
FailedToFormatMultiaddr(#[from] multiaddr::Error),
#[error("unexpected protocol in multiaddress")]
InvalidProtocolForMultiaddr,
#[error("failed to decode message")]
InvalidMessageBytes,
#[error("no connection found for ConnectionResponse")]
NoConnectionForResponse,
#[error("received ConnectionResponse but connection was already established")]
ConnectionAlreadyEstablished,
#[error("received None recipient in ConnectionRequest")]
NoneRecipientInConnectionRequest,
#[error("cannot handle connection request; already have connection with given ID")]
ConnectionIDExists,
#[error("no connection found for TransportMessage")]
NoConnectionForTransportMessage,
#[error("failed to decode ConnectionMessage; too short")]
ConnectionMessageBytesTooShort,
#[error("failed to decode ConnectionMessage; no recipient")]
ConnectionMessageBytesNoRecipient,
#[error("failed to decode ConnectionMessage; no peer ID")]
ConnectionMessageBytesNoPeerId,
#[error("invalid peer ID bytes")]
InvalidPeerIdBytes,
#[error("invalid recipient bytes")]
InvalidRecipientBytes(#[from] RecipientFormattingError),
#[error("invalid recipient prefix byte")]
InvalidRecipientPrefixByte,
#[error("failed to decode TransportMessage; too short")]
TransportMessageBytesTooShort,
#[error("failed to decode TransportMessage; invalid nonce")]
InvalidNonce,
#[error("invalid substream ID")]
InvalidSubstreamMessageBytes,
#[error("invalid substream message type byte")]
InvalidSubstreamMessageType,
#[error("substrean with given ID already exists")]
SubstreamIdExists(SubstreamId),
#[error("no substream found for given ID")]
SubstreamIdDoesNotExist(SubstreamId),
#[error("recv error: channel closed")]
OneshotRecvFailure(#[from] tokio::sync::oneshot::error::RecvError),
#[error("recv error: channel closed")]
RecvFailure,
#[error("outbound send error")]
OutboundSendFailure(String),
#[error("inbound send error")]
InboundSendFailure(String),
#[error("failed to send new connection; receiver dropped")]
ConnectionSendFailure,
#[error("failed to send initial TransportEvent::NewAddress")]
SendErrorTransportEvent,
#[error("dial timed out")]
DialTimeout(#[from] tokio::time::error::Elapsed),
}
@@ -1,10 +0,0 @@
pub(crate) mod connection;
pub mod error;
pub(crate) mod message;
pub(crate) mod mixnet;
pub(crate) mod queue;
pub mod substream;
pub mod transport;
/// The deafult timeout secs for [`transport::Upgrade`] future.
const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 5;
@@ -1,335 +0,0 @@
use libp2p::core::PeerId;
use nym_sphinx::addressing::clients::Recipient;
use rand::rngs::OsRng;
use rand::RngCore;
use std::fmt::{Debug, Formatter};
use super::error::Error;
const RECIPIENT_LENGTH: usize = Recipient::LEN;
const CONNECTION_ID_LENGTH: usize = 32;
const SUBSTREAM_ID_LENGTH: usize = 32;
const NONCE_BYTES_LEN: usize = 8; // length of u64
const MIN_CONNECTION_MESSAGE_LEN: usize = CONNECTION_ID_LENGTH + NONCE_BYTES_LEN;
/// ConnectionId is a unique, randomly-generated per-connection ID that's used to
/// identify which connection a message belongs to.
#[derive(Clone, Default, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionId([u8; 32]);
impl ConnectionId {
pub(crate) fn generate() -> Self {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
ConnectionId(bytes)
}
fn from_bytes(bytes: &[u8]) -> Self {
let mut id = [0u8; 32];
id[..].copy_from_slice(&bytes[0..CONNECTION_ID_LENGTH]);
ConnectionId(id)
}
}
impl Debug for ConnectionId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
/// SubstreamId is a unique, randomly-generated per-substream ID that's used to
/// identify which substream a message belongs to.
#[derive(Clone, Default, Eq, Hash, PartialEq)]
pub struct SubstreamId(pub(crate) [u8; 32]);
impl SubstreamId {
pub(crate) fn generate() -> Self {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
SubstreamId(bytes)
}
fn from_bytes(bytes: &[u8]) -> Self {
let mut id = [0u8; 32];
id[..].copy_from_slice(&bytes[0..SUBSTREAM_ID_LENGTH]);
SubstreamId(id)
}
}
impl Debug for SubstreamId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
#[derive(Debug)]
#[allow(clippy::enum_variant_names)]
pub(crate) enum Message {
ConnectionRequest(ConnectionMessage),
ConnectionResponse(ConnectionMessage),
TransportMessage(TransportMessage),
}
/// ConnectionMessage is exchanged to open a new connection.
#[derive(Debug)]
pub(crate) struct ConnectionMessage {
pub(crate) peer_id: PeerId,
pub(crate) id: ConnectionId,
/// recipient is the sender's Nym address.
/// only required if this is a ConnectionRequest.
pub(crate) recipient: Option<Recipient>,
}
/// TransportMessage is sent over a connection after establishment.
#[derive(Debug, Clone)]
pub(crate) struct TransportMessage {
/// increments by 1 for every TransportMessage sent over a connection.
/// required for ordering, since Nym does not guarantee ordering.
/// ConnectionMessages do not need nonces, as we know that they will
/// be the first messages sent over a connection.
/// the first TransportMessage sent over a connection will have nonce 1.
pub(crate) nonce: u64,
pub(crate) message: SubstreamMessage,
pub(crate) id: ConnectionId,
}
impl Message {
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, Error> {
if bytes.len() < 2 {
return Err(Error::InvalidMessageBytes);
}
Ok(match bytes[0] {
0 => Message::ConnectionRequest(ConnectionMessage::try_from_bytes(&bytes[1..])?),
1 => Message::ConnectionResponse(ConnectionMessage::try_from_bytes(&bytes[1..])?),
2 => Message::TransportMessage(TransportMessage::try_from_bytes(&bytes[1..])?),
_ => return Err(Error::InvalidMessageBytes),
})
}
}
impl ConnectionMessage {
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.id.0.to_vec();
match self.recipient {
Some(recipient) => {
bytes.push(1u8);
bytes.append(&mut recipient.to_bytes().to_vec());
}
None => bytes.push(0u8),
}
bytes.append(&mut self.peer_id.to_bytes());
bytes
}
fn try_from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < CONNECTION_ID_LENGTH + 1 {
return Err(Error::ConnectionMessageBytesTooShort);
}
let id = ConnectionId::from_bytes(&bytes[0..CONNECTION_ID_LENGTH]);
let recipient = match bytes[CONNECTION_ID_LENGTH] {
0u8 => None,
1u8 => {
if bytes.len() < CONNECTION_ID_LENGTH + 1 + RECIPIENT_LENGTH {
return Err(Error::ConnectionMessageBytesNoRecipient);
}
let mut recipient_bytes = [0u8; RECIPIENT_LENGTH];
recipient_bytes[..].copy_from_slice(
&bytes[CONNECTION_ID_LENGTH + 1..CONNECTION_ID_LENGTH + 1 + RECIPIENT_LENGTH],
);
Some(
Recipient::try_from_bytes(recipient_bytes)
.map_err(Error::InvalidRecipientBytes)?,
)
}
_ => {
return Err(Error::InvalidRecipientPrefixByte);
}
};
let peer_id = match recipient {
Some(_) => {
if bytes.len() < CONNECTION_ID_LENGTH + RECIPIENT_LENGTH + 2 {
return Err(Error::ConnectionMessageBytesNoPeerId);
}
PeerId::from_bytes(&bytes[CONNECTION_ID_LENGTH + 1 + RECIPIENT_LENGTH..])
.map_err(|_| Error::InvalidPeerIdBytes)?
}
None => {
if bytes.len() < CONNECTION_ID_LENGTH + 2 {
return Err(Error::ConnectionMessageBytesNoPeerId);
}
PeerId::from_bytes(&bytes[CONNECTION_ID_LENGTH + 1..])
.map_err(|_| Error::InvalidPeerIdBytes)?
}
};
Ok(ConnectionMessage {
peer_id,
recipient,
id,
})
}
}
impl TransportMessage {
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.nonce.to_be_bytes().to_vec();
bytes.extend_from_slice(self.id.0.as_ref());
bytes.extend_from_slice(&self.message.to_bytes());
bytes
}
fn try_from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < MIN_CONNECTION_MESSAGE_LEN + 1 {
return Err(Error::TransportMessageBytesTooShort);
}
let nonce = u64::from_be_bytes(
bytes[0..NONCE_BYTES_LEN]
.to_vec()
.try_into()
.map_err(|_| Error::InvalidNonce)?,
);
let id = ConnectionId::from_bytes(&bytes[NONCE_BYTES_LEN..MIN_CONNECTION_MESSAGE_LEN]);
let message = SubstreamMessage::try_from_bytes(&bytes[MIN_CONNECTION_MESSAGE_LEN..])?;
Ok(TransportMessage { nonce, message, id })
}
}
impl Ord for TransportMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.nonce.cmp(&other.nonce)
}
}
impl std::cmp::PartialOrd for TransportMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::cmp::Eq for TransportMessage {}
impl std::cmp::PartialEq for TransportMessage {
fn eq(&self, other: &Self) -> bool {
self.nonce == other.nonce
}
}
#[derive(Debug, Clone)]
pub(crate) enum SubstreamMessageType {
OpenRequest,
OpenResponse,
Close,
Data(Vec<u8>),
}
impl SubstreamMessageType {
fn to_u8(&self) -> u8 {
match self {
SubstreamMessageType::OpenRequest => 0,
SubstreamMessageType::OpenResponse => 1,
SubstreamMessageType::Close => 2,
SubstreamMessageType::Data(_) => 3,
}
}
}
/// SubstreamMessage is a message sent over a substream.
#[derive(Debug, Clone)]
pub(crate) struct SubstreamMessage {
pub(crate) substream_id: SubstreamId,
pub(crate) message_type: SubstreamMessageType,
}
impl SubstreamMessage {
pub(crate) fn new_with_data(substream_id: SubstreamId, message: Vec<u8>) -> Self {
SubstreamMessage {
substream_id,
message_type: SubstreamMessageType::Data(message),
}
}
pub(crate) fn new_close(substream_id: SubstreamId) -> Self {
SubstreamMessage {
substream_id,
message_type: SubstreamMessageType::Close,
}
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.substream_id.0.clone().to_vec();
bytes.push(self.message_type.to_u8());
if let SubstreamMessageType::Data(message) = &self.message_type {
bytes.extend_from_slice(message);
}
bytes
}
pub(crate) fn try_from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < SUBSTREAM_ID_LENGTH + 1 {
return Err(Error::InvalidSubstreamMessageBytes);
}
let substream_id = SubstreamId::from_bytes(&bytes[0..SUBSTREAM_ID_LENGTH]);
let message_type = match bytes[SUBSTREAM_ID_LENGTH] {
0 => SubstreamMessageType::OpenRequest,
1 => SubstreamMessageType::OpenResponse,
2 => SubstreamMessageType::Close,
3 => {
if bytes.len() < SUBSTREAM_ID_LENGTH + 2 {
return Err(Error::InvalidSubstreamMessageBytes);
}
SubstreamMessageType::Data(bytes[SUBSTREAM_ID_LENGTH + 1..].to_vec())
}
_ => return Err(Error::InvalidSubstreamMessageType),
};
Ok(SubstreamMessage {
substream_id,
message_type,
})
}
}
impl Message {
pub(crate) fn to_bytes(&self) -> Vec<u8> {
match self {
Message::ConnectionRequest(msg) => {
let mut bytes = 0_u8.to_be_bytes().to_vec();
bytes.append(&mut msg.to_bytes());
bytes
}
Message::ConnectionResponse(msg) => {
let mut bytes = 1_u8.to_be_bytes().to_vec();
bytes.append(&mut msg.to_bytes());
bytes
}
Message::TransportMessage(msg) => {
let mut bytes = 2_u8.to_be_bytes().to_vec();
bytes.append(&mut msg.to_bytes());
bytes
}
}
}
}
/// InboundMessage represents an inbound mixnet message.
pub(crate) struct InboundMessage(pub(crate) Message);
/// OutboundMessage represents an outbound mixnet message.
#[derive(Debug)]
pub(crate) struct OutboundMessage {
pub(crate) message: Message,
pub(crate) recipient: Recipient,
}
pub(crate) fn parse_message_data(data: &[u8]) -> Result<InboundMessage, Error> {
if data.len() < 2 {
return Err(Error::InvalidMessageBytes);
}
let msg = Message::try_from_bytes(data.to_vec())?;
Ok(InboundMessage(msg))
}
@@ -1,164 +0,0 @@
use futures::{pin_mut, select};
use futures::{FutureExt, StreamExt};
use log::debug;
use nym_sdk::mixnet::{IncludedSurbs, MixnetClient, MixnetClientSender, MixnetMessageSender};
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::receiver::ReconstructedMessage;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use super::error::Error;
use super::message::*;
/// initialize_mixnet initializes a read/write connection to a Nym websockets endpoint.
/// It starts a task that listens for inbound messages from the endpoint and writes outbound messages to the endpoint.
pub(crate) async fn initialize_mixnet(
client: MixnetClient,
notify_inbound_tx: Option<UnboundedSender<()>>,
) -> Result<
(
Recipient,
UnboundedReceiver<InboundMessage>,
UnboundedSender<OutboundMessage>,
),
Error,
> {
let recipient = *client.nym_address();
// a channel of inbound messages from the mixnet..
// the transport reads from (listens) to the inbound_rx.
// TODO: this is probably a DOS vector; we should limit the size of the channel.
let (inbound_tx, inbound_rx) = unbounded_channel::<InboundMessage>();
// a channel of outbound messages to be written to the mixnet.
// the transport writes to outbound_tx.
let (outbound_tx, mut outbound_rx) = unbounded_channel::<OutboundMessage>();
let sink = client.split_sender();
let mut stream = client;
tokio::task::spawn(async move {
loop {
let t1 = check_inbound(&mut stream, &inbound_tx, &notify_inbound_tx).fuse();
let t2 = check_outbound(&sink, &mut outbound_rx).fuse();
pin_mut!(t1, t2);
select! {
_ = t1 => {},
_ = t2 => {},
};
}
});
Ok((recipient, inbound_rx, outbound_tx))
}
async fn check_inbound(
client: &mut MixnetClient,
inbound_tx: &UnboundedSender<InboundMessage>,
notify_inbound_tx: &Option<UnboundedSender<()>>,
) -> Result<(), Error> {
if let Some(msg) = client.next().await {
if let Some(notify_tx) = notify_inbound_tx {
notify_tx
.send(())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
}
handle_inbound(msg, inbound_tx).await?;
}
Err(Error::Unimplemented)
}
async fn handle_inbound(
msg: ReconstructedMessage,
inbound_tx: &UnboundedSender<InboundMessage>,
) -> Result<(), Error> {
let data = parse_message_data(&msg.message)?;
inbound_tx
.send(data)
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
Ok(())
}
async fn check_outbound(
mixnet_sender: &MixnetClientSender,
outbound_rx: &mut UnboundedReceiver<OutboundMessage>,
) -> Result<(), Error> {
match outbound_rx.recv().await {
Some(message) => {
write_bytes(
mixnet_sender,
message.recipient,
&message.message.to_bytes(),
)
.await
}
None => Err(Error::RecvFailure),
}
}
async fn write_bytes(
mixnet_sender: &MixnetClientSender,
recipient: Recipient,
message: &[u8],
) -> Result<(), Error> {
if let Err(_err) = mixnet_sender
.send_message(recipient, message, IncludedSurbs::ExposeSelfAddress)
.await
{
return Err(Error::Unimplemented);
}
debug!(
"wrote message to mixnet: recipient: {:?}",
recipient.to_string()
);
Ok(())
}
#[cfg(test)]
mod test {
use super::super::message::{
self, ConnectionId, Message, SubstreamId, SubstreamMessage, SubstreamMessageType,
TransportMessage,
};
use super::super::mixnet::initialize_mixnet;
use nym_sdk::mixnet::MixnetClient;
#[tokio::test]
async fn test_mixnet_poll_inbound_and_outbound() {
let client = MixnetClient::connect_new().await.unwrap();
let (self_address, mut inbound_rx, outbound_tx) =
initialize_mixnet(client, None).await.unwrap();
let msg_inner = "hello".as_bytes();
let substream_id = SubstreamId::generate();
let msg = Message::TransportMessage(TransportMessage {
nonce: 1, // arbitrary
id: ConnectionId::generate(),
message: SubstreamMessage::new_with_data(substream_id.clone(), msg_inner.to_vec()),
});
// send a message to ourselves through the mixnet
let out_msg = message::OutboundMessage {
message: msg,
recipient: self_address,
};
outbound_tx.send(out_msg).unwrap();
// receive the message from ourselves over the mixnet
let received_msg = inbound_rx.recv().await.unwrap();
if let Message::TransportMessage(recv_msg) = received_msg.0 {
assert_eq!(substream_id, recv_msg.message.substream_id);
if let SubstreamMessageType::Data(data) = recv_msg.message.message_type {
assert_eq!(msg_inner, data.as_slice());
} else {
panic!("expected SubstreamMessage::Data")
}
} else {
panic!("expected Message::TransportMessage")
}
}
}
@@ -1,138 +0,0 @@
use log::{debug, warn};
use std::collections::BTreeSet;
use super::message::TransportMessage;
/// MessageQueue is a queue of messages, ordered by nonce, that we've
/// received but are not yet able to process because we're waiting for
/// a message with the next expected nonce first.
/// This is required because Nym does not guarantee any sort of message
/// ordering, only delivery.
/// TODO: is there a DOS vector here where a malicious peer sends us
/// messages only with nonce higher than the next expected nonce?
pub(crate) struct MessageQueue {
/// nonce of the next message we expect to receive on the
/// connection.
/// any messages with a nonce greater than this are pushed into
/// the queue.
/// if we get a message with a nonce equal to this, then we
/// immediately handle it in the transport and increment the nonce.
next_expected_nonce: u64,
/// the actual queue of messages, ordered by nonce.
/// the head of the queue's nonce is always greater
/// than the next expected nonce.
queue: BTreeSet<TransportMessage>,
}
impl MessageQueue {
pub(crate) fn new() -> Self {
MessageQueue {
next_expected_nonce: 0,
queue: BTreeSet::new(),
}
}
pub(crate) fn print_nonces(&self) {
let nonces = self.queue.iter().map(|msg| msg.nonce).collect::<Vec<_>>();
debug!("MessageQueue: {:?}", nonces);
}
/// sets the next expected nonce to 1, indicating that we've received
/// a ConnectionRequest or ConnectionResponse.
pub(crate) fn set_connection_message_received(&mut self) {
if self.next_expected_nonce != 0 {
panic!("connection message received twice");
}
self.next_expected_nonce = self.next_expected_nonce.wrapping_add(1);
}
/// tries to push a message into the queue.
/// if the message has the next expected nonce, then the message is returned,
/// and should be processed by the caller.
/// in that case, the internal queue's next expected nonce is incremented.
pub(crate) fn try_push(&mut self, msg: TransportMessage) -> Option<TransportMessage> {
if msg.nonce == self.next_expected_nonce {
self.next_expected_nonce = self.next_expected_nonce.wrapping_add(1);
Some(msg)
} else {
if msg.nonce < self.next_expected_nonce {
// this shouldn't happen normally, only if the other node
// is not following the protocol
warn!("received a message with a nonce that is too low");
return None;
}
if !self.queue.insert(msg) {
// this shouldn't happen normally, only if the other node
// is not following the protocol
warn!("received a message with a duplicate nonce");
return None;
}
None
}
}
pub(crate) fn pop(&mut self) -> Option<TransportMessage> {
let head = self.queue.first()?;
if head.nonce == self.next_expected_nonce {
self.next_expected_nonce = self.next_expected_nonce.wrapping_add(1);
Some(self.queue.pop_first().unwrap())
} else {
None
}
}
}
#[cfg(test)]
mod test {
use super::super::message::{ConnectionId, SubstreamId, SubstreamMessage};
use super::*;
impl TransportMessage {
fn new(nonce: u64, message: SubstreamMessage, id: ConnectionId) -> Self {
TransportMessage { nonce, message, id }
}
}
#[test]
fn test_message_queue() {
let mut queue = MessageQueue::new();
let test_substream_message =
SubstreamMessage::new_with_data(SubstreamId::generate(), vec![1, 2, 3]);
let connection_id = ConnectionId::generate();
let msg1 = TransportMessage::new(1, test_substream_message.clone(), connection_id.clone());
let msg2 = TransportMessage::new(2, test_substream_message.clone(), connection_id.clone());
let msg3 = TransportMessage::new(3, test_substream_message.clone(), connection_id.clone());
assert_eq!(queue.try_push(msg1.clone()), None);
assert_eq!(queue.try_push(msg3.clone()), None);
assert_eq!(queue.try_push(msg2.clone()), None);
assert_eq!(queue.pop(), None);
// set expected nonce to 1
queue.set_connection_message_received();
assert_eq!(queue.pop(), Some(msg1));
let msg4 = TransportMessage::new(4, test_substream_message.clone(), connection_id.clone());
assert_eq!(queue.try_push(msg4.clone()), None);
assert_eq!(queue.pop(), Some(msg2));
assert_eq!(queue.pop(), Some(msg3));
assert_eq!(queue.pop(), Some(msg4));
assert_eq!(queue.pop(), None);
assert_eq!(queue.next_expected_nonce, 5);
// should just return the message and increment nonce when message nonce = next expected nonce
let msg5 = TransportMessage::new(5, test_substream_message, connection_id);
assert_eq!(queue.try_push(msg5.clone()), Some(msg5));
assert_eq!(queue.next_expected_nonce, 6);
}
}
@@ -1,412 +0,0 @@
use super::message::{
ConnectionId, Message, OutboundMessage, SubstreamId, SubstreamMessage, TransportMessage,
};
use futures::{
io::{Error as IoError, ErrorKind},
AsyncRead, AsyncWrite,
};
use log::debug;
use nym_sphinx::addressing::clients::Recipient;
use parking_lot::Mutex;
use std::{
pin::Pin,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
task::{Context, Poll},
};
use tokio::sync::{
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot::Receiver,
};
#[derive(Debug)]
pub struct Substream {
remote_recipient: Recipient,
connection_id: ConnectionId,
pub(crate) substream_id: SubstreamId,
/// inbound messages; inbound_tx is in the corresponding Connection
pub(crate) inbound_rx: UnboundedReceiver<Vec<u8>>,
/// outbound messages; go directly to the mixnet
outbound_tx: UnboundedSender<OutboundMessage>,
/// used to signal when the substream is closed
close_rx: Receiver<()>,
closed: Mutex<bool>,
// buffer of data that's been written to the stream,
// but not yet read by the application.
unread_data: Mutex<Vec<u8>>,
message_nonce: Arc<AtomicU64>,
}
impl Substream {
pub(crate) fn new(
remote_recipient: Recipient,
connection_id: ConnectionId,
substream_id: SubstreamId,
inbound_rx: UnboundedReceiver<Vec<u8>>,
outbound_tx: UnboundedSender<OutboundMessage>,
close_rx: Receiver<()>,
message_nonce: Arc<AtomicU64>,
) -> Self {
Substream {
remote_recipient,
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
closed: Mutex::new(false),
unread_data: Mutex::new(vec![]),
message_nonce,
}
}
fn check_closed(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Result<(), IoError> {
let closed_err = IoError::other("stream closed");
// close_rx will return an error if the channel is closed (ie. sender was dropped),
// or if it's empty
let received_closed = self.close_rx.try_recv();
let mut closed = self.closed.lock();
if *closed {
return Err(closed_err);
}
if received_closed.is_ok() {
*closed = true;
return Err(closed_err);
}
Ok(())
}
}
impl AsyncRead for Substream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut [u8],
) -> Poll<Result<usize, IoError>> {
let closed_result = self.as_mut().check_closed(cx);
if let Err(e) = closed_result {
return Poll::Ready(Err(e));
}
let inbound_rx_data = self.inbound_rx.poll_recv(cx);
// first, write any previously unread data to the buf
let mut unread_data = self.unread_data.lock();
let filled_len = if unread_data.len() > 0 {
let unread_len = unread_data.len();
let buf_len = buf.len();
let copy_len = std::cmp::min(unread_len, buf_len);
buf[..copy_len].copy_from_slice(&unread_data[..copy_len]);
*unread_data = unread_data[copy_len..].to_vec();
copy_len
} else {
0
};
if let Poll::Ready(Some(data)) = inbound_rx_data {
if filled_len == buf.len() {
// we've filled the buffer, so we'll have to save the rest for later
let mut new = vec![];
new.extend(unread_data.drain(..));
new.extend(data.iter());
*unread_data = new;
return Poll::Ready(Ok(filled_len));
}
// otherwise, there's still room in the buffer, so we'll copy the rest of the data
let remaining_len = buf.len() - filled_len;
let data_len = data.len();
// we have more data than buffer room remaining, save the extra for later
if remaining_len < data_len {
unread_data.extend_from_slice(&data[remaining_len..]);
}
let copied = std::cmp::min(remaining_len, data_len);
buf[filled_len..filled_len + copied].copy_from_slice(&data[..copied]);
debug!("poll_read copied {} bytes: data {:?}", copied, buf);
return Poll::Ready(Ok(copied));
}
if filled_len > 0 {
debug!("poll_read copied {} bytes: data {:?}", filled_len, buf);
return Poll::Ready(Ok(filled_len));
}
Poll::Pending
}
}
impl AsyncWrite for Substream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, IoError>> {
if let Err(e) = self.as_mut().check_closed(cx) {
return Poll::Ready(Err(e));
}
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
self.outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.connection_id.clone(),
message: SubstreamMessage::new_with_data(
self.substream_id.clone(),
buf.to_vec(),
),
}),
})
.map_err(|e| IoError::other(format!("poll_write outbound_tx error: {}", e)))?;
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), IoError>> {
if let Err(e) = self.check_closed(cx) {
return Poll::Ready(Err(e));
}
Poll::Ready(Ok(()))
}
fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), IoError>> {
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
let mut closed = self.closed.lock();
if *closed {
return Poll::Ready(Err(IoError::other("stream closed")));
}
*closed = true;
// send a close message to the mixnet
self.outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.connection_id.clone(),
message: SubstreamMessage::new_close(self.substream_id.clone()),
}),
})
.map_err(|e| IoError::other(format!("poll_close outbound_rx error: {}", e)))?;
Poll::Ready(Ok(()))
}
}
#[cfg(test)]
mod test {
use super::super::message::{
ConnectionId, Message, SubstreamId, SubstreamMessage, TransportMessage,
};
use super::super::mixnet::initialize_mixnet;
use super::Substream;
use futures::{AsyncReadExt, AsyncWriteExt};
use nym_sdk::mixnet::MixnetClient;
use nym_sphinx::addressing::clients::Recipient;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
#[tokio::test]
async fn test_substream_poll_read_unread_data() {
let (outbound_tx, _) = tokio::sync::mpsc::unbounded_channel();
let connection_id = ConnectionId::generate();
let substream_id = SubstreamId::generate();
let (inbound_tx, inbound_rx) = tokio::sync::mpsc::unbounded_channel();
let (_, close_rx) = tokio::sync::oneshot::channel();
let mut substream = Substream::new(
Recipient::try_from_base58_string("D1rrpsysCGCYXy9saP8y3kmNpGtJZUXN9SvFoUcqAsM9.9Ssso1ea5NfkbMASdiseDSjTN1fSWda5SgEVjdSN4CvV@GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeuY5KUuN").unwrap(),
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
Arc::new(AtomicU64::new(1)),
);
// test writing and reading w/ same length data
let data = b"hello".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 5];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, data.len());
assert_eq!(buf.to_vec(), data);
// test writing data longer than read buffer
let data = b"nootwashere".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 4];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"noot".to_vec());
let mut buf = [0u8; 7];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"washere".to_vec());
// test read buffer larger than written data
let data = b"nootwashere".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 16];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, data.len());
assert_eq!(buf[..data.len()], data);
assert_eq!(buf[data.len()..].to_vec(), vec![0u8; 16 - data.len()]);
// test writing data longer than read buffer multiple times
let data = b"nootwashere".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 4];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"noot".to_vec());
let data = b"asdf".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 4];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"wash".to_vec());
let mut buf = [0u8; 8];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, 7);
assert_eq!(buf[..7], b"ereasdf".to_vec());
}
#[tokio::test]
async fn test_substream_read_write() {
let client = MixnetClient::connect_new().await.unwrap();
let (self_address, mut mixnet_inbound_rx, outbound_tx) =
initialize_mixnet(client, None).await.unwrap();
const MSG_INNER: &[u8] = "hello".as_bytes();
let connection_id = ConnectionId::generate();
let substream_id = SubstreamId::generate();
let (inbound_tx, inbound_rx) = tokio::sync::mpsc::unbounded_channel();
let (_, close_rx) = tokio::sync::oneshot::channel();
let mut substream = Substream::new(
self_address,
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
Arc::new(AtomicU64::new(1)),
);
// send message to ourselves over the mixnet
substream.write_all(MSG_INNER).await.unwrap();
// receive full message over the mixnet
let recv_msg = mixnet_inbound_rx.recv().await.unwrap();
match recv_msg.0 {
Message::TransportMessage(TransportMessage {
nonce,
id: _,
message:
SubstreamMessage {
substream_id: _,
message_type: msg,
},
}) => {
assert_eq!(nonce, 1);
match msg {
super::super::message::SubstreamMessageType::Data(data) => {
assert_eq!(data, MSG_INNER);
// send message to substream inbound channel
inbound_tx.send(data).unwrap();
}
_ => panic!("unexpected message type"),
}
}
_ => panic!("unexpected message"),
}
// read message from substream
let mut buf = [0u8; MSG_INNER.len()];
substream.read_exact(&mut buf).await.unwrap();
assert_eq!(buf, MSG_INNER);
// close substream
substream.close().await.unwrap();
// try to read/write to closed substream; should error
substream.write_all(MSG_INNER).await.unwrap_err();
substream.read_exact(&mut buf).await.unwrap_err();
// assert a close message was sent over the mixnet
let recv_msg = mixnet_inbound_rx.recv().await.unwrap();
match recv_msg.0 {
Message::TransportMessage(TransportMessage {
nonce: _,
id: _,
message:
SubstreamMessage {
substream_id: _,
message_type: msg,
},
}) => match msg {
super::super::message::SubstreamMessageType::Close => {}
_ => panic!("unexpected message type"),
},
_ => panic!("unexpected message: {:?}", recv_msg.0),
}
}
#[tokio::test]
async fn test_substream_recv_close() {
let client = MixnetClient::connect_new().await.unwrap();
let (self_address, _, outbound_tx) = initialize_mixnet(client, None).await.unwrap();
const MSG_INNER: &[u8] = "hello".as_bytes();
let connection_id = ConnectionId::generate();
let substream_id = SubstreamId::generate();
let (_, inbound_rx) = tokio::sync::mpsc::unbounded_channel();
let (close_tx, close_rx) = tokio::sync::oneshot::channel();
let mut substream = Substream::new(
self_address,
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
Arc::new(AtomicU64::new(1)),
);
// close substream
close_tx.send(()).unwrap();
// try to read/write to closed substream; should error
substream.write_all(MSG_INNER).await.unwrap_err();
let mut buf = [0u8; MSG_INNER.len()];
substream.read_exact(&mut buf).await.unwrap_err();
}
}
@@ -1,898 +0,0 @@
use futures::prelude::*;
use libp2p::core::{
identity::Keypair,
multiaddr::{Multiaddr, Protocol},
transport::{ListenerId, TransportError, TransportEvent},
PeerId, Transport,
};
use log::debug;
use nym_sdk::mixnet::MixnetClient;
use nym_sphinx::addressing::clients::Recipient;
use std::{
collections::HashMap,
pin::Pin,
str::FromStr,
task::{Context, Poll, Waker},
};
use tokio::{
sync::{
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
oneshot,
},
time::{timeout, Duration},
};
use tokio_stream::wrappers::UnboundedReceiverStream;
use super::connection::{Connection, PendingConnection};
use super::error::Error;
use super::message::{
ConnectionId, ConnectionMessage, InboundMessage, Message, OutboundMessage, SubstreamMessage,
TransportMessage,
};
use super::mixnet::initialize_mixnet;
use super::queue::MessageQueue;
use super::DEFAULT_HANDSHAKE_TIMEOUT_SECS;
/// InboundTransportEvent represents an inbound event from the mixnet.
pub enum InboundTransportEvent {
ConnectionRequest(Upgrade),
ConnectionResponse,
TransportMessage,
}
/// NymTransport implements the Transport trait using the Nym mixnet.
pub struct NymTransport {
/// our Nym address
self_address: Recipient,
pub(crate) listen_addr: Multiaddr,
pub(crate) listener_id: ListenerId,
/// our libp2p keypair; currently not really used
keypair: Keypair,
/// established connections -> channel which sends messages received from
/// the mixnet to the corresponding Connection
connections: HashMap<ConnectionId, UnboundedSender<SubstreamMessage>>,
/// outbound pending dials
pending_dials: HashMap<ConnectionId, PendingConnection>,
/// connection message queues
message_queues: HashMap<ConnectionId, MessageQueue>,
/// inbound mixnet messages
inbound_stream: UnboundedReceiverStream<InboundMessage>,
/// outbound mixnet messages
outbound_tx: UnboundedSender<OutboundMessage>,
/// inbound messages for Transport.poll()
poll_rx: UnboundedReceiver<TransportEvent<Upgrade, Error>>,
/// outbound messages to Transport.poll()
poll_tx: UnboundedSender<TransportEvent<Upgrade, Error>>,
waker: Option<Waker>,
/// Timeout for the [`Upgrade`] future.
handshake_timeout: Duration,
}
impl NymTransport {
/// New transport.
#[allow(unused)]
pub async fn new(client: MixnetClient, keypair: Keypair) -> Result<Self, Error> {
Self::new_maybe_with_notify_inbound(client, keypair, None, None).await
}
/// New transport with a timeout.
#[allow(dead_code)]
pub async fn new_with_timeout(
client: MixnetClient,
keypair: Keypair,
timeout: Duration,
) -> Result<Self, Error> {
Self::new_maybe_with_notify_inbound(client, keypair, None, Some(timeout)).await
}
/// Add timeout to transport and return self.
#[allow(dead_code)]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.handshake_timeout = timeout;
self
}
async fn new_maybe_with_notify_inbound(
client: MixnetClient,
keypair: Keypair,
notify_inbound_tx: Option<UnboundedSender<()>>,
timeout: Option<Duration>,
) -> Result<Self, Error> {
let (self_address, inbound_rx, outbound_tx) =
initialize_mixnet(client, notify_inbound_tx).await?;
let listen_addr = nym_address_to_multiaddress(self_address)?;
let listener_id = ListenerId::new();
let (poll_tx, poll_rx) = unbounded_channel::<TransportEvent<Upgrade, Error>>();
poll_tx
.send(TransportEvent::NewAddress {
listener_id,
listen_addr: listen_addr.clone(),
})
.map_err(|_| Error::SendErrorTransportEvent)?;
let inbound_stream = UnboundedReceiverStream::new(inbound_rx);
let handshake_timeout =
timeout.unwrap_or_else(|| Duration::from_secs(DEFAULT_HANDSHAKE_TIMEOUT_SECS));
Ok(Self {
self_address,
listen_addr,
listener_id,
keypair,
connections: HashMap::new(),
pending_dials: HashMap::new(),
message_queues: HashMap::new(),
inbound_stream,
outbound_tx,
poll_rx,
poll_tx,
waker: None,
handshake_timeout,
})
}
pub(crate) fn peer_id(&self) -> PeerId {
PeerId::from_public_key(&self.keypair.public())
}
fn handle_message_queue_on_connection_initiation(
&mut self,
id: &ConnectionId,
) -> Result<(), Error> {
debug!("handle_message_queue_on_connection_initiation");
let Some(inbound_tx) = self.connections.get(id) else {
// this should not happen
return Err(Error::NoConnectionForTransportMessage);
};
match self.message_queues.get_mut(id) {
Some(queue) => {
// update expected nonce
queue.set_connection_message_received();
// push pending inbound some messages in this case
while let Some(msg) = queue.pop() {
debug!(
"popped queued message with nonce {} for connection",
msg.nonce
);
inbound_tx
.send(msg.message.clone())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
}
}
None => {
// no queue exists for this connection, create one
let queue = MessageQueue::new();
self.message_queues.insert(id.clone(), queue);
let queue = self.message_queues.get_mut(id).unwrap();
queue.set_connection_message_received();
}
};
debug!("returning from handle_message_queue_on_connection_initiation");
Ok(())
}
// handle_connection_response resolves the pending connection corresponding to the response
// (if there is one) into a Connection.
fn handle_connection_response(&mut self, msg: &ConnectionMessage) -> Result<(), Error> {
if self.connections.contains_key(&msg.id) {
return Err(Error::ConnectionAlreadyEstablished);
}
if let Some(pending_conn) = self.pending_dials.remove(&msg.id) {
// resolve connection and put into pending_conn channel
let (conn, conn_tx) = self.create_connection_types(
msg.peer_id,
pending_conn.remote_recipient,
msg.id.clone(),
);
self.connections.insert(msg.id.clone(), conn_tx);
self.handle_message_queue_on_connection_initiation(&msg.id)?;
pending_conn
.connection_tx
.send(conn)
.map_err(|_| Error::ConnectionSendFailure)?;
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(())
} else {
Err(Error::NoConnectionForResponse)
}
}
/// handle_connection_request handles an incoming connection request, sends back a
/// connection response, and finally completes the upgrade into a Connection.
fn handle_connection_request(&mut self, msg: &ConnectionMessage) -> Result<Connection, Error> {
if msg.recipient.is_none() {
return Err(Error::NoneRecipientInConnectionRequest);
}
// ensure we don't already have a conn with the same id
if self.connections.contains_key(&msg.id) {
return Err(Error::ConnectionIDExists);
}
let (conn, conn_tx) =
self.create_connection_types(msg.peer_id, msg.recipient.unwrap(), msg.id.clone());
self.connections.insert(msg.id.clone(), conn_tx);
self.handle_message_queue_on_connection_initiation(&msg.id)?;
let resp = ConnectionMessage {
peer_id: self.peer_id(),
recipient: None,
id: msg.id.clone(),
};
self.outbound_tx
.send(OutboundMessage {
message: Message::ConnectionResponse(resp),
recipient: msg.recipient.unwrap(),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(conn)
}
fn handle_transport_message(&mut self, msg: TransportMessage) -> Result<(), Error> {
let queue = match self.message_queues.get_mut(&msg.id) {
Some(queue) => queue,
None => {
// no queue exists for this connection, create one
let queue = MessageQueue::new();
self.message_queues.insert(msg.id.clone(), queue);
self.message_queues.get_mut(&msg.id).unwrap()
}
};
queue.print_nonces();
let nonce = msg.nonce;
let Some(msg) = queue.try_push(msg) else {
// don't push the message yet, it's been queued
debug!("message with nonce {} queued for connection", nonce);
return Ok(());
};
let Some(inbound_tx) = self.connections.get(&msg.id) else {
return Err(Error::NoConnectionForTransportMessage);
};
// send original message
debug!(
"sending original message with nonce {} for connection",
nonce
);
inbound_tx
.send(msg.message.clone())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
// try to pop queued messages and send them on inbound channel
while let Some(msg) = queue.pop() {
debug!(
"popped queued message with nonce {} for connection",
msg.nonce
);
inbound_tx
.send(msg.message.clone())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
}
if let Some(waker) = self.waker.clone().take() {
waker.wake();
}
Ok(())
}
fn create_connection_types(
&self,
remote_peer_id: PeerId,
recipient: Recipient,
id: ConnectionId,
) -> (Connection, UnboundedSender<SubstreamMessage>) {
let (inbound_tx, inbound_rx) = unbounded_channel::<SubstreamMessage>();
// representation of a connection; this contains channels for applications to read/write to.
let conn = Connection::new(
remote_peer_id,
recipient,
id,
inbound_rx,
self.outbound_tx.clone(),
);
// inbound_tx is what we write to when receiving messages on the mixnet,
(conn, inbound_tx)
}
/// handle_inbound handles an inbound message from the mixnet, received via self.inbound_stream.
fn handle_inbound(&mut self, msg: Message) -> Result<InboundTransportEvent, Error> {
match msg {
Message::ConnectionRequest(inner) => {
debug!("got inbound connection request {:?}", inner);
match self.handle_connection_request(&inner) {
Ok(conn) => {
let (connection_tx, connection_rx) =
oneshot::channel::<(PeerId, Connection)>();
let upgrade = Upgrade::new(connection_rx);
connection_tx
.send((inner.peer_id, conn))
.map_err(|_| Error::ConnectionSendFailure)?;
Ok(InboundTransportEvent::ConnectionRequest(upgrade))
}
Err(e) => Err(e),
}
}
Message::ConnectionResponse(msg) => {
debug!("got inbound connection response {:?}", msg);
self.handle_connection_response(&msg)
.map(|_| InboundTransportEvent::ConnectionResponse)
}
Message::TransportMessage(msg) => {
debug!("got inbound TransportMessage: {:?}", msg);
self.handle_transport_message(msg)
.map(|_| InboundTransportEvent::TransportMessage)
}
}
}
}
/// Upgrade represents a transport listener upgrade.
/// Note: we immediately upgrade a connection request to a connection,
/// so this only contains a channel for receiving that connection.
pub struct Upgrade {
connection_tx: oneshot::Receiver<(PeerId, Connection)>,
}
impl Upgrade {
fn new(connection_tx: oneshot::Receiver<(PeerId, Connection)>) -> Upgrade {
Upgrade { connection_tx }
}
}
impl Future for Upgrade {
type Output = Result<(PeerId, Connection), Error>;
// poll checks if the upgrade has turned into a connection yet
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.connection_tx
.poll_unpin(cx)
.map_err(|_| Error::RecvFailure)
}
}
impl Transport for NymTransport {
type Output = (PeerId, Connection);
type Error = Error;
type ListenerUpgrade = Upgrade;
type Dial = Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + Send>>;
fn listen_on(&mut self, _: Multiaddr) -> Result<ListenerId, TransportError<Self::Error>> {
// we should only allow listening on the multiaddress containing our Nym address
Ok(self.listener_id)
}
fn remove_listener(&mut self, id: ListenerId) -> bool {
if self.listener_id != id {
return false;
}
// TODO: close channels?
self.poll_tx
.send(TransportEvent::ListenerClosed {
listener_id: id,
reason: Ok(()),
})
.expect("failed to send listener closed event");
true
}
fn dial(&mut self, addr: Multiaddr) -> Result<Self::Dial, TransportError<Self::Error>> {
debug!("dialing {}", addr);
let id = ConnectionId::generate();
// create remote recipient address
let recipient = multiaddress_to_nym_address(addr).map_err(TransportError::Other)?;
// create pending conn structs and store
let (connection_tx, connection_rx) = oneshot::channel::<Connection>();
let inner_pending_conn = PendingConnection::new(recipient, connection_tx);
self.pending_dials.insert(id.clone(), inner_pending_conn);
// put ConnectionRequest message into outbound message channel
let msg = ConnectionMessage {
peer_id: self.peer_id(),
recipient: Some(self.self_address),
id,
};
let outbound_tx = self.outbound_tx.clone();
let mut waker = self.waker.clone();
let handshake_timeout = self.handshake_timeout;
Ok(async move {
outbound_tx
.send(OutboundMessage {
message: Message::ConnectionRequest(msg),
recipient,
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
debug!("sent outbound ConnectionRequest");
if let Some(waker) = waker.take() {
waker.wake();
};
let conn = timeout(handshake_timeout, connection_rx).await??;
Ok((conn.peer_id, conn))
}
.boxed())
}
// dial_as_listener currently just calls self.dial().
fn dial_as_listener(
&mut self,
addr: Multiaddr,
) -> Result<Self::Dial, TransportError<Self::Error>> {
self.dial(addr)
}
fn poll(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<TransportEvent<Self::ListenerUpgrade, Self::Error>> {
// new addresses + listener close events
if let Poll::Ready(Some(res)) = self.poll_rx.recv().boxed().poll_unpin(cx) {
return Poll::Ready(res);
}
// check for and handle inbound messages
while let Poll::Ready(Some(msg)) = self.inbound_stream.poll_next_unpin(cx) {
match self.handle_inbound(msg.0) {
Ok(event) => match event {
InboundTransportEvent::ConnectionRequest(upgrade) => {
debug!("InboundTransportEvent::ConnectionRequest");
return Poll::Ready(TransportEvent::Incoming {
listener_id: self.listener_id,
upgrade,
local_addr: self.listen_addr.clone(),
send_back_addr: self.listen_addr.clone(),
});
}
InboundTransportEvent::ConnectionResponse => {
debug!("InboundTransportEvent::ConnectionResponse");
}
InboundTransportEvent::TransportMessage => {
debug!("InboundTransportEvent::TransportMessage");
}
},
Err(e) => {
return Poll::Ready(TransportEvent::ListenerError {
listener_id: self.listener_id,
error: e,
});
}
};
}
self.waker = Some(cx.waker().clone());
Poll::Pending
}
fn address_translation(&self, _listen: &Multiaddr, _observed: &Multiaddr) -> Option<Multiaddr> {
None
}
}
fn nym_address_to_multiaddress(addr: Recipient) -> Result<Multiaddr, Error> {
Multiaddr::from_str(&format!("/nym/{}", addr)).map_err(Error::FailedToFormatMultiaddr)
}
fn multiaddress_to_nym_address(multiaddr: Multiaddr) -> Result<Recipient, Error> {
let mut multiaddr = multiaddr;
match multiaddr.pop().unwrap() {
Protocol::Nym(addr) => Recipient::from_str(&addr).map_err(Error::InvalidRecipientBytes),
_ => Err(Error::InvalidProtocolForMultiaddr),
}
}
#[cfg(test)]
mod test {
use super::super::connection::Connection;
use super::super::error::Error;
use super::super::message::{
Message, OutboundMessage, SubstreamId, SubstreamMessage, SubstreamMessageType,
TransportMessage,
};
use super::super::substream::Substream;
use super::{nym_address_to_multiaddress, NymTransport};
use futures::{future::poll_fn, AsyncReadExt, AsyncWriteExt, FutureExt};
use libp2p::core::{
identity::Keypair,
transport::{Transport, TransportEvent},
Multiaddr, StreamMuxer,
};
use log::info;
use nym_bin_common::logging::setup_tracing_logger;
use nym_sdk::mixnet::MixnetClient;
use std::{pin::Pin, str::FromStr, sync::atomic::Ordering};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
impl Connection {
fn write(&self, msg: SubstreamMessage) -> Result<(), Error> {
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
self.mixnet_outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.id.clone(),
message: msg,
}),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
Ok(())
}
}
impl NymTransport {
async fn new_with_notify_inbound(
client: MixnetClient,
notify_inbound_tx: UnboundedSender<()>,
) -> Result<Self, Error> {
let local_key = Keypair::generate_ed25519();
Self::new_maybe_with_notify_inbound(client, local_key, Some(notify_inbound_tx), None)
.await
}
}
#[tokio::test]
async fn test_transport_connection() {
setup_tracing_logger();
let client = MixnetClient::connect_new().await.unwrap();
let (dialer_notify_inbound_tx, mut dialer_notify_inbound_rx) = unbounded_channel();
let mut dialer_transport =
NymTransport::new_with_notify_inbound(client, dialer_notify_inbound_tx)
.await
.unwrap();
let client2 = MixnetClient::connect_new().await.unwrap();
let (listener_notify_inbound_tx, mut listener_notify_inbound_rx) = unbounded_channel();
let mut listener_transport =
NymTransport::new_with_notify_inbound(client2, listener_notify_inbound_tx)
.await
.unwrap();
let listener_multiaddr =
nym_address_to_multiaddress(listener_transport.self_address).unwrap();
assert_new_address_event(Pin::new(&mut dialer_transport)).await;
assert_new_address_event(Pin::new(&mut listener_transport)).await;
// dial the remote peer
let mut dial = dialer_transport.dial(listener_multiaddr).unwrap();
// poll the dial to send the connection request message
assert!(poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.is_none());
listener_notify_inbound_rx.recv().await.unwrap();
// should receive the connection request from the mixnet and send the connection response
let res = poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx)).await;
let mut upgrade = match res {
TransportEvent::Incoming {
listener_id,
upgrade,
local_addr,
send_back_addr,
} => {
assert_eq!(listener_id, listener_transport.listener_id);
assert_eq!(local_addr, listener_transport.listen_addr);
assert_eq!(send_back_addr, listener_transport.listen_addr);
upgrade
}
_ => panic!("expected TransportEvent::Incoming, got {:?}", res),
};
dialer_notify_inbound_rx.recv().await.unwrap();
// should receive the connection response from the mixnet
assert!(
poll_fn(|cx| Pin::new(&mut dialer_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
info!("waiting for connections...");
// should be able to resolve the connections now
let (_, mut listener_conn) = poll_fn(|cx| Pin::new(&mut upgrade).as_mut().poll_unpin(cx))
.now_or_never()
.expect("the upgrade should be ready")
.expect("the upgrade should not error");
let (_, mut dialer_conn) = poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.expect("the upgrade should be ready")
.expect("the upgrade should not error");
info!("connections established");
// write messages from the dialer to the listener and vice versa
send_and_receive_over_conns(
b"hello".to_vec(),
&mut dialer_conn,
&mut listener_conn,
Pin::new(&mut listener_transport),
&mut listener_notify_inbound_rx,
)
.await;
send_and_receive_over_conns(
b"hi".to_vec(),
&mut dialer_conn,
&mut listener_conn,
Pin::new(&mut listener_transport),
&mut listener_notify_inbound_rx,
)
.await;
send_and_receive_over_conns(
b"world".to_vec(),
&mut listener_conn,
&mut dialer_conn,
Pin::new(&mut dialer_transport),
&mut dialer_notify_inbound_rx,
)
.await;
}
async fn assert_new_address_event(mut transport: Pin<&mut NymTransport>) {
match poll_fn(|cx| transport.as_mut().poll(cx)).await {
TransportEvent::NewAddress {
listener_id,
listen_addr,
} => {
assert_eq!(listener_id, transport.listener_id);
assert_eq!(listen_addr, transport.listen_addr);
}
_ => panic!("expected TransportEvent::NewAddress"),
}
}
async fn send_and_receive_over_conns(
msg: Vec<u8>,
conn1: &mut Connection,
conn2: &mut Connection,
mut transport2: Pin<&mut NymTransport>,
notify_inbound_rx: &mut UnboundedReceiver<()>,
) {
// send message over conn1 to conn2
let substream_id = SubstreamId::generate();
conn1
.write(SubstreamMessage::new_with_data(
substream_id.clone(),
msg.clone(),
))
.unwrap();
notify_inbound_rx.recv().await.unwrap();
// poll transport2 to push message from transport to connection
assert!(poll_fn(|cx| transport2.as_mut().poll(cx))
.now_or_never()
.is_none());
let substream_msg = conn2.inbound_rx.recv().await.unwrap();
if let SubstreamMessageType::Data(data) = substream_msg.message_type {
assert_eq!(data, msg);
} else {
panic!("expected data message");
}
}
#[tokio::test]
async fn test_transport_substream() {
let client = MixnetClient::connect_new().await.unwrap();
let (dialer_notify_inbound_tx, mut dialer_notify_inbound_rx) = unbounded_channel();
let mut dialer_transport =
NymTransport::new_with_notify_inbound(client, dialer_notify_inbound_tx)
.await
.unwrap();
let client2 = MixnetClient::connect_new().await.unwrap();
let (listener_notify_inbound_tx, mut listener_notify_inbound_rx) = unbounded_channel();
let mut listener_transport =
NymTransport::new_with_notify_inbound(client2, listener_notify_inbound_tx)
.await
.unwrap();
let listener_multiaddr =
nym_address_to_multiaddress(listener_transport.self_address).unwrap();
assert_new_address_event(Pin::new(&mut dialer_transport)).await;
assert_new_address_event(Pin::new(&mut listener_transport)).await;
// dial the remote peer
let mut dial = dialer_transport.dial(listener_multiaddr).unwrap();
// poll the dial to send the connection request message
assert!(poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.is_none());
listener_notify_inbound_rx.recv().await.unwrap();
// should receive the connection request from the mixnet and send the connection response
let res = poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx)).await;
let mut upgrade = match res {
TransportEvent::Incoming {
listener_id,
upgrade,
local_addr,
send_back_addr,
} => {
assert_eq!(listener_id, listener_transport.listener_id);
assert_eq!(local_addr, listener_transport.listen_addr);
assert_eq!(send_back_addr, listener_transport.listen_addr);
upgrade
}
_ => panic!("expected TransportEvent::Incoming, got {:?}", res),
};
dialer_notify_inbound_rx.recv().await.unwrap();
// should receive the connection response from the mixnet
assert!(
poll_fn(|cx| Pin::new(&mut dialer_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
info!("waiting for connections...");
// should be able to resolve the connections now
let (_, mut listener_conn) = poll_fn(|cx| Pin::new(&mut upgrade).as_mut().poll_unpin(cx))
.now_or_never()
.unwrap()
.unwrap();
let (_, mut dialer_conn) = poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.unwrap()
.unwrap();
info!("connections established");
// initiate a new substream from the dialer
let mut dialer_substream =
poll_fn(|cx| Pin::new(&mut dialer_conn).as_mut().poll_outbound(cx))
.await
.unwrap();
listener_notify_inbound_rx.recv().await.unwrap();
// accept the substream on the listener
assert!(
poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
poll_fn(|cx| Pin::new(&mut listener_conn).as_mut().poll(cx)).now_or_never();
// poll recipient's poll_inbound to receive the substream; sends a response to the sender
let mut listener_substream =
poll_fn(|cx| Pin::new(&mut listener_conn).as_mut().poll_inbound(cx))
.now_or_never()
.unwrap()
.unwrap();
info!("got listener substream");
dialer_notify_inbound_rx.recv().await.unwrap();
// poll sender to finalize the substream
assert!(
poll_fn(|cx| Pin::new(&mut dialer_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
poll_fn(|cx| Pin::new(&mut dialer_conn).as_mut().poll(cx)).now_or_never();
info!("got dialer substream");
// write message from dialer to listener
send_and_receive_substream_message(
b"hello world".to_vec(),
Pin::new(&mut dialer_substream),
Pin::new(&mut listener_substream),
Pin::new(&mut listener_transport),
Pin::new(&mut listener_conn),
&mut listener_notify_inbound_rx,
)
.await;
// write message from listener to dialer
send_and_receive_substream_message(
b"hello back".to_vec(),
Pin::new(&mut listener_substream),
Pin::new(&mut dialer_substream),
Pin::new(&mut dialer_transport),
Pin::new(&mut dialer_conn),
&mut dialer_notify_inbound_rx,
)
.await;
// close the substream from the dialer side
info!("closing dialer substream");
dialer_substream.close().await.unwrap();
listener_notify_inbound_rx.recv().await.unwrap();
info!("dialer substream closed");
// assert we can't read or write to either substream
dialer_substream.write_all(b"hello").await.unwrap_err();
// poll listener transport and conn to receive the substream close message
poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx)).now_or_never();
poll_fn(|cx| Pin::new(&mut listener_conn).as_mut().poll(cx)).now_or_never();
listener_substream.write_all(b"hello").await.unwrap_err();
let mut buf = vec![0u8; 5];
dialer_substream.read(&mut buf).await.unwrap_err();
listener_substream.read(&mut buf).await.unwrap_err();
dialer_substream.close().await.unwrap_err();
listener_substream.close().await.unwrap_err();
}
async fn send_and_receive_substream_message(
data: Vec<u8>,
mut sender_substream: Pin<&mut Substream>,
mut recipient_substream: Pin<&mut Substream>,
mut recipient_transport: Pin<&mut NymTransport>,
mut recipient_conn: Pin<&mut Connection>,
recipient_notify_inbound_rx: &mut UnboundedReceiver<()>,
) {
// write message
sender_substream.write_all(&data).await.unwrap();
recipient_notify_inbound_rx.recv().await.unwrap();
// poll recipient for message
poll_fn(|cx| recipient_transport.as_mut().poll(cx)).now_or_never();
poll_fn(|cx| recipient_conn.as_mut().poll(cx)).now_or_never();
let mut buf = vec![0u8; data.len()];
let n = recipient_substream.read(&mut buf).await.unwrap();
assert_eq!(n, data.len());
assert_eq!(buf, data[..]);
}
#[tokio::test]
async fn test_transport_timeout() {
let client = MixnetClient::connect_new().await.unwrap();
let (dialer_notify_inbound_tx, _) = unbounded_channel();
let mut dialer_transport =
NymTransport::new_with_notify_inbound(client, dialer_notify_inbound_tx)
.await
.unwrap()
.with_timeout(std::time::Duration::from_millis(100));
// mock a transport that will never resolve the connection.
let empty_addr = Multiaddr::from_str(
"/nym/Hmer6Ndt3PV13YW53HM8ri4NvqqtfDQUQBhzvKqb1dag.2g478dyxtrQXGWc1Mk2VEqdPcWXpz7EhAcjhdAJtVZdA@AnnYnEtBjB2a5sHmeRCnBq43qxyHDf95Bqd7cwQyKNLR"
)
.expect("unable to parse multiaddress");
let dial = dialer_transport.dial(empty_addr).unwrap();
assert!(dial
.await
.expect_err("should have timed out")
.to_string()
.contains("dial timed out"));
}
}
+17
View File
@@ -5,6 +5,23 @@ to provide real `TcpStream` and `UdpSocket` types that work with the async
Rust ecosystem: tokio-rustls, hyper, tokio-tungstenite, libp2p, and anything
else built on `AsyncRead + AsyncWrite`.
## Workspace layout
```text
smolmix-hyper
(top-level)
/ \
v v
smolmix-dns ←→ smolmix-tls
(resolution) (encryption)
\ /
v v
smolmix
(tunnel)
```
`smolmix` provides the underlying TCP/UDP tunnel. `smolmix-dns` and `smolmix-tls` are companion crates that each handle one concern; `smolmix-hyper` glues them into a complete HTTP client. Pick the level of abstraction that matches your needs; the lower-level crates remain useful when you want manual control (e.g. websockets, libp2p, custom protocols).
## Why IP, not messages
The Nym SDK works at the message layer: you send and receive `Vec<u8>`
+17
View File
@@ -5,6 +5,23 @@ smolmix tunnels TCP and UDP over the Nym mixnet. It exposes `TcpStream` and
hyper, tokio-tungstenite), with all traffic routed through the mixnet so a
network observer cannot correlate source and destination.
## Workspace layout
```text
smolmix-hyper
(top-level)
/ \
v v
smolmix-dns ←→ smolmix-tls
(resolution) (encryption)
\ /
v v
smolmix
(tunnel)
```
This crate (`smolmix`) provides the underlying TCP/UDP tunnel. The companion crates each handle one concern: [`smolmix-dns`](https://crates.io/crates/smolmix-dns) for tunneled DNS resolution, [`smolmix-tls`](https://crates.io/crates/smolmix-tls) for TLS over the tunnel, and [`smolmix-hyper`](https://crates.io/crates/smolmix-hyper) which combines them into a complete HTTP client. The arrows show conceptual layering, not strict Cargo dependencies.
## Stack
```text
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "smolmix-dns"
description = "DNS resolution through the Nym mixnet via hickory-resolver"
version.workspace = true
authors.workspace = true
edition = "2021"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme = "README.md"
publish = true
[dependencies]
smolmix = { workspace = true }
hickory-proto = { workspace = true, features = ["tokio"] }
hickory-resolver = { workspace = true, features = ["tokio"] }
tokio-smoltcp = { workspace = true }
[dev-dependencies]
hickory-resolver = { workspace = true, features = ["tokio", "system-config"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
tracing = { workspace = true }
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
[[example]]
name = "resolve"
+75
View File
@@ -0,0 +1,75 @@
# 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.
## Workspace layout
```text
smolmix-hyper
(top-level)
/ \
v v
smolmix-dns ←→ smolmix-tls
(resolution) (encryption)
\ /
v v
smolmix
(tunnel)
```
`smolmix-dns` is one of three companion crates around [`smolmix`](https://crates.io/crates/smolmix). It pairs with [`smolmix-tls`](https://crates.io/crates/smolmix-tls) for HTTPS connections, or use [`smolmix-hyper`](https://crates.io/crates/smolmix-hyper) for a complete HTTP client built on top. Arrows show conceptual layering, not strict Cargo dependencies.
## 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)`**: Quad9 upstream (`9.9.9.9`)
- **`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 = "1.21.0"
smolmix-dns = "1.21.0"
```
This crate depends on `smolmix` (for the `Tunnel` type), `hickory-proto`, and `hickory-resolver`.
## See also
- [`smolmix-tls`](../tls) for TLS over the tunnel once you have a resolved address
- [`smolmix-hyper`](../hyper) for a complete HTTP client that bundles DNS + TLS + HTTP
+85
View File
@@ -0,0 +1,85 @@
//! DNS resolution: clearnet vs Nym mixnet comparison.
//!
//! Resolves a hostname via clearnet (hickory-resolver) and via the mixnet
//! (smolmix-dns), then compares resolved IPs and timing.
//!
//! Run with:
//! cargo run -p smolmix-dns --example resolve
//! cargo run -p smolmix-dns --example resolve -- --ipr <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> {
nym_bin_common::logging::setup_tracing_logger();
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(())
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
#![doc = include_str!("../README.md")]
//! DNS resolution through the Nym mixnet.
//!
//! # Why a separate DNS crate?
//!
//! If you resolve hostnames using the OS resolver or a clearnet DNS library,
//! the queries travel over your local network — leaking which domains you're
//! visiting even though the TCP traffic itself goes through the mixnet. This
//! crate routes all DNS queries (both UDP and TCP) through the smolmix
//! [`Tunnel`], so hostname lookups are as private as the rest of your traffic.
//!
//! # How it works
//!
//! [hickory-resolver](https://docs.rs/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.
//!
//! For UDP, `SmolmixUdpSocket` is a thin newtype over `tokio_smoltcp::UdpSocket`
//! that implements hickory's [`DnsUdpSocket`](hickory_proto::udp::DnsUdpSocket)
//! — just delegates `poll_recv_from` and `poll_send_to`.
//!
//! # Quick start
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! 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?;
//! # Ok(())
//! # }
//! ```
//!
//! # Caching
//!
//! hickory-resolver maintains an internal LRU cache for DNS responses. To
//! benefit from caching, **reuse the [`Resolver`] across requests** rather
//! than creating a new one per lookup. The free function [`resolve()`]
//! constructs a fresh resolver each time and does not cache.
//!
//! # Custom upstream DNS
//!
//! By default, queries go to Quad9 (`9.9.9.9`). Use
//! [`Resolver::with_config()`] for other upstreams:
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! use smolmix_dns::{Resolver, ResolverConfig};
//!
//! let tunnel = smolmix::Tunnel::new().await?;
//! let resolver = Resolver::with_config(&tunnel, ResolverConfig::cloudflare());
//! # Ok(())
//! # }
//! ```
//!
//! [`RuntimeProvider`]: hickory_proto::runtime::RuntimeProvider
mod provider;
use std::io;
use std::net::SocketAddr;
use std::ops::Deref;
use hickory_resolver::config::NameServerConfigGroup;
use hickory_resolver::name_server::GenericConnector;
use hickory_proto::runtime::TokioHandle;
use smolmix::Tunnel;
/// Re-exported from hickory-resolver. Used with [`Resolver::with_config()`]
/// to select a custom upstream DNS server (Quad9, Cloudflare, Google, etc.).
pub use hickory_resolver::config::ResolverConfig;
/// Re-exported from hickory-resolver. The result of a successful `lookup_ip()`
/// call — iterate with `.iter()` to get `IpAddr` values.
pub use hickory_resolver::lookup_ip::LookupIp;
/// Re-exported from hickory-resolver. The error type for DNS resolution failures.
pub use hickory_resolver::ResolveError;
/// The runtime provider that routes DNS I/O through the tunnel.
///
/// You don't usually need to use this directly — [`Resolver::new()`] wires it
/// up for you. Exposed for advanced use cases (custom resolver configurations
/// beyond what `with_config` covers).
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 Quad9 (`9.9.9.9`) as upstream DNS.
pub fn new(tunnel: &Tunnel) -> Self {
Self::with_config(tunnel, ResolverConfig::quad9())
}
/// Create a resolver with a custom upstream DNS configuration.
///
/// IPv6 nameservers are filtered out because the tunnel's smoltcp
/// interface is IPv4-only (see [`tokio_smoltcp::NetConfig`]). Passing a
/// config with *only* IPv6 nameservers will cause lookups to fail.
pub fn with_config(tunnel: &Tunnel, config: ResolverConfig) -> Self {
// tokio-smoltcp only supports a single IpCidr (IPv4), so contacting
// an IPv6 nameserver panics in smoltcp's wire layer (IP version
// mismatch). Strip IPv6 nameservers until the tunnel supports
// dual-stack.
let config = ipv4_only_nameservers(config);
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 Quad9 (`9.9.9.9`) as the upstream DNS server. Equivalent to
/// [`Resolver::new()`].
pub fn resolver(tunnel: &Tunnel) -> Resolver {
Resolver::new(tunnel)
}
/// Resolve a hostname through the tunnel (uncached).
///
/// Convenience wrapper for one-shot lookups. Creates a fresh [`Resolver`]
/// internally, so **DNS responses are not cached** across calls. If you're
/// making multiple lookups, create a [`Resolver`] once and reuse it.
pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
let r = resolver(tunnel);
r.resolve(host, port).await
}
/// Strip IPv6 nameservers from a resolver config.
///
/// hickory's preset configs (quad9, cloudflare, google) all include IPv6
/// nameservers. The smolmix tunnel is IPv4-only (tokio-smoltcp's `NetConfig`
/// takes a single `IpCidr`), so sending a UDP packet to an IPv6 nameserver
/// panics in smoltcp's wire layer with "IP version mismatch".
fn ipv4_only_nameservers(config: ResolverConfig) -> ResolverConfig {
let ipv4_servers: Vec<_> = config
.name_servers()
.iter()
.filter(|ns| ns.socket_addr.is_ipv4())
.cloned()
.collect();
ResolverConfig::from_parts(
config.domain().cloned(),
config.search().to_vec(),
NameServerConfigGroup::from(ipv4_servers),
)
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! 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))
})
}
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "smolmix-hyper"
description = "HTTP client routing all traffic through the Nym mixnet via hyper"
version.workspace = true
authors.workspace = true
edition = "2021"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme = "README.md"
publish = true
[dependencies]
smolmix = { workspace = true }
smolmix-dns = { workspace = true }
smolmix-tls = { workspace = true }
bytes = { workspace = true }
hyper = { workspace = true, features = ["client", "http1"] }
hyper-util = { workspace = true, features = ["tokio", "client-legacy"] }
http-body-util = { workspace = true }
tokio-smoltcp = { workspace = true }
pin-project-lite = "0.2"
tower = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }
rustls = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
tracing = { workspace = true }
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
[[example]]
name = "get"
[[example]]
name = "post"
+97
View File
@@ -0,0 +1,97 @@
# 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`.
## Workspace layout
```text
smolmix-hyper
(top-level)
/ \
v v
smolmix-dns ←→ smolmix-tls
(resolution) (encryption)
\ /
v v
smolmix
(tunnel)
```
`smolmix-hyper` is the top-level convenience crate, bundling DNS, TLS, and HTTP. For lower-level control, use [`smolmix-dns`](https://crates.io/crates/smolmix-dns) and [`smolmix-tls`](https://crates.io/crates/smolmix-tls) directly over a [`smolmix`](https://crates.io/crates/smolmix) tunnel. Arrows show conceptual layering, not strict Cargo dependencies.
## 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> # If you want to use a specific IPR on an Exit Gateway
```
## Dependencies
```toml
[dependencies]
smolmix = "1.21.0"
smolmix-hyper = "1.21.0"
```
This crate depends on `smolmix`, `smolmix-dns` (DNS resolution through the tunnel), and `smolmix-tls` (webpki roots, TLS handshake).
## See also
If you want lower-level control over individual steps rather than the full HTTP client:
- [`smolmix-dns`](../dns) for tunneled DNS resolution only
- [`smolmix-tls`](../tls) for TLS only (bring your own `TcpStream` from the tunnel)
+85
View File
@@ -0,0 +1,85 @@
//! HTTPS GET: clearnet vs Nym mixnet comparison.
//!
//! Fetches Cloudflare's `/cdn-cgi/trace` endpoint over clearnet (reqwest) and
//! through the mixnet (smolmix-hyper), then compares responses and timing.
//!
//! Run with:
//! cargo run -p smolmix-hyper --example get
//! cargo run -p smolmix-hyper --example get -- --ipr <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> {
nym_bin_common::logging::setup_tracing_logger();
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
@@ -0,0 +1,98 @@
//! HTTP POST: clearnet vs Nym mixnet comparison.
//!
//! Sends a POST request with a JSON body to httpbin.org via clearnet (reqwest)
//! and through the mixnet (smolmix-hyper with SmolmixConnector), then compares
//! responses and timing.
//!
//! Demonstrates using `SmolmixConnector` directly for requests that carry a body
//! (the `Client` newtype uses `Empty<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> {
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!("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(())
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! [`tower::Service<Uri>`] connector routing TCP + TLS through a [`Tunnel`].
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use hyper::Uri;
use hyper_util::rt::TokioIo;
use tower::Service;
use smolmix::Tunnel;
use smolmix_dns::Resolver;
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.
///
/// DNS lookups are cached internally via a shared [`smolmix_dns::Resolver`] —
/// repeat requests to the same host reuse cached records (subject to TTL).
///
/// Use this directly when you need a custom body type (e.g. `Full<Bytes>` for
/// POST requests) — see the [crate-level docs](crate#sending-request-bodies-post-put-etc)
/// for an example.
#[derive(Clone)]
pub struct SmolmixConnector {
tunnel: Tunnel,
tls: TlsConnector,
resolver: Arc<Resolver>,
}
impl SmolmixConnector {
/// Create a new connector for the given tunnel.
///
/// Sets up a TLS connector with the standard webpki root certificates and
/// a DNS resolver that caches lookups across requests.
pub fn new(tunnel: &Tunnel) -> Self {
Self {
tunnel: tunnel.clone(),
tls: smolmix_tls::connector(),
resolver: Arc::new(Resolver::new(tunnel)),
}
}
}
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();
let resolver = self.resolver.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 = resolver.resolve(&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))
})
}
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
#![doc = include_str!("../README.md")]
//! HTTP client routing all traffic through the Nym mixnet.
//!
//! # What this crate does
//!
//! Wraps [hyper-util]'s legacy `Client` with a [`SmolmixConnector`] that
//! routes DNS resolution, TCP connections, and TLS handshakes through a
//! smolmix [`Tunnel`]. From the outside it behaves like a normal HTTP client,
//! but all traffic travels through the mixnet.
//!
//! # How it works
//!
//! hyper-util's `Client` uses the [`tower::Service<Uri>`] trait to open
//! connections. [`SmolmixConnector`] implements this: given a URI, it resolves
//! the hostname via [`smolmix_dns`], connects TCP via the tunnel, and wraps
//! in TLS for `https://` URIs:
//!
//! ```text
//! SmolmixConnector::call(uri)
//! → resolver.resolve(host, port) DNS through tunnel (cached)
//! → tunnel.tcp_connect(addr) TCP through mixnet
//! → smolmix_tls::connect_with(tls, tcp, host) TLS if https
//! → MaybeTlsStream::Plain { TcpStream }
//! or MaybeTlsStream::Tls { TlsStream<TcpStream> }
//! → TokioIo<MaybeTlsStream> (implements hyper's Read/Write/Connection)
//! ```
//!
//! [`MaybeTlsStream`] is a two-variant enum with [`pin_project_lite`] for safe
//! pin projection through `AsyncRead`/`AsyncWrite`. hyper-util's `TokioIo`
//! wraps it to satisfy hyper's own I/O traits.
//!
//! # Quick start (GET)
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! 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();
//! println!("{}", String::from_utf8_lossy(&body));
//! # Ok(())
//! # }
//! ```
//!
//! # Sending request bodies (POST, PUT, etc.)
//!
//! The convenience [`Client`] wrapper uses `Empty<Bytes>` as its body type,
//! which is suitable for GET/HEAD/DELETE. For requests that carry a body,
//! construct a hyper-util client directly with [`SmolmixConnector`]:
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! use http_body_util::Full;
//! use hyper_util::{client::legacy, rt::TokioExecutor};
//! use bytes::Bytes;
//! use smolmix_hyper::SmolmixConnector;
//!
//! let tunnel = smolmix::Tunnel::new().await?;
//! let connector = SmolmixConnector::new(&tunnel);
//! let client = legacy::Client::builder(TokioExecutor::new())
//! .build::<_, Full<Bytes>>(connector);
//!
//! let body = Full::new(Bytes::from(r#"{"key": "value"}"#));
//! let req = hyper::Request::post("https://httpbin.org/post")
//! .header("Host", "httpbin.org")
//! .header("Content-Type", "application/json")
//! .body(body)?;
//! let resp = client.request(req).await?;
//! # Ok(())
//! # }
//! ```
//!
//! # Re-exports
//!
//! This crate re-exports the most commonly needed types so you don't need
//! `hyper`, `http-body-util`, or `bytes` in your `Cargo.toml` for basic use:
//!
//! - [`Request`], [`Response`], [`StatusCode`], [`Uri`] — from hyper
//! - [`BodyExt`], [`EmptyBody`] — from http-body-util
//! - [`bytes`] — the bytes crate
//!
//! [hyper-util]: https://docs.rs/hyper-util
//! [`pin_project_lite`]: https://docs.rs/pin-project-lite
mod connector;
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-exported [`bytes`](https://docs.rs/bytes) crate for constructing request bodies.
pub use bytes;
/// Extension trait for consuming HTTP response bodies. Provides `.collect()`,
/// `.frame()`, etc.
pub use http_body_util::BodyExt;
/// An empty HTTP body. Use `EmptyBody::<Bytes>::new()` for GET/HEAD requests.
pub use http_body_util::Empty as EmptyBody;
/// Re-exported hyper types for building requests without depending on hyper directly.
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)
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! 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.
///
/// Returned by [`SmolmixConnector`](crate::SmolmixConnector) — you won't
/// normally interact with this directly.
#[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()
}
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "smolmix-tls"
description = "TLS connector with webpki roots for smolmix tunneled connections"
version.workspace = true
authors.workspace = true
edition = "2021"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme = "README.md"
publish = true
[dependencies]
tokio-smoltcp = { workspace = true }
tokio-rustls = { workspace = true }
rustls = { workspace = true }
webpki-roots = { workspace = true }
[dev-dependencies]
smolmix = { workspace = true }
smolmix-dns = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "net", "io-util"] }
tracing = { workspace = true }
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
[[example]]
name = "connect"
+84
View File
@@ -0,0 +1,84 @@
# 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`.
## Workspace layout
```text
smolmix-hyper
(top-level)
/ \
v v
smolmix-dns ←→ smolmix-tls
(resolution) (encryption)
\ /
v v
smolmix
(tunnel)
```
`smolmix-tls` is one of three companion crates around [`smolmix`](https://crates.io/crates/smolmix). It pairs with [`smolmix-dns`](https://crates.io/crates/smolmix-dns) for hostname-based connections, or use [`smolmix-hyper`](https://crates.io/crates/smolmix-hyper) for a complete HTTP client built on top. Arrows show conceptual layering, not strict Cargo dependencies.
## 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`)
## Resolving hostnames
The quick-start above uses a literal IP. For real hostnames, pair with [`smolmix-dns`](../dns) so resolution also goes through the tunnel (no DNS leaks to the local network):
```rust
use smolmix_dns::Resolver;
use smolmix_tls::{connect_with, connector};
let resolver = Resolver::new(&tunnel);
let addr = resolver.resolve("example.com", 443).await?
.into_iter().next().ok_or("no addresses resolved")?;
let tcp = tunnel.tcp_connect(addr).await?;
let tls = connector();
let stream = connect_with(&tls, tcp, "example.com").await?;
```
`smolmix-tls` itself stays DNS-agnostic on purpose: it accepts any `TcpStream`, so callers with a pre-resolved address or a different resolver don't pay for `hickory-resolver`. See `examples/connect.rs` for a runnable end-to-end version.
## Dependencies
```toml
[dependencies]
smolmix = "1.21.0"
smolmix-tls = "1.21.0"
```
This crate depends on `tokio-rustls`, `rustls`, and `webpki-roots`.
## See also
- [`smolmix-dns`](../dns) for tunneled hostname resolution
- [`smolmix-hyper`](../hyper) for a complete HTTP client (DNS + TLS + HTTP) built on top of this
+250
View File
@@ -0,0 +1,250 @@
//! TLS connection: clearnet vs Nym mixnet comparison.
//!
//! Performs a TLS handshake and HTTPS GET request via both clearnet (tokio-rustls
//! over a system TCP socket) and the mixnet (smolmix-tls over a tunnel), then
//! compares timing and verifies both see the same content.
//!
//! Run with:
//! cargo run -p smolmix-tls --example connect
//! cargo run -p smolmix-tls --example connect -- --ipr <IPR_ADDRESS>
use std::sync::Arc;
use rustls::ClientConfig;
use smolmix::{Recipient, Tunnel};
use smolmix_dns::Resolver;
use smolmix_tls::{connect_with, connector};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
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();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let host = "example.com";
let port = 443u16;
// Clearnet baseline via tokio + tokio-rustls
info!("Clearnet TLS to {host}:{port}...");
let clearnet_start = std::time::Instant::now();
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(config));
let tcp = tokio::net::TcpStream::connect((host, port)).await?;
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())?;
let mut tls = tls_connector.connect(server_name, tcp).await?;
tls.write_all(
format!("GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await?;
let mut clearnet_buf = Vec::new();
tls.read_to_end(&mut clearnet_buf).await?;
let clearnet_duration = clearnet_start.elapsed();
let clearnet_status = extract_status(&clearnet_buf);
info!(
"{clearnet_status} {} in {clearnet_duration:.1?}",
format_bytes(clearnet_buf.len() as u64)
);
// Mixnet tunnel setup
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_str) = ipr_addr {
let recipient: Recipient = addr_str.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
info!(
"Tunnel ready — allocated IP: {}",
tunnel.allocated_ips().ipv4
);
let tls_conn = connector();
let resolver = Resolver::new(&tunnel);
let overall_start = std::time::Instant::now();
// DNS resolution via mixnet
info!("Mixnet TLS to {host}:{port}...");
let spinner = spin(&format!("Resolving {host} via mixnet DNS..."));
let dns_start = std::time::Instant::now();
let addrs = resolver.resolve(host, port).await?;
let addr = addrs.into_iter().next().ok_or("no addresses resolved")?;
let dns_duration = dns_start.elapsed();
spinner.abort();
eprint!("\r \r");
info!("DNS: {host} → {addr} ({dns_duration:.1?})");
// TCP connection through mixnet
let spinner = spin(&format!("TCP connecting to {addr}..."));
let tcp_start = std::time::Instant::now();
let tcp = tunnel.tcp_connect(addr).await?;
let tcp_duration = tcp_start.elapsed();
spinner.abort();
eprint!("\r \r");
info!("TCP: connected to {addr} ({tcp_duration:.1?})");
// TLS handshake
let spinner = spin(&format!("TLS handshake with {host}..."));
let tls_start = std::time::Instant::now();
let mut tls = connect_with(&tls_conn, tcp, host).await?;
let tls_duration = tls_start.elapsed();
spinner.abort();
eprint!("\r \r");
info!("TLS: handshake complete ({tls_duration:.1?})");
// First HTTP GET (keep-alive)
let spinner = spin("GET / (first request)...");
let http1_start = std::time::Instant::now();
tls.write_all(format!("GET / HTTP/1.1\r\nHost: {host}\r\n\r\n").as_bytes())
.await?;
let mixnet_buf = read_http_response(&mut tls).await?;
let http1_duration = http1_start.elapsed();
spinner.abort();
let mixnet_status = extract_status(&mixnet_buf);
info!(
"GET1: {mixnet_status} {} ({http1_duration:.1?})",
format_bytes(mixnet_buf.len() as u64)
);
// Second HTTP GET over same connection (no DNS/TCP/TLS overhead)
let spinner = spin("GET / (reusing connection)...");
let http2_start = std::time::Instant::now();
tls.write_all(
format!("GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await?;
let mut mixnet_buf2 = Vec::new();
tls.read_to_end(&mut mixnet_buf2).await?;
let http2_duration = http2_start.elapsed();
spinner.abort();
let mixnet_status2 = extract_status(&mixnet_buf2);
info!(
"GET2: {mixnet_status2} {} ({http2_duration:.1?}) (reused connection)",
format_bytes(mixnet_buf2.len() as u64)
);
let mixnet_duration = overall_start.elapsed();
// Compare
info!("Results");
info!(
"Clearnet: {} in {clearnet_duration:.1?}",
format_bytes(clearnet_buf.len() as u64)
);
info!(
"Mixnet #1: {} in {mixnet_duration:.1?} (DNS {dns_duration:.1?} + TCP {tcp_duration:.1?} + TLS {tls_duration:.1?} + HTTP {http1_duration:.1?})",
format_bytes(mixnet_buf.len() as u64),
);
info!(
"Mixnet #2: {} in {http2_duration:.1?} (reused connection)",
format_bytes(mixnet_buf2.len() as u64),
);
let slowdown1 =
mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
let slowdown2 = http2_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
info!("Slowdown: {slowdown1:.1}x (cold) / {slowdown2:.1}x (warm)");
tunnel.shutdown().await;
Ok(())
}
fn spin(msg: &str) -> tokio::task::JoinHandle<()> {
let msg = msg.to_string();
tokio::spawn(async move {
let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let mut i = 0;
loop {
eprint!("\r {} {}", frames[i % frames.len()], msg);
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
}
})
}
/// Read a complete HTTP/1.1 response from a keep-alive connection.
///
/// Parses headers to find `Content-Length`, then reads exactly that many body
/// bytes. Returns the full response (headers + body) as a single buffer.
async fn read_http_response<R: tokio::io::AsyncRead + Unpin>(
reader: &mut R,
) -> Result<Vec<u8>, BoxError> {
let mut buf = Vec::with_capacity(4096);
let mut tmp = [0u8; 1024];
// Read until we find the end-of-headers marker
let header_end = loop {
let n = reader.read(&mut tmp).await?;
if n == 0 {
return Err("connection closed before headers complete".into());
}
buf.extend_from_slice(&tmp[..n]);
if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
break pos + 4;
}
};
// Parse Content-Length from headers
let headers = std::str::from_utf8(&buf[..header_end]).unwrap_or("");
let content_length = headers
.lines()
.find_map(|line| {
let (key, val) = line.split_once(':')?;
if key.trim().eq_ignore_ascii_case("content-length") {
val.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
// Read remaining body bytes
let body_so_far = buf.len() - header_end;
let remaining = content_length.saturating_sub(body_so_far);
if remaining > 0 {
let mut body_buf = vec![0u8; remaining];
reader.read_exact(&mut body_buf).await?;
buf.extend_from_slice(&body_buf);
}
Ok(buf)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn extract_status(buf: &[u8]) -> &str {
let s = std::str::from_utf8(&buf[..buf.len().min(40)]).unwrap_or("");
s.lines().next().unwrap_or("")
}
fn format_bytes(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1} MB", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1} KB", n as f64 / 1_000.0)
} else {
format!("{n} B")
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
#![doc = include_str!("../README.md")]
//! Shared TLS configuration for smolmix tunneled connections.
//!
//! # Why a separate TLS crate?
//!
//! Every protocol that needs encryption over smolmix (HTTPS, WebSocket, etc.)
//! requires the same setup: build a `ClientConfig` with webpki root
//! certificates, wrap it in a `TlsConnector`. Rather than duplicating this
//! in every crate, `smolmix-tls` provides a single source of truth.
//!
//! The crate is deliberately minimal — 60 lines of pure configuration, no
//! trait impls needed. `tokio-rustls` works directly with anything that
//! implements tokio's `AsyncRead + AsyncWrite`, which `smolmix::TcpStream`
//! does out of the box.
//!
//! # Usage patterns
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! # let tunnel = smolmix::Tunnel::new().await?;
//! // One-shot: creates a fresh connector internally.
//! // Simple but rebuilds the root cert store each time.
//! let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?;
//! let tls = smolmix_tls::connect(tcp, "example.com").await?;
//!
//! // Reusable: create a connector once, use for many connections.
//! // The TlsConnector wraps an Arc<ClientConfig> — cloning is cheap.
//! let connector = smolmix_tls::connector();
//! let tcp1 = tunnel.tcp_connect("1.1.1.1:443".parse()?).await?;
//! let stream1 = smolmix_tls::connect_with(&connector, tcp1, "one.one.one.one").await?;
//! # Ok(())
//! # }
//! ```
//!
//! # What's inside
//!
//! - [`connector()`] — builds a `TlsConnector` with Mozilla's root CA bundle
//! ([`webpki-roots`](https://docs.rs/webpki-roots))
//! - [`connect()`] — one-shot TLS handshake (convenience, creates connector internally)
//! - [`connect_with()`] — TLS handshake using a pre-built connector (preferred for repeated use)
//! - Re-exports [`TlsStream`] and [`TlsConnector`] so downstream code doesn't
//! need `tokio-rustls` in its `Cargo.toml`
//!
//! # Security
//!
//! The connector uses rustls with the standard webpki root certificates and
//! no client authentication. SNI (Server Name Indication) is set from the
//! hostname you pass to `connect`/`connect_with`. There is no way to disable
//! certificate verification — this is intentional.
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
}