---
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'
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;
/// 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 ]
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 = 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
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.
## 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 {
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 40–47: 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
```
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;
/// 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 = 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 {
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)
}
```