From 4122d34cfafb3a1826a4d6256f5eb0484a583919 Mon Sep 17 00:00:00 2001 From: CosminPerRam Date: Fri, 28 Apr 2023 18:00:04 +0300 Subject: [PATCH] [Service] Add valve master server query service (#34) * [Service] Add initial files * [Service] Add initial request packet * [Service] Add filters * [Service] Some clippy improvements * [Service] Make query a vector of ipv4addr and port * [Service] Add complete and singular query * [Crate] Update md files * [Service] Add docs and clippy adjustments * [Service] Add hasTags and fix filters * [Service] Use let some instead of match * [Service] Add other filters * [Service] Add nor and nand filters * [Service] Remove 0.0.0.0:0 from query * [Service] Remove dev testing test * [Service] Add valve_master_server_query example --- CHANGELOG.md | 3 + SERVICES.md | 6 +- examples/valve_master_server_query.rs | 14 ++ src/lib.rs | 2 + src/services/mod.rs | 4 + src/services/valve_master_server/mod.rs | 7 + src/services/valve_master_server/service.rs | 145 ++++++++++++ src/services/valve_master_server/types.rs | 237 ++++++++++++++++++++ 8 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 examples/valve_master_server_query.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/valve_master_server/mod.rs create mode 100644 src/services/valve_master_server/service.rs create mode 100644 src/services/valve_master_server/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acb431..1146981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Games: - [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support. - [Frontlines: Fuel of War](https://store.steampowered.com/app/9460/Frontlines_Fuel_of_War/) support. +Services: +- [Valve Master Server Query](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) support. + ### Breaking: Protocols: - Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info` diff --git a/SERVICES.md b/SERVICES.md index c1033d2..38d7cac 100644 --- a/SERVICES.md +++ b/SERVICES.md @@ -1,8 +1,8 @@ # Supported services: -| ID | Name | Notes | -|-----|------|-------| -| --- | ---- | ----- | +| Name | Documentation reference | +|---------------------|-------------------------------------------------------------------------------------------------------| +| Valve Master Server | [Master Server Query Protocol](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) | ## Planned to add support: TeamSpeak diff --git a/examples/valve_master_server_query.rs b/examples/valve_master_server_query.rs new file mode 100644 index 0000000..438f3bb --- /dev/null +++ b/examples/valve_master_server_query.rs @@ -0,0 +1,14 @@ +use gamedig::valve_master_server::{query, Filter, Region, SearchFilters}; + +fn main() { + let search_filters = SearchFilters::new() + .insert(Filter::RunsAppID(440)) + .insert(Filter::CanBeEmpty(false)) + .insert(Filter::CanBeFull(false)) + .insert(Filter::CanHavePassword(false)) + .insert(Filter::IsSecured(true)) + .insert(Filter::HasTags(&["minecraft"])); + + let ips = query(Region::Europe, Some(search_filters)).unwrap(); + println!("Servers: {:?} \n Amount: {}", ips, ips.len()); +} diff --git a/src/lib.rs b/src/lib.rs index 0dbd300..8727951 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod errors; #[cfg(not(feature = "no_games"))] pub mod games; pub mod protocols; +pub mod services; mod bufferer; mod socket; @@ -30,3 +31,4 @@ mod utils; pub use errors::*; #[cfg(not(feature = "no_games"))] pub use games::*; +pub use services::*; diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..ba33024 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,4 @@ +//! Services that are currently implemented. + +/// Reference: [Master Server Query Protocol](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) +pub mod valve_master_server; diff --git a/src/services/valve_master_server/mod.rs b/src/services/valve_master_server/mod.rs new file mode 100644 index 0000000..908f8f6 --- /dev/null +++ b/src/services/valve_master_server/mod.rs @@ -0,0 +1,7 @@ +/// The implementation. +pub mod service; +/// All types used by the implementation. +pub mod types; + +pub use service::*; +pub use types::*; diff --git a/src/services/valve_master_server/service.rs b/src/services/valve_master_server/service.rs new file mode 100644 index 0000000..a7d3ec0 --- /dev/null +++ b/src/services/valve_master_server/service.rs @@ -0,0 +1,145 @@ +use crate::bufferer::{Bufferer, Endianess}; +use crate::socket::{Socket, UdpSocket}; +use crate::valve_master_server::{Region, SearchFilters}; +use crate::{GDError, GDResult}; +use std::net::Ipv4Addr; + +/// The default master ip, which is the one for Source. +pub const DEFAULT_MASTER_IP: &str = "hl2master.steampowered.com"; +/// The default master port. +pub const DEFAULT_MASTER_PORT: u16 = 27011; + +fn construct_payload(region: Region, filters: &Option, last_ip: &str, last_port: u16) -> Vec { + let filters_bytes: Vec = match filters { + None => vec![0x00], + Some(f) => f.to_bytes(), + }; + + let region_byte = &[region as u8]; + + [ + // Packet has to begin with the character '1' + &[0x31], + // The region byte is next + region_byte, + // The last fetched ip as a string + last_ip.as_bytes(), + // Followed by an ':' + &[b':'], + // And the port, as a string + last_port.to_string().as_bytes(), + // Which needs to end with a NULL byte + &[0x00], + // Then the filters + &filters_bytes, + ] + .concat() +} + +/// The implementation, use this if you want to keep the same socket. +pub struct ValveMasterServer { + socket: UdpSocket, +} + +impl ValveMasterServer { + /// Construct a new struct. + pub fn new(master_ip: &str, master_port: u16) -> GDResult { + let socket = UdpSocket::new(master_ip, master_port)?; + socket.apply_timeout(None)?; + + Ok(Self { socket }) + } + + /// Make just a single query, providing `0.0.0.0` as the last ip and `0` as + /// the last port will give the initial packet. + pub fn query_specific( + &mut self, + region: Region, + search_filters: &Option, + last_address_ip: &str, + last_address_port: u16, + ) -> GDResult> { + let payload = construct_payload(region, search_filters, last_address_ip, last_address_port); + println!("{:02X?}", payload); + self.socket.send(&payload)?; + + let received_data = self.socket.receive(Some(1400))?; + let mut buf = Bufferer::new_with_data(Endianess::Big, &received_data); + + if buf.get_u32()? != 4294967295 || buf.get_u16()? != 26122 { + return Err(GDError::PacketBad); + } + + let mut ips: Vec<(Ipv4Addr, u16)> = Vec::new(); + while buf.remaining_length() > 0 { + let ip = Ipv4Addr::new(buf.get_u8()?, buf.get_u8()?, buf.get_u8()?, buf.get_u8()?); + let port = buf.get_u16()?; + + ips.push((ip, port)); + } + + Ok(ips) + } + + /// Make a complete query. + pub fn query(&mut self, region: Region, search_filters: Option) -> GDResult> { + let mut ips: Vec<(Ipv4Addr, u16)> = Vec::new(); + + let mut exit_fetching = false; + let mut last_ip: String = "0.0.0.0".to_string(); + let mut last_port: u16 = 0; + + while !exit_fetching { + let new_ips = self.query_specific(region, &search_filters, last_ip.as_str(), last_port)?; + + match new_ips.last() { + None => exit_fetching = true, + Some((latest_ip, latest_port)) => { + let mut remove_last = false; + + let latest_ip_string = latest_ip.to_string(); + if latest_ip_string == "0.0.0.0" && *latest_port == 0 { + exit_fetching = true; + remove_last = true; + } else if latest_ip_string == last_ip && *latest_port == last_port { + exit_fetching = true; + } else { + last_ip = latest_ip_string; + last_port = *latest_port; + } + + ips.extend(new_ips); + if remove_last { + ips.pop(); + } + } + } + } + + Ok(ips) + } +} + +/// Take only the first response of (what would be a) complete query. This is +/// faster as it results in less packets being sent, received and processed but +/// yields less ips. +pub fn query_singular(region: Region, search_filters: Option) -> GDResult> { + let mut master_server = ValveMasterServer::new(DEFAULT_MASTER_IP, DEFAULT_MASTER_PORT)?; + + let mut ips = master_server.query_specific(region, &search_filters, "0.0.0.0", 0)?; + + if let Some((last_ip, last_port)) = ips.last() { + if last_ip.to_string() == "0.0.0.0" && *last_port == 0 { + ips.pop(); + } + } + + Ok(ips) +} + +/// Make a complete query. +pub fn query(region: Region, search_filters: Option) -> GDResult> { + let mut master_server = ValveMasterServer::new(DEFAULT_MASTER_IP, DEFAULT_MASTER_PORT)?; + + master_server.query(region, search_filters) +} diff --git a/src/services/valve_master_server/types.rs b/src/services/valve_master_server/types.rs new file mode 100644 index 0000000..a2e78de --- /dev/null +++ b/src/services/valve_master_server/types.rs @@ -0,0 +1,237 @@ +/// A query filter. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Filter<'a> { + IsSecured(bool), + RunsMap(&'a str), + CanHavePassword(bool), + CanBeEmpty(bool), + IsEmpty(bool), + CanBeFull(bool), + RunsAppID(u32), + NotAppID(u32), + HasTags(&'a [&'a str]), + MatchName(&'a str), + MatchVersion(&'a str), + RestrictUniqueIP(bool), + OnAddress(&'a str), + Whitelisted(bool), + SpectatorProxy(bool), + IsDedicated(bool), + RunsLinux(bool), + HasGameDir(&'a str), +} + +fn bool_as_char_u8(b: bool) -> u8 { + match b { + true => b'1', + false => b'0', + } +} + +impl<'a> Filter<'a> { + pub(crate) fn to_bytes(&self) -> Vec { + let mut bytes: Vec = Vec::new(); + + match self { + Filter::IsSecured(secured) => { + bytes = "\\secure\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*secured)]); + } + Filter::RunsMap(map) => { + bytes = "\\map\\".as_bytes().to_vec(); + bytes.extend(map.as_bytes()); + } + Filter::CanHavePassword(password) => { + bytes = "\\password\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*password)]); + } + Filter::CanBeEmpty(empty) => { + bytes = "\\empty\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*empty)]); + } + Filter::CanBeFull(full) => { + bytes = "\\full\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*full)]); + } + Filter::RunsAppID(id) => { + bytes = "\\appid\\".as_bytes().to_vec(); + bytes.extend(id.to_string().as_bytes()); + } + Filter::HasTags(tags) => { + if !tags.is_empty() { + bytes = "\\gametype\\".as_bytes().to_vec(); + for tag in tags.iter() { + bytes.extend(tag.as_bytes()); + bytes.extend([b',']); + } + + bytes.pop(); + } + } + Filter::NotAppID(id) => { + bytes = "\\napp\\".as_bytes().to_vec(); + bytes.extend(id.to_string().as_bytes()); + } + Filter::IsEmpty(empty) => { + bytes = "\\noplayers\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*empty)]); + } + Filter::MatchName(name) => { + bytes = "\\name_match\\".as_bytes().to_vec(); + bytes.extend(name.as_bytes()); + } + Filter::MatchVersion(version) => { + bytes = "\\version_match\\".as_bytes().to_vec(); + bytes.extend(version.as_bytes()); + } + Filter::RestrictUniqueIP(unique) => { + bytes = "\\collapse_addr_hash\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*unique)]); + } + Filter::OnAddress(address) => { + bytes = "\\gameaddr\\".as_bytes().to_vec(); + bytes.extend(address.as_bytes()); + } + Filter::Whitelisted(whitelisted) => { + bytes = "\\white\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*whitelisted)]); + } + Filter::SpectatorProxy(condition) => { + bytes = "\\proxy\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*condition)]); + } + Filter::IsDedicated(dedicated) => { + bytes = "\\dedicated\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*dedicated)]); + } + Filter::RunsLinux(linux) => { + bytes = "\\linux\\".as_bytes().to_vec(); + bytes.extend([bool_as_char_u8(*linux)]); + } + Filter::HasGameDir(game_dir) => { + bytes = "\\gamedir\\".as_bytes().to_vec(); + bytes.extend(game_dir.as_bytes()); + } + } + + bytes + } +} + +/// Query search filters. +/// An example of constructing one: +/// ```rust +/// use gamedig::valve_master_server::{Filter, SearchFilters}; +/// +/// let search_filters = SearchFilters::new() +/// .insert(Filter::RunsAppID(440)) +/// .insert(Filter::CanHavePassword(true)); +/// ``` +/// This would query the servers that are (by App ID) 440 and that can contain +/// passwords. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SearchFilters<'a> { + filters: Vec>, + nor_filters: Vec>, + nand_filters: Vec>, +} + +impl<'a> Default for SearchFilters<'a> { + fn default() -> Self { SearchFilters::new() } +} + +fn update_or_insert_vec<'a>(filter_list: Vec>, filter: Filter<'a>) -> Vec> { + let mut list = filter_list; + + let found_same_filter = list.iter_mut().find_map(|f| { + if std::mem::discriminant(f) == std::mem::discriminant(&filter) { + Some(f) + } else { + None + } + }); + + match found_same_filter { + None => list.push(filter), + Some(f) => *f = filter, + } + + list +} + +impl<'a> SearchFilters<'a> { + pub fn new() -> Self { + Self { + filters: Vec::new(), + nor_filters: Vec::new(), + nand_filters: Vec::new(), + } + } + + pub fn insert(self, filter: Filter<'a>) -> Self { + Self { + filters: update_or_insert_vec(self.filters, filter), + nand_filters: self.nand_filters, + nor_filters: self.nor_filters, + } + } + + pub fn insert_nand(self, filter: Filter<'a>) -> Self { + Self { + filters: self.filters, + nand_filters: self.nand_filters, + nor_filters: update_or_insert_vec(self.nor_filters, filter), + } + } + + pub fn insert_nor(self, filter: Filter<'a>) -> Self { + Self { + filters: self.filters, + nand_filters: update_or_insert_vec(self.nand_filters, filter), + nor_filters: self.nor_filters, + } + } + + pub(crate) fn to_bytes(&self) -> Vec { + let mut bytes: Vec = Vec::new(); + + // hmm, this is repetitive + for filter in &self.filters { + bytes.extend(filter.to_bytes()) + } + + if !self.nand_filters.is_empty() { + bytes.extend(b"\\nand\\".to_vec()); + bytes.extend(self.nand_filters.len().to_string().as_bytes()); + for filter in &self.nand_filters { + bytes.extend(filter.to_bytes()) + } + } + + if !self.nor_filters.is_empty() { + bytes.extend(b"\\nor\\".to_vec()); + bytes.extend(self.nor_filters.len().to_string().as_bytes()); + for filter in &self.nor_filters { + bytes.extend(filter.to_bytes()) + } + } + + bytes.extend([0x00]); + bytes + } +} + +/// The region that you want to query server for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(u8)] +pub enum Region { + UsEast = 0x00, + UsWest = 0x01, + AmericaSouth = 0x02, + Europe = 0x03, + Asia = 0x04, + Australia = 0x05, + MiddleEast = 0x06, + Africa = 0x07, + Others = 0xFF, +}