integrate docker and implement state tracking for server lifecycle management
This commit is contained in:
239
Cargo.lock
generated
239
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Self, Box<dyn Error>> {
|
||||
pub fn load(path: &str) -> Result<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
|
||||
174
src/docker.rs
Normal file
174
src/docker.rs
Normal file
@@ -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<Self> {
|
||||
let docker = Docker::connect_with_socket_defaults()?;
|
||||
Ok(Self {
|
||||
docker,
|
||||
container_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_container_status(&self) -> Result<ContainerStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/main.rs
44
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<dyn Error>> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<u32> {
|
||||
s.split_whitespace()
|
||||
.find_map(|tok| tok.parse::<u32>().ok())
|
||||
}
|
||||
|
||||
pub async fn run_idle_monitor(config: Config) -> Result<(), Box<dyn Error>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
src/motd.rs
119
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<mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
#[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<CleanupFn, Text> {
|
||||
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<mpsc::Sender<()>>,
|
||||
) -> 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()
|
||||
})
|
||||
|
||||
37
src/proxy.rs
37
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<dyn Error>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
27
src/rcon.rs
27
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<Connection<TcpStream>, Box<dyn Error>> {
|
||||
) -> Result<Connection<TcpStream>> {
|
||||
let conn = <Connection<TcpStream>>::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<Connection<TcpStream>, Box<dyn Error>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
src/state.rs
Normal file
109
src/state.rs
Normal file
@@ -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<u64> {
|
||||
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<RwLock<ServerState>>,
|
||||
metrics: Arc<RwLock<StartupMetrics>>,
|
||||
}
|
||||
|
||||
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<u64> {
|
||||
self.metrics.read().await.average_startup_time()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SharedServerState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user