317 lines
11 KiB
Plaintext
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.
|