mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-06-01 09:42:41 +00:00
[Service] Add valve master server query service (#34)
* [Service] Add initial files * [Service] Add initial request packet * [Service] Add filters * [Service] Some clippy improvements * [Service] Make query a vector of ipv4addr and port * [Service] Add complete and singular query * [Crate] Update md files * [Service] Add docs and clippy adjustments * [Service] Add hasTags and fix filters * [Service] Use let some instead of match * [Service] Add other filters * [Service] Add nor and nand filters * [Service] Remove 0.0.0.0:0 from query * [Service] Remove dev testing test * [Service] Add valve_master_server_query example
This commit is contained in:
parent
348147b415
commit
4122d34cfa
8 changed files with 415 additions and 3 deletions
|
|
@ -15,6 +15,9 @@ Games:
|
||||||
- [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support.
|
- [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support.
|
||||||
- [Frontlines: Fuel of War](https://store.steampowered.com/app/9460/Frontlines_Fuel_of_War/) support.
|
- [Frontlines: Fuel of War](https://store.steampowered.com/app/9460/Frontlines_Fuel_of_War/) support.
|
||||||
|
|
||||||
|
Services:
|
||||||
|
- [Valve Master Server Query](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) support.
|
||||||
|
|
||||||
### Breaking:
|
### Breaking:
|
||||||
Protocols:
|
Protocols:
|
||||||
- Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info`
|
- Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info`
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
|
||||||
# Supported services:
|
# Supported services:
|
||||||
| ID | Name | Notes |
|
| Name | Documentation reference |
|
||||||
|-----|------|-------|
|
|---------------------|-------------------------------------------------------------------------------------------------------|
|
||||||
| --- | ---- | ----- |
|
| Valve Master Server | [Master Server Query Protocol](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol) |
|
||||||
|
|
||||||
## Planned to add support:
|
## Planned to add support:
|
||||||
TeamSpeak
|
TeamSpeak
|
||||||
|
|
|
||||||
14
examples/valve_master_server_query.rs
Normal file
14
examples/valve_master_server_query.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use gamedig::valve_master_server::{query, Filter, Region, SearchFilters};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let search_filters = SearchFilters::new()
|
||||||
|
.insert(Filter::RunsAppID(440))
|
||||||
|
.insert(Filter::CanBeEmpty(false))
|
||||||
|
.insert(Filter::CanBeFull(false))
|
||||||
|
.insert(Filter::CanHavePassword(false))
|
||||||
|
.insert(Filter::IsSecured(true))
|
||||||
|
.insert(Filter::HasTags(&["minecraft"]));
|
||||||
|
|
||||||
|
let ips = query(Region::Europe, Some(search_filters)).unwrap();
|
||||||
|
println!("Servers: {:?} \n Amount: {}", ips, ips.len());
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ pub mod errors;
|
||||||
#[cfg(not(feature = "no_games"))]
|
#[cfg(not(feature = "no_games"))]
|
||||||
pub mod games;
|
pub mod games;
|
||||||
pub mod protocols;
|
pub mod protocols;
|
||||||
|
pub mod services;
|
||||||
|
|
||||||
mod bufferer;
|
mod bufferer;
|
||||||
mod socket;
|
mod socket;
|
||||||
|
|
@ -30,3 +31,4 @@ mod utils;
|
||||||
pub use errors::*;
|
pub use errors::*;
|
||||||
#[cfg(not(feature = "no_games"))]
|
#[cfg(not(feature = "no_games"))]
|
||||||
pub use games::*;
|
pub use games::*;
|
||||||
|
pub use services::*;
|
||||||
|
|
|
||||||
4
src/services/mod.rs
Normal file
4
src/services/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
//! Services that are currently implemented.
|
||||||
|
|
||||||
|
/// Reference: [Master Server Query Protocol](https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol)
|
||||||
|
pub mod valve_master_server;
|
||||||
7
src/services/valve_master_server/mod.rs
Normal file
7
src/services/valve_master_server/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/// The implementation.
|
||||||
|
pub mod service;
|
||||||
|
/// All types used by the implementation.
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use service::*;
|
||||||
|
pub use types::*;
|
||||||
145
src/services/valve_master_server/service.rs
Normal file
145
src/services/valve_master_server/service.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
use crate::bufferer::{Bufferer, Endianess};
|
||||||
|
use crate::socket::{Socket, UdpSocket};
|
||||||
|
use crate::valve_master_server::{Region, SearchFilters};
|
||||||
|
use crate::{GDError, GDResult};
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
/// The default master ip, which is the one for Source.
|
||||||
|
pub const DEFAULT_MASTER_IP: &str = "hl2master.steampowered.com";
|
||||||
|
/// The default master port.
|
||||||
|
pub const DEFAULT_MASTER_PORT: u16 = 27011;
|
||||||
|
|
||||||
|
fn construct_payload(region: Region, filters: &Option<SearchFilters>, last_ip: &str, last_port: u16) -> Vec<u8> {
|
||||||
|
let filters_bytes: Vec<u8> = match filters {
|
||||||
|
None => vec![0x00],
|
||||||
|
Some(f) => f.to_bytes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let region_byte = &[region as u8];
|
||||||
|
|
||||||
|
[
|
||||||
|
// Packet has to begin with the character '1'
|
||||||
|
&[0x31],
|
||||||
|
// The region byte is next
|
||||||
|
region_byte,
|
||||||
|
// The last fetched ip as a string
|
||||||
|
last_ip.as_bytes(),
|
||||||
|
// Followed by an ':'
|
||||||
|
&[b':'],
|
||||||
|
// And the port, as a string
|
||||||
|
last_port.to_string().as_bytes(),
|
||||||
|
// Which needs to end with a NULL byte
|
||||||
|
&[0x00],
|
||||||
|
// Then the filters
|
||||||
|
&filters_bytes,
|
||||||
|
]
|
||||||
|
.concat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The implementation, use this if you want to keep the same socket.
|
||||||
|
pub struct ValveMasterServer {
|
||||||
|
socket: UdpSocket,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValveMasterServer {
|
||||||
|
/// Construct a new struct.
|
||||||
|
pub fn new(master_ip: &str, master_port: u16) -> GDResult<Self> {
|
||||||
|
let socket = UdpSocket::new(master_ip, master_port)?;
|
||||||
|
socket.apply_timeout(None)?;
|
||||||
|
|
||||||
|
Ok(Self { socket })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make just a single query, providing `0.0.0.0` as the last ip and `0` as
|
||||||
|
/// the last port will give the initial packet.
|
||||||
|
pub fn query_specific(
|
||||||
|
&mut self,
|
||||||
|
region: Region,
|
||||||
|
search_filters: &Option<SearchFilters>,
|
||||||
|
last_address_ip: &str,
|
||||||
|
last_address_port: u16,
|
||||||
|
) -> GDResult<Vec<(Ipv4Addr, u16)>> {
|
||||||
|
let payload = construct_payload(region, search_filters, last_address_ip, last_address_port);
|
||||||
|
println!("{:02X?}", payload);
|
||||||
|
self.socket.send(&payload)?;
|
||||||
|
|
||||||
|
let received_data = self.socket.receive(Some(1400))?;
|
||||||
|
let mut buf = Bufferer::new_with_data(Endianess::Big, &received_data);
|
||||||
|
|
||||||
|
if buf.get_u32()? != 4294967295 || buf.get_u16()? != 26122 {
|
||||||
|
return Err(GDError::PacketBad);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ips: Vec<(Ipv4Addr, u16)> = Vec::new();
|
||||||
|
while buf.remaining_length() > 0 {
|
||||||
|
let ip = Ipv4Addr::new(buf.get_u8()?, buf.get_u8()?, buf.get_u8()?, buf.get_u8()?);
|
||||||
|
let port = buf.get_u16()?;
|
||||||
|
|
||||||
|
ips.push((ip, port));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a complete query.
|
||||||
|
pub fn query(&mut self, region: Region, search_filters: Option<SearchFilters>) -> GDResult<Vec<(Ipv4Addr, u16)>> {
|
||||||
|
let mut ips: Vec<(Ipv4Addr, u16)> = Vec::new();
|
||||||
|
|
||||||
|
let mut exit_fetching = false;
|
||||||
|
let mut last_ip: String = "0.0.0.0".to_string();
|
||||||
|
let mut last_port: u16 = 0;
|
||||||
|
|
||||||
|
while !exit_fetching {
|
||||||
|
let new_ips = self.query_specific(region, &search_filters, last_ip.as_str(), last_port)?;
|
||||||
|
|
||||||
|
match new_ips.last() {
|
||||||
|
None => exit_fetching = true,
|
||||||
|
Some((latest_ip, latest_port)) => {
|
||||||
|
let mut remove_last = false;
|
||||||
|
|
||||||
|
let latest_ip_string = latest_ip.to_string();
|
||||||
|
if latest_ip_string == "0.0.0.0" && *latest_port == 0 {
|
||||||
|
exit_fetching = true;
|
||||||
|
remove_last = true;
|
||||||
|
} else if latest_ip_string == last_ip && *latest_port == last_port {
|
||||||
|
exit_fetching = true;
|
||||||
|
} else {
|
||||||
|
last_ip = latest_ip_string;
|
||||||
|
last_port = *latest_port;
|
||||||
|
}
|
||||||
|
|
||||||
|
ips.extend(new_ips);
|
||||||
|
if remove_last {
|
||||||
|
ips.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take only the first response of (what would be a) complete query. This is
|
||||||
|
/// faster as it results in less packets being sent, received and processed but
|
||||||
|
/// yields less ips.
|
||||||
|
pub fn query_singular(region: Region, search_filters: Option<SearchFilters>) -> GDResult<Vec<(Ipv4Addr, u16)>> {
|
||||||
|
let mut master_server = ValveMasterServer::new(DEFAULT_MASTER_IP, DEFAULT_MASTER_PORT)?;
|
||||||
|
|
||||||
|
let mut ips = master_server.query_specific(region, &search_filters, "0.0.0.0", 0)?;
|
||||||
|
|
||||||
|
if let Some((last_ip, last_port)) = ips.last() {
|
||||||
|
if last_ip.to_string() == "0.0.0.0" && *last_port == 0 {
|
||||||
|
ips.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a complete query.
|
||||||
|
pub fn query(region: Region, search_filters: Option<SearchFilters>) -> GDResult<Vec<(Ipv4Addr, u16)>> {
|
||||||
|
let mut master_server = ValveMasterServer::new(DEFAULT_MASTER_IP, DEFAULT_MASTER_PORT)?;
|
||||||
|
|
||||||
|
master_server.query(region, search_filters)
|
||||||
|
}
|
||||||
237
src/services/valve_master_server/types.rs
Normal file
237
src/services/valve_master_server/types.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
/// A query filter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub enum Filter<'a> {
|
||||||
|
IsSecured(bool),
|
||||||
|
RunsMap(&'a str),
|
||||||
|
CanHavePassword(bool),
|
||||||
|
CanBeEmpty(bool),
|
||||||
|
IsEmpty(bool),
|
||||||
|
CanBeFull(bool),
|
||||||
|
RunsAppID(u32),
|
||||||
|
NotAppID(u32),
|
||||||
|
HasTags(&'a [&'a str]),
|
||||||
|
MatchName(&'a str),
|
||||||
|
MatchVersion(&'a str),
|
||||||
|
RestrictUniqueIP(bool),
|
||||||
|
OnAddress(&'a str),
|
||||||
|
Whitelisted(bool),
|
||||||
|
SpectatorProxy(bool),
|
||||||
|
IsDedicated(bool),
|
||||||
|
RunsLinux(bool),
|
||||||
|
HasGameDir(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool_as_char_u8(b: bool) -> u8 {
|
||||||
|
match b {
|
||||||
|
true => b'1',
|
||||||
|
false => b'0',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Filter<'a> {
|
||||||
|
pub(crate) fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Filter::IsSecured(secured) => {
|
||||||
|
bytes = "\\secure\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*secured)]);
|
||||||
|
}
|
||||||
|
Filter::RunsMap(map) => {
|
||||||
|
bytes = "\\map\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(map.as_bytes());
|
||||||
|
}
|
||||||
|
Filter::CanHavePassword(password) => {
|
||||||
|
bytes = "\\password\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*password)]);
|
||||||
|
}
|
||||||
|
Filter::CanBeEmpty(empty) => {
|
||||||
|
bytes = "\\empty\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*empty)]);
|
||||||
|
}
|
||||||
|
Filter::CanBeFull(full) => {
|
||||||
|
bytes = "\\full\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*full)]);
|
||||||
|
}
|
||||||
|
Filter::RunsAppID(id) => {
|
||||||
|
bytes = "\\appid\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(id.to_string().as_bytes());
|
||||||
|
}
|
||||||
|
Filter::HasTags(tags) => {
|
||||||
|
if !tags.is_empty() {
|
||||||
|
bytes = "\\gametype\\".as_bytes().to_vec();
|
||||||
|
for tag in tags.iter() {
|
||||||
|
bytes.extend(tag.as_bytes());
|
||||||
|
bytes.extend([b',']);
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Filter::NotAppID(id) => {
|
||||||
|
bytes = "\\napp\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(id.to_string().as_bytes());
|
||||||
|
}
|
||||||
|
Filter::IsEmpty(empty) => {
|
||||||
|
bytes = "\\noplayers\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*empty)]);
|
||||||
|
}
|
||||||
|
Filter::MatchName(name) => {
|
||||||
|
bytes = "\\name_match\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(name.as_bytes());
|
||||||
|
}
|
||||||
|
Filter::MatchVersion(version) => {
|
||||||
|
bytes = "\\version_match\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(version.as_bytes());
|
||||||
|
}
|
||||||
|
Filter::RestrictUniqueIP(unique) => {
|
||||||
|
bytes = "\\collapse_addr_hash\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*unique)]);
|
||||||
|
}
|
||||||
|
Filter::OnAddress(address) => {
|
||||||
|
bytes = "\\gameaddr\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(address.as_bytes());
|
||||||
|
}
|
||||||
|
Filter::Whitelisted(whitelisted) => {
|
||||||
|
bytes = "\\white\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*whitelisted)]);
|
||||||
|
}
|
||||||
|
Filter::SpectatorProxy(condition) => {
|
||||||
|
bytes = "\\proxy\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*condition)]);
|
||||||
|
}
|
||||||
|
Filter::IsDedicated(dedicated) => {
|
||||||
|
bytes = "\\dedicated\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*dedicated)]);
|
||||||
|
}
|
||||||
|
Filter::RunsLinux(linux) => {
|
||||||
|
bytes = "\\linux\\".as_bytes().to_vec();
|
||||||
|
bytes.extend([bool_as_char_u8(*linux)]);
|
||||||
|
}
|
||||||
|
Filter::HasGameDir(game_dir) => {
|
||||||
|
bytes = "\\gamedir\\".as_bytes().to_vec();
|
||||||
|
bytes.extend(game_dir.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query search filters.
|
||||||
|
/// An example of constructing one:
|
||||||
|
/// ```rust
|
||||||
|
/// use gamedig::valve_master_server::{Filter, SearchFilters};
|
||||||
|
///
|
||||||
|
/// let search_filters = SearchFilters::new()
|
||||||
|
/// .insert(Filter::RunsAppID(440))
|
||||||
|
/// .insert(Filter::CanHavePassword(true));
|
||||||
|
/// ```
|
||||||
|
/// This would query the servers that are (by App ID) 440 and that can contain
|
||||||
|
/// passwords.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct SearchFilters<'a> {
|
||||||
|
filters: Vec<Filter<'a>>,
|
||||||
|
nor_filters: Vec<Filter<'a>>,
|
||||||
|
nand_filters: Vec<Filter<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for SearchFilters<'a> {
|
||||||
|
fn default() -> Self { SearchFilters::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_or_insert_vec<'a>(filter_list: Vec<Filter<'a>>, filter: Filter<'a>) -> Vec<Filter<'a>> {
|
||||||
|
let mut list = filter_list;
|
||||||
|
|
||||||
|
let found_same_filter = list.iter_mut().find_map(|f| {
|
||||||
|
if std::mem::discriminant(f) == std::mem::discriminant(&filter) {
|
||||||
|
Some(f)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match found_same_filter {
|
||||||
|
None => list.push(filter),
|
||||||
|
Some(f) => *f = filter,
|
||||||
|
}
|
||||||
|
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SearchFilters<'a> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
filters: Vec::new(),
|
||||||
|
nor_filters: Vec::new(),
|
||||||
|
nand_filters: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(self, filter: Filter<'a>) -> Self {
|
||||||
|
Self {
|
||||||
|
filters: update_or_insert_vec(self.filters, filter),
|
||||||
|
nand_filters: self.nand_filters,
|
||||||
|
nor_filters: self.nor_filters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_nand(self, filter: Filter<'a>) -> Self {
|
||||||
|
Self {
|
||||||
|
filters: self.filters,
|
||||||
|
nand_filters: self.nand_filters,
|
||||||
|
nor_filters: update_or_insert_vec(self.nor_filters, filter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_nor(self, filter: Filter<'a>) -> Self {
|
||||||
|
Self {
|
||||||
|
filters: self.filters,
|
||||||
|
nand_filters: update_or_insert_vec(self.nand_filters, filter),
|
||||||
|
nor_filters: self.nor_filters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
// hmm, this is repetitive
|
||||||
|
for filter in &self.filters {
|
||||||
|
bytes.extend(filter.to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.nand_filters.is_empty() {
|
||||||
|
bytes.extend(b"\\nand\\".to_vec());
|
||||||
|
bytes.extend(self.nand_filters.len().to_string().as_bytes());
|
||||||
|
for filter in &self.nand_filters {
|
||||||
|
bytes.extend(filter.to_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.nor_filters.is_empty() {
|
||||||
|
bytes.extend(b"\\nor\\".to_vec());
|
||||||
|
bytes.extend(self.nor_filters.len().to_string().as_bytes());
|
||||||
|
for filter in &self.nor_filters {
|
||||||
|
bytes.extend(filter.to_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.extend([0x00]);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The region that you want to query server for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum Region {
|
||||||
|
UsEast = 0x00,
|
||||||
|
UsWest = 0x01,
|
||||||
|
AmericaSouth = 0x02,
|
||||||
|
Europe = 0x03,
|
||||||
|
Asia = 0x04,
|
||||||
|
Australia = 0x05,
|
||||||
|
MiddleEast = 0x06,
|
||||||
|
Africa = 0x07,
|
||||||
|
Others = 0xFF,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue