Files
nym/documentation/docs/pages/developers/smolmix/tutorial-udp.mdx
T
mfahampshire 4fdd9f2024 Temp commit
2026-04-29 10:28:02 +01:00

472 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "smolmix: Private DNS & NTP via UDP"
description: "Step-by-step Rust tutorial that sends private DNS lookups and an NTP time query through the Nym mixnet using smolmix's UDP socket."
schemaType: "HowTo"
section: "Developers"
lastUpdated: "2026-04-17"
---
# Private DNS & NTP via UDP
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 program that uses the mixnet's UDP socket for two real-world tasks:
1. **Private DNS lookups** — resolve multiple hostnames through Cloudflare's `1.1.1.1` without revealing your IP
2. **NTP time sync** — query a public NTP server to get the current time, privately
Both are simple request/response protocols over UDP — perfect for the mixnet's datagram transport.
## What you'll learn
- Creating a `Tunnel` and using its `UdpSocket`
- Constructing raw DNS queries with `hickory-proto`
- Parsing DNS responses to extract A records
- Building an NTP request packet from scratch (48 bytes)
- Parsing NTP timestamps and converting to UTC
- Using timeouts for UDP operations
## 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-udp
cd smolmix-udp
```
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"] }
rustls = { version = "0.23", features = ["std", "ring"] }
hickory-proto = "0.25"
chrono = "0.4"
blake3 = "=1.7.0" # required pin — see https://nymtech.net/docs/developers/rust/importing
```
No HTTP crates needed — this tutorial is pure UDP. `rustls` is still required because the mixnet client uses TLS internally for gateway connections. The `ring` feature selects ring as the crypto backend (vs. `aws-lc-rs`, which needs a C compiler).
## 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 hickory_proto::op::{Message, Query};
use hickory_proto::rr::{Name, RData, RecordType};
use smolmix::Tunnel;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Hostnames to resolve via mixnet DNS.
const DNS_TARGETS: &[&str] = &["example.com", "cloudflare.com", "nymtech.net"];
#[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
Same as the TCP tutorial — connect to the mixnet and get a tunnel with UDP/TCP socket access:
```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: Private DNS lookups
Create a UDP socket and resolve several hostnames. Each query goes through the mixnet to Cloudflare's `1.1.1.1:53` — the DNS server sees the exit gateway's IP, not yours.
```rust
println!("\nPrivate DNS Lookups (via mixnet UDP)\n");
let udp = tunnel.udp_socket().await?;
for host in DNS_TARGETS {
let start = std::time::Instant::now();
// Build DNS query
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()?;
// Send to Cloudflare DNS via mixnet
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
// Read response with timeout
let mut buf = vec![0u8; 1500];
let result = tokio::time::timeout(
std::time::Duration::from_secs(15),
udp.recv_from(&mut buf),
)
.await;
match result {
Ok(Ok((n, _))) => {
let rtt = start.elapsed();
let response = Message::from_vec(&buf[..n])?;
let ips: Vec<_> = response
.answers()
.iter()
.filter_map(|r| match r.data() {
RData::A(a) => Some(a.0.to_string()),
_ => None,
})
.collect();
println!("{host:<16} → {} (rtt: {rtt:.1?})", ips.join(", "));
}
Ok(Err(e)) => println!("{host:<16} → ERROR: {e}"),
Err(_) => println!("{host:<16} → TIMEOUT"),
}
}
```
Key points:
- `tunnel.udp_socket()` creates a UDP socket routed through the mixnet
- `send_to` / `recv_from` work just like `tokio::net::UdpSocket`
- We wrap the receive in `tokio::time::timeout` since UDP has no built-in delivery guarantee
- The same socket is reused for all three queries
<Callout type="info">
Using `hickory-proto` gives us proper DNS query construction and response parsing. We could also construct raw DNS packets by hand, but there's no reason to — standard crates work unchanged over smolmix.
</Callout>
## Step 5: NTP time sync
NTP is a simple 48-byte request/response protocol over UDP. We'll resolve `pool.ntp.org`, send a client request, and parse the server's timestamp.
First, resolve the NTP server. We extract the DNS logic into a reusable helper:
```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)
}
```
Then build and send the NTP request:
```rust
println!("\nNTP Time Sync (via mixnet UDP)\n");
let ntp_ip = resolve_dns(&tunnel, "pool.ntp.org").await?;
println!("Resolved pool.ntp.org → {ntp_ip}");
// Build NTP request (48 bytes, version 4, client mode)
let mut ntp_req = [0u8; 48];
ntp_req[0] = 0x23; // LI=0, Version=4, Mode=3 (client)
let ntp_udp = tunnel.udp_socket().await?;
let start = std::time::Instant::now();
let ntp_dest: std::net::SocketAddr = (ntp_ip, 123).into();
ntp_udp.send_to(&ntp_req, ntp_dest).await?;
```
The NTP request is minimal — a 48-byte packet with only the first byte set: `0x23` means LI (leap indicator) = 0, version = 4, mode = 3 (client).
## Step 6: Parse the NTP response
The server's transmit timestamp lives at bytes 4047: a 32-bit seconds count since 1900-01-01 followed by a 32-bit fraction.
```rust
let mut buf = [0u8; 48];
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
ntp_udp.recv_from(&mut buf),
)
.await;
match result {
Ok(Ok((n, _))) if n >= 48 => {
let rtt = start.elapsed();
// Transmit timestamp at bytes 40..48 (seconds since 1900-01-01)
let secs = u32::from_be_bytes([buf[40], buf[41], buf[42], buf[43]]);
let frac = u32::from_be_bytes([buf[44], buf[45], buf[46], buf[47]]);
// Convert NTP epoch (1900) to Unix epoch (1970).
// Valid for Era 0 (until 2036-02-07); Era 1 wraps secs to 0.
const NTP_TO_UNIX: u64 = 2_208_988_800;
let unix_secs = secs as u64 - NTP_TO_UNIX;
let millis = (frac as u64 * 1000) >> 32;
let dt = chrono::DateTime::from_timestamp(unix_secs as i64, (millis * 1_000_000) as u32)
.expect("valid timestamp");
println!("NTP response in {rtt:.1?}");
println!("Unix timestamp: {unix_secs}.{millis:03}");
println!("UTC: {}", dt.format("%Y-%m-%d %H:%M:%S%.3f UTC"));
}
Ok(Ok((n, _))) => println!("Short response: {n} bytes (expected 48)"),
Ok(Err(e)) => println!("ERROR: {e}"),
Err(_) => println!("TIMEOUT (30s)"),
}
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:
```
Tunnel ready — allocated IP: 10.0.132.128
Private DNS Lookups (via mixnet UDP)
example.com → 104.20.23.154, 172.66.147.243 (rtt: 1.6s)
cloudflare.com → 104.16.133.229, 104.16.132.229 (rtt: 1.9s)
nymtech.net → 76.76.21.21 (rtt: 2.8s)
NTP Time Sync (via mixnet UDP)
Resolved pool.ntp.org → 172.232.146.46
NTP response in 1.5s
Unix timestamp: 1776188886.068
UTC: 2026-04-14 17:48:06.068 UTC
```
Every DNS query and the NTP request travel through the mixnet as UDP datagrams. The remote servers see the exit gateway's IP — not yours.
## How it works
```text
┌──────────────────────────────────────────────────────┐
│ DNS / NTP (application-layer UDP protocols) │
│ └─ smolmix::UdpSocket (UDP over mixnet) │
│ └─ smoltcp (userspace IP stack) │
│ └─ NymIprBridge → Nym mixnet → IPR │
└──────────────────────────────────────────────────────┘
```
The smolmix `UdpSocket` has the same `send_to`/`recv_from` API as `tokio::net::UdpSocket`. Any UDP-based protocol works unchanged — DNS and NTP are just two examples.
## What you've learned
- `tunnel.udp_socket()` creates a UDP socket that routes datagrams through the mixnet
- The UDP socket API (`send_to`, `recv_from`) matches `tokio::net::UdpSocket`
- Standard crates like `hickory-proto` work unmodified over smolmix UDP
- Raw protocols like NTP (48-byte request/response) work perfectly over the mixnet
- `tokio::time::timeout` is essential for UDP — there's no built-in delivery guarantee
- DNS queries through the mixnet hide your IP from the DNS server
- NTP through the mixnet lets you sync time without revealing your IP to time servers
## Complete code
```rust
use std::net::Ipv4Addr;
use hickory_proto::op::{Message, Query};
use hickory_proto::rr::{Name, RData, RecordType};
use smolmix::Tunnel;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Hostnames to resolve via mixnet DNS.
const DNS_TARGETS: &[&str] = &["example.com", "cloudflare.com", "nymtech.net"];
#[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));
// 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);
// DNS lookups
println!("\nPrivate DNS Lookups (via mixnet UDP)\n");
let udp = tunnel.udp_socket().await?;
for host in DNS_TARGETS {
let start = std::time::Instant::now();
// Build DNS query
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()?;
// Send to Cloudflare DNS via mixnet
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
// Read response with timeout
let mut buf = vec![0u8; 1500];
let result = tokio::time::timeout(
std::time::Duration::from_secs(15),
udp.recv_from(&mut buf),
)
.await;
match result {
Ok(Ok((n, _))) => {
let rtt = start.elapsed();
let response = Message::from_vec(&buf[..n])?;
let ips: Vec<_> = response
.answers()
.iter()
.filter_map(|r| match r.data() {
RData::A(a) => Some(a.0.to_string()),
_ => None,
})
.collect();
println!("{host:<16} → {} (rtt: {rtt:.1?})", ips.join(", "));
}
Ok(Err(e)) => println!("{host:<16} → ERROR: {e}"),
Err(_) => println!("{host:<16} → TIMEOUT"),
}
}
// NTP time sync
println!("\nNTP Time Sync (via mixnet UDP)\n");
// Resolve pool.ntp.org
let ntp_ip = resolve_dns(&tunnel, "pool.ntp.org").await?;
println!("Resolved pool.ntp.org → {ntp_ip}");
// Build NTP request (48 bytes, version 4, client mode)
let mut ntp_req = [0u8; 48];
ntp_req[0] = 0x23; // LI=0, Version=4, Mode=3 (client)
let ntp_udp = tunnel.udp_socket().await?;
let start = std::time::Instant::now();
let ntp_dest: std::net::SocketAddr = (ntp_ip, 123).into();
ntp_udp.send_to(&ntp_req, ntp_dest).await?;
let mut buf = [0u8; 48];
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
ntp_udp.recv_from(&mut buf),
)
.await;
match result {
Ok(Ok((n, _))) if n >= 48 => {
let rtt = start.elapsed();
// Transmit timestamp at bytes 40..48 (seconds since 1900-01-01)
let secs = u32::from_be_bytes([buf[40], buf[41], buf[42], buf[43]]);
let frac = u32::from_be_bytes([buf[44], buf[45], buf[46], buf[47]]);
// Convert NTP epoch (1900) to Unix epoch (1970).
// Valid for Era 0 (until 2036-02-07); Era 1 wraps secs to 0.
const NTP_TO_UNIX: u64 = 2_208_988_800;
let unix_secs = secs as u64 - NTP_TO_UNIX;
let millis = (frac as u64 * 1000) >> 32;
let dt = chrono::DateTime::from_timestamp(unix_secs as i64, (millis * 1_000_000) as u32)
.expect("valid timestamp");
println!("NTP response in {rtt:.1?}");
println!("Unix timestamp: {unix_secs}.{millis:03}");
println!("UTC: {}", dt.format("%Y-%m-%d %H:%M:%S%.3f UTC"));
}
Ok(Ok((n, _))) => println!("Short response: {n} bytes (expected 48)"),
Ok(Err(e)) => println!("ERROR: {e}"),
Err(_) => println!("TIMEOUT (30s)"),
}
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)
}
```