Update license + add old tutorial code as examples

This commit is contained in:
mfahampshire
2026-04-26 16:35:41 +01:00
parent 778f84aae6
commit 144617f5e1
11 changed files with 464 additions and 34 deletions
Generated
+1
View File
@@ -11167,6 +11167,7 @@ dependencies = [
name = "smolmix"
version = "0.0.1"
dependencies = [
"chrono",
"futures",
"hickory-proto",
"hickory-resolver",
+1
View File
@@ -42,3 +42,4 @@ hyper = { workspace = true, features = ["client", "http1"] }
hyper-util = { workspace = true, features = ["tokio"] }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["rustls"] }
chrono = { workspace = true }
+37 -11
View File
@@ -1,16 +1,34 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! HTTPS request through the Nym mixnet.
//!
//! Fetches Cloudflare's `/cdn-cgi/trace` diagnostic endpoint over clearnet
//! (reqwest) and through the mixnet (hyper over tokio-rustls over smolmix),
//! then compares the responses. The exit IP should differ — the mixnet path
//! exits through an IPR gateway.
//! Fetches Cloudflare's `/cdn-cgi/trace` over clearnet (reqwest) and through
//! the mixnet (hyper over tokio-rustls over smolmix), then compares the exit
//! IPs. The mixnet path should show a different IP — traffic exits through
//! an IPR (Internet Packet Router) gateway, not your machine.
//!
//! Run with:
//! cargo run -p smolmix --example tcp
//! cargo run -p smolmix --example tcp -- --ipr <IPR_ADDRESS>
//! ```text
//! hyper (HTTP/1.1 client)
//! └─ tokio-rustls (TLS encryption)
//! └─ smolmix::TcpStream (TCP over mixnet)
//! └─ smoltcp (userspace TCP/IP)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - Creating a [`Tunnel`] and connecting TCP through the mixnet
//! - Layering TLS ([`tokio_rustls`]) on a [`smolmix::TcpStream`] — it
//! implements `AsyncRead + AsyncWrite`, so standard crates work unchanged
//! - Using [`hyper`]'s HTTP/1.1 client over a custom transport via
//! [`TokioIo`](hyper_util::rt::TokioIo)
//! - The exit IP differs from clearnet — the remote server sees the IPR
//! gateway's IP, not yours
//!
//! ```sh
//! cargo run -p smolmix --example tcp
//! cargo run -p smolmix --example tcp -- --ipr <IPR_ADDRESS>
//! ```
use std::sync::Arc;
@@ -53,7 +71,10 @@ async fn main() -> Result<(), BoxError> {
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
// Mixnet: smolmix TCP -> tokio-rustls -> hyper
// -- Mixnet path --
// Create a tunnel, then stack the same TLS + HTTP layers on top.
// The only difference: smolmix::TcpStream instead of tokio::net::TcpStream.
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
@@ -66,7 +87,10 @@ async fn main() -> Result<(), BoxError> {
}
let tunnel = builder.build().await?;
// Phase 1: Setup (TCP + TLS + HTTP handshakes)
// Stage 1: TCP + TLS + HTTP handshakes through the mixnet.
// tcp_connect() returns a TcpStream that implements AsyncRead + AsyncWrite.
// tokio-rustls accepts it directly — no adapters or trait shims needed.
// TokioIo then bridges hyper's I/O traits with tokio's.
let setup_start = tokio::time::Instant::now();
info!("TCP connecting to 1.1.1.1:443 via mixnet...");
@@ -86,7 +110,9 @@ async fn main() -> Result<(), BoxError> {
let setup_duration = setup_start.elapsed();
info!("Setup complete ({:?})", setup_duration);
// Phase 2: Request/response
// Stage 2: Send request and read response.
// From here the code is identical to any hyper client — the mixnet
// transport is invisible to higher layers.
let request_start = tokio::time::Instant::now();
info!("Sending GET {PATH}...");
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! Multi-request HTTPS download through the Nym mixnet.
//!
//! Resolves a hostname via mixnet UDP DNS, then makes multiple HTTP/1.1
//! requests over a single keep-alive TCP+TLS connection — all routed
//! through the mixnet. Demonstrates DNS resolution, connection reuse,
//! and progress feedback.
//!
//! ```text
//! hyper (HTTP/1.1 client, keep-alive)
//! └─ tokio-rustls (TLS encryption)
//! └─ smolmix::TcpStream (TCP over mixnet)
//! └─ smoltcp (userspace TCP/IP)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - DNS resolution via mixnet UDP (avoids clearnet DNS leaks)
//! - TCP + TLS connection to the resolved IP
//! - HTTP/1.1 keep-alive: multiple requests over one mixnet connection
//! - Progress spinner during downloads
//!
//! Compare with `tcp.rs` which does a single request with clearnet comparison.
//!
//! ```sh
//! cargo run -p smolmix --example tcp_download
//! cargo run -p smolmix --example tcp_download -- --ipr <IPR_ADDRESS>
//! ```
use std::net::Ipv4Addr;
use std::sync::Arc;
use hickory_proto::op::{Message, Query};
use hickory_proto::rr::{Name, RData, RecordType};
use http_body_util::{BodyExt, Empty};
use hyper::body::Bytes;
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use smolmix::Tunnel;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const HOST: &str = "httpbin.org";
/// Sizes (in bytes) to download sequentially over one connection.
const SIZES: &[usize] = &[100, 1_000, 10_000];
#[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");
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 resolution via mixnet UDP
// We use hickory-proto to send a raw DNS query through the tunnel's
// UdpSocket, so even the DNS lookup is hidden from your ISP.
let ip = resolve_dns(&tunnel, HOST).await?;
println!("Resolved {HOST}{ip} (via mixnet DNS)");
// TCP + TLS through the mixnet
println!("Connecting to {HOST}:443...");
let tcp = tunnel.tcp_connect((ip, 443).into()).await?;
println!("TCP connected to {ip}:443 via mixnet");
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let domain = ServerName::try_from(HOST)?.to_owned();
let tls = connector.connect(domain, tcp).await?;
println!("TLS established with {HOST}");
// HTTP/1.1 connection (reused for all requests)
let io = TokioIo::new(tls);
let (mut sender, conn) = http1::handshake(io).await?;
tokio::spawn(conn);
// Multiple requests over the same connection
let total = SIZES.len();
println!("\nSending {total} requests over one connection...\n");
let overall = std::time::Instant::now();
let mut total_bytes = 0usize;
for (i, &size) in SIZES.iter().enumerate() {
let seq = i + 1;
let start = std::time::Instant::now();
let req = hyper::Request::get(format!("/bytes/{size}"))
.header("Host", HOST)
.body(Empty::<Bytes>::new())?;
let spinner = tokio::spawn(async move {
let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let mut i = 0;
loop {
eprint!(
"\r [{seq}/{total}] GET /bytes/{size:<5} {}",
frames[i % frames.len()]
);
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
}
});
let resp = sender.send_request(req).await?;
let status = resp.status();
let body = resp.into_body().collect().await?.to_bytes();
let elapsed = start.elapsed();
spinner.abort();
let speed = body.len() as f64 / elapsed.as_secs_f64();
eprintln!(
"\r [{seq}/{total}] GET /bytes/{size:<5}{status} {} in {elapsed:.1?} ({}/s) ",
format_bytes(body.len() as u64),
format_bytes(speed as u64),
);
total_bytes += body.len();
}
let elapsed = overall.elapsed();
println!(
"\nDone! {} in {total} requests over {elapsed:.1?}",
format_bytes(total_bytes as u64),
);
tunnel.shutdown().await;
Ok(())
}
/// Resolve a hostname to an IPv4 address via mixnet UDP DNS.
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)
}
fn format_bytes(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1} MB", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1} KB", n as f64 / 1_000.0)
} else {
format!("{n} B")
}
}
+30 -7
View File
@@ -1,12 +1,31 @@
//! DNS lookup through the Nym mixnet.
//!
//! Resolves `example.com` via clearnet (hickory-resolver) and via the mixnet
//! (hickory-proto UDP query to Cloudflare 1.1.1.1), then compares resolved
//! IPs and timing.
//! (hickory-proto raw UDP query to Cloudflare `1.1.1.1`), then compares
//! resolved IPs and timing.
//!
//! Run with:
//! cargo run -p smolmix --example udp
//! cargo run -p smolmix --example udp -- --ipr <IPR_ADDRESS>
//! ```text
//! DNS query / response (application-layer UDP)
//! └─ smolmix::UdpSocket (UDP over mixnet)
//! └─ smoltcp (userspace IP stack)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - Creating a [`Tunnel`] and using its [`UdpSocket`](smolmix::UdpSocket)
//! - The `send_to` / `recv_from` API matches [`tokio::net::UdpSocket`]
//! - Constructing a raw DNS query with [`hickory_proto`] and parsing the
//! response — standard crates work unchanged over smolmix UDP
//! - The DNS server sees the IPR gateway's IP, not yours
//!
//! For a more complete UDP example (multiple lookups + NTP time sync), see
//! the test tutorial in `test-tutorials/smolmix-udp/`.
//!
//! ```sh
//! cargo run -p smolmix --example udp
//! cargo run -p smolmix --example udp -- --ipr <IPR_ADDRESS>
//! ```
use std::net::Ipv4Addr;
@@ -39,7 +58,10 @@ async fn main() -> Result<(), BoxError> {
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {:?} in {:?}", clearnet_ips, clearnet_duration);
// Mixnet: hickory-proto query over smolmix UDP
// We use hickory-proto (not hickory-resolver) because we want to send
// the raw UDP query through the tunnel ourselves, rather than relying
// on the system resolver which would go over clearnet.
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
@@ -59,7 +81,8 @@ async fn main() -> Result<(), BoxError> {
query.add_query(Query::query(Name::from_ascii(domain)?, RecordType::A));
let query_bytes = query.to_vec()?;
// UDP is connectionless — no setup phase, just send/recv
// Send the DNS query through the mixnet.
// UDP is connectionless — no handshake, just send_to / recv_from.
info!("Sending DNS query via mixnet...");
let mixnet_start = tokio::time::Instant::now();
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
+182
View File
@@ -0,0 +1,182 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! Multiple DNS lookups + NTP time sync through the Nym mixnet.
//!
//! Resolves several hostnames and syncs the clock via NTP — all over
//! mixnet UDP. Demonstrates timeout handling, socket reuse, and raw
//! protocol construction over smolmix's `UdpSocket`.
//!
//! ```text
//! DNS / NTP (application-layer UDP protocols)
//! └─ smolmix::UdpSocket (UDP over mixnet)
//! └─ smoltcp (userspace IP stack)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - Multiple DNS queries over a single `UdpSocket`
//! - Timeout handling with `tokio::time::timeout` (essential for UDP)
//! - NTP time sync via a raw 48-byte UDP packet
//! - Converting NTP epoch (1900) to Unix epoch (1970)
//!
//! Compare with `udp.rs` which does a single DNS lookup with clearnet comparison.
//!
//! ```sh
//! cargo run -p smolmix --example udp_multi
//! cargo run -p smolmix --example udp_multi -- --ipr <IPR_ADDRESS>
//! ```
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");
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
// Stage 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
);
// Stage 2: Multiple DNS lookups over one UdpSocket
// Each query goes to Cloudflare DNS (1.1.1.1:53) through the mixnet.
// The DNS server sees the IPR exit gateway's IP, not yours.
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();
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()?;
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
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"),
}
}
// Stage 3: NTP time sync via mixnet UDP
// NTP uses a simple 48-byte request/response over UDP port 123.
// We first resolve pool.ntp.org via the mixnet, then send the NTP request.
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}");
// NTP request: 48 bytes, LI=0 Version=4 Mode=3 (client)
let mut ntp_req = [0u8; 48];
ntp_req[0] = 0x23;
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]]);
// NTP epoch (1900) → 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(())
}
/// Resolve a hostname to an IPv4 address via mixnet UDP DNS.
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)
}
+27 -12
View File
@@ -1,11 +1,8 @@
//! WebSocket echo over the Nym mixnet.
//!
//! Demonstrates stacking tokio-tungstenite on top of tokio-rustls on top of
//! smolmix TcpStream. Sends a message to a public echo server via clearnet
//! and via the mixnet, then compares responses and timing.
//!
//! The clearnet and mixnet paths use the *exact same* TLS + WebSocket stack —
//! only the underlying TCP transport differs:
//! Sends a message to a public echo server via clearnet and via the mixnet,
//! then compares responses and timing. The clearnet and mixnet paths use the
//! *exact same* TLS + WebSocket stack — only the TCP transport differs.
//!
//! ```text
//! tokio-tungstenite (WebSocket framing)
@@ -14,9 +11,19 @@
//! └─ smolmix::TcpStream (mixnet)
//! ```
//!
//! Run with:
//! cargo run -p smolmix --example websocket
//! cargo run -p smolmix --example websocket -- --ipr <IPR_ADDRESS>
//! ## What this demonstrates
//!
//! - Composability: [`tokio_tungstenite::client_async`] accepts any
//! `AsyncRead + AsyncWrite` stream — it doesn't know or care that
//! TLS is backed by the mixnet rather than a kernel TCP socket
//! - The same `tls_connector()` and WebSocket upgrade code works for both
//! clearnet and mixnet — you only swap the underlying TCP stream
//! - The echo server sees the IPR gateway's IP, not yours
//!
//! ```sh
//! cargo run -p smolmix --example websocket
//! cargo run -p smolmix --example websocket -- --ipr <IPR_ADDRESS>
//! ```
use std::sync::Arc;
@@ -75,7 +82,11 @@ async fn main() -> Result<(), BoxError> {
info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}");
// Mixnet: smolmix TCP -> rustls -> tungstenite (same stack)
// -- Mixnet path --
// Exact same stack as clearnet, but over smolmix::TcpStream.
// This is the key composability point: swap the TCP transport
// and everything above it works unchanged.
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
@@ -89,7 +100,11 @@ async fn main() -> Result<(), BoxError> {
let tunnel = builder.build().await?;
info!("Allocated IP: {}", tunnel.allocated_ips().ipv4);
// Phase 1: Setup (TCP + TLS + WebSocket handshakes)
// Stage 1: TCP + TLS + WebSocket handshakes through the mixnet.
// Each layer only knows about the one directly below it:
// tungstenite thinks it's talking to a normal TLS stream
// rustls thinks it's talking to a normal TCP stream
// smolmix handles the mixnet routing transparently
let setup_start = tokio::time::Instant::now();
info!("TCP connecting via mixnet...");
@@ -107,7 +122,7 @@ async fn main() -> Result<(), BoxError> {
let setup_duration = setup_start.elapsed();
info!("Setup complete ({:?})", setup_duration);
// Phase 2: Echo request/response
// Stage 2: Send a message and verify the echo.
let request_start = tokio::time::Instant::now();
mixnet_ws.send(Message::Text(ECHO_MSG.into())).await?;
-1
View File
@@ -1,5 +1,4 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
//! Async device adapter for tokio-smoltcp.
//!
-1
View File
@@ -1,5 +1,4 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
use thiserror::Error;
-1
View File
@@ -1,5 +1,4 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
#![doc = include_str!("ARCHITECTURE.md")]
-1
View File
@@ -1,5 +1,4 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
//! High-level tunnel providing TCP and UDP sockets over the Nym mixnet.
//!