mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-06-01 09:42:41 +00:00
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:
parent
b3a29b15b1
commit
7510fe3de0
9 changed files with 209 additions and 13 deletions
|
|
@ -14,6 +14,10 @@ Protocols:
|
||||||
|
|
||||||
CLI:
|
CLI:
|
||||||
- Added a CLI (by @cainthebest).
|
- 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:
|
### Breaking:
|
||||||
Game:
|
Game:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,15 @@ edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# 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]
|
[dependencies]
|
||||||
clap = { version = "4.1.11", features = ["derive"] }
|
clap = { version = "4.1.11", features = ["derive"] }
|
||||||
gamedig = { version = "*", path = "../lib" }
|
gamedig = { version = "*", path = "../lib", features = ["clap"] }
|
||||||
thiserror = "1.0.43"
|
thiserror = "1.0.43"
|
||||||
|
|
||||||
|
# JSON dependencies
|
||||||
|
serde = { version = "1", optional = true }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,7 @@ pub enum Error {
|
||||||
|
|
||||||
#[error("Unknown Game: {0}")]
|
#[error("Unknown Game: {0}")]
|
||||||
UnknownGame(String),
|
UnknownGame(String),
|
||||||
|
|
||||||
|
#[error("Invalid hostname: {0}")]
|
||||||
|
InvalidHostname(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,188 @@
|
||||||
use std::net::IpAddr;
|
use std::net::{IpAddr, ToSocketAddrs};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::{Parser, ValueEnum};
|
||||||
use gamedig::games::*;
|
use gamedig::{
|
||||||
|
games::*,
|
||||||
|
protocols::types::{CommonResponse, ExtraRequestSettings, TimeoutSettings},
|
||||||
|
};
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
|
|
||||||
use self::error::Result;
|
use self::error::{Error, Result};
|
||||||
|
|
||||||
#[derive(Parser)]
|
// NOTE: For some reason without setting long_about here the doc comment for
|
||||||
#[command(author, version, about)]
|
// ExtraRequestSettings gets set as the about for the CLI.
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
/// Unique identifier of the game for which server information is being
|
||||||
|
/// queried.
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
game: String,
|
game: String,
|
||||||
|
|
||||||
|
/// Hostname or IP address of the server.
|
||||||
#[arg(short, long)]
|
#[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)]
|
#[arg(short, long)]
|
||||||
port: Option<u16>,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
|
// Parse the command line arguments
|
||||||
let args = Cli::parse();
|
let args = Cli::parse();
|
||||||
|
|
||||||
let game = match GAMES.get(&args.game) {
|
// Retrieve the game based on the provided ID
|
||||||
Some(game) => game,
|
let game = find_game(&args.game)?;
|
||||||
None => return Err(error::Error::UnknownGame(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ games = []
|
||||||
services = []
|
services = []
|
||||||
game_defs = ["dep:phf", "games"]
|
game_defs = ["dep:phf", "games"]
|
||||||
serde = ["dep:serde", "serde/derive"]
|
serde = ["dep:serde", "serde/derive"]
|
||||||
|
clap = ["dep:clap"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
byteorder = "1.5"
|
byteorder = "1.5"
|
||||||
|
|
@ -34,6 +35,8 @@ serde = { version = "1.0", optional = true }
|
||||||
|
|
||||||
phf = { version = "0.11", optional = true, features = ["macros"] }
|
phf = { version = "0.11", optional = true, features = ["macros"] }
|
||||||
|
|
||||||
|
clap = { version = "4.1.11", optional = true, features = ["derive"] }
|
||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "minecraft"
|
name = "minecraft"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ impl CommonPlayer for Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Versioned response type
|
/// Versioned response type
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum VersionedResponse<'a> {
|
pub enum VersionedResponse<'a> {
|
||||||
Bedrock(&'a BedrockResponse),
|
Bedrock(&'a BedrockResponse),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub enum GameSpyVersion {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Versioned response type
|
/// Versioned response type
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum VersionedResponse<'a> {
|
pub enum VersionedResponse<'a> {
|
||||||
One(&'a one::Response),
|
One(&'a one::Response),
|
||||||
|
|
@ -25,6 +26,7 @@ pub enum VersionedResponse<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Versioned player type
|
/// Versioned player type
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum VersionedPlayer<'a> {
|
pub enum VersionedPlayer<'a> {
|
||||||
One(&'a one::Player),
|
One(&'a one::Player),
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ impl<P: QuakePlayerType> CommonResponse for Response<P> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Versioned response type
|
/// Versioned response type
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum VersionedResponse<'a> {
|
pub enum VersionedResponse<'a> {
|
||||||
One(&'a Response<crate::protocols::quake::one::Player>),
|
One(&'a Response<crate::protocols::quake::one::Player>),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ pub enum Protocol {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All response types
|
/// All response types
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum GenericResponse<'a> {
|
pub enum GenericResponse<'a> {
|
||||||
GameSpy(gamespy::VersionedResponse<'a>),
|
GameSpy(gamespy::VersionedResponse<'a>),
|
||||||
|
|
@ -50,6 +51,7 @@ pub enum GenericResponse<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All player types
|
/// All player types
|
||||||
|
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum GenericPlayer<'a> {
|
pub enum GenericPlayer<'a> {
|
||||||
Valve(&'a valve::ServerPlayer),
|
Valve(&'a valve::ServerPlayer),
|
||||||
|
|
@ -149,13 +151,25 @@ pub struct CommonPlayerJson<'a> {
|
||||||
pub score: Option<i32>,
|
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
|
/// Timeout settings for socket operations
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::Args))]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
pub struct TimeoutSettings {
|
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>,
|
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>,
|
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>,
|
write: Option<Duration>,
|
||||||
|
/// Number of retries per request
|
||||||
|
#[cfg_attr(feature = "clap", arg(long, default_value = "0"))]
|
||||||
retries: usize,
|
retries: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,32 +294,38 @@ impl Default for TimeoutSettings {
|
||||||
/// let valve_settings: valve::GatheringSettings = ExtraRequestSettings::default().set_check_app_id(false).into();
|
/// let valve_settings: valve::GatheringSettings = ExtraRequestSettings::default().set_check_app_id(false).into();
|
||||||
/// ```
|
/// ```
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::Args))]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
||||||
pub struct ExtraRequestSettings {
|
pub struct ExtraRequestSettings {
|
||||||
/// The server's hostname.
|
/// The server's hostname.
|
||||||
///
|
///
|
||||||
/// Used by:
|
/// Used by:
|
||||||
/// - [minecraft::RequestSettings#structfield.hostname]
|
/// - [minecraft::RequestSettings#structfield.hostname]
|
||||||
|
#[cfg_attr(feature = "clap", arg(long))]
|
||||||
pub hostname: Option<String>,
|
pub hostname: Option<String>,
|
||||||
/// The protocol version to use.
|
/// The protocol version to use.
|
||||||
///
|
///
|
||||||
/// Used by:
|
/// Used by:
|
||||||
/// - [minecraft::RequestSettings#structfield.protocol_version]
|
/// - [minecraft::RequestSettings#structfield.protocol_version]
|
||||||
|
#[cfg_attr(feature = "clap", arg(long))]
|
||||||
pub protocol_version: Option<i32>,
|
pub protocol_version: Option<i32>,
|
||||||
/// Whether to gather player information
|
/// Whether to gather player information
|
||||||
///
|
///
|
||||||
/// Used by:
|
/// Used by:
|
||||||
/// - [valve::GatheringSettings#structfield.players]
|
/// - [valve::GatheringSettings#structfield.players]
|
||||||
|
#[cfg_attr(feature = "clap", arg(long))]
|
||||||
pub gather_players: Option<bool>,
|
pub gather_players: Option<bool>,
|
||||||
/// Whether to gather rule information.
|
/// Whether to gather rule information.
|
||||||
///
|
///
|
||||||
/// Used by:
|
/// Used by:
|
||||||
/// - [valve::GatheringSettings#structfield.rules]
|
/// - [valve::GatheringSettings#structfield.rules]
|
||||||
|
#[cfg_attr(feature = "clap", arg(long))]
|
||||||
pub gather_rules: Option<bool>,
|
pub gather_rules: Option<bool>,
|
||||||
/// Whether to check if the App ID is valid.
|
/// Whether to check if the App ID is valid.
|
||||||
///
|
///
|
||||||
/// Used by:
|
/// Used by:
|
||||||
/// - [valve::GatheringSettings#structfield.check_app_id]
|
/// - [valve::GatheringSettings#structfield.check_app_id]
|
||||||
|
#[cfg_attr(feature = "clap", arg(long))]
|
||||||
pub check_app_id: Option<bool>,
|
pub check_app_id: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue