From 45ffa53de3e3244cc04cb92d5f14b50ba3d60545 Mon Sep 17 00:00:00 2001 From: CosminPerRam Date: Sun, 21 Apr 2024 18:53:33 +0300 Subject: [PATCH] feat: add Ark: Survival Ascended support (#197) * feat: add initial epic client auth call * fix: working client auth * feat: unfinished initial EOS query * first successful query * first successful server query * run fmt * be a bit more detailed about servers * properly run fmt for sure this time fr fr * port of what node gamedig has done * feat: remove query_raw_values to query_raw * feat: add raw field to epic response * feat: pass SocketAddr to epic * feat: remove unused pub access to internal only struct * feat: add initial generic impl * fix: possibly conditional comp * feat: add epic to the protocol list * feat: add version and add epic to RESPONSES.md * feat: add asa to definitions * feat: add initial protocol macros * feat: conditional serde ser and des * fix: cfg serde stuff * fix: epic macro warn dead code * partial feature gate epic to tls * fix: remove asa from game definitions --- GAMES.md | 1 + PROTOCOLS.md | 1 + RESPONSES.md | 108 ++++++------- crates/lib/CHANGELOG.md | 6 + crates/lib/Cargo.toml | 1 + crates/lib/src/games/definitions.rs | 3 + crates/lib/src/games/epic.rs | 15 ++ crates/lib/src/games/mod.rs | 4 + crates/lib/src/games/query.rs | 4 + crates/lib/src/http.rs | 70 +++++---- crates/lib/src/protocols/epic/mod.rs | 58 +++++++ crates/lib/src/protocols/epic/protocol.rs | 181 ++++++++++++++++++++++ crates/lib/src/protocols/epic/types.rs | 52 +++++++ crates/lib/src/protocols/mod.rs | 3 + crates/lib/src/protocols/types.rs | 8 + 15 files changed, 434 insertions(+), 81 deletions(-) create mode 100644 crates/lib/src/games/epic.rs create mode 100644 crates/lib/src/protocols/epic/mod.rs create mode 100644 crates/lib/src/protocols/epic/protocol.rs create mode 100644 crates/lib/src/protocols/epic/types.rs diff --git a/GAMES.md b/GAMES.md index 004e480..0ce4231 100644 --- a/GAMES.md +++ b/GAMES.md @@ -89,6 +89,7 @@ requirements/information. | Myth of Empires | MOE | Valve | | | Pirates, Vikings, and Knights II | PVAK2 | Valve | | | PixARK | PIXARK | | | +| Ark: Survival Ascended | ASA | Epic | Available on the 'tls' feature | ## Planned to add support: diff --git a/PROTOCOLS.md b/PROTOCOLS.md index 3c50809..abf10d1 100644 --- a/PROTOCOLS.md +++ b/PROTOCOLS.md @@ -11,6 +11,7 @@ A protocol is defined as proprietary if it is being used only for a single scope | Just Cause 2: Multiplayer | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/jc2mp.js) | | Unreal 2 | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/unreal2.js) | Sometimes servers send strings that node-gamedig would treat as latin1 that are UTF-8 encoded, when this happens the remove color code breaks because latin1 decodes the colour sequences differently. Some games provide additional info at the end of the server info packet, this is not currently handled (see the node implementation). Some games use a bot player to denote the team names, this is not currently handled. | | Savage 2 | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/savage2.js) | | +| Epic | Games | No | [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/epic.js) | Available only on the 'tls' feature. | ## Planned to add support: diff --git a/RESPONSES.md b/RESPONSES.md index f32f97c..9827130 100644 --- a/RESPONSES.md +++ b/RESPONSES.md @@ -6,57 +6,57 @@ annotated in brackets. # Response table -| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP | Proprietary: Savage 2 | -|----------------------|----------|------------|------------|------------|-----------------|--------------------|---------------|-----------|------------|-------------------|----------------------|--------------------|-----------------------| -| name | `Option` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | -| description | `Option` | | | | `String` | | | | | `String` | | `String` | | -| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | `String` | `String` | | `String` | -| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | | -| map | `Option` | `String` | `String` | `String` | | `Option` | `String` | `String` | `String` | `String` | `String` | | `String` | -| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` | `u8` | -| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` | `u8` | -| players_bots | `Option` | | | | | | `u8` | | | | `u8` | | | -| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | | -| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | `u8` | -| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec ` | `Vec` | | `Vec` | `Vec` | | -| tournament | | `bool` | | `bool` | | | | | | | | | | -| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | | -| teams | | | `Vec` | `Vec` | | | | | | | | | | -| protocol_version | | | | | `i32` | `String` | `u8` | | | `u8` | `u8` | | `String` | -| server_type | | | | | `Server` | `Server` | `Server` | | | | `Server` | | | -| rules | | | | | | | `Option>` | | `HashMap>` | | `HashMap` | | | -| environment_type | | | | | | | `Environment` | | | `Environment` | | | | -| vac_secured | | | | | | | `bool` | | | `bool` | `bool` | | | -| map_title | | `Option` | | | | | | | | | | | | -| admin_contact | | `Option` | | | | | | | | | | | | -| admin_name | | `Option` | | | | | | | | | | | | -| favicon | | | | | `Option` | | | | | | | | | -| previews_chat | | | | | `Option` | | | | | | | | | -| enforces_secure_chat | | | | | `Option` | | | | | | | | | -| edition | | | | | | `String` | | | | | | | | -| id | | | | | | `String` | | | `String` | | | | | -| the_ship | | | | | | | `Option` | | | | | | | -| is_mod | | | | | | | `bool` | | | | | | | -| extra_data | | | | | | | `Option` | | | | | | | -| mod_data | | | | | | | `Option` | | | | | | | -| folder | | | | | | | `String` | | | | | | | -| appid | | | | | | | `u32` | | | | | | | -| active_mod | | | | | | | | | | `String` | | | | -| round | | | | | | | | | | `u8` | | | | -| rounds_maximum | | | | | | | | | | `u8` | | | | -| time_left | | | | | | | | | | `u16` | | | | -| port | | | | | | | | | `u32` | | `Option` | | | -| steam_id | | | | | | | | | | | `Option` | | | -| tv_port | | | | | | | | | | | `Option` | | | -| tv_name | | | | | | | | | | | `Option` | | | -| keywords | | | | | | | | | | | `Option` | | | -| mode | | | | | | | | | | | `u8` | | | -| witnesses | | | | | | | | | | | `u8` | | | -| duration | | | | | | | | | | | `u8` | | | -| query_port | | | | | | | | | `u32` | | | | | -| ip | | | | | | | | | `String` | | | | | -| mutators | | | | | | | | | `HashSet` | | | | | -| next_map | | | | | | | | | | | | | `String` | -| location | | | | | | | | | | | | | `String` | -| level_minimum | | | | | | | | | | | | | `String` | -| time | | | | | | | | | | | | | `String` | +| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Epic | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP | Proprietary: Savage 2 | +|----------------------|----------|------------|------------|------------|-----------------|--------------------|---------------|-----------|------------|----------|-------------------|----------------------|--------------------|-----------------------| +| name | `Option` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | `String` | +| description | `Option` | | | | `String` | | | | | | `String` | | `String` | | +| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | | `String` | `String` | | `String` | +| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | `String` | | +| map | `Option` | `String` | `String` | `String` | | `Option` | `String` | `String` | `String` | `String` | `String` | `String` | | `String` | +| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | +| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | +| players_bots | `Option` | | | | | | `u8` | | | | | `u8` | | | +| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | `bool` | | +| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | | `u8` | +| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec ` | `Vec` | `Vec` | | `Vec` | `Vec` | | +| tournament | | `bool` | | `bool` | | | | | | | | | | | +| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | | | +| teams | | | `Vec` | `Vec` | | | | | | | | | | | +| protocol_version | | | | | `i32` | `String` | `u8` | | | | `u8` | `u8` | | `String` | +| server_type | | | | | `Server` | `Server` | `Server` | | | | | `Server` | | | +| rules | | | | | | | `Option>` | | `HashMap>` | | | `HashMap` | | | +| environment_type | | | | | | | `Environment` | | | | `Environment` | | | | +| vac_secured | | | | | | | `bool` | | | | `bool` | `bool` | | | +| map_title | | `Option` | | | | | | | | | | | | | +| admin_contact | | `Option` | | | | | | | | | | | | | +| admin_name | | `Option` | | | | | | | | | | | | | +| favicon | | | | | `Option` | | | | | | | | | | +| previews_chat | | | | | `Option` | | | | | | | | | | +| enforces_secure_chat | | | | | `Option` | | | | | | | | | | +| edition | | | | | | `String` | | | | | | | | | +| id | | | | | | `String` | | | `String` | | | | | | +| the_ship | | | | | | | `Option` | | | | | | | | +| is_mod | | | | | | | `bool` | | | | | | | | +| extra_data | | | | | | | `Option` | | | | | | | | +| mod_data | | | | | | | `Option` | | | | | | | | +| folder | | | | | | | `String` | | | | | | | | +| appid | | | | | | | `u32` | | | | | | | | +| active_mod | | | | | | | | | | | `String` | | | | +| round | | | | | | | | | | | `u8` | | | | +| rounds_maximum | | | | | | | | | | | `u8` | | | | +| time_left | | | | | | | | | | | `u16` | | | | +| port | | | | | | | | | `u32` | | | `Option` | | | +| steam_id | | | | | | | | | | | | `Option` | | | +| tv_port | | | | | | | | | | | | `Option` | | | +| tv_name | | | | | | | | | | | | `Option` | | | +| keywords | | | | | | | | | | | | `Option` | | | +| mode | | | | | | | | | | | | `u8` | | | +| witnesses | | | | | | | | | | | | `u8` | | | +| duration | | | | | | | | | | | | `u8` | | | +| query_port | | | | | | | | | `u32` | | | | | | +| ip | | | | | | | | | `String` | | | | | | +| mutators | | | | | | | | | `HashSet` | | | | | | +| next_map | | | | | | | | | | | | | | `String` | +| location | | | | | | | | | | | | | | `String` | +| level_minimum | | | | | | | | | | | | | | `String` | +| time | | | | | | | | | | | | | | `String` | diff --git a/crates/lib/CHANGELOG.md b/crates/lib/CHANGELOG.md index fca1464..e4a1e5e 100644 --- a/crates/lib/CHANGELOG.md +++ b/crates/lib/CHANGELOG.md @@ -9,6 +9,12 @@ Games: - [Myth of Empires](https://store.steampowered.com/app/1371580/Myth_of_Empires/) support. - [Pirates, Vikings, and Knights II](https://store.steampowered.com/app/17570/Pirates_Vikings_and_Knights_II/) support. - [PixARK](https://store.steampowered.com/app/593600/PixARK/) support. +- [Ark: Survival Ascended](https://store.steampowered.com/app/2399830/ARK_Survival_Ascended/) support, note: not yet in + the games definitions. + +Protocols: + +- Epic (EOS) support, available only on the `tls` feature. # 0.5.0 - 15/03/2024 diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 71e1b3c..028180d 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -41,6 +41,7 @@ tls = ["ureq/tls"] byteorder = "1.5" bzip2-rs = "0.1" crc32fast = "1.3" +base64 = "0.22.0" encoding_rs = "0.8" ureq = { version = "2.8", default-features = false, features = ["gzip", "json"] } diff --git a/crates/lib/src/games/definitions.rs b/crates/lib/src/games/definitions.rs index 02d4280..d73b36f 100644 --- a/crates/lib/src/games/definitions.rs +++ b/crates/lib/src/games/definitions.rs @@ -8,6 +8,9 @@ use crate::protocols::types::{GatherToggle, ProprietaryProtocol}; use crate::protocols::valve::GatheringSettings; use phf::{phf_map, Map}; +#[cfg(feature = "tls")] +use crate::protocols::epic::Credentials; + macro_rules! game { ($name: literal, $default_port: expr, $protocol: expr) => { game!( diff --git a/crates/lib/src/games/epic.rs b/crates/lib/src/games/epic.rs new file mode 100644 index 0000000..6e2771c --- /dev/null +++ b/crates/lib/src/games/epic.rs @@ -0,0 +1,15 @@ +//! Unreal2 game query modules + +use crate::protocols::epic::game_query_mod; + +game_query_mod!( + asa, + "Ark: Survival Ascended", + 7777, + Credentials { + deployment: "ad9a8feffb3b4b2ca315546f038c3ae2", + id: "xyza7891muomRmynIIHaJB9COBKkwj6n", + secret: "PP5UGxysEieNfSrEicaD1N2Bb3TdXuD7xHYcsdUHZ7s", + auth_by_external: false, + } +); diff --git a/crates/lib/src/games/mod.rs b/crates/lib/src/games/mod.rs index 7c53935..1d25506 100644 --- a/crates/lib/src/games/mod.rs +++ b/crates/lib/src/games/mod.rs @@ -1,10 +1,14 @@ //! Currently supported games. +#[cfg(feature = "tls")] +pub mod epic; pub mod gamespy; pub mod quake; pub mod unreal2; pub mod valve; +#[cfg(feature = "tls")] +pub use epic::*; pub use gamespy::*; pub use quake::*; pub use unreal2::*; diff --git a/crates/lib/src/games/query.rs b/crates/lib/src/games/query.rs index 402ed76..d87a631 100644 --- a/crates/lib/src/games/query.rs +++ b/crates/lib/src/games/query.rs @@ -48,6 +48,10 @@ pub fn query_with_timeout_and_extra_settings( ) .map(Box::new)? } + #[cfg(feature = "tls")] + Protocol::Epic(credentials) => { + protocols::epic::query_with_timeout(credentials.clone(), &socket_addr, timeout_settings).map(Box::new)? + } Protocol::Gamespy(version) => { match version { GameSpyVersion::One => protocols::gamespy::one::query(&socket_addr, timeout_settings).map(Box::new)?, diff --git a/crates/lib/src/http.rs b/crates/lib/src/http.rs index b14a3e5..d3ac842 100644 --- a/crates/lib/src/http.rs +++ b/crates/lib/src/http.rs @@ -13,7 +13,7 @@ use crate::{GDResult, TimeoutSettings}; use std::io::Read; use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs}; -use ureq::{Agent, AgentBuilder}; +use ureq::{Agent, AgentBuilder, Request}; use url::{Host, Url}; use serde::{de::DeserializeOwned, Serialize}; @@ -250,14 +250,20 @@ impl HttpClient { self.request_with_json_data("POST", path, headers, data) } + /// Send a HTTP Post request with FORM data and parse a JSON response. + pub fn post_json_with_form( + &mut self, + path: &str, + headers: HttpHeaders, + data: &[(&str, &str)], + ) -> GDResult { + self.request_with_form_data("POST", path, headers, data) + } + // NOTE: More methods can be added here as required using the request_json or // request_with_json methods - /// Internal request method, makes a request with an arbitrary HTTP method. - #[inline] - fn request(&mut self, method: &str, path: &str, headers: HttpHeaders) -> GDResult> { - // Append the path to the pre-parsed URL and create a request object. - self.address.set_path(path); + fn make_request(&self, method: &str, headers: HttpHeaders) -> Request { let mut request = self.client.request_url(method, &self.address); // Set the request headers. @@ -271,6 +277,16 @@ impl HttpClient { } } + request + } + + /// Internal request method, makes a request with an arbitrary HTTP method. + #[inline] + fn request(&mut self, method: &str, path: &str, headers: HttpHeaders) -> GDResult> { + // Append the path to the pre-parsed URL and create a request object. + self.address.set_path(path); + let request = self.make_request(method, headers); + // Send the request. let http_response = request.call().map_err(|e| PacketSend.context(e))?; @@ -299,17 +315,7 @@ impl HttpClient { fn request_json(&mut self, method: &str, path: &str, headers: HttpHeaders) -> GDResult { // Append the path to the pre-parsed URL and create a request object. self.address.set_path(path); - let mut request = self.client.request_url(method, &self.address); - - // Set the request headers. - for (key, value) in self.headers.iter() { - request = request.set(key, value); - } - if let Some(headers) = headers { - for (key, value) in headers { - request = request.set(key, value); - } - } + let request = self.make_request(method, headers); // Send the request and parse the response as JSON. request @@ -329,16 +335,7 @@ impl HttpClient { data: S, ) -> GDResult { self.address.set_path(path); - let mut request = self.client.request_url(method, &self.address); - - for (key, value) in self.headers.iter() { - request = request.set(key, value); - } - if let Some(headers) = headers { - for (key, value) in headers { - request = request.set(key, value); - } - } + let request = self.make_request(method, headers); request .send_json(data) @@ -346,6 +343,25 @@ impl HttpClient { .into_json::() .map_err(|e| ProtocolFormat.context(e)) } + + /// Send a HTTP request with FORM data and parse the JSON response. + #[inline] + fn request_with_form_data( + &mut self, + method: &str, + path: &str, + headers: HttpHeaders, + data: &[(&str, &str)], + ) -> GDResult { + self.address.set_path(path); + let request = self.make_request(method, headers); + + request + .send_form(data) + .map_err(|e| PacketSend.context(e))? + .into_json::() + .map_err(|e| ProtocolFormat.context(e)) + } } #[cfg(test)] diff --git a/crates/lib/src/protocols/epic/mod.rs b/crates/lib/src/protocols/epic/mod.rs new file mode 100644 index 0000000..a9c5c24 --- /dev/null +++ b/crates/lib/src/protocols/epic/mod.rs @@ -0,0 +1,58 @@ +/// The implementation. +pub mod protocol; +/// All types used by the implementation. +pub mod types; + +pub use protocol::*; +pub use types::*; + +/// Generate a module containing a query function for an epic (EOS) game. +/// +/// * `mod_name` - The name to be given to the game module (see ID naming +/// conventions in CONTRIBUTING.md). +/// * `pretty_name` - The full name of the game, will be used as the +/// documentation for the created module. +/// * `steam_app`, `default_port` - Passed through to [game_query_fn]. +#[cfg(feature = "games")] +macro_rules! game_query_mod { + ($mod_name: ident, $pretty_name: expr, $default_port: literal, $credentials: expr) => { + #[doc = $pretty_name] + pub mod $mod_name { + use crate::protocols::epic::Credentials; + + crate::protocols::epic::game_query_fn!($pretty_name, $default_port, $credentials); + } + }; +} + +#[cfg(feature = "games")] +pub(crate) use game_query_mod; + +/// Generate a query function for an epic (EOS) game. +/// +/// * `default_port` - The default port the game uses. +/// * `credentials` - Credentials to access EOS. +#[cfg(feature = "games")] +macro_rules! game_query_fn { + ($pretty_name: expr, $default_port: literal, $credentials: expr) => { + crate::protocols::epic::game_query_fn! {@gen $default_port, concat!( + "Make a Epic query for ", $pretty_name, ".\n\n", + "If port is `None`, then the default port (", stringify!($default_port), ") will be used."), $credentials} + }; + + (@gen $default_port: literal, $doc: expr, $credentials: expr) => { + #[doc = $doc] + pub fn query( + address: &std::net::IpAddr, + port: Option, + ) -> crate::GDResult { + crate::protocols::epic::query( + $credentials, + &std::net::SocketAddr::new(*address, port.unwrap_or($default_port)), + ) + } + }; +} + +#[cfg(feature = "games")] +pub(crate) use game_query_fn; diff --git a/crates/lib/src/protocols/epic/protocol.rs b/crates/lib/src/protocols/epic/protocol.rs new file mode 100644 index 0000000..7314f36 --- /dev/null +++ b/crates/lib/src/protocols/epic/protocol.rs @@ -0,0 +1,181 @@ +use crate::http::HttpClient; +use crate::protocols::epic::Response; +use crate::GDErrorKind::{JsonParse, PacketBad}; +use crate::{GDResult, TimeoutSettings}; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use serde::Deserialize; +#[cfg(feature = "serde")] +use serde::Serialize; +use serde_json::Value; +use std::net::SocketAddr; + +const EPIC_API_ENDPOINT: &'static str = "https://api.epicgames.dev"; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Credentials { + #[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))] + pub deployment: &'static str, + #[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))] + pub id: &'static str, + #[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))] + pub secret: &'static str, + pub auth_by_external: bool, +} + +pub struct EpicProtocol { + client: HttpClient, + credentials: Credentials, +} + +#[derive(Deserialize)] +struct ClientTokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct QueryResponse { + sessions: Value, +} + +macro_rules! extract_optional_field { + ($value:expr, $fields:expr, $map_func:expr) => { + $fields + .iter() + .fold(Some(&$value), |acc, &key| acc.and_then(|val| val.get(key))) + .map($map_func) + .flatten() + }; +} + +macro_rules! extract_field { + ($value:expr, $fields:expr, $map_func:expr) => { + extract_optional_field!($value, $fields, $map_func) + .ok_or(PacketBad.context("Field is missing or is not parsable."))? + }; +} + +impl EpicProtocol { + pub fn new(credentials: Credentials, timeout_settings: TimeoutSettings) -> GDResult { + Ok(Self { + client: HttpClient::from_url(EPIC_API_ENDPOINT, &Some(timeout_settings), None)?, + credentials, + }) + } + + pub fn auth_by_external(&self) -> GDResult { Ok(String::new()) } + + pub fn auth_by_client(&mut self) -> GDResult { + let body = [ + ("grant_type", "client_credentials"), + ("deployment_id", &self.credentials.deployment), + ]; + + let auth_format = format!("{}:{}", self.credentials.id, self.credentials.secret); + let auth_base = BASE64_STANDARD.encode(auth_format); + let auth = format!("Basic {}", auth_base.as_str()); + let authorization = auth.as_str(); + + let headers = [ + ("Authorization", authorization), + ("Content-Type", "application/x-www-form-urlencoded"), + ]; + + let response = + self.client + .post_json_with_form::("/auth/v1/oauth/token", Some(&headers), &body)?; + Ok(response.access_token) + } + + pub fn query_raw(&mut self, address: &SocketAddr) -> GDResult { + let port = address.port(); + let address = address.ip().to_string(); + + let body = format!( + "{{\"criteria\":[{{\"key\":\"attributes.ADDRESS_s\",\"op\":\"EQUAL\",\"value\":\"{}\"}}]}}", + address + ); + let body = serde_json::from_str::(body.as_str()).map_err(|e| JsonParse.context(e))?; + + let token = if self.credentials.auth_by_external { + self.auth_by_external()? + } else { + self.auth_by_client()? + }; + let authorization = format!("Bearer {}", token); + let headers = [ + ("Content-Type", "application/json"), + ("Accept", "application/json"), + ("Authorization", authorization.as_str()), + ]; + + let url = format!("/matchmaking/v1/{}/filter", self.credentials.deployment); + let response: QueryResponse = self.client.post_json(url.as_str(), Some(&headers), body)?; + + if let Value::Array(sessions) = response.sessions { + if sessions.is_empty() { + return Err(PacketBad.context("No servers provided.")); + } + + for session in sessions.into_iter() { + let attributes = session + .get("attributes") + .ok_or(PacketBad.context("Expected attributes field missing in sessions."))?; + if attributes + .get("ADDRESSBOUND_s") + .and_then(Value::as_str) + .map_or(false, |v| { + v.contains(&address) || v.contains(&port.to_string()) + }) + || attributes + .get("ADDRESS_s") + .and_then(Value::as_str) + .map_or(false, |v| v.contains(&address)) + { + return Ok(session); + } + } + + return Err(PacketBad.context("Servers were provided but the specified one couldn't be find amonst them.")); + } + + Err(PacketBad.context("Expected session field to be an array.")) + } + + pub fn query(&mut self, address: &SocketAddr) -> GDResult { + let value = self.query_raw(address)?; + + let build_version = extract_optional_field!(value, ["attributes", "BUILDID_s"], Value::as_str); + let minor_version = extract_optional_field!(value, ["attributes", "MINORBUILDID_s"], Value::as_str); + + let game_version = match (build_version, minor_version) { + (Some(b), Some(m)) => Some(format!("{b}.{m}")), + _ => None, + }; + + Ok(Response { + name: extract_field!(value, ["attributes", "CUSTOMSERVERNAME_s"], Value::as_str).to_string(), + map: extract_field!(value, ["attributes", "MAPNAME_s"], Value::as_str).to_string(), + has_password: extract_field!(value, ["attributes", "SERVERPASSWORD_b"], Value::as_bool), + players_online: extract_field!(value, ["totalPlayers"], Value::as_u64) as u32, + players_maxmimum: extract_field!(value, ["settings", "maxPublicPlayers"], Value::as_u64) as u32, + players: vec![], + game_version, + raw: value, + }) + } +} + +pub fn query(credentials: Credentials, address: &SocketAddr) -> GDResult { + query_with_timeout(credentials, address, None) +} + +pub fn query_with_timeout( + credentials: Credentials, + address: &SocketAddr, + timeout_settings: Option, +) -> GDResult { + let mut client = EpicProtocol::new(credentials, timeout_settings.unwrap_or_default())?; + client.query(address) +} diff --git a/crates/lib/src/protocols/epic/types.rs b/crates/lib/src/protocols/epic/types.rs new file mode 100644 index 0000000..1734dca --- /dev/null +++ b/crates/lib/src/protocols/epic/types.rs @@ -0,0 +1,52 @@ +use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer}; +use crate::protocols::GenericResponse; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Response { + pub name: String, + pub map: String, + pub has_password: bool, + pub players_online: u32, + pub players_maxmimum: u32, + pub players: Vec, + pub game_version: Option, + pub raw: Value, +} + +impl CommonResponse for Response { + fn as_original(&self) -> GenericResponse { GenericResponse::Epic(self) } + fn name(&self) -> Option<&str> { Some(&self.name) } + fn map(&self) -> Option<&str> { Some(&self.map) } + fn players_maximum(&self) -> u32 { self.players_maxmimum } + + fn players_online(&self) -> u32 { self.players_online } + + fn has_password(&self) -> Option { Some(self.has_password) } + + fn players(&self) -> Option> { + Some( + self.players + .iter() + .map(|p| p as &dyn CommonPlayer) + .collect(), + ) + } + + fn game_version(&self) -> Option<&str> { self.game_version.as_deref() } +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Player { + pub name: String, +} + +impl CommonPlayer for Player { + fn as_original(&self) -> GenericPlayer { GenericPlayer::Epic(self) } + + fn name(&self) -> &str { &self.name } +} diff --git a/crates/lib/src/protocols/mod.rs b/crates/lib/src/protocols/mod.rs index 639fbf8..e98843e 100644 --- a/crates/lib/src/protocols/mod.rs +++ b/crates/lib/src/protocols/mod.rs @@ -4,6 +4,9 @@ //! implementation will be in that specific needed place, a protocol can be //! independently queried. +#[cfg(feature = "tls")] +/// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/epic.js) +pub mod epic; /// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js) pub mod gamespy; /// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js) diff --git a/crates/lib/src/protocols/types.rs b/crates/lib/src/protocols/types.rs index 0470a60..bd614fd 100644 --- a/crates/lib/src/protocols/types.rs +++ b/crates/lib/src/protocols/types.rs @@ -1,5 +1,7 @@ #[cfg(feature = "games")] use crate::games::minecraft; +#[cfg(feature = "tls")] +use crate::protocols::epic; use crate::protocols::{gamespy, quake, unreal2, valve}; use crate::GDErrorKind::InvalidInput; use crate::GDResult; @@ -31,6 +33,8 @@ pub enum Protocol { Quake(quake::QuakeVersion), Valve(valve::Engine), Unreal2, + #[cfg(feature = "tls")] + Epic(epic::Credentials), #[cfg(feature = "games")] PROPRIETARY(ProprietaryProtocol), } @@ -43,6 +47,8 @@ pub enum GenericResponse<'a> { Quake(quake::VersionedResponse<'a>), Valve(&'a valve::Response), Unreal2(&'a unreal2::Response), + #[cfg(feature = "tls")] + Epic(&'a epic::Response), #[cfg(feature = "games")] Mindustry(&'a crate::games::mindustry::types::ServerData), #[cfg(feature = "games")] @@ -68,6 +74,8 @@ pub enum GenericPlayer<'a> { QuakeTwo(&'a quake::two::Player), Gamespy(gamespy::VersionedPlayer<'a>), Unreal2(&'a unreal2::Player), + #[cfg(feature = "tls")] + Epic(&'a epic::Player), #[cfg(feature = "games")] Minecraft(&'a minecraft::Player), #[cfg(feature = "games")]