diff --git a/sdk/rust/nym-sdk/src/ipr_wrapper.rs b/sdk/rust/nym-sdk/src/ipr_wrapper.rs new file mode 100644 index 0000000000..7e4b06a261 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ipr_wrapper.rs @@ -0,0 +1,15 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! High-level IPR (IP Packet Router) stream wrapper. +//! +//! [`IpMixStream`] tunnels IP packets through the Nym mixnet to an exit +//! gateway running an IP Packet Router. Both requests and responses are +//! wrapped in LP Stream frames for type-safe detection at the IPR and +//! dispatch by the client's stream router. + +mod ip_mix_stream; +pub mod network_env; + +pub use ip_mix_stream::{ConnectionState, IpMixStream, IprWithPerformance}; +pub use network_env::NetworkEnvironment; diff --git a/sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs b/sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs new file mode 100644 index 0000000000..7352283709 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs @@ -0,0 +1,520 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::network_env::NetworkEnvironment; +use crate::ip_packet_client::{ + helpers::check_ipr_message_version, IprListener, MixnetMessageOutcome, +}; +use crate::mixnet::{MixnetClient, MixnetStream, Recipient}; +use crate::Error; + +use bytes::Bytes; +use nym_crypto::asymmetric::ed25519; +use nym_ip_packet_requests::{ + v8::{ + request::IpPacketRequest, + response::{ConnectResponseReply, ControlResponse, IpPacketResponse, IpPacketResponseData}, + }, + IpPair, +}; +use nym_network_defaults::ApiUrl; +use nym_sphinx::receiver::ReconstructedMessage; +use nym_validator_client::nym_api::NymApiClientExt; +use std::collections::HashMap; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{debug, error, info}; + +const IPR_CONNECT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Maximum size for a single IPR response read from the stream. +/// IPR responses fit within one Sphinx packet payload (~1.8 KB) so 64 KB +/// provides ample headroom. +const READ_BUF_SIZE: usize = 64 * 1024; + +// --------------------------------------------------------------------------- +// Gateway discovery helpers +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct IprWithPerformance { + pub(crate) address: Recipient, + pub(crate) identity: ed25519::PublicKey, + pub(crate) performance: u8, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected, +} + +#[allow(clippy::result_large_err)] +fn create_nym_api_client(nym_api_urls: Vec) -> Result { + let user_agent = format!("nym-sdk/{}", env!("CARGO_PKG_VERSION")); + + let urls = nym_api_urls + .into_iter() + .map(|url| url.url.parse()) + .collect::, _>>() + .map_err(|err| { + error!("malformed nym-api url: {err}"); + Error::NoNymAPIUrl + })?; + + if urls.is_empty() { + return Err(Error::NoNymAPIUrl); + } + + let client = nym_http_api_client::ClientBuilder::new_with_urls(urls)? + .with_user_agent(user_agent) + .build()?; + + Ok(client) +} + +async fn retrieve_exit_nodes_with_performance( + client: nym_http_api_client::Client, +) -> Result, Error> { + let all_nodes = client + .get_all_described_nodes() + .await? + .into_iter() + .map(|described| (described.ed25519_identity_key(), described)) + .collect::>(); + + let exit_gateways = client.get_all_basic_nodes_with_metadata().await?.nodes; + + let mut described = Vec::new(); + + for exit in exit_gateways { + if let Some(ipr_info) = all_nodes + .get(&exit.ed25519_identity_pubkey) + .and_then(|n| n.description.ip_packet_router.clone()) + { + if let Ok(parsed_address) = ipr_info.address.parse() { + described.push(IprWithPerformance { + address: parsed_address, + identity: exit.ed25519_identity_pubkey, + performance: exit.performance.round_to_integer(), + }) + } + } + } + + Ok(described) +} + +async fn get_random_ipr(client: nym_http_api_client::Client) -> Result { + let nodes = retrieve_exit_nodes_with_performance(client).await?; + info!("Found {} Exit Gateways", nodes.len()); + + let selected_gateway = nodes + .into_iter() + .max_by_key(|gw| gw.performance) + .ok_or_else(|| Error::NoGatewayAvailable)?; + + let ipr_address = selected_gateway.address; + + info!( + "Using IPR: {} (Gateway: {}, Performance: {:?})", + ipr_address, selected_gateway.identity, selected_gateway.performance + ); + + Ok(ipr_address) +} + +// --------------------------------------------------------------------------- +// IpMixStream +// --------------------------------------------------------------------------- + +/// A bidirectional tunnel for sending and receiving IP packets through the mixnet. +/// +/// Wraps a [`MixnetStream`] (opened to an IPR exit gateway) and provides a +/// high-level API for the IPR protocol. The underlying `MixnetStream` handles +/// LP Stream framing and stream multiplexing automatically. +/// +/// # Data flow +/// +/// ```text +/// IpMixStream.send_ip_packet(bytes) +/// → IpPacketRequest::to_bytes() → MixnetStream.write() +/// → LP Stream frame (stream_id, seq, Data) +/// → Sphinx packets → mixnet → IPR +/// +/// IPR processes request → TUN → internet → response +/// → IPR wraps in LP Stream frame → Sphinx → mixnet → client +/// → stream router dispatches by stream_id +/// → MixnetStream.read() → IpPacketResponse bytes +/// → IprListener → extract IP packets +/// ``` +pub struct IpMixStream { + /// The underlying multiplexed stream to the IPR gateway. + stream: MixnetStream, + /// Kept for `nym_address()` and `disconnect()`. + client: MixnetClient, + /// Parses incoming IPR protocol responses. + listener: IprListener, + /// Reusable read buffer to avoid allocating per `handle_incoming()` call. + read_buf: Vec, + allocated_ips: Option, + connection_state: ConnectionState, +} + +impl IpMixStream { + /// Create a new IP packet router stream connected to the mixnet. + /// + /// Discovers an IPR gateway, connects a MixnetClient, and opens a + /// `MixnetStream` to the IPR. Call [`connect_tunnel`](Self::connect_tunnel) + /// to establish the IP tunnel. + pub async fn new(env: NetworkEnvironment) -> Result { + let network_defaults = env.network_defaults(); + let api_client = create_nym_api_client(network_defaults.nym_api_urls.unwrap_or_default())?; + let ipr_address = get_random_ipr(api_client).await?; + + nym_network_defaults::setup_env(Some(env.env_file_path())); + let mut client = MixnetClient::connect_new().await?; + + // Open a stream to the IPR — this sends the LP Stream Open handshake + // and starts the background stream router. + let stream = client.open_stream(ipr_address, Some(10)).await?; + + Ok(Self { + stream, + client, + listener: IprListener::new(), + read_buf: vec![0u8; READ_BUF_SIZE], + allocated_ips: None, + connection_state: ConnectionState::Disconnected, + }) + } + + /// Get the Nym network address of this stream. + pub fn nym_address(&self) -> &Recipient { + self.client.nym_address() + } + + /// Establish tunnel connection with the IPR and allocate IP addresses. + pub async fn connect_tunnel(&mut self) -> Result { + if self.connection_state != ConnectionState::Disconnected { + return Err(Error::IprStreamClientAlreadyConnectedOrConnecting); + } + + self.connection_state = ConnectionState::Connecting; + info!("Connecting to IP packet router"); + + match self.connect_inner().await { + Ok(ip_pair) => { + self.allocated_ips = Some(ip_pair); + self.connection_state = ConnectionState::Connected; + info!( + "Connected to IPv4: {}, IPv6: {}", + ip_pair.ipv4, ip_pair.ipv6 + ); + Ok(ip_pair) + } + Err(e) => { + self.connection_state = ConnectionState::Disconnected; + error!("Failed to connect: {:?}", e); + Err(e) + } + } + } + + async fn connect_inner(&mut self) -> Result { + let (request, request_id) = IpPacketRequest::new_connect_request(None); + debug!("Sending connect request with ID: {}", request_id); + + // Write the connect request — MixnetStream wraps it in an LP Stream + // Data frame automatically. + let request_bytes = request.to_bytes()?; + self.stream + .write_all(&request_bytes) + .await + .map_err(|_| Error::MessageSendingFailure)?; + + self.listen_for_connect_response(request_id).await + } + + async fn listen_for_connect_response(&mut self, request_id: u64) -> Result { + let timeout = tokio::time::sleep(IPR_CONNECT_TIMEOUT); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + return Err(Error::IPRConnectResponseTimeout); + } + result = self.stream.read(&mut self.read_buf) => { + match result { + Ok(0) => return Err(Error::IPRClientStreamClosed), + Ok(n) => { + let msg = ReconstructedMessage { + message: self.read_buf[..n].to_vec(), + sender_tag: None, + }; + if let Err(e) = check_ipr_message_version(&msg) { + return Err(Error::IPRMessageVersionCheckFailed(e.to_string())); + } + if let Ok(response) = IpPacketResponse::from_reconstructed_message(&msg) { + if response.id() == Some(request_id) { + return self.handle_connect_response(response); + } + } + } + Err(_) => return Err(Error::IPRClientStreamClosed), + } + } + } + } + } + + fn handle_connect_response(&self, response: IpPacketResponse) -> Result { + let control_response = match response.data { + IpPacketResponseData::Control(c) => c, + other => return Err(Error::UnexpectedResponseType(other)), + }; + + match *control_response { + ControlResponse::Connect(connect_resp) => match connect_resp.reply { + ConnectResponseReply::Success(success) => Ok(success.ips), + ConnectResponseReply::Failure(reason) => Err(Error::ConnectDenied(reason)), + }, + _ => Err(Error::UnexpectedResponseType( + IpPacketResponseData::Control(control_response.clone()), + )), + } + } + + /// Send an IP packet through the tunnel. + pub async fn send_ip_packet(&mut self, packet: &[u8]) -> Result<(), Error> { + if self.connection_state != ConnectionState::Connected { + return Err(Error::IprStreamClientNotConnected); + } + let request = IpPacketRequest::new_data_request(packet.to_vec().into()); + let request_bytes = request.to_bytes()?; + self.stream + .write_all(&request_bytes) + .await + .map_err(|_| Error::MessageSendingFailure) + } + + /// Handle incoming messages from the mixnet. + /// + /// Reads from the underlying `MixnetStream`, parses IPR responses, and + /// extracts IP packets. Returns an empty vec on timeout (10 s). + pub async fn handle_incoming(&mut self) -> Result, Error> { + match tokio::time::timeout( + Duration::from_secs(10), + self.stream.read(&mut self.read_buf), + ) + .await + { + // Timeout — no data yet, not an error + Err(_) => Ok(Vec::new()), + // EOF — stream router shut down, channel dead + Ok(Ok(0)) => { + self.connection_state = ConnectionState::Disconnected; + Err(Error::IPRClientStreamClosed) + } + // IO error + Ok(Err(_)) => { + self.connection_state = ConnectionState::Disconnected; + Err(Error::IPRClientStreamClosed) + } + Ok(Ok(n)) => { + let msg = ReconstructedMessage { + message: self.read_buf[..n].to_vec(), + sender_tag: None, + }; + match self.listener.handle_reconstructed_message(msg).await { + Ok(Some(MixnetMessageOutcome::IpPackets(packets))) => { + debug!("Extracted {} IP packets", packets.len()); + Ok(packets) + } + Ok(Some(MixnetMessageOutcome::Disconnect)) => { + info!("Received disconnect"); + self.connection_state = ConnectionState::Disconnected; + self.allocated_ips = None; + Ok(Vec::new()) + } + Ok(Some(MixnetMessageOutcome::MixnetSelfPing)) => { + debug!("Received mixnet self ping"); + Ok(Vec::new()) + } + Ok(None) => Ok(Vec::new()), + Err(e) => { + error!("Failed to handle message: {}", e); + Ok(Vec::new()) + } + } + } + } + } + + /// Get the allocated IP addresses for this tunnel. + pub fn allocated_ips(&self) -> Option<&IpPair> { + self.allocated_ips.as_ref() + } + + /// Check if the tunnel is currently connected. + pub fn is_connected(&self) -> bool { + self.connection_state == ConnectionState::Connected + } + + /// Disconnect from the Mixnet. Disconnected clients cannot be reconnected. + pub async fn disconnect_stream(self) { + debug!("Disconnecting"); + self.client.disconnect().await; + debug!("Disconnected"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ip_packet_client::helpers::{ + icmp_identifier, is_icmp_echo_reply, is_icmp_v6_echo_reply, + }; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[tokio::test] + #[ignore] + async fn connect_to_ipr() -> Result<(), Box> { + let mut stream = IpMixStream::new(NetworkEnvironment::Mainnet).await?; + let ip_pair = stream.connect_tunnel().await?; + + let ipv4: Ipv4Addr = ip_pair.ipv4; + assert!(!ipv4.is_unspecified(), "IPv4 address should not be 0.0.0.0"); + + let ipv6: Ipv6Addr = ip_pair.ipv6; + assert!(!ipv6.is_unspecified(), "IPv6 address should not be ::"); + + assert!(stream.is_connected(), "Stream should be connected"); + assert!( + stream.allocated_ips().is_some(), + "Should have allocated IPs" + ); + + stream.disconnect_stream().await; + + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn dns_ping_checks() -> Result<(), Box> { + use crate::ip_packet_client::helpers::{ + create_icmpv4_echo_request, create_icmpv6_echo_request, wrap_icmp_in_ipv4, + wrap_icmp_in_ipv6, + }; + use nym_ip_packet_requests::codec::MultiIpPacketCodec; + use pnet_packet::Packet; + + let mut stream = IpMixStream::new(NetworkEnvironment::Mainnet).await?; + let ip_pair = stream.connect_tunnel().await?; + + info!( + "Connected with IPs - IPv4: {}, IPv6: {}", + ip_pair.ipv4, ip_pair.ipv6 + ); + + let external_v4_targets = vec![ + ("Google DNS", Ipv4Addr::new(8, 8, 8, 8)), + ("Cloudflare DNS", Ipv4Addr::new(1, 1, 1, 1)), + ("Quad9 DNS", Ipv4Addr::new(9, 9, 9, 9)), + ]; + + let external_v6_targets = vec![ + ("Google DNS", "2001:4860:4860::8888".parse::()?), + ( + "Cloudflare DNS", + "2606:4700:4700::1111".parse::()?, + ), + ("Quad9 DNS", "2620:fe::fe".parse::()?), + ]; + + let identifier = icmp_identifier(); + let mut successful_v4_pings = 0; + let mut total_v4_pings = 0; + let mut successful_v6_pings = 0; + let mut total_v6_pings = 0; + + for (name, target) in &external_v4_targets { + info!("Testing IPv4 connectivity to {} ({})", name, target); + + for seq in 0..3 { + let icmp = create_icmpv4_echo_request(seq, identifier)?; + let ipv4_packet = wrap_icmp_in_ipv4(icmp, ip_pair.ipv4, *target)?; + let bundled = + MultiIpPacketCodec::bundle_one_packet(ipv4_packet.packet().to_vec().into()); + stream.send_ip_packet(&bundled).await?; + total_v4_pings += 1; + } + } + + for (name, target) in &external_v6_targets { + info!("Testing IPv6 connectivity to {} ({})", name, target); + + for seq in 0..3 { + let icmp = create_icmpv6_echo_request(seq, identifier, &ip_pair.ipv6, target)?; + let ipv6_packet = wrap_icmp_in_ipv6(icmp, ip_pair.ipv6, *target)?; + let bundled = + MultiIpPacketCodec::bundle_one_packet(ipv6_packet.packet().to_vec().into()); + stream.send_ip_packet(&bundled).await?; + total_v6_pings += 1; + } + } + + let collect_timeout = tokio::time::sleep(Duration::from_secs(10)); + tokio::pin!(collect_timeout); + + loop { + tokio::select! { + _ = &mut collect_timeout => { + info!("Finished collecting replies"); + break; + } + result = stream.handle_incoming() => { + if let Ok(packets) = result { + for packet in packets { + if let Some((reply_id, _source, dest)) = is_icmp_echo_reply(&packet) { + if reply_id == identifier && dest == ip_pair.ipv4 { + successful_v4_pings += 1; + } + } + + if let Some((reply_id, _source, dest)) = is_icmp_v6_echo_reply(&packet) { + if reply_id == identifier && dest == ip_pair.ipv6 { + successful_v6_pings += 1; + } + } + } + } + } + } + } + + let v4_success_rate = (successful_v4_pings as f64 / total_v4_pings as f64) * 100.0; + let v6_success_rate = (successful_v6_pings as f64 / total_v6_pings as f64) * 100.0; + + info!( + "IPv4: {}/{} ({:.1}%), IPv6: {}/{} ({:.1}%)", + successful_v4_pings, + total_v4_pings, + v4_success_rate, + successful_v6_pings, + total_v6_pings, + v6_success_rate + ); + + assert!(successful_v4_pings > 0, "No IPv4 pings successful"); + assert!(v4_success_rate >= 75.0, "IPv4 success rate < 75%"); + assert!(successful_v6_pings > 0, "No IPv6 pings successful"); + assert!(v6_success_rate >= 75.0, "IPv6 success rate < 75%"); + + stream.disconnect_stream().await; + Ok(()) + } +} diff --git a/sdk/rust/nym-sdk/src/ipr_wrapper/network_env.rs b/sdk/rust/nym-sdk/src/ipr_wrapper/network_env.rs new file mode 100644 index 0000000000..f7a4bf3601 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ipr_wrapper/network_env.rs @@ -0,0 +1,55 @@ +use std::fs; +use std::path::PathBuf; + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkEnvironment { + #[default] + Mainnet, + // Sandbox, +} + +fn find_workspace_root() -> PathBuf { + let mut current = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + loop { + let cargo_toml = current.join("Cargo.toml"); + + if cargo_toml.exists() { + if let Ok(contents) = fs::read_to_string(&cargo_toml) { + // Check if this Cargo.toml defines a workspace + if contents.contains("[workspace]") { + return current; + } + } + } + + if !current.pop() { + panic!("Could not find workspace root"); + } + } +} + +impl NetworkEnvironment { + pub fn env_file_path(&self) -> PathBuf { + let root = find_workspace_root(); + match self { + Self::Mainnet => root.join("envs/mainnet.env"), + // Self::Sandbox => root.join("envs/sandbox.env"), + } + } + + pub fn network_defaults(&self) -> crate::NymNetworkDetails { + match self { + Self::Mainnet => crate::NymNetworkDetails::new_mainnet(), + // Self::Sandbox => crate::NymNetworkDetails::new_sandbox(), // TODO + } + } + + pub fn parse_network(s: &str) -> Result { + match s.to_lowercase().as_str() { + "mainnet" | "main" => Ok(Self::Mainnet), + // "sandbox" | "sand" => Ok(Self::Sandbox), + _ => Err(format!("Unknown env: {}", s)), + } + } +} diff --git a/sdk/rust/nym-sdk/src/lib.rs b/sdk/rust/nym-sdk/src/lib.rs index 255d0467d8..aff475d8cc 100644 --- a/sdk/rust/nym-sdk/src/lib.rs +++ b/sdk/rust/nym-sdk/src/lib.rs @@ -3,15 +3,16 @@ //! The main component currently is [`mixnet`]. //! [`client_pool`] is a configurable client pool. //! [`tcp_proxy`] is a soon to be deprecated wrapper around the mixnet client which exposes a localhost port. -//! [`stream_wrapper`] is the v2 of the tcp_proxy, exposing a socket-like abstraction around the mixnet client. +//! [`ipr_wrapper`] tunnels IP packets through the mixnet to an IPR exit gateway. mod error; pub mod bandwidth; pub mod client_pool; pub mod ip_packet_client; +pub mod ipr_wrapper; pub mod mixnet; -// TODO: rewrite stream_wrapper to use MixnetStream + LP frame envelope +// stream_wrapper is superseded by ipr_wrapper (LP frame envelope over mixnet) // pub mod stream_wrapper; pub mod tcp_proxy; diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs b/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs index 2e466e8625..1613f04f2c 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs @@ -9,7 +9,7 @@ //! stream's channel (or to the listener for `Open` messages). mod mixnet_stream; -mod protocol; +pub(crate) mod protocol; pub use mixnet_stream::MixnetStream; pub use protocol::StreamId;