From 7510fe3de010aa2038c608755d9ac27cba6671e4 Mon Sep 17 00:00:00 2001 From: Tom <25043847+Douile@users.noreply.github.com> Date: Sun, 26 Nov 2023 22:59:59 +0000 Subject: [PATCH] Various improvements for the CLI (#159) * cli: Do DNS lookup if host is not an IP address * cli: Add option to output as JSON * cli: Pass hostname to ExtraRequestSettings if it isn't an IP * cli: Add help docs to all arguments * cli: Add options for all extra request settings * cli: Use a CLI only error for DNS * cli: Add option to set timeout settings * docs: Update CHANGELOG * cli: Add default values to TimeoutSettings * cli: Refactor finding game definition into its own function Co-Authored-By: Cain <75994858+cainthebest@users.noreply.github.com> * cli: Refactor IP resolution into its own set of functions Co-Authored-By: Cain <75994858+cainthebest@users.noreply.github.com> * cli: Refactor output formatting into its own functions Co-Authored-By: Cain <75994858+cainthebest@users.noreply.github.com> * cli: Improve doc comments for CLI args and derive Debug Co-Authored-By: Cain <75994858+cainthebest@users.noreply.github.com> * protocols: Derive Serialize for versioned generic responses This allows for serializing the output of as_original(). We cannot also derive Deserialize here because the enums use references to the inner types, which is unavoidable in the current implementation because as_original() takes a reference to self. * cli: Add the output mode options This allows selected whether to use CommonResponse or the original response struct when outputting. * cli: Fix ExtraRequestSettings docs showing up in help output * cli: Add help headings for timeouts and extra request settings --------- Co-authored-by: Cain <75994858+cainthebest@users.noreply.github.com> --- CHANGELOG.md | 4 + crates/cli/Cargo.toml | 10 +- crates/cli/src/error.rs | 3 + crates/cli/src/main.rs | 178 ++++++++++++++++++++++-- crates/lib/Cargo.toml | 3 + crates/lib/src/games/minecraft/types.rs | 1 + crates/lib/src/protocols/gamespy/mod.rs | 2 + crates/lib/src/protocols/quake/types.rs | 1 + crates/lib/src/protocols/types.rs | 20 +++ 9 files changed, 209 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cb450..bf2050d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ Protocols: CLI: - Added a CLI (by @cainthebest). +- Added DNS lookup support (by @Douile). +- Added JSON output option (by @Douile). +- Added ExtraRequestSettings as CLI arguments (by @Douile). +- Added TimeoutSettings as CLI argument (by @Douile). ### Breaking: Game: diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b02867b..d74c1d0 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -10,7 +10,15 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = ["json"] +json = ["dep:serde", "dep:serde_json", "gamedig/serde"] + [dependencies] clap = { version = "4.1.11", features = ["derive"] } -gamedig = { version = "*", path = "../lib" } +gamedig = { version = "*", path = "../lib", features = ["clap"] } thiserror = "1.0.43" + +# JSON dependencies +serde = { version = "1", optional = true } +serde_json = { version = "1", optional = true } diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 49b6813..d5db795 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -13,4 +13,7 @@ pub enum Error { #[error("Unknown Game: {0}")] UnknownGame(String), + + #[error("Invalid hostname: {0}")] + InvalidHostname(String), } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b21b0ae..526f227 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,34 +1,188 @@ -use std::net::IpAddr; +use std::net::{IpAddr, ToSocketAddrs}; -use clap::Parser; -use gamedig::games::*; +use clap::{Parser, ValueEnum}; +use gamedig::{ + games::*, + protocols::types::{CommonResponse, ExtraRequestSettings, TimeoutSettings}, +}; mod error; -use self::error::Result; +use self::error::{Error, Result}; -#[derive(Parser)] -#[command(author, version, about)] +// NOTE: For some reason without setting long_about here the doc comment for +// ExtraRequestSettings gets set as the about for the CLI. +#[derive(Debug, Parser)] +#[command(author, version, about, long_about = None)] struct Cli { + /// Unique identifier of the game for which server information is being + /// queried. #[arg(short, long)] game: String, + /// Hostname or IP address of the server. #[arg(short, long)] - ip: IpAddr, + ip: String, + /// Optional query port number for the server. If not provided the default + /// port for the game is used. #[arg(short, long)] port: Option, + + /// Flag indicating if the output should be in JSON format. + #[cfg(feature = "json")] + #[arg(short, long)] + json: bool, + + /// Which response variant to use when outputting. + #[arg(short, long, default_value = "generic")] + output_mode: OutputMode, + + /// Optional timeout settings for the server query. + #[command(flatten, next_help_heading = "Timeouts")] + timeout_settings: Option, + + /// Optional extra settings for the server query. + #[command(flatten, next_help_heading = "Query options")] + extra_options: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] +enum OutputMode { + /// A generalised response that maps common fields from all game types to + /// the same name. + Generic, + /// The raw result returned from the protocol query, formatted similarly to + /// how the server returned it. + ProtocolSpecific, +} + +/// Attempt to find a game from the [library game definitions](GAMES) based on +/// its unique identifier. +/// +/// # Arguments +/// * `game_id` - A string slice containing the unique game identifier. +/// +/// # Returns +/// * Result<&'static [Game]> - On sucess returns a reference to the game +/// definition; on failure returns a [Error::UnknownGame] error. +fn find_game(game_id: &str) -> Result<&'static Game> { + // Attempt to retrieve the game from the predefined game list + GAMES + .get(game_id) + .ok_or_else(|| Error::UnknownGame(game_id.to_string())) +} + +/// Resolve an IP address by either parsing an IP address or doing a DNS lookup. +/// In the case of DNS lookup update extra request options with the hostname. +/// +/// # Arguments +/// * `host` - A string slice containing the IP address or hostname of a server +/// to resolve. +/// * `extra_options` - Mutable reference to extra options for the game query. +/// +/// # Returns +/// * `Result` - On sucess returns a resolved IP address; on failure +/// returns an [Error::InvalidHostname] error. +fn resolve_ip_or_domain(host: &str, extra_options: &mut Option) -> Result { + if let Ok(parsed_ip) = host.parse() { + Ok(parsed_ip) + } else { + set_hostname_if_missing(host, extra_options); + + resolve_domain(host) + } +} + +/// Resolve a domain name to one of its IP addresses (the first one returned). +/// +/// # Arguments +/// * `domain` - A string slice containing the domain name to lookup. +/// +/// # Returns +/// * `Result` - On success, returns one of the resolved IP addresses; +/// on failure returns an [Error::InvalidHostname] error. +fn resolve_domain(domain: &str) -> Result { + // Append a dummy port to perform socket address resolution and then extract the + // IP + Ok(format!("{}:0", domain) + .to_socket_addrs() + .map_err(|_| Error::InvalidHostname(domain.to_string()))? + .next() + .ok_or_else(|| Error::InvalidHostname(domain.to_string()))? + .ip()) +} + +/// Sets the hostname on extra request settings if it is not already set. +/// +/// # Arguments +/// * `host` - A string slice containing the hostname. +/// * `extra_options` - A mutable reference to optional [ExtraRequestSettings]. +fn set_hostname_if_missing(host: &str, extra_options: &mut Option) { + if let Some(extra_options) = extra_options { + if extra_options.hostname.is_none() { + // If extra_options exists but hostname is None overwrite hostname in place + extra_options.hostname = Some(host.to_string()) + } + } else { + // If extra_options is None create default settings with hostname + *extra_options = Some(ExtraRequestSettings::default().set_hostname(host.to_string())); + } +} + +/// Output the result of a query to stdout. +/// +/// # Arguments +/// * `args` - A reference to the command line options. +/// * `result` - A reference to the result of the query. +fn output_result(args: &Cli, result: &dyn CommonResponse) { + match args.output_mode { + #[cfg(feature = "json")] + OutputMode::Generic if args.json => output_result_json(result.as_json()), + #[cfg(feature = "json")] + OutputMode::ProtocolSpecific if args.json => output_result_json(result.as_original()), + + OutputMode::Generic => output_result_debug(result.as_json()), + OutputMode::ProtocolSpecific => output_result_debug(result.as_original()), + } +} + +/// Output the result using debug formatting. +/// +/// # Arguments +/// * `result` - A result that can be output using the debug formatter. +fn output_result_debug(result: R) { + println!("{:#?}", result); +} + +/// Output the result as a JSON object. +/// +/// # Arguments +/// * `result` - A serde serializable result. +#[cfg(feature = "json")] +fn output_result_json(result: R) { + serde_json::to_writer_pretty(std::io::stdout(), &result).unwrap(); } fn main() -> Result<()> { + // Parse the command line arguments let args = Cli::parse(); - let game = match GAMES.get(&args.game) { - Some(game) => game, - None => return Err(error::Error::UnknownGame(args.game)), - }; + // Retrieve the game based on the provided ID + let game = find_game(&args.game)?; - println!("{:#?}", query(game, &args.ip, args.port)?.as_json()); + // Extract extra options for use in setup + let mut extra_options = args.extra_options.clone(); + + // Resolve the IP address + let ip = resolve_ip_or_domain(&args.ip, &mut extra_options)?; + + // Query the server using game definition, parsed IP, and user command line + // flags. + let result = query_with_timeout_and_extra_settings(game, &ip, args.port, args.timeout_settings, extra_options)?; + + // Output the result in the specified format + output_result(&args, result.as_ref()); Ok(()) } diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index fe9cae9..5e24b5f 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -22,6 +22,7 @@ games = [] services = [] game_defs = ["dep:phf", "games"] serde = ["dep:serde", "serde/derive"] +clap = ["dep:clap"] [dependencies] byteorder = "1.5" @@ -34,6 +35,8 @@ serde = { version = "1.0", optional = true } phf = { version = "0.11", optional = true, features = ["macros"] } +clap = { version = "4.1.11", optional = true, features = ["derive"] } + # Examples [[example]] name = "minecraft" diff --git a/crates/lib/src/games/minecraft/types.rs b/crates/lib/src/games/minecraft/types.rs index 3bb90ff..db64792 100644 --- a/crates/lib/src/games/minecraft/types.rs +++ b/crates/lib/src/games/minecraft/types.rs @@ -55,6 +55,7 @@ impl CommonPlayer for Player { } /// Versioned response type +#[cfg_attr(feature = "serde", derive(Serialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum VersionedResponse<'a> { Bedrock(&'a BedrockResponse), diff --git a/crates/lib/src/protocols/gamespy/mod.rs b/crates/lib/src/protocols/gamespy/mod.rs index ef50096..125b87d 100644 --- a/crates/lib/src/protocols/gamespy/mod.rs +++ b/crates/lib/src/protocols/gamespy/mod.rs @@ -17,6 +17,7 @@ pub enum GameSpyVersion { } /// Versioned response type +#[cfg_attr(feature = "serde", derive(Serialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum VersionedResponse<'a> { One(&'a one::Response), @@ -25,6 +26,7 @@ pub enum VersionedResponse<'a> { } /// Versioned player type +#[cfg_attr(feature = "serde", derive(Serialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum VersionedPlayer<'a> { One(&'a one::Player), diff --git a/crates/lib/src/protocols/quake/types.rs b/crates/lib/src/protocols/quake/types.rs index ebb05b8..4dc06ec 100644 --- a/crates/lib/src/protocols/quake/types.rs +++ b/crates/lib/src/protocols/quake/types.rs @@ -51,6 +51,7 @@ impl CommonResponse for Response

{ } /// Versioned response type +#[cfg_attr(feature = "serde", derive(Serialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum VersionedResponse<'a> { One(&'a Response), diff --git a/crates/lib/src/protocols/types.rs b/crates/lib/src/protocols/types.rs index 35c7c9f..4249d5c 100644 --- a/crates/lib/src/protocols/types.rs +++ b/crates/lib/src/protocols/types.rs @@ -33,6 +33,7 @@ pub enum Protocol { } /// All response types +#[cfg_attr(feature = "serde", derive(Serialize))] #[derive(Debug, Clone, PartialEq)] pub enum GenericResponse<'a> { GameSpy(gamespy::VersionedResponse<'a>), @@ -50,6 +51,7 @@ pub enum GenericResponse<'a> { } /// All player types +#[cfg_attr(feature = "serde", derive(Serialize))] #[derive(Debug, Clone, PartialEq)] pub enum GenericPlayer<'a> { Valve(&'a valve::ServerPlayer), @@ -149,13 +151,25 @@ pub struct CommonPlayerJson<'a> { pub score: Option, } +#[cfg(feature = "clap")] +fn parse_duration_secs(value: &str) -> Result { + let secs = value.parse()?; + Ok(Duration::from_secs(secs)) +} + /// Timeout settings for socket operations #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "clap", derive(clap::Args))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TimeoutSettings { + #[cfg_attr(feature = "clap", arg(long = "connect-timeout", value_parser = parse_duration_secs, help = "Socket connect timeout (in seconds)", default_value = "4"))] connect: Option, + #[cfg_attr(feature = "clap", arg(long = "read-timeout", value_parser = parse_duration_secs, help = "Socket read timeout (in seconds)", default_value = "4"))] read: Option, + #[cfg_attr(feature = "clap", arg(long = "write-timeout", value_parser = parse_duration_secs, help = "Socket write timeout (in seconds)", default_value = "4"))] write: Option, + /// Number of retries per request + #[cfg_attr(feature = "clap", arg(long, default_value = "0"))] retries: usize, } @@ -280,32 +294,38 @@ impl Default for TimeoutSettings { /// let valve_settings: valve::GatheringSettings = ExtraRequestSettings::default().set_check_app_id(false).into(); /// ``` #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "clap", derive(clap::Args))] #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] pub struct ExtraRequestSettings { /// The server's hostname. /// /// Used by: /// - [minecraft::RequestSettings#structfield.hostname] + #[cfg_attr(feature = "clap", arg(long))] pub hostname: Option, /// The protocol version to use. /// /// Used by: /// - [minecraft::RequestSettings#structfield.protocol_version] + #[cfg_attr(feature = "clap", arg(long))] pub protocol_version: Option, /// Whether to gather player information /// /// Used by: /// - [valve::GatheringSettings#structfield.players] + #[cfg_attr(feature = "clap", arg(long))] pub gather_players: Option, /// Whether to gather rule information. /// /// Used by: /// - [valve::GatheringSettings#structfield.rules] + #[cfg_attr(feature = "clap", arg(long))] pub gather_rules: Option, /// Whether to check if the App ID is valid. /// /// Used by: /// - [valve::GatheringSettings#structfield.check_app_id] + #[cfg_attr(feature = "clap", arg(long))] pub check_app_id: Option, }