Max/mixtcp (#6321)
* Add mixtcp crate
Components:
- NymIprDevice: smoltcp::phy::Device impl using channel-based I/O
- NymIprBridge: async task bridging the device to IpMixStream
- create_device(): helper to set up the complete stack
* - Cleanup
- Add graceful shutdown
- Declutter logging - move a lot of bridge info! -> trace!
- Move rustls, nym-bin-common, bytes to dev-dependencies
- Extract TlsOverTcp to mod.rs
- Make timing more granular
- Update readme
* Add UDP example
* Add UDP example to readme
* rename mixtcp -> smolmix
* Add Tunnel API with TcpStream and UdpSocket over tokio-smoltcp
* Re-export Tunnel API and add init_logging convenience function
* Remove raw smoltcp path, flatten tunnel module
* Clean up bridge, device, and tunnel code
* Consolidate architecture docs, tidy examples and README
- Add src/ARCHITECTURE.md as single source of truth for architecture
- Include in docs.rs via doc = include_str!
- Strip duplicated diagrams from tunnel.rs, device.rs, README
- Extract tls_connector() helper in HTTPS example to match websocket example
- Use consistent 'smolmix' casing in README
* Update smolmix imports for ipr_wrapper API
- stream_wrapper::{IpMixStream, NetworkEnvironment} → ipr_wrapper::
- connect_tunnel() → check_connected()
- disconnect_stream() → disconnect()
- allocated_ips() returns &IpPair directly (no Option)
* Add Tunnel::new_with_ipr, re-export IpPair/Recipient, tidy examples
- Add Tunnel::new_with_ipr() for targeting a specific exit node
- Re-export IpPair and Recipient so users don't need direct deps
- Add DNS leak warning to WebSocket example
- Await hyper connection task in HTTPS example
* Restructure smolmix into multi-crate workspace
- Move core tunnel code to smolmix/core/- Rewrite examples for each crate with clearnet/mixnet comparisons
* Add workspace README with architecture overview
* Update nym-sdk README module descriptions
- Replace stale stream_wrapper description with ipr_wrapper + mixnet::stream
- Remove TODO comment
* Remove companion crates, scope to smolmix-core
* Comment out additional components on -core branch README.md
* Cargo.lock fix for compilation issue
* Downgrade accidentally bumped dependencies in Cargo lock + change
smolmix dependencies to import from workspace
* Fix workspace deps + move nym-bin-common to dev-deps
* PR review changes + fix Sink delegation
* Fix borked merge + update README.md
* Fix up stale docs + rewrite examples to use proper imports and timing
logs
* Update readmes + architecture file
* Impl Drop for BridgeShutdownHandle + update comment
This commit is contained in:
Generated
+121
@@ -2350,6 +2350,47 @@ dependencies = [
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "delegate-display"
|
||||
version = "3.0.0"
|
||||
@@ -3398,6 +3439,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -3523,6 +3573,16 @@ dependencies = [
|
||||
"http 1.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -5047,6 +5107,12 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "managed"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
@@ -11074,6 +11140,47 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smolmix"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"hickory-proto",
|
||||
"hickory-resolver",
|
||||
"http-body-util",
|
||||
"hyper 1.6.0",
|
||||
"hyper-util",
|
||||
"nym-bin-common",
|
||||
"nym-ip-packet-requests",
|
||||
"nym-sdk",
|
||||
"reqwest 0.13.1",
|
||||
"rustls 0.23.37",
|
||||
"smoltcp",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.2",
|
||||
"tokio-smoltcp",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
"cfg-if",
|
||||
"defmt 0.3.100",
|
||||
"heapless",
|
||||
"libc",
|
||||
"log",
|
||||
"managed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snafu"
|
||||
version = "0.7.5"
|
||||
@@ -12040,6 +12147,20 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-smoltcp"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5f5d53da1c3095663a8900d86c2abb0ffe02d3f6aa86527b066148fcb33e65e"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"smoltcp",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
|
||||
@@ -147,6 +147,7 @@ members = [
|
||||
"sdk/ffi/go",
|
||||
"sdk/ffi/shared",
|
||||
"sdk/rust/nym-sdk",
|
||||
"smolmix/core",
|
||||
"service-providers/common",
|
||||
"service-providers/ip-packet-router",
|
||||
"service-providers/network-requester",
|
||||
@@ -279,6 +280,7 @@ getrandom03 = { package = "getrandom", version = "=0.3.3" }
|
||||
glob = "0.3"
|
||||
handlebars = "3.5.5"
|
||||
hex = "0.4.3"
|
||||
hickory-proto = "0.25.2"
|
||||
hickory-resolver = "0.25.2"
|
||||
hkdf = "0.12.3"
|
||||
hmac = "0.12.1"
|
||||
@@ -347,6 +349,8 @@ serde_yaml = "0.9.25"
|
||||
serde_plain = "1.0.2"
|
||||
sha2 = "0.10.3"
|
||||
si-scale = "0.2.3"
|
||||
smolmix = { version = "0.0.1", path = "smolmix/core" }
|
||||
smoltcp = "0.12"
|
||||
snow = "0.9.6"
|
||||
sphinx-packet = "=0.6.0"
|
||||
sqlx = "0.8.6"
|
||||
@@ -367,6 +371,8 @@ tokio-postgres = "0.7"
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-test = "0.4.4"
|
||||
tokio-tun = "0.11.5"
|
||||
tokio-rustls = "0.26"
|
||||
tokio-smoltcp = "0.5"
|
||||
tokio-tungstenite = { version = "0.20.1" }
|
||||
tokio-util = "0.7.15"
|
||||
toml = "0.8.22"
|
||||
@@ -559,6 +565,7 @@ wasm-bindgen = "0.2.99"
|
||||
wasm-bindgen-futures = "0.4.49"
|
||||
wasm-bindgen-test = "0.3.49"
|
||||
wasmtimer = "0.4.1"
|
||||
webpki-roots = "0.26"
|
||||
web-sys = "0.3.76"
|
||||
|
||||
# for local development:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Nym Rust SDK
|
||||
|
||||
<!--TODO MAX stream abstraction + notice on tcpproxy -->
|
||||
|
||||
This repo contains several components:
|
||||
- `mixnet`: exposes Nym Client builders and methods. This is useful if you want to interact directly with the Client, or build transport abstractions.
|
||||
- `tcp_proxy`: exposes functionality to set up client/server instances that expose a localhost TcpSocket to read/write to like a 'normal' socket connection. `tcp_proxy/bin/` contains standalone `nym-proxy-client` and `nym-proxy-server` binaries.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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 IP, 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 **IP layer**. A userspace smoltcp stack manages
|
||||
real TCP state machines (retransmits, windowing, port allocation) and UDP
|
||||
datagram delivery, and the mixnet becomes a transparent transport underneath.
|
||||
Any protocol that works over TCP or UDP works over smolmix — with zero
|
||||
adaptation.
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Application protocols that "just work" over smolmix │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ TLS │ │ HTTP/1.1 │ │ WebSocket │ │ libp2p │ │
|
||||
│ │ (rustls) │ │ (hyper) │ │ (tungstenite)│ │ (noise+yamux) │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ └───────┬────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────┴──────────────┴─────────────────┘ │
|
||||
│ │ │
|
||||
│ tokio_smoltcp::TcpStream │
|
||||
│ (AsyncRead + AsyncWrite, Send, Unpin) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ smolmix Tunnel │
|
||||
│ (smoltcp → mixnet → IPR) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```rust
|
||||
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?;
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
cargo run -p smolmix --example tcp # HTTPS via hyper
|
||||
cargo run -p smolmix --example udp # DNS via hickory-proto
|
||||
cargo run -p smolmix --example websocket # WebSocket via tungstenite
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [`core/src/ARCHITECTURE.md`](core/src/ARCHITECTURE.md) for the internal
|
||||
stack (smoltcp, device adapter, bridge, mixnet client).
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "smolmix"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
smoltcp = { workspace = true, features = [
|
||||
"std",
|
||||
"medium-ip",
|
||||
"proto-ipv4",
|
||||
"socket-tcp",
|
||||
"socket-udp",
|
||||
] }
|
||||
tokio = { workspace = true }
|
||||
tokio-smoltcp = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
nym-sdk = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "time", "net"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
rustls = { workspace = true, features = ["std", "ring"] }
|
||||
tokio-rustls = { workspace = true }
|
||||
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
|
||||
hickory-proto = { workspace = true }
|
||||
hickory-resolver = { workspace = true, features = ["tokio", "system-config"] }
|
||||
hyper = { workspace = true, features = ["client", "http1"] }
|
||||
hyper-util = { workspace = true, features = ["tokio"] }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["rustls"] }
|
||||
@@ -0,0 +1,19 @@
|
||||
# smolmix
|
||||
|
||||
A TCP/UDP tunnel over the Nym mixnet. Uses smoltcp as a userspace network stack and connects to an Exit Gateway's IP Packet Router, so the exit IP is the gateway's — not yours.
|
||||
|
||||
`Tunnel` gives you standard `TcpStream` and `UdpSocket` types (from tokio-smoltcp) that work transparently with the async Rust ecosystem: tokio-rustls for TLS, hyper for HTTP, tokio-tungstenite for WebSockets, etc.
|
||||
|
||||
## Examples
|
||||
|
||||
All examples include a clearnet-vs-mixnet comparison with timing and accept `--ipr <ADDRESS>` for targeting a specific exit node.
|
||||
|
||||
```sh
|
||||
cargo run -p smolmix --example tcp # raw TCP connection
|
||||
cargo run -p smolmix --example udp # raw UDP datagram
|
||||
cargo run -p smolmix --example websocket # WebSocket over TLS (raw TcpStream composability)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [`src/ARCHITECTURE.md`](src/ARCHITECTURE.md).
|
||||
@@ -0,0 +1,131 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run -p smolmix --example tcp
|
||||
//! cargo run -p smolmix --example tcp -- --ipr <IPR_ADDRESS>
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::body::Bytes;
|
||||
use hyper::Request;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rustls::pki_types::ServerName;
|
||||
use smolmix::Tunnel;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tracing::info;
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
const HOST: &str = "cloudflare.com";
|
||||
const PATH: &str = "/cdn-cgi/trace";
|
||||
|
||||
fn tls_connector() -> TlsConnector {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
TlsConnector::from(Arc::new(config))
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// Clearnet baseline via reqwest
|
||||
info!("Fetching via clearnet...");
|
||||
let clearnet_start = tokio::time::Instant::now();
|
||||
let clearnet_resp = reqwest::get(format!("https://{HOST}{PATH}")).await?;
|
||||
let clearnet_status = clearnet_resp.status();
|
||||
let clearnet_body = clearnet_resp.text().await?;
|
||||
let clearnet_duration = clearnet_start.elapsed();
|
||||
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
|
||||
|
||||
// Mixnet: smolmix TCP -> tokio-rustls -> hyper
|
||||
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?;
|
||||
|
||||
// Phase 1: Setup (TCP + TLS + HTTP handshakes)
|
||||
let setup_start = tokio::time::Instant::now();
|
||||
|
||||
info!("TCP connecting to 1.1.1.1:443 via mixnet...");
|
||||
let tcp = tunnel.tcp_connect("1.1.1.1:443".parse()?).await?;
|
||||
info!("TCP connected ({:?})", setup_start.elapsed());
|
||||
|
||||
info!("TLS handshake...");
|
||||
let connector = tls_connector();
|
||||
let domain = ServerName::try_from(HOST)?.to_owned();
|
||||
let tls = connector.connect(domain, tcp).await?;
|
||||
info!("TLS established ({:?})", setup_start.elapsed());
|
||||
|
||||
info!("HTTP/1.1 handshake...");
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls)).await?;
|
||||
tokio::spawn(conn);
|
||||
|
||||
let setup_duration = setup_start.elapsed();
|
||||
info!("Setup complete ({:?})", setup_duration);
|
||||
|
||||
// Phase 2: Request/response
|
||||
let request_start = tokio::time::Instant::now();
|
||||
|
||||
info!("Sending GET {PATH}...");
|
||||
let req = Request::get(PATH)
|
||||
.header("Host", HOST)
|
||||
.body(http_body_util::Empty::<Bytes>::new())?;
|
||||
let resp = sender.send_request(req).await?;
|
||||
let mixnet_status = resp.status();
|
||||
let body_bytes = resp.into_body().collect().await?.to_bytes();
|
||||
let mixnet_body = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
let request_duration = request_start.elapsed();
|
||||
info!(
|
||||
"Response: {} ({} bytes, {:?})",
|
||||
mixnet_status,
|
||||
body_bytes.len(),
|
||||
request_duration
|
||||
);
|
||||
|
||||
// Results
|
||||
let clearnet_ip = clearnet_body.lines().find(|l| l.starts_with("ip="));
|
||||
let mixnet_ip = mixnet_body.lines().find(|l| l.starts_with("ip="));
|
||||
|
||||
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
|
||||
info!(
|
||||
"Mixnet: {} (setup {:?} + request {:?} = {:?})",
|
||||
mixnet_status,
|
||||
setup_duration,
|
||||
request_duration,
|
||||
setup_duration + request_duration
|
||||
);
|
||||
info!("Clearnet IP: {}", clearnet_ip.unwrap_or("?"));
|
||||
info!("Mixnet IP: {}", mixnet_ip.unwrap_or("?"));
|
||||
|
||||
let total = setup_duration + request_duration;
|
||||
let slowdown = total.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
|
||||
info!(
|
||||
"Slowdown: {slowdown:.1}x (setup: {:.1}x, request: {:.1}x)",
|
||||
setup_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64,
|
||||
request_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64
|
||||
);
|
||||
|
||||
tunnel.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 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.
|
||||
//!
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run -p smolmix --example udp
|
||||
//! cargo run -p smolmix --example udp -- --ipr <IPR_ADDRESS>
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use hickory_proto::op::{Message, Query};
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
use hickory_resolver::TokioResolver;
|
||||
use smolmix::Tunnel;
|
||||
use tracing::info;
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), BoxError> {
|
||||
nym_bin_common::logging::setup_tracing_logger();
|
||||
|
||||
let domain = "example.com";
|
||||
|
||||
// Clearnet baseline via hickory-resolver
|
||||
info!("Clearnet DNS lookup for '{domain}'...");
|
||||
let resolver = TokioResolver::builder_tokio()?.build();
|
||||
let clearnet_start = tokio::time::Instant::now();
|
||||
let lookup = resolver.lookup_ip(domain).await?;
|
||||
let clearnet_ips: Vec<Ipv4Addr> = lookup
|
||||
.iter()
|
||||
.filter_map(|ip| match ip {
|
||||
std::net::IpAddr::V4(v4) => Some(v4),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let clearnet_duration = clearnet_start.elapsed();
|
||||
info!("Clearnet: {:?} in {:?}", clearnet_ips, clearnet_duration);
|
||||
|
||||
// Mixnet: hickory-proto query over smolmix UDP
|
||||
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?;
|
||||
|
||||
let udp = tunnel.udp_socket().await?;
|
||||
|
||||
let mut query = Message::new();
|
||||
query.set_recursion_desired(true);
|
||||
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
|
||||
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?;
|
||||
info!("Query sent ({:?})", mixnet_start.elapsed());
|
||||
|
||||
let mut buf = vec![0u8; 1500];
|
||||
let (n, _from) = udp.recv_from(&mut buf).await?;
|
||||
let mixnet_duration = mixnet_start.elapsed();
|
||||
info!("Response received ({} bytes, {:?})", n, mixnet_duration);
|
||||
|
||||
let response = Message::from_vec(&buf[..n])?;
|
||||
let mixnet_ips: Vec<Ipv4Addr> = response
|
||||
.answers()
|
||||
.iter()
|
||||
.filter_map(|r| match r.data() {
|
||||
RData::A(a) => Some(a.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Results
|
||||
info!("Clearnet: {:?} ({:?})", clearnet_ips, clearnet_duration);
|
||||
info!("Mixnet: {:?} ({:?})", mixnet_ips, mixnet_duration);
|
||||
|
||||
let ips_match = !mixnet_ips.is_empty() && mixnet_ips.iter().all(|ip| clearnet_ips.contains(ip));
|
||||
info!("IPs match: {ips_match}");
|
||||
|
||||
let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
|
||||
info!("Slowdown: {slowdown:.1}x");
|
||||
|
||||
tunnel.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! 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:
|
||||
//!
|
||||
//! ```text
|
||||
//! tokio-tungstenite (WebSocket framing)
|
||||
//! └─ tokio-rustls (TLS encryption)
|
||||
//! └─ tokio::net::TcpStream (clearnet)
|
||||
//! └─ smolmix::TcpStream (mixnet)
|
||||
//! ```
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run -p smolmix --example websocket
|
||||
//! cargo run -p smolmix --example websocket -- --ipr <IPR_ADDRESS>
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use rustls::pki_types::ServerName;
|
||||
use smolmix::Tunnel;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::info;
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
const WS_HOST: &str = "ws.postman-echo.com";
|
||||
const WS_PATH: &str = "/raw";
|
||||
const ECHO_MSG: &str = "Hello from the Nym mixnet!";
|
||||
|
||||
fn tls_connector() -> tokio_rustls::TlsConnector {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
tokio_rustls::TlsConnector::from(Arc::new(config))
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// Resolve hostname via clearnet DNS - you can resolve via the Mixnet (see UDP example) but for this test its not necessary
|
||||
let addr = tokio::net::lookup_host(format!("{WS_HOST}:443"))
|
||||
.await?
|
||||
.next()
|
||||
.ok_or("DNS resolution failed")?;
|
||||
info!("Resolved {WS_HOST} -> {addr}");
|
||||
|
||||
let connector = tls_connector();
|
||||
let domain = ServerName::try_from(WS_HOST)?.to_owned();
|
||||
|
||||
// Clearnet baseline: tokio TCP -> rustls -> tungstenite
|
||||
info!("Connecting via clearnet...");
|
||||
let clearnet_start = tokio::time::Instant::now();
|
||||
|
||||
let clearnet_tcp = tokio::net::TcpStream::connect(addr).await?;
|
||||
let clearnet_tls = connector.connect(domain.clone(), clearnet_tcp).await?;
|
||||
let (mut clearnet_ws, _) =
|
||||
tokio_tungstenite::client_async(format!("wss://{WS_HOST}{WS_PATH}"), clearnet_tls).await?;
|
||||
|
||||
clearnet_ws.send(Message::Text(ECHO_MSG.into())).await?;
|
||||
let clearnet_reply = clearnet_ws.next().await.ok_or("no clearnet reply")??;
|
||||
let clearnet_duration = clearnet_start.elapsed();
|
||||
let clearnet_text = clearnet_reply.into_text()?;
|
||||
clearnet_ws.close(None).await?;
|
||||
|
||||
info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}");
|
||||
|
||||
// Mixnet: smolmix TCP -> rustls -> tungstenite (same stack)
|
||||
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?;
|
||||
info!("Allocated IP: {}", tunnel.allocated_ips().ipv4);
|
||||
|
||||
// Phase 1: Setup (TCP + TLS + WebSocket handshakes)
|
||||
let setup_start = tokio::time::Instant::now();
|
||||
|
||||
info!("TCP connecting via mixnet...");
|
||||
let mixnet_tcp = tunnel.tcp_connect(addr).await?;
|
||||
info!("TCP connected ({:?})", setup_start.elapsed());
|
||||
|
||||
info!("TLS handshake...");
|
||||
let mixnet_tls = connector.connect(domain, mixnet_tcp).await?;
|
||||
info!("TLS established ({:?})", setup_start.elapsed());
|
||||
|
||||
info!("WebSocket upgrade...");
|
||||
let (mut mixnet_ws, _) =
|
||||
tokio_tungstenite::client_async(format!("wss://{WS_HOST}{WS_PATH}"), mixnet_tls).await?;
|
||||
|
||||
let setup_duration = setup_start.elapsed();
|
||||
info!("Setup complete ({:?})", setup_duration);
|
||||
|
||||
// Phase 2: Echo request/response
|
||||
let request_start = tokio::time::Instant::now();
|
||||
|
||||
mixnet_ws.send(Message::Text(ECHO_MSG.into())).await?;
|
||||
let mixnet_reply = mixnet_ws.next().await.ok_or("no mixnet reply")??;
|
||||
|
||||
let request_duration = request_start.elapsed();
|
||||
let mixnet_text = mixnet_reply.into_text()?;
|
||||
mixnet_ws.close(None).await?;
|
||||
|
||||
info!("Echo: \"{mixnet_text}\" ({:?})", request_duration);
|
||||
|
||||
// Results
|
||||
info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}");
|
||||
info!(
|
||||
"Mixnet: \"{mixnet_text}\" (setup {:?} + echo {:?} = {:?})",
|
||||
setup_duration,
|
||||
request_duration,
|
||||
setup_duration + request_duration
|
||||
);
|
||||
info!("Clearnet echo match: {}", clearnet_text == ECHO_MSG);
|
||||
info!("Mixnet echo match: {}", mixnet_text == ECHO_MSG);
|
||||
|
||||
let total = setup_duration + request_duration;
|
||||
let slowdown = total.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
|
||||
info!(
|
||||
"Slowdown: {slowdown:.1}x (setup: {:.1}x, echo: {:.1}x)",
|
||||
setup_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64,
|
||||
request_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64
|
||||
);
|
||||
|
||||
tunnel.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Architecture
|
||||
|
||||
smolmix is a TCP/UDP tunnel over the Nym mixnet. It gives you standard
|
||||
`TcpStream` and `UdpSocket` types that work transparently with the async Rust
|
||||
ecosystem (tokio-rustls, hyper, tokio-tungstenite, etc.) while routing all
|
||||
traffic through the mixnet for metadata privacy.
|
||||
|
||||
## Stack
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User code │
|
||||
│ tunnel.tcp_connect() → TcpStream (AsyncRead + AsyncWrite) │
|
||||
│ tunnel.udp_socket() → UdpSocket (send_to / recv_from) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ tokio-smoltcp::Net │
|
||||
│ Owns the smoltcp Interface + SocketSet + async poll loop. │
|
||||
│ Manages TCP state machines, retransmits, port allocation. │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ NymAsyncDevice (device.rs) │
|
||||
│ Stream + Sink adapter for raw IP packets over mpsc channels. │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ NymIprBridge (bridge.rs) │
|
||||
│ Background task shuttling packets between channels and the │
|
||||
│ mixnet. Bundles outgoing packets with MultiIpPacketCodec │
|
||||
│ (required by the IPR protocol). │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ IpMixStream → MixnetClient → Nym mixnet → IPR exit node │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
```text
|
||||
outgoing: smoltcp → NymAsyncDevice (Sink) → channel → NymIprBridge → IpMixStream → mixnet
|
||||
incoming: mixnet → IpMixStream → NymIprBridge → channel → NymAsyncDevice (Stream) → smoltcp
|
||||
```
|
||||
|
||||
tokio-smoltcp handles all the hard parts (smoltcp polling, TCP state machines,
|
||||
port allocation, waker management). We just give it a device that produces and
|
||||
consumes raw IP packets — `NymAsyncDevice` wraps the mpsc channel ends in the
|
||||
`Stream`/`Sink` traits that tokio-smoltcp requires.
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **Single async device adapter.** All traffic flows through one
|
||||
`NymAsyncDevice`. If you need a new transport type (e.g. ICMP), add a method
|
||||
to `Tunnel` rather than introducing a separate device — the device and bridge
|
||||
don't need to change. smoltcp already supports ICMP sockets; you'd enable
|
||||
the `socket-icmp` feature in `Cargo.toml`, add a method like
|
||||
`Tunnel::icmp_socket()` that calls the appropriate `Net` method, and expose
|
||||
the socket type via a re-export in `lib.rs`.
|
||||
|
||||
- **Tokio-only.** The bridge, SDK (`IpMixStream`, `MixnetClient`), and shutdown
|
||||
signaling are tokio-based. The data-plane channels use `futures::channel::mpsc`
|
||||
because `UnboundedSender` implements `Sink` — required by tokio-smoltcp's
|
||||
`AsyncDevice` trait. An earlier version had a sync smoltcp `Device` adapter
|
||||
for use without tokio-smoltcp, but it still required a tokio runtime
|
||||
underneath (for the bridge and SDK), so it provided no real runtime
|
||||
independence — just duplicated the bridging logic. If alternative-runtime
|
||||
support is ever needed, it would require swapping out the bridge, SDK, and
|
||||
channel layers — a separate crate, not a feature flag on this one.
|
||||
|
||||
- **Unbounded channels.** The channels between the device and bridge are
|
||||
unbounded. Backpressure is handled at the mixnet layer (IPR protocol), not
|
||||
at the channel level. If that assumption changes, consider bounded channels
|
||||
with a drop policy.
|
||||
|
||||
- **`Medium::Ip` (no Ethernet framing).** Raw IP packets go in and out,
|
||||
matching what the IPR protocol expects.
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
|
||||
use crate::error::SmolmixError;
|
||||
use futures::channel::mpsc;
|
||||
use futures::StreamExt;
|
||||
use nym_ip_packet_requests::codec::MultiIpPacketCodec;
|
||||
use nym_sdk::ipr_wrapper::IpMixStream;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
/// Asynchronous bridge between the smoltcp device and the Nym mixnet.
|
||||
///
|
||||
/// Runs as a background task, shuttling raw IP packets in both directions:
|
||||
///
|
||||
/// **Outgoing** (smoltcp → mixnet): receives packets from the device via channel,
|
||||
/// bundles them with [`MultiIpPacketCodec`] (required by the IPR protocol), and
|
||||
/// sends them through the mixnet.
|
||||
///
|
||||
/// **Incoming** (mixnet → smoltcp): polls the mixnet for packets and forwards
|
||||
/// them to the device via channel for smoltcp consumption.
|
||||
pub(crate) struct NymIprBridge {
|
||||
stream: IpMixStream,
|
||||
/// Receives outgoing packets from the device (smoltcp → bridge → mixnet).
|
||||
outgoing_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
/// Sends incoming packets to the device (mixnet → bridge → smoltcp).
|
||||
///
|
||||
/// Unbounded: backpressure is handled at the mixnet layer (IPR protocol),
|
||||
/// not here. If that changes, consider bounded channels with a drop policy.
|
||||
incoming_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_rx: oneshot::Receiver<()>,
|
||||
}
|
||||
|
||||
/// Handle for signaling the bridge to shut down gracefully.
|
||||
pub(crate) struct BridgeShutdownHandle {
|
||||
tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl BridgeShutdownHandle {
|
||||
/// Signal the bridge to shut down gracefully.
|
||||
///
|
||||
/// Sends a one-shot signal that breaks the bridge event loop. The bridge
|
||||
/// then calls `IpMixStream::disconnect()` before returning. Consumes
|
||||
/// `self` — can only be called once.
|
||||
///
|
||||
/// If this handle is dropped without calling `shutdown()`, the `Drop`
|
||||
/// impl sends the signal automatically.
|
||||
pub(crate) fn shutdown(mut self) {
|
||||
if let Some(tx) = self.tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BridgeShutdownHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NymIprBridge {
|
||||
/// Create a new bridge and its associated shutdown handle.
|
||||
///
|
||||
/// Returns `(bridge, shutdown_handle)`.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `stream` — the connected `IpMixStream` (owns the mixnet client)
|
||||
/// - `outgoing_rx` — receives raw IP packets from the smoltcp device
|
||||
/// - `incoming_tx` — sends raw IP packets to the smoltcp device
|
||||
pub(crate) fn new(
|
||||
stream: IpMixStream,
|
||||
outgoing_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
incoming_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
) -> (Self, BridgeShutdownHandle) {
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
(
|
||||
Self {
|
||||
stream,
|
||||
outgoing_rx,
|
||||
incoming_tx,
|
||||
shutdown_rx,
|
||||
},
|
||||
BridgeShutdownHandle {
|
||||
tx: Some(shutdown_tx),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs the bridge event loop.
|
||||
///
|
||||
/// Should be spawned via `tokio::spawn`. The loop exits when a shutdown
|
||||
/// signal is received, channels close, or an unrecoverable error occurs.
|
||||
///
|
||||
/// # Cancel safety
|
||||
///
|
||||
/// `IpMixStream::handle_incoming()` is **not** cancel-safe — its internal
|
||||
/// `FramedRead` buffers partial frames, and it mutates connection state after
|
||||
/// awaiting. In `tokio::select!`, the shutdown branch can cancel a pending
|
||||
/// `handle_incoming()` call, potentially losing buffered data. This is
|
||||
/// acceptable during shutdown but worth noting for future changes.
|
||||
pub(crate) async fn run(mut self) -> Result<(), SmolmixError> {
|
||||
info!("Starting bridge");
|
||||
let mut packets_sent: u64 = 0;
|
||||
let mut packets_received: u64 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut self.shutdown_rx => {
|
||||
info!(packets_sent, packets_received, "Bridge received shutdown signal");
|
||||
break;
|
||||
}
|
||||
|
||||
Some(packet) = self.outgoing_rx.next() => {
|
||||
trace!(len = packet.len(), "Sending packet to mixnet");
|
||||
|
||||
// IPR expects packets wrapped in MultiIpPacketCodec framing.
|
||||
let bundled = MultiIpPacketCodec::bundle_one_packet(packet.into());
|
||||
if let Err(e) = self.stream.send_ip_packet(&bundled).await {
|
||||
error!("Failed to send packet through mixnet: {e}");
|
||||
} else {
|
||||
packets_sent += 1;
|
||||
debug!(packets_sent, "Packet sent");
|
||||
}
|
||||
}
|
||||
|
||||
result = self.stream.handle_incoming() => {
|
||||
match result {
|
||||
Ok(packets) if !packets.is_empty() => {
|
||||
trace!(count = packets.len(), "Received packets from mixnet");
|
||||
for packet in packets {
|
||||
if self.incoming_tx.unbounded_send(packet.to_vec()).is_err() {
|
||||
error!("Device channel closed");
|
||||
return Err(SmolmixError::ChannelClosed);
|
||||
}
|
||||
packets_received += 1;
|
||||
}
|
||||
debug!(packets_received, "Packets received");
|
||||
}
|
||||
Ok(_) => {} // empty batch, keep polling
|
||||
Err(e) => {
|
||||
// handle_incoming() internally uses a 10-second timeout,
|
||||
// so this won't busy-loop on persistent errors.
|
||||
warn!("Mixnet receive error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else => {
|
||||
info!(packets_sent, packets_received, "All channels closed, shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// disconnect() internally waits for all SDK tasks via TaskTracker.
|
||||
info!("Disconnecting from mixnet...");
|
||||
self.stream.disconnect().await;
|
||||
info!("Disconnected");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
|
||||
//! Async device adapter for tokio-smoltcp.
|
||||
//!
|
||||
//! Wraps mpsc channel ends (connected to [`NymIprBridge`](crate::bridge::NymIprBridge))
|
||||
//! in the [`Stream`]/[`Sink`] traits that tokio-smoltcp requires. See the
|
||||
//! [crate-level docs](crate) for how this fits into the full stack.
|
||||
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use futures::channel::mpsc;
|
||||
use futures::{Sink, Stream};
|
||||
use smoltcp::phy::{DeviceCapabilities, Medium};
|
||||
use tokio_smoltcp::device::AsyncDevice;
|
||||
|
||||
/// Async adapter bridging mpsc channels to tokio-smoltcp's [`AsyncDevice`] trait.
|
||||
///
|
||||
/// Incoming packets (mixnet → smoltcp) arrive via the `rx` channel as a [`Stream`].
|
||||
/// Outgoing packets (smoltcp → mixnet) are sent via the `tx` channel as a [`Sink`].
|
||||
pub(crate) struct NymAsyncDevice {
|
||||
rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
capabilities: DeviceCapabilities,
|
||||
}
|
||||
|
||||
impl NymAsyncDevice {
|
||||
pub(crate) fn new(
|
||||
rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
) -> Self {
|
||||
let mut capabilities = DeviceCapabilities::default();
|
||||
capabilities.medium = Medium::Ip;
|
||||
capabilities.max_transmission_unit = 1500;
|
||||
capabilities.max_burst_size = Some(1);
|
||||
|
||||
Self {
|
||||
rx,
|
||||
tx,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tokio-smoltcp calls poll_next() in its reactor loop to feed packets into the
|
||||
// smoltcp Interface for processing.
|
||||
impl Stream for NymAsyncDevice {
|
||||
type Item = io::Result<Vec<u8>>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
Pin::new(&mut self.rx).poll_next(cx).map(|opt| opt.map(Ok))
|
||||
}
|
||||
}
|
||||
|
||||
// When smoltcp produces a packet (e.g. TCP SYN, data segment, UDP datagram),
|
||||
// tokio-smoltcp sends it here and we forward it to the bridge for mixnet delivery.
|
||||
//
|
||||
// Delegates to the built-in Sink impl on futures::channel::mpsc::UnboundedSender,
|
||||
// which handles channel liveness checks (poll_ready) and disconnect (poll_close).
|
||||
impl Sink<Vec<u8>> for NymAsyncDevice {
|
||||
type Error = io::Error;
|
||||
|
||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.tx)
|
||||
.poll_ready(cx)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed"))
|
||||
}
|
||||
|
||||
fn start_send(mut self: Pin<&mut Self>, item: Vec<u8>) -> Result<(), Self::Error> {
|
||||
Pin::new(&mut self.tx)
|
||||
.start_send(item)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed"))
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.tx)
|
||||
.poll_flush(cx)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed"))
|
||||
}
|
||||
|
||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.tx)
|
||||
.poll_close(cx)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge channel closed"))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncDevice for NymAsyncDevice {
|
||||
fn capabilities(&self) -> &DeviceCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SmolmixError {
|
||||
#[error("Channel closed")]
|
||||
ChannelClosed,
|
||||
|
||||
#[error("Not connected to IPR")]
|
||||
NotConnected,
|
||||
|
||||
#[error("Nym SDK error: {0}")]
|
||||
NymSdk(#[from] nym_sdk::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
|
||||
#![doc = include_str!("ARCHITECTURE.md")]
|
||||
|
||||
mod bridge;
|
||||
mod device;
|
||||
mod error;
|
||||
mod tunnel;
|
||||
|
||||
/// Error type for all fallible smolmix operations.
|
||||
pub use error::SmolmixError;
|
||||
|
||||
/// The IPv4/IPv6 address pair allocated to this tunnel by the IPR.
|
||||
pub use tunnel::IpPair;
|
||||
|
||||
/// A Nym mixnet address, used to target a specific IPR exit node.
|
||||
pub use tunnel::Recipient;
|
||||
|
||||
/// A TCP stream routed through the mixnet. Implements `AsyncRead + AsyncWrite`.
|
||||
///
|
||||
/// Obtained via [`Tunnel::tcp_connect`]. Works as a drop-in replacement for
|
||||
/// `tokio::net::TcpStream` with tokio-rustls, hyper, tokio-tungstenite, etc.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// # let tunnel = smolmix::Tunnel::new().await?;
|
||||
/// use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
///
|
||||
/// let mut stream = tunnel.tcp_connect("1.1.1.1:80".parse()?).await?;
|
||||
/// stream.write_all(b"GET / HTTP/1.1\r\nHost: 1.1.1.1\r\nConnection: close\r\n\r\n").await?;
|
||||
/// let mut buf = Vec::new();
|
||||
/// stream.read_to_end(&mut buf).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub use tunnel::TcpStream;
|
||||
|
||||
/// A mixnet tunnel providing TCP and UDP socket access.
|
||||
pub use tunnel::Tunnel;
|
||||
|
||||
/// Builder for configuring and creating a [`Tunnel`].
|
||||
///
|
||||
/// See [`Tunnel::builder()`] for usage.
|
||||
pub use tunnel::TunnelBuilder;
|
||||
|
||||
/// A UDP socket routed through the mixnet. Supports `send_to` / `recv_from`.
|
||||
///
|
||||
/// Obtained via [`Tunnel::udp_socket`] or [`Tunnel::udp_socket_on`].
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// # let tunnel = smolmix::Tunnel::new().await?;
|
||||
/// let udp = tunnel.udp_socket().await?;
|
||||
/// udp.send_to(b"hello", "1.1.1.1:9999".parse()?).await?;
|
||||
///
|
||||
/// let mut buf = [0u8; 1024];
|
||||
/// let (len, _src) = udp.recv_from(&mut buf).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub use tunnel::UdpSocket;
|
||||
@@ -0,0 +1,361 @@
|
||||
// Copyright 2024 - 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.
|
||||
//!
|
||||
//! See the [crate-level docs](crate) for the full architecture diagram.
|
||||
//!
|
||||
//! The returned [`TcpStream`] implements `tokio::io::AsyncRead + AsyncWrite`, so it
|
||||
//! works transparently with the entire async Rust ecosystem: tokio-rustls for TLS,
|
||||
//! tokio-tungstenite for WebSockets, hyper for HTTP, etc.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::channel::mpsc;
|
||||
pub use nym_ip_packet_requests::IpPair;
|
||||
use nym_sdk::ipr_wrapper::IpMixStream;
|
||||
use smoltcp::iface::Config;
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::info;
|
||||
|
||||
use crate::bridge::{BridgeShutdownHandle, NymIprBridge};
|
||||
use crate::device::NymAsyncDevice;
|
||||
use crate::SmolmixError;
|
||||
use tokio_smoltcp::{Net, NetConfig};
|
||||
|
||||
pub use nym_sdk::mixnet::Recipient;
|
||||
pub use tokio_smoltcp::{TcpStream, UdpSocket};
|
||||
|
||||
struct ShutdownState {
|
||||
bridge_shutdown: BridgeShutdownHandle,
|
||||
bridge_handle: JoinHandle<Result<(), SmolmixError>>,
|
||||
}
|
||||
|
||||
struct TunnelInner {
|
||||
/// tokio-smoltcp network stack. Its methods take &self, so multiple tasks can
|
||||
/// open sockets concurrently without locking.
|
||||
net: Net,
|
||||
allocated_ips: IpPair,
|
||||
/// Mutex only protects shutdown — called once, not on the hot path.
|
||||
shutdown: Mutex<Option<ShutdownState>>,
|
||||
}
|
||||
|
||||
/// A mixnet tunnel providing TCP and UDP socket access.
|
||||
///
|
||||
/// `Tunnel` manages a smoltcp network stack connected to the Nym mixnet via an IPR
|
||||
/// (Internet Packet Router). It spawns a background bridge task and a network reactor,
|
||||
/// then provides familiar socket APIs on top.
|
||||
///
|
||||
///
|
||||
/// # Shutdown
|
||||
///
|
||||
/// Call [`shutdown()`](Self::shutdown) for a clean disconnect. Rust has no async `Drop`,
|
||||
/// so dropping without calling `shutdown()` triggers a fire-and-forget cleanup via the
|
||||
/// oneshot channel — the bridge will still shut down, but the caller can't await it.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// use smolmix::Tunnel;
|
||||
/// use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
///
|
||||
/// let tunnel = Tunnel::new().await?;
|
||||
///
|
||||
/// // TCP — connect and use like any async stream
|
||||
/// 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?;
|
||||
/// let mut buf = Vec::new();
|
||||
/// tcp.read_to_end(&mut buf).await?;
|
||||
///
|
||||
/// // UDP — datagrams over the mixnet
|
||||
/// let udp = tunnel.udp_socket().await?;
|
||||
/// udp.send_to(b"hello", "1.1.1.1:9999".parse()?).await?;
|
||||
///
|
||||
/// // Share across tasks (cheap Arc-based clone)
|
||||
/// let t2 = tunnel.clone();
|
||||
/// tokio::spawn(async move {
|
||||
/// let _tcp2 = t2.tcp_connect("93.184.216.34:80".parse().unwrap()).await.unwrap();
|
||||
/// });
|
||||
///
|
||||
/// tunnel.shutdown().await;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// See also the repository examples: `tcp`, `udp`, `websocket`.
|
||||
#[derive(Clone)]
|
||||
pub struct Tunnel {
|
||||
inner: Arc<TunnelInner>,
|
||||
}
|
||||
|
||||
/// Builder for configuring and creating a [`Tunnel`].
|
||||
///
|
||||
/// Use [`Tunnel::builder()`] to create a new builder.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// use smolmix::Tunnel;
|
||||
///
|
||||
/// // Auto-discover the best IPR:
|
||||
/// let tunnel = Tunnel::builder().build().await?;
|
||||
///
|
||||
/// // Or specify an IPR exit node:
|
||||
/// use smolmix::Recipient;
|
||||
/// let ipr: Recipient = "gateway-address...".parse()?;
|
||||
/// let tunnel = Tunnel::builder().ipr_address(ipr).build().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// For full control over the mixnet client (credentials, gateway selection,
|
||||
/// storage, etc.), configure an [`IpMixStream`] directly and pass it to
|
||||
/// [`Tunnel::from_stream()`]. Deeper builder integration with `MixnetClientBuilder`
|
||||
/// will require upstream SDK changes to expose `IpMixStream` internals (TODO).
|
||||
pub struct TunnelBuilder {
|
||||
ipr_address: Option<Recipient>,
|
||||
}
|
||||
|
||||
impl TunnelBuilder {
|
||||
/// Target a specific IPR exit node instead of auto-discovering one.
|
||||
pub fn ipr_address(mut self, addr: Recipient) -> Self {
|
||||
self.ipr_address = Some(addr);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build and connect the tunnel.
|
||||
pub async fn build(self) -> Result<Tunnel, SmolmixError> {
|
||||
let stream = match self.ipr_address {
|
||||
Some(addr) => IpMixStream::new_with_ipr(addr).await?,
|
||||
None => IpMixStream::new().await?,
|
||||
};
|
||||
Tunnel::from_stream(stream).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Create a [`TunnelBuilder`] for configuring the tunnel before connecting.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// use smolmix::Tunnel;
|
||||
/// let tunnel = Tunnel::builder().build().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn builder() -> TunnelBuilder {
|
||||
TunnelBuilder { ipr_address: None }
|
||||
}
|
||||
|
||||
/// Create a new tunnel, automatically discovering the best IPR exit node.
|
||||
///
|
||||
/// Shorthand for `Tunnel::builder().build().await`.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// use smolmix::Tunnel;
|
||||
/// let tunnel = Tunnel::new().await?;
|
||||
/// let tcp = tunnel.tcp_connect("1.1.1.1:443".parse()?).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn new() -> Result<Self, SmolmixError> {
|
||||
Self::builder().build().await
|
||||
}
|
||||
|
||||
/// Create a new tunnel connected to a specific IPR exit node.
|
||||
///
|
||||
/// Shorthand for `Tunnel::builder().ipr_address(addr).build().await`.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// use smolmix::{Recipient, Tunnel};
|
||||
/// let ipr: Recipient = "gateway-address...".parse()?;
|
||||
/// let tunnel = Tunnel::new_with_ipr(ipr).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn new_with_ipr(ipr_address: Recipient) -> Result<Self, SmolmixError> {
|
||||
Self::builder().ipr_address(ipr_address).build().await
|
||||
}
|
||||
|
||||
/// Create a tunnel from a pre-configured [`IpMixStream`].
|
||||
///
|
||||
/// Use this for full control over the mixnet client (credentials, gateway
|
||||
/// selection, storage, etc.) — configure the `IpMixStream` upstream and
|
||||
/// pass it in directly.
|
||||
pub async fn from_stream(ipr_stream: IpMixStream) -> Result<Self, SmolmixError> {
|
||||
ipr_stream
|
||||
.check_connected()
|
||||
.map_err(|_| SmolmixError::NotConnected)?;
|
||||
|
||||
let allocated_ips = *ipr_stream.allocated_ips();
|
||||
|
||||
// Wire up two channel pairs connecting the bridge (async mixnet I/O) to the
|
||||
// async device adapter (which tokio-smoltcp polls for raw IP packets):
|
||||
//
|
||||
// outgoing: smoltcp → NymAsyncDevice.Sink → outgoing_tx → outgoing_rx → Bridge → mixnet
|
||||
// incoming: mixnet → Bridge → incoming_tx → incoming_rx → NymAsyncDevice.Stream → smoltcp
|
||||
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
|
||||
let (incoming_tx, incoming_rx) = mpsc::unbounded();
|
||||
|
||||
// Bridge runs as a background task, shuttling packets between channels and IpMixStream.
|
||||
let (bridge, bridge_shutdown) = NymIprBridge::new(ipr_stream, outgoing_rx, incoming_tx);
|
||||
let bridge_handle = tokio::spawn(bridge.run());
|
||||
|
||||
// NymAsyncDevice wraps the channel ends as Stream + Sink, which is all
|
||||
// tokio-smoltcp needs to drive the smoltcp Interface internally.
|
||||
let device = NymAsyncDevice::new(incoming_rx, outgoing_tx);
|
||||
|
||||
// Configure smoltcp: raw IP mode (no Ethernet), /32 for our allocated IP,
|
||||
// default route via unspecified (the IPR handles actual routing).
|
||||
let iface_config = Config::new(HardwareAddress::Ip);
|
||||
let net_config = NetConfig::new(
|
||||
iface_config,
|
||||
IpCidr::new(IpAddress::from(allocated_ips.ipv4), 32),
|
||||
vec![IpAddress::from(Ipv4Address::UNSPECIFIED)],
|
||||
);
|
||||
|
||||
// Net::new spawns the smoltcp reactor as a background task. From here on,
|
||||
// tcp_connect/udp_bind create sockets managed by that reactor.
|
||||
let net = Net::new(device, net_config);
|
||||
|
||||
info!("Tunnel ready, allocated IP: {}", allocated_ips.ipv4);
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(TunnelInner {
|
||||
net,
|
||||
allocated_ips,
|
||||
shutdown: Mutex::new(Some(ShutdownState {
|
||||
bridge_shutdown,
|
||||
bridge_handle,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a TCP connection to `addr` through the mixnet.
|
||||
///
|
||||
/// The returned [`TcpStream`] implements `tokio::io::AsyncRead + AsyncWrite`,
|
||||
/// so it works transparently with tokio-rustls, hyper, tokio-tungstenite, and
|
||||
/// any other async I/O consumer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SmolmixError::Io`] if the TCP handshake fails (connection
|
||||
/// refused, timeout, etc.) or if the tunnel has been shut down.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Raw HTTP request:
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// # let tunnel = smolmix::Tunnel::new().await?;
|
||||
/// use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
///
|
||||
/// 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?;
|
||||
///
|
||||
/// let mut response = Vec::new();
|
||||
/// tcp.read_to_end(&mut response).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// TLS via tokio-rustls (the stream is a drop-in for `tokio::net::TcpStream`):
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// # let tunnel = smolmix::Tunnel::new().await?;
|
||||
/// use rustls::pki_types::ServerName;
|
||||
/// use tokio_rustls::TlsConnector;
|
||||
///
|
||||
/// let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?;
|
||||
/// # let connector: TlsConnector = todo!();
|
||||
/// let tls = connector.connect(ServerName::try_from("example.com")?.to_owned(), tcp).await?;
|
||||
/// // `tls` implements AsyncRead + AsyncWrite — use with hyper, tungstenite, etc.
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn tcp_connect(&self, addr: SocketAddr) -> Result<TcpStream, SmolmixError> {
|
||||
Ok(self.inner.net.tcp_connect(addr).await?)
|
||||
}
|
||||
|
||||
/// Create a UDP socket bound to an ephemeral port.
|
||||
///
|
||||
/// The port is chosen by smoltcp's allocator. Use [`udp_socket_on`](Self::udp_socket_on)
|
||||
/// if you need a specific port (e.g. for a protocol that expects replies on
|
||||
/// a known port).
|
||||
///
|
||||
/// The returned [`UdpSocket`] supports `send_to` / `recv_from` for datagram I/O.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Send a DNS query and read the response:
|
||||
/// ```no_run
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// # let tunnel = smolmix::Tunnel::new().await?;
|
||||
/// let udp = tunnel.udp_socket().await?;
|
||||
///
|
||||
/// // Send a raw DNS query to Cloudflare
|
||||
/// let query = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\
|
||||
/// \x07example\x03com\x00\x00\x01\x00\x01";
|
||||
/// udp.send_to(query, "1.1.1.1:53".parse()?).await?;
|
||||
///
|
||||
/// let mut buf = [0u8; 512];
|
||||
/// let (len, _src) = udp.recv_from(&mut buf).await?;
|
||||
/// println!("Got {} bytes back", len);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn udp_socket(&self) -> Result<UdpSocket, SmolmixError> {
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], 0).into();
|
||||
Ok(self.inner.net.udp_bind(addr).await?)
|
||||
}
|
||||
|
||||
/// Create a UDP socket bound to a specific local port.
|
||||
///
|
||||
/// Binds to `0.0.0.0:<port>` on the tunnel's virtual interface. Use this when
|
||||
/// the remote side expects replies on a well-known port, or when you need
|
||||
/// multiple sockets on distinct ports.
|
||||
pub async fn udp_socket_on(&self, port: u16) -> Result<UdpSocket, SmolmixError> {
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
||||
Ok(self.inner.net.udp_bind(addr).await?)
|
||||
}
|
||||
|
||||
/// The IPv4/IPv6 address pair allocated to this tunnel by the IPR.
|
||||
///
|
||||
/// Available immediately after construction. The IPv4 address is assigned as
|
||||
/// a /32 on the tunnel's virtual interface — all traffic to/from external
|
||||
/// hosts appears to originate from this IP at the exit gateway.
|
||||
pub fn allocated_ips(&self) -> IpPair {
|
||||
self.inner.allocated_ips
|
||||
}
|
||||
|
||||
/// Gracefully shut down the tunnel.
|
||||
///
|
||||
/// Signals the bridge to disconnect from the mixnet and waits for it to finish.
|
||||
/// The smoltcp reactor stops when all `Tunnel` clones are dropped.
|
||||
///
|
||||
/// If the `Tunnel` is dropped without calling `shutdown()`, cleanup still happens:
|
||||
/// dropping the `Arc<TunnelInner>` drops the oneshot sender inside `ShutdownState`,
|
||||
/// which resolves the bridge's `shutdown_rx` and triggers its shutdown path. However,
|
||||
/// the drop path is fire-and-forget — call `shutdown()` explicitly if you need to
|
||||
/// wait for the mixnet disconnect to complete.
|
||||
///
|
||||
/// After shutdown, new socket operations (`tcp_connect`, `udp_socket`) will fail
|
||||
/// with IO errors — the bridge channels are closed.
|
||||
pub async fn shutdown(&self) {
|
||||
let mut state = self.inner.shutdown.lock().await;
|
||||
if let Some(s) = state.take() {
|
||||
info!("Shutting down tunnel");
|
||||
s.bridge_shutdown.shutdown();
|
||||
let _ = s.bridge_handle.await;
|
||||
info!("Tunnel shut down");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user