diff --git a/src/main.rs b/src/main.rs index 9b49460..086ac14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,23 +20,24 @@ use tokio::{ }; use tracing::{debug, error, info, warn}; -/// The main entry point for the GhostLink application. +/// Main entry point for the GhostLink application. /// -/// It initializes the application components: -/// 1. Loads configuration. -/// 2. Sets up communication channels (Command & Event loops). -/// 3. Initializes the shared application state. -/// 4. Starts the Web Server (UI). -/// 5. Starts the Controller (Networking & Logic). +/// Initializes and starts: +/// 1. Logging system +/// 2. Configuration +/// 3. Communication channels +/// 4. Application state +/// 5. Web server +/// 6. Network controller #[tokio::main] async fn main() -> Result<()> { // Initialize logging tracing_subscriber::fmt::init(); - info!("GhostLink v1.0 Starting..."); + info!("Starting GhostLink v1.0"); // Load configuration let config = Config::load(); - info!("Config loaded successfully."); + debug!("Configuration loaded successfully"); // Create channels for communication between Web Server and Controller let (cmd_tx, cmd_rx) = mpsc::channel::(32); @@ -51,14 +52,14 @@ async fn main() -> Result<()> { let web_server_handle = tokio::spawn(async move { let port = config.web_port; if let Err(e) = web_server::serve(state_clone, port).await { - error!("Web server crashed: {:?}", e); + error!("Web server error: {:?}", e); } }); // Start the Controller (Main Logic) // We await this as it runs the main event loop if let Err(e) = start_controller(&config, &shared_state, cmd_rx).await { - error!("Controller encountered a critical error: {:?}", e); + error!("Controller error: {:?}", e); } // Wait for web server (optional, usually controller keeps app alive) @@ -67,12 +68,14 @@ async fn main() -> Result<()> { Ok(()) } -/// Starts the main controller logic. +/// Starts the main network controller. /// -/// This function: -/// 1. Binds the UDP socket. -/// 2. Performs STUN resolution to find the public IP. -/// 3. Enters a command loop to handle user actions (like "Connect"). +/// Responsibilities: +/// 1. Binds UDP socket +/// 2. Resolves public IP via STUN +/// 3. Detects NAT type +/// 4. Handles commands and incoming messages +/// 5. Maintains NAT mappings via keep-alive async fn start_controller( config: &Config, shared_state: &SharedState, @@ -84,43 +87,57 @@ async fn start_controller( let socket = Arc::new(socket); let local_port = socket.local_addr()?.port(); - info!("UDP Socket bound locally to port: {}", local_port); + info!("UDP socket bound to port {}", local_port); + + // 2. Resolve Local IP + match net::get_local_ip(local_port).await { + Ok(local_addr) => { + info!("Local IP: {}", local_addr); + shared_state.write().await.set_local_ip( + local_addr, + Some("Local IP resolved".into()), + None, + ); + } + Err(e) => { + warn!("Could not resolve local IP: {:?}", e); + } + } - // 2. Resolve Public IP via STUN + // 3. Resolve Public IP via STUN // Note: We pass a reference to the socket. net::resolve_public_ip now expects &UdpSocket. match net::resolve_public_ip(&socket, &config.stun_server).await { Ok(public_addr) => { - info!("STUN Success! Your Public IP is: {}", public_addr); - info!("Share this address with your peer to connect."); + info!("Public IP resolved via STUN: {}", public_addr); // Update state safely using the setter. // This triggers an event update so the UI displays the IP immediately. shared_state.write().await.set_public_ip( public_addr, - Some("STUN Resolution Successful".into()), + Some("Public IP resolved".into()), None, ); - // 3. Detect NAT type + // 4. Detect NAT type let nat_type = net::get_nat_type(&socket, &config.stun_verifier, public_addr).await; shared_state.write().await.set_nat_type( nat_type, - Some("NAT type resolved.".into()), + Some("NAT type detected".into()), None, ); - debug!("Behind {:?} NAT type", nat_type); + info!("NAT type: {:?}", nat_type); } Err(e) => { - error!("STUN Resolution Failed: {:?}", e); - warn!("You may not be reachable from the internet."); + error!("STUN resolution failed: {:?}", e); + warn!("Cannot accept incoming connections without public IP"); } }; let mut message_manager = MessageManager::new(Arc::clone(&socket), Arc::clone(shared_state)); - // 4. Command Loop with Heartbeat - info!("Waiting for commands (NAT Keep-Alive active)..."); + // 5. Command Loop with Keep-Alive + info!("Ready to accept commands"); let mut keep_alive_interval = tokio::time::interval(Duration::from_secs(config.punch_hole_secs)); @@ -136,22 +153,22 @@ async fn start_controller( Some(cmd) => { match cmd { Command::ConnectPeer => { - debug!("Command received: ConnectPeer"); + info!("Initiating peer connection"); // Read the target peer IP from state let peer_ip_opt = shared_state.read().await.peer_ip; if let Some(peer_addr) = peer_ip_opt { - debug!("Initiating connection to peer: {}", peer_addr); + debug!("Connecting to peer: {}", peer_addr); if let Err(e) = message_manager.handshake(peer_addr, config.handshake_timeout_secs).await { - error!("Connection attempt failed: {:?}", e); + error!("Handshake failed: {:?}", e); } else { - debug!("Connection established successfully. MessageManager active."); + info!("Connection established with peer"); message_manager.upgrade_to_kcp().await?; } } else { - warn!("Connect command received but no peer IP is set in state."); + warn!("Cannot connect: no peer IP configured"); } } @@ -161,16 +178,16 @@ async fn start_controller( Ok(_) => { shared_state.read().await.add_message(msg, true); }, - Err(e) => error!("Failed to send KCP message: {}", e), + Err(e) => error!("Message send failed: {}", e), } } else { - warn!("Cannot send message: Not connected."); + warn!("Cannot send message: not connected to peer"); } } } } None => { - info!("Command channel closed. Controller shutting down."); + info!("Command channel closed, shutting down"); break; } } @@ -182,11 +199,11 @@ async fn start_controller( match res { Ok(n) => { let msg_str = String::from_utf8_lossy(&recv_buf[..n]).to_string(); - info!("Received message: {}", msg_str); + debug!("Received message from peer: {}", msg_str); shared_state.read().await.add_message(msg_str, false); }, Err(e) => { - error!("Error reading from KCP stream: {}", e); + error!("KCP stream error: {}", e); } } } @@ -198,14 +215,14 @@ async fn start_controller( let status = shared_state.read().await.status; if status == Status::Disconnected { - debug!("Sending Keep-Alive packet to STUN server..."); - // Re resolving IP sends a STUN packet, which refreshes the NAT mapping. + debug!("Sending NAT keep-alive to STUN server"); + // Re-resolving IP sends a STUN packet, which refreshes the NAT mapping. match net::resolve_public_ip(&socket, &config.stun_server).await { Ok(addr) => { let mut state = shared_state.write().await; if state.public_ip != Some(addr) { - warn!("Public IP changed! Old: {:?}, New: {}", state.public_ip, addr); - state.set_public_ip(addr, Some("Public IP Changed".into()), None); + info!("Public IP changed from {:?} to {}", state.public_ip, addr); + state.set_public_ip(addr, Some("Public IP updated".into()), None); } } Err(e) => { diff --git a/src/messaging/handshake.rs b/src/messaging/handshake.rs index ab5f056..20f2ed4 100644 --- a/src/messaging/handshake.rs +++ b/src/messaging/handshake.rs @@ -47,6 +47,11 @@ pub async fn handshake( let mut send_interval = tokio::time::interval(Duration::from_millis(500)); send_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Track if client received SYN-ACK + let mut received_syn_ack = false; + // Track if client sent SYN-ACK + let mut sent_syn_ack = false; + info!("Starting handshake with {}", peer_addr); // Update initial state @@ -97,17 +102,39 @@ pub async fn handshake( let reply = bincode::serialize(&HandshakeMsg::SynAck)?; client_socket.send_to(&reply, peer_addr).await?; + + sent_syn_ack = true; + if received_syn_ack && sent_syn_ack { + // Transition to Connected state + state.write().await.set_status( + Status::Connected, + Some(format!("Connected to {}", sender)), + None + ); + return Ok(()); + } + } HandshakeMsg::SynAck => { - info!("Received SYN-ACK from {}! Connection Established.", sender); + info!("Received SYN-ACK from {}. Sending ACK.", sender); // Transition to Connected state state.write().await.set_status( - Status::Connected, - Some(format!("Connected to {}", sender)), - None + Status::Punching, + Some(format!("Received SYN-ACK from {}. Sending ACK.", sender)), + Some(secs_left), ); - return Ok(()); + + received_syn_ack = true; + if received_syn_ack && sent_syn_ack { + // Transition to Connected state + state.write().await.set_status( + Status::Connected, + Some(format!("Connected to {}", sender)), + None + ); + return Ok(()); + } } HandshakeMsg::Bye => { warn!("Peer {} rejected connection (received BYE)", sender); @@ -185,14 +212,24 @@ mod tests { tokio::spawn(async move { let mut buf = [0u8; 1024]; - // Wait for Peer A to send SYN - let (len, _) = socket_b.recv_from(&mut buf).await.unwrap(); - let msg: HandshakeMsg = bincode::deserialize(&buf[..len]).unwrap(); - assert_eq!(msg, HandshakeMsg::Syn); - - // Send SYN-ACK back - let reply = bincode::serialize(&HandshakeMsg::SynAck).unwrap(); - socket_b.send_to(&reply, addr_a).await.unwrap(); + // 1. Send SYN to A so A can fulfill `sent_syn_ack` requirement + let syn_msg = bincode::serialize(&HandshakeMsg::Syn).unwrap(); + socket_b.send_to(&syn_msg, addr_a).await.unwrap(); + + // 2. Respond to A's SYN + loop { + let (len, sender) = socket_b.recv_from(&mut buf).await.unwrap(); + if sender == addr_a { + if let Ok(msg) = bincode::deserialize::(&buf[..len]) { + if msg == HandshakeMsg::Syn { + // Send SYN-ACK back so A can fulfill `received_syn_ack` + let reply = bincode::serialize(&HandshakeMsg::SynAck).unwrap(); + socket_b.send_to(&reply, addr_a).await.unwrap(); + break; + } + } + } + } }); let result = handshake(socket_a, addr_b, state_a.clone(), 5).await; @@ -232,6 +269,12 @@ mod tests { // 2. Real peer replies later tokio::time::sleep(Duration::from_millis(1000)).await; + + // Peer sends SYN + let syn = bincode::serialize(&HandshakeMsg::Syn).unwrap(); + socket_b.send_to(&syn, addr_a).await.unwrap(); + + // Peer sends SYN-ACK let reply = bincode::serialize(&HandshakeMsg::SynAck).unwrap(); socket_b.send_to(&reply, addr_a).await.unwrap(); }); @@ -269,21 +312,30 @@ mod tests { let socket_a = bind_local().await; let socket_b = bind_local().await; let state_a = create_dummy_state(); + let _state_b = create_dummy_state(); let addr_a = socket_a.local_addr().unwrap(); let addr_b = socket_b.local_addr().unwrap(); - // Peer B logic + // Peer B logic - simulates another peer also initiating handshake let socket_b_clone = socket_b.clone(); tokio::spawn(async move { let mut buf = [0u8; 1024]; - let (len, sender) = socket_b_clone.recv_from(&mut buf).await.unwrap(); - if sender == addr_a { - // If we get a SYN, we also reply SYN-ACK (Standard TCP-like hole punching) - if let Ok(HandshakeMsg::Syn) = bincode::deserialize(&buf[..len]) { - let reply = bincode::serialize(&HandshakeMsg::SynAck).unwrap(); - socket_b_clone.send_to(&reply, addr_a).await.unwrap(); + // 1. Send SYN to A proactively + let syn = bincode::serialize(&HandshakeMsg::Syn).unwrap(); + socket_b_clone.send_to(&syn, addr_a).await.unwrap(); + + // 2. Receive SYN from A and Reply + loop { + let (len, sender) = socket_b_clone.recv_from(&mut buf).await.unwrap(); + if sender == addr_a { + if let Ok(HandshakeMsg::Syn) = bincode::deserialize(&buf[..len]) { + // Send SYN-ACK back + let reply = bincode::serialize(&HandshakeMsg::SynAck).unwrap(); + socket_b_clone.send_to(&reply, addr_a).await.unwrap(); + break; + } } } }); @@ -292,4 +344,39 @@ mod tests { assert!(result.is_ok()); assert_eq!(state_a.read().await.status, Status::Connected); } + + /// Test that both peers complete handshake when initiating simultaneously + /// Verifies the fix for the issue where user B would mark as connected + /// and ignore further SYN packets from user A + #[tokio::test] + async fn test_both_peers_complete_handshake_when_initiating_simultaneously() { + let socket_a = bind_local().await; + let socket_b = bind_local().await; + let state_a = create_dummy_state(); + let state_b = create_dummy_state(); + + let addr_a = socket_a.local_addr().unwrap(); + let addr_b = socket_b.local_addr().unwrap(); + + // Both peers start handshake simultaneously + let socket_a_clone = socket_a.clone(); + let state_a_clone = state_a.clone(); + let handle_a = + tokio::spawn(async move { handshake(socket_a_clone, addr_b, state_a_clone, 5).await }); + + let socket_b_clone = socket_b.clone(); + let state_b_clone = state_b.clone(); + let handle_b = + tokio::spawn(async move { handshake(socket_b_clone, addr_a, state_b_clone, 5).await }); + + // Both should complete successfully + let result_a = handle_a.await.unwrap(); + let result_b = handle_b.await.unwrap(); + + assert!(result_a.is_ok(), "Peer A should complete handshake"); + assert!(result_b.is_ok(), "Peer B should complete handshake"); + + assert_eq!(state_a.read().await.status, Status::Connected); + assert_eq!(state_b.read().await.status, Status::Connected); + } } diff --git a/src/net.rs b/src/net.rs index 46eda01..f0ba33d 100644 --- a/src/net.rs +++ b/src/net.rs @@ -1,11 +1,10 @@ -//! Network module for GhostLink. +//! Network utilities for GhostLink. //! -//! This module handles low-level networking operations, specifically -//! NAT Traversal and Public IP discovery using the STUN protocol. +//! Handles NAT traversal and public IP discovery using STUN. use super::web::shared_state::NatType; use anyhow::{Context, Result, bail}; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use stun::{ agent::TransactionId, message::{BINDING_REQUEST, Getter, Message}, @@ -15,35 +14,59 @@ use tokio::{ net::UdpSocket, time::{Duration, timeout}, }; -use tracing::{debug, info}; +use tracing::debug; /// The duration to wait for a STUN response before timing out. const STUN_TIMEOUT: Duration = Duration::from_secs(3); -/// Resolves the public IP and port of the local machine by querying a public STUN server. +/// Resolves the local IP address using a DNS server. /// -/// # Workflow -/// 1. Resolves DNS of the provided STUN server. -/// 2. Sends a STUN `BINDING_REQUEST` using the provided UDP socket. -/// 3. Waits for a `BINDING_SUCCESS` response (with a 3-second timeout). -/// 4. Validates the Transaction ID to prevent spoofing. -/// 5. Extracts the `XorMappedAddress` (public IP) from the response. +/// Connecting to a remote address causes the OS +/// to select the appropriate local interface and IP. +/// +/// # Returns +/// +/// * `Ok(SocketAddr)` - Local IP and port +/// * `Err` - If resolution fails +pub async fn get_local_ip(local_port: u16) -> Result { + // Connect to Google's public DNS (we don't send any data) + let socket = UdpSocket::bind(("0.0.0.0", 0)).await?; + socket.connect("8.8.8.8:80").await?; + let local_addr = socket.local_addr()?; + + // Replace the ephemeral port with the actual listening port + let local_ip = match local_addr.ip() { + IpAddr::V4(ip) => SocketAddr::new(IpAddr::V4(ip), local_port), + IpAddr::V6(ip) => SocketAddr::new(IpAddr::V6(ip), local_port), + }; + + Ok(local_ip) +} + +/// Discovers public IP and port using STUN. +/// +/// Workflow: +/// 1. Resolves STUN server DNS +/// 2. Sends BINDING_REQUEST +/// 3. Waits for response (3 second timeout) +/// 4. Validates transaction ID +/// 5. Extracts public address /// /// # Arguments /// -/// * `socket` - A reference to the UDP socket. The socket must be bound before calling. -/// * `stun_server` - The address of the STUN server (e.g., "stun.l.google.com:19302"). +/// * `socket` - Bound UDP socket +/// * `stun_server` - STUN server address (e.g., "stun.l.google.com:19302") /// /// # Returns /// -/// * `Ok(SocketAddr)` - The public IP and port of the local machine. -/// * `Err` - If DNS fails, the server is unreachable, the request times out, or the response is invalid. +/// * `Ok(SocketAddr)` - Public IP and port +/// * `Err` - If DNS, network, or STUN validation fails pub async fn resolve_public_ip( socket: &UdpSocket, stun_server: impl AsRef, ) -> Result { let stun_server = stun_server.as_ref(); - info!("Resolving public IP via {}", stun_server); + debug!("Querying STUN server: {}", stun_server); // 1. Resolve DNS for the STUN server. let mut addrs = tokio::net::lookup_host(stun_server) @@ -102,19 +125,21 @@ pub async fn resolve_public_ip( Ok(public_addr) } -/// Checks if user is behind a symittric network. -/// Resolves the public IP by querying another public STUN server and validates with previous -/// response. +/// Detects NAT type by querying a second STUN server. +/// +/// Compares the public port from two different STUN servers: +/// - Same port → Cone NAT (P2P-friendly) +/// - Different port → Symmetric NAT (P2P-difficult) /// /// # Arguments /// -/// * `socket` - A reference to the UDP socket. The socket must be bound before calling. -/// * `stun_server` - The address of the STUN server (e.g., "stun.l.google.com:19302"). -/// * `prev_addr` - The address resolved by previous STUN. +/// * `socket` - Bound UDP socket +/// * `stun_server` - Second STUN server address +/// * `prev_addr` - Address from first STUN query /// /// # Returns /// -/// * `NatType` - Indicates the type of NAT user's router is using. +/// NAT type: Cone, Symmetric, or Unknown pub async fn get_nat_type( socket: &UdpSocket, stun_server: impl AsRef, diff --git a/src/web/shared_state.rs b/src/web/shared_state.rs index d0f4e23..09eb486 100644 --- a/src/web/shared_state.rs +++ b/src/web/shared_state.rs @@ -2,66 +2,57 @@ use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, sync::Arc}; use tokio::sync::{RwLock, broadcast, mpsc}; -/// A thread safe wrapper around the application state. +/// Thread-safe wrapper for application state. /// -/// This alias allows multiple parts of the application (e.g., the web server -/// and the P2P controller) to share and modify the state concurrently. +/// Allows the web server and network controller to share state concurrently. pub type SharedState = Arc>; -/// Represents the core runtime state of the GhostLink application. +/// Core application state. /// -/// This struct serves as the "source of truth" for the application's status. -/// It holds network information (IPs, NAT type) and connectivity status. +/// Holds network information and connectivity status. /// -/// # Architecture -/// This struct is shared between: -/// 1. The **Controller**: Handles P2P logic, STUN resolution, and connection management. -/// 2. The **Web Server**: Serves the UI and streams state updates to the frontend via SSE/WebSocket. +/// Shared between: +/// - Network controller (manages P2P, STUN, connections) +/// - Web server (serves UI and streams updates via SSE) /// -/// # Serialization -/// Fields marked with `#[serde(skip)]` are excluded from JSON serialization -/// because they are runtime control channels, not persistent state data. +/// Fields marked `#[serde(skip)]` are excluded from JSON serialization +/// as they're internal control channels. #[derive(Debug, Serialize, Clone)] pub struct AppState { - /// The public IP address and port of this node, as seen by the STUN server. - /// - /// This is `None` initially and is populated once STUN resolution succeeds. + /// Local IP and port (for LAN connections). + pub local_ip: Option, + + /// Public IP and port (resolved via STUN). pub public_ip: Option, - /// The type of NAT (Network Address Translation) detected by the router. - /// This determines the compatibility of P2P connections. + /// NAT type detected by the router. pub nat_type: NatType, - /// The current operational status of the P2P node. + /// Current connection status. pub status: Status, - /// The IP address of the peer we are connecting to or are connected with. - /// - /// This is `None` until a valid handshake is initiated. + /// Peer's IP address. pub peer_ip: Option, - /// Channel to send commands *to* the background controller task. - /// - /// Used by the web server to trigger actions like "Connect". + /// Channel for sending commands to the controller. #[serde(skip)] cmd_tx: mpsc::Sender, - /// Channel to broadcast state change events *to* the web UI. - /// - /// The web server subscribes to this to push updates to the frontend. + /// Channel for broadcasting state changes to the UI. #[serde(skip)] event_tx: broadcast::Sender, } impl AppState { - /// Creates a new instance of the application state with default values. + /// Creates a new application state with default values. /// /// # Arguments /// - /// * `cmd_tx` - Channel for sending commands to the controller. - /// * `event_tx` - Channel for broadcasting events to the UI. + /// * `cmd_tx` - Channel for sending commands to controller + /// * `event_tx` - Channel for broadcasting events to UI pub fn new(cmd_tx: mpsc::Sender, event_tx: broadcast::Sender) -> Self { Self { + local_ip: None, public_ip: None, nat_type: NatType::default(), status: Status::default(), @@ -71,20 +62,31 @@ impl AppState { } } - /// Returns a reference to the command sender channel. + /// Returns the command sender channel. pub fn cmd_tx(&self) -> &mpsc::Sender { &self.cmd_tx } - /// Creates a new subscriber for the event broadcast channel. + /// Creates a new event subscriber. pub fn subscribe_events(&self) -> broadcast::Receiver { self.event_tx.subscribe() } - // -- State Mutators -- - // These methods update the state and automatically broadcast the change to the UI. + // -- State Setters -- + // These methods update state and broadcast changes to listeners. + + /// Updates local IP and notifies listeners. + pub fn set_local_ip( + &mut self, + addr: SocketAddr, + message: Option, + timeout: Option, + ) { + self.local_ip = Some(addr); + self.broadcast_status_change(message, timeout); + } - /// Updates the public IP address and notifies listeners. + /// Updates public IP and notifies listeners. pub fn set_public_ip( &mut self, addr: SocketAddr, @@ -95,7 +97,7 @@ impl AppState { self.broadcast_status_change(message, timeout); } - /// Updates the NAT type and notifies listeners. + /// Updates NAT type and notifies listeners. #[allow(dead_code)] pub fn set_nat_type( &mut self, @@ -107,22 +109,22 @@ impl AppState { self.broadcast_status_change(message, timeout); } - /// Updates the connection status and notifies listeners. + /// Updates connection status and notifies listeners. pub fn set_status(&mut self, status: Status, message: Option, timeout: Option) { self.status = status; self.broadcast_status_change(message, timeout); } - /// Updates the peer's IP address and notifies listeners. + /// Updates peer IP and notifies listeners. pub fn set_peer_ip(&mut self, addr: SocketAddr, message: Option, timeout: Option) { self.peer_ip = Some(addr); self.broadcast_status_change(message, timeout); } - /// Broadcasts the current state to all active listeners (e.g., Web UI). + /// Broadcasts current state to all active listeners. /// - /// This constructs an `AppEvent` based on the current `status` and sends it - /// via the `event_tx` channel. + /// Constructs an event based on the current status and sends it + /// via the event channel. fn broadcast_status_change(&self, message: Option, timeout: Option) { let event = match self.status { // When disconnected, we send the full state so the UI can sync up. @@ -137,57 +139,55 @@ impl AppState { self.broadcast_event(event); } - /// Explicitly broadcasts a new chat message to the UI. + /// Broadcasts a chat message to the UI. pub fn add_message(&self, content: String, from_me: bool) { let _ = self.event_tx.send(AppEvent::Message { content, from_me }); } - /// General helper for other updates if needed + /// Broadcasts an event to the UI. fn broadcast_event(&self, event: AppEvent) { let _ = self.event_tx.send(event); } } -/// Represents the NAT (Network Address Translation) type of the network. +/// NAT (Network Address Translation) type. /// -/// This classification helps determine if a direct P2P connection is feasible. +/// Determines if direct P2P connections are possible. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)] pub enum NatType { - /// NAT type has not yet been determined (STUN pending). + /// NAT type not yet determined. #[default] Unknown, - /// Cone NAT: The router uses a consistent external port for internal clients. - /// This is **favorable** for P2P hole punching. + /// Cone NAT: Uses consistent external port (P2P-friendly). Cone, - /// Symmetric NAT: The router assigns different external ports for different destinations. - /// This is **unfavorable** and difficult for P2P hole punching. + /// Symmetric NAT: Uses different external ports per destination (P2P-difficult). Symmetric, } -/// Represents a distinct event sent from the server to the Web UI. +/// Event sent from server to UI. /// -/// The structure of the event changes based on the application's connection status. +/// Structure varies based on connection status. #[derive(Debug, Clone, Serialize)] #[serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE")] pub enum AppEvent { - /// The application is idle or disconnected. + /// Application is idle or disconnected. /// - /// Payload includes the full `AppState` to ensure the UI is fully synchronized. + /// Includes full state for UI synchronization. Disconnected { state: AppState, }, - /// The application is actively trying to punch a hole through the NAT. + /// Attempting NAT hole punching. Punching { - /// Time remaining (in seconds) for the handshake attempt. + /// Time remaining for handshake attempt (seconds). timeout: Option, - /// Logs (e.g., hole punched/ACK received). + /// Log messages. message: Option, }, - /// A P2P connection has been successfully established. + /// P2P connection established. Connected { - /// Informational message from the peer or system. + /// System or peer message. message: Option, }, @@ -197,25 +197,26 @@ pub enum AppEvent { }, } -/// Represents the high level connection state of the P2P node. +/// Connection state of the P2P node. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum Status { - /// The node is idle and waiting for user input or STUN resolution. + /// Idle, waiting for user input. #[default] Disconnected, - /// The node is actively performing the hole punching handshake. + /// Performing hole punching handshake. Punching, - /// The node has successfully established a P2P session with a peer. + /// P2P session established. Connected, } -/// Commands sent from the Web Interface (or other drivers) to the Controller. +/// Commands from Web UI to Controller. #[derive(Debug)] pub enum Command { - /// Instructs the controller to initiate a connection attempt to the configured peer. + /// Initiate connection to configured peer. ConnectPeer, + /// Sends a message SendMessage(String), } diff --git a/src/web/web_server.rs b/src/web/web_server.rs index a33f5bf..be94d9f 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -1,9 +1,9 @@ -//! Web server module for GhostLink. +//! Web server for GhostLink. //! -//! This module handles the HTTP layer of the application. It serves: -//! 1. The Static UI files (HTML/JS/CSS) from the `static/` directory. -//! 2. The API endpoints (e.g., status, configuration) for the frontend. -//! 3. Server Sent Events (SSE) for real time state updates. +//! Provides: +//! 1. Static UI (HTML/JS/CSS) +//! 2. REST API endpoints +//! 3. Server-Sent Events (SSE) for real-time updates use super::shared_state::{Command, SharedState, Status}; use anyhow::Result; @@ -30,12 +30,12 @@ use tokio_stream::{StreamExt, wrappers::BroadcastStream}; use tower_http::{cors::CorsLayer, services::ServeDir}; use tracing::{debug, error, info}; -/// Starts the HTTP server on the specified port. +/// Starts the HTTP server. /// /// # Arguments /// -/// * `shared_state` - The thread safe application state. -/// * `port` - The port number to listen on (e.g., 8080). +/// * `shared_state` - Thread-safe application state +/// * `port` - Port to listen on pub async fn serve(shared_state: SharedState, port: u16) -> Result<()> { let app = router(shared_state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); @@ -48,11 +48,11 @@ pub async fn serve(shared_state: SharedState, port: u16) -> Result<()> { Ok(()) } -/// Creates the Axum Router with all routes and middleware configured. +/// Creates the Axum router with all routes and middleware. pub fn router(shared_state: SharedState) -> Router { Router::new() // API Routes - .route("/api/state", get(get_ip)) + .route("/api/state", get(get_state)) .route("/api/connect", post(connect_peer)) .route("/api/message", post(send_message)) .route("/api/events", get(sse_handler)) @@ -65,9 +65,9 @@ pub fn router(shared_state: SharedState) -> Router { // --- API Handlers --- -/// Handler for `GET /api/ip`. -/// Returns the public IP and port of the local node (if resolved). -async fn get_ip(State(state): State) -> impl IntoResponse { +/// Handler for `GET /api/state`. +/// Returns current application state including IPs, NAT type, and status. +async fn get_state(State(state): State) -> impl IntoResponse { let data = state.read().await; Json(json!({ "state": data.clone() })) } @@ -79,7 +79,7 @@ struct ConnectionRequest { } /// Handler for `POST /api/connect`. -/// Validates input, updates state, and triggers the connection controller. +/// Validates peer IP and triggers connection process. async fn connect_peer( State(state): State, Json(input): Json, @@ -156,7 +156,7 @@ async fn send_message( } /// Handler for `GET /api/events`. -/// Upgrades the connection to a Server Sent Events (SSE) stream. +/// Establishes SSE stream for real-time state updates. async fn sse_handler( State(state): State, ) -> Sse>> { @@ -356,4 +356,99 @@ mod tests { "text/event-stream" ); } + + /// Verifies that updating public IP triggers a broadcast event. + #[tokio::test] + async fn test_public_ip_update_broadcasts_event() { + let state = create_test_state(); + + // Subscribe to events before updating + let mut event_rx = state.read().await.subscribe_events(); + + // Update public IP + { + let mut guard = state.write().await; + guard.set_public_ip( + "203.0.113.10:8080".parse().unwrap(), + Some("Public IP resolved".into()), + None, + ); + } + + // Verify event was broadcast + let event = event_rx.recv().await.unwrap(); + match event { + AppEvent::Disconnected { state: app_state } => { + assert_eq!( + app_state.public_ip.unwrap().to_string(), + "203.0.113.10:8080" + ); + } + _ => panic!("Expected Disconnected event"), + } + } + + /// Verifies that public IP changes are detected and broadcast correctly. + #[tokio::test] + async fn test_public_ip_change_detection() { + let state = create_test_state(); + + // Set initial IP + { + let mut guard = state.write().await; + guard.set_public_ip( + "203.0.113.10:8080".parse().unwrap(), + Some("Initial IP".into()), + None, + ); + } + + // Subscribe after initial setup + let mut event_rx = state.read().await.subscribe_events(); + + // Change IP + { + let mut guard = state.write().await; + let old_ip = guard.public_ip; + let new_ip: SocketAddr = "203.0.113.20:8080".parse().unwrap(); + + assert_ne!(old_ip, Some(new_ip)); + + guard.set_public_ip(new_ip, Some("Public IP updated".into()), None); + } + + // Verify event contains new IP + let event = event_rx.recv().await.unwrap(); + match event { + AppEvent::Disconnected { state: app_state } => { + assert_eq!( + app_state.public_ip.unwrap().to_string(), + "203.0.113.20:8080" + ); + } + _ => panic!("Expected Disconnected event with updated IP"), + } + } + + /// Verifies that NAT type updates are broadcast correctly. + #[tokio::test] + async fn test_nat_type_update_broadcasts_event() { + let state = create_test_state(); + let mut event_rx = state.read().await.subscribe_events(); + + // Update NAT type + { + let mut guard = state.write().await; + guard.set_nat_type(NatType::Cone, Some("NAT type detected".into()), None); + } + + // Verify event + let event = event_rx.recv().await.unwrap(); + match event { + AppEvent::Disconnected { state: app_state } => { + assert_eq!(app_state.nat_type, NatType::Cone); + } + _ => panic!("Expected Disconnected event"), + } + } } diff --git a/static/index.html b/static/index.html index cfceaea..5023c8c 100644 --- a/static/index.html +++ b/static/index.html @@ -21,20 +21,33 @@

GhostLink

- + Detecting NAT...
-
- Initializing... - -
- - +
+ +
+ Initializing... + +
+ +
+
+
+ +
+ +
+ Initializing... + +
+ +
@@ -133,7 +146,6 @@

Connection Established

- Notification
diff --git a/static/script.js b/static/script.js index 0c721c2..d8288cf 100644 --- a/static/script.js +++ b/static/script.js @@ -1,6 +1,7 @@ // --- State Management --- const state = { fullAddress: null, + localAddress: null, peerAddress: null, natType: 'Unknown', connectionStatus: 'disconnected', // disconnected, punching, connected @@ -24,10 +25,11 @@ const els = { // Home myIpDisplay: document.getElementById('myIpDisplay'), + myLocalIpDisplay: document.getElementById('myLocalIpDisplay'), natTypeDisplay: document.getElementById('natTypeDisplay'), // New NAT Element apiErrorMsg: document.getElementById('apiErrorMsg'), copyBtn: document.getElementById('copyBtn'), - refreshBtn: document.getElementById('refreshBtn'), + copyLocalBtn: document.getElementById('copyLocalBtn'), connectForm: document.getElementById('connectForm'), peerIpInput: document.getElementById('peerIp'), peerPortInput: document.getElementById('peerPort'), @@ -71,10 +73,6 @@ async function init() { * Replaces old /api/ip, /api/status, and /api/peer calls. */ async function fetchState() { - if (els.refreshBtn) { - els.refreshBtn.classList.add('spin'); - els.refreshBtn.disabled = true; - } els.myIpDisplay.style.opacity = '0.5'; try { @@ -100,10 +98,6 @@ async function fetchState() { renderMyInfo(false); } finally { setTimeout(() => { - if (els.refreshBtn) { - els.refreshBtn.classList.remove('spin'); - els.refreshBtn.disabled = false; - } els.myIpDisplay.style.opacity = '1'; }, 500); } @@ -119,17 +113,20 @@ function syncState(data) { // 1. Public IP if (data.public_ip) state.fullAddress = data.public_ip; - // 2. Peer IP + // 2. Local IP + if (data.local_ip) state.localAddress = data.local_ip; + + // 3. Peer IP if (data.peer_ip) state.peerAddress = data.peer_ip; else if (data.peer_ip === null) state.peerAddress = null; // Explicit reset - // 3. NAT Type (New) + // 4. NAT Type (New) if (data.nat_type) { state.natType = data.nat_type; renderNatType(); } - // 4. Status + // 5. Status if (data.status) { // reuse handleStatusChange to trigger UI transitions if needed, // but strictly speaking, fetchState is usually for init/refresh. @@ -146,6 +143,8 @@ function handleStatusChange(statusStr, data = {}) { // CASE A: Disconnected Event via SSE (AppEvent::Disconnected { state }) if (normStatus === 'DISCONNECTED' && data.state) { syncState(data.state); + // Update UI to reflect any changes in public/local IP + renderMyInfo(true); } // CASE B: Standard AppState via REST API (/api/state) or top-level event fields // (Note: syncState calls handleStatusChange, so we avoid infinite recursion by not calling syncState back) @@ -297,6 +296,17 @@ function renderMyInfo(success) { els.apiErrorMsg.style.display = 'block'; els.copyBtn.style.display = 'none'; } + + // Render local IP + if (success && state.localAddress) { + els.myLocalIpDisplay.innerText = state.localAddress; + els.myLocalIpDisplay.classList.remove('error'); + els.copyLocalBtn.style.display = 'flex'; + } else { + els.myLocalIpDisplay.innerText = "Not Available"; + els.myLocalIpDisplay.classList.add('error'); + els.copyLocalBtn.style.display = 'none'; + } } function addLog(message) { @@ -369,7 +379,23 @@ function copyToClipboard() { textarea.select(); try { document.execCommand('copy'); - showToast("Copied to clipboard"); + showToast("Public IP copied to clipboard"); + } catch (err) { + console.error('Copy failed', err); + } + document.body.removeChild(textarea); + } +} + +function copyLocalToClipboard() { + if (state.localAddress) { + const textarea = document.createElement('textarea'); + textarea.value = state.localAddress; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + showToast("Local IP copied to clipboard"); } catch (err) { console.error('Copy failed', err); } @@ -382,8 +408,36 @@ const validators = { port: (p) => { const n = parseInt(p, 10); return !isNaN(n) && n > 0 && n <= 65535; } }; +/** + * Parses IP:Port format and populates both fields if detected. + * Returns true if parsing happened, false otherwise. + */ +function parseIpPort(inputValue) { + const match = inputValue.match(/^([0-9.]+):(\d+)$/); + if (match) { + const [, ip, port] = match; + if (validators.ip(ip) && validators.port(port)) { + els.peerIpInput.value = ip; + els.peerPortInput.value = port; + return true; + } + } + return false; +} + function handleIpValidation(eventType) { const val = els.peerIpInput.value.trim(); + + // Try to parse IP:Port format on paste + if (eventType === 'input' && val.includes(':')) { + if (parseIpPort(val)) { + // Successfully parsed, validate both fields + handleIpValidation('input'); + handlePortValidation(); + return; + } + } + const isValid = validators.ip(val); state.isIpValid = isValid; if (isValid) { @@ -426,8 +480,7 @@ function handlePortValidation() { function setupEventListeners() { if(els.copyBtn) els.copyBtn.addEventListener('click', copyToClipboard); - // Updated listener: "Refresh" now calls the unified fetchState - if(els.refreshBtn) els.refreshBtn.addEventListener('click', fetchState); + if(els.copyLocalBtn) els.copyLocalBtn.addEventListener('click', copyLocalToClipboard); els.connectForm.addEventListener('submit', handleConnect); els.disconnectBtn.addEventListener('click', handleDisconnect); els.peerIpInput.addEventListener('input', () => handleIpValidation('input'));