--- title: "smolmix: 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" --- # Private File Downloads import { Callout } from 'nextra/components' import { CodeVerified } from '../../../components/code-verified' import { RUST_MSRV } from '../../../components/versions' 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 ({RUST_MSRV}+) - A working internet connection (the tunnel connects to the live Nym mixnet) ## Step 1: Set up the project ```sh cargo init smolmix-tcp cd smolmix-tcp ``` 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; 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
] 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 = 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 { 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)"); ``` 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. ## 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}"); ``` 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. ## 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::::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; ``` Add this helper function outside `main()`: ```rust 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") } } ``` Key points: - `Empty::::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 ``` 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::builder().build()` 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; 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 = 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::::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 { 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") } } ```