472 lines
16 KiB
Plaintext
472 lines
16 KiB
Plaintext
---
|
||
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 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 <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)
|
||
}
|
||
|
||
```
|