490 lines
17 KiB
Plaintext
490 lines
17 KiB
Plaintext
---
|
|
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'
|
|
|
|
<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 ({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<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;
|
|
```
|
|
|
|
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::<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::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<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")
|
|
}
|
|
}
|
|
```
|