Files
nym/documentation/docs/pages/developers/smolmix/tutorial-websocket.mdx
T
mfahampshire 4fdd9f2024 Temp commit
2026-04-29 10:28:02 +01:00

317 lines
11 KiB
Plaintext

---
title: "smolmix: Private WebSocket Echo"
description: "Step-by-step Rust tutorial that connects a WebSocket through the Nym mixnet by stacking tokio-tungstenite on tokio-rustls on a smolmix TcpStream."
schemaType: "HowTo"
section: "Developers"
lastUpdated: "2026-04-23"
---
# Private WebSocket Echo
import { Callout } from 'nextra/components'
import { CodeVerified } from '../../../components/code-verified'
import { RUST_MSRV } from '../../../components/versions'
<CodeVerified crate="smolmix" />
In this tutorial you'll open a WebSocket connection to a public echo server — with every byte routed through the Nym mixnet:
1. **TCP** — connect through the tunnel to the echo server's IP
2. **TLS** — negotiate a secure channel with tokio-rustls
3. **WebSocket** — upgrade the connection with tokio-tungstenite
4. **Echo** — send a message and verify the reply
The key idea: smolmix's `TcpStream` implements `AsyncRead + AsyncWrite`, so every crate in the standard async networking stack works unchanged. You swap `tokio::net::TcpStream` for `smolmix::TcpStream` and the rest composes as normal.
```text
tokio-tungstenite (WebSocket framing)
└─ tokio-rustls (TLS encryption)
└─ smolmix::TcpStream (TCP over mixnet)
```
## What you'll learn
- Stacking tokio-rustls TLS on a smolmix `TcpStream`
- Using `tokio_tungstenite::client_async` over a custom transport
- Why `AsyncRead + AsyncWrite` composability matters for privacy
- Graceful WebSocket close and tunnel shutdown
## Prerequisites
- Rust toolchain ({RUST_MSRV}+)
- A working internet connection (the tunnel connects to the live Nym mixnet)
## Step 1: Set up the project
```sh
cargo init smolmix-websocket
cd smolmix-websocket
```
Add dependencies to `Cargo.toml`:
```toml
[dependencies]
smolmix = "X.Y.Z"
nym-bin-common = { version = "X.Y.Z", features = ["basic_tracing"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
tokio-rustls = "0.26"
rustls = { version = "0.23", features = ["std", "ring"] }
webpki-roots = "0.26"
tokio-tungstenite = "0.24"
futures = "0.3"
blake3 = "=1.7.0" # required pin — see https://nymtech.net/docs/developers/rust/importing
```
No HTTP or DNS crates needed — this tutorial is pure TCP + TLS + WebSocket.
## Step 2: Scaffold `main()`
Replace `src/main.rs` with:
```rust
use std::sync::Arc;
use futures::{SinkExt, StreamExt};
use rustls::pki_types::ServerName;
use smolmix::Tunnel;
use tokio_tungstenite::tungstenite::Message;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const WS_HOST: &str = "ws.postman-echo.com";
const WS_PATH: &str = "/raw";
const ECHO_MSG: &str = "Hello from the Nym mixnet!";
#[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");
// Usage: cargo run [-- --ipr <ADDRESS>]
Ok(())
}
```
## Step 3: Create the tunnel and resolve the host
We resolve the hostname via clearnet DNS here for simplicity. For production use, resolve through the mixnet instead (see the [UDP tutorial](/developers/smolmix/tutorial-udp) for the DNS pattern).
```rust
// Resolve via clearnet DNS (see UDP tutorial for mixnet DNS)
let addr = tokio::net::lookup_host(format!("{WS_HOST}:443"))
.await?
.next()
.ok_or("DNS resolution failed")?;
println!("Resolved {WS_HOST} -> {addr}");
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
let mut builder = Tunnel::builder();
if let Some(addr) = ipr_addr {
builder = builder.ipr_address(addr.parse().expect("invalid IPR address"));
}
let tunnel = builder.build().await?;
println!("Tunnel ready — allocated IP: {}", tunnel.allocated_ips().ipv4);
```
## Step 4: TCP + TLS through the mixnet
Connect to the resolved IP over TCP, then layer TLS on top. This is identical to the [TCP tutorial](/developers/smolmix/tutorial#step-5-tcp--tls-through-the-mixnet) — the smolmix `TcpStream` drops in where `tokio::net::TcpStream` would go.
```rust
println!("TCP connecting via mixnet...");
let tcp = tunnel.tcp_connect(addr).await?;
println!("TCP connected");
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let domain = ServerName::try_from(WS_HOST)?.to_owned();
let tls = connector.connect(domain, tcp).await?;
println!("TLS established with {WS_HOST}");
```
## Step 5: WebSocket upgrade
This is where composability pays off. `tokio_tungstenite::client_async` accepts any `AsyncRead + AsyncWrite` stream — it doesn't care that the TLS stream is backed by the mixnet rather than a kernel TCP socket.
```rust
println!("WebSocket upgrade...");
let (mut ws, _) = tokio_tungstenite::client_async(
format!("wss://{WS_HOST}{WS_PATH}"),
tls,
).await?;
println!("WebSocket connected");
```
## Step 6: Send a message and read the echo
```rust
ws.send(Message::Text(ECHO_MSG.into())).await?;
let reply = ws.next().await.ok_or("no reply")??;
let reply_text = reply.into_text()?;
println!("Sent: \"{ECHO_MSG}\"");
println!("Received: \"{reply_text}\"");
println!("Match: {}", reply_text == ECHO_MSG);
ws.close(None).await?;
tunnel.shutdown().await;
```
## Step 7: Run it
```sh
RUST_LOG=info cargo run
```
Or target a specific IPR exit node:
```sh
RUST_LOG=info cargo run -- --ipr <IPR_ADDRESS>
```
You'll see:
```
Resolved ws.postman-echo.com -> 44.195.202.69:443
Tunnel ready — allocated IP: 10.0.232.7
TCP connecting via mixnet...
TCP connected
TLS established with ws.postman-echo.com
WebSocket upgrade...
WebSocket connected
Sent: "Hello from the Nym mixnet!"
Received: "Hello from the Nym mixnet!"
Match: true
```
The echo server sees traffic from the IPR exit gateway's IP — not yours. The full handshake chain (TCP + TLS + WebSocket) adds a few seconds of mixnet latency, but once established, each message round-trip is ~1-3 seconds.
## How it works
```text
┌───────────────────────────────────────────────────────┐
│ tokio-tungstenite (WebSocket framing) │
│ └─ tokio-rustls (TLS encryption) │
│ └─ smolmix::TcpStream (TCP over mixnet) │
│ └─ smoltcp (userspace TCP state machine) │
│ └─ NymIprBridge → Nym mixnet → IPR │
└───────────────────────────────────────────────────────┘
```
Each layer only knows about the one directly below it:
- **tokio-tungstenite** thinks it's talking to a normal TLS stream
- **rustls** thinks it's talking to a normal TCP stream
- **smolmix** handles the mixnet routing transparently
This is the same composability model as regular networking — swap `tokio::net::TcpStream` for `smolmix::TcpStream` and the rest of your stack works unchanged.
## What you've learned
- `tokio_tungstenite::client_async` works over any `AsyncRead + AsyncWrite` transport — no special adapters needed
- Stacking TLS + WebSocket on a smolmix `TcpStream` is identical to the clearnet case
- The same pattern extends to any protocol that composes over `AsyncRead + AsyncWrite`: HTTP/2, gRPC, or any custom framing layer
- `tunnel.shutdown()` disconnects gracefully; dropping the tunnel triggers fire-and-forget cleanup
<Callout type="info">
For production use, resolve the hostname through the mixnet to avoid leaking DNS queries over clearnet. See the [TCP tutorial](/developers/smolmix/tutorial#step-4-resolve-dns-through-the-mixnet) for the DNS-over-mixnet pattern.
</Callout>
## Complete code
```rust
use std::sync::Arc;
use futures::{SinkExt, StreamExt};
use rustls::pki_types::ServerName;
use smolmix::Tunnel;
use tokio_tungstenite::tungstenite::Message;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const WS_HOST: &str = "ws.postman-echo.com";
const WS_PATH: &str = "/raw";
const ECHO_MSG: &str = "Hello from the Nym mixnet!";
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Resolve via clearnet DNS (see UDP tutorial for mixnet DNS)
let addr = tokio::net::lookup_host(format!("{WS_HOST}:443"))
.await?
.next()
.ok_or("DNS resolution failed")?;
println!("Resolved {WS_HOST} -> {addr}");
// Parse --ipr flag
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
// Create the tunnel
let mut builder = Tunnel::builder();
if let Some(addr) = ipr_addr {
builder = builder.ipr_address(addr.parse().expect("invalid IPR address"));
}
let tunnel = builder.build().await?;
println!("Tunnel ready — allocated IP: {}", tunnel.allocated_ips().ipv4);
// TCP + TLS through the mixnet
println!("TCP connecting via mixnet...");
let tcp = tunnel.tcp_connect(addr).await?;
println!("TCP connected");
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let domain = ServerName::try_from(WS_HOST)?.to_owned();
let tls = connector.connect(domain, tcp).await?;
println!("TLS established with {WS_HOST}");
// WebSocket upgrade
println!("WebSocket upgrade...");
let (mut ws, _) = tokio_tungstenite::client_async(
format!("wss://{WS_HOST}{WS_PATH}"),
tls,
).await?;
println!("WebSocket connected");
// Send and receive
ws.send(Message::Text(ECHO_MSG.into())).await?;
let reply = ws.next().await.ok_or("no reply")??;
let reply_text = reply.into_text()?;
println!("Sent: \"{ECHO_MSG}\"");
println!("Received: \"{reply_text}\"");
println!("Match: {}", reply_text == ECHO_MSG);
ws.close(None).await?;
tunnel.shutdown().await;
Ok(())
}
```
See the full [runnable example](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) which also compares clearnet vs. mixnet timing.