diff --git a/Cargo.lock b/Cargo.lock index e74f8a3..748cfd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ version = "0.10.5" dependencies = [ "blake3", "colored", + "futures", "futures-util", "indicatif", "mslnk", @@ -410,9 +411,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -441,9 +442,9 @@ checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", diff --git a/Cargo.toml b/Cargo.toml index 86681eb..ce27ba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ colored = "3.0.0" once_cell = "1.20.3" reqwest = { version = "0.12.12", features = ["stream"] } futures-util = "0.3.31" +futures = "0.3.31" indicatif = "0.17.11" tokio = { version="1.43.0", features = ["rt-multi-thread", "macros"] } simple-log = "2.3.0" diff --git a/src/cdn.rs b/src/cdn.rs new file mode 100644 index 0000000..85de482 --- /dev/null +++ b/src/cdn.rs @@ -0,0 +1,161 @@ +use crate::global::CDN_HOSTS; +use crate::http; +use futures::future::join_all; +use simple_log::*; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +static CURRENT_CDN: Mutex>> = Mutex::new(None); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Region { + Europe, + Global, +} + +#[derive(Clone, Copy, Debug)] +pub struct Server { + pub host: &'static str, + pub rating: u8, + pub latency: Option, + pub region: Region, +} + +impl Server { + pub const fn new(host: &'static str, region: Region) -> Self { + Server { + host, + rating: 255, + latency: None, + region, + } + } + + pub fn url(&self) -> String { + format!("https://{}/", self.host) + } + + async fn rate(&mut self, asn: u32, is_initial: bool) { + let timeout = if is_initial { + Duration::from_millis(500) + } else { + Duration::from_millis(5000) + }; + match http::rating_request(&self.url(), timeout).await { + Ok((latency, is_cloudflare)) => { + self.latency = Some(latency); + // Always use complete rating calculation with all available information + self.rating = self.calculate_rating(latency, is_cloudflare, asn); + + info!( + "Server {} rated {} ({}ms, rating: {}, cloudflare: {}, region: {:?})", + self.host, + self.rating, + latency.as_millis(), + self.rating, + is_cloudflare, + self.region + ); + } + Err(e) => { + error!("Failed to connect to {}: {}", self.host, e); + self.rating = 0; + self.latency = None; + } + } + } + + fn calculate_initial_rating(&self, latency: std::time::Duration) -> u8 { + let mut rating: f32 = 255.0; + let ms = latency.as_millis() as f32; + let latency_mult = (200.0 / ms.max(200.0)).powf(0.5); + rating *= latency_mult; + rating.clamp(1.0, 255.0) as u8 + } + + fn calculate_rating(&self, latency: std::time::Duration, is_cloudflare: bool, asn: u32) -> u8 { + let mut rating = self.calculate_initial_rating(latency); + + // Additional factors for full rating + if is_cloudflare { + // 3320/DTAG: bad cf peering + // 5483/Magyar Telekom: sub. of DTAG + if asn == 3320 || asn == 5483 { + rating = (rating as f32 * 0.1) as u8; + } + } + + if self.region == Region::Global { + rating = (rating as f32 * 1.1).min(255.0) as u8; + } + + rating + } +} + +pub struct Hosts { + pub servers: Vec, + pub active_index: RwLock>, +} + +impl Hosts { + /// create new rated hosts instance + pub async fn new() -> Self { + let mut hosts = Hosts { + servers: CDN_HOSTS.to_vec(), + active_index: RwLock::new(None), + }; + + let asn = crate::http::get_asn().await; + hosts.rate(asn, true).await; + hosts + } + + /// get the URL of the currently active CDN + pub fn active_url(&self) -> Option { + CURRENT_CDN.lock().unwrap().as_ref().map(|s| s.url()) + } + + /// set the next best host based on ratings + pub fn next(&self) -> bool { + if self.servers.is_empty() { + return false; + } + + // find best host by rating, fifo if equal + if let Some((idx, _)) = self + .servers + .iter() + .enumerate() + .max_by_key(|(idx, server)| (server.rating, -(*idx as i32))) + { + let server = &self.servers[idx]; + *CURRENT_CDN.lock().unwrap() = Some(Arc::new(*server)); + *self.active_index.write().unwrap() = Some(idx); + true + } else { + false + } + } + + /// rate and order all servers, then select the best one + pub async fn rate(&mut self, asn: u32, is_initial: bool) { + let rating_futures: Vec<_> = self + .servers + .iter_mut() + .map(|server| server.rate(asn, is_initial)) + .collect(); + + join_all(rating_futures).await; + + // reset state and select best host + *self.active_index.write().unwrap() = None; + *CURRENT_CDN.lock().unwrap() = None; + self.next(); + } + + /// Get the best CDN URL for use + pub fn get_master_url(&self) -> Option { + self.active_url() + } +} diff --git a/src/global.rs b/src/global.rs index 96b9b9a..696c8e8 100644 --- a/src/global.rs +++ b/src/global.rs @@ -1,19 +1,35 @@ -use crate::http_async; use crate::structs::PrintPrefix; use colored::Colorize; use once_cell::sync::Lazy; -use serde_json::Value; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Mutex; +use crate::cdn::{Hosts, Region, Server}; + pub const GH_OWNER: &str = "mxve"; pub const GH_REPO: &str = "alterware-launcher"; pub const GH_IW4X_OWNER: &str = "iw4x"; pub const GH_IW4X_REPO: &str = "iw4x-client"; pub const DEFAULT_MASTER: &str = "https://cdn.alterware.ovh"; -pub const BACKUP_MASTER: &str = "https://cdn.getserve.rs"; + +pub const CDN_HOSTS: [Server; 2] = [ + Server::new("cdn.alterware.ovh", Region::Global), + Server::new("cdn.iw4x.dev", Region::Europe), +]; + +pub const IP2ASN: &str = "https://ip2asn.getserve.rs/v1/as/ip/self"; + +pub static USER_AGENT: Lazy = Lazy::new(|| { + format!( + "AlterWare Launcher v{} on {} | github.com/{}/{}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + GH_OWNER, + GH_REPO + ) +}); pub static MASTER_URL: Lazy> = Lazy::new(|| Mutex::new(String::from(DEFAULT_MASTER))); @@ -66,58 +82,53 @@ pub static PREFIXES: Lazy> = Lazy::new(|| { ]) }); +pub async fn check_connectivity_and_rate_cdns() -> Pin + Send>> { + Box::pin(async move { + crate::println_info!("Initializing CDN rating system..."); + let hosts = Hosts::new().await; + let best_cdn = hosts.get_master_url(); + + if let Some(cdn_url) = best_cdn { + let cdn_url = cdn_url.trim_end_matches('/'); + *MASTER_URL.lock().unwrap() = cdn_url.to_string(); + crate::println_info!("Selected CDN: {}", cdn_url); + + match crate::http_async::get_body_string(cdn_url).await { + Ok(_) => { + info!("Successfully connected to CDN: {}", cdn_url); + true + } + Err(e) => { + error!("Failed to connect to selected CDN {}: {}", cdn_url, e); + *IS_OFFLINE.lock().unwrap() = true; + false + } + } + } else { + crate::println_error!("No CDN hosts are available"); + *IS_OFFLINE.lock().unwrap() = true; + false + } + }) +} + pub fn check_connectivity( master_url: Option, ) -> Pin + Send>> { Box::pin(async move { - let retry = master_url.is_some(); - if !retry { - crate::println_info!("Running connectivity check on {}", DEFAULT_MASTER); - } else { - let master = master_url.unwrap(); - *MASTER_URL.lock().unwrap() = master.clone(); - crate::println_info!("Running connectivity check on {}", master); - } + if let Some(url) = master_url { + *MASTER_URL.lock().unwrap() = url.clone(); + crate::println_info!("Using fallback connectivity check on {}", url); - let master_url = MASTER_URL.lock().unwrap().clone(); - - // Check ASN number using the new get_json function - let asn_response: Result = - http_async::get_json("https://ip2asn.getserve.rs/v1/as/ip/self").await; - - let mut switched_to_backup = false; - - if let Ok(asn_data) = asn_response { - if let Some(as_number) = asn_data.get("as_number").and_then(|v| v.as_i64()) { - if (as_number == 3320 || as_number == 5483) && master_url == DEFAULT_MASTER { - *MASTER_URL.lock().unwrap() = String::from(BACKUP_MASTER); - crate::println_info!( - "Detected Deutsche Telekom/DTAG/Magyar Telekom as ISP, switching to backup master URL: {}", - BACKUP_MASTER - ); - switched_to_backup = true; + match crate::http_async::get_body_string(&url).await { + Ok(_) => true, + Err(_) => { + *IS_OFFLINE.lock().unwrap() = true; + false } } - } - - // Run connectivity check regardless of ASN switch - let result = match crate::http_async::get_body_string(&master_url).await { - Ok(_) => true, - Err(_) => { - *IS_OFFLINE.lock().unwrap() = true; - false - } - }; - - if !result { - crate::println_error!("Failed to connect to CDN {}", master_url); - } - - // If we switched to backup, do not retry - if !retry && !result && !switched_to_backup { - check_connectivity(Some(String::from(BACKUP_MASTER))).await } else { - result + check_connectivity_and_rate_cdns().await.await } }) } diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..85b3061 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,102 @@ +use reqwest::header::HeaderMap; +use serde_json::Value; +use simple_log::*; +use std::time::{Duration, Instant}; + +/// Wrapper to make a quick request and get body +pub async fn quick_request(url: &str) -> Result> { + info!("Making a quick request to: {}", url); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build()?; + + let res = client + .get(url) + .header("User-Agent", crate::global::USER_AGENT.to_string()) + .send() + .await; + + if let Err(e) = &res { + error!("Failed to get {}: {}", url, e); + return Err(format!("Failed to get {} {}", url, e).into()); + } + + let res = res.unwrap(); + match res.text().await { + Ok(text) => { + info!("Successfully received response from: {}", url); + Ok(text) + } + Err(e) => { + warn!("Failed to get response text from {}: {}", url, e); + Err(e.into()) + } + } +} + +/// Check if server is using Cloudflare based on headers +fn is_cloudflare(headers: &HeaderMap) -> bool { + headers.contains_key("cf-ray") + || headers.contains_key("cf-cache-status") + || headers + .get("server") + .is_some_and(|v| v.as_bytes().starts_with(b"cloudflare")) +} + +/// Make a request for rating purposes, measuring latency and detecting Cloudflare +pub async fn rating_request( + url: &str, + timeout: Duration, +) -> Result<(Duration, bool), Box> { + info!( + "Making a rating request to: {} with timeout {:?}", + url, timeout + ); + let client = reqwest::Client::builder().timeout(timeout).build()?; + + let start = Instant::now(); + let res = client + .get(url) + .header("User-Agent", crate::global::USER_AGENT.to_string()) + .send() + .await; + + let latency = start.elapsed(); + + if let Err(e) = &res { + error!("Failed to get {}: {} (after {:?})", url, e, latency); + return Err(format!("Failed to get {} {} (after {:?})", url, e, latency).into()); + } + + let res = res.unwrap(); + let headers = res.headers().clone(); + let is_cloudflare = is_cloudflare(&headers); + + // We don't need the response body for rating + if let Err(e) = res.text().await { + warn!( + "Failed to get response text from {}: {} (after {:?})", + url, e, latency + ); + return Err(e.into()); + } + + info!( + "Successfully rated {} in {:?} (cloudflare: {})", + url, latency, is_cloudflare + ); + Ok((latency, is_cloudflare)) +} + +/// Retrieve client ASN +pub async fn get_asn() -> u32 { + let response = quick_request(crate::global::IP2ASN).await; + if let Ok(as_data_str) = response { + if let Ok(as_data) = serde_json::from_str::(&as_data_str) { + if let Some(as_number) = as_data.get("as_number").and_then(|v| v.as_u64()) { + return as_number as u32; + } + } + } + 0 +} diff --git a/src/main.rs b/src/main.rs index 9944c1f..03c57da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ mod cache; +mod cdn; mod config; mod extend; mod github; mod global; +mod http; mod http_async; mod iw4x; mod misc; @@ -726,7 +728,11 @@ async fn main() { }; if !cfg.offline && !cfg.skip_connectivity_check { - cfg.offline = !global::check_connectivity(initial_cdn).await; + if initial_cdn.is_some() { + cfg.offline = !global::check_connectivity(initial_cdn).await; + } else { + cfg.offline = !global::check_connectivity_and_rate_cdns().await.await; + } } if cfg.offline {