---
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.
