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>
This commit is contained in:
Tom 2023-11-26 22:59:59 +00:00 committed by GitHub
parent b3a29b15b1
commit 7510fe3de0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 209 additions and 13 deletions

View file

@ -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:

View file

@ -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 }

View file

@ -13,4 +13,7 @@ pub enum Error {
#[error("Unknown Game: {0}")]
UnknownGame(String),
#[error("Invalid hostname: {0}")]
InvalidHostname(String),
}

View file

@ -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<u16>,
/// 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<TimeoutSettings>,
/// Optional extra settings for the server query.
#[command(flatten, next_help_heading = "Query options")]
extra_options: Option<ExtraRequestSettings>,
}
#[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<IpAddr>` - 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<ExtraRequestSettings>) -> Result<IpAddr> {
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<IpAddr>` - On success, returns one of the resolved IP addresses;
/// on failure returns an [Error::InvalidHostname] error.
fn resolve_domain(domain: &str) -> Result<IpAddr> {
// 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<ExtraRequestSettings>) {
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<R: std::fmt::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<R: serde::Serialize>(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(())
}

View file

@ -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"

View file

@ -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),

View file

@ -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),

View file

@ -51,6 +51,7 @@ impl<P: QuakePlayerType> CommonResponse for Response<P> {
}
/// Versioned response type
#[cfg_attr(feature = "serde", derive(Serialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionedResponse<'a> {
One(&'a Response<crate::protocols::quake::one::Player>),

View file

@ -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<i32>,
}
#[cfg(feature = "clap")]
fn parse_duration_secs(value: &str) -> Result<Duration, std::num::ParseIntError> {
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<Duration>,
#[cfg_attr(feature = "clap", arg(long = "read-timeout", value_parser = parse_duration_secs, help = "Socket read timeout (in seconds)", default_value = "4"))]
read: Option<Duration>,
#[cfg_attr(feature = "clap", arg(long = "write-timeout", value_parser = parse_duration_secs, help = "Socket write timeout (in seconds)", default_value = "4"))]
write: Option<Duration>,
/// 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<String>,
/// The protocol version to use.
///
/// Used by:
/// - [minecraft::RequestSettings#structfield.protocol_version]
#[cfg_attr(feature = "clap", arg(long))]
pub protocol_version: Option<i32>,
/// Whether to gather player information
///
/// Used by:
/// - [valve::GatheringSettings#structfield.players]
#[cfg_attr(feature = "clap", arg(long))]
pub gather_players: Option<bool>,
/// Whether to gather rule information.
///
/// Used by:
/// - [valve::GatheringSettings#structfield.rules]
#[cfg_attr(feature = "clap", arg(long))]
pub gather_rules: Option<bool>,
/// 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<bool>,
}