---
title: "smolmix Tutorial 1: Private File Downloads via DNS + TLS"
description: "Step-by-step Rust tutorial that resolves a hostname via mixnet DNS, connects over mixnet TCP, negotiates TLS, and downloads files over a single HTTP connection — all through the Nym mixnet."
schemaType: "HowTo"
section: "Developers"
lastUpdated: "2026-04-17"
---

# Tutorial 1: Private File Downloads

import { Callout } from 'nextra/components'
import { CratesPaused } from '../../../components/crates-paused'
import { CodeVerified } from '../../../components/code-verified'

<CratesPaused />
<CodeVerified crate="smolmix" />

In this tutorial you'll build a single program that sends HTTP requests to a public server — with every byte routed through the Nym mixnet:

1. **DNS** — resolve the hostname via a UDP query to Cloudflare's `1.1.1.1`
2. **TCP** — connect to the resolved IP
3. **TLS** — negotiate a secure channel with tokio-rustls
4. **HTTP** — use hyper to send multiple requests over a single keep-alive connection

Each layer stacks on the one below it using standard Rust traits. None of them know they're running over the mixnet — that's the point.

## What you'll learn

- Creating a `Tunnel` and using its `TcpStream` and `UdpSocket`
- Constructing a raw DNS query with `hickory-proto` and sending it via mixnet UDP
- Stacking `tokio-rustls` TLS on a `smolmix` `TcpStream`
- Using `hyper`'s low-level HTTP/1.1 client over a custom transport
- Reusing a single connection for multiple requests (HTTP keep-alive)
- Graceful tunnel shutdown

## Prerequisites

- Rust toolchain (1.70+)
- A working internet connection (the tunnel connects to the live Nym mixnet)

## Step 1: Set up the project

```sh
cargo init smolmix-echo
cd smolmix-echo
```

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", "time"] }
tokio-rustls = "0.26"
rustls = { version = "0.23", features = ["std", "ring"] }
webpki-roots = "0.26"
hickory-proto = "0.25"
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
http-body-util = "0.1"
blake3 = "=1.7.0"  # required pin — see https://nymtech.net/docs/developers/rust/importing
```

We use `hyper` for HTTP/1.1 client requests, `hyper-util` for the `TokioIo` adapter, and `http-body-util` to collect response bodies.

## Step 2: Scaffold `main()`

Start with the boilerplate: a tokio runtime, tracing for logs, the rustls crypto provider, and `--ipr` arg parsing.

Replace `src/main.rs` with:

```rust
use std::net::Ipv4Addr;
use std::sync::Arc;

use hickory_proto::op::{Message, Query};
use hickory_proto::rr::{Name, RData, RecordType};
use http_body_util::{BodyExt, Empty};
use hyper::body::Bytes;
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use smolmix::Tunnel;

type BoxError = Box<dyn std::error::Error + Send + Sync>;

const HOST: &str = "httpbin.org";

/// Sizes (in bytes) to download sequentially over one connection.
const SIZES: &[usize] = &[100, 1_000, 10_000];

#[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

The `Tunnel` connects to the mixnet, registers with an IPR (Internet Packet Router) exit gateway, and spawns the internal bridge and smoltcp reactor. From this point on, `tcp_connect()` and `udp_socket()` route through the mixnet.

```rust
    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: Resolve DNS through the mixnet

Instead of using the system resolver (which would go over clearnet), we send a raw DNS query through the tunnel's UDP socket to Cloudflare's `1.1.1.1:53`:

```rust
async fn resolve_dns(tunnel: &Tunnel, host: &str) -> Result<Ipv4Addr, BoxError> {
    let mut query = Message::new();
    query.set_recursion_desired(true);
    query.add_query(Query::query(Name::from_ascii(host)?, RecordType::A));
    let query_bytes = query.to_vec()?;

    let udp = tunnel.udp_socket().await?;
    udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;

    let mut buf = vec![0u8; 1500];
    let (n, _) = udp.recv_from(&mut buf).await?;

    let response = Message::from_vec(&buf[..n])?;
    let ip = response
        .answers()
        .iter()
        .find_map(|r| match r.data() {
            RData::A(a) => Some(a.0),
            _ => None,
        })
        .ok_or("no A record in DNS response")?;
    Ok(ip)
}
```

Call it from `main()`:

```rust
    let ip = resolve_dns(&tunnel, HOST).await?;
    println!("Resolved {HOST} → {ip} (via mixnet DNS)");
```

<Callout type="info">
The DNS query and response both travel through the mixnet as UDP datagrams. The IPR exit gateway sends the query to `1.1.1.1` on your behalf — the DNS server sees the gateway's IP, not yours.
</Callout>

## Step 5: TCP + TLS through the mixnet

Now connect to the resolved IP over TCP, then layer TLS on top. The smolmix `TcpStream` implements `AsyncRead + AsyncWrite`, so tokio-rustls accepts it directly — no adapters needed.

```rust
    println!("Connecting to {HOST}:443...");
    let tcp = tunnel.tcp_connect((ip, 443).into()).await?;
    println!("TCP connected to {ip}:443 via mixnet");

    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(HOST)?.to_owned();
    let tls = connector.connect(domain, tcp).await?;
    println!("TLS established with {HOST}");
```

<Callout type="warning">
TLS is essential here. Traffic is Sphinx-encrypted inside the mixnet, but between the exit gateway and the remote host it travels as normal internet traffic. TLS protects the final hop.
</Callout>

## Step 6: HTTP/1.1 connection with hyper

Use hyper's low-level `http1::handshake()` API to establish an HTTP connection over our TLS stream. The `TokioIo` wrapper bridges hyper's I/O traits with tokio's.

```rust
    let io = TokioIo::new(tls);
    let (mut sender, conn) = http1::handshake(io).await?;
    tokio::spawn(conn);
```

`handshake()` returns a `SendRequest` handle for sending requests and a connection future that drives the HTTP state machine. We spawn the connection so it runs in the background.

## Step 7: Send multiple requests

Now send several GET requests to `httpbin.org/bytes/{n}` over the same connection. Each request downloads a different amount of random bytes, showing HTTP keep-alive working transparently over the mixnet:

```rust
    let total = SIZES.len();
    println!("\nSending {total} requests over one connection...\n");
    let overall = std::time::Instant::now();
    let mut total_bytes = 0usize;

    for (i, &size) in SIZES.iter().enumerate() {
        let seq = i + 1;
        let start = std::time::Instant::now();

        let req = hyper::Request::get(format!("/bytes/{size}"))
            .header("Host", HOST)
            .body(Empty::<Bytes>::new())?;

        // Spinner while waiting for response
        let spinner = tokio::spawn(async move {
            let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
            let mut i = 0;
            loop {
                eprint!("\r  [{seq}/{total}] GET /bytes/{size:<5} {}", frames[i % frames.len()]);
                i += 1;
                tokio::time::sleep(std::time::Duration::from_millis(80)).await;
            }
        });

        let resp = sender.send_request(req).await?;
        let status = resp.status();
        let body = resp.into_body().collect().await?.to_bytes();
        let elapsed = start.elapsed();
        spinner.abort();

        let speed = body.len() as f64 / elapsed.as_secs_f64();
        eprintln!(
            "\r  [{seq}/{total}] GET /bytes/{size:<5} → {status} {} in {elapsed:.1?} ({}/s)   ",
            format_bytes(body.len() as u64),
            format_bytes(speed as u64),
        );
        total_bytes += body.len();
    }

    let elapsed = overall.elapsed();
    println!(
        "\nDone! {} in {total} requests over {elapsed:.1?}",
        format_bytes(total_bytes as u64),
    );

    tunnel.shutdown().await;
```

Key points:
- `Empty::<Bytes>::new()` is an empty body for our GET requests
- `collect().await?.to_bytes()` gathers each response body into memory
- A spinner runs while each request is in-flight, then gets replaced with the result
- All three requests reuse the same TCP+TLS connection — no reconnection overhead

## Step 8: 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 the full flow logged:

```
Tunnel ready — allocated IP: 10.0.232.7
Resolved httpbin.org → 18.214.245.199 (via mixnet DNS)
Connecting to httpbin.org:443...
TCP connected to 18.214.245.199:443 via mixnet
TLS established with httpbin.org

Sending 3 requests over one connection...

  [1/3] GET /bytes/100   → 200 OK 100 B in 2.0s (51 B/s)
  [2/3] GET /bytes/1000  → 200 OK 1.0 KB in 1.8s (554 B/s)
  [3/3] GET /bytes/10000 → 200 OK 10.0 KB in 11.5s (868 B/s)

Done! 11.1 KB in 3 requests over 15.3s
```

The remote server sees traffic from the IPR exit gateway's IP — not yours. All three requests travel through the mixnet over a single TCP connection.

## How it works

Here's the full protocol stack for this tutorial:

```text
┌──────────────────────────────────────────────────────┐
│  hyper (HTTP/1.1 client, keep-alive)                 │
│    └─ 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:
- **hyper** thinks it's talking to a normal TLS stream (via `TokioIo`)
- **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

- `Tunnel::new()` connects to the mixnet and gives you TCP/UDP socket access
- `tunnel.udp_socket()` creates a UDP socket for sending datagrams (like DNS queries) through the mixnet
- `tunnel.tcp_connect()` returns a `TcpStream` that implements `AsyncRead + AsyncWrite`
- `hyper`'s low-level `http1::handshake()` works over any `AsyncRead + AsyncWrite` transport via `TokioIo`
- HTTP keep-alive works transparently — multiple requests over a single mixnet connection
- Standard crates (hyper, tokio-rustls, hickory-proto) work unmodified on top of smolmix
- `tunnel.shutdown()` disconnects gracefully; dropping the tunnel triggers fire-and-forget cleanup

## Complete code

```rust
use std::net::Ipv4Addr;
use std::sync::Arc;

use hickory_proto::op::{Message, Query};
use hickory_proto::rr::{Name, RData, RecordType};
use http_body_util::{BodyExt, Empty};
use hyper::body::Bytes;
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use smolmix::Tunnel;

type BoxError = Box<dyn std::error::Error + Send + Sync>;

const HOST: &str = "httpbin.org";

/// Sizes (in bytes) to download sequentially over one connection.
const SIZES: &[usize] = &[100, 1_000, 10_000];

#[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");

    // 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));

    // Step 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);

    // Step 2: DNS resolution via mixnet UDP
    let ip = resolve_dns(&tunnel, HOST).await?;
    println!("Resolved {HOST} → {ip} (via mixnet DNS)");

    // Step 3: TCP + TLS through the mixnet (single connection)
    println!("Connecting to {HOST}:443...");
    let tcp = tunnel.tcp_connect((ip, 443).into()).await?;
    println!("TCP connected to {ip}:443 via mixnet");

    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(HOST)?.to_owned();
    let tls = connector.connect(domain, tcp).await?;
    println!("TLS established with {HOST}");

    // Step 4: HTTP/1.1 connection (reused for all requests)
    let io = TokioIo::new(tls);
    let (mut sender, conn) = http1::handshake(io).await?;
    tokio::spawn(conn);

    // Step 5: Send multiple requests over the same connection
    let total = SIZES.len();
    println!("\nSending {total} requests over one connection...\n");
    let overall = std::time::Instant::now();
    let mut total_bytes = 0usize;

    for (i, &size) in SIZES.iter().enumerate() {
        let seq = i + 1;
        let start = std::time::Instant::now();

        let req = hyper::Request::get(format!("/bytes/{size}"))
            .header("Host", HOST)
            .body(Empty::<Bytes>::new())?;

        // Spinner while waiting for response
        let spinner = tokio::spawn(async move {
            let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
            let mut i = 0;
            loop {
                eprint!("\r  [{seq}/{total}] GET /bytes/{size:<5} {}", frames[i % frames.len()]);
                i += 1;
                tokio::time::sleep(std::time::Duration::from_millis(80)).await;
            }
        });

        let resp = sender.send_request(req).await?;
        let status = resp.status();
        let body = resp.into_body().collect().await?.to_bytes();
        let elapsed = start.elapsed();
        spinner.abort();

        let speed = body.len() as f64 / elapsed.as_secs_f64();
        eprintln!(
            "\r  [{seq}/{total}] GET /bytes/{size:<5} → {status} {} in {elapsed:.1?} ({}/s)   ",
            format_bytes(body.len() as u64),
            format_bytes(speed as u64),
        );
        total_bytes += body.len();
    }

    let elapsed = overall.elapsed();
    println!(
        "\nDone! {} in {total} requests over {elapsed:.1?}",
        format_bytes(total_bytes as u64),
    );

    tunnel.shutdown().await;
    Ok(())
}

async fn resolve_dns(tunnel: &Tunnel, host: &str) -> Result<Ipv4Addr, BoxError> {
    let mut query = Message::new();
    query.set_recursion_desired(true);
    query.add_query(Query::query(Name::from_ascii(host)?, RecordType::A));
    let query_bytes = query.to_vec()?;

    let udp = tunnel.udp_socket().await?;
    udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;

    let mut buf = vec![0u8; 1500];
    let (n, _) = udp.recv_from(&mut buf).await?;

    let response = Message::from_vec(&buf[..n])?;
    let ip = response
        .answers()
        .iter()
        .find_map(|r| match r.data() {
            RData::A(a) => Some(a.0),
            _ => None,
        })
        .ok_or("no A record in DNS response")?;
    Ok(ip)
}

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")
    }
}
```
