use std::{ net::{IpAddr, ToSocketAddrs}, path::PathBuf, }; use clap::{Parser, Subcommand, ValueEnum}; use gamedig::{ games::*, protocols::types::{CommonResponse, ExtraRequestSettings, TimeoutSettings}, }; mod error; use self::error::{Error, Result}; const GAMEDIG_HEADER: &str = r" _____ _____ _ _____ _ _____ / ____| | __ \(_) / ____| | |_ _| | | __ __ _ _ __ ___ ___| | | |_ __ _ | | | | | | | | |_ |/ _` | '_ ` _ \ / _ \ | | | |/ _` | | | | | | | | |__| | (_| | | | | | | __/ |__| | | (_| | | |____| |____ _| |_ \_____|\__,_|_| |_| |_|\___|_____/|_|\__, | \_____|______|_____| __/ | |___/ A command line interface for querying game servers. Copyright (C) 2022 - 2023 GameDig Organization & Contributors Licensed under the MIT license "; // 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 = GAMEDIG_HEADER, long_about = None)] struct Cli { #[command(subcommand)] action: Action, } #[derive(Subcommand, Debug)] enum Action { /// Query game server information Query { /// 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: String, /// Optional query port number for the server. If not provided the default /// port for the game is used. #[arg(short, long)] port: Option, /// Specifies the output format #[arg(short, long, default_value = "debug", value_enum)] format: OutputFormat, /// Which response variant to use when outputting #[arg(short, long, default_value = "generic")] output_mode: OutputMode, /// Optional file path for packet capture file writer #[cfg(feature = "packet_capture")] #[arg(short, long)] capture: Option, /// 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, }, /// Check out the source code Source, /// Display the MIT License information License, } #[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, } #[derive(Clone, Debug, PartialEq, Eq, ValueEnum)] enum OutputFormat { /// Human readable structured output Debug, /// RFC 8259 #[cfg(feature = "json")] JsonPretty, /// RFC 8259 #[cfg(feature = "json")] Json, /// Parser tries to be mostly XML 1.1 (RFC 7303) compliant #[cfg(feature = "xml")] Xml, /// RFC 4648 section 8 #[cfg(feature = "bson")] BsonHex, /// RFC 4648 section 4 #[cfg(feature = "bson")] BsonBase64, } /// 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: T, extra_options: &mut Option) -> Result { let host_str = host.as_ref(); if let Ok(parsed_ip) = host_str.parse() { Ok(parsed_ip) } else { set_hostname_if_missing(host_str, extra_options); resolve_domain(host_str) } } /// 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(output_mode: OutputMode, format: OutputFormat, result: &T) { match format { OutputFormat::Debug => match output_mode { OutputMode::Generic => output_result_debug(result.as_json()), OutputMode::ProtocolSpecific => output_result_debug(result.as_original()), }, #[cfg(feature = "json")] OutputFormat::JsonPretty => match output_mode { OutputMode::Generic => output_result_json_pretty(result.as_json()), OutputMode::ProtocolSpecific => output_result_json_pretty(result.as_original()), }, #[cfg(feature = "json")] OutputFormat::Json => match output_mode { OutputMode::Generic => output_result_json(result.as_json()), OutputMode::ProtocolSpecific => output_result_json(result.as_original()), }, #[cfg(feature = "xml")] OutputFormat::Xml => match output_mode { OutputMode::Generic => output_result_xml(result.as_json()), //BUG: In this case we get a writer error with all serde write methods some reason with xml 0.6.0 //BUG: gamedig-cli.exe query -g -i -p -f xml --output-mode protocol-specific //BUG: Writer: emitter error: document start event has already been emitted //BUG: With xml 0.5.1 we get unsupported operation: 'serialize_unit_variant' OutputMode::ProtocolSpecific => panic!("XML format is not supported for protocol specific output"), }, #[cfg(feature = "bson")] OutputFormat::BsonHex => match output_mode { OutputMode::Generic => output_result_bson_hex(result.as_json()), OutputMode::ProtocolSpecific => output_result_bson_hex(result.as_original()), }, #[cfg(feature = "bson")] OutputFormat::BsonBase64 => match output_mode { OutputMode::Generic => output_result_bson_base64(result.as_json()), OutputMode::ProtocolSpecific => output_result_bson_base64(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: T) { println!("{}", serde_json::to_string(&result).unwrap()); } /// Output the result as a pretty printed JSON object. /// /// # Arguments /// * `result` - A serde serializable result. #[cfg(feature = "json")] fn output_result_json_pretty(result: T) { println!("{}", serde_json::to_string_pretty(&result).unwrap()); } /// Output the result as an XML object. /// # Arguments /// * `result` - A serde serializable result. fn output_result_xml(result: T) { println!("{}", serde_xml_rs::to_string(&result).unwrap()); } /// Output the result as a BSON object encoded as a hex string. /// /// # Arguments /// * `result` - A serde serializable result. #[cfg(feature = "bson")] fn output_result_bson_hex(result: T) { let bson = bson::to_bson(&result).unwrap(); if let bson::Bson::Document(document) = bson { let bytes = bson::to_vec(&document).unwrap(); println!("{}", hex::encode(bytes)); } else { panic!("Failed to convert result to BSON Hex"); } } /// Output the result as a BSON object encoded as a base64 string. /// /// # Arguments /// * `result` - A serde serializable result. #[cfg(feature = "bson")] fn output_result_bson_base64(result: T) { use base64::Engine; let bson = bson::to_bson(&result).unwrap(); if let bson::Bson::Document(document) = bson { let bytes = bson::to_vec(&document).unwrap(); println!("{}", base64::prelude::BASE64_STANDARD.encode(&bytes)); } else { panic!("Failed to convert result to BSON Base64"); } } fn main() -> Result<()> { let args = Cli::parse(); match args.action { Action::Query { game, ip, port, format, output_mode, capture, timeout_settings, extra_options, } => { // Process the query command let game = find_game(&game)?; let mut extra_options = extra_options; let ip = resolve_ip_or_domain(&ip, &mut extra_options)?; #[cfg(feature = "packet_capture")] gamedig::capture::setup_capture(capture); let result = query_with_timeout_and_extra_settings(game, &ip, port, timeout_settings, extra_options)?; output_result(output_mode, format, result.as_ref()); } Action::Source => { println!("{}", GAMEDIG_HEADER); #[cfg(feature = "browser")] { // Directly offering to open the URL println!("\nWould you like to open the GitHub repository in your default browser? [Y/n]"); let mut choice = String::new(); std::io::stdin().read_line(&mut choice).unwrap(); if choice.trim().eq_ignore_ascii_case("Y") { if webbrowser::open("https://github.com/gamedig/rust-gamedig").is_ok() { println!("Opening GitHub repository in default browser..."); } else { println!("Failed to open GitHub repository in default browser."); println!("Please use the following URL: https://github.com/gamedig/rust-gamedig"); } } else { println!("Not to worry, you can always open the repository manually"); println!("by visiting the following URL: https://github.com/gamedig/rust-gamedig"); } } #[cfg(not(feature = "browser"))] { println!("\nYou can find the source code for this project at the following URL:"); println!("https://github.com/gamedig/rust-gamedig"); } println!("\nBe sure to leave a star if you like the project :)"); } Action::License => { // Bake the license into the binary // so we don't have to ship it separately println!("{}", include_str!("../../../LICENSE.md")); } } Ok(()) }