diff --git a/Cargo.lock b/Cargo.lock index 7cb481b..e81a8a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,7 @@ checksum = "a12aa0eb539080d55c3f2d45a67c3b58b6b0773c1a3ca2dfec66d58c97fd66ca" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -468,6 +469,17 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d" +[[package]] +name = "futures-executor" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45025be030969d763025784f7f355043dc6bc74093e4ecc5000ca4dc50d8745c" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.17" @@ -508,11 +520,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481" dependencies = [ "autocfg 1.0.1", + "futures-channel", "futures-core", + "futures-io", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -639,6 +655,7 @@ dependencies = [ "rand 0.8.4", "rcon", "serde", + "serde_json", "shlex", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 75db550..5973d9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,12 +38,13 @@ colored = "2.0" derive_builder = "0.10" dotenv = "0.15" flate2 = { version = "1.0", default-features = false, features = ["default"] } -futures = { version = "0.3", default-features = false } +futures = { version = "0.3", default-features = false, features = ["executor"] } log = "0.4" minecraft-protocol = { git = "https://github.com/timvisee/rust-minecraft-protocol", rev = "a14b40e" } pretty_env_logger = "0.4" rand = "0.8" serde = "1.0" +serde_json = "1.0" shlex = "1.1" thiserror = "1.0" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "io-util", "net", "macros", "time", "process", "signal", "sync"] } diff --git a/res/lazymc.toml b/res/lazymc.toml index a22ebaa..245b452 100644 --- a/res/lazymc.toml +++ b/res/lazymc.toml @@ -43,6 +43,9 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui" #start_timeout = 300 #stop_timeout = 150 +# Block banned IPs as listed in banned-ips.json in server directory. +#block_banned_ips = true + [time] # Sleep after number of seconds. #sleep_after = 60 diff --git a/src/config.rs b/src/config.rs index b0f702d..f8c01ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,6 +180,10 @@ pub struct Server { /// Server stopping timeout. Force kill server process if it takes longer. #[serde(default = "u32_150")] pub stop_timeout: u32, + + /// Block banned IPs as listed in banned-ips.json in server directory. + #[serde(default = "bool_true")] + pub block_banned_ips: bool, } /// Time configuration. @@ -451,3 +455,7 @@ fn u32_300() -> u32 { fn u32_150() -> u32 { 300 } + +fn bool_true() -> bool { + true +} diff --git a/src/mc/ban.rs b/src/mc/ban.rs new file mode 100644 index 0000000..31fba60 --- /dev/null +++ b/src/mc/ban.rs @@ -0,0 +1,37 @@ +use std::error::Error; +use std::fs; +use std::net::IpAddr; +use std::path::Path; + +use serde::Deserialize; + +/// File name. +pub const FILE: &str = "banned-ips.json"; + +/// A banned IP entry. +#[derive(Debug, Deserialize)] +pub struct BannedIp { + /// Banned IP. + pub ip: IpAddr, + + /// Ban creation time. + pub created: String, + + /// Ban source. + pub source: String, + + /// Ban expiry time. + pub expires: String, + + /// Ban reason. + pub reason: String, +} + +/// Load banned IPs from file. +pub fn load(path: &Path) -> Result, Box> { + // Load file contents + let contents = fs::read_to_string(path)?; + + // Parse contents + Ok(serde_json::from_str(&contents)?) +} diff --git a/src/mc/mod.rs b/src/mc/mod.rs index 8a26720..32d0f3b 100644 --- a/src/mc/mod.rs +++ b/src/mc/mod.rs @@ -1,3 +1,4 @@ +pub mod ban; #[cfg(feature = "rcon")] pub mod rcon; pub mod server_properties; diff --git a/src/server.rs b/src/server.rs index b9b2985..a2a73bf 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -12,6 +13,7 @@ use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; use tokio::time; use crate::config::Config; +use crate::mc::ban::BannedIp; use crate::os; /// Server cooldown after the process quit. @@ -25,45 +27,6 @@ const SERVER_QUIT_COOLDOWN: Duration = Duration::from_millis(2500); #[cfg(feature = "rcon")] const RCON_COOLDOWN: Duration = Duration::from_secs(15); -/// Server state. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub enum State { - /// Server is stopped. - Stopped, - - /// Server is starting. - Starting, - - /// Server is online and responding. - Started, - - /// Server is stopping. - Stopping, -} - -impl State { - /// From u8, panics if invalid. - pub fn from_u8(state: u8) -> Self { - match state { - 0 => Self::Stopped, - 1 => Self::Starting, - 2 => Self::Started, - 3 => Self::Stopping, - _ => panic!("invalid State u8"), - } - } - - /// To u8. - pub fn to_u8(self) -> u8 { - match self { - Self::Stopped => 0, - Self::Starting => 1, - Self::Started => 2, - Self::Stopping => 3, - } - } -} - /// Shared server state. #[derive(Debug)] pub struct Server { @@ -102,6 +65,9 @@ pub struct Server { /// Used as starting/stopping timeout. kill_at: RwLock>, + /// List of banned IPs. + banned_ips: RwLock>, + /// Lock for exclusive RCON operations. #[cfg(feature = "rcon")] rcon_lock: Semaphore, @@ -345,6 +311,27 @@ impl Server { .filter(|d| *d > 0) .map(|d| Instant::now() + Duration::from_secs(d as u64)); } + + /// Check whether the given IP is banned. + /// + /// This uses the latest known `banned-ips.json` contents if known. + /// If this feature is disabled, this will always return false. + pub async fn is_banned_ip(&self, ip: &IpAddr) -> bool { + self.banned_ips.read().await.iter().any(|i| &i.ip == ip) + } + + /// Check whether the given IP is banned. + /// + /// This uses the latest known `banned-ips.json` contents if known. + /// If this feature is disabled, this will always return false. + pub fn is_banned_ip_blocking(&self, ip: &IpAddr) -> bool { + futures::executor::block_on(async { self.is_banned_ip(ip).await }) + } + + /// Update the list of banned IPs. + pub async fn set_banned_ips(&self, ips: Vec) { + *self.banned_ips.write().await = ips; + } } impl Default for Server { @@ -360,6 +347,7 @@ impl Default for Server { last_active: Default::default(), keep_online_until: Default::default(), kill_at: Default::default(), + banned_ips: Default::default(), #[cfg(feature = "rcon")] rcon_lock: Semaphore::new(1), #[cfg(feature = "rcon")] @@ -368,6 +356,45 @@ impl Default for Server { } } +/// Server state. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum State { + /// Server is stopped. + Stopped, + + /// Server is starting. + Starting, + + /// Server is online and responding. + Started, + + /// Server is stopping. + Stopping, +} + +impl State { + /// From u8, panics if invalid. + pub fn from_u8(state: u8) -> Self { + match state { + 0 => Self::Stopped, + 1 => Self::Starting, + 2 => Self::Started, + 3 => Self::Stopping, + _ => panic!("invalid State u8"), + } + } + + /// To u8. + pub fn to_u8(self) -> u8 { + match self { + Self::Stopped => 0, + Self::Starting => 1, + Self::Started => 2, + Self::Stopping => 3, + } + } +} + /// Invoke server command, store PID and wait for it to quit. pub async fn invoke_server_cmd( config: Arc, diff --git a/src/service/server.rs b/src/service/server.rs index 4c156c6..f07c375 100644 --- a/src/service/server.rs +++ b/src/service/server.rs @@ -6,6 +6,7 @@ use futures::FutureExt; use tokio::net::{TcpListener, TcpStream}; use crate::config::Config; +use crate::mc::ban::{self, BannedIp}; use crate::proto::client::Client; use crate::proxy; use crate::server::{self, Server}; @@ -23,6 +24,9 @@ pub async fn service(config: Arc) -> Result<(), ()> { // Load server state let server = Arc::new(Server::default()); + // Load banned IPs + server.set_banned_ips(load_banned_ips(&config)).await; + // Listen for new connections let listener = TcpListener::bind(config.public.address) .await @@ -66,6 +70,12 @@ pub async fn service(config: Arc) -> Result<(), ()> { /// Route inbound TCP stream to correct service, spawning a new task. #[inline] fn route(inbound: TcpStream, config: Arc, server: Arc) { + // Check ban + if !check_ban(&inbound, &server) { + return; + } + + // Route connection through proper channel let should_proxy = server.state() == server::State::Started && !config.lockout.enabled; if should_proxy { route_proxy(inbound, config) @@ -74,6 +84,29 @@ fn route(inbound: TcpStream, config: Arc, server: Arc) { } } +/// Check whether user IP is banned. +/// +/// Returns `true` if user is still allowed to connect. +fn check_ban(inbound: &TcpStream, server: &Server) -> bool { + // Get user peer address + let peer = match inbound.peer_addr() { + Ok(peer) => peer, + Err(err) => { + warn!(target: "lazymc", "Connection from unknown peer, disconnecting: {}", err); + return false; + } + }; + + // Check if user is banned + let is_banned = server.is_banned_ip_blocking(&peer.ip()); + if is_banned { + warn!(target: "lazymc", "Connection from banned IP {}, disconnecting", peer); + return false; + } + + true +} + /// Route inbound TCP stream to status server, spawning a new task. #[inline] fn route_status(inbound: TcpStream, config: Arc, server: Arc) { @@ -123,3 +156,41 @@ pub fn route_proxy_address_queue(inbound: TcpStream, addr: SocketAddr, queue: By tokio::spawn(service); } + +/// Load banned IPs if IP banning is enabled. +/// +/// If disabled or on error, an empty list is returned. +fn load_banned_ips(config: &Config) -> Vec { + // Blocking banned IPs must be enabled + if !config.server.block_banned_ips { + return vec![]; + } + + // Ensure server directory is set, it must exist + let dir = match &config.server.directory { + Some(dir) => dir, + None => { + warn!(target: "lazymc", "Not blocking banned IPs, server directory not configured, unable to find {} file", ban::FILE); + return vec![]; + } + }; + + // 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 vec![]; + } + + // Load banned IPs + let banned_ips = match ban::load(&path) { + Ok(ips) => ips, + Err(err) => { + // TODO: quit here, require user to disable feature as security feature? + error!(target: "lazymc", "Failed to load banned IPs from {}: {}", ban::FILE, err); + return vec![]; + } + }; + + banned_ips +}