diff --git a/Cargo.lock b/Cargo.lock index 27e54c7..5beb1ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -146,6 +152,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -410,6 +422,49 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -767,6 +822,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -786,6 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", @@ -860,7 +927,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap 2.13.0", "slab", "tokio", @@ -906,6 +973,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.12.1" @@ -926,6 +999,16 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -933,7 +1016,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -960,8 +1066,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -973,6 +1079,43 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -980,13 +1123,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", - "http", - "hyper", + "http 0.2.12", + "hyper 0.14.32", "rustls", "tokio", "tokio-rustls", ] +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "libc", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1217,6 +1395,8 @@ dependencies = [ name = "mc-proxy-controller" version = "0.1.0" dependencies = [ + "anyhow", + "bollard", "rcon", "serde", "tokio", @@ -1610,15 +1790,15 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", "h2", - "http", - "http-body", - "hyper", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", "hyper-rustls", "ipnet", "js-sys", @@ -1719,7 +1899,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -1803,6 +1983,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2597,7 +2788,7 @@ checksum = "1b31cde2e3178f2d3167a8fb0bd74120804a7f43655f308a1eac56f81ff22dee" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.21.7", "bevy_app", "bevy_ecs", "bytes", @@ -2641,7 +2832,7 @@ checksum = "db702fdbaf978d864f7a0dc965ed67311878a047125c6613dfcb0faf090f5c5e" dependencies = [ "aes", "anyhow", - "base64", + "base64 0.21.7", "bevy_ecs", "bitfield-struct", "byteorder", @@ -2900,6 +3091,28 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[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-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 00e9132..f98f5a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,5 @@ tokio = { version = "1.49.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } toml = "0.8" valence = "0.2.0-alpha.1+mc.1.20.1" +bollard = "0.20.1" +anyhow = "1.0.100" diff --git a/src/config.rs b/src/config.rs index 4f82863..36c272f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ -use std::error::Error; use std::fs; +use anyhow::Result; use serde::Deserialize; #[derive(Debug, Deserialize, Clone)] @@ -11,10 +11,26 @@ pub struct Config { pub rcon_password: String, pub idle_timeout_secs: u64, pub polling_interval_millis: u64, + + pub container_name: String, + + #[serde(default = "default_startup_timeout_secs")] + pub startup_timeout_secs: u64, + + #[serde(default = "default_rcon_retry_interval_secs")] + pub rcon_retry_interval_secs: u64, +} + +fn default_startup_timeout_secs() -> u64 { + 600 +} + +fn default_rcon_retry_interval_secs() -> u64 { + 1 } impl Config { - pub fn load(path: &str) -> Result> { + pub fn load(path: &str) -> Result { let content = fs::read_to_string(path)?; let config: Config = toml::from_str(&content)?; Ok(config) diff --git a/src/docker.rs b/src/docker.rs new file mode 100644 index 0000000..399a246 --- /dev/null +++ b/src/docker.rs @@ -0,0 +1,174 @@ +use std::time::Duration; + +use anyhow::{Result, bail}; +use bollard::Docker; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use crate::config::Config; +use crate::rcon; +use crate::state::{ServerState, SharedServerState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContainerStatus { + Running, + Stopped, + NotFound, +} + +pub struct DockerManager { + docker: Docker, + container_name: String, +} + +impl DockerManager { + pub async fn new(container_name: String) -> Result { + let docker = Docker::connect_with_socket_defaults()?; + Ok(Self { + docker, + container_name, + }) + } + + pub async fn get_container_status(&self) -> Result { + match self + .docker + .inspect_container(&self.container_name, None) + .await + { + Ok(details) => { + let Some(state) = details.state else { + bail!("No state in container details"); + }; + let running = state.running.unwrap_or(false); + + if running { + Ok(ContainerStatus::Running) + } else { + Ok(ContainerStatus::Stopped) + } + } + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => Ok(ContainerStatus::NotFound), + Err(e) => Err(e.into()), + } + } + + pub async fn start_container(&self) -> Result<()> { + println!("Starting Docker container: {}", self.container_name); + self.docker + .start_container(&self.container_name, None) + .await?; + Ok(()) + } +} + +pub async fn run_docker_lifecycle_manager( + config: Config, + state: SharedServerState, + mut player_connect_rx: mpsc::Receiver<()>, +) -> Result<()> { + let docker = DockerManager::new(config.container_name.clone()).await?; + + match docker.get_container_status().await { + Ok(ContainerStatus::Running) => { + println!( + "Container '{}' is already running, checking for RCON availability...", + config.container_name + ); + state.transition_to_starting().await; + } + Ok(ContainerStatus::Stopped) => { + println!( + "Container '{}' is stopped, waiting for player connection...", + config.container_name + ); + state.transition_to_stopped().await; + } + Ok(ContainerStatus::NotFound) => { + eprintln!("ERROR: Container '{}' not found!", config.container_name); + bail!("Container not found"); + } + Err(e) => { + eprintln!("ERROR: Failed to connect to Docker: {}", e); + return Err(e); + } + } + + let startup_timeout = Duration::from_secs(config.startup_timeout_secs); + let stop_timeout = Duration::from_secs(30); + + loop { + tokio::select! { + Some(_) = player_connect_rx.recv() => { + let current_state = state.get().await; + if matches!(current_state, ServerState::Stopped | ServerState::Unknown) { + println!("Player connection detected, starting container..."); + if let Err(e) = docker.start_container().await { + eprintln!("Failed to start container: {}", e); + } else { + state.transition_to_starting().await; + } + } else { + println!("Player connection detected, but server is already in state {:?}", current_state); + } + } + + _ = tokio::time::sleep(Duration::from_millis(500)) => { + let current_state = state.get().await; + + match current_state { + ServerState::Starting { started_at } => { + match docker.get_container_status().await { + Ok(ContainerStatus::Stopped) => { + eprintln!("Container stopped unexpectedly during startup (crashed/exited)"); + state.transition_to_stopped().await; + continue; + } + Ok(ContainerStatus::NotFound) => { + eprintln!("Container disappeared during startup"); + state.transition_to_stopped().await; + continue; + } + Err(e) => { + eprintln!("Failed to check container status: {}", e); + } + Ok(ContainerStatus::Running) => { + } + } + + let rcon_available = rcon::connect_rcon(&config.rcon_addr, &config.rcon_password).await.is_ok(); + if rcon_available { + let startup_duration = (Instant::now() - started_at).as_secs(); + println!("RCON connection established, server is ready!"); + state.record_startup_duration(startup_duration).await; + state.transition_to_running().await; + } else if Instant::now() - started_at > startup_timeout { + eprintln!( + "Server start timeout ({}s), transitioning back to Stopped", + startup_timeout.as_secs() + ); + state.transition_to_stopped().await; + } + } + ServerState::Stopping { stop_requested_at } => { + match docker.get_container_status().await { + Ok(ContainerStatus::Stopped) => { + println!("Container stopped successfully"); + state.transition_to_stopped().await; + } + _ => { + if Instant::now() - stop_requested_at > stop_timeout { + eprintln!("Container stop timeout, forcing transition to Stopped"); + state.transition_to_stopped().await; + } + } + } + } + _ => {} + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 082cb95..6da715c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,57 @@ mod config; -mod motd; +mod docker; mod monitor; +mod motd; mod proxy; mod rcon; +mod state; -use std::error::Error; +use anyhow::Result; use tokio::main; +use tokio::sync::mpsc; use config::Config; +use state::SharedServerState; #[main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<()> { let config = Config::load("config.toml")?; + let shared_state = SharedServerState::new(); + + let (player_connect_tx, player_connect_rx) = mpsc::channel::<()>(10); + let motd_config = config.clone(); + let motd_state = shared_state.clone(); + let motd_tx = player_connect_tx.clone(); tokio::spawn(async move { - motd::create_motd_server(&motd_config).run(); + motd::create_motd_server(&motd_config, motd_state, Some(motd_tx)).run(); }); - let monitor_config = config.clone(); + let docker_config = config.clone(); + let docker_state = shared_state.clone(); tokio::spawn(async move { - if let Err(e) = monitor::run_idle_monitor(monitor_config).await { - eprintln!("Idle monitor error: {e}"); + if let Err(e) = + docker::run_docker_lifecycle_manager(docker_config, docker_state, player_connect_rx) + .await + { + eprintln!("Docker lifecycle manager error: {}", e); } }); - proxy::run_proxy(config.listen_addr, config.server_addr).await + let monitor_config = config.clone(); + let monitor_state = shared_state.clone(); + tokio::spawn(async move { + if let Err(e) = monitor::run_idle_monitor(monitor_config, monitor_state).await { + eprintln!("Idle monitor error: {}", e); + } + }); + + proxy::run_proxy( + config.listen_addr, + config.server_addr, + config.motd_server_addr, + shared_state, + ) + .await } diff --git a/src/monitor.rs b/src/monitor.rs index 3e0f91b..11a1783 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,43 +1,73 @@ -use std::error::Error; use std::time::Duration; +use anyhow::Result; use tokio::time::Instant; use crate::config::Config; use crate::rcon; +use crate::state::{ServerState, SharedServerState}; fn parse_players_online(s: &str) -> Option { s.split_whitespace() .find_map(|tok| tok.parse::().ok()) } -pub async fn run_idle_monitor(config: Config) -> Result<(), Box> { - let mut conn = rcon::connect_rcon(&config.rcon_addr, &config.rcon_password).await?; - - let mut idle = false; +pub async fn run_idle_monitor( + config: Config, + state: SharedServerState, +) -> Result<()> { let mut last_online = Instant::now(); let idle_timeout = Duration::from_secs(config.idle_timeout_secs); let polling_interval = Duration::from_millis(config.polling_interval_millis); + let mut backoff = Duration::from_secs(config.rcon_retry_interval_secs); + let max_backoff = Duration::from_secs(30); + loop { - let players_cmd_output = conn.cmd("list").await?; - let players_number = parse_players_online(&players_cmd_output).unwrap_or(0); - - if players_number > 0 { - last_online = Instant::now(); - } - - println!("{players_number} {idle}"); - - if Instant::now() - last_online > idle_timeout { - if !idle { - idle = true; - println!("Stopping the server"); - conn.cmd("stop").await?; - } - } else { - idle = false; - } - tokio::time::sleep(polling_interval).await; + + let current_state = state.get().await; + + if !matches!(current_state, ServerState::Running { .. }) { + last_online = Instant::now(); + backoff = Duration::from_secs(config.rcon_retry_interval_secs); + continue; + } + + let conn_result = rcon::connect_rcon(&config.rcon_addr, &config.rcon_password).await; + + let mut conn = if let Ok(c) = conn_result { + backoff = Duration::from_secs(config.rcon_retry_interval_secs); + c + } else { + eprintln!("RCON connection failed, retrying in {:?}", backoff); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(max_backoff); + continue; + }; + + match conn.cmd("list").await { + Ok(output) => { + let players_number = parse_players_online(&output).unwrap_or(0); + + if players_number > 0 { + last_online = Instant::now(); + } + + if Instant::now() - last_online > idle_timeout { + println!("Idle timeout reached, stopping server"); + + state.transition_to_stopping().await; + + if let Err(e) = conn.cmd("stop").await { + eprintln!("Failed to send stop command: {}", e); + } + + last_online = Instant::now(); + } + } + Err(e) => { + eprintln!("RCON command failed: {}", e); + } + } } } diff --git a/src/motd.rs b/src/motd.rs index d8e2812..df4d61a 100644 --- a/src/motd.rs +++ b/src/motd.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use tokio::sync::mpsc; use valence::network::{ async_trait, BroadcastToLan, CleanupFn, ConnectionMode, HandshakeData, ServerListPing, }; @@ -6,8 +7,12 @@ use valence::prelude::*; use valence::MINECRAFT_VERSION; use crate::config::Config; +use crate::state::{ServerState, SharedServerState}; -struct MotdCallbacks; +struct MotdCallbacks { + state: SharedServerState, + player_connect_tx: Option>, +} #[async_trait] impl NetworkCallbacks for MotdCallbacks { @@ -17,19 +22,65 @@ impl NetworkCallbacks for MotdCallbacks { _remote_addr: SocketAddr, handshake_data: &HandshakeData, ) -> ServerListPing { + let current_state = self.state.get().await; + + let (description, eta_secs) = match current_state { + ServerState::Stopped | ServerState::Unknown => { + let avg_time = self.state.get_average_startup_time().await; + ( + "Serwer sobie ".into_text() + + "śpi".into_text().color(Color::rgb(250, 50, 50)) + + "!\n" + + "Dołącz aby go obudzić! :3 " + .into_text() + .color(Color::rgb(255, 150, 230)), + avg_time, + ) + } + ServerState::Starting { started_at } => { + let elapsed = (tokio::time::Instant::now() - started_at).as_secs(); + let avg_time = self.state.get_average_startup_time().await; + let remaining = avg_time.map(|avg| avg.saturating_sub(elapsed)); + ( + "Serwer się ".into_text() + + "budzi UwU".into_text().color(Color::rgb(255, 200, 50)) + + "...\n" + + "Poczekaj chwileczkę!!! :3 " + .into_text() + .color(Color::rgb(150, 255, 150)), + remaining, + ) + } + ServerState::Running { .. } => { + ( + "Serwer jest ".into_text() + + "obudzony".into_text().color(Color::rgb(50, 250, 50)) + + "!\n", + None, + ) + } + ServerState::Stopping { .. } => ( + "Serwer idzie sobie ".into_text() + + "spać".into_text().color(Color::rgb(250, 150, 50)) + + "...\n", + Some(30), + ), + }; + + let description = if let Some(secs) = eta_secs { + description + + format!("⏲ {}s", secs) + .into_text() + .color(Color::rgb(80, 80, 80)) + } else { + description + }; + ServerListPing::Respond { online_players: 0, max_players: 0, player_sample: vec![], - description: "Serwer jest ".into_text() - + "wyłączony".into_text().color(Color::rgb(250, 50, 50)) - + "!\n" - + "Dołącz aby uruchomić serwer! " - .into_text() - .color(Color::rgb(255, 150, 230)) - + format!("⏲ {}s", 20) - .into_text() - .color(Color::rgb(80, 80, 80)), + description, favicon_png: include_bytes!("../assets/icon.png"), version_name: MINECRAFT_VERSION.to_string(), protocol: handshake_data.protocol_version, @@ -37,7 +88,7 @@ impl NetworkCallbacks for MotdCallbacks { } async fn broadcast_to_lan(&self, _shared: &SharedNetworkState) -> BroadcastToLan { - BroadcastToLan::Enabled("Hello Valence!".into()) + BroadcastToLan::Disabled } async fn login( @@ -45,15 +96,55 @@ impl NetworkCallbacks for MotdCallbacks { _shared: &SharedNetworkState, _info: &NewClientInfo, ) -> Result { - Err("You are not meant to join this example".color(Color::rgb(250, 30, 21))) + let current_state = self.state.get().await; + + match current_state { + ServerState::Stopped | ServerState::Unknown => { + if let Some(tx) = &self.player_connect_tx { + let _ = tx.send(()).await; + } + + if let Some(avg_secs) = self.state.get_average_startup_time().await { + Err(format!("Serwer się słodko budzi, poczekaj jeszcze ~{}s... :>", avg_secs) + .color(Color::rgb(255, 200, 50))) + } else { + Err("Serwer się słodko budzi, poczekaj chwilę... :>" + .color(Color::rgb(255, 200, 50))) + } + } + ServerState::Starting { started_at } => { + let elapsed = (tokio::time::Instant::now() - started_at).as_secs(); + if let Some(avg) = self.state.get_average_startup_time().await { + let remaining = avg.saturating_sub(elapsed); + Err(format!("Serwer się słodko budzi, poczekaj jeszcze ~{}s... :>", remaining) + .color(Color::rgb(255, 150, 50))) + } else { + Err("Serwer się słodko budzi, poczekaj chwilę... :>" + .color(Color::rgb(255, 150, 50))) + } + } + ServerState::Stopping { .. } => Err("Serwer idzie spać, poczekaj... >:c" + .color(Color::rgb(250, 150, 50))), + ServerState::Running { .. } => { + Err("Połącz się z głównym serwerem".color(Color::rgb(50, 250, 50))) + } + } } } -pub fn create_motd_server(config: &Config) -> App { +pub fn create_motd_server( + config: &Config, + state: SharedServerState, + player_connect_tx: Option>, +) -> App { let mut app = App::new(); app.insert_resource(NetworkSettings { connection_mode: ConnectionMode::Offline, - callbacks: MotdCallbacks.into(), + callbacks: MotdCallbacks { + state, + player_connect_tx, + } + .into(), address: config.motd_server_addr.parse().unwrap(), ..Default::default() }) diff --git a/src/proxy.rs b/src/proxy.rs index ab9d24e..7605b38 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,25 +1,48 @@ -use std::error::Error; +use anyhow::Result; use tokio::io::copy_bidirectional; use tokio::net::{TcpListener, TcpStream}; -pub async fn run_proxy(listen_addr: String, server_addr: String) -> Result<(), Box> { +use crate::state::{ServerState, SharedServerState}; + +pub async fn run_proxy( + listen_addr: String, + server_addr: String, + motd_server_addr: String, + state: SharedServerState, +) -> Result<()> { println!("Listening on {}", listen_addr); - println!("Proxying to {}", server_addr); + println!("Server backend: {}", server_addr); + println!("MOTD backend: {}", motd_server_addr); let listener = TcpListener::bind(&listen_addr).await?; - while let Ok((mut inbound, _)) = listener.accept().await { + while let Ok((mut inbound, client_addr)) = listener.accept().await { let server_addr = server_addr.clone(); + let motd_server_addr = motd_server_addr.clone(); + let state = state.clone(); tokio::spawn(async move { - match TcpStream::connect(&server_addr).await { + let current_state = state.get().await; + + let backend = match current_state { + ServerState::Running { .. } => { + println!("Client {} -> server (Running)", client_addr); + server_addr + } + _ => { + println!("Client {} -> MOTD (state: {:?})", client_addr, current_state); + motd_server_addr + } + }; + + match TcpStream::connect(&backend).await { Ok(mut outbound) => { if let Err(e) = copy_bidirectional(&mut inbound, &mut outbound).await { - println!("Failed to transfer; error={e}"); + eprintln!("Failed to transfer; error={}", e); } } Err(e) => { - println!("Failed to connect to server; error={e}"); + eprintln!("Failed to connect to backend {}; error={}", backend, e); } } }); diff --git a/src/rcon.rs b/src/rcon.rs index b7de686..c37ad3c 100644 --- a/src/rcon.rs +++ b/src/rcon.rs @@ -1,37 +1,14 @@ -use std::error::Error; -use std::time::Duration; +use anyhow::Result; use rcon::Connection; use tokio::net::TcpStream; pub async fn connect_rcon( addr: &str, password: &str, -) -> Result, Box> { +) -> Result> { let conn = >::builder() .enable_minecraft_quirks(true) .connect(addr, password) .await?; Ok(conn) } - -pub async fn wait_for_rcon( - addr: &str, - password: &str, - timeout: Duration, - retry_interval: Duration, -) -> Result, Box> { - let start = tokio::time::Instant::now(); - - loop { - if tokio::time::Instant::now() - start > timeout { - return Err("RCON connection timeout".into()); - } - - match connect_rcon(addr, password).await { - Ok(conn) => return Ok(conn), - Err(_) => { - tokio::time::sleep(retry_interval).await; - } - } - } -} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..e93a7c3 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::time::Instant; + +#[derive(Debug, Clone, Default)] +pub struct StartupMetrics { + pub total_startups: u32, + pub total_duration_secs: u64, +} + +impl StartupMetrics { + pub fn record_startup(&mut self, duration_secs: u64) { + self.total_startups += 1; + self.total_duration_secs += duration_secs; + } + + pub fn average_startup_time(&self) -> Option { + if self.total_startups == 0 { + None + } else { + Some(self.total_duration_secs / self.total_startups as u64) + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ServerState { + Unknown, + Stopped, + Starting { + started_at: Instant, + }, + Running { + rcon_connected_at: Instant, + }, + Stopping { + stop_requested_at: Instant, + }, +} + +#[derive(Clone)] +pub struct SharedServerState { + state: Arc>, + metrics: Arc>, +} + +impl SharedServerState { + pub fn new() -> Self { + Self { + state: Arc::new(RwLock::new(ServerState::Unknown)), + metrics: Arc::new(RwLock::new(StartupMetrics::default())), + } + } + + pub async fn get(&self) -> ServerState { + self.state.read().await.clone() + } + + pub async fn set(&self, new_state: ServerState) { + let mut state = self.state.write().await; + let old_state = state.clone(); + *state = new_state.clone(); + + if old_state != new_state { + println!("State transition: {:?} -> {:?}", old_state, new_state); + } + } + + pub async fn transition_to_starting(&self) { + self.set(ServerState::Starting { + started_at: Instant::now(), + }).await; + } + + pub async fn transition_to_running(&self) { + self.set(ServerState::Running { + rcon_connected_at: Instant::now(), + }).await; + } + + pub async fn transition_to_stopping(&self) { + self.set(ServerState::Stopping { + stop_requested_at: Instant::now(), + }).await; + } + + pub async fn transition_to_stopped(&self) { + self.set(ServerState::Stopped).await; + } + + pub async fn record_startup_duration(&self, duration_secs: u64) { + let mut metrics = self.metrics.write().await; + metrics.record_startup(duration_secs); + println!("Server startup took {}s (avg: {}s over {} startups)", + duration_secs, + metrics.average_startup_time().unwrap_or(0), + metrics.total_startups); + } + + pub async fn get_average_startup_time(&self) -> Option { + self.metrics.read().await.average_startup_time() + } +} + +impl Default for SharedServerState { + fn default() -> Self { + Self::new() + } +}