From c477e455538359c14d1fd33d41c9f34c798d68c7 Mon Sep 17 00:00:00 2001 From: timvisee Date: Sun, 28 Nov 2021 20:52:10 +0100 Subject: [PATCH] Add server whitelist support, use generic server files watcher to reload --- res/lazymc.toml | 3 + src/config.rs | 4 + src/mc/mod.rs | 1 + src/mc/whitelist.rs | 107 +++++++++++++++++++++++ src/server.rs | 27 ++++++ src/service/ban_reload.rs | 119 -------------------------- src/service/file_watcher.rs | 163 ++++++++++++++++++++++++++++++++++++ src/service/mod.rs | 2 +- src/service/server.rs | 2 +- src/status.rs | 12 +++ 10 files changed, 319 insertions(+), 121 deletions(-) create mode 100644 src/mc/whitelist.rs delete mode 100644 src/service/ban_reload.rs create mode 100644 src/service/file_watcher.rs diff --git a/res/lazymc.toml b/res/lazymc.toml index 3f76e23..d0699a2 100644 --- a/res/lazymc.toml +++ b/res/lazymc.toml @@ -50,6 +50,9 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui" #start_timeout = 300 #stop_timeout = 150 +# To wake server, user must be in server whitelist if enabled on server. +#wake_whitelist = true + # Block banned IPs as listed in banned-ips.json in server directory. #block_banned_ips = true diff --git a/src/config.rs b/src/config.rs index d364a2d..598039d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -198,6 +198,10 @@ pub struct Server { #[serde(default = "u32_150")] pub stop_timeout: u32, + /// To wake server, user must be in server whitelist if enabled on server. + #[serde(default = "bool_true")] + pub wake_whitelist: bool, + /// Block banned IPs as listed in banned-ips.json in server directory. #[serde(default = "bool_true")] pub block_banned_ips: bool, diff --git a/src/mc/mod.rs b/src/mc/mod.rs index a6111e6..20f4005 100644 --- a/src/mc/mod.rs +++ b/src/mc/mod.rs @@ -7,6 +7,7 @@ pub mod rcon; pub mod server_properties; #[cfg(feature = "lobby")] pub mod uuid; +pub mod whitelist; /// Minecraft ticks per second. #[allow(unused)] diff --git a/src/mc/whitelist.rs b/src/mc/whitelist.rs new file mode 100644 index 0000000..d469aa7 --- /dev/null +++ b/src/mc/whitelist.rs @@ -0,0 +1,107 @@ +use std::error::Error; +use std::fs; +use std::path::Path; + +use serde::Deserialize; + +/// Whitelist file name. +pub const WHITELIST_FILE: &str = "whitelist.json"; + +/// OPs file name. +pub const OPS_FILE: &str = "ops.json"; + +/// Whitelisted users. +/// +/// Includes list of OPs, which are also automatically whitelisted. +#[derive(Debug, Default)] +pub struct Whitelist { + /// Whitelisted users. + whitelist: Vec, + + /// OPd users. + ops: Vec, +} + +impl Whitelist { + /// Check whether a user is whitelisted. + pub fn is_whitelisted(&self, username: &str) -> bool { + self.whitelist.iter().any(|u| u == username) || self.ops.iter().any(|u| u == username) + } +} + +/// A whitelist user. +#[derive(Debug, Deserialize, Clone)] +pub struct WhitelistUser { + /// Whitelisted username. + #[serde(rename = "name", alias = "username")] + pub username: String, + + /// Whitelisted UUID. + pub uuid: Option, +} + +/// An OP user. +#[derive(Debug, Deserialize, Clone)] +pub struct OpUser { + /// OP username. + #[serde(rename = "name", alias = "username")] + pub username: String, + + /// OP UUID. + pub uuid: Option, + + /// OP level. + pub level: Option, + + /// Whether OP can bypass player limit. + #[serde(rename = "bypassesPlayerLimit")] + pub byapsses_player_limit: Option, +} + +/// Load whitelist from directory. +pub fn load_dir(path: &Path) -> Result> { + let whitelist_file = path.join(WHITELIST_FILE); + let ops_file = path.join(OPS_FILE); + + // Load whitelist users + let whitelist = if whitelist_file.is_file() { + load_whitelist(&whitelist_file)? + } else { + vec![] + }; + + // Load OPd users + let ops = if ops_file.is_file() { + load_ops(&ops_file)? + } else { + vec![] + }; + + debug!(target: "lazymc", "Loaded {} whitelist and {} OP users", whitelist.len(), ops.len()); + + Ok(Whitelist { whitelist, ops }) +} + +/// Load whitelist from file. +fn load_whitelist(path: &Path) -> Result, Box> { + // Load file contents + let contents = fs::read_to_string(path)?; + + // Parse contents + let users: Vec = serde_json::from_str(&contents)?; + + // Pluck usernames + Ok(users.into_iter().map(|user| user.username).collect()) +} + +/// Load OPs from file. +fn load_ops(path: &Path) -> Result, Box> { + // Load file contents + let contents = fs::read_to_string(path)?; + + // Parse contents + let users: Vec = serde_json::from_str(&contents)?; + + // Pluck usernames + Ok(users.into_iter().map(|user| user.username).collect()) +} diff --git a/src/server.rs b/src/server.rs index 566ef17..7d852c1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -14,6 +14,7 @@ use tokio::time; use crate::config::{Config, Server as ConfigServer}; use crate::mc::ban::{BannedIp, BannedIps}; +use crate::mc::whitelist::Whitelist; use crate::os; use crate::proto::packets::play::join_game::JoinGameData; @@ -73,6 +74,9 @@ pub struct Server { /// List of banned IPs. banned_ips: RwLock, + /// Whitelist if enabled. + whitelist: RwLock>, + /// Lock for exclusive RCON operations. #[cfg(feature = "rcon")] rcon_lock: Semaphore, @@ -346,6 +350,18 @@ impl Server { futures::executor::block_on(async { self.is_banned_ip(ip).await }) } + /// Check whether the given username is whitelisted. + /// + /// Returns `true` if no whitelist is currently used. + pub async fn is_whitelisted(&self, username: &str) -> bool { + self.whitelist + .read() + .await + .as_ref() + .map(|w| w.is_whitelisted(username)) + .unwrap_or(true) + } + /// Update the list of banned IPs. pub async fn set_banned_ips(&self, ips: BannedIps) { *self.banned_ips.write().await = ips; @@ -355,6 +371,16 @@ impl Server { pub fn set_banned_ips_blocking(&self, ips: BannedIps) { futures::executor::block_on(async { self.set_banned_ips(ips).await }) } + + /// Update the whitelist. + pub async fn set_whitelist(&self, whitelist: Option) { + *self.whitelist.write().await = whitelist; + } + + /// Update the whitelist. + pub fn set_whitelist_blocking(&self, whitelist: Option) { + futures::executor::block_on(async { self.set_whitelist(whitelist).await }) + } } impl Default for Server { @@ -371,6 +397,7 @@ impl Default for Server { keep_online_until: Default::default(), kill_at: Default::default(), banned_ips: Default::default(), + whitelist: Default::default(), #[cfg(feature = "rcon")] rcon_lock: Semaphore::new(1), #[cfg(feature = "rcon")] diff --git a/src/service/ban_reload.rs b/src/service/ban_reload.rs deleted file mode 100644 index dbe9832..0000000 --- a/src/service/ban_reload.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::path::Path; -use std::sync::mpsc::channel; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher}; - -use crate::config::{Config, Server as ConfigServer}; -use crate::mc::ban; -use crate::server::Server; - -/// File debounce time. -const WATCH_DEBOUNCE: Duration = Duration::from_secs(2); - -/// Service to reload banned IPs when its file changes. -pub fn service(config: Arc, server: Arc) { - // TODO: check what happens when file doesn't exist at first? - - // Ensure we need to reload banned IPs - if !config.server.block_banned_ips && !config.server.drop_banned_ips { - return; - } - - // Ensure server directory is set, it must exist - let dir = match ConfigServer::server_directory(&config) { - Some(dir) => dir, - None => { - warn!(target: "lazymc", "Not blocking banned IPs, server directory not configured, unable to find {} file", ban::FILE); - return; - } - }; - - // Determine file path, ensure it exists - let path = dir.join(crate::mc::ban::FILE); - if !path.is_file() { - warn!(target: "lazymc", "Not blocking banned IPs, {} file does not exist", ban::FILE); - return; - } - - // Load banned IPs once - match ban::load(&path) { - Ok(ips) => server.set_banned_ips_blocking(ips), - Err(err) => { - error!(target: "lazymc", "Failed to load banned IPs from {}: {}", ban::FILE, err); - } - } - - // Show warning if 127.0.0.1 is banned - if server.is_banned_ip_blocking(&("127.0.0.1".parse().unwrap())) { - warn!(target: "lazymc", "Local address 127.0.0.1 IP banned, probably not what you want"); - warn!(target: "lazymc", "Use '/pardon-ip 127.0.0.1' on the server to unban"); - } - - // Keep watching - while watch(&server, &path) {} -} - -/// Watch the given file. -fn watch(server: &Server, path: &Path) -> bool { - // The file must exist - if !path.is_file() { - warn!(target: "lazymc", "File {} does not exist, not watching changes", ban::FILE); - return false; - } - - // Create watcher for banned IPs file - let (tx, rx) = channel(); - let mut watcher = - watcher(tx, WATCH_DEBOUNCE).expect("failed to create watcher for banned-ips.json"); - if let Err(err) = watcher.watch(path, RecursiveMode::NonRecursive) { - error!(target: "lazymc", "An error occured while creating watcher for {}: {}", ban::FILE, err); - return true; - } - - loop { - // Take next event - let event = rx.recv().unwrap(); - - // Decide whether to reload and rewatch - let (reload, rewatch) = match event { - // Reload on write - DebouncedEvent::NoticeWrite(_) | DebouncedEvent::Write(_) => (true, false), - - // Reload and rewatch on rename/remove - DebouncedEvent::NoticeRemove(_) - | DebouncedEvent::Remove(_) - | DebouncedEvent::Rename(_, _) - | DebouncedEvent::Rescan - | DebouncedEvent::Create(_) => { - trace!(target: "lazymc", "File banned-ips.json removed, trying to rewatch after 1 second"); - thread::sleep(WATCH_DEBOUNCE); - (true, true) - } - - // Ignore chmod changes - DebouncedEvent::Chmod(_) => (false, false), - - // Rewatch on error - DebouncedEvent::Error(_, _) => (false, true), - }; - - // Reload banned IPs - if reload { - debug!(target: "lazymc", "Reloading list of banned IPs..."); - match ban::load(path) { - Ok(ips) => server.set_banned_ips_blocking(ips), - Err(err) => { - error!(target: "lazymc", "Failed reload list of banned IPs from {}: {}", ban::FILE, err); - } - } - } - - // Rewatch - if rewatch { - return true; - } - } -} diff --git a/src/service/file_watcher.rs b/src/service/file_watcher.rs new file mode 100644 index 0000000..ada691e --- /dev/null +++ b/src/service/file_watcher.rs @@ -0,0 +1,163 @@ +use std::path::Path; +use std::sync::mpsc::channel; +use std::sync::Arc; +use std::time::Duration; + +use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher}; + +use crate::config::{Config, Server as ConfigServer}; +use crate::mc::ban::{self, BannedIps}; +use crate::mc::whitelist; +use crate::server::Server; + +/// File watcher debounce time. +const WATCH_DEBOUNCE: Duration = Duration::from_secs(2); + +/// Service to watch server file changes. +pub fn service(config: Arc, server: Arc) { + // Ensure server directory is set, it must exist + let dir = match ConfigServer::server_directory(&config) { + Some(dir) if dir.is_dir() => dir, + _ => { + warn!(target: "lazymc", "Server directory doesn't exist, can't watch file changes to reload whitelist and banned IPs"); + return; + } + }; + + // Keep watching + #[allow(clippy::blocks_in_if_conditions)] + while { + // Update all files once + reload_bans(&config, &server, &dir.join(ban::FILE)); + reload_whitelist(&config, &server, &dir); + + // Watch for changes, update accordingly + watch_server(&config, &server, &dir) + } {} +} + +/// Watch server directory. +/// +/// Returns `true` if we should watch again. +#[must_use] +fn watch_server(config: &Config, server: &Server, dir: &Path) -> bool { + // Directory must exist + if !dir.is_dir() { + error!(target: "lazymc", "Server directory does not exist at {} anymore, not watching changes", dir.display()); + return false; + } + + // Create watcher for directory + let (tx, rx) = channel(); + let mut watcher = + watcher(tx, WATCH_DEBOUNCE).expect("failed to create watcher for banned-ips.json"); + if let Err(err) = watcher.watch(dir, RecursiveMode::NonRecursive) { + error!(target: "lazymc", "An error occured while creating watcher for server files: {}", err); + return true; + } + + // Handle change events + loop { + match rx.recv().unwrap() { + // Handle file updates + DebouncedEvent::Create(ref path) + | DebouncedEvent::Write(ref path) + | DebouncedEvent::Remove(ref path) => { + update(config, server, dir, path); + } + + // Handle file updates on both paths for rename + DebouncedEvent::Rename(ref before_path, ref after_path) => { + update(config, server, dir, before_path); + update(config, server, dir, after_path); + } + + // Ignore write/remove notices, will receive write/remove event later + DebouncedEvent::NoticeWrite(_) | DebouncedEvent::NoticeRemove(_) => {} + + // Ignore chmod changes + DebouncedEvent::Chmod(_) => {} + + // Rewatch on rescan + DebouncedEvent::Rescan => { + debug!(target: "lazymc", "Rescanning server directory files due to file watching problem"); + return true; + } + + // Rewatch on error + DebouncedEvent::Error(err, _) => { + error!(target: "lazymc", "Error occurred while watching server directory for file changes: {}", err); + return true; + } + } + } +} + +/// Process a file change on the given path. +/// +/// Should be called both when created, changed or removed. +fn update(config: &Config, server: &Server, dir: &Path, path: &Path) { + // Update bans + if path.ends_with(ban::FILE) { + reload_bans(config, server, path); + } + + // Update whitelist + if path.ends_with(whitelist::WHITELIST_FILE) || path.ends_with(whitelist::OPS_FILE) { + reload_whitelist(config, server, dir); + } + + // TODO: update on server.properties change +} + +/// Reload banned IPs. +fn reload_bans(config: &Config, server: &Server, path: &Path) { + // Bans must be enabled + if !config.server.block_banned_ips && !config.server.drop_banned_ips { + return; + } + + trace!(target: "lazymc", "Reloading banned IPs..."); + + // File must exist, clear file otherwise + if !path.is_file() { + debug!(target: "lazymc", "No banned IPs, {} does not exist", ban::FILE); + // warn!(target: "lazymc", "Not blocking banned IPs, {} file does not exist", ban::FILE); + server.set_banned_ips_blocking(BannedIps::default()); + return; + } + + // Load and update banned IPs + match ban::load(path) { + Ok(ips) => server.set_banned_ips_blocking(ips), + Err(err) => { + debug!(target: "lazymc", "Failed load banned IPs from {}, ignoring: {}", ban::FILE, err); + } + } + + // Show warning if 127.0.0.1 is banned + if server.is_banned_ip_blocking(&("127.0.0.1".parse().unwrap())) { + warn!(target: "lazymc", "Local address 127.0.0.1 IP banned, probably not what you want"); + warn!(target: "lazymc", "Use '/pardon-ip 127.0.0.1' on the server to unban"); + } +} + +/// Reload whitelisted users. +fn reload_whitelist(config: &Config, server: &Server, dir: &Path) { + // Whitelist must be enabled + if !config.server.wake_whitelist { + return; + } + + // TODO: whitelist must be enabled in server.properties + + trace!(target: "lazymc", "Reloading whitelisted users..."); + + // Load and update whitelisted users + match whitelist::load_dir(dir) { + Ok(whitelist) => server.set_whitelist_blocking(Some(whitelist)), + Err(err) => { + debug!(target: "lazymc", "Failed load whitelist from {}, ignoring: {}", dir.display(), err); + } + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 1b35833..3bc2c0f 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,4 +1,4 @@ -pub mod ban_reload; +pub mod file_watcher; pub mod monitor; pub mod probe; pub mod server; diff --git a/src/service/server.rs b/src/service/server.rs index 9035cfa..62c0cd7 100644 --- a/src/service/server.rs +++ b/src/service/server.rs @@ -59,7 +59,7 @@ pub async fn service(config: Arc) -> Result<(), ()> { tokio::spawn(service::probe::service(config.clone(), server.clone())); tokio::task::spawn_blocking({ let (config, server) = (config.clone(), server.clone()); - || service::ban_reload::service(config, server) + || service::file_watcher::service(config, server) }); // Route all incomming connections diff --git a/src/status.rs b/src/status.rs index 19c2b1c..6058642 100644 --- a/src/status.rs +++ b/src/status.rs @@ -27,6 +27,9 @@ const BAN_MESSAGE_PREFIX: &str = "Your IP address is banned from this server.\nR /// Default ban reason if unknown. const DEFAULT_BAN_REASON: &str = "Banned by an operator."; +/// The not-whitelisted kick message. +const WHITELIST_MESSAGE: &str = "You are not white-listed on this server!"; + /// Server icon file path. const SERVER_ICON_FILE: &str = "server-icon.png"; @@ -159,6 +162,15 @@ pub async fn serve( } } + // Kick if client is not whitelisted to wake server + if let Some(ref username) = username { + if !server.is_whitelisted(username).await { + info!(target: "lazymc", "User '{}' tried to wake server but is not whitelisted, disconnecting", username); + action::kick(&client, WHITELIST_MESSAGE, &mut writer).await?; + break; + } + } + // Start server if not starting yet Server::start(config.clone(), server.clone(), username).await;