Files
fips/src/transport/nym/mod.rs
T
Johnathan Corgan f2e6b8befb transport: promote FMP stream framing to transport::framing
The FMP frame-boundary reader lived in transport/tcp/stream.rs, but the
Tor and Nym transports both reached across module boundaries to
'use crate::transport::tcp::stream::read_fmp_packet', a layering smell:
the reader is a shared stream-framing utility, not a TCP-private one.
Move the file to transport/framing.rs and repoint the tcp/tor/nym use
sites, removing the tor->tcp and nym->tcp dependencies. Behavior
unchanged.
2026-07-11 04:32:38 +00:00

1142 lines
39 KiB
Rust

//! Nym Mixnet Transport Implementation
//!
//! Provides Nym-based transport for FIPS peer communication using the
//! "Mixnet-As-Proxy" pattern. Traffic is routed through a local
//! nym-socks5-client SOCKS5 proxy into the Nym mixnet, providing
//! anonymity via Sphinx packet routing and timing obfuscation.
//!
//! ## Architecture
//!
//! Outbound-only: connects to remote TCP peers through the local
//! nym-socks5-client SOCKS5 proxy. Like the Tor transport, reuses FMP
//! stream framing from `transport::framing` and follows the same connection
//! pool pattern. No inbound service is supported.
pub mod stats;
#[cfg(test)]
mod mock_socks5;
use super::{
ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr,
TransportError, TransportId, TransportState, TransportType,
};
use crate::config::NymConfig;
use crate::transport::framing::read_fmp_packet;
use stats::NymStats;
use futures::FutureExt;
use socket2::TcpKeepalive;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use tokio::net::tcp::OwnedWriteHalf;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tokio_socks::tcp::Socks5Stream;
use tracing::{debug, info, trace, warn};
// ============================================================================
// Connection Pool
// ============================================================================
/// State for a single Nym connection to a peer.
struct NymConnection {
/// Write half of the split stream.
writer: Arc<Mutex<OwnedWriteHalf>>,
/// Receive task for this connection.
recv_task: JoinHandle<()>,
/// MTU for this connection.
#[allow(dead_code)]
mtu: u16,
/// When the connection was established.
#[allow(dead_code)]
established_at: Instant,
}
/// Shared connection pool.
type ConnectionPool = Arc<Mutex<HashMap<TransportAddr, NymConnection>>>;
/// A pending background connection attempt.
struct ConnectingEntry {
/// Background task performing SOCKS5 connect + socket configuration.
task: JoinHandle<Result<(TcpStream, u16), TransportError>>,
}
/// Map of addresses with background connection attempts in progress.
type ConnectingPool = Arc<Mutex<HashMap<TransportAddr, ConnectingEntry>>>;
// ============================================================================
// Nym Transport
// ============================================================================
/// Nym mixnet transport for FIPS.
///
/// Provides connection-oriented, reliable byte stream delivery through
/// the Nym mixnet via a local nym-socks5-client SOCKS5 proxy.
/// Outbound-only — no inbound service.
pub struct NymTransport {
/// Unique transport identifier.
transport_id: TransportId,
/// Optional instance name (for named instances in config).
name: Option<String>,
/// Configuration.
config: NymConfig,
/// Current state.
state: TransportState,
/// Connection pool: addr -> per-connection state.
pool: ConnectionPool,
/// Pending connection attempts: addr -> background connect task.
connecting: ConnectingPool,
/// Channel for delivering received packets to Node.
packet_tx: PacketTx,
/// Transport statistics.
stats: Arc<NymStats>,
}
impl NymTransport {
/// Create a new Nym transport.
pub fn new(
transport_id: TransportId,
name: Option<String>,
config: NymConfig,
packet_tx: PacketTx,
) -> Self {
Self {
transport_id,
name,
config,
state: TransportState::Configured,
pool: Arc::new(Mutex::new(HashMap::new())),
connecting: Arc::new(Mutex::new(HashMap::new())),
packet_tx,
stats: Arc::new(NymStats::new()),
}
}
/// Get the instance name (if configured as a named instance).
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
/// Get the transport statistics.
pub fn stats(&self) -> &Arc<NymStats> {
&self.stats
}
/// Start the transport asynchronously.
///
/// Validates the SOCKS5 proxy address and transitions to Up.
/// The nym-socks5-client must already be running and listening
/// on the configured address.
pub async fn start_async(&mut self) -> Result<(), TransportError> {
if !self.state.can_start() {
return Err(TransportError::AlreadyStarted);
}
self.state = TransportState::Starting;
let socks5_addr = self.config.socks5_addr().to_string();
validate_host_port(&socks5_addr, "socks5_addr")?;
// Wait for nym-socks5-client to be ready by probing the SOCKS5 port
let ready = self.wait_for_socks5_ready(&socks5_addr).await;
if !ready {
warn!(
transport_id = %self.transport_id,
socks5_addr = %socks5_addr,
"Nym SOCKS5 client not reachable after waiting — starting anyway \
(connections will fail until it becomes available)"
);
}
self.state = TransportState::Up;
if let Some(ref name) = self.name {
info!(
name = %name,
socks5_addr = %socks5_addr,
mtu = self.config.mtu(),
"Nym mixnet transport started"
);
} else {
info!(
socks5_addr = %socks5_addr,
mtu = self.config.mtu(),
"Nym mixnet transport started"
);
}
Ok(())
}
/// Wait for the nym-socks5-client SOCKS5 proxy to become reachable.
///
/// Probes the TCP port with exponential backoff. Returns true if the
/// proxy is reachable within the timeout, false otherwise.
async fn wait_for_socks5_ready(&self, socks5_addr: &str) -> bool {
let max_wait = Duration::from_secs(self.config.startup_timeout_secs());
let start = Instant::now();
let mut delay = Duration::from_secs(1);
info!(
transport_id = %self.transport_id,
socks5_addr = %socks5_addr,
timeout_secs = max_wait.as_secs(),
"Waiting for Nym SOCKS5 client to become ready..."
);
loop {
match TcpStream::connect(socks5_addr).await {
Ok(_) => {
info!(
transport_id = %self.transport_id,
socks5_addr = %socks5_addr,
elapsed_secs = start.elapsed().as_secs(),
"Nym SOCKS5 client is ready"
);
return true;
}
Err(e) => {
if start.elapsed() >= max_wait {
warn!(
transport_id = %self.transport_id,
socks5_addr = %socks5_addr,
error = %e,
elapsed_secs = start.elapsed().as_secs(),
"Nym SOCKS5 client not ready after timeout"
);
return false;
}
debug!(
transport_id = %self.transport_id,
socks5_addr = %socks5_addr,
error = %e,
retry_in_secs = delay.as_secs(),
"Nym SOCKS5 client not ready yet, retrying..."
);
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(10));
}
}
}
}
/// Stop the transport asynchronously.
pub async fn stop_async(&mut self) -> Result<(), TransportError> {
if !self.state.is_operational() {
return Err(TransportError::NotStarted);
}
// Abort pending connection attempts
let mut connecting = self.connecting.lock().await;
for (addr, entry) in connecting.drain() {
entry.task.abort();
debug!(
transport_id = %self.transport_id,
remote_addr = %addr,
"Nym connect aborted (transport stopping)"
);
}
drop(connecting);
// Close all connections
let mut pool = self.pool.lock().await;
for (addr, conn) in pool.drain() {
conn.recv_task.abort();
let _ = conn.recv_task.await;
debug!(
transport_id = %self.transport_id,
remote_addr = %addr,
"Nym connection closed (transport stopping)"
);
}
drop(pool);
self.state = TransportState::Down;
info!(
transport_id = %self.transport_id,
"Nym transport stopped"
);
Ok(())
}
/// Send a packet asynchronously.
///
/// If no connection exists, performs connect-on-send through the
/// Nym SOCKS5 proxy.
pub async fn send_async(
&self,
addr: &TransportAddr,
data: &[u8],
) -> Result<usize, TransportError> {
if !self.state.is_operational() {
return Err(TransportError::NotStarted);
}
// Pre-send MTU check
let mtu = self.config.mtu() as usize;
if data.len() > mtu {
self.stats.record_mtu_exceeded();
return Err(TransportError::MtuExceeded {
packet_size: data.len(),
mtu: self.config.mtu(),
});
}
// Get or create connection
let writer = {
let pool = self.pool.lock().await;
pool.get(addr).map(|c| c.writer.clone())
};
let writer = match writer {
Some(w) => w,
None => {
// Connect-on-send
self.connect(addr).await?
}
};
// Write packet
let mut w = writer.lock().await;
match w.write_all(data).await {
Ok(()) => {
self.stats.record_send(data.len());
trace!(
transport_id = %self.transport_id,
remote_addr = %addr,
bytes = data.len(),
"Nym packet sent"
);
Ok(data.len())
}
Err(e) => {
self.stats.record_send_error();
drop(w);
// Remove failed connection from pool
let mut pool = self.pool.lock().await;
if let Some(conn) = pool.remove(addr) {
conn.recv_task.abort();
}
Err(TransportError::SendFailed(format!("{}", e)))
}
}
}
/// Establish a new connection through the Nym SOCKS5 proxy.
async fn connect(
&self,
addr: &TransportAddr,
) -> Result<Arc<Mutex<OwnedWriteHalf>>, TransportError> {
let target_addr = parse_target_addr(addr)?;
let proxy_addr = self.config.socks5_addr();
let timeout_ms = self.config.connect_timeout_ms();
debug!(
transport_id = %self.transport_id,
remote_addr = %addr,
proxy = %proxy_addr,
timeout_secs = timeout_ms / 1000,
"Connecting via Nym mixnet SOCKS5 proxy"
);
let connect_start = Instant::now();
let socks_result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
match target_addr {
TargetAddr::Ip(socket_addr) => Socks5Stream::connect(proxy_addr, socket_addr).await,
TargetAddr::Hostname(host, port) => {
Socks5Stream::connect(proxy_addr, (host.as_str(), port)).await
}
}
})
.await;
let stream = match socks_result {
Ok(Ok(socks_stream)) => socks_stream.into_inner(),
Ok(Err(e)) => {
self.stats.record_socks5_error();
warn!(
transport_id = %self.transport_id,
remote_addr = %addr,
error = %e,
elapsed_secs = connect_start.elapsed().as_secs(),
"Nym SOCKS5 connection failed"
);
return Err(TransportError::ConnectionRefused);
}
Err(_) => {
self.stats.record_connect_timeout();
warn!(
transport_id = %self.transport_id,
remote_addr = %addr,
timeout_secs = timeout_ms / 1000,
"Nym SOCKS5 connection timed out"
);
return Err(TransportError::Timeout);
}
};
// Configure socket options via socket2
let std_stream = stream
.into_std()
.map_err(|e| TransportError::StartFailed(format!("into_std: {}", e)))?;
configure_socket(&std_stream)?;
// Convert back to tokio
let stream = TcpStream::from_std(std_stream)
.map_err(|e| TransportError::StartFailed(format!("from_std: {}", e)))?;
// Split and spawn receive task
let (read_half, write_half) = stream.into_split();
let writer = Arc::new(Mutex::new(write_half));
let transport_id = self.transport_id;
let packet_tx = self.packet_tx.clone();
let pool = self.pool.clone();
let recv_stats = self.stats.clone();
let remote_addr = addr.clone();
let mtu = self.config.mtu();
let recv_task = tokio::spawn(async move {
nym_receive_loop(
read_half,
transport_id,
remote_addr.clone(),
packet_tx,
pool,
mtu,
recv_stats,
)
.await;
});
let conn = NymConnection {
writer: writer.clone(),
recv_task,
mtu,
established_at: Instant::now(),
};
let mut pool = self.pool.lock().await;
pool.insert(addr.clone(), conn);
self.stats.record_connection_established();
debug!(
transport_id = %self.transport_id,
remote_addr = %addr,
elapsed_secs = connect_start.elapsed().as_secs(),
"Nym mixnet connection established via SOCKS5"
);
Ok(writer)
}
/// Initiate a non-blocking connection to a remote address.
pub async fn connect_async(&self, addr: &TransportAddr) -> Result<(), TransportError> {
if !self.state.is_operational() {
return Err(TransportError::NotStarted);
}
// Already established?
{
let pool = self.pool.lock().await;
if pool.contains_key(addr) {
return Ok(());
}
}
// Already connecting?
{
let connecting = self.connecting.lock().await;
if connecting.contains_key(addr) {
return Ok(());
}
}
let target_addr = parse_target_addr(addr)?;
let proxy_addr = self.config.socks5_addr().to_string();
let timeout_ms = self.config.connect_timeout_ms();
let transport_id = self.transport_id;
let remote_addr = addr.clone();
let config = self.config.clone();
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
timeout_ms,
"Initiating background Nym SOCKS5 connect"
);
let task = tokio::spawn(async move {
let connect_start = Instant::now();
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
proxy = %proxy_addr,
timeout_secs = timeout_ms / 1000,
"Nym SOCKS5 CONNECT starting (this may take several minutes through mixnet)"
);
let socks_result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
match target_addr {
TargetAddr::Ip(socket_addr) => {
Socks5Stream::connect(proxy_addr.as_str(), socket_addr).await
}
TargetAddr::Hostname(host, port) => {
Socks5Stream::connect(proxy_addr.as_str(), (host.as_str(), port)).await
}
}
})
.await;
let stream = match socks_result {
Ok(Ok(socks_stream)) => {
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
elapsed_secs = connect_start.elapsed().as_secs(),
"Nym SOCKS5 CONNECT succeeded"
);
socks_stream.into_inner()
}
Ok(Err(e)) => {
warn!(
transport_id = %transport_id,
remote_addr = %remote_addr,
error = %e,
elapsed_secs = connect_start.elapsed().as_secs(),
"Background Nym SOCKS5 connect failed"
);
return Err(TransportError::ConnectionRefused);
}
Err(_) => {
warn!(
transport_id = %transport_id,
remote_addr = %remote_addr,
timeout_secs = timeout_ms / 1000,
elapsed_secs = connect_start.elapsed().as_secs(),
"Background Nym SOCKS5 connect timed out after {}s",
connect_start.elapsed().as_secs()
);
return Err(TransportError::Timeout);
}
};
// Configure socket options via socket2
let std_stream = stream
.into_std()
.map_err(|e| TransportError::StartFailed(format!("into_std: {}", e)))?;
configure_socket(&std_stream)?;
let mtu = config.mtu();
// Convert back to tokio
let stream = TcpStream::from_std(std_stream)
.map_err(|e| TransportError::StartFailed(format!("from_std: {}", e)))?;
Ok((stream, mtu))
});
let mut connecting = self.connecting.lock().await;
connecting.insert(addr.clone(), ConnectingEntry { task });
Ok(())
}
/// Query the state of a connection to a remote address.
pub fn connection_state_sync(&self, addr: &TransportAddr) -> ConnectionState {
// Check established pool first
if let Ok(pool) = self.pool.try_lock() {
if pool.contains_key(addr) {
return ConnectionState::Connected;
}
} else {
return ConnectionState::Connecting;
}
// Check connecting pool
let mut connecting = match self.connecting.try_lock() {
Ok(c) => c,
Err(_) => return ConnectionState::Connecting,
};
let entry = match connecting.get_mut(addr) {
Some(e) => e,
None => return ConnectionState::None,
};
if !entry.task.is_finished() {
return ConnectionState::Connecting;
}
// Task is done — take the result
let addr_clone = addr.clone();
let task = connecting.remove(&addr_clone).unwrap().task;
match task.now_or_never() {
Some(Ok(Ok((stream, mtu)))) => {
self.promote_connection(addr, stream, mtu);
ConnectionState::Connected
}
Some(Ok(Err(e))) => ConnectionState::Failed(format!("{}", e)),
Some(Err(e)) => ConnectionState::Failed(format!("task failed: {}", e)),
None => ConnectionState::Connecting,
}
}
/// Promote a completed background connection to the established pool.
fn promote_connection(&self, addr: &TransportAddr, stream: TcpStream, mtu: u16) {
let (read_half, write_half) = stream.into_split();
let writer = Arc::new(Mutex::new(write_half));
let transport_id = self.transport_id;
let packet_tx = self.packet_tx.clone();
let pool = self.pool.clone();
let recv_stats = self.stats.clone();
let remote_addr = addr.clone();
let recv_task = tokio::spawn(async move {
nym_receive_loop(
read_half,
transport_id,
remote_addr.clone(),
packet_tx,
pool,
mtu,
recv_stats,
)
.await;
});
let conn = NymConnection {
writer,
recv_task,
mtu,
established_at: Instant::now(),
};
if let Ok(mut pool) = self.pool.try_lock() {
pool.insert(addr.clone(), conn);
self.stats.record_connection_established();
debug!(
transport_id = %self.transport_id,
remote_addr = %addr,
"Nym connection established (background connect)"
);
} else {
conn.recv_task.abort();
warn!(
transport_id = %self.transport_id,
remote_addr = %addr,
"Failed to promote Nym connection (pool locked)"
);
}
}
/// Close a specific connection asynchronously.
pub async fn close_connection_async(&self, addr: &TransportAddr) {
let mut pool = self.pool.lock().await;
if let Some(conn) = pool.remove(addr) {
conn.recv_task.abort();
debug!(
transport_id = %self.transport_id,
remote_addr = %addr,
"Nym connection closed"
);
}
}
}
impl Transport for NymTransport {
fn transport_id(&self) -> TransportId {
self.transport_id
}
fn transport_type(&self) -> &TransportType {
&TransportType::NYM
}
fn state(&self) -> TransportState {
self.state
}
fn mtu(&self) -> u16 {
self.config.mtu()
}
fn link_mtu(&self, _addr: &TransportAddr) -> u16 {
self.config.mtu()
}
fn start(&mut self) -> Result<(), TransportError> {
Err(TransportError::NotSupported(
"use start_async() for Nym transport".into(),
))
}
fn stop(&mut self) -> Result<(), TransportError> {
Err(TransportError::NotSupported(
"use stop_async() for Nym transport".into(),
))
}
fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> {
Err(TransportError::NotSupported(
"use send_async() for Nym transport".into(),
))
}
fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
Ok(Vec::new())
}
fn accept_connections(&self) -> bool {
false
}
}
// ============================================================================
// Address Parsing
// ============================================================================
/// Target address for the SOCKS5 CONNECT request.
#[derive(Clone, Debug)]
enum TargetAddr {
/// Numeric IP:port.
Ip(SocketAddr),
/// Hostname:port (DNS resolved by the exit node).
Hostname(String, u16),
}
/// Parse a TransportAddr string into a target address.
fn parse_target_addr(addr: &TransportAddr) -> Result<TargetAddr, TransportError> {
let s = addr.as_str().ok_or_else(|| {
TransportError::InvalidAddress("Nym address must be a valid UTF-8 string".into())
})?;
if let Ok(socket_addr) = s.parse::<SocketAddr>() {
Ok(TargetAddr::Ip(socket_addr))
} else {
let (host, port_str) = s.rsplit_once(':').ok_or_else(|| {
TransportError::InvalidAddress(format!("invalid address (expected host:port): {}", s))
})?;
let port: u16 = port_str
.parse()
.map_err(|_| TransportError::InvalidAddress(format!("invalid port: {}", s)))?;
Ok(TargetAddr::Hostname(host.to_string(), port))
}
}
// ============================================================================
// Receive Loop (per-connection)
// ============================================================================
/// Per-connection Nym receive loop.
async fn nym_receive_loop(
mut reader: tokio::net::tcp::OwnedReadHalf,
transport_id: TransportId,
remote_addr: TransportAddr,
packet_tx: PacketTx,
pool: ConnectionPool,
mtu: u16,
stats: Arc<NymStats>,
) {
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
"Nym receive loop starting"
);
loop {
match read_fmp_packet(&mut reader, mtu).await {
Ok(data) => {
stats.record_recv(data.len());
trace!(
transport_id = %transport_id,
remote_addr = %remote_addr,
bytes = data.len(),
"Nym packet received"
);
let packet = ReceivedPacket::new(transport_id, remote_addr.clone(), data);
if packet_tx.send(packet).await.is_err() {
debug!(
transport_id = %transport_id,
"Packet channel closed, stopping Nym receive loop"
);
break;
}
}
Err(e) => {
stats.record_recv_error();
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
error = %e,
"Nym receive error, removing connection"
);
break;
}
}
}
// Clean up: remove ourselves from the pool
let mut pool_guard = pool.lock().await;
pool_guard.remove(&remote_addr);
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
"Nym receive loop stopped"
);
}
// ============================================================================
// Socket Configuration
// ============================================================================
/// Configure socket options on a SOCKS5-connected stream.
fn configure_socket(stream: &std::net::TcpStream) -> Result<(), TransportError> {
let socket = socket2::SockRef::from(stream);
// TCP_NODELAY — always enable for FIPS (latency-sensitive protocol messages)
socket
.set_tcp_nodelay(true)
.map_err(|e| TransportError::StartFailed(format!("set nodelay: {}", e)))?;
// TCP keepalive (30s, matching TCP transport)
let keepalive = TcpKeepalive::new().with_time(Duration::from_secs(30));
socket
.set_tcp_keepalive(&keepalive)
.map_err(|e| TransportError::StartFailed(format!("set keepalive: {}", e)))?;
Ok(())
}
// ============================================================================
// Address Validation
// ============================================================================
/// Validate that a string is a valid host:port address.
fn validate_host_port(addr: &str, field: &str) -> Result<(), TransportError> {
let parts: Vec<&str> = addr.rsplitn(2, ':').collect();
if parts.len() != 2 {
return Err(TransportError::InvalidAddress(format!(
"{} must be host:port, got: {}",
field, addr
)));
}
let _port: u16 = parts[0].parse().map_err(|_| {
TransportError::InvalidAddress(format!("{} has invalid port: {}", field, addr))
})?;
Ok(())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::packet_channel;
/// Test config: a syntactically valid loopback proxy address, with the
/// startup readiness probe disabled (no real nym-socks5-client runs in
/// unit tests) and a short connect timeout to bound any accidental dial.
fn make_config() -> NymConfig {
NymConfig {
socks5_addr: Some("127.0.0.1:1080".to_string()),
startup_timeout_secs: Some(0),
connect_timeout_ms: Some(2000),
..Default::default()
}
}
// ---- parse_target_addr ----
#[test]
fn test_parse_target_addr_ipv4() {
let addr = TransportAddr::from_string("192.0.2.10:2121");
match parse_target_addr(&addr).unwrap() {
TargetAddr::Ip(socket_addr) => {
assert_eq!(
socket_addr,
"192.0.2.10:2121".parse::<SocketAddr>().unwrap()
);
}
other => panic!("expected Ip variant, got {:?}", other),
}
}
#[test]
fn test_parse_target_addr_ipv6_bracketed() {
// A bracketed IPv6 literal parses cleanly as a SocketAddr, so the
// connect path treats it as an Ip target with the brackets handled
// correctly (this is the path that actually dials peers).
let addr = TransportAddr::from_string("[2001:db8::1]:443");
match parse_target_addr(&addr).unwrap() {
TargetAddr::Ip(socket_addr) => {
assert_eq!(
socket_addr,
"[2001:db8::1]:443".parse::<SocketAddr>().unwrap()
);
}
other => panic!("expected Ip variant, got {:?}", other),
}
}
#[test]
fn test_parse_target_addr_hostname() {
let addr = TransportAddr::from_string("peer.example.com:8443");
match parse_target_addr(&addr).unwrap() {
TargetAddr::Hostname(host, port) => {
assert_eq!(host, "peer.example.com");
assert_eq!(port, 8443);
}
other => panic!("expected Hostname variant, got {:?}", other),
}
}
#[test]
fn test_parse_target_addr_missing_port() {
// No colon at all — cannot be split into host:port.
let addr = TransportAddr::from_string("peer.example.com");
assert!(parse_target_addr(&addr).is_err());
}
#[test]
fn test_parse_target_addr_non_numeric_port() {
let addr = TransportAddr::from_string("peer.example.com:notaport");
assert!(parse_target_addr(&addr).is_err());
}
// ---- validate_host_port ----
#[test]
fn test_validate_host_port_ok() {
assert!(validate_host_port("127.0.0.1:1080", "socks5_addr").is_ok());
assert!(validate_host_port("proxy.local:9050", "socks5_addr").is_ok());
}
#[test]
fn test_validate_host_port_missing_port() {
// No colon -> not host:port.
assert!(validate_host_port("127.0.0.1", "socks5_addr").is_err());
}
#[test]
fn test_validate_host_port_non_numeric_port() {
assert!(validate_host_port("127.0.0.1:abc", "socks5_addr").is_err());
}
/// Documents a known limitation: `validate_host_port` splits on the last
/// colon, so a bracketed IPv6 literal validates with port `1080` and a
/// host of `[::1]` (stray brackets) rather than being rejected. It is
/// harmless in practice because the SOCKS5 proxy defaults to an IPv4
/// loopback address, and the Tor transport has the same gap. Pin the
/// current behavior so any future change here is a deliberate one.
#[test]
fn test_validate_host_port_ipv6_bracket_is_accepted() {
assert!(validate_host_port("[::1]:1080", "socks5_addr").is_ok());
}
// ---- config defaults ----
#[test]
fn test_config_defaults() {
let config = NymConfig::default();
assert_eq!(config.socks5_addr(), "127.0.0.1:1080");
assert_eq!(config.connect_timeout_ms(), 300_000);
assert_eq!(config.mtu(), 1400);
assert_eq!(config.startup_timeout_secs(), 120);
}
// ---- Transport trait surface ----
#[test]
fn test_transport_type() {
let (tx, _rx) = packet_channel(32);
let transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
let tt = transport.transport_type();
assert_eq!(tt.name, "nym");
assert!(tt.connection_oriented);
assert!(tt.reliable);
}
#[test]
fn test_accept_connections_false() {
let (tx, _rx) = packet_channel(32);
let transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
assert!(!transport.accept_connections());
}
#[test]
fn test_discover_returns_empty() {
let (tx, _rx) = packet_channel(32);
let transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
assert!(transport.discover().unwrap().is_empty());
}
#[test]
fn test_sync_methods_return_not_supported() {
let (tx, _rx) = packet_channel(32);
let mut transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
assert!(transport.start().is_err());
assert!(transport.stop().is_err());
let addr = TransportAddr::from_string("127.0.0.1:2121");
assert!(transport.send(&addr, &[0u8; 10]).is_err());
}
// ---- lifecycle ----
#[tokio::test]
async fn test_start_stop() {
let (tx, _rx) = packet_channel(32);
let mut transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
transport.start_async().await.unwrap();
assert_eq!(transport.state(), TransportState::Up);
transport.stop_async().await.unwrap();
assert_eq!(transport.state(), TransportState::Down);
}
#[tokio::test]
async fn test_double_start_fails() {
let (tx, _rx) = packet_channel(32);
let mut transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
transport.start_async().await.unwrap();
assert!(transport.start_async().await.is_err());
}
#[tokio::test]
async fn test_stop_not_started_fails() {
let (tx, _rx) = packet_channel(32);
let mut transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
assert!(transport.stop_async().await.is_err());
}
#[tokio::test]
async fn test_send_not_started() {
let (tx, _rx) = packet_channel(32);
let transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
let addr = TransportAddr::from_string("127.0.0.1:2121");
assert!(transport.send_async(&addr, &[0u8; 10]).await.is_err());
}
#[tokio::test]
async fn test_invalid_socks5_addr_start_fails() {
let (tx, _rx) = packet_channel(32);
let config = NymConfig {
socks5_addr: Some("not-a-host-port".to_string()),
startup_timeout_secs: Some(0),
..Default::default()
};
let mut transport = NymTransport::new(TransportId::new(1), None, config, tx);
assert!(transport.start_async().await.is_err());
}
#[tokio::test]
async fn test_send_async_rejects_oversized_packet() {
let (tx, _rx) = packet_channel(32);
let mut transport = NymTransport::new(TransportId::new(1), None, make_config(), tx);
transport.start_async().await.unwrap();
let mtu = transport.mtu() as usize;
let addr = TransportAddr::from_string("127.0.0.1:2121");
// One byte over the MTU is rejected for size, before any dial.
let oversized = vec![0u8; mtu + 1];
let result = transport.send_async(&addr, &oversized).await;
assert!(matches!(result, Err(TransportError::MtuExceeded { .. })));
// A packet at exactly the MTU is not rejected for size. (It still
// fails — no proxy is listening — but not with MtuExceeded.)
let at_mtu = vec![0u8; mtu];
let result = transport.send_async(&addr, &at_mtu).await;
assert!(!matches!(result, Err(TransportError::MtuExceeded { .. })));
transport.stop_async().await.unwrap();
}
// ========================================================================
// Integration test using MockSocks5Server (connect path), mirroring the
// Tor transport's `test_send_recv_via_socks5`.
// ========================================================================
use crate::config::TcpConfig;
use crate::transport::tcp::TcpTransport;
use mock_socks5::MockSocks5Server;
/// msg1 wire size: 4 prefix + 4 sender_idx + 106 noise_msg1 = 114 bytes.
const MSG1_WIRE_SIZE: usize = 114;
/// msg1 payload_len: sender_idx(4) + noise_msg1(106) = 110.
const MSG1_PAYLOAD_LEN: u16 = (MSG1_WIRE_SIZE - 4) as u16;
/// Build a msg1 FMP frame (114 bytes) that `read_fmp_packet` accepts.
fn build_msg1_frame() -> Vec<u8> {
let mut frame = vec![0xAA; MSG1_WIRE_SIZE];
frame[0] = 0x01; // ver=0, phase=1
frame[1] = 0x00; // flags
frame[2..4].copy_from_slice(&MSG1_PAYLOAD_LEN.to_le_bytes());
frame
}
/// End-to-end connect path: a real TCP transport is the destination, a
/// mock SOCKS5 proxy sits in front of it, and the Nym transport dials the
/// destination through the proxy. A valid FMP frame sent via the Nym
/// transport must arrive at the destination byte-for-byte.
#[tokio::test]
async fn test_send_recv_via_socks5() {
// Destination TCP transport with a real listener.
let (dest_tx, mut dest_rx) = packet_channel(32);
let dest_config = TcpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
..Default::default()
};
let mut dest = TcpTransport::new(TransportId::new(100), None, dest_config, dest_tx);
dest.start_async().await.unwrap();
let dest_addr = dest.local_addr().unwrap();
// Mock SOCKS5 proxy forwarding to the destination.
let mock = MockSocks5Server::new(dest_addr).await.unwrap();
let proxy_addr = mock.addr();
let _proxy_handle = mock.spawn();
// Nym transport pointing at the mock proxy.
let (nym_tx, _nym_rx) = packet_channel(32);
let nym_config = NymConfig {
socks5_addr: Some(proxy_addr.to_string()),
startup_timeout_secs: Some(5),
connect_timeout_ms: Some(5000),
..Default::default()
};
let mut nym = NymTransport::new(TransportId::new(200), None, nym_config, nym_tx);
nym.start_async().await.unwrap();
// Send a valid FMP frame through the SOCKS5 (mixnet) path.
let frame = build_msg1_frame();
let target = TransportAddr::from_string(&dest_addr.to_string());
nym.send_async(&target, &frame).await.unwrap();
// It must arrive at the destination, byte-for-byte.
let received = tokio::time::timeout(Duration::from_secs(5), dest_rx.recv())
.await
.expect("timeout waiting for packet")
.expect("channel closed");
assert_eq!(received.data, frame);
nym.stop_async().await.unwrap();
dest.stop_async().await.unwrap();
}
}