diff --git a/.gitattributes b/.gitattributes index 43c43ce94a..6dea9476f4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ nym-validator-rewarder/.sqlx/** diff=nodiff nym-node-status-api/nym-node-status-api/.sqlx/** diff=nodiff + +# Use bd merge for beads JSONL files +.beads/beads.jsonl merge=beads diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index a74a141fe6..568cea2b56 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -896,7 +896,8 @@ impl Client { } fn matches_current_host(&self, url: &Url) -> bool { - if cfg!(feature = "tunneling") { + #[cfg(feature = "tunneling")] + { if let Some(ref front) = self.front && front.is_enabled() { @@ -904,7 +905,9 @@ impl Client { } else { url.host_str() == self.current_url().host_str() } - } else { + } + #[cfg(not(feature = "tunneling"))] + { url.host_str() == self.current_url().host_str() } } diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index 260d344ec6..f9109e6641 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -3,8 +3,8 @@ use crate::LpError; use crate::message::{ - ClientHelloData, EncryptedDataPayload, HandshakeData, KKTRequestData, KKTResponseData, - LpMessage, MessageType, + ClientHelloData, EncryptedDataPayload, ForwardPacketData, HandshakeData, KKTRequestData, + KKTResponseData, LpMessage, MessageType, }; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use bytes::BytesMut; @@ -74,6 +74,12 @@ pub fn parse_lp_packet(src: &[u8]) -> Result { // KKT response contains serialized KKTFrame bytes LpMessage::KKTResponse(KKTResponseData(payload_slice.to_vec())) } + MessageType::ForwardPacket => { + // ForwardPacket has structured data + let data: ForwardPacketData = bincode::deserialize(payload_slice) + .map_err(|e| LpError::DeserializationError(e.to_string()))?; + LpMessage::ForwardPacket(data) + } }; // Extract trailer @@ -572,4 +578,44 @@ mod tests { } } } + + #[test] + fn test_forward_packet_encode_decode_roundtrip() { + let mut dst = BytesMut::new(); + + let forward_data = crate::message::ForwardPacketData { + target_gateway_identity: [77u8; 32], + target_lp_address: "1.2.3.4:41264".to_string(), + inner_packet_bytes: vec![0xa, 0xb, 0xc, 0xd], + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + session_id: 999, + counter: 555, + }, + message: LpMessage::ForwardPacket(forward_data), + trailer: [0xff; TRAILER_LEN], + }; + + // Serialize + serialize_lp_packet(&packet, &mut dst).unwrap(); + + // Parse back + let decoded = parse_lp_packet(&dst).unwrap(); + + // Verify LP protocol handling works correctly + assert_eq!(decoded.header.session_id, 999); + assert!(matches!(decoded.message.typ(), MessageType::ForwardPacket)); + + if let LpMessage::ForwardPacket(data) = decoded.message { + assert_eq!(data.target_gateway_identity, [77u8; 32]); + assert_eq!(data.target_lp_address, "1.2.3.4:41264"); + assert_eq!(data.inner_packet_bytes, vec![0xa, 0xb, 0xc, 0xd]); + } else { + panic!("Expected ForwardPacket message"); + } + } } diff --git a/common/nym-lp/src/message.rs b/common/nym-lp/src/message.rs index bbc0ea9904..1f53c2b76d 100644 --- a/common/nym-lp/src/message.rs +++ b/common/nym-lp/src/message.rs @@ -72,6 +72,7 @@ pub enum MessageType { ClientHello = 0x0003, KKTRequest = 0x0004, KKTResponse = 0x0005, + ForwardPacket = 0x0006, } impl MessageType { @@ -98,6 +99,20 @@ pub struct KKTRequestData(pub Vec); #[derive(Debug, Clone, PartialEq, Eq)] pub struct KKTResponseData(pub Vec); +/// Packet forwarding request with embedded inner LP packet +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForwardPacketData { + /// Target gateway's Ed25519 identity (32 bytes) + pub target_gateway_identity: [u8; 32], + + /// Target gateway's LP address (IP:port string) + pub target_lp_address: String, + + /// Complete inner LP packet bytes (serialized LpPacket) + /// This is the CLIENT→EXIT gateway packet, encrypted for exit + pub inner_packet_bytes: Vec, +} + #[derive(Debug, Clone)] pub enum LpMessage { Busy, @@ -106,6 +121,7 @@ pub enum LpMessage { ClientHello(ClientHelloData), KKTRequest(KKTRequestData), KKTResponse(KKTResponseData), + ForwardPacket(ForwardPacketData), } impl Display for LpMessage { @@ -117,6 +133,7 @@ impl Display for LpMessage { LpMessage::ClientHello(_) => write!(f, "ClientHello"), LpMessage::KKTRequest(_) => write!(f, "KKTRequest"), LpMessage::KKTResponse(_) => write!(f, "KKTResponse"), + LpMessage::ForwardPacket(_) => write!(f, "ForwardPacket"), } } } @@ -127,9 +144,10 @@ impl LpMessage { LpMessage::Busy => &[], LpMessage::Handshake(payload) => payload.0.as_slice(), LpMessage::EncryptedData(payload) => payload.0.as_slice(), - LpMessage::ClientHello(_) => unimplemented!(), // Structured data, serialized in encode_content + LpMessage::ClientHello(_) => &[], // Structured data, serialized in encode_content LpMessage::KKTRequest(payload) => payload.0.as_slice(), LpMessage::KKTResponse(payload) => payload.0.as_slice(), + LpMessage::ForwardPacket(_) => &[], // Structured data, serialized in encode_content } } @@ -141,6 +159,7 @@ impl LpMessage { LpMessage::ClientHello(_) => false, // Always has data LpMessage::KKTRequest(payload) => payload.0.is_empty(), LpMessage::KKTResponse(payload) => payload.0.is_empty(), + LpMessage::ForwardPacket(_) => false, // Always has data } } @@ -152,6 +171,9 @@ impl LpMessage { LpMessage::ClientHello(_) => 97, // 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt + 1 byte bincode overhead LpMessage::KKTRequest(payload) => payload.0.len(), LpMessage::KKTResponse(payload) => payload.0.len(), + LpMessage::ForwardPacket(data) => { + 32 + data.target_lp_address.len() + data.inner_packet_bytes.len() + 10 + } } } @@ -163,6 +185,7 @@ impl LpMessage { LpMessage::ClientHello(_) => MessageType::ClientHello, LpMessage::KKTRequest(_) => MessageType::KKTRequest, LpMessage::KKTResponse(_) => MessageType::KKTResponse, + LpMessage::ForwardPacket(_) => MessageType::ForwardPacket, } } @@ -187,6 +210,11 @@ impl LpMessage { LpMessage::KKTResponse(payload) => { dst.put_slice(&payload.0); } + LpMessage::ForwardPacket(data) => { + let serialized = + bincode::serialize(data).expect("Failed to serialize ForwardPacketData"); + dst.put_slice(&serialized); + } } } } diff --git a/docker/localnet/build_topology.py b/docker/localnet/build_topology.py index 88c2bfe813..bdf6f459fc 100644 --- a/docker/localnet/build_topology.py +++ b/docker/localnet/build_topology.py @@ -178,30 +178,31 @@ def create_mixnode_entry(base_dir, mix_id, port_delta, suffix, host_ip): return entry -def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip): +def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip, gateway_name="gateway"): """Create a node_details entry for a gateway""" - debug(f"\n=== Creating gateway entry ===") - gateway_file = Path(base_dir) / "gateway.json" + debug(f"\n=== Creating {gateway_name} entry ===") + gateway_file = Path(base_dir) / f"{gateway_name}.json" debug(f"Reading bonding JSON from: {gateway_file}") with gateway_file.open("r") as json_blob: gateway_data = json.load(json_blob) - node_details = read_node_details("gateway", suffix) + node_details = read_node_details(gateway_name, suffix) # Get identity key from bonding JSON (already byte array) identity = gateway_data.get("identity_key") if not identity: - raise RuntimeError("Missing identity_key in gateway.json") + raise RuntimeError(f"Missing identity_key in {gateway_name}.json") debug(f" ✓ Got identity_key from bonding JSON: {len(identity)} bytes") # Get sphinx key from node-details (decoded from Base58) sphinx_key = node_details.get("sphinx_key") if not sphinx_key: - raise RuntimeError("Missing sphinx_key from node-details for gateway") + raise RuntimeError(f"Missing sphinx_key from node-details for {gateway_name}") host = host_ip mix_port = 10000 + port_delta - clients_port = 9000 + # Calculate clients_port: gateway uses 9000, gateway2 uses 9001, etc. + clients_port = 9000 + (port_delta - 4) debug(f" Using host: {host} (mix:{mix_port}, clients:{clients_port})") entry = { @@ -229,7 +230,7 @@ def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip): def main(args): if not args: - raise SystemExit("Usage: build_topology.py [node_suffix] [mix1_ip] [mix2_ip] [mix3_ip] [gateway_ip]") + raise SystemExit("Usage: build_topology.py [node_suffix] [mix1_ip] [mix2_ip] [mix3_ip] [gateway_ip] [gateway2_ip]") base_dir = args[0] suffix = args[1] if len(args) > 1 and args[1] else DEFAULT_SUFFIX @@ -239,18 +240,20 @@ def main(args): mix2_ip = args[3] if len(args) > 3 else "127.0.0.1" mix3_ip = args[4] if len(args) > 4 else "127.0.0.1" gateway_ip = args[5] if len(args) > 5 else "127.0.0.1" + gateway2_ip = args[6] if len(args) > 6 else "127.0.0.1" debug(f"\n=== Starting topology generation ===") debug(f"Output directory: {base_dir}") debug(f"Node suffix: {suffix}") - debug(f"Container IPs: mix1={mix1_ip}, mix2={mix2_ip}, mix3={mix3_ip}, gateway={gateway_ip}") + debug(f"Container IPs: mix1={mix1_ip}, mix2={mix2_ip}, mix3={mix3_ip}, gateway={gateway_ip}, gateway2={gateway2_ip}") # Create node_details entries with integer keys node_details = { 1: create_mixnode_entry(base_dir, 1, 1, suffix, mix1_ip), 2: create_mixnode_entry(base_dir, 2, 2, suffix, mix2_ip), 3: create_mixnode_entry(base_dir, 3, 3, suffix, mix3_ip), - 4: create_gateway_entry(base_dir, 4, 4, suffix, gateway_ip) + 4: create_gateway_entry(base_dir, 4, 4, suffix, gateway_ip, "gateway"), + 5: create_gateway_entry(base_dir, 5, 5, suffix, gateway2_ip, "gateway2") } # Create the NymTopology structure @@ -262,8 +265,8 @@ def main(args): }, "rewarded_set": { "epoch_id": 0, - "entry_gateways": [4], - "exit_gateways": [4], + "entry_gateways": [4, 5], + "exit_gateways": [4, 5], "layer1": [1], "layer2": [2], "layer3": [3], @@ -279,7 +282,7 @@ def main(args): print(f"✓ Generated topology with {len(node_details)} nodes") print(f" - 3 mixnodes (layers 1, 2, 3)") - print(f" - 1 gateway (entry + exit)") + print(f" - 2 gateways (entry + exit)") debug(f"\n=== Topology generation complete ===\n") diff --git a/docker/localnet/localnet.sh b/docker/localnet/localnet.sh index 03478fa55e..850df6258b 100755 --- a/docker/localnet/localnet.sh +++ b/docker/localnet/localnet.sh @@ -20,6 +20,7 @@ MIXNODE1_CONTAINER="nym-mixnode1" MIXNODE2_CONTAINER="nym-mixnode2" MIXNODE3_CONTAINER="nym-mixnode3" GATEWAY_CONTAINER="nym-gateway" +GATEWAY2_CONTAINER="nym-gateway2" REQUESTER_CONTAINER="nym-network-requester" SOCKS5_CONTAINER="nym-socks5-client" @@ -28,6 +29,7 @@ ALL_CONTAINERS=( "$MIXNODE2_CONTAINER" "$MIXNODE3_CONTAINER" "$GATEWAY_CONTAINER" + "$GATEWAY2_CONTAINER" "$REQUESTER_CONTAINER" "$SOCKS5_CONTAINER" ) @@ -57,7 +59,7 @@ log_error() { cleanup_host_state() { log_info "Cleaning local nym-node state for suffix ${SUFFIX}" - for node in mix1 mix2 mix3 gateway; do + for node in mix1 mix2 mix3 gateway gateway2; do rm -rf "$HOME/.nym/nym-nodes/${node}-${SUFFIX}" done } @@ -283,6 +285,73 @@ start_gateway() { done log_success "Gateway is ready on port 9000" } + +# Start gateway2 +start_gateway2() { + log_info "Starting $GATEWAY2_CONTAINER..." + + container run \ + --name "$GATEWAY2_CONTAINER" \ + -m 2G \ + --network "$NETWORK_NAME" \ + -p 9001:9001 \ + -p 10005:10005 \ + -p 20005:20005 \ + -p 30005:30005 \ + -p 41265:41265 \ + -p 51265:51265 \ + -v "$VOLUME_PATH:/localnet" \ + -v "$NYM_VOLUME_PATH:/root/.nym" \ + -d \ + -e "NYM_NODE_SUFFIX=$SUFFIX" \ + "$IMAGE_NAME" \ + sh -c ' + CONTAINER_IP=$(hostname -i); + echo "Container IP: $CONTAINER_IP"; + echo "Initializing gateway2..."; + nym-node run --id gateway2-localnet --init-only \ + --unsafe-disable-replay-protection \ + --local \ + --mode entry-gateway \ + --mode exit-gateway \ + --mixnet-bind-address=0.0.0.0:10005 \ + --entry-bind-address=0.0.0.0:9001 \ + --verloc-bind-address=0.0.0.0:20005 \ + --http-bind-address=0.0.0.0:30005 \ + --http-access-token=lala \ + --public-ips $CONTAINER_IP \ + --enable-lp true \ + --lp-use-mock-ecash true \ + --output=json \ + --wireguard-enabled true \ + --wireguard-userspace true \ + --bonding-information-output="/localnet/gateway2.json"; + + echo "Waiting for network.json..."; + while [ ! -f /localnet/network.json ]; do + sleep 2; + done; + echo "Starting gateway2 with LP listener (mock ecash)..."; + exec nym-node run --id gateway2-localnet --unsafe-disable-replay-protection --local --wireguard-enabled true --wireguard-userspace true --lp-use-mock-ecash true + ' + + log_success "$GATEWAY2_CONTAINER started" + + # Wait for gateway2 to be ready + log_info "Waiting for gateway2 to listen on port 9001..." + local retries=0 + local max_retries=30 + while ! nc -z 127.0.0.1 9001 2>/dev/null; do + sleep 2 + retries=$((retries + 1)) + if [ $retries -ge $max_retries ]; then + log_error "Gateway2 failed to start on port 9001" + return 1 + fi + done + log_success "Gateway2 is ready on port 9001" +} + # Start network requester start_network_requester() { log_info "Starting $REQUESTER_CONTAINER..." @@ -473,7 +542,7 @@ build_topology() { # Wait for all bonding JSON files to be created log_info "Waiting for all nodes to complete initialization..." - for file in mix1.json mix2.json mix3.json gateway.json; do + for file in mix1.json mix2.json mix3.json gateway.json gateway2.json; do while [ ! -f "$VOLUME_PATH/$file" ]; do echo " Waiting for $file..." sleep 1 @@ -487,12 +556,14 @@ build_topology() { MIX2_IP=$(container exec "$MIXNODE2_CONTAINER" hostname -i) MIX3_IP=$(container exec "$MIXNODE3_CONTAINER" hostname -i) GATEWAY_IP=$(container exec "$GATEWAY_CONTAINER" hostname -i) + GATEWAY2_IP=$(container exec "$GATEWAY2_CONTAINER" hostname -i) log_info "Container IPs:" - echo " mix1: $MIX1_IP" - echo " mix2: $MIX2_IP" - echo " mix3: $MIX3_IP" - echo " gateway: $GATEWAY_IP" + echo " mix1: $MIX1_IP" + echo " mix2: $MIX2_IP" + echo " mix3: $MIX3_IP" + echo " gateway: $GATEWAY_IP" + echo " gateway2: $GATEWAY2_IP" # Run build_topology.py in a container with access to the volumes container run \ @@ -508,7 +579,8 @@ build_topology() { "$MIX1_IP" \ "$MIX2_IP" \ "$MIX3_IP" \ - "$GATEWAY_IP" + "$GATEWAY_IP" \ + "$GATEWAY2_IP" # Verify network.json was created if [ -f "$VOLUME_PATH/network.json" ]; then @@ -532,6 +604,7 @@ start_all() { start_mixnode 2 "$MIXNODE2_CONTAINER" start_mixnode 3 "$MIXNODE3_CONTAINER" start_gateway + start_gateway2 build_topology start_network_requester start_socks5_client diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 3fd487e76a..0aaaf1e26d 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -6,7 +6,7 @@ use super::messages::{LpRegistrationRequest, LpRegistrationResponse}; use super::registration::process_registration; use super::LpHandlerState; use crate::error::GatewayError; -use nym_lp::{keypair::PublicKey, LpMessage, LpPacket, LpSession}; +use nym_lp::{keypair::PublicKey, message::ForwardPacketData, LpMessage, LpPacket, LpSession}; use nym_metrics::{add_histogram_obs, inc}; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -186,6 +186,21 @@ impl LpConnectionHandler { "LP registration successful for {} (session {})", self.remote_addr, response.session_id ); + + // After successful registration, keep connection open for forwarding + info!( + "Entering forwarding mode for {} (session {})", + self.remote_addr, + session.id() + ); + if let Err(e) = self.handle_forwarding_loop(&session).await { + warn!( + "Forwarding loop error for {} (session {}): {}", + self.remote_addr, + session.id(), + e + ); + } } else { warn!( "LP registration failed for {}: {:?}", @@ -363,6 +378,229 @@ impl LpConnectionHandler { self.send_lp_packet(&packet).await } + /// Forward an LP packet to another gateway + /// + /// This method connects to the target gateway, forwards the inner packet bytes, + /// and returns the response. Used for hiding client IP from exit gateway. + /// + /// # Arguments + /// * `forward_data` - ForwardPacketData containing target gateway info and inner packet + /// + /// # Returns + /// * `Ok(Vec)` - Raw response bytes from target gateway + /// * `Err(GatewayError)` - If forwarding fails + async fn handle_forward_packet( + &mut self, + forward_data: ForwardPacketData, + ) -> Result, GatewayError> { + use tokio::time::timeout; + use std::time::Duration; + + inc!("lp_forward_total"); + let start = std::time::Instant::now(); + + // Parse target gateway address + let target_addr: SocketAddr = forward_data.target_lp_address.parse().map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpProtocolError(format!("Invalid target address: {}", e)) + })?; + + // Connect to target gateway with timeout + let mut target_stream = match timeout(Duration::from_secs(5), TcpStream::connect(target_addr)).await { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + inc!("lp_forward_failed"); + return Err(GatewayError::LpConnectionError(format!( + "Failed to connect to target gateway: {}", + e + ))); + } + Err(_) => { + inc!("lp_forward_failed"); + return Err(GatewayError::LpConnectionError( + "Target gateway connection timeout".to_string(), + )); + } + }; + + debug!( + "Forwarding packet to {} (target: {})", + target_addr, forward_data.target_lp_address + ); + + // Forward inner packet bytes (4-byte length prefix + packet data) + let len = forward_data.inner_packet_bytes.len() as u32; + target_stream + .write_all(&len.to_be_bytes()) + .await + .map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to send length to target: {}", e)) + })?; + + target_stream + .write_all(&forward_data.inner_packet_bytes) + .await + .map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to send packet to target: {}", e)) + })?; + + target_stream.flush().await.map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to flush target stream: {}", e)) + })?; + + // Read response from target gateway (4-byte length prefix + packet data) + let mut len_buf = [0u8; 4]; + target_stream.read_exact(&mut len_buf).await.map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to read response length from target: {}", e)) + })?; + + let response_len = u32::from_be_bytes(len_buf) as usize; + + // Sanity check + const MAX_PACKET_SIZE: usize = 65536; + if response_len > MAX_PACKET_SIZE { + inc!("lp_forward_failed"); + return Err(GatewayError::LpProtocolError(format!( + "Response size {} exceeds maximum {}", + response_len, MAX_PACKET_SIZE + ))); + } + + let mut response_buf = vec![0u8; response_len]; + target_stream + .read_exact(&mut response_buf) + .await + .map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to read response from target: {}", e)) + })?; + + // Record metrics + let duration = start.elapsed().as_secs_f64(); + add_histogram_obs!( + "lp_forward_duration_seconds", + duration, + LP_DURATION_BUCKETS + ); + + debug!( + "Forwarding successful to {} ({} bytes response, {:.3}s)", + target_addr, + response_len, + duration + ); + + Ok(response_buf) + } + + /// Handle incoming forwarding requests in a loop + /// + /// After successful registration, the connection stays open to handle + /// ForwardPacket messages. This allows the entry gateway to relay packets + /// to exit gateways, hiding the client's IP address. + /// + /// # Arguments + /// * `session` - The established LP session with the client + /// + /// # Returns + /// * `Ok(())` - When connection closes gracefully + /// * `Err(GatewayError)` - On protocol errors + async fn handle_forwarding_loop(&mut self, session: &LpSession) -> Result<(), GatewayError> { + debug!( + "Entering forwarding loop for {} (session {})", + self.remote_addr, + session.id() + ); + + loop { + // Receive packet from client + let packet = match self.receive_lp_packet().await { + Ok(p) => p, + Err(e) => { + // Connection closed or error - exit loop gracefully + debug!( + "Forwarding loop ended for {} (session {}): {}", + self.remote_addr, + session.id(), + e + ); + return Ok(()); + } + }; + + // Verify session ID + if packet.header().session_id != session.id() { + warn!( + "Session ID mismatch in forwarding loop: expected {}, got {}", + session.id(), + packet.header().session_id + ); + return Err(GatewayError::LpProtocolError(format!( + "Session ID mismatch: expected {}, got {}", + session.id(), + packet.header().session_id + ))); + } + + // Decrypt packet + let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to decrypt forwarding packet: {}", e)) + })?; + + // Deserialize to ForwardPacketData + let forward_request: ForwardPacketData = + bincode::deserialize(&decrypted_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!( + "Failed to deserialize forward request: {}", + e + )) + })?; + + debug!( + "Forwarding request from {} to {}", + self.remote_addr, forward_request.target_lp_address + ); + + // Forward the packet + let response_bytes = match self.handle_forward_packet(forward_request).await { + Ok(bytes) => bytes, + Err(e) => { + warn!( + "Forwarding failed for {}: {}. Continuing loop.", + self.remote_addr, e + ); + // Send error response back to client + // For now, continue the loop - client will retry if needed + continue; + } + }; + + // Encrypt response + let encrypted_msg = session.encrypt_data(&response_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to encrypt response: {}", e)) + })?; + + let response_packet = session.next_packet(encrypted_msg).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) + })?; + + // Send response back to client + if let Err(e) = self.send_lp_packet(&response_packet).await { + warn!( + "Failed to send forwarding response to {}: {}", + self.remote_addr, e + ); + return Err(e); + } + + trace!("Forwarding response sent to {}", self.remote_addr); + } + } + /// Receive an LP packet from the stream with proper length-prefixed framing async fn receive_lp_packet(&mut self) -> Result { use nym_lp::codec::parse_lp_packet; diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 5424386557..d2413be425 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -139,7 +139,7 @@ impl TestedNode { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct TestedNodeDetails { identity: NodeIdentity, exit_router_address: Option, @@ -157,6 +157,8 @@ pub struct Probe { credentials_args: CredentialArgs, /// Pre-queried gateway node (used when --gateway-ip is specified) direct_gateway_node: Option, + /// Pre-queried exit gateway node (used when --exit-gateway-ip is specified for LP forwarding) + exit_gateway_node: Option, } impl Probe { @@ -173,6 +175,7 @@ impl Probe { netstack_args, credentials_args, direct_gateway_node: None, + exit_gateway_node: None, } } @@ -191,6 +194,27 @@ impl Probe { netstack_args, credentials_args, direct_gateway_node: Some(gateway_node), + exit_gateway_node: None, + } + } + + /// Create a probe with both entry and exit gateways pre-queried (for LP forwarding tests) + pub fn new_with_gateways( + entrypoint: NodeIdentity, + tested_node: TestedNode, + netstack_args: NetstackArgs, + credentials_args: CredentialArgs, + entry_gateway_node: DirectoryNode, + exit_gateway_node: DirectoryNode, + ) -> Self { + Self { + entrypoint, + tested_node, + amnezia_args: "".into(), + netstack_args, + credentials_args, + direct_gateway_node: Some(entry_gateway_node), + exit_gateway_node: Some(exit_gateway_node), } } @@ -206,6 +230,7 @@ impl Probe { ignore_egress_epoch_role: bool, only_wireguard: bool, only_lp_registration: bool, + test_lp_wg: bool, min_mixnet_performance: Option, ) -> anyhow::Result { let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?; @@ -234,14 +259,16 @@ impl Probe { let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; self.do_probe_test( - mixnet_client, + Some(mixnet_client), storage, mixnet_entry_gateway_id, node_info, + directory.as_ref(), nyxd_url, tested_entry, only_wireguard, only_lp_registration, + test_lp_wg, false, // Not using mock ecash in regular probe mode ) .await @@ -257,9 +284,46 @@ impl Probe { ignore_egress_epoch_role: bool, only_wireguard: bool, only_lp_registration: bool, + test_lp_wg: bool, min_mixnet_performance: Option, use_mock_ecash: bool, ) -> anyhow::Result { + // If both gateways are pre-queried via --gateway-ip and --exit-gateway-ip, + // skip mixnet setup entirely - we have all the data we need + if self.direct_gateway_node.is_some() && self.exit_gateway_node.is_some() { + let entry_node = self.direct_gateway_node.as_ref().unwrap(); + let exit_node = self.exit_gateway_node.as_ref().unwrap(); + + // Initialize storage (needed for credentials) + if !config_dir.exists() { + std::fs::create_dir_all(config_dir)?; + } + let storage_paths = StoragePaths::new_from_dir(config_dir)?; + let storage = storage_paths + .initialise_default_persistent_storage() + .await?; + + // Get node details from pre-queried nodes + let mixnet_entry_gateway_id = entry_node.identity(); + let node_info = exit_node.to_testable_node()?; + + return self + .do_probe_test( + None, + storage, + mixnet_entry_gateway_id, + node_info, + directory.as_ref(), + nyxd_url, + false, // tested_entry + only_wireguard, + only_lp_registration, + test_lp_wg, + use_mock_ecash, + ) + .await; + } + // If only testing LP registration, use the dedicated LP-only path // This skips mixnet setup entirely and allows testing local gateways if only_lp_registration { @@ -332,14 +396,16 @@ impl Probe { let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; self.do_probe_test( - mixnet_client, + Some(mixnet_client), storage, mixnet_entry_gateway_id, node_info, + directory.as_ref(), nyxd_url, tested_entry, only_wireguard, only_lp_registration, + test_lp_wg, use_mock_ecash, ) .await @@ -476,14 +542,16 @@ impl Probe { #[allow(clippy::too_many_arguments)] pub async fn do_probe_test( &self, - mixnet_client: nym_sdk::Result, + mixnet_client: Option>, storage: T, mixnet_entry_gateway_id: NodeIdentity, node_info: TestedNodeDetails, + directory: Option<&NymApiDirectory>, nyxd_url: Url, tested_entry: bool, only_wireguard: bool, only_lp_registration: bool, + test_lp_wg: bool, use_mock_ecash: bool, ) -> anyhow::Result where @@ -492,8 +560,8 @@ impl Probe { { let mut rng = rand::thread_rng(); let mixnet_client = match mixnet_client { - Ok(mixnet_client) => mixnet_client, - Err(err) => { + Some(Ok(mixnet_client)) => Some(mixnet_client), + Some(Err(err)) => { error!("Failed to connect to mixnet: {err}"); return Ok(ProbeResult { node: node_info.identity.to_string(), @@ -510,45 +578,131 @@ impl Probe { }, }); } + None => None, }; - let nym_address = *mixnet_client.nym_address(); - let entry_gateway = nym_address.gateway().to_base58_string(); + let (outcome, mixnet_client) = if let Some(mixnet_client) = mixnet_client { + let nym_address = *mixnet_client.nym_address(); + let entry_gateway = nym_address.gateway().to_base58_string(); - info!("Successfully connected to entry gateway: {entry_gateway}"); - info!("Our nym address: {nym_address}"); + info!("Successfully connected to entry gateway: {entry_gateway}"); + info!("Our nym address: {nym_address}"); - // Now that we have a connected mixnet client, we can start pinging - let (outcome, mixnet_client) = if only_wireguard || only_lp_registration { - ( - Ok(ProbeOutcome { - as_entry: if tested_entry { - Entry::success() - } else { - Entry::NotTested - }, - as_exit: None, - wg: None, - lp: None, - }), - mixnet_client, - ) + // Now that we have a connected mixnet client, we can start pinging + let (outcome, mixnet_client) = if only_wireguard || only_lp_registration { + ( + Ok(ProbeOutcome { + as_entry: if tested_entry { + Entry::success() + } else { + Entry::NotTested + }, + as_exit: None, + wg: None, + lp: None, + }), + mixnet_client, + ) + } else { + do_ping( + mixnet_client, + nym_address, + node_info.exit_router_address, + tested_entry, + ) + .await + }; + (outcome, Some(mixnet_client)) + } else if test_lp_wg { + // No mixnet client needed for LP-WG test with pre-queried nodes + // Create default outcome and continue to LP-WG test below + (Ok(ProbeOutcome { + as_entry: Entry::NotTested, + as_exit: None, + wg: None, + lp: None, + }), None) } else { - do_ping( - mixnet_client, - nym_address, - node_info.exit_router_address, - tested_entry, - ) - .await + // For non-LP-WG modes, missing mixnet client is a failure + (Ok(ProbeOutcome { + as_entry: if tested_entry { + Entry::fail_to_connect() + } else { + Entry::EntryFailure + }, + as_exit: None, + wg: None, + lp: None, + }), None) }; let wg_outcome = if only_lp_registration { // Skip WireGuard test when only testing LP registration WgProbeResults::default() + } else if test_lp_wg { + // Test WireGuard via LP registration (nested session forwarding) + info!("Testing WireGuard via LP registration (no mixnet)"); + + // Create bandwidth controller for LP registration + let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( + &NymNetworkDetails::new_from_env(), + )?; + let client = + nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; + let bw_controller = nym_bandwidth_controller::BandwidthController::new( + storage.credential_store().clone(), + client, + ); + + // Determine entry and exit gateways + let (entry_gateway, exit_gateway) = if let Some(exit_node) = &self.exit_gateway_node { + // Both entry and exit gateways were pre-queried (direct IP mode) + info!("Using pre-queried entry and exit gateways for LP forwarding test"); + let entry_node = self + .direct_gateway_node + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Entry gateway not available"))?; + + let entry_gateway = entry_node.to_testable_node()?; + let exit_gateway = exit_node.to_testable_node()?; + + (entry_gateway, exit_gateway) + } else { + // Original behavior: query from directory + // The tested node is the exit + let exit_gateway = node_info.clone(); + + let directory = directory + .ok_or_else(|| anyhow::anyhow!("Directory is required for LP-WG test mode"))?; + let entry_gateway_node = directory.entry_gateway(&mixnet_entry_gateway_id)?; + let entry_gateway = entry_gateway_node.to_testable_node()?; + + (entry_gateway, exit_gateway) + }; + + wg_probe_lp( + &entry_gateway, + &exit_gateway, + &bw_controller, + storage.credential_store().clone(), + use_mock_ecash, + self.amnezia_args.clone(), + self.netstack_args.clone(), + ) + .await + .unwrap_or_default() } else if let (Some(authenticator), Some(ip_address)) = (node_info.authenticator_address, node_info.ip_address) { + let mixnet_client = if let Some(mixnet_client) = mixnet_client { + mixnet_client + } else { + bail!( + "Mixnet client is required for authenticator WireGuard probe, run in LP mode instead" + ); + }; + + let nym_address = *mixnet_client.nym_address(); // Start the mixnet listener that the auth clients use to receive messages. let mixnet_listener_task = AuthClientMixnetListener::new(mixnet_client, CancellationToken::new()).start(); @@ -595,7 +749,6 @@ impl Probe { outcome } else { - mixnet_client.disconnect().await; WgProbeResults::default() }; @@ -983,6 +1136,249 @@ where Ok(lp_outcome) } +/// LP-based WireGuard probe: Tests LP nested session registration + WireGuard tunnel connectivity +/// +/// This function tests the full VPN flow using LP registration instead of mixnet+authenticator: +/// 1. Connects to entry gateway (outer LP session) +/// 2. Registers with exit gateway via entry forwarding (nested LP session) +/// 3. Receives WireGuard configuration from both gateways +/// 4. Tests WireGuard tunnel connectivity (IPv4/IPv6) +/// +/// This validates that IP hiding works (exit sees entry IP, not client IP) and that the +/// full VPN tunnel operates correctly after LP registration. +async fn wg_probe_lp( + entry_gateway: &TestedNodeDetails, + exit_gateway: &TestedNodeDetails, + bandwidth_controller: &nym_bandwidth_controller::BandwidthController< + nym_validator_client::nyxd::NyxdClient, + St, + >, + _storage: St, + use_mock_ecash: bool, + awg_args: String, + netstack_args: NetstackArgs, +) -> anyhow::Result +where + St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, + ::StorageError: Send + Sync, +{ + use nym_crypto::asymmetric::{ed25519, x25519}; + use nym_registration_client::{LpRegistrationClient, NestedLpSession}; + + info!("Starting LP-based WireGuard probe (entry→exit via forwarding)"); + + let mut wg_outcome = WgProbeResults::default(); + + // Validate that both gateways have required information + let entry_lp_address = entry_gateway + .lp_address + .ok_or_else(|| anyhow::anyhow!("Entry gateway missing LP address"))?; + let exit_lp_address = exit_gateway + .lp_address + .ok_or_else(|| anyhow::anyhow!("Exit gateway missing LP address"))?; + let entry_ip = entry_gateway + .ip_address + .ok_or_else(|| anyhow::anyhow!("Entry gateway missing IP address"))?; + let exit_ip = exit_gateway + .ip_address + .ok_or_else(|| anyhow::anyhow!("Exit gateway missing IP address"))?; + + // Generate Ed25519 keypairs for LP protocol + let mut rng = rand::thread_rng(); + let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + + // Generate WireGuard keypairs for VPN registration + let entry_wg_keypair = x25519::KeyPair::new(&mut rng); + let exit_wg_keypair = x25519::KeyPair::new(&mut rng); + + // STEP 1: Establish outer LP session with entry gateway + info!("Connecting to entry gateway via LP..."); + let mut entry_client = LpRegistrationClient::new_with_default_psk( + entry_lp_keypair, + entry_gateway.identity, + entry_lp_address, + entry_ip, + ); + + // Connect to entry gateway + if let Err(e) = entry_client.connect().await { + error!("Failed to connect to entry gateway: {}", e); + return Ok(wg_outcome); + } + + // Perform handshake with entry gateway + if let Err(e) = entry_client.perform_handshake().await { + error!("Failed to handshake with entry gateway: {}", e); + return Ok(wg_outcome); + } + info!("Outer LP session with entry gateway established"); + + // STEP 2: Use nested session to register with exit gateway via forwarding + info!("Registering with exit gateway via entry forwarding..."); + let mut nested_session = NestedLpSession::new( + exit_gateway.identity.to_bytes(), + exit_lp_address.to_string(), + exit_lp_keypair, + ed25519::PublicKey::from_bytes(&exit_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid exit gateway identity: {}", e))?, + ); + + // Convert exit gateway identity to ed25519 public key for registration + let exit_gateway_pubkey = ed25519::PublicKey::from_bytes(&exit_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid exit gateway identity: {}", e))?; + + // Perform handshake and registration with exit gateway via forwarding + if use_mock_ecash { + info!("Note: Using mock ecash mode - gateways must be started with --lp-use-mock-ecash"); + } + let exit_gateway_data = match nested_session + .handshake_and_register( + &mut entry_client, + &exit_wg_keypair, + &exit_gateway_pubkey, + bandwidth_controller, + TicketType::V1WireguardExit, + exit_ip, + ) + .await + { + Ok(data) => data, + Err(e) => { + error!("Failed to register with exit gateway: {}", e); + return Ok(wg_outcome); + } + }; + info!("Exit gateway registration successful via forwarding"); + + // STEP 3: Register with entry gateway + info!("Registering with entry gateway..."); + let entry_gateway_pubkey = + ed25519::PublicKey::from_bytes(&entry_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid entry gateway identity: {}", e))?; + + if let Err(e) = entry_client + .send_registration_request( + &entry_wg_keypair, + &entry_gateway_pubkey, + bandwidth_controller, + TicketType::V1WireguardEntry, + ) + .await + { + error!("Failed to send entry registration request: {}", e); + return Ok(wg_outcome); + } + + let _entry_gateway_data = match entry_client.receive_registration_response().await { + Ok(data) => data, + Err(e) => { + error!("Failed to receive entry registration response: {}", e); + return Ok(wg_outcome); + } + }; + info!("Entry gateway registration successful"); + + info!("LP registration successful for both gateways!"); + wg_outcome.can_register = true; + + // STEP 4: Test WireGuard tunnels using exit gateway configuration + // Convert keys to hex for netstack + let private_key_hex = hex::encode(exit_wg_keypair.private_key().to_bytes()); + let public_key_hex = hex::encode(exit_gateway_data.public_key.to_bytes()); + + // Build WireGuard endpoint address + let wg_endpoint = format!("{}:{}", exit_ip, exit_gateway_data.endpoint.port()); + + info!("Exit WireGuard configuration:"); + info!(" Private IPv4: {}", exit_gateway_data.private_ipv4); + info!(" Private IPv6: {}", exit_gateway_data.private_ipv6); + info!(" Endpoint: {}", wg_endpoint); + + // Run tunnel tests (copied from wg_probe) + let netstack_request = crate::netstack::NetstackRequest::new( + &exit_gateway_data.private_ipv4.to_string(), + &exit_gateway_data.private_ipv6.to_string(), + &private_key_hex, + &public_key_hex, + &wg_endpoint, + &format!("http://{WG_TUN_DEVICE_IP_ADDRESS_V4}:{WG_METADATA_PORT}"), + netstack_args.netstack_download_timeout_sec, + &awg_args, + netstack_args, + ); + + // Perform IPv4 ping test + info!("Testing IPv4 tunnel connectivity..."); + let ipv4_request = crate::netstack::NetstackRequestGo::from_rust_v4(&netstack_request); + + match crate::netstack::ping(&ipv4_request) { + Ok(NetstackResult::Response(netstack_response_v4)) => { + info!( + "Wireguard probe response for IPv4: {:#?}", + netstack_response_v4 + ); + wg_outcome.can_query_metadata_v4 = netstack_response_v4.can_query_metadata; + wg_outcome.can_handshake_v4 = netstack_response_v4.can_handshake; + wg_outcome.can_resolve_dns_v4 = netstack_response_v4.can_resolve_dns; + wg_outcome.ping_hosts_performance_v4 = + netstack_response_v4.received_hosts as f32 / netstack_response_v4.sent_hosts as f32; + wg_outcome.ping_ips_performance_v4 = + netstack_response_v4.received_ips as f32 / netstack_response_v4.sent_ips as f32; + + wg_outcome.download_duration_sec_v4 = netstack_response_v4.download_duration_sec; + wg_outcome.download_duration_milliseconds_v4 = + netstack_response_v4.download_duration_milliseconds; + wg_outcome.downloaded_file_size_bytes_v4 = + netstack_response_v4.downloaded_file_size_bytes; + wg_outcome.downloaded_file_v4 = netstack_response_v4.downloaded_file; + wg_outcome.download_error_v4 = netstack_response_v4.download_error; + } + Ok(NetstackResult::Error { error }) => { + error!("Netstack runtime error (IPv4): {error}") + } + Err(error) => { + error!("Internal error (IPv4): {error}") + } + } + + // Perform IPv6 ping test + info!("Testing IPv6 tunnel connectivity..."); + let ipv6_request = crate::netstack::NetstackRequestGo::from_rust_v6(&netstack_request); + + match crate::netstack::ping(&ipv6_request) { + Ok(NetstackResult::Response(netstack_response_v6)) => { + info!( + "Wireguard probe response for IPv6: {:#?}", + netstack_response_v6 + ); + wg_outcome.can_handshake_v6 = netstack_response_v6.can_handshake; + wg_outcome.can_resolve_dns_v6 = netstack_response_v6.can_resolve_dns; + wg_outcome.ping_hosts_performance_v6 = + netstack_response_v6.received_hosts as f32 / netstack_response_v6.sent_hosts as f32; + wg_outcome.ping_ips_performance_v6 = + netstack_response_v6.received_ips as f32 / netstack_response_v6.sent_ips as f32; + + wg_outcome.download_duration_sec_v6 = netstack_response_v6.download_duration_sec; + wg_outcome.download_duration_milliseconds_v6 = + netstack_response_v6.download_duration_milliseconds; + wg_outcome.downloaded_file_size_bytes_v6 = + netstack_response_v6.downloaded_file_size_bytes; + wg_outcome.downloaded_file_v6 = netstack_response_v6.downloaded_file; + wg_outcome.download_error_v6 = netstack_response_v6.download_error; + } + Ok(NetstackResult::Error { error }) => { + error!("Netstack runtime error (IPv6): {error}") + } + Err(error) => { + error!("Internal error (IPv6): {error}") + } + } + + info!("LP-based WireGuard probe completed"); + Ok(wg_outcome) +} + fn mixnet_debug_config( min_gateway_performance: Option, ignore_egress_epoch_role: bool, diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index f293733d2e..05cffed838 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -42,6 +42,12 @@ struct CliArgs { #[arg(long, global = true)] gateway_ip: Option, + /// The address of the exit gateway for LP forwarding tests (used with --test-lp-wg) + /// When specified, --gateway-ip becomes the entry gateway and this becomes the exit gateway + /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) + #[arg(long, global = true)] + exit_gateway_ip: Option, + /// Identity of the node to test #[arg(long, short, value_parser = validate_node_identity, global = true)] node: Option, @@ -58,6 +64,10 @@ struct CliArgs { #[arg(long, global = true)] only_lp_registration: bool, + /// Test WireGuard via LP registration (no mixnet) - uses nested session forwarding + #[arg(long, global = true)] + test_lp_wg: bool, + /// Disable logging during probe #[arg(long, global = true)] ignore_egress_epoch_role: bool, @@ -129,11 +139,19 @@ pub(crate) async fn run() -> anyhow::Result { .map(|ep| ep.nyxd_url()) .ok_or(anyhow::anyhow!("missing nyxd url"))?; // If gateway IP is provided, query it directly without using the directory - let (entry, directory, gateway_node) = if let Some(gateway_ip) = args.gateway_ip { + let (entry, directory, gateway_node, exit_gateway_node) = if let Some(gateway_ip) = args.gateway_ip { info!("Using direct IP query mode for gateway: {}", gateway_ip); let gateway_node = query_gateway_by_ip(gateway_ip).await?; let identity = gateway_node.identity(); + // Query exit gateway if provided (for LP forwarding tests) + let exit_node = if let Some(exit_gateway_ip) = args.exit_gateway_ip { + info!("Using direct IP query mode for exit gateway: {}", exit_gateway_ip); + Some(query_gateway_by_ip(exit_gateway_ip).await?) + } else { + None + }; + // Still create the directory for potential secondary lookups, // but only if API URL is available let directory = if let Some(api_url) = network.endpoints.first().and_then(|ep| ep.api_url()) @@ -143,7 +161,7 @@ pub(crate) async fn run() -> anyhow::Result { None }; - (identity, directory, Some(gateway_node)) + (identity, directory, Some(gateway_node), exit_node) } else { // Original behavior: use directory service let api_url = network @@ -160,7 +178,7 @@ pub(crate) async fn run() -> anyhow::Result { directory.random_exit_with_ipr()? }; - (entry, Some(directory), None) + (entry, Some(directory), None, None) }; let test_point = if let Some(node) = args.node { @@ -169,7 +187,19 @@ pub(crate) async fn run() -> anyhow::Result { TestedNode::SameAsEntry }; - let mut trial = if let Some(gw_node) = gateway_node { + let mut trial = if let (Some(entry_node), Some(exit_node)) = (&gateway_node, &exit_gateway_node) { + // Both entry and exit gateways provided (for LP telescoping tests) + info!("Using both entry and exit gateways for LP forwarding test"); + nym_gateway_probe::Probe::new_with_gateways( + entry, + test_point, + args.netstack_args, + args.credential_args, + entry_node.clone(), + exit_node.clone(), + ) + } else if let Some(gw_node) = gateway_node { + // Only entry gateway provided nym_gateway_probe::Probe::new_with_gateway( entry, test_point, @@ -178,6 +208,7 @@ pub(crate) async fn run() -> anyhow::Result { gw_node, ) } else { + // No direct gateways, use directory lookup nym_gateway_probe::Probe::new(entry, test_point, args.netstack_args, args.credential_args) }; @@ -208,6 +239,7 @@ pub(crate) async fn run() -> anyhow::Result { args.ignore_egress_epoch_role, args.only_wireguard, args.only_lp_registration, + args.test_lp_wg, args.min_gateway_mixnet_performance, *use_mock_ecash, )) @@ -220,6 +252,7 @@ pub(crate) async fn run() -> anyhow::Result { args.ignore_egress_epoch_role, args.only_wireguard, args.only_lp_registration, + args.test_lp_wg, args.min_gateway_mixnet_performance, )) .await diff --git a/nym-registration-client/src/lib.rs b/nym-registration-client/src/lib.rs index 05d5ceeb7d..7e7313578b 100644 --- a/nym-registration-client/src/lib.rs +++ b/nym-registration-client/src/lib.rs @@ -12,7 +12,6 @@ use nym_sdk::mixnet::{EventReceiver, MixnetClient, Recipient}; use std::sync::Arc; use crate::config::RegistrationClientConfig; -use crate::lp_client::{LpClientError, LpTransport}; mod builder; mod config; @@ -27,7 +26,7 @@ pub use builder::config::{ }; pub use config::RegistrationMode; pub use error::RegistrationClientError; -pub use lp_client::{LpConfig, LpRegistrationClient}; +pub use lp_client::{LpConfig, LpRegistrationClient, NestedLpSession}; pub use types::{ LpRegistrationResult, MixnetRegistrationResult, RegistrationResult, WireguardRegistrationResult, }; @@ -153,6 +152,8 @@ impl RegistrationClient { } async fn register_lp(self) -> Result { + use crate::lp_client::{LpRegistrationClient, NestedLpSession}; + // Extract and validate LP addresses let entry_lp_address = self.config.entry.node.lp_address.ok_or( RegistrationClientError::LpRegistrationNotPossible { @@ -176,118 +177,102 @@ impl RegistrationClient { let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); - // Register entry gateway via LP - let entry_fut = { - let bandwidth_controller = &self.bandwidth_controller; - let entry_keys = self.config.entry.keys.clone(); - let entry_identity = self.config.entry.node.identity; - let entry_ip = self.config.entry.node.ip_address; - let entry_lp_keys = entry_lp_keypair.clone(); + // STEP 1: Establish outer session with entry gateway + // This creates the LP connection that will be used to forward packets to exit + tracing::info!("Establishing outer session with entry gateway"); + let mut entry_client = LpRegistrationClient::new_with_default_psk( + entry_lp_keypair.clone(), + self.config.entry.node.identity, + entry_lp_address, + self.config.entry.node.ip_address, + ); - async move { - let mut client = LpRegistrationClient::new_with_default_psk( - entry_lp_keys, - entry_identity, - entry_lp_address, - entry_ip, - ); - - // Connect - client.connect().await?; - - // Perform handshake - client.perform_handshake().await?; - - // Send registration request - client - .send_registration_request( - &entry_keys, - &entry_identity, - &**bandwidth_controller, - TicketType::V1WireguardEntry, - ) - .await?; - - // Receive registration response - let gateway_data = client.receive_registration_response().await?; - - // Convert to transport for ongoing communication - let transport = client.into_transport()?; - - Ok::<(LpTransport, _), LpClientError>((transport, gateway_data)) - } - }; - - // Register exit gateway via LP - let exit_fut = { - let bandwidth_controller = &self.bandwidth_controller; - let exit_keys = self.config.exit.keys.clone(); - let exit_identity = self.config.exit.node.identity; - let exit_ip = self.config.exit.node.ip_address; - let exit_lp_keys = exit_lp_keypair; - - async move { - let mut client = LpRegistrationClient::new_with_default_psk( - exit_lp_keys, - exit_identity, - exit_lp_address, - exit_ip, - ); - - // Connect - client.connect().await?; - - // Perform handshake - client.perform_handshake().await?; - - // Send registration request - client - .send_registration_request( - &exit_keys, - &exit_identity, - &**bandwidth_controller, - TicketType::V1WireguardExit, - ) - .await?; - - // Receive registration response - let gateway_data = client.receive_registration_response().await?; - - // Convert to transport for ongoing communication - let transport = client.into_transport()?; - - Ok::<(LpTransport, _), LpClientError>((transport, gateway_data)) - } - }; - - // Execute registrations in parallel - let (entry_result, exit_result) = - Box::pin(async { tokio::join!(entry_fut, exit_fut) }).await; - - // Handle entry gateway result - // Note: entry_transport is dropped here, closing the LP connection - let (_entry_transport, entry_gateway_data) = - entry_result.map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + // Connect to entry gateway + entry_client + .connect() + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { gateway_id: self.config.entry.node.identity.to_base58_string(), lp_address: entry_lp_address, source: Box::new(source), })?; - // Handle exit gateway result - // Note: exit_transport is dropped here, closing the LP connection - let (_exit_transport, exit_gateway_data) = - exit_result.map_err(|source| RegistrationClientError::ExitGatewayRegisterLp { + // Perform handshake with entry gateway (outer session now established) + entry_client + .perform_handshake() + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + gateway_id: self.config.entry.node.identity.to_base58_string(), + lp_address: entry_lp_address, + source: Box::new(source), + })?; + + tracing::info!("Outer session with entry gateway established"); + + // STEP 2: Use nested session to register with exit gateway via forwarding + // This hides the client's IP address from the exit gateway + tracing::info!("Registering with exit gateway via entry forwarding"); + let mut nested_session = NestedLpSession::new( + self.config.exit.node.identity.to_bytes(), + exit_lp_address.to_string(), + exit_lp_keypair, + self.config.exit.node.identity, + ); + + // Perform handshake and registration with exit gateway (all via entry forwarding) + let exit_gateway_data = nested_session + .handshake_and_register( + &mut entry_client, + &self.config.exit.keys, + &self.config.exit.node.identity, + &*self.bandwidth_controller, + TicketType::V1WireguardExit, + self.config.exit.node.ip_address, + ) + .await + .map_err(|source| RegistrationClientError::ExitGatewayRegisterLp { gateway_id: self.config.exit.node.identity.to_base58_string(), lp_address: exit_lp_address, source: Box::new(source), })?; + tracing::info!("Exit gateway registration completed via forwarding"); + + // STEP 3: Send registration request to entry gateway + tracing::info!("Sending registration request to entry gateway"); + entry_client + .send_registration_request( + &self.config.entry.keys, + &self.config.entry.node.identity, + &*self.bandwidth_controller, + TicketType::V1WireguardEntry, + ) + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + gateway_id: self.config.entry.node.identity.to_base58_string(), + lp_address: entry_lp_address, + source: Box::new(source), + })?; + + // Receive registration response from entry + let entry_gateway_data = entry_client + .receive_registration_response() + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + gateway_id: self.config.entry.node.identity.to_base58_string(), + lp_address: entry_lp_address, + source: Box::new(source), + })?; + + tracing::info!("Entry gateway registration successful"); + tracing::info!( "LP registration successful for both gateways (LP connections will be closed)" ); // LP is registration-only. All data flows through WireGuard after this point. - // The LP transports have been dropped, automatically closing TCP connections. + // The entry LP connection will be dropped, automatically closing TCP connection. + // Exit registration was completed via forwarding through entry, so no direct connection exists. Ok(RegistrationResult::Lp(Box::new(LpRegistrationResult { entry_gateway_data, exit_gateway_data, diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index e541a58766..ffd40d59ad 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -12,6 +12,7 @@ use nym_credentials_interface::{CredentialSpendingData, TicketType}; use nym_crypto::asymmetric::{ed25519, x25519}; use nym_lp::LpPacket; use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::message::ForwardPacketData; use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; use nym_wireguard_types::PeerPublicKey; @@ -734,6 +735,140 @@ impl LpRegistrationClient { Ok(gateway_data) } + /// Sends a ForwardPacket message to the entry gateway for forwarding to the exit gateway. + /// + /// This method constructs a ForwardPacket containing the target gateway's identity, + /// address, and the inner LP packet bytes, encrypts it through the outer session + /// (client-entry), and receives the response from the exit gateway via the entry gateway. + /// + /// # Arguments + /// * `target_identity` - Target gateway's Ed25519 identity (32 bytes) + /// * `target_address` - Target gateway's LP address (e.g., "1.1.1.1:41264") + /// * `inner_packet_bytes` - Complete inner LP packet bytes to forward to exit gateway + /// + /// # Returns + /// * `Ok(Vec)` - Decrypted response bytes from the exit gateway + /// + /// # Errors + /// Returns an error if: + /// - No connection is established + /// - Handshake has not been completed + /// - Serialization fails + /// - Encryption or network transmission fails + /// - Response decryption fails + /// + /// # Example Flow + /// ```ignore + /// // Construct inner packet for exit gateway (ClientHello, handshake, etc.) + /// let inner_packet = LpPacket::new(...); + /// let inner_bytes = serialize_lp_packet(&inner_packet, &mut BytesMut::new())?; + /// + /// // Forward through entry gateway + /// let response_bytes = client.send_forward_packet( + /// exit_identity, + /// "2.2.2.2:41264".to_string(), + /// inner_bytes.to_vec(), + /// ).await?; + /// ``` + pub async fn send_forward_packet( + &mut self, + target_identity: [u8; 32], + target_address: String, + inner_packet_bytes: Vec, + ) -> Result> { + // Ensure we have a TCP connection + let stream = self.tcp_stream.as_mut().ok_or_else(|| { + LpClientError::Transport("Cannot send forward packet: not connected".to_string()) + })?; + + // Ensure handshake is complete (state machine exists and is in Transport state) + let state_machine = self.state_machine.as_mut().ok_or_else(|| { + LpClientError::Transport( + "Cannot send forward packet: handshake not completed".to_string(), + ) + })?; + + tracing::debug!( + "Sending ForwardPacket to {} ({} inner bytes)", + target_address, + inner_packet_bytes.len() + ); + + // 1. Construct ForwardPacketData + let forward_data = ForwardPacketData { + target_gateway_identity: target_identity, + target_lp_address: target_address.clone(), + inner_packet_bytes, + }; + + // 2. Serialize the ForwardPacketData + let forward_data_bytes = bincode::serialize(&forward_data).map_err(|e| { + LpClientError::Transport(format!("Failed to serialize ForwardPacketData: {}", e)) + })?; + + tracing::trace!( + "Serialized ForwardPacketData ({} bytes)", + forward_data_bytes.len() + ); + + // 3. Encrypt and prepare packet via state machine + let action = state_machine + .process_input(LpInput::SendData(forward_data_bytes)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!("Failed to encrypt ForwardPacket: {}", e)) + })?; + + // 4. Send the encrypted packet + match action { + LpAction::SendPacket(packet) => { + Self::send_packet(stream, &packet).await?; + tracing::trace!("Sent encrypted ForwardPacket to entry gateway"); + } + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when sending ForwardPacket: {:?}", + other + ))); + } + } + + // 5. Receive the response from entry gateway + let response_packet = Self::receive_packet(stream).await?; + tracing::trace!("Received response packet from entry gateway"); + + // 6. Decrypt via state machine + let action = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!("Failed to decrypt forward response: {}", e)) + })?; + + // 7. Extract decrypted response data + let response_data = match action { + LpAction::DeliverData(data) => data, + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when receiving forward response: {:?}", + other + ))); + } + }; + + tracing::debug!( + "Successfully received forward response from {} ({} bytes)", + target_address, + response_data.len() + ); + + Ok(response_data.to_vec()) + } + /// Converts this client into an LpTransport for ongoing post-handshake communication. /// /// This consumes the client and transfers ownership of the TCP stream and state machine diff --git a/nym-registration-client/src/lp_client/mod.rs b/nym-registration-client/src/lp_client/mod.rs index 6a145fdaca..7be34c37ae 100644 --- a/nym-registration-client/src/lp_client/mod.rs +++ b/nym-registration-client/src/lp_client/mod.rs @@ -33,9 +33,10 @@ mod client; mod config; mod error; +mod nested_session; mod transport; pub use client::LpRegistrationClient; pub use config::LpConfig; pub use error::LpClientError; -pub use transport::LpTransport; +pub use nested_session::NestedLpSession; diff --git a/nym-registration-client/src/lp_client/nested_session.rs b/nym-registration-client/src/lp_client/nested_session.rs new file mode 100644 index 0000000000..a2a4536af5 --- /dev/null +++ b/nym-registration-client/src/lp_client/nested_session.rs @@ -0,0 +1,543 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Nested LP session for client-exit handshake through entry gateway forwarding. +//! +//! This module implements the inner LP session management where a client establishes +//! a secure connection with an exit gateway by forwarding LP packets through an +//! entry gateway. This hides the client's IP address from the exit gateway. +//! +//! # Architecture +//! +//! ```text +//! Client ←→ Entry Gateway (outer session, encrypted) +//! ↓ forwards +//! Exit Gateway (inner session, client establishes handshake) +//! ``` +//! +//! The entry gateway sees the client's IP but doesn't know the final destination. +//! The exit gateway processes the LP handshake but only sees the entry gateway's IP. + +use super::client::LpRegistrationClient; +use super::error::{LpClientError, Result}; +use bytes::BytesMut; +use nym_bandwidth_controller::BandwidthTicketProvider; +use nym_credentials_interface::TicketType; +use nym_crypto::asymmetric::{ed25519, x25519}; +use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; +use nym_lp::{LpMessage, LpPacket}; +use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; +use nym_wireguard_types::PeerPublicKey; +use std::net::IpAddr; +use std::sync::Arc; + +/// Manages a nested LP session where the client establishes a handshake with +/// an exit gateway by forwarding packets through an entry gateway. +/// +/// # Example +/// +/// ```ignore +/// // Outer session already established with entry gateway +/// let mut outer_client = LpRegistrationClient::new(...); +/// outer_client.connect().await?; +/// outer_client.perform_handshake().await?; +/// +/// // Now establish inner session with exit gateway +/// let nested = NestedLpSession::new( +/// exit_identity, +/// "2.2.2.2:41264".to_string(), +/// client_keypair, +/// exit_public_key, +/// ); +/// +/// let exit_session = nested.perform_handshake(&mut outer_client).await?; +/// ``` +pub struct NestedLpSession { + /// Exit gateway's Ed25519 identity (32 bytes) + exit_identity: [u8; 32], + + /// Exit gateway's LP address (e.g., "2.2.2.2:41264") + exit_address: String, + + /// Client's Ed25519 keypair (for PSQ authentication and X25519 derivation) + client_keypair: Arc, + + /// Exit gateway's Ed25519 public key + exit_public_key: ed25519::PublicKey, + + /// LP state machine for exit gateway session (populated after handshake) + state_machine: Option, +} + +impl NestedLpSession { + /// Creates a new nested LP session handler. + /// + /// # Arguments + /// * `exit_identity` - Exit gateway's Ed25519 identity (32 bytes) + /// * `exit_address` - Exit gateway's LP address (e.g., "2.2.2.2:41264") + /// * `client_keypair` - Client's Ed25519 keypair + /// * `exit_public_key` - Exit gateway's Ed25519 public key + pub fn new( + exit_identity: [u8; 32], + exit_address: String, + client_keypair: Arc, + exit_public_key: ed25519::PublicKey, + ) -> Self { + Self { + exit_identity, + exit_address, + client_keypair, + exit_public_key, + state_machine: None, + } + } + + /// Performs the LP handshake with the exit gateway by forwarding packets + /// through the entry gateway. + /// + /// This method: + /// 1. Generates ClientHello for exit gateway + /// 2. Creates LP state machine for exit handshake + /// 3. Runs handshake loop, forwarding all packets through entry gateway + /// 4. Stores established session in internal state machine + /// + /// # Arguments + /// * `outer_client` - Connected LP client with established outer session to entry gateway + /// + /// # Errors + /// Returns an error if: + /// - Packet serialization/parsing fails + /// - Forwarding through entry gateway fails + /// - Exit gateway handshake fails + /// - Cryptographic operations fail + async fn perform_handshake( + &mut self, + outer_client: &mut LpRegistrationClient, + ) -> Result<()> { + tracing::debug!( + "Starting nested LP handshake with exit gateway {}", + self.exit_address + ); + + // Step 1: Derive X25519 keys from Ed25519 for Noise protocol + let client_x25519_public = self + .client_keypair + .public_key() + .to_x25519() + .map_err(|e| { + LpClientError::Crypto(format!("Failed to derive X25519 public key: {}", e)) + })?; + + // Step 2: Generate ClientHello for exit gateway + let client_hello_data = nym_lp::ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + self.client_keypair.public_key().to_bytes(), + ); + let salt = client_hello_data.salt; + + tracing::trace!( + "Generated ClientHello for exit gateway (timestamp: {})", + client_hello_data.extract_timestamp() + ); + + // Step 3: Send ClientHello to exit gateway via forwarding + let client_hello_header = nym_lp::packet::LpHeader::new( + 0, // session_id not yet established + 0, // counter starts at 0 + ); + let client_hello_packet = nym_lp::LpPacket::new( + client_hello_header, + LpMessage::ClientHello(client_hello_data), + ); + + // Serialize and forward ClientHello + let client_hello_bytes = Self::serialize_packet(&client_hello_packet)?; + let _response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + client_hello_bytes, + ) + .await?; + + tracing::debug!("Sent ClientHello to exit gateway via entry"); + + // Step 4: Create state machine for exit gateway handshake + let mut state_machine = LpStateMachine::new( + true, // is_initiator + ( + self.client_keypair.private_key(), + self.client_keypair.public_key(), + ), + &self.exit_public_key, + &salt, + )?; + + // Step 5: Start handshake - send initial handshake packet + if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { + match action? { + LpAction::SendPacket(packet) => { + tracing::trace!("Sending initial handshake packet to exit"); + let packet_bytes = Self::serialize_packet(&packet)?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Parse response and feed to state machine + let response_packet = Self::parse_packet(&response_bytes)?; + tracing::trace!("Received handshake response from exit"); + + // Process response through state machine + if let Some(action) = + state_machine.process_input(LpInput::ReceivePacket(response_packet)) + { + match action? { + LpAction::SendPacket(response_packet) => { + // Send response packet + tracing::trace!("Sending handshake response to exit"); + let packet_bytes = Self::serialize_packet(&response_packet)?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Check if handshake completed after sending + if state_machine.session()?.is_handshake_complete() { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + + // Process the response from exit gateway + let response_packet = Self::parse_packet(&response_bytes)?; + if let Some(action) = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + { + match action? { + LpAction::HandshakeComplete => { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + LpAction::SendPacket(_) => { + // More rounds needed - fall through to loop + tracing::trace!("More handshake rounds needed"); + } + other => { + tracing::trace!("Action after send: {:?}", other); + } + } + } + } + LpAction::HandshakeComplete => { + tracing::info!("Nested LP handshake completed with exit gateway"); + self.state_machine = Some(state_machine); + return Ok(()); + } + LpAction::KKTComplete => { + tracing::info!("KKT exchange completed with exit, starting Noise"); + // After KKT completes, initiator must send first Noise handshake message + let noise_msg = state_machine + .session()? + .prepare_handshake_message() + .ok_or_else(|| { + LpClientError::Transport( + "No handshake message available after KKT".to_string(), + ) + })??; + let noise_packet = state_machine.session()?.next_packet(noise_msg)?; + tracing::trace!("Sending first Noise handshake message to exit"); + let packet_bytes = Self::serialize_packet(&noise_packet)?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Process the Noise response from exit gateway + let response_packet = Self::parse_packet(&response_bytes)?; + if let Some(action) = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + { + match action? { + LpAction::HandshakeComplete => { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + LpAction::SendPacket(final_packet) => { + tracing::trace!("Sending final handshake packet to exit"); + let packet_bytes = Self::serialize_packet(&final_packet)?; + let _ = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Check if complete after sending final packet + if state_machine.session()?.is_handshake_complete() { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + } + other => { + tracing::trace!( + "Action after Noise response: {:?}", + other + ); + } + } + } + } + other => { + tracing::trace!("Received action during handshake: {:?}", other); + } + } + } + } + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action at handshake start: {:?}", + other + ))); + } + } + } + + // If we reach here, the handshake didn't complete properly + Err(LpClientError::Transport( + "Nested handshake completed without reaching HandshakeComplete state".to_string(), + )) + } + + /// Performs handshake and registration with the exit gateway via forwarding. + /// + /// This is the main entry point for nested LP registration. It: + /// 1. Performs handshake with exit gateway (via `perform_handshake`) + /// 2. Builds and sends registration request through the forwarded connection + /// 3. Receives and processes registration response + /// 4. Returns gateway data on successful registration + /// + /// # Arguments + /// * `outer_client` - Connected LP client with established outer session to entry gateway + /// * `wg_keypair` - Client's WireGuard x25519 keypair + /// * `gateway_identity` - Exit gateway's Ed25519 identity (for credential verification) + /// * `bandwidth_controller` - Provider for bandwidth credentials + /// * `ticket_type` - Type of bandwidth ticket to use + /// * `client_ip` - Client IP address for registration metadata + /// + /// # Returns + /// * `Ok(GatewayData)` - Exit gateway configuration data on successful registration + /// + /// # Errors + /// Returns an error if: + /// - Handshake fails + /// - Credential acquisition fails + /// - Request serialization/encryption fails + /// - Forwarding through entry gateway fails + /// - Response decryption/deserialization fails + /// - Gateway rejects the registration + pub async fn handshake_and_register( + &mut self, + outer_client: &mut LpRegistrationClient, + wg_keypair: &x25519::KeyPair, + gateway_identity: &ed25519::PublicKey, + bandwidth_controller: &dyn BandwidthTicketProvider, + ticket_type: TicketType, + client_ip: IpAddr, + ) -> Result { + // Step 1: Perform handshake with exit gateway via forwarding + self.perform_handshake(outer_client).await?; + + // Step 2: Get the state machine (must exist after successful handshake) + let state_machine = self.state_machine.as_mut().ok_or_else(|| { + LpClientError::Transport("State machine missing after handshake".to_string()) + })?; + + tracing::debug!("Building registration request for exit gateway"); + + // Step 3: Acquire bandwidth credential + let credential = bandwidth_controller + .get_ecash_ticket(ticket_type, *gateway_identity, nym_bandwidth_controller::DEFAULT_TICKETS_TO_SPEND) + .await + .map_err(|e| { + LpClientError::Transport(format!( + "Failed to acquire bandwidth credential: {}", + e + )) + })? + .data; + + // Step 4: Build registration request + let wg_public_key = PeerPublicKey::new(wg_keypair.public_key().to_bytes().into()); + let request = LpRegistrationRequest::new_dvpn(wg_public_key, credential, ticket_type, client_ip); + + tracing::trace!("Built registration request: {:?}", request); + + // Step 5: Serialize the request + let request_bytes = bincode::serialize(&request).map_err(|e| { + LpClientError::Transport(format!("Failed to serialize registration request: {}", e)) + })?; + + tracing::debug!( + "Sending registration request to exit gateway via forwarding ({} bytes)", + request_bytes.len() + ); + + // Step 6: Encrypt and prepare packet via state machine + let action = state_machine + .process_input(LpInput::SendData(request_bytes)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!( + "Failed to encrypt registration request: {}", + e + )) + })?; + + // Step 7: Send the encrypted packet via forwarding + let response_bytes = match action { + LpAction::SendPacket(packet) => { + let packet_bytes = Self::serialize_packet(&packet)?; + outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await? + } + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when sending registration data: {:?}", + other + ))); + } + }; + + tracing::trace!("Received registration response from exit gateway"); + + // Step 8: Parse response bytes to LP packet + let response_packet = Self::parse_packet(&response_bytes)?; + + // Step 9: Decrypt via state machine + let action = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!( + "Failed to decrypt registration response: {}", + e + )) + })?; + + // Step 10: Extract decrypted data + let response_data = match action { + LpAction::DeliverData(data) => data, + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when receiving registration response: {:?}", + other + ))); + } + }; + + // Step 11: Deserialize the response + let response: LpRegistrationResponse = + bincode::deserialize(&response_data).map_err(|e| { + LpClientError::Transport(format!( + "Failed to deserialize registration response: {}", + e + )) + })?; + + tracing::debug!( + "Received registration response from exit: success={}, session_id={}", + response.success, + response.session_id + ); + + // Step 12: Validate and extract GatewayData + if !response.success { + let error_msg = response + .error + .unwrap_or_else(|| "Unknown error".to_string()); + tracing::warn!("Exit gateway rejected registration: {}", error_msg); + return Err(LpClientError::RegistrationRejected { reason: error_msg }); + } + + // Extract gateway_data + let gateway_data = response.gateway_data.ok_or_else(|| { + LpClientError::Transport( + "Gateway response missing gateway_data despite success=true".to_string(), + ) + })?; + + tracing::info!( + "Exit gateway registration successful! Session ID: {}, Allocated bandwidth: {} bytes", + response.session_id, + response.allocated_bandwidth + ); + + Ok(gateway_data) + } + + /// Serializes an LP packet to bytes. + /// + /// # Arguments + /// * `packet` - The LP packet to serialize + /// + /// # Returns + /// * `Ok(Vec)` - Serialized packet bytes + /// + /// # Errors + /// Returns an error if serialization fails + fn serialize_packet(packet: &LpPacket) -> Result> { + let mut buf = BytesMut::new(); + serialize_lp_packet(packet, &mut buf).map_err(|e| { + LpClientError::Transport(format!("Failed to serialize LP packet: {}", e)) + })?; + Ok(buf.to_vec()) + } + + /// Parses an LP packet from bytes. + /// + /// # Arguments + /// * `bytes` - The bytes to parse + /// + /// # Returns + /// * `Ok(LpPacket)` - Parsed LP packet + /// + /// # Errors + /// Returns an error if parsing fails + fn parse_packet(bytes: &[u8]) -> Result { + parse_lp_packet(bytes).map_err(|e| { + LpClientError::Transport(format!("Failed to parse LP packet: {}", e)) + }) + } +}