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

```
