mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 16:43:12 +00:00
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.
1142 lines
39 KiB
Rust
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();
|
|
}
|
|
}
|