mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-05-06 15:27:28 +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.
|
||||
- [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:
|
||||
Protocols:
|
||||
- Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info`
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
|
||||
# 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:
|
||||
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"))]
|
||||
pub mod games;
|
||||
pub mod protocols;
|
||||
pub mod services;
|
||||
|
||||
mod bufferer;
|
||||
mod socket;
|
||||
|
|
@ -30,3 +31,4 @@ mod utils;
|
|||
pub use errors::*;
|
||||
#[cfg(not(feature = "no_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