From 81932d9e6bb2b1309cae5a007d380702a62076d2 Mon Sep 17 00:00:00 2001 From: Denis Drakhnia Date: Sat, 7 Oct 2023 10:29:33 +0300 Subject: [PATCH] Single-threaded master server --- Cargo.lock | 366 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 27 +++ src/cli.rs | 117 +++++++++++++ src/client.rs | 90 ++++++++++ src/filter.rs | 405 +++++++++++++++++++++++++++++++++++++++++++ src/logger.rs | 38 ++++ src/main.rs | 27 +++ src/master_server.rs | 289 ++++++++++++++++++++++++++++++ src/parser.rs | 194 +++++++++++++++++++++ src/server.rs | 24 +++ src/server_info.rs | 334 +++++++++++++++++++++++++++++++++++ 11 files changed, 1911 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/cli.rs create mode 100644 src/client.rs create mode 100644 src/filter.rs create mode 100644 src/logger.rs create mode 100644 src/main.rs create mode 100644 src/master_server.rs create mode 100644 src/parser.rs create mode 100644 src/server.rs create mode 100644 src/server_info.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..874a241 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,366 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "winapi", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "hlmaster" +version = "0.1.0" +dependencies = [ + "bitflags", + "byteorder", + "chrono", + "fastrand", + "getopts", + "log", + "once_cell", + "thiserror", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" + +[[package]] +name = "log" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" + +[[package]] +name = "num-traits" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b" + +[[package]] +name = "proc-macro2" +version = "1.0.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d205725 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hlmaster" +version = "0.1.0" +license = "GPL-3.0-only" +authors = ["Denis Drakhnia "] +edition = "2021" +rust-version = "1.56" + +[features] +default = ["logtime"] +logtime = ["chrono"] + +[dependencies] +thiserror = "1.0.49" +getopts = "0.2.21" +log = "<0.4.19" +bitflags = "2.4" +byteorder = "1.4.3" +fastrand = "2.0.1" + +[dependencies.chrono] +version = "<0.4.27" +optional = true +default-features = false +features = ["clock"] +[target.wasm32-unknown-emscripten.dependencies] +once_cell = { version = "<1.18", optional = true } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..a4ce967 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::process; + +use getopts::Options; +use log::LevelFilter; +use thiserror::Error; + +const BIN_NAME: &str = env!("CARGO_BIN_NAME"); +const PKG_NAME: &str = env!("CARGO_PKG_NAME"); +const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const DEFAULT_MASTER_SERVER_PORT: u16 = 27010; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Invalid ip address \"{0}\"")] + InvalidIp(String), + #[error("Invalid port number \"{0}\"")] + InvalidPort(String), + #[error(transparent)] + Options(#[from] getopts::Fail), +} + +#[derive(Debug)] +pub struct Cli { + pub log_level: LevelFilter, + pub listen: SocketAddr, +} + +impl Default for Cli { + fn default() -> Self { + Self { + log_level: LevelFilter::Warn, + listen: SocketAddr::new( + IpAddr::from(Ipv4Addr::new(0, 0, 0, 0)), + DEFAULT_MASTER_SERVER_PORT, + ), + } + } +} + +fn print_usage(opts: Options) { + let brief = format!("Usage: {} [options]", BIN_NAME); + print!("{}", opts.usage(&brief)); +} + +fn print_version() { + println!("{} v{}", PKG_NAME, PKG_VERSION); +} + +pub fn parse() -> Result { + let mut cli = Cli::default(); + + let args: Vec<_> = std::env::args().collect(); + let mut opts = Options::new(); + opts.optflag("h", "help", "print usage help"); + opts.optflag("v", "version", "print program version"); + let log_help = + "logging level [default: warn(2)]\nLEVEL: 0-5, off, error, warn, info, debug, trace"; + opts.optopt("l", "log", log_help, "LEVEL"); + let ip_help = format!("listen ip [default: {}]", cli.listen.ip()); + opts.optopt("i", "ip", &ip_help, "IP"); + let port_help = format!("listen port [default: {}]", cli.listen.port()); + opts.optopt("p", "port", &port_help, "PORT"); + + let matches = opts.parse(&args[1..])?; + + if matches.opt_present("help") { + print_usage(opts); + process::exit(0); + } + + if matches.opt_present("version") { + print_version(); + process::exit(0); + } + + if let Some(value) = matches.opt_str("log") { + use LevelFilter as E; + + cli.log_level = match value.as_str() { + _ if "off".starts_with(&value) => E::Off, + _ if "error".starts_with(&value) => E::Error, + _ if "warn".starts_with(&value) => E::Warn, + _ if "info".starts_with(&value) => E::Info, + _ if "debug".starts_with(&value) => E::Debug, + _ if "trace".starts_with(&value) => E::Trace, + _ => match value.parse::() { + Ok(0) => E::Off, + Ok(1) => E::Error, + Ok(2) => E::Warn, + Ok(3) => E::Info, + Ok(4) => E::Debug, + Ok(5) => E::Trace, + _ => { + eprintln!("Invalid value for log option: \"{}\"", value); + process::exit(1); + } + }, + }; + } + + if let Some(s) = matches.opt_str("ip") { + cli.listen + .set_ip(s.parse().map_err(|_| Error::InvalidIp(s))?); + } + + if let Some(s) = matches.opt_str("port") { + cli.listen + .set_port(s.parse().map_err(|_| Error::InvalidPort(s))?); + } + + Ok(cli) +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..6dfe6f8 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use std::fmt; +use std::io::{self, Cursor}; +use std::ops::Deref; +use std::str; + +use byteorder::{ReadBytesExt, LE}; +use log::debug; +use thiserror::Error; + +use crate::server_info::{Region, ServerInfo}; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Invalid packet")] + InvalidPacket, + #[error(transparent)] + IoError(#[from] io::Error), +} + +pub struct Filter<'a>(&'a [u8]); + +impl fmt::Debug for Filter<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + String::from_utf8_lossy(self.0).fmt(fmt) + } +} + +impl<'a> Deref for Filter<'a> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.0 + } +} + +#[derive(Debug)] +pub enum Packet<'a> { + Challenge(Option), + ServerAdd(u32, ServerInfo<&'a str>), + ServerRemove, + QueryServers(Region, Filter<'a>), +} + +impl<'a> Packet<'a> { + pub fn decode(s: &'a [u8]) -> Result { + match s { + [b'1', region, tail @ ..] => { + let region = Region::try_from(*region).map_err(|_| Error::InvalidPacket)?; + let (tail, _last_ip) = decode_cstr(tail)?; + let (tail, filter) = decode_cstr(tail)?; + if !tail.is_empty() { + return Err(Error::InvalidPacket); + } + + Ok(Self::QueryServers(region, Filter(filter))) + } + [b'q', 0xff, tail @ ..] => { + let challenge = Cursor::new(tail).read_u32::()?; + Ok(Self::Challenge(Some(challenge))) + } + [b'0', b'\n', tail @ ..] => { + let (challenge, info, tail) = + ServerInfo::from_bytes(tail).map_err(|_| Error::InvalidPacket)?; + if tail != b"" && tail != b"\n" { + debug!("unexpected end {:?}", tail); + } + Ok(Self::ServerAdd(challenge, info)) + } + [b'b', b'\n'] => Ok(Self::ServerRemove), + [b'q'] => Ok(Self::Challenge(None)), + _ => Err(Error::InvalidPacket), + } + } +} + +fn decode_cstr(data: &[u8]) -> Result<(&[u8], &[u8]), Error> { + data.iter() + .position(|&c| c == 0) + .ok_or(Error::InvalidPacket) + .map(|offset| (&data[offset + 1..], &data[..offset])) +} + +// fn decode_str(data: &[u8]) -> Result<(&[u8], &str), Error> { +// let (tail, s) = decode_cstr(data)?; +// let s = str::from_utf8(s).map_err(|_| Error::InvalidPacket)?; +// Ok((tail, s)) +// } diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..eae6eb8 --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use std::net::SocketAddrV4; + +use bitflags::bitflags; +use log::{debug, log_enabled, Level}; + +use crate::parser::{Error as ParserError, ParseValue, Parser}; +use crate::server::Server; +use crate::server_info::{Os, ServerFlags, ServerInfo, ServerType}; + +bitflags! { + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct FilterFlags: u16 { + /// Servers running dedicated + const DEDICATED = 1 << 0; + /// Servers that are spectator proxies + const PROXY = 1 << 1; + /// Servers using anti-cheat technology (VAC, but potentially others as well) + const SECURE = 1 << 2; + /// Servers running on a Linux platform + const LINUX = 1 << 3; + /// Servers that are not password protected + const PASSWORD = 1 << 4; + /// Servers that are not empty + const NOT_EMPTY = 1 << 5; + /// Servers that are not full + const FULL = 1 << 6; + /// Servers that are empty + const NOPLAYERS = 1 << 7; + /// Servers that are whitelisted + const WHITE = 1 << 8; + /// Servers that are behind NAT + const NAT = 1 << 9; + /// Servers that are LAN + const LAN = 1 << 11; + /// Servers that has bots + const BOTS = 1 << 12; + } +} + +impl From<&ServerInfo> for FilterFlags { + fn from(info: &ServerInfo) -> Self { + let mut flags = Self::empty(); + + flags.set(Self::DEDICATED, info.server_type == ServerType::Dedicated); + flags.set(Self::PROXY, info.server_type == ServerType::Proxy); + flags.set(Self::SECURE, info.flags.contains(ServerFlags::SECURE)); + flags.set(Self::LINUX, info.os == Os::Linux); + flags.set(Self::PASSWORD, info.flags.contains(ServerFlags::PASSWORD)); + flags.set(Self::NOT_EMPTY, info.players > 0); // XXX: and not full? + flags.set(Self::FULL, info.players >= info.max); + flags.set(Self::NOPLAYERS, info.players == 0); + flags.set(Self::NAT, info.flags.contains(ServerFlags::NAT)); + flags.set(Self::LAN, info.flags.contains(ServerFlags::LAN)); + flags.set(Self::BOTS, info.flags.contains(ServerFlags::BOTS)); + + flags + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Filter<'a> { + // A special filter, specifies that servers matching any of the following [x] conditions should not be returned + // TODO: \nor\[x] + // A special filter, specifies that servers matching all of the following [x] conditions should not be returned + // TODO: \nand\[x] + /// Servers running the specified modification (ex. cstrike) + pub gamedir: Option<&'a str>, + /// Servers running the specified map (ex. cs_italy) + pub map: Option<&'a str>, + /// Servers with all of the given tag(s) in sv_tags + pub gametype: Option<&'a str>, + /// Servers with all of the given tag(s) in their 'hidden' tags (L4D2) + pub gamedata: Option<&'a str>, + /// Servers with any of the given tag(s) in their 'hidden' tags (L4D2) + pub gamedataor: Option<&'a str>, + /// Servers with their hostname matching [hostname] (can use * as a wildcard) + pub name_match: Option<&'a str>, + /// Servers running version [version] (can use * as a wildcard) + pub version_match: Option<&'a str>, + /// Return only servers on the specified IP address (port supported and optional) + pub gameaddr: Option, + /// Servers that are running game [appid] + pub appid: Option, + /// Servers that are NOT running game [appid] (This was introduced to block Left 4 Dead games from the Steam Server Browser) + pub napp: Option, + /// Return only one server for each unique IP address matched + pub collapse_addr_hash: bool, + /// Client version. + pub clver: Option<&'a str>, + + pub flags: FilterFlags, + pub flags_mask: FilterFlags, +} + +impl Filter<'_> { + pub fn insert_flag(&mut self, flag: FilterFlags, value: bool) { + self.flags.set(flag, value); + self.flags_mask.insert(flag); + } + + pub fn matches(&self, addr: SocketAddrV4, server: &Server) -> bool { + if (server.flags & self.flags_mask) != self.flags { + return false; + } + if self.gamedir.map_or(false, |i| &*server.gamedir != i) { + return false; + } + if self.version_match.map_or(false, |i| &*server.version != i) { + return false; + } + if let Some(a) = self.gameaddr { + if addr.ip() != a.ip() { + return false; + } + if a.port() != 0 && addr.port() != a.port() { + return false; + } + } + true + } +} + +impl<'a> Filter<'a> { + pub fn from_bytes(src: &'a [u8]) -> Result { + let mut parser = Parser::new(src); + let filter = parser.parse()?; + Ok(filter) + } +} + +impl<'a> ParseValue<'a> for Filter<'a> { + type Err = ParserError; + + fn parse(p: &mut Parser<'a>) -> Result { + let mut filter = Self::default(); + + loop { + let name = match p.parse_bytes() { + Ok(s) => s, + Err(ParserError::End) => break, + Err(e) => return Err(e), + }; + + match name { + b"dedicated" => filter.insert_flag(FilterFlags::DEDICATED, p.parse()?), + b"secure" => filter.insert_flag(FilterFlags::SECURE, p.parse()?), + b"gamedir" => filter.gamedir = Some(p.parse()?), + b"map" => filter.map = Some(p.parse()?), + b"empty" => filter.insert_flag(FilterFlags::NOT_EMPTY, p.parse()?), + b"full" => filter.insert_flag(FilterFlags::FULL, p.parse()?), + b"linux" => filter.insert_flag(FilterFlags::LINUX, p.parse()?), + b"password" => filter.insert_flag(FilterFlags::PASSWORD, p.parse()?), + b"proxy" => filter.insert_flag(FilterFlags::PROXY, p.parse()?), + b"appid" => filter.appid = Some(p.parse()?), + b"napp" => filter.napp = Some(p.parse()?), + b"noplayers" => filter.insert_flag(FilterFlags::NOPLAYERS, p.parse()?), + b"white" => filter.insert_flag(FilterFlags::WHITE, p.parse()?), + b"gametype" => filter.gametype = Some(p.parse()?), + b"gamedata" => filter.gamedata = Some(p.parse()?), + b"gamedataor" => filter.gamedataor = Some(p.parse()?), + b"name_match" => filter.name_match = Some(p.parse()?), + b"version_match" => filter.version_match = Some(p.parse()?), + b"collapse_addr_hash" => filter.collapse_addr_hash = p.parse()?, + b"gameaddr" => { + let s = p.parse::<&str>()?; + if let Ok(addr) = s.parse() { + filter.gameaddr = Some(addr); + } else if let Ok(ip) = s.parse() { + filter.gameaddr = Some(SocketAddrV4::new(ip, 0)); + } + } + b"clver" => filter.clver = Some(p.parse()?), + b"nat" => filter.insert_flag(FilterFlags::NAT, p.parse()?), + b"lan" => filter.insert_flag(FilterFlags::LAN, p.parse()?), + b"bots" => filter.insert_flag(FilterFlags::BOTS, p.parse()?), + _ => { + // skip unknown fields + let value = p.parse_bytes()?; + if log_enabled!(Level::Debug) { + let name = String::from_utf8_lossy(name); + let value = String::from_utf8_lossy(value); + debug!("Invalid Filter field \"{}\" = \"{}\"", name, value); + } + } + } + } + + Ok(filter) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::net::Ipv4Addr; + + macro_rules! tests { + ($($name:ident$(($($predefined_f:ident: $predefined_v:expr),+ $(,)?))? { + $($src:expr => { + $($field:ident: $value:expr),* $(,)? + })+ + })+) => { + $(#[test] + fn $name() { + let predefined = Filter { + $($($predefined_f: $predefined_v,)+)? + .. Filter::default() + }; + $(assert_eq!( + Filter::from_bytes($src), + Ok(Filter { + $($field: $value,)* + ..predefined + }) + );)+ + })+ + }; + } + + tests! { + parse_gamedir { + b"\\gamedir\\valve" => { + gamedir: Some("valve"), + } + } + parse_map { + b"\\map\\crossfire" => { + map: Some("crossfire"), + } + } + parse_appid { + b"\\appid\\70" => { + appid: Some(70), + } + } + parse_napp { + b"\\napp\\70" => { + napp: Some(70), + } + } + parse_gametype { + b"\\gametype\\a,b,c,d" => { + gametype: Some("a,b,c,d"), + } + } + parse_gamedata { + b"\\gamedata\\a,b,c,d" => { + gamedata: Some("a,b,c,d"), + } + } + parse_gamedataor { + b"\\gamedataor\\a,b,c,d" => { + gamedataor: Some("a,b,c,d"), + } + } + parse_name_match { + b"\\name_match\\localhost" => { + name_match: Some("localhost"), + } + } + parse_version_match { + b"\\version_match\\1.2.3.4" => { + version_match: Some("1.2.3.4"), + } + } + parse_collapse_addr_hash { + b"\\collapse_addr_hash\\1" => { + collapse_addr_hash: true, + } + } + parse_gameaddr { + b"\\gameaddr\\192.168.1.100" => { + gameaddr: Some(SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 100), 0)), + } + b"\\gameaddr\\192.168.1.100:27015" => { + gameaddr: Some(SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 100), 27015)), + } + } + parse_clver { + b"\\clver\\0.20" => { + clver: Some("0.20"), + } + } + parse_dedicated(flags_mask: FilterFlags::DEDICATED) { + b"\\dedicated\\0" => {} + b"\\dedicated\\1" => { + flags: FilterFlags::DEDICATED, + } + } + parse_secure(flags_mask: FilterFlags::SECURE) { + b"\\secure\\0" => {} + b"\\secure\\1" => { + flags: FilterFlags::SECURE, + } + } + parse_linux(flags_mask: FilterFlags::LINUX) { + b"\\linux\\0" => {} + b"\\linux\\1" => { + flags: FilterFlags::LINUX, + } + } + parse_password(flags_mask: FilterFlags::PASSWORD) { + b"\\password\\0" => {} + b"\\password\\1" => { + flags: FilterFlags::PASSWORD, + } + } + parse_empty(flags_mask: FilterFlags::NOT_EMPTY) { + b"\\empty\\0" => {} + b"\\empty\\1" => { + flags: FilterFlags::NOT_EMPTY, + } + } + parse_full(flags_mask: FilterFlags::FULL) { + b"\\full\\0" => {} + b"\\full\\1" => { + flags: FilterFlags::FULL, + } + } + parse_proxy(flags_mask: FilterFlags::PROXY) { + b"\\proxy\\0" => {} + b"\\proxy\\1" => { + flags: FilterFlags::PROXY, + } + } + parse_noplayers(flags_mask: FilterFlags::NOPLAYERS) { + b"\\noplayers\\0" => {} + b"\\noplayers\\1" => { + flags: FilterFlags::NOPLAYERS, + } + } + parse_white(flags_mask: FilterFlags::WHITE) { + b"\\white\\0" => {} + b"\\white\\1" => { + flags: FilterFlags::WHITE, + } + } + parse_nat(flags_mask: FilterFlags::NAT) { + b"\\nat\\0" => {} + b"\\nat\\1" => { + flags: FilterFlags::NAT, + } + } + parse_lan(flags_mask: FilterFlags::LAN) { + b"\\lan\\0" => {} + b"\\lan\\1" => { + flags: FilterFlags::LAN, + } + } + parse_bots(flags_mask: FilterFlags::BOTS) { + b"\\bots\\0" => {} + b"\\bots\\1" => { + flags: FilterFlags::BOTS, + } + } + + parse_all { + b"\ + \\appid\\70\ + \\bots\\1\ + \\clver\\0.20\ + \\collapse_addr_hash\\1\ + \\dedicated\\1\ + \\empty\\1\ + \\full\\1\ + \\gameaddr\\192.168.1.100\ + \\gamedata\\a,b,c,d\ + \\gamedataor\\a,b,c,d\ + \\gamedir\\valve\ + \\gametype\\a,b,c,d\ + \\lan\\1\ + \\linux\\1\ + \\map\\crossfire\ + \\name_match\\localhost\ + \\napp\\60\ + \\nat\\1\ + \\noplayers\\1\ + \\password\\1\ + \\proxy\\1\ + \\secure\\1\ + \\version_match\\1.2.3.4\ + \\white\\1\ + " => { + gamedir: Some("valve"), + map: Some("crossfire"), + appid: Some(70), + napp: Some(60), + gametype: Some("a,b,c,d"), + gamedata: Some("a,b,c,d"), + gamedataor: Some("a,b,c,d"), + name_match: Some("localhost"), + version_match: Some("1.2.3.4"), + collapse_addr_hash: true, + gameaddr: Some(SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 100), 0)), + clver: Some("0.20"), + flags: FilterFlags::all(), + flags_mask: FilterFlags::all(), + } + } + } +} diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..1f0d0c0 --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use log::{LevelFilter, Metadata, Record}; + +struct Logger; + +impl Logger {} + +impl log::Log for Logger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + #[cfg(not(feature = "logtime"))] + println!("{} - {}", record.level(), record.args()); + + #[cfg(feature = "logtime")] + { + let dt = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + println!("[{}] {} - {}", dt, record.level(), record.args()); + } + } + } + + fn flush(&self) {} +} + +static LOGGER: Logger = Logger; + +pub fn init(level_filter: LevelFilter) { + if let Err(e) = log::set_logger(&LOGGER) { + eprintln!("Failed to initialize logger: {}", e); + } + log::set_max_level(level_filter); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e0f2c91 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +mod cli; +mod client; +mod filter; +mod logger; +mod master_server; +mod parser; +mod server; +mod server_info; + +use log::error; + +fn main() { + let cli = cli::parse().unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + logger::init(cli.log_level); + + if let Err(e) = master_server::run(cli.listen) { + error!("{}", e); + std::process::exit(1); + } +} diff --git a/src/master_server.rs b/src/master_server.rs new file mode 100644 index 0000000..cad6c64 --- /dev/null +++ b/src/master_server.rs @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use std::collections::HashMap; +use std::io::prelude::*; +use std::io::{self, Cursor}; +use std::net::{SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; +use std::ops::Deref; +use std::time::Instant; + +use fastrand::Rng; +use log::{error, info, trace, warn}; +use thiserror::Error; + +use crate::client::Packet; +use crate::filter::Filter; +use crate::server::Server; +use crate::server_info::Region; + +/// The maximum size of UDP packets. +const MAX_PACKET_SIZE: usize = 512; + +const CHALLENGE_RESPONSE_HEADER: &[u8] = b"\xff\xff\xff\xffs\n"; +const SERVER_LIST_HEADER: &[u8] = b"\xff\xff\xff\xfff\n"; + +/// Time in seconds while server is valid. +const SERVER_TIMEOUT: u32 = 300; +/// How many cleanup calls should be skipped before removing outdated servers. +const SERVER_CLEANUP_MAX: usize = 100; + +/// Time in seconds while challenge is valid. +const CHALLENGE_TIMEOUT: u32 = 300; +/// How many cleanup calls should be skipped before removing outdated challenges. +const CHALLENGE_CLEANUP_MAX: usize = 100; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Failed to bind server socket: {0}")] + BindSocket(io::Error), + #[error("Failed to decode packet: {0}")] + ClientPacket(#[from] crate::client::Error), + #[error(transparent)] + Io(#[from] io::Error), +} + +/// HashMap entry to keep tracking creation time. +struct Entry { + time: u32, + value: T, +} + +impl Entry { + fn new(time: u32, value: T) -> Self { + Self { time, value } + } + + fn is_valid(&self, now: u32, duration: u32) -> bool { + (now - self.time) < duration + } +} + +impl Entry { + fn matches(&self, addr: SocketAddrV4, region: Region, filter: &Filter) -> bool { + self.region == region && filter.matches(addr, self) + } +} + +impl Deref for Entry { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +struct MasterServer { + sock: UdpSocket, + start_time: Instant, + challenges: HashMap>, + servers: HashMap>, + rng: Rng, + cleanup_challenges: usize, + cleanup_servers: usize, +} + +impl MasterServer { + fn new(addr: SocketAddr) -> Result { + info!("Listen address: {}", addr); + let sock = UdpSocket::bind(addr).map_err(Error::BindSocket)?; + + Ok(Self { + sock, + start_time: Instant::now(), + challenges: Default::default(), + servers: Default::default(), + rng: Rng::new(), + cleanup_challenges: 0, + cleanup_servers: 0, + }) + } + + fn run(&mut self) -> Result<(), Error> { + let mut buf = [0; MAX_PACKET_SIZE]; + loop { + let (n, from) = self.sock.recv_from(&mut buf)?; + let from = match from { + SocketAddr::V4(a) => a, + _ => { + warn!("{}: Received message from IPv6, unimplemented", from); + continue; + } + }; + + if let Err(e) = self.handle_packet(from, &buf[..n]) { + error!("{}: {}", from, e); + } + } + } + + fn handle_packet(&mut self, from: SocketAddrV4, s: &[u8]) -> Result<(), Error> { + let packet = Packet::decode(s)?; + trace!("{}: recv {:?}", from, packet); + + match packet { + Packet::Challenge(server_challenge) => { + let challenge = self.add_challenge(from); + trace!("{}: New challenge {}", from, challenge); + self.send_challenge_response(from, challenge, server_challenge)?; + self.remove_outdated_challenges(); + } + Packet::ServerAdd(challenge, info) => { + let entry = match self.challenges.get(&from) { + Some(e) => e, + None => { + trace!("{}: Challenge does not exists", from); + return Ok(()); + } + }; + if !entry.is_valid(self.now(), CHALLENGE_TIMEOUT) { + return Ok(()); + } + if challenge != entry.value { + warn!( + "{}: Expected challenge {} but received {}", + from, entry.value, challenge + ); + return Ok(()); + } + if self.challenges.remove(&from).is_some() { + self.add_server(from, Server::new(&info)); + } + self.remove_outdated_servers(); + } + Packet::ServerRemove => { + // XXX: anyone can delete server from the list? + self.servers.remove(&from); + } + Packet::QueryServers(region, filter) => match Filter::from_bytes(&filter) { + Ok(filter) => self.send_server_list(from, region, &filter)?, + _ => { + warn!("{}: Invalid filter: {:?}", from, filter); + return Ok(()); + } + }, + } + + Ok(()) + } + + fn now(&self) -> u32 { + self.start_time.elapsed().as_secs() as u32 + } + + fn add_challenge(&mut self, addr: SocketAddrV4) -> u32 { + let x = self.rng.u32(..); + let entry = Entry::new(self.now(), x); + self.challenges.insert(addr, entry); + x + } + + fn remove_outdated_challenges(&mut self) { + if self.cleanup_challenges < CHALLENGE_CLEANUP_MAX { + self.cleanup_challenges += 1; + return; + } + let now = self.now(); + let old = self.challenges.len(); + self.challenges + .retain(|_, v| v.is_valid(now, CHALLENGE_TIMEOUT)); + let new = self.challenges.len(); + if old != new { + trace!("Removed {} outdated challenges", old - new); + } + self.cleanup_challenges = 0; + } + + fn add_server(&mut self, addr: SocketAddrV4, server: Server) { + let entry = Entry::new(self.now(), server); + match self.servers.insert(addr, entry) { + Some(_) => trace!("{}: Updated GameServer", addr), + None => trace!("{}: New GameServer", addr), + } + } + + fn remove_outdated_servers(&mut self) { + if self.cleanup_servers < SERVER_CLEANUP_MAX { + self.cleanup_servers += 1; + return; + } + let now = self.now(); + let old = self.servers.len(); + self.servers.retain(|_, v| v.is_valid(now, SERVER_TIMEOUT)); + let new = self.servers.len(); + if old != new { + trace!("Removed {} outdated servers", old - new); + } + self.cleanup_servers = 0; + } + + fn send_challenge_response( + &self, + to: A, + challenge: u32, + server_challenge: Option, + ) -> Result<(), io::Error> { + let mut buf = [0; MAX_PACKET_SIZE]; + let mut cur = Cursor::new(&mut buf[..]); + + cur.write_all(CHALLENGE_RESPONSE_HEADER)?; + cur.write_all(&challenge.to_le_bytes())?; + if let Some(x) = server_challenge { + cur.write_all(&x.to_le_bytes())?; + } + + let n = cur.position() as usize; + self.sock.send_to(&buf[..n], to)?; + Ok(()) + } + + fn send_server_list( + &self, + to: A, + region: Region, + filter: &Filter, + ) -> Result<(), io::Error> { + let now = self.now(); + let mut iter = self + .servers + .iter() + .filter(|i| i.1.is_valid(now, SERVER_TIMEOUT)) + .filter(|i| i.1.matches(*i.0, region, filter)) + .map(|i| i.0); + + let mut buf = [0; MAX_PACKET_SIZE]; + let mut done = false; + while !done { + let mut cur = Cursor::new(&mut buf[..]); + cur.write_all(SERVER_LIST_HEADER)?; + + loop { + match iter.next() { + Some(i) => { + cur.write_all(&i.ip().octets()[..])?; + cur.write_all(&i.port().to_be_bytes())?; + } + None => { + done = true; + break; + } + } + + if (cur.position() as usize) > (MAX_PACKET_SIZE - 12) { + break; + } + } + + // terminate list + cur.write_all(&[0; 6][..])?; + + let n = cur.position() as usize; + self.sock.send_to(&buf[..n], &to)?; + } + Ok(()) + } +} + +pub fn run(addr: SocketAddr) -> Result<(), Error> { + MasterServer::new(addr)?.run() +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..3eddb4f --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use std::str; + +use thiserror::Error; + +#[derive(Copy, Clone, Error, Debug, PartialEq, Eq)] +pub enum Error { + #[error("End of map")] + End, + #[error("Invalid map")] + InvalidMap, + #[error("Invalid string")] + InvalidString, + #[error("Invalid boolean")] + InvalidBool, + #[error("Invalid integer")] + InvalidInteger, +} + +pub type Result = std::result::Result; + +pub struct Parser<'a> { + cur: &'a [u8], +} + +impl<'a> Parser<'a> { + pub fn new(cur: &'a [u8]) -> Self { + Self { cur } + } + + pub fn parse_bytes(&mut self) -> Result<&'a [u8]> { + match self.cur.split_first() { + Some((b'\\', tail)) => { + let pos = tail + .iter() + .position(|&c| c == b'\\' || c == b'\n') + .unwrap_or(tail.len()); + let (head, tail) = tail.split_at(pos); + self.cur = tail; + Ok(head) + } + Some((b'\n', _)) | None => Err(Error::End), + _ => Err(Error::InvalidMap), + } + } + + pub fn parse>(&mut self) -> Result { + T::parse(self) + } + + pub fn end(self) -> &'a [u8] { + self.cur + } +} + +pub trait ParseValue<'a>: Sized { + type Err: From; + + fn parse(p: &mut Parser<'a>) -> Result; +} + +impl<'a> ParseValue<'a> for &'a [u8] { + type Err = Error; + + fn parse(p: &mut Parser<'a>) -> Result { + p.parse_bytes() + } +} + +impl<'a> ParseValue<'a> for &'a str { + type Err = Error; + + fn parse(p: &mut Parser<'a>) -> Result { + p.parse_bytes() + .and_then(|s| str::from_utf8(s).map_err(|_| Error::InvalidString)) + } +} + +impl ParseValue<'_> for String { + type Err = Error; + + fn parse(p: &mut Parser) -> Result { + p.parse::<&str>().map(|s| s.to_string()) + } +} + +impl ParseValue<'_> for Box { + type Err = Error; + + fn parse(p: &mut Parser) -> Result { + p.parse::().map(|s| s.into_boxed_str()) + } +} + +impl ParseValue<'_> for bool { + type Err = Error; + + fn parse(p: &mut Parser) -> Result { + p.parse_bytes().and_then(|s| match s { + b"0" => Ok(false), + b"1" => Ok(true), + _ => Err(Error::InvalidBool), + }) + } +} + +macro_rules! impl_parse_int { + ($($t:ty : $f:ty),+ $(,)?) => ( + $(impl ParseValue<'_> for $t { + type Err = Error; + + fn parse(p: &mut Parser) -> Result { + p.parse::<&str>().and_then(|s| { + s.parse::<$t>() + .or_else(|_| s.parse::<$f>().map(|i| i as $t)) + .map_err(|_| Error::InvalidInteger) + }) + } + })+ + ); +} + +impl_parse_int! { + i8 :u8, + i16:u16, + i32:u32, + i64:u64, + + u8 :i8, + u16:i16, + u32:i32, + u64:i64, +} + +#[cfg(test)] +pub(crate) fn parse<'a, T: ParseValue<'a>>(s: &'a [u8]) -> Result { + Parser::new(s).parse() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_end() { + assert_eq!(parse::<&[u8]>(b"\\abc"), Ok(&b"abc"[..])); + assert_eq!(parse::<&[u8]>(b"\\abc\\"), Ok(&b"abc"[..])); + assert_eq!(parse::<&[u8]>(b"\\abc\n"), Ok(&b"abc"[..])); + } + + #[test] + fn parse_empty() { + assert_eq!(parse::<&[u8]>(b""), Err(Error::End)); + assert_eq!(parse::<&[u8]>(b"\n"), Err(Error::End)); + assert_eq!(parse::<&[u8]>(b"\\"), Ok(&b""[..])); + assert_eq!(parse::<&[u8]>(b"\\\\"), Ok(&b""[..])); + assert_eq!(parse::<&[u8]>(b"\\\n"), Ok(&b""[..])); + } + + #[test] + fn parse_str() { + assert_eq!(parse::<&str>(b"\\abc\n"), Ok("abc")); + assert_eq!(parse::<&str>(b"\\abc\0\n"), Ok("abc\0")); + assert_eq!(parse::<&str>(b"\\abc\x80\\n"), Err(Error::InvalidString)); + } + + #[test] + fn parse_bool() { + assert_eq!(parse::(b"\\0\n"), Ok(false)); + assert_eq!(parse::(b"\\1\n"), Ok(true)); + assert_eq!(parse::(b"\\2\n"), Err(Error::InvalidBool)); + assert_eq!(parse::(b"\\00\n"), Err(Error::InvalidBool)); + assert_eq!(parse::(b"\\true\n"), Err(Error::InvalidBool)); + assert_eq!(parse::(b"\\false\n"), Err(Error::InvalidBool)); + } + + #[test] + fn parse_int() { + assert_eq!(parse::(b"\\0\n"), Ok(0)); + assert_eq!(parse::(b"\\255\n"), Ok(255)); + assert_eq!(parse::(b"\\-1\n"), Ok(255)); + assert_eq!(parse::(b"\\256\n"), Err(Error::InvalidInteger)); + assert_eq!(parse::(b"\\0xff\n"), Err(Error::InvalidInteger)); + + assert_eq!(parse::(b"\\-1\n"), Ok(-1)); + assert_eq!(parse::(b"\\-128\n"), Ok(-128)); + assert_eq!(parse::(b"\\255\n"), Ok(-1)); + assert_eq!(parse::(b"\\128\n"), Ok(-128)); + assert_eq!(parse::(b"\\-129\n"), Err(Error::InvalidInteger)); + assert_eq!(parse::(b"\\0xff\n"), Err(Error::InvalidInteger)); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..a22c159 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use crate::filter::FilterFlags; +use crate::server_info::{Region, ServerInfo}; + +#[derive(Clone, Debug)] +pub struct Server { + pub version: Box, + pub gamedir: Box, + pub flags: FilterFlags, + pub region: Region, +} + +impl Server { + pub fn new(info: &ServerInfo<&str>) -> Self { + Self { + version: info.version.to_string().into_boxed_str(), + gamedir: info.gamedir.to_string().into_boxed_str(), + flags: FilterFlags::from(info), + region: info.region, + } + } +} diff --git a/src/server_info.rs b/src/server_info.rs new file mode 100644 index 0000000..235fe61 --- /dev/null +++ b/src/server_info.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: 2023 Denis Drakhnia + +use std::fmt; + +use bitflags::bitflags; +use log::{debug, log_enabled, Level}; +use thiserror::Error; + +use crate::parser::{Error as ParserError, ParseValue, Parser}; + +#[derive(Copy, Clone, Error, Debug, PartialEq, Eq)] +pub enum Error { + #[error("Invalid region")] + InvalidRegion, + #[error("Missing challenge in ServerInfo")] + MissingChallenge, + #[error(transparent)] + Parser(#[from] ParserError), +} + +pub type Result = std::result::Result; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Os { + Linux, + Windows, + Mac, + Unknown, +} + +impl Default for Os { + fn default() -> Os { + Os::Unknown + } +} + +impl ParseValue<'_> for Os { + type Err = Error; + + fn parse(p: &mut Parser) -> Result { + match p.parse_bytes()? { + b"l" => Ok(Os::Linux), + b"w" => Ok(Os::Windows), + b"m" => Ok(Os::Mac), + _ => Ok(Os::Unknown), + } + } +} + +impl fmt::Display for Os { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + Os::Linux => "Linux", + Os::Windows => "Windows", + Os::Mac => "Mac", + Os::Unknown => "Unknown", + }; + write!(fmt, "{}", s) + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +#[repr(u8)] +pub enum ServerType { + Dedicated, + Local, + Proxy, + Unknown, +} + +impl Default for ServerType { + fn default() -> Self { + Self::Unknown + } +} + +impl ParseValue<'_> for ServerType { + type Err = Error; + + fn parse(p: &mut Parser) -> Result { + match p.parse_bytes()? { + b"d" => Ok(Self::Dedicated), + b"l" => Ok(Self::Local), + b"p" => Ok(Self::Proxy), + _ => Ok(Self::Unknown), + } + } +} + +impl fmt::Display for ServerType { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + use ServerType as E; + + let s = match self { + E::Dedicated => "dedicated", + E::Local => "local", + E::Proxy => "proxy", + E::Unknown => "unknown", + }; + + write!(fmt, "{}", s) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Region { + USEastCoast = 0x00, + USWestCoast = 0x01, + SouthAmerica = 0x02, + Europe = 0x03, + Asia = 0x04, + Australia = 0x05, + MiddleEast = 0x06, + Africa = 0x07, + RestOfTheWorld = 0xff, +} + +impl Default for Region { + fn default() -> Self { + Self::RestOfTheWorld + } +} + +impl TryFrom for Region { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 0x00 => Ok(Region::USEastCoast), + 0x01 => Ok(Region::USWestCoast), + 0x02 => Ok(Region::SouthAmerica), + 0x03 => Ok(Region::Europe), + 0x04 => Ok(Region::Asia), + 0x05 => Ok(Region::Australia), + 0x06 => Ok(Region::MiddleEast), + 0x07 => Ok(Region::Africa), + 0xff => Ok(Region::RestOfTheWorld), + _ => Err(()), + } + } +} + +impl ParseValue<'_> for Region { + type Err = Error; + + fn parse(p: &mut Parser<'_>) -> Result { + let value = p.parse::()?; + Self::try_from(value).map_err(|_| Error::InvalidRegion) + } +} + +bitflags! { + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct ServerFlags: u8 { + const BOTS = 1 << 0; + const PASSWORD = 1 << 1; + const SECURE = 1 << 2; + const LAN = 1 << 3; + const NAT = 1 << 4; + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ServerInfo> { + pub gamedir: T, + pub map: T, + pub version: T, + pub product: T, + pub server_type: ServerType, + pub os: Os, + pub region: Region, + pub protocol: u8, + pub players: u8, + pub max: u8, + pub flags: ServerFlags, +} + +impl<'a, T> ServerInfo +where + T: 'a + Default + ParseValue<'a, Err = ParserError>, +{ + pub fn from_bytes(src: &'a [u8]) -> Result<(u32, Self, &'a [u8]), Error> { + let mut parser = Parser::new(src); + let (challenge, info) = parser.parse()?; + let tail = match parser.end() { + [b'\n', tail @ ..] => tail, + tail => tail, + }; + Ok((challenge, info, tail)) + } +} + +impl<'a, T> ParseValue<'a> for (u32, ServerInfo) +where + T: 'a + Default + ParseValue<'a, Err = ParserError>, +{ + type Err = Error; + + fn parse(p: &mut Parser<'a>) -> Result { + let mut info = ServerInfo::default(); + let mut challenge = None; + + loop { + let name = match p.parse_bytes() { + Ok(s) => s, + Err(ParserError::End) => break, + Err(e) => return Err(e.into()), + }; + + match name { + b"protocol" => info.protocol = p.parse()?, + b"challenge" => challenge = Some(p.parse()?), + b"players" => info.players = p.parse()?, + b"max" => info.max = p.parse()?, + b"gamedir" => info.gamedir = p.parse()?, + b"map" => info.map = p.parse()?, + b"type" => info.server_type = p.parse()?, + b"os" => info.os = p.parse()?, + b"version" => info.version = p.parse()?, + b"region" => info.region = p.parse()?, + b"product" => info.product = p.parse()?, + b"bots" => info.flags.set(ServerFlags::BOTS, p.parse()?), + b"password" => info.flags.set(ServerFlags::PASSWORD, p.parse()?), + b"secure" => info.flags.set(ServerFlags::SECURE, p.parse()?), + b"lan" => info.flags.set(ServerFlags::LAN, p.parse()?), + b"nat" => info.flags.set(ServerFlags::NAT, p.parse()?), + _ => { + // skip unknown fields + let value = p.parse_bytes()?; + if log_enabled!(Level::Debug) { + let name = String::from_utf8_lossy(name); + let value = String::from_utf8_lossy(value); + debug!("Invalid ServerInfo field \"{}\" = \"{}\"", name, value); + } + } + } + } + + match challenge { + Some(challenge) => Ok((challenge, info)), + None => Err(Error::MissingChallenge), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + #[test] + fn parse_os() { + assert_eq!(parse(b"\\l\\"), Ok(Os::Linux)); + assert_eq!(parse(b"\\w\\"), Ok(Os::Windows)); + assert_eq!(parse(b"\\m\\"), Ok(Os::Mac)); + assert_eq!(parse::(b"\\u\\"), Ok(Os::Unknown)); + } + + #[test] + fn parse_server_type() { + use ServerType as E; + + assert_eq!(parse(b"\\d\\"), Ok(E::Dedicated)); + assert_eq!(parse(b"\\l\\"), Ok(E::Local)); + assert_eq!(parse(b"\\p\\"), Ok(E::Proxy)); + assert_eq!(parse::(b"\\u\\"), Ok(E::Unknown)); + } + + #[test] + fn parse_region() { + assert_eq!(parse(b"\\0\\"), Ok(Region::USEastCoast)); + assert_eq!(parse(b"\\1\\"), Ok(Region::USWestCoast)); + assert_eq!(parse(b"\\2\\"), Ok(Region::SouthAmerica)); + assert_eq!(parse(b"\\3\\"), Ok(Region::Europe)); + assert_eq!(parse(b"\\4\\"), Ok(Region::Asia)); + assert_eq!(parse(b"\\5\\"), Ok(Region::Australia)); + assert_eq!(parse(b"\\6\\"), Ok(Region::MiddleEast)); + assert_eq!(parse(b"\\7\\"), Ok(Region::Africa)); + assert_eq!(parse(b"\\-1\\"), Ok(Region::RestOfTheWorld)); + assert_eq!(parse::(b"\\-2\\"), Err(Error::InvalidRegion)); + assert_eq!( + parse::(b"\\u\\"), + Err(Error::Parser(ParserError::InvalidInteger)) + ); + } + + #[test] + fn parse_server_info() { + let buf = b"\ + \\protocol\\47\ + \\challenge\\12345678\ + \\players\\16\ + \\max\\32\ + \\bots\\1\ + \\invalid_field\\field_value\ + \\gamedir\\cstrike\ + \\map\\de_dust\ + \\type\\d\ + \\password\\1\ + \\os\\l\ + \\secure\\1\ + \\lan\\1\ + \\version\\1.1.2.5\ + \\region\\-1\ + \\product\\cstrike\ + \\nat\\1\ + \ntail\ + "; + + assert_eq!( + ServerInfo::from_bytes(&buf[..]), + Ok(( + 12345678, + ServerInfo::<&str> { + protocol: 47, + players: 16, + max: 32, + gamedir: "cstrike", + map: "de_dust", + server_type: ServerType::Dedicated, + os: Os::Linux, + version: "1.1.2.5", + region: Region::RestOfTheWorld, + product: "cstrike", + flags: ServerFlags::all(), + }, + &b"tail"[..] + )) + ); + } +}