diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4343a0f7..6e3b6540 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [macos-latest, ubuntu-20.04, windows-latest] + platform: [windows-latest] defaults: run: working-directory: 'frontend' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5b7046d..5bfe812e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [macos-latest, ubuntu-20.04, windows-latest] + platform: [windows-latest] defaults: run: working-directory: 'frontend' diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index f1c3cb4f..63c058d7 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "better_fleet" version = "0.1.0" -description = "To kill FleetCreator" +description = "A better fleet creator" authors = ["Zelytra", "dadodasyra"] license = "" repository = "" @@ -18,6 +18,14 @@ tauri-build = { version = "1.5.1", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tauri = { version = "1.6.0", features = [] } +anyhow = "1.0.80" +socket2 = "0.5.6" +tokio = { version = "1.36.0", features = ["full"] } +winapi = { version = "0.3.9", features = ["winsock2"] } +etherparse = "0.14.2" +hostname = "0.3" +sysinfo = "0.30.6" +netstat2 = "0.9.1" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. diff --git a/frontend/src-tauri/src/api.rs b/frontend/src-tauri/src/api.rs new file mode 100644 index 00000000..b2c3afc5 --- /dev/null +++ b/frontend/src-tauri/src/api.rs @@ -0,0 +1,64 @@ +use std::time::Instant; +use serde::Serialize; + +// This whole file is used to cache data from thread and expose it to the frontend. +#[derive(Clone)] +pub struct Api { + pub game_status: GameStatus, + pub server_ip: String, + pub server_port: u16, + pub last_updated_server_ip: Instant +} + +#[derive(PartialEq, Debug, Clone, Serialize)] +pub enum GameStatus { + Closed, // Game is closed + Started, // Game is in first menu after launch / launching / stopping + MainMenu, // In menu to select game mode + InGame, // Status when the remote IP and port was found and player is in game + Unknown // Default / errored status, this should not be used +} + +impl Api { + pub fn new() -> Self { + Self { + game_status: GameStatus::Unknown, + server_ip: String::new(), + server_port: 0, + last_updated_server_ip: Instant::now() + } + } + + /** + * This is the most accurate info you can get about what's going on. + * It's updated AT LEAST every 5 seconds. (1-5 secs) + * This information is prioritized over the others. + */ + pub async fn get_game_status(&self) -> GameStatus { + self.game_status.clone() + } + + /** + * Server IP, should only be used when GameStatus is InGame. + */ + pub async fn get_server_ip(&self) -> String { + self.server_ip.clone() + } + + + /** + * Server port, should only be used when GameStatus is InGame. + */ + pub async fn get_server_port(&self) -> u16 { + self.server_port + } + + /** + * This corresponds to a timestamp of the last time the server IP was updated. + * This may be used to check if the thread crashed / if something gones wrong. + */ + pub async fn get_last_updated_server_ip(&self) -> Instant { + self.last_updated_server_ip + } +} + diff --git a/frontend/src-tauri/src/fetch_informations.rs b/frontend/src-tauri/src/fetch_informations.rs new file mode 100644 index 00000000..03c01525 --- /dev/null +++ b/frontend/src-tauri/src/fetch_informations.rs @@ -0,0 +1,336 @@ +use std::string::String; +use std::sync::Arc; +use tokio::sync::RwLock; +use crate::api::Api; +use etherparse::PacketHeaders; +use anyhow::{Result, bail}; +use std::time::{Duration, Instant}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::mem::size_of_val; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::net::UdpSocket as StdSocket; +use tokio::net::UdpSocket; +use std::os::windows::io::{AsRawSocket, FromRawSocket, IntoRawSocket}; +use std::ptr::null_mut; +use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo}; +use winapi::shared::minwindef::DWORD; +use winapi::um::winsock2; +use crate::api::GameStatus; +use sysinfo::{System}; + +const SIO_RCVALL: DWORD = 0x98000001; + + +pub async fn init() -> std::result::Result>, anyhow::Error> { + let api_base = Arc::new(RwLock::new(Api::new())); + let api = Arc::clone(&api_base); + + tokio::spawn(async move { + loop { + // Fetch pid game + let pid = find_pid_of("SoTGame.exe"); + + if pid.is_empty() { + api.write().await.game_status = GameStatus::Closed; + } else { + let pid = pid[0].parse().unwrap(); + // List of udp sockets used by the game + let udp_connections = get_udp_connections(pid); + + // Update game_status + if udp_connections.len() != 2 { + api.write().await.game_status = match udp_connections.len() { //TODO Optimization: Cache game_status and only update when it changes + 0 => GameStatus::Started, + 1 => GameStatus::MainMenu, + _ => GameStatus::Unknown + }; + } + + // 2 sockets = connected to a server + if udp_connections.len() == 2 { + // Get UDP Listen port + let listen_port = udp_connections[1]; // The first one is for MainMenu socket, second one is game server + + // Get hostname + let hostname = match get_local_hostname() { + Ok(hn) => hn, + Err(e) => { + eprintln!("Error getting local hostname: {}", e); + continue; + } + }; + + // We need to add the port to the hostname to get the IP + let hostname = format!("{}:0", hostname); + let ip_addresses = match hostname.to_socket_addrs() { //TODO Optimization: Cache ip_addresses + Ok(addrs) => addrs.map(|socket_addr| socket_addr.ip()).collect::>(), + Err(e) => { + eprintln!("Error getting IP addresses: {}", e); + continue; + } + }; + + // Object for each "local" IP used by every network interface (ipv4 and ipv6) + let socket_addresses: Vec = ip_addresses.into_iter().map(|ip| SocketAddr::new(ip, 0)).collect(); + for socket_addr in socket_addresses { + let api_clone = Arc::clone(&api); + + // One thread / IP + tokio::spawn(async move { + // Init RAW listen socket + let socket = match create_raw_socket(socket_addr).await { + Ok(socket) => socket, + Err(e) => { + eprintln!("Error creating raw socket: {}", e); + return; + } + }; + + // Capture the IP by filtering headers + match capture_ip(socket, listen_port).await { + Some((ip, port)) => { + // Got an IP, lock api and update every information + let mut api_lock = api_clone.write().await; + api_lock.game_status = GameStatus::InGame; + api_lock.server_ip = ip; + api_lock.server_port = port; + api_lock.last_updated_server_ip = Instant::now(); + + // Release the lock + drop(api_lock); + } + + None => { + // Got no result, get the last update time and check if it's too old + // This is not a typical timeout and should never happen, it's a security + let last_updated_server_ip = api_clone.read().await.last_updated_server_ip; + if last_updated_server_ip.elapsed() > Duration::from_secs(20) { + println!("Resetting server_ip, no result"); + let mut api_lock = api_clone.write().await; + + api_lock.server_ip = String::new(); + api_lock.server_port = 0; + api_lock.last_updated_server_ip = Instant::now(); + + drop(api_lock); + } + } + } + }); + } + } + } + + let game_status = api.read().await.game_status.clone(); + let dynamic_time = match game_status { + GameStatus::Closed => 5000, + GameStatus::Started => 3000, + GameStatus::MainMenu => 500, + GameStatus::InGame => 3000, + GameStatus::Unknown => 2000, + }; + + tokio::time::sleep(Duration::from_millis(dynamic_time)).await; + } + }); + + Ok(api_base) +} + +// Fetch game UDP connections +fn get_udp_connections(target_pid: usize) -> Vec { + let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6; + let proto_flags = ProtocolFlags::UDP; + let sockets_info = match get_sockets_info(af_flags, proto_flags) { + Ok(sockets_info) => sockets_info, + Err(e) => { + eprintln!("Failed to get socket information: {}", e); + return Vec::new(); + } + }; + + let ports: Vec = sockets_info.iter().filter_map(|si| { + if let ProtocolSocketInfo::Udp(udp_si) = &si.protocol_socket_info { + // Check if any of the associated PIDs match the target PID + if si.associated_pids.iter().any(|&pid| pid == (target_pid as u32)) { + Some(udp_si.local_port) + } else { None } + } else { + None // This line is technically unnecessary due to the UDP filter applied earlier + } + }).collect(); + + //Filter out duplicates + return ports.into_iter().collect::>().into_iter().collect(); +} + +// Get local hostname +fn get_local_hostname() -> std::io::Result { + Ok(hostname::get()?.into_string().unwrap_or_else(|_| "localhost".into())) +} + +// Get game PID +pub fn find_pid_of(process_name: &str) -> Vec { + let mut system = System::new_all(); + let mut pids = Vec::new(); + system.refresh_all(); + + for (pid, process) in system.processes() { + if process.name().to_lowercase() == process_name.to_lowercase() { + pids.push(pid.to_string()); + } + } + + pids +} + +// Receive & filter packets to get the server IP +async fn capture_ip(socket: UdpSocket, listen_port: u16) -> Option<(String, u16)> { + let mut buf = [0u8; (256 * 256) - 1]; + let timeout = Duration::from_millis(2000); + let start_time = Instant::now(); + + loop { + if start_time.elapsed() > timeout { + // Timeout reached without receiving a packet + return None; + } + + // select! macro is used to wait for the first of two futures to complete, returning the result of that future. + tokio::select! { + recv_result = socket.recv(&mut buf) => { + match recv_result { + Ok(len) if len > 0 => { + // We got a packet, let's parse it + let recv_result = socket.recv(&mut buf).await; + return match recv_result { + Ok(len) => { + let packet = PacketHeaders::from_ip_slice(&buf[0..len]).ok()?; + + let net = packet.net.unwrap(); + let transport = packet.transport.unwrap(); + + // Parse source_ip and destination_ip + let (source_ip, destination_ip) = match net { + etherparse::NetHeaders::Ipv4(header, _) => { + let source = std::net::Ipv4Addr::new(header.source[0], header.source[1], header.source[2], header.source[3]); + let destination = std::net::Ipv4Addr::new(header.destination[0], header.destination[1], header.destination[2], header.destination[3]); + (source.to_string(), destination.to_string()) + }, + etherparse::NetHeaders::Ipv6(header, _) => { + let source = std::net::Ipv6Addr::from(header.source); + let destination = std::net::Ipv6Addr::from(header.destination); + (source.to_string(), destination.to_string()) + }, + }; + + let source_port; + let destination_port; + + // Parse ports, we don't need to support anything else than UDP + match transport { + etherparse::TransportHeader::Udp(header) => { + source_port = header.source_port; + destination_port = header.destination_port; + }, + _ => return None + } + + let mut remote_ip = String::new(); + let mut remote_port = 0; + + if source_port == listen_port { + // We are the source + remote_ip = destination_ip; + remote_port = destination_port; + } else if destination_port == listen_port { + // We are the destination + remote_ip = source_ip; + remote_port = source_port; + } + + if remote_port > 30000 && remote_port < 40000 { + // Got a plausible result + Some((remote_ip, remote_port)) + } else { + // Result make no sense for SoT + None + } + } + Err(err) => { + eprintln!("Error receiving packet: {}", err); + None + } + } + }, + + Ok(_) => (), + Err(e) => eprintln!("Error receiving packet: {}", e), + } + } + _ = tokio::time::sleep(timeout) => { + // Timeout reached without receiving a packet + return None; + } + } + } +} + + +// Puts a socket into promiscuous mode so that it can receive all packets. +async fn enter_promiscuous(socket: &mut StdSocket) -> Result<()> { + let rc = unsafe { + let in_value: DWORD = 1; + + let mut out: DWORD = 0; + winsock2::WSAIoctl( + socket.as_raw_socket() as usize, + SIO_RCVALL, + &in_value as *const _ as *mut _, + size_of_val(&in_value) as DWORD, + null_mut(), //out value + 0, //size of out value + &mut out as *mut _, //byte returned + null_mut(), //pointer zero + None, + ) + }; + if rc == winsock2::SOCKET_ERROR { + bail!("WSAIoctl() failed: {}", unsafe { winsock2::WSAGetLastError() }) + } else { + Ok(()) + } +} + +// Creates a raw socket used to capture packets (disguised as a UdpSocket) +pub async fn create_raw_socket(socket_addr: SocketAddr) -> Result { + // Specify protocol + let protocol = Protocol::UDP; // IPPROTO_IP is typically 0 + + // Check if IPv4 or IPv6 + let domain = match socket_addr.ip() { + IpAddr::V4(_) => Domain::IPV4, + IpAddr::V6(_) => Domain::IPV6, + }; + + // Create a raw socket with domain, Type::RAW, and IPPROTO_IP + let socket = Socket::new(domain, Type::RAW, Some(protocol))?; + socket.set_nonblocking(true)?; + + // Convert SocketAddr to SockAddr + let sock_addr = socket2::SockAddr::from(socket_addr); + + // Bind the socket using a reference to the parsed address + socket.bind(&sock_addr)?; + + // Raw socket + let raw_socket = socket.into_raw_socket(); + let mut socket = unsafe { StdSocket::from_raw_socket(raw_socket) }; + enter_promiscuous(&mut socket).await?; + + // Set a read timeout of 500ms + socket.set_read_timeout(Some(Duration::from_millis(500)))?; + + let socket = UdpSocket::from_std(socket)?; + Ok(socket) +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index f5c5be23..667c9618 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -1,8 +1,59 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -fn main() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running tauri application"); +use std::sync::Arc; +use tauri::State; +use tokio::sync::RwLock; +use crate::api::{Api, GameStatus}; + +mod fetch_informations; +mod api; + +// Here's how to call Rust functions from frontend : https://tauri.app/v1/guides/features/command/ + +#[tokio::main] +async fn main() { + let api_arc = fetch_informations::init().await.expect("Failed to initialize API"); + + tauri::Builder::default() + .manage(api_arc) + .invoke_handler(tauri::generate_handler![ + get_game_status, + get_server_ip, + get_server_port, + get_last_updated_server_ip + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[tauri::command] +async fn get_game_status(api: State<'_, Arc>>) -> Result { + let api_lock = api.inner().read().await; + Ok(api_lock.get_game_status().await) } + +#[tauri::command] +async fn get_server_ip(api: State<'_, Arc>>) -> Result { + let api_lock = api.inner().read().await; + Ok(api_lock.get_server_ip().await) +} + +#[tauri::command] +async fn get_server_port(api: State<'_, Arc>>) -> Result { + let api_lock = api.inner().read().await; + Ok(api_lock.get_server_port().await) +} + +#[tauri::command] +async fn get_last_updated_server_ip(api: State<'_, Arc>>) -> Result { + let api_lock = api.inner().read().await; + + let instant = api_lock.get_last_updated_server_ip().await; + let now = std::time::SystemTime::now(); + let epoch = std::time::UNIX_EPOCH; + let duration_since_epoch = now.duration_since(epoch).expect("Time went backwards"); + let instant_duration = instant.elapsed(); + let total_duration = duration_since_epoch.checked_sub(instant_duration).expect("Time went backwards"); + Ok(total_duration.as_secs()) +} \ No newline at end of file