mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-05-06 15:27:28 +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:
|
||||
- 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:
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -13,4 +13,7 @@ pub enum Error {
|
|||
|
||||
#[error("Unknown Game: {0}")]
|
||||
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 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue