--- 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) } ```