mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-06-01 09:42:41 +00:00
feat: Add packet capture functionality and many more CLI improvements (#182)
This commit is contained in:
commit
e86e80522b
14 changed files with 1445 additions and 82 deletions
|
|
@ -12,14 +12,44 @@ default-run = "gamedig-cli"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[features]
|
||||
default = ["json"]
|
||||
default = ["json", "bson", "xml", "browser"]
|
||||
|
||||
# Tools
|
||||
packet_capture = ["gamedig/packet_capture"]
|
||||
|
||||
# Output formats
|
||||
bson = ["dep:serde", "dep:bson", "dep:hex", "dep:base64", "gamedig/serde"]
|
||||
json = ["dep:serde", "dep:serde_json", "gamedig/serde"]
|
||||
xml = ["dep:serde", "dep:serde_json", "dep:quick-xml", "gamedig/serde"]
|
||||
|
||||
# Misc
|
||||
browser = ["dep:webbrowser"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.1.11", features = ["derive"] }
|
||||
gamedig = { version = "*", path = "../lib", features = ["clap"] }
|
||||
# Core Dependencies
|
||||
thiserror = "1.0.43"
|
||||
clap = { version = "4.1.11", default-features = false, features = ["derive"] }
|
||||
gamedig = { version = "*", path = "../lib", default-features = false, features = [
|
||||
"clap",
|
||||
"games",
|
||||
"game_defs",
|
||||
] }
|
||||
|
||||
# Feature Dependencies
|
||||
# Serialization / Deserialization
|
||||
serde = { version = "1", optional = true, default-features = false }
|
||||
|
||||
# BSON
|
||||
bson = { version = "2.8.1", optional = true, default-features = false }
|
||||
base64 = { version = "0.21.7", optional = true, default-features = false, features = ["std"]}
|
||||
hex = { version = "0.4.3", optional = true, default-features = false }
|
||||
|
||||
# JSON
|
||||
serde_json = { version = "1", optional = true, default-features = false }
|
||||
|
||||
# XML
|
||||
quick-xml = { version = "0.31.0", optional = true, default-features = false }
|
||||
|
||||
# Browser
|
||||
webbrowser = { version = "0.8.12", optional = true, default-features = false }
|
||||
|
||||
# JSON dependencies
|
||||
serde = { version = "1", optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ pub enum Error {
|
|||
#[error("Gamedig Error: {0}")]
|
||||
Gamedig(#[from] gamedig::errors::GDError),
|
||||
|
||||
#[cfg(any(feature = "json", feature = "xml"))]
|
||||
#[error("Serde Error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[cfg(feature = "bson")]
|
||||
#[error("Bson Error: {0}")]
|
||||
Bson(#[from] bson::ser::Error),
|
||||
|
||||
#[cfg(feature = "xml")]
|
||||
#[error("Xml Error: {0}")]
|
||||
Xml(#[from] quick_xml::Error),
|
||||
|
||||
#[error("Unknown Game: {0}")]
|
||||
UnknownGame(String),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,47 +1,91 @@
|
|||
use std::net::{IpAddr, ToSocketAddrs};
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use gamedig::{games::*, protocols::types::CommonResponse, ExtraRequestSettings, TimeoutSettings};
|
||||
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 - 2024 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, long_about = None)]
|
||||
#[command(author, version, about = GAMEDIG_HEADER, long_about = None)]
|
||||
struct Cli {
|
||||
/// Unique identifier of the game for which server information is being
|
||||
/// queried.
|
||||
#[arg(short, long)]
|
||||
game: String,
|
||||
#[command(subcommand)]
|
||||
action: Action,
|
||||
}
|
||||
|
||||
/// Hostname or IP address of the server.
|
||||
#[arg(short, long)]
|
||||
ip: String,
|
||||
#[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,
|
||||
|
||||
/// Optional query port number for the server. If not provided the default
|
||||
/// port for the game is used.
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
/// Hostname or IP address of the server.
|
||||
#[arg(short, long)]
|
||||
ip: String,
|
||||
|
||||
/// Flag indicating if the output should be in JSON format.
|
||||
#[cfg(feature = "json")]
|
||||
#[arg(short, long)]
|
||||
json: bool,
|
||||
/// Optional query port number for the server. If not provided the
|
||||
/// default port for the game is used.
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Which response variant to use when outputting.
|
||||
#[arg(short, long, default_value = "generic")]
|
||||
output_mode: OutputMode,
|
||||
/// Specifies the output format
|
||||
#[arg(short, long, default_value = "debug", value_enum)]
|
||||
format: OutputFormat,
|
||||
|
||||
/// Optional timeout settings for the server query.
|
||||
#[command(flatten, next_help_heading = "Timeouts")]
|
||||
timeout_settings: Option<TimeoutSettings>,
|
||||
/// Which response variant to use when outputting
|
||||
#[arg(short, long, default_value = "generic")]
|
||||
output_mode: OutputMode,
|
||||
|
||||
/// Optional extra settings for the server query.
|
||||
#[command(flatten, next_help_heading = "Query options")]
|
||||
extra_options: Option<ExtraRequestSettings>,
|
||||
/// Optional file path for packet capture file writer
|
||||
///
|
||||
/// When set a PCAP file will be written to the location. This file can
|
||||
/// be read with a tool like wireshark. The PCAP contains a log of the
|
||||
/// TCP and UDP data sent/recieved by the gamedig library, it does not
|
||||
/// contain an accurate representation of the real packets sent on the
|
||||
/// wire as some information has to be hallucinated in order for it to
|
||||
/// display nicely.
|
||||
#[cfg(feature = "packet_capture")]
|
||||
#[arg(short, long)]
|
||||
capture: Option<std::path::PathBuf>,
|
||||
|
||||
/// 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>,
|
||||
},
|
||||
|
||||
/// Check out the source code
|
||||
Source,
|
||||
/// Display the MIT License information
|
||||
License,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
|
||||
|
|
@ -54,6 +98,27 @@ enum OutputMode {
|
|||
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.
|
||||
///
|
||||
|
|
@ -81,15 +146,14 @@ fn find_game(game_id: &str) -> Result<&'static Game> {
|
|||
/// # 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> {
|
||||
host.parse().map_or_else(
|
||||
|_| {
|
||||
set_hostname_if_missing(host, extra_options);
|
||||
|
||||
resolve_domain(host)
|
||||
},
|
||||
Ok,
|
||||
)
|
||||
fn resolve_ip_or_domain<T: AsRef<str>>(host: T, extra_options: &mut Option<ExtraRequestSettings>) -> Result<IpAddr> {
|
||||
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).
|
||||
|
|
@ -133,15 +197,49 @@ fn set_hostname_if_missing(host: &str, extra_options: &mut Option<ExtraRequestSe
|
|||
/// # 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 {
|
||||
fn output_result<T: CommonResponse + ?Sized>(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")]
|
||||
OutputMode::Generic if args.json => output_result_json(result.as_json()),
|
||||
OutputFormat::JsonPretty => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_json_pretty(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_json_pretty(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[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()),
|
||||
OutputFormat::Json => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_json(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_json(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "xml")]
|
||||
OutputFormat::Xml => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_xml(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_xml(result.as_original()),
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "bson")]
|
||||
OutputFormat::BsonHex => {
|
||||
let _ = 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 => {
|
||||
let _ = match output_mode {
|
||||
OutputMode::Generic => output_result_bson_base64(result.as_json()),
|
||||
OutputMode::ProtocolSpecific => output_result_bson_base64(result.as_original()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,29 +256,232 @@ fn output_result_debug<R: std::fmt::Debug>(result: R) {
|
|||
/// # 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();
|
||||
|
||||
// Retrieve the game based on the provided ID
|
||||
let game = find_game(&args.game)?;
|
||||
|
||||
// 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());
|
||||
fn output_result_json<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Output the result as a pretty printed JSON object.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "json")]
|
||||
fn output_result_json_pretty<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Output the result as an XML object.
|
||||
/// # Arguments
|
||||
/// * `result` - A serde serializable result.
|
||||
#[cfg(feature = "xml")]
|
||||
fn output_result_xml<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
use quick_xml::{
|
||||
events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
|
||||
Writer,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
// Serialize the input `result` of generic type `T` into a JSON value.
|
||||
// This step converts the Rust data structure into a JSON format,
|
||||
// which will then be used to generate the corresponding XML.
|
||||
let json = serde_json::to_value(result)?;
|
||||
|
||||
// Initialize the XML writer with a new, empty vector to store the XML data.
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
|
||||
// Write the XML 1.1 declaration
|
||||
writer.write_event(Event::Decl(BytesDecl::new("1.1", Some("utf-8"), None)))?;
|
||||
|
||||
// Define a recursive function `json_to_xml` to convert the JSON value into XML
|
||||
// format. The function takes a mutable reference to the XML writer, an
|
||||
// optional key as a string slice, and a reference to the JSON value to be
|
||||
// converted.
|
||||
fn json_to_xml<W: std::io::Write>(writer: &mut Writer<W>, key: Option<&str>, value: &Value) -> Result<()> {
|
||||
match value {
|
||||
// If the JSON value is an object, iterate through its properties,
|
||||
// creating XML elements with corresponding keys and values.
|
||||
Value::Object(obj) => {
|
||||
if let Some(key) = key {
|
||||
// Start an XML element for the object.
|
||||
writer.write_event(Event::Start(BytesStart::new(key)))?;
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
// Recursively process each property of the object.
|
||||
json_to_xml(writer, Some(k), v)?;
|
||||
}
|
||||
|
||||
if let Some(key) = key {
|
||||
// Close the XML element for the object.
|
||||
writer.write_event(Event::End(BytesEnd::new(key)))?;
|
||||
}
|
||||
}
|
||||
|
||||
// If the JSON value is an array, iterate through its elements,
|
||||
// creating XML elements for each item.
|
||||
Value::Array(arr) => {
|
||||
for v in arr {
|
||||
// Use "item" as the default key for array elements without keys.
|
||||
json_to_xml(writer, key.or(Some("item")), v)?;
|
||||
}
|
||||
}
|
||||
|
||||
// If the JSON value is null, create an empty XML element.
|
||||
Value::Null => {
|
||||
if let Some(key) = key {
|
||||
writer.write_event(Event::Empty(BytesStart::new(key)))?;
|
||||
}
|
||||
}
|
||||
|
||||
// For all other JSON value types (String, Number, Bool),
|
||||
// convert the value to a string and create an XML element with the text content.
|
||||
// Note: We handle null strings here as well, as they are treated as a string type.
|
||||
_ => {
|
||||
if let Some(key) = key {
|
||||
// Start the XML element with the given key.
|
||||
writer.write_event(Event::Start(BytesStart::new(key)))?;
|
||||
}
|
||||
|
||||
// Convert the JSON value to a string, trimming quotes for non-string values.
|
||||
let text_string = match value {
|
||||
Value::String(s) => s.to_string(),
|
||||
_ => value.to_string().trim_matches('"').to_string(),
|
||||
};
|
||||
|
||||
// Create a text node with the converted string value.
|
||||
writer.write_event(Event::Text(BytesText::new(&text_string)))?;
|
||||
|
||||
if let Some(key) = key {
|
||||
// Close the XML element.
|
||||
writer.write_event(Event::End(BytesEnd::new(key)))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Start the root XML element named "data".
|
||||
writer.write_event(Event::Start(BytesStart::new("data")))?;
|
||||
// Convert the top-level JSON value to XML.
|
||||
json_to_xml(&mut writer, None, &json)?;
|
||||
// Close the root XML element.
|
||||
writer.write_event(Event::End(BytesEnd::new("data")))?;
|
||||
|
||||
// Convert the XML data stored in the writer to a UTF-8 string.
|
||||
let xml_bytes = writer.into_inner();
|
||||
let xml_string = String::from_utf8(xml_bytes).expect("Failed to convert XML bytes to UTF-8 string");
|
||||
|
||||
println!("{}", xml_string);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
let bson = bson::to_bson(&result)?;
|
||||
|
||||
if let bson::Bson::Document(document) = bson {
|
||||
let bytes = bson::to_vec(&document)?;
|
||||
|
||||
println!("{}", hex::encode(bytes));
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
panic!("Failed to convert result to BSON Hex (BSON_DOCUMENT_UNAVAILABLE)");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<T: serde::Serialize>(result: T) -> Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let bson = bson::to_bson(&result)?;
|
||||
|
||||
if let bson::Bson::Document(document) = bson {
|
||||
let bytes = bson::to_vec(&document)?;
|
||||
|
||||
println!("{}", base64::prelude::BASE64_STANDARD.encode(bytes));
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
panic!("Failed to convert result to BSON Base64 (BSON_DOCUMENT_UNAVAILABLE)");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Cli::parse();
|
||||
|
||||
match args.action {
|
||||
Action::Query {
|
||||
game,
|
||||
ip,
|
||||
port,
|
||||
format,
|
||||
output_mode,
|
||||
#[cfg(feature = "packet_capture")]
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue