--- 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' 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; 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
] 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 = 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 ``` 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 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. ## 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; 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 = 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.