smolmix
TCP/UDP tunnel over the Nym mixnet. Uses a userspace network stack (smoltcp)
to provide real TcpStream and UdpSocket types that work transparently
with the async Rust ecosystem — tokio-rustls, hyper, tokio-tungstenite,
libp2p, and anything else built on AsyncRead + AsyncWrite.
Why TCP, not messages
The Nym SDK works at the message layer: you send and receive Vec<u8>
payloads through the mixnet. Every protocol must be hand-adapted — you need
custom framing, ordering, connection state, and flow control.
smolmix operates at the TCP layer. A userspace smoltcp stack manages real TCP state machines (retransmits, windowing, port allocation), and the mixnet becomes a transparent transport underneath. Any protocol that works over a TCP stream works over smolmix — with zero adaptation.
┌──────────────────────────────────────────────────────────────────┐
│ Application protocols that "just work" over smolmix TcpStream │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ TLS │ │ HTTP/1.1 │ │ WebSocket │ │ libp2p │ │
│ │ (rustls) │ │ (hyper) │ │ (tungstenite)│ │ (noise+yamux) │ │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │ │
│ └─────────────┴──────────────┴─────────────────┘ │
│ │ │
│ tokio_smoltcp::TcpStream │
│ (AsyncRead + AsyncWrite, Send, Unpin) │
├──────────────────────────────────────────────────────────────────┤
│ smolmix Tunnel │
│ (smoltcp → mixnet → IPR) │
└──────────────────────────────────────────────────────────────────┘
Concrete example: the Nym SDK's libp2p integration (libp2p_shared/) is
~2400 lines — custom Connection (StreamMuxer), custom Substream
(AsyncRead/Write), nonce-based ordering, and a custom handshake. With
smolmix, the same integration is ~175 lines: parse a multiaddr, resolve DNS
through the tunnel, and call tunnel.tcp_connect(). libp2p's built-in noise
and yamux work natively because they only need AsyncRead + AsyncWrite.
Each companion crate is a thin wrapper — typically under 200 lines — that
adapts an existing library to use tunnel I/O instead of OS sockets. The key
insight is that tokio_smoltcp::TcpStream implements tokio's AsyncRead + AsyncWrite, so the only work needed is bridging to whatever I/O trait the
target library expects:
| Crate | Lines | Trait implemented | I/O bridge |
|---|---|---|---|
smolmix-dns |
~110 | hickory RuntimeProvider |
AsyncIoTokioAsStd (tokio→futures) |
smolmix-tls |
~60 | (none — factory only) | direct (tokio-rustls takes AsyncRead/Write) |
smolmix-hyper |
~170 | tower::Service<Uri> |
TokioIo + pin_project! enum |
smolmix-dns
hickory-resolver's extension point is the RuntimeProvider trait — it
controls how the resolver creates TCP connections and UDP sockets.
SmolmixRuntimeProvider implements this trait, routing all I/O through the
tunnel:
RuntimeProvider::connect_tcp() → tunnel.tcp_connect() → AsyncIoTokioAsStd<TcpStream>
RuntimeProvider::bind_udp() → tunnel.udp_socket() → SmolmixUdpSocket (newtype)
hickory expects futures_io::AsyncRead/Write for TCP, not tokio's version.
AsyncIoTokioAsStd (from hickory-proto) adapts between them — and because
hickory's DnsTcpStream has a blanket impl for any futures_io::AsyncRead + AsyncWrite, the wrapped stream satisfies it automatically. Zero glue code.
For UDP, SmolmixUdpSocket is a thin newtype over tokio_smoltcp::UdpSocket
that implements hickory's DnsUdpSocket — just delegates poll_recv_from
and poll_send_to, whose signatures already match.
smolmix-tls
No trait adaptation needed. tokio-rustls works directly with anything that
implements tokio's AsyncRead + AsyncWrite — which TcpStream does. This
crate is pure configuration: build a ClientConfig with webpki roots, wrap
it in a TlsConnector, expose connect() and connect_with().
Re-exports TlsStream and TlsConnector so downstream crates don't need
tokio-rustls in their Cargo.toml.
smolmix-hyper
hyper-util's Client uses the tower::Service<Uri> trait to open
connections. SmolmixConnector implements this: given a URI, it resolves the
hostname, connects TCP, and optionally wraps in TLS.
The interesting part is the return type. hyper needs a stream that implements
hyper::rt::Read + Write + Connection. TokioIo<T> (from hyper-util)
provides this, but it needs a single type — not "sometimes TLS, sometimes
plain". MaybeTlsStream solves this with a two-variant enum and
pin_project_lite for safe pin projection through AsyncRead/AsyncWrite:
SmolmixConnector::call(uri)
→ resolve + tcp_connect + optional TLS
→ MaybeTlsStream::Plain { TcpStream }
or MaybeTlsStream::Tls { TlsStream<TcpStream> }
→ TokioIo<MaybeTlsStream> (implements hyper's Read/Write/Connection)
Advanced patterns (not published crates)
WebSocket and libp2p integration are demonstrated as examples rather than separate crates — the glue code is minimal enough that users are better served by copy-pasting and adapting:
- WebSocket:
tokio-tungstenitetakes anyAsyncRead + AsyncWrite, soTlsStream<TcpStream>works directly. Seecore/examples/websocket.rs. - libp2p: implement the
Transporttrait, resolve DNS through the tunnel, and bridge withtokio_util::compat. Seecore/examples/libp2p_ping.rs.
Quick start
use smolmix::Tunnel;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let tunnel = Tunnel::new().await?;
// Raw TCP — works with any protocol
let mut tcp = tunnel.tcp_connect("1.1.1.1:80".parse()?).await?;
tcp.write_all(b"GET / HTTP/1.1\r\nHost: 1.1.1.1\r\nConnection: close\r\n\r\n").await?;
// Raw UDP — datagrams over the mixnet
let udp = tunnel.udp_socket().await?;
udp.send_to(&packet, "1.1.1.1:53".parse()?).await?;
Crates
| Crate | What it provides |
|---|---|
smolmix |
Tunnel, TcpStream, UdpSocket |
smolmix-dns |
Resolver newtype wrapping hickory-resolver |
smolmix-tls |
Shared TlsConnector and connect() with webpki roots |
smolmix-hyper |
Client newtype wrapping hyper-util |
smolmix-hyper depends on smolmix-dns (hostname resolution through the
tunnel) and smolmix-tls (shared TLS setup).
[dependencies]
smolmix = { workspace = true }
smolmix-dns = { workspace = true } # DNS resolution
smolmix-tls = { workspace = true } # TLS
smolmix-hyper = { workspace = true } # HTTP client
Examples
Each crate has its own examples with clearnet-vs-mixnet comparisons:
cargo run -p smolmix --example tcp # raw TCP
cargo run -p smolmix --example udp # raw UDP
cargo run -p smolmix --example https # HTTPS via tokio-rustls
cargo run -p smolmix --example websocket # WebSocket over TLS
cargo run -p smolmix --example libp2p_ping # libp2p ping through mixnet
cargo run -p smolmix-dns --example resolve # DNS resolution
cargo run -p smolmix-hyper --example get # HTTPS GET via hyper
cargo run -p smolmix-hyper --example post # HTTP POST via hyper
Architecture
See core/src/ARCHITECTURE.md for the internal
stack (smoltcp, device adapter, bridge, mixnet client).