diff --git a/Cargo.toml b/Cargo.toml index fa1bcb5..8ec77d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,32 +4,26 @@ description = "Get server addresses from QuakeWorld master servers." keywords = ["masters", "quake", "quakeworld", "servers"] repository = "https://github.com/quakeworld/masterstat" authors = ["Viktor Persson "] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT" -include = [ - "/Cargo.toml", - "/LICENSE", - "/README.md", - "/src/**", - "/tests/**", -] - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +include = ["/Cargo.toml", "/LICENSE", "/README.md", "/src/**", "/tests/**"] [dependencies] anyhow = "1.0.97" -binrw = "0.14.1" +binrw = "0.15.0" futures = "0.3.31" -tokio = { version = "1.44.1", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } -tinyudp = "0.5.1" - -serde = { optional = true, version = "1.0.219", features = ["derive"] } -serde_json = { optional = true, version = "1.0.140" } +tokio = { version = "1.44.1", features = [ + "macros", + "net", + "rt-multi-thread", + "sync", + "time", +] } +tinyudp = { version = "0.6.0", features = ["tokio"] } [dev-dependencies] pretty_assertions = "1.4.1" [features] ci = [] -json = ["dep:serde", "dep:serde_json"] diff --git a/README.md b/README.md index 14643a2..f4793ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # masterstat [![Test](https://github.com/quakeworld/masterstat/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/quakeworld/masterstat/actions/workflows/test.yml) [![crates](https://img.shields.io/crates/v/masterstat)](https://crates.io/crates/masterstat) [![docs.rs](https://img.shields.io/docsrs/masterstat)](https://docs.rs/masterstat/) -> Get server addresses from QuakeWorld master servers +> A Rust crate for querying QuakeWorld master servers ## Installation diff --git a/src/command.rs b/src/command.rs deleted file mode 100644 index 6a70d5f..0000000 --- a/src/command.rs +++ /dev/null @@ -1,199 +0,0 @@ -use std::io::Cursor; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Result, anyhow as e}; -use binrw::BinRead; -use tokio::sync::Mutex; - -use crate::server_address::{RawServerAddress, ServerAddress}; - -/// Get server addresses from a single master server -/// -/// # Example -/// -/// ``` -/// use std::time::Duration; -/// -/// async fn test() { -/// let master = "master.quakeworld.nu:27000"; -/// let timeout = Duration::from_secs(2); -/// match masterstat::server_addresses(&master, timeout).await { -/// Ok(result) => { println!("found {} server addresses", result.len()) }, -/// Err(e) => { eprintln!("error: {}", e); } -/// } -/// } -/// ``` -pub async fn server_addresses( - master_address: &str, - timeout: Duration, -) -> Result> { - let servers = get_server_addresses(master_address, timeout).await?; - Ok(sorted_and_unique(&servers)) -} - -/// Get server addresses from many master servers (concurrently) -/// -/// # Example -/// -/// ``` -/// use std::time::Duration; -/// -/// async fn test() { -/// let masters = ["master.quakeworld.nu:27000", "master.quakeservers.net:27000"]; -/// let timeout = Duration::from_secs(2); -/// let result = masterstat::server_addresses_from_many(&masters, timeout).await; -/// println!("found {} server addresses", result.len()); -/// } -/// ``` -pub async fn server_addresses_from_many( - master_addresses: &[impl AsRef], - timeout: Duration, -) -> Vec { - let mut task_handles = vec![]; - let result_mux = Arc::>>::default(); - - for master_address in master_addresses.iter().map(|a| a.as_ref().to_string()) { - let result_mux = result_mux.clone(); - let task = tokio::spawn(async move { - if let Ok(servers) = get_server_addresses(&master_address, timeout).await { - let mut result = result_mux.lock().await; - result.extend(servers); - } - }); - task_handles.push(task); - } - - futures::future::join_all(task_handles).await; - - let server_addresses = result_mux.lock().await.clone(); - sorted_and_unique(&server_addresses) -} - -async fn get_server_addresses( - master_address: &str, - timeout: Duration, -) -> Result> { - const STATUS_MSG: [u8; 3] = [99, 10, 0]; - let response = tinyudp::send_and_receive( - master_address, - &STATUS_MSG, - tinyudp::ReadOptions { - timeout, - buffer_size: 64 * 1024, // 64 kb - }, - ) - .await?; - parse_response(&response) -} - -fn parse_response(response: &[u8]) -> Result> { - const RESPONSE_HEADER: [u8; 6] = [255, 255, 255, 255, 100, 10]; - - if !response.starts_with(&RESPONSE_HEADER) { - return Err(e!("Invalid response")); - } - - let body = &mut Cursor::new(&response[RESPONSE_HEADER.len()..]); - let mut server_addresses = vec![]; - - while let Ok(raw_address) = RawServerAddress::read(body) { - server_addresses.push(ServerAddress::from(raw_address)); - } - - Ok(server_addresses) -} - -fn sorted_and_unique(server_addresses: &[ServerAddress]) -> Vec { - let mut servers = server_addresses.to_vec(); - servers.sort(); - servers.dedup(); - servers -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[tokio::test] - #[cfg_attr(feature = "ci", ignore)] - async fn test_server_addresses() -> Result<()> { - let master = "master.quakeservers.net:27000"; - let timeout = Duration::from_secs(2); - let result = server_addresses(master, timeout).await?; - assert!(!result.is_empty()); - Ok(()) - } - - #[tokio::test] - #[cfg_attr(feature = "ci", ignore)] - async fn test_server_addresses_from_many() -> Result<()> { - let masters = [ - "master.quakeservers.net:27000", - "master.quakeworld.nu:27000", - ]; - let timeout = Duration::from_secs(2); - let result = server_addresses_from_many(&masters, timeout).await; - assert!(result.len() > 500); - Ok(()) - } - - #[tokio::test] - async fn test_parse_response() -> Result<()> { - // invalid response header - { - let response = [0xff, 0xff]; - let result = parse_response(&response); - assert_eq!(result.unwrap_err().to_string(), "Invalid response"); - } - - // valid response - { - let response = [ - 0xff, 0xff, 0xff, 0xff, 0x64, 0x0a, 192, 168, 1, 1, 0x75, 0x30, 192, 168, 1, 2, - 0x75, 0x30, - ]; - let result = parse_response(&response)?; - assert_eq!(result.len(), 2); - assert_eq!(result[0].ip, "192.168.1.1"); - assert_eq!(result[0].port, 30000); - assert_eq!(result[1].ip, "192.168.1.2"); - assert_eq!(result[1].port, 30000); - } - - Ok(()) - } - - #[test] - fn test_sorted_and_unique() { - let server1_1 = ServerAddress { - ip: "192.168.1.1".to_string(), - port: 1, - }; - let server1_2 = ServerAddress { - ip: "192.168.1.1".to_string(), - port: 2, - }; - let server3 = ServerAddress { - ip: "192.168.1.3".to_string(), - port: 1, - }; - let server4 = ServerAddress { - ip: "192.168.1.4".to_string(), - port: 1, - }; - let servers = vec![ - server4.clone(), - server4.clone(), - server4.clone(), - server1_1.clone(), - server1_2.clone(), - server3.clone(), - ]; - assert_eq!( - sorted_and_unique(&servers), - vec![server1_1, server1_2, server3, server4] - ); - } -} diff --git a/src/lib.rs b/src/lib.rs index 4553536..ab2a84a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,8 +2,9 @@ //! //! Get server addresses from QuakeWorld master servers. -mod command; +mod query; +mod query_multiple; mod server_address; -pub use crate::command::{server_addresses, server_addresses_from_many}; -pub use crate::server_address::ServerAddress; +pub use crate::query::query; +pub use crate::query_multiple::{MultiQueryResult, query_multiple}; diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..7c44fd4 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,87 @@ +use std::{io::Cursor, time::Duration}; + +use anyhow::{Result, anyhow as e}; +use binrw::BinRead; + +use crate::server_address::RawServerAddress; + +/// Get server addresses from a single master server +/// +/// # Example +/// +/// ``` +/// use std::time::Duration; +/// +/// async fn test() { +/// let master = "master.quakeworld.nu:27000"; +/// let timeout = Duration::from_secs(2); +/// match masterstat::query(&master, timeout).await { +/// Ok(addresses) => { println!("found {} server addresses", addresses.len()) }, +/// Err(e) => { eprintln!("error: {}", e); } +/// } +/// } +/// ``` +pub async fn query(master_address: &str, timeout: Duration) -> Result> { + const STATUS_MSG: [u8; 3] = [99, 10, 0]; + const BUFFER_SIZE: usize = 64 * 1024; + let options = tinyudp::ReadOptions::new(timeout, BUFFER_SIZE); + let response = tinyudp::send_and_receive_async(master_address, &STATUS_MSG, options).await?; + parse_response(&response) +} + +fn parse_response(response: &[u8]) -> Result> { + const RESPONSE_HEADER: [u8; 6] = [255, 255, 255, 255, 100, 10]; + + if !response.starts_with(&RESPONSE_HEADER) { + return Err(e!("Invalid response")); + } + + let body = &mut Cursor::new(&response[RESPONSE_HEADER.len()..]); + let mut addresses = vec![]; + + while let Ok(raw_address) = RawServerAddress::read(body) { + addresses.push(raw_address.to_string()); + } + + Ok(addresses) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[tokio::test] + #[cfg_attr(feature = "ci", ignore)] + async fn test_query() -> Result<()> { + let master = "master.quakeservers.net:27000"; + let timeout = Duration::from_secs(2); + let result = query(master, timeout).await?; + assert!(!result.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_parse_response() -> Result<()> { + // invalid response header + { + let response = [0xff, 0xff]; + let result = parse_response(&response); + assert_eq!(result.unwrap_err().to_string(), "Invalid response"); + } + + // valid response + { + let response = [ + 0xff, 0xff, 0xff, 0xff, 0x64, 0x0a, 192, 168, 1, 1, 0x75, 0x30, 192, 168, 1, 2, + 0x75, 0x30, + ]; + let result = parse_response(&response)?; + assert_eq!(result.len(), 2); + assert_eq!(result[0], "192.168.1.1:30000".to_string()); + assert_eq!(result[1], "192.168.1.2:30000".to_string()); + } + + Ok(()) + } +} diff --git a/src/query_multiple.rs b/src/query_multiple.rs new file mode 100644 index 0000000..bfc1658 --- /dev/null +++ b/src/query_multiple.rs @@ -0,0 +1,154 @@ +use futures::future; +use std::time::Duration; + +use crate::query; + +#[derive(Debug, Default)] +pub struct QuerySuccess { + master_address: String, + server_addresses: Vec, +} +impl QuerySuccess { + pub fn master_address(&self) -> &str { + &self.master_address + } + + pub fn server_addresses(&self) -> &[String] { + &self.server_addresses + } +} + +#[derive(Debug)] +pub struct QueryFailure { + master_address: String, + error: anyhow::Error, +} +impl QueryFailure { + pub fn master_address(&self) -> &str { + &self.master_address + } + + pub fn error(&self) -> &anyhow::Error { + &self.error + } +} + +#[derive(Debug, Default)] +pub struct MultiQueryResult { + /// Collection of successful queries. + successes: Vec, + + /// Collection of failed queries. + failures: Vec, +} + +impl MultiQueryResult { + /// Iterator over successful query results. + pub fn successful_queries(&self) -> impl Iterator { + self.successes.iter() + } + + /// Iterator over failed query results. + pub fn failed_queries(&self) -> impl Iterator { + self.failures.iter() + } + + /// Unique server addresses from successful queries. + pub fn server_addresses(&self) -> Vec { + let mut addresses: Vec = self + .successes + .iter() + .flat_map(|res| res.server_addresses().iter()) + .cloned() + .collect(); + addresses.sort(); + addresses.dedup(); + addresses + } +} + +/// Get server addresses from multiple master servers (concurrently) +/// +/// # Arguments +/// * `master_addresses` - A slice of master server addresses to query. +/// * `timeout` - The timeout duration for each query. +/// +/// # Returns +/// A `MultiQueryResult` containing successful and failed queries. +/// +/// # Example +/// ```rust +/// #[tokio::main] +/// async fn main() { +/// let master_addresses = vec![ +/// "master.quakeservers.net:27000".to_string(), +/// "master.quakeworld.nu:27000".to_string(), +/// ]; +/// let timeout = std::time::Duration::from_secs(2); +/// let result = masterstat::query_multiple(&master_addresses, timeout).await; +/// } +/// ``` +pub async fn query_multiple(master_addresses: &[String], timeout: Duration) -> MultiQueryResult { + let tasks = master_addresses + .iter() + .map(|address| async move { (address.clone(), query(address, timeout).await) }); + + let mut results = MultiQueryResult::default(); + + for (master_address, res) in future::join_all(tasks).await { + match res { + Ok(server_addresses) => results.successes.push(QuerySuccess { + master_address, + server_addresses, + }), + Err(error) => results.failures.push(QueryFailure { + master_address, + error, + }), + } + } + + results +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + + #[tokio::test] + #[cfg_attr(feature = "ci", ignore)] + async fn test_query_multiple() -> Result<()> { + let master_addresses = vec![ + "master.quakeservers.net:27000".to_string(), + "master.quakeworld.nu:27000".to_string(), + "INVALID:27000".to_string(), + ]; + let results = query_multiple(&master_addresses, Duration::from_secs(2)).await; + + assert!(results.server_addresses().len() >= 300); + assert!(2 == results.successful_queries().count()); + assert!(1 == results.failed_queries().count()); + + let query1 = results.successful_queries().next().unwrap(); + assert!(query1.server_addresses().len() >= 300); + assert_eq!( + query1.master_address(), + "master.quakeservers.net:27000".to_string() + ); + + let query2 = results.successful_queries().last().unwrap(); + assert!(query2.server_addresses().len() >= 300); + assert_eq!( + query2.master_address(), + "master.quakeworld.nu:27000".to_string() + ); + + let query3 = results.failed_queries().next().unwrap(); + assert_eq!(query3.master_address(), "INVALID:27000".to_string()); + assert!(query3.error().to_string().contains("failed to lookup")); + + Ok(()) + } +} diff --git a/src/server_address.rs b/src/server_address.rs index 9c48b15..055c896 100644 --- a/src/server_address.rs +++ b/src/server_address.rs @@ -1,110 +1,44 @@ use binrw::BinRead; use std::fmt::Display; -#[cfg(feature = "json")] -use serde::{Serialize, Serializer}; - -#[derive(BinRead)] +#[derive(Debug, BinRead, PartialEq)] #[br(big)] pub(crate) struct RawServerAddress { ip: [u8; 4], port: u16, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ServerAddress { - pub ip: String, - pub port: u16, -} - -impl Display for ServerAddress { +impl Display for RawServerAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", self.ip, self.port) - } -} - -impl From for ServerAddress { - fn from(raw: RawServerAddress) -> Self { - ServerAddress { - ip: raw.ip.map(|b| b.to_string()).join("."), - port: raw.port, - } - } -} - -impl From<&ServerAddress> for String { - fn from(addr: &ServerAddress) -> Self { - addr.to_string() - } -} - -#[cfg(feature = "json")] -impl Serialize for ServerAddress { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.to_string()) + let ip_str = self.ip.map(|b| b.to_string()).join("."); + write!(f, "{}:{}", ip_str, self.port) } } #[cfg(test)] mod tests { - use crate::server_address::{RawServerAddress, ServerAddress}; - use anyhow::Result; + use crate::server_address::RawServerAddress; use binrw::BinRead; use pretty_assertions::assert_eq; use std::io::Cursor; #[test] - fn test_raw_server_address() -> Result<()> { - let raw_address = RawServerAddress::read(&mut Cursor::new(&[192, 168, 1, 1, 117, 48]))?; - assert_eq!(raw_address.ip, [192, 168, 1, 1]); - assert_eq!(raw_address.port, 30000); - Ok(()) + fn test_read() { + assert_eq!( + RawServerAddress::read(&mut Cursor::new(&[192, 168, 1, 1, 117, 48])).unwrap(), + RawServerAddress { + ip: [192, 168, 1, 1], + port: 30000 + } + ); } #[test] - fn test_server_address_from_raw_server_address() { - let raw_address = RawServerAddress { + fn test_display() { + let address = RawServerAddress { ip: [192, 168, 1, 1], port: 30000, }; - let address = ServerAddress::from(raw_address); - assert_eq!(address.ip, "192.168.1.1"); - assert_eq!(address.port, 30000); - } - - #[test] - fn test_from_server_address_ref_for_string() { - let address = ServerAddress { - ip: "10.10.10.10".to_string(), - port: 30000, - }; - let address_str: String = String::from(&address); - assert_eq!(address_str, "10.10.10.10:30000"); - } - - #[test] - fn test_server_address_display() { - let address = ServerAddress { - ip: "192.168.1.1".to_string(), - port: 30000, - }; - assert_eq!(address.to_string(), "192.168.1.1:30000"); - } - - #[cfg(feature = "json")] - #[test] - fn test_serialize() -> Result<()> { - assert_eq!( - serde_json::to_string(&ServerAddress { - ip: "10.10.10.10".to_string(), - port: 30000, - })?, - r#""10.10.10.10:30000""# - ); - - Ok(()) + assert_eq!(address.to_string(), "192.168.1.1:30000".to_string()); } }