[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:
CosminPerRam 2023-04-28 18:00:04 +03:00 committed by GitHub
parent 348147b415
commit 4122d34cfa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 415 additions and 3 deletions

View file

@ -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`

View file

@ -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

View 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());
}

View file

@ -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
View 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;

View 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::*;

View 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)
}

View 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,
}