diff --git a/Cargo.lock b/Cargo.lock index 6fea798..3c9c95b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,7 @@ name = "xash3d-master" version = "0.1.0" dependencies = [ "bitflags 2.4.0", + "blake2b_simd", "chrono", "fastrand", "getopts", diff --git a/admin/src/main.rs b/admin/src/main.rs index 1d7e563..3740980 100644 --- a/admin/src/main.rs +++ b/admin/src/main.rs @@ -6,8 +6,8 @@ mod cli; use std::io::{self, Write}; use std::net::UdpSocket; -use termion::input::TermRead; use blake2b_simd::Params; +use termion::input::TermRead; use thiserror::Error; use xash3d_protocol::{admin, master}; diff --git a/master/Cargo.toml b/master/Cargo.toml index 48706b7..50af3b2 100644 --- a/master/Cargo.toml +++ b/master/Cargo.toml @@ -18,6 +18,7 @@ bitflags = "2.4" fastrand = "2.0.1" serde = { version = "1.0.188", features = ["derive"] } toml = "0.5.11" +blake2b_simd = "<0.6" xash3d-protocol = { path = "../protocol", version = "0.1.0" } [dependencies.chrono] diff --git a/master/config/main.toml b/master/config/main.toml index adda896..d12cca3 100644 --- a/master/config/main.toml +++ b/master/config/main.toml @@ -20,3 +20,16 @@ version = "0.20" update_title = "https://github.com/FWGS/xash3d-fwgs" update_map = "Update please" update_addr = "127.0.0.1:27010" + +[hash] +len = 64 +key = "Half-Life" +personal = "Freeman" + +#[[admin]] +#name = "gordon" +#password = "crowbar" + +#[[admin]] +#name = "gman" +#password = "time2choose" diff --git a/master/src/config.rs b/master/src/config.rs index 8dd8c65..47b8e64 100644 --- a/master/src/config.rs +++ b/master/src/config.rs @@ -9,6 +9,7 @@ use std::path::Path; use log::LevelFilter; use serde::{de::Error as _, Deserialize, Deserializer}; use thiserror::Error; +use xash3d_protocol::admin; use xash3d_protocol::filter::Version; pub const DEFAULT_CONFIG_PATH: &str = "config/main.toml"; @@ -34,6 +35,11 @@ pub struct Config { pub server: ServerConfig, #[serde(default)] pub client: ClientConfig, + #[serde(default)] + pub hash: HashConfig, + #[serde(rename = "admin")] + #[serde(default)] + pub admin_list: Box<[AdminConfig]>, } #[derive(Deserialize, Debug)] @@ -105,6 +111,24 @@ pub struct ClientConfig { pub update_addr: Option, } +#[derive(Deserialize, Default, Debug)] +#[serde(deny_unknown_fields)] +pub struct HashConfig { + #[serde(default = "default_hash_len")] + pub len: usize, + #[serde(default = "default_hash_key")] + pub key: Box, + #[serde(default = "default_hash_personal")] + pub personal: Box, +} + +#[derive(Deserialize, Default, Debug)] +#[serde(deny_unknown_fields)] +pub struct AdminConfig { + pub name: Box, + pub password: Box, +} + fn default_log_level() -> LevelFilter { LevelFilter::Warn } @@ -121,6 +145,18 @@ fn default_timeout() -> u32 { DEFAULT_TIMEOUT } +fn default_hash_len() -> usize { + admin::HASH_LEN +} + +fn default_hash_key() -> Box { + Box::from(admin::HASH_KEY) +} + +fn default_hash_personal() -> Box { + Box::from(admin::HASH_PERSONAL) +} + fn deserialize_log_level<'de, D>(de: D) -> Result where D: Deserializer<'de>, diff --git a/master/src/master_server.rs b/master/src/master_server.rs index 2407abf..37f0165 100644 --- a/master/src/master_server.rs +++ b/master/src/master_server.rs @@ -1,18 +1,19 @@ // SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: 2023 Denis Drakhnia -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io; -use std::net::{SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; use std::ops::Deref; use std::time::Instant; +use blake2b_simd::Params; use fastrand::Rng; use log::{error, info, trace, warn}; use thiserror::Error; use xash3d_protocol::filter::{Filter, Version}; use xash3d_protocol::server::Region; -use xash3d_protocol::{ServerInfo, admin, game, master, server, Error as ProtocolError}; +use xash3d_protocol::{admin, game, master, server, Error as ProtocolError, ServerInfo}; use crate::config::{self, Config}; @@ -25,6 +26,9 @@ const SERVER_CLEANUP_MAX: usize = 100; /// How many cleanup calls should be skipped before removing outdated challenges. const CHALLENGE_CLEANUP_MAX: usize = 100; +/// How many cleanup calls should be skipped before removing outdated admin challenges. +const ADMIN_CHALLENGE_CLEANUP_MAX: usize = 100; + #[derive(Error, Debug)] pub enum Error { #[error("Failed to bind server socket: {0}")] @@ -33,10 +37,12 @@ pub enum Error { Protocol(#[from] ProtocolError), #[error(transparent)] Io(#[from] io::Error), + #[error("Admin challenge do not exist")] + AdminChallengeNotFound, } /// HashMap entry to keep tracking creation time. -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] struct Entry { time: u32, value: T, @@ -66,21 +72,49 @@ impl Deref for Entry { } } +struct Counter { + max: usize, + cur: usize, +} + +impl Counter { + fn new(max: usize) -> Self { + Self { max, cur: 0 } + } + + fn next(&mut self) -> bool { + if self.cur <= self.max { + self.cur += 1; + false + } else { + self.cur = 0; + true + } + } +} + struct MasterServer { sock: UdpSocket, challenges: HashMap>, + challenges_counter: Counter, servers: HashMap>, + servers_counter: Counter, rng: Rng, start_time: Instant, - cleanup_challenges: usize, - cleanup_servers: usize, timeout: config::TimeoutConfig, clver: Version, update_title: Box, update_map: Box, update_addr: SocketAddrV4, + + admin_challenges: HashMap>, + admin_challenges_counter: Counter, + admin_list: Box<[config::AdminConfig]>, + hash: config::HashConfig, + + blocklist: HashSet, } impl MasterServer { @@ -100,15 +134,20 @@ impl MasterServer { sock, start_time: Instant::now(), challenges: Default::default(), + challenges_counter: Counter::new(CHALLENGE_CLEANUP_MAX), servers: Default::default(), + servers_counter: Counter::new(SERVER_CLEANUP_MAX), rng: Rng::new(), - cleanup_challenges: 0, - cleanup_servers: 0, timeout: cfg.server.timeout, clver: cfg.client.version, update_title: cfg.client.update_title, update_map: cfg.client.update_map, update_addr, + admin_challenges: Default::default(), + admin_challenges_counter: Counter::new(ADMIN_CHALLENGE_CLEANUP_MAX), + admin_list: cfg.admin_list, + hash: cfg.hash, + blocklist: Default::default(), }) } @@ -116,6 +155,7 @@ impl MasterServer { 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, _ => { @@ -131,6 +171,10 @@ impl MasterServer { } fn handle_packet(&mut self, from: SocketAddrV4, src: &[u8]) -> Result<(), Error> { + if self.is_blocked(from.ip()) { + return Ok(()); + } + if let Ok(p) = server::Packet::decode(src) { match p { server::Packet::Challenge(p) => { @@ -210,18 +254,53 @@ impl MasterServer { } } - if let Ok(p) = admin::Packet::decode(src) { + if let Ok(p) = admin::Packet::decode(self.hash.len, src) { + // TODO: throttle match p { admin::Packet::AdminChallenge(p) => { trace!("{}: recv {:?}", from, p); - let challenge = 0x12345678; // TODO: + let challenge = self.admin_challenge_add(from); + let p = master::AdminChallengeResponse::new(challenge); + trace!("{}: send {:?}", from, p); let mut buf = [0; 64]; let n = p.encode(&mut buf)?; self.sock.send_to(&buf[..n], from)?; + + self.admin_challenges_cleanup(); } admin::Packet::AdminCommand(p) => { trace!("{}: recv {:?}", from, p); + let challenge = *self + .admin_challenges + .get(from.ip()) + .ok_or(Error::AdminChallengeNotFound)?; + + let state = Params::new() + .hash_length(self.hash.len) + .key(self.hash.key.as_bytes()) + .personal(self.hash.personal.as_bytes()) + .to_state(); + + let admin = self.admin_list.iter().find(|i| { + let hash = state + .clone() + .update(i.password.as_bytes()) + .update(&challenge.to_le_bytes()) + .finalize(); + *p.hash == hash.as_bytes() + }); + + match admin { + Some(admin) => { + info!("{}: admin({}), command: {:?}", from, &admin.name, p.command); + self.admin_command(p.command); + self.admin_challenge_remove(from); + } + None => { + warn!("{}: invalid admin hash, command: {:?}", from, p.command); + } + } } } } @@ -241,19 +320,31 @@ impl MasterServer { } fn remove_outdated_challenges(&mut self) { - if self.cleanup_challenges < CHALLENGE_CLEANUP_MAX { - self.cleanup_challenges += 1; - return; + if self.challenges_counter.next() { + let now = self.now(); + self.challenges + .retain(|_, v| v.is_valid(now, self.timeout.challenge)); } - let now = self.now(); - let old = self.challenges.len(); - self.challenges - .retain(|_, v| v.is_valid(now, self.timeout.challenge)); - let new = self.challenges.len(); - if old != new { - trace!("Removed {} outdated challenges", old - new); + } + + fn admin_challenge_add(&mut self, addr: SocketAddrV4) -> u32 { + let x = self.rng.u32(..); + let entry = Entry::new(self.now(), x); + self.admin_challenges.insert(*addr.ip(), entry); + x + } + + fn admin_challenge_remove(&mut self, addr: SocketAddrV4) { + self.admin_challenges.remove(addr.ip()); + } + + /// Remove outdated entries + fn admin_challenges_cleanup(&mut self) { + if self.admin_challenges_counter.next() { + let now = self.now(); + self.admin_challenges + .retain(|_, v| v.is_valid(now, self.timeout.challenge)); } - self.cleanup_challenges = 0; } fn add_server(&mut self, addr: SocketAddrV4, server: ServerInfo) { @@ -264,19 +355,11 @@ impl MasterServer { } fn remove_outdated_servers(&mut self) { - if self.cleanup_servers < SERVER_CLEANUP_MAX { - self.cleanup_servers += 1; - return; + if self.servers_counter.next() { + let now = self.now(); + self.servers + .retain(|_, v| v.is_valid(now, self.timeout.server)); } - let now = self.now(); - let old = self.servers.len(); - self.servers - .retain(|_, v| v.is_valid(now, self.timeout.server)); - let new = self.servers.len(); - if old != new { - trace!("Removed {} outdated servers", old - new); - } - self.cleanup_servers = 0; } fn send_server_list(&self, to: A, iter: I) -> Result<(), Error> @@ -295,6 +378,45 @@ impl MasterServer { } Ok(()) } + + #[inline] + fn is_blocked(&self, ip: &Ipv4Addr) -> bool { + self.blocklist.contains(ip) + } + + fn admin_command(&mut self, cmd: &str) { + let args: Vec<_> = cmd.split(' ').collect(); + + fn helper(args: &[&str], mut op: F) { + let iter = args.iter().map(|i| (i, i.parse::())); + for (i, ip) in iter { + match ip { + Ok(ip) => op(i, ip), + Err(_) => warn!("invalid ip: {}", i), + } + } + } + + match args[0] { + "ban" => { + helper(&args[1..], |_, ip| { + if self.blocklist.insert(ip) { + info!("ban ip: {}", ip); + } + }); + } + "unban" => { + helper(&args[1..], |_, ip| { + if self.blocklist.remove(&ip) { + info!("unban ip: {}", ip); + } + }); + } + _ => { + warn!("invalid command: {}", args[0]); + } + } + } } pub fn run(cfg: Config) -> Result<(), Error> { diff --git a/protocol/src/admin.rs b/protocol/src/admin.rs index 5eb763c..4dd88b0 100644 --- a/protocol/src/admin.rs +++ b/protocol/src/admin.rs @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2023 Denis Drakhnia use crate::cursor::{Cursor, CursorMut}; -use crate::types::{Hide, Str}; +use crate::types::Hide; use crate::Error; pub const HASH_LEN: usize = 64; @@ -31,7 +31,7 @@ impl AdminChallenge { #[derive(Clone, Debug, PartialEq)] pub struct AdminCommand<'a> { pub hash: Hide<&'a [u8]>, - pub command: Str<&'a [u8]>, + pub command: &'a str, } impl<'a> AdminCommand<'a> { @@ -40,7 +40,7 @@ impl<'a> AdminCommand<'a> { pub fn new(hash: &'a [u8], command: &'a str) -> Self { Self { hash: Hide(hash), - command: Str(command.as_bytes()), + command, } } @@ -48,7 +48,7 @@ impl<'a> AdminCommand<'a> { let mut cur = Cursor::new(src); cur.expect(Self::HEADER)?; let hash = Hide(cur.get_bytes(hash_len)?); - let command = Str(cur.get_bytes(cur.remaining())?); + let command = cur.get_str(cur.remaining())?; cur.expect_empty()?; Ok(Self { hash, command }) } @@ -62,7 +62,7 @@ impl<'a> AdminCommand<'a> { Ok(CursorMut::new(buf) .put_bytes(Self::HEADER)? .put_bytes(&self.hash)? - .put_bytes(&self.command)? + .put_str(self.command)? .pos()) } } @@ -74,12 +74,12 @@ pub enum Packet<'a> { } impl<'a> Packet<'a> { - pub fn decode(src: &'a [u8]) -> Result { + pub fn decode(hash_len: usize, src: &'a [u8]) -> Result { if let Ok(p) = AdminChallenge::decode(src) { return Ok(Self::AdminChallenge(p)); } - if let Ok(p) = AdminCommand::decode(src) { + if let Ok(p) = AdminCommand::decode_with_hash_len(hash_len, src) { return Ok(Self::AdminCommand(p)); }