fix: something

This commit is contained in:
hibna
2026-08-02 20:26:54 +03:00
parent 5215560ede
commit 11924416a9
38 changed files with 2198 additions and 466 deletions
+8 -4
View File
@@ -3,9 +3,13 @@ FROM rust:1.83-bookworm AS build
# Install protoc
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/daemon/ .
# build.rs compiles ../../packages/proto/daemon.proto, so the workspace layout
# has to be preserved inside the build context.
WORKDIR /build
COPY packages/proto ./packages/proto
COPY apps/daemon ./apps/daemon
WORKDIR /build/apps/daemon
RUN cargo build --release
# --- Production ---
@@ -18,12 +22,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/target/release/gamepanel-daemon /app/gamepanel-daemon
COPY --from=build /build/apps/daemon/target/release/gamepanel-daemon /app/gamepanel-daemon
# Data directories
RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel
EXPOSE 50051
HEALTHCHECK --interval=30s --timeout=5s CMD /app/gamepanel-daemon --health-check || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD /app/gamepanel-daemon --health-check || exit 1
CMD ["/app/gamepanel-daemon"]
+24 -1
View File
@@ -12,6 +12,12 @@ pub struct DaemonConfig {
pub docker: DockerConfig,
#[serde(default = "default_data_path")]
pub data_path: PathBuf,
/// Where `data_path` lives on the Docker host. Only differs from `data_path`
/// when the daemon itself runs in a container: bind mounts for the game
/// containers are resolved by the host Docker engine, not by the daemon's
/// own mount namespace. Defaults to `data_path`.
#[serde(default)]
pub host_data_path: Option<PathBuf>,
#[serde(default = "default_backup_path")]
pub backup_path: PathBuf,
#[serde(default)]
@@ -92,7 +98,24 @@ grpc_port: 50051
.to_string()
});
let config: DaemonConfig = serde_yaml::from_str(&content)?;
let mut config: DaemonConfig = serde_yaml::from_str(&content)?;
// Environment overrides make containerised deployments configurable
// without templating the YAML file.
if let Ok(host_data_path) = std::env::var("DAEMON_HOST_DATA_PATH") {
let trimmed = host_data_path.trim();
if !trimmed.is_empty() {
config.host_data_path = Some(PathBuf::from(trimmed));
}
}
Ok(config)
}
/// Path prefix the Docker host uses for server data directories.
pub fn host_data_path(&self) -> PathBuf {
self.host_data_path
.clone()
.unwrap_or_else(|| self.data_path.clone())
}
}
+397 -21
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use bollard::container::{
@@ -14,13 +14,24 @@ use tokio::time::{sleep, Duration};
use tracing::{debug, info};
use crate::docker::DockerManager;
use crate::server::ServerSpec;
use crate::server::{ServerRuntime, ServerSpec};
use crate::server::state::ServerState;
/// Container name prefix for all managed game servers.
const CONTAINER_PREFIX: &str = "gp_";
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
/// Labels used to persist panel-supplied runtime options on the container, so
/// they survive a daemon restart (in-memory specs are rebuilt from Docker).
const LABEL_DATA_PATH: &str = "gamepanel.data_mount_path";
const LABEL_STOP_COMMAND: &str = "gamepanel.stop_command";
const LABEL_STOP_TIMEOUT: &str = "gamepanel.stop_timeout_seconds";
/// Docker's SIGTERM grace period once the in-game stop command has had its turn.
const SIGTERM_GRACE_SECS: i64 = 15;
/// Fallback shutdown budget when the game defines no explicit timeout.
pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
pub fn container_name(server_uuid: &str) -> String {
format!("{}{}", CONTAINER_PREFIX, server_uuid)
}
@@ -47,9 +58,63 @@ fn container_data_path_for_image(image: &str) -> &'static str {
if normalized.contains("wolveix/satisfactory-server") {
return "/config";
}
if normalized.contains("ark-server") || normalized.contains("ark-survival-evolved") {
return "/app";
}
"/data"
}
/// Mount point of the server data directory inside the container. The panel can
/// override the image-derived default per game.
fn container_data_path(spec: &ServerSpec) -> String {
spec.runtime
.data_mount_path
.as_deref()
.map(str::trim)
.filter(|path| path.starts_with('/'))
.map(str::to_string)
.unwrap_or_else(|| container_data_path_for_image(&spec.docker_image).to_string())
}
/// Games whose process does not read stdin, so console commands have to go over
/// RCON instead of the container's attached stdin.
fn prefers_rcon_console(image: &str) -> bool {
let normalized = image.to_ascii_lowercase();
normalized.contains("cs2")
|| normalized.contains("csgo")
|| normalized.contains("ark-server")
|| normalized.contains("ark-survival-evolved")
}
fn runtime_labels(spec: &ServerSpec) -> HashMap<String, String> {
let mut labels = HashMap::new();
labels.insert(LABEL_DATA_PATH.to_string(), container_data_path(spec));
if let Some(stop_command) = spec.runtime.stop_command.as_deref() {
labels.insert(LABEL_STOP_COMMAND.to_string(), stop_command.to_string());
}
if let Some(timeout) = spec.runtime.stop_timeout_seconds {
labels.insert(LABEL_STOP_TIMEOUT.to_string(), timeout.to_string());
}
labels
}
fn runtime_from_labels(labels: Option<&HashMap<String, String>>) -> ServerRuntime {
let Some(labels) = labels else {
return ServerRuntime::default();
};
ServerRuntime {
data_mount_path: labels.get(LABEL_DATA_PATH).cloned(),
stop_command: labels.get(LABEL_STOP_COMMAND).cloned(),
stop_timeout_seconds: labels
.get(LABEL_STOP_TIMEOUT)
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0),
}
}
fn is_wolveix_satisfactory_image(image: &str) -> bool {
image
.to_ascii_lowercase()
@@ -69,6 +134,7 @@ impl DockerManager {
async fn attach_command_stream(
&self,
container_name: &str,
container_id: String,
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
let bollard::container::AttachContainerResults { mut output, input } = self
.client()
@@ -93,28 +159,80 @@ impl DockerManager {
debug!(container = %name, "Container stdin attach stream ended");
});
Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(input, drain_task)))
Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(
container_id,
input,
drain_task,
)))
}
/// Resolve the id of the container backing a server, but only while it is
/// actually running. Writing to a stopped container's stdin looks like it
/// succeeds and then goes nowhere, so this is the gate for every command.
async fn running_container_id(&self, server_uuid: &str) -> Result<String> {
let name = container_name(server_uuid);
let info = match self.client().inspect_container(&name, None).await {
Ok(info) => info,
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => {
return Err(anyhow::anyhow!(
"server container does not exist (server has not been installed yet)"
))
}
Err(error) => return Err(error.into()),
};
let running = info
.state
.as_ref()
.and_then(|state| state.running)
.unwrap_or(false);
if !running {
return Err(anyhow::anyhow!(
"server is not running, start it before sending console commands"
));
}
info.id
.ok_or_else(|| anyhow::anyhow!("Docker did not report a container id"))
}
async fn get_or_attach_command_stream(
&self,
server_uuid: &str,
container_id: &str,
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
let name = container_name(server_uuid);
if let Some(existing) = self.command_streams().read().await.get(&name).cloned() {
return Ok(existing);
if existing.container_id() == container_id {
return Ok(existing);
}
}
let created = self.attach_command_stream(&name).await?;
// Container was recreated or restarted since we last attached — the old
// hijacked socket is dead, drop it before opening a fresh one.
self.clear_command_stream(server_uuid).await;
let created = self
.attach_command_stream(&name, container_id.to_string())
.await?;
let mut streams = self.command_streams().write().await;
if let Some(existing) = streams.get(&name).cloned() {
created.abort();
return Ok(existing);
if existing.container_id() == container_id {
created.abort();
return Ok(existing);
}
}
streams.insert(name, created.clone());
// Another task may have raced in with a stream for a different
// container; its drain task has to be stopped or it leaks.
if let Some(displaced) = streams.insert(name, created.clone()) {
displaced.abort();
}
Ok(created)
}
@@ -202,6 +320,123 @@ impl DockerManager {
.await
}
/// IP of the container on the panel's Docker network. Reaching the game
/// over RCON via the container IP works whether the daemon runs on the host
/// or as a sibling container on the same network — unlike `127.0.0.1`.
pub async fn container_ip(&self, server_uuid: &str) -> Option<String> {
let name = container_name(server_uuid);
let info = self.client().inspect_container(&name, None).await.ok()?;
let networks = info.network_settings.as_ref()?;
if let Some(named) = networks.networks.as_ref() {
if let Some(ip) = named
.get(self.network_name())
.and_then(|net| net.ip_address.clone())
.filter(|ip| !ip.is_empty())
{
return Some(ip);
}
if let Some(ip) = named
.values()
.filter_map(|net| net.ip_address.clone())
.find(|ip| !ip.is_empty())
{
return Some(ip);
}
}
networks
.ip_address
.clone()
.filter(|ip| !ip.is_empty())
}
/// Resolve `(address, password)` for the container's RCON endpoint from its
/// image and environment.
async fn rcon_endpoint(&self, server_uuid: &str) -> Result<(String, String)> {
let (image, env) = self.container_runtime_metadata(server_uuid).await?;
let normalized = image.to_ascii_lowercase();
let lookup = |keys: &[&str]| -> Option<String> {
keys.iter()
.find_map(|key| env.get(*key))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
};
let is_ark = normalized.contains("ark-server")
|| normalized.contains("ark-survival-evolved");
let (password_keys, port_keys, default_port): (&[&str], &[&str], u16) = if is_ark {
(
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
&["RCON_PORT"],
27020,
)
} else {
(
&["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"],
&["RCON_PORT", "CS2_PORT"],
27015,
)
};
let password = lookup(password_keys)
.ok_or_else(|| anyhow::anyhow!("no RCON password is configured for this server"))?;
let port = lookup(port_keys)
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(default_port);
let host = match lookup(&["RCON_HOST"]) {
Some(host) => host,
None => self
.container_ip(server_uuid)
.await
.unwrap_or_else(|| "127.0.0.1".to_string()),
};
Ok((format!("{host}:{port}"), password))
}
async fn send_command_via_rcon(&self, server_uuid: &str, command: &str) -> Result<()> {
let (address, password) = self.rcon_endpoint(server_uuid).await?;
let mut client = crate::game::rcon::RconClient::connect(&address, &password).await?;
client.command(command).await?;
debug!(server_uuid = %server_uuid, address = %address, "Console command delivered over RCON");
Ok(())
}
async fn send_command_via_stdin(
&self,
server_uuid: &str,
container_id: &str,
command: &str,
) -> Result<()> {
let payload = format!("{command}\n");
for attempt in 0..2 {
let stream = self
.get_or_attach_command_stream(server_uuid, container_id)
.await?;
match stream.write_all(payload.as_bytes()).await {
Ok(_) => return Ok(()),
Err(error) => {
debug!(
server_uuid = %server_uuid,
attempt,
error = %error,
"Failed to write to container stdin, resetting attach stream",
);
self.clear_command_stream(server_uuid).await;
}
}
}
Err(anyhow::anyhow!("failed to write command to container stdin"))
}
/// Pull a Docker image if not already present.
pub async fn pull_image(&self, image: &str) -> Result<()> {
info!(image = %image, "Pulling Docker image");
@@ -230,7 +465,9 @@ impl DockerManager {
/// Create and configure a container for a game server.
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
let name = container_name(&spec.uuid);
let data_mount_path = container_data_path_for_image(&spec.docker_image);
let data_mount_path = container_data_path(spec);
let data_mount_path = data_mount_path.as_str();
let bind_source = self.host_bind_source(&spec.data_path);
// Build port bindings
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
@@ -267,8 +504,7 @@ impl DockerManager {
network_mode: Some(self.network_name().to_string()),
binds: Some(vec![format!(
"{}:{}",
spec.data_path.display()
,
bind_source.display(),
data_mount_path
)]),
..Default::default()
@@ -280,6 +516,7 @@ impl DockerManager {
env: Some(env),
exposed_ports: Some(exposed_ports),
host_config: Some(host_config),
labels: Some(runtime_labels(spec)),
// Preserve image default working directory when no custom startup command is set.
// Some game images rely on their built-in WORKDIR and entrypoint scripts.
working_dir: if spec.startup_command.is_empty() {
@@ -342,6 +579,100 @@ impl DockerManager {
Ok(())
}
/// Shut a server down the way its game expects.
///
/// Sending the in-game stop command first is what makes shutdown fast: most
/// dedicated servers ignore SIGTERM entirely and only exit once Docker's
/// timeout expires and SIGKILL lands, which is why "stop" used to sit there
/// for the full grace period every single time.
pub async fn stop_container_graceful(
&self,
server_uuid: &str,
stop_command: Option<&str>,
stop_timeout_secs: i64,
) -> Result<()> {
let budget = if stop_timeout_secs > 0 {
stop_timeout_secs
} else {
DEFAULT_STOP_TIMEOUT_SECS
}
.max(5);
let stop_command = stop_command
.map(str::trim)
.filter(|command| !command.is_empty());
if let Some(command) = stop_command {
match self.send_command(server_uuid, command).await {
Ok(_) => {
let graceful_budget = (budget - SIGTERM_GRACE_SECS).max(5);
if self.wait_until_exited(server_uuid, graceful_budget).await? {
self.clear_command_stream(server_uuid).await;
info!(
uuid = %server_uuid,
command = %command,
"Server exited after in-game stop command",
);
return Ok(());
}
tracing::warn!(
uuid = %server_uuid,
command = %command,
graceful_budget,
"Server ignored the in-game stop command, falling back to SIGTERM",
);
return self.stop_container(server_uuid, SIGTERM_GRACE_SECS).await;
}
Err(error) => {
tracing::warn!(
uuid = %server_uuid,
command = %command,
error = %error,
"Could not deliver the in-game stop command, falling back to SIGTERM",
);
}
}
}
// No usable stop command: SIGTERM gets the whole budget. Docker returns
// as soon as the container exits, so a well-behaved image (ARK, itzg)
// still stops quickly.
self.stop_container(server_uuid, budget).await
}
/// Poll until the container is no longer running. Returns `false` on timeout.
async fn wait_until_exited(&self, server_uuid: &str, timeout_secs: i64) -> Result<bool> {
let deadline =
tokio::time::Instant::now() + Duration::from_secs(timeout_secs.max(1) as u64);
let name = container_name(server_uuid);
loop {
match self.client().inspect_container(&name, None).await {
Ok(info) => {
let running = info
.state
.as_ref()
.and_then(|state| state.running)
.unwrap_or(false);
if !running {
return Ok(true);
}
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => return Ok(true),
Err(error) => return Err(error.into()),
}
if tokio::time::Instant::now() >= deadline {
return Ok(false);
}
sleep(Duration::from_millis(500)).await;
}
}
/// Kill a container immediately.
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
let name = container_name(server_uuid);
@@ -476,6 +807,12 @@ impl DockerManager {
.and_then(|cfg| cfg.image.clone())
.unwrap_or_default();
let runtime = runtime_from_labels(
info.config.as_ref().and_then(|cfg| cfg.labels.as_ref()),
);
// `mount.source` is a host path; map it back into the daemon's own
// mount namespace before we try to read or write it.
let data_mount_path = info
.mounts
.as_ref()
@@ -484,7 +821,10 @@ impl DockerManager {
if mount.typ != Some(MountPointTypeEnum::BIND) {
return None;
}
mount.source.as_ref().map(PathBuf::from)
mount
.source
.as_ref()
.map(|source| self.daemon_data_path(Path::new(source)))
})
})
.unwrap_or_else(|| data_root.join(&uuid));
@@ -594,6 +934,7 @@ impl DockerManager {
data_path: data_mount_path,
state,
container_id: info.id,
runtime,
});
}
@@ -620,22 +961,57 @@ impl DockerManager {
})
}
/// Send a command to a container via a persistent Docker attach stdin stream.
/// Send a console command to a server.
///
/// Most images pipe the game's stdin straight through, so the attached
/// stdin stream is the default. Source-engine and ARK servers never read
/// stdin, so those go over RCON first — with the other transport used as a
/// fallback in both directions.
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n');
let payload = format!("{trimmed}\n");
if trimmed.trim().is_empty() {
return Err(anyhow::anyhow!("Command cannot be empty"));
}
for _ in 0..2 {
let stream = self.get_or_attach_command_stream(server_uuid).await?;
match stream.write_all(payload.as_bytes()).await {
let container_id = self.running_container_id(server_uuid).await?;
let image = self
.container_runtime_metadata(server_uuid)
.await
.map(|(image, _)| image)
.unwrap_or_default();
if prefers_rcon_console(&image) {
match self.send_command_via_rcon(server_uuid, trimmed).await {
Ok(_) => return Ok(()),
Err(error) => {
debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream");
self.clear_command_stream(server_uuid).await;
Err(rcon_error) => {
debug!(
server_uuid = %server_uuid,
error = %rcon_error,
"RCON console delivery failed, trying container stdin",
);
return self
.send_command_via_stdin(server_uuid, &container_id, trimmed)
.await
.map_err(|stdin_error| {
anyhow::anyhow!(
"RCON failed ({rcon_error}) and stdin failed ({stdin_error})"
)
});
}
}
}
Err(anyhow::anyhow!("failed to write command to container stdin"))
match self
.send_command_via_stdin(server_uuid, &container_id, trimmed)
.await
{
Ok(_) => Ok(()),
Err(stdin_error) => self
.send_command_via_rcon(server_uuid, trimmed)
.await
.map_err(|rcon_error| {
anyhow::anyhow!("stdin failed ({stdin_error}) and RCON failed ({rcon_error})")
}),
}
}
}
+61 -6
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -10,23 +11,37 @@ use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
use tracing::info;
use crate::config::DockerConfig;
use crate::config::DaemonConfig;
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
pub(crate) struct CommandStreamHandle {
/// Docker id of the container this stdin stream was opened against. A
/// container that gets recreated (or restarted) keeps the same name but
/// gets a new id, and writes to the old hijacked socket are silently
/// swallowed — so the id is what makes a cached stream reusable.
container_id: String,
input: Mutex<AttachedInput>,
drain_task: JoinHandle<()>,
}
impl CommandStreamHandle {
pub(crate) fn new(input: AttachedInput, drain_task: JoinHandle<()>) -> Self {
pub(crate) fn new(
container_id: String,
input: AttachedInput,
drain_task: JoinHandle<()>,
) -> Self {
Self {
container_id,
input: Mutex::new(input),
drain_task,
}
}
pub(crate) fn container_id(&self) -> &str {
&self.container_id
}
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
let mut input = self.input.lock().await;
input.write_all(bytes).await?;
@@ -44,13 +59,15 @@ impl CommandStreamHandle {
pub struct DockerManager {
client: Docker,
network_name: String,
data_root: PathBuf,
host_data_root: PathBuf,
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
}
impl DockerManager {
pub async fn new(config: &DockerConfig) -> Result<Self> {
pub async fn new(config: &DaemonConfig) -> Result<Self> {
let client = Docker::connect_with_socket(
&config.socket,
&config.docker.socket,
120, // timeout
bollard::API_DEFAULT_VERSION,
)?;
@@ -62,13 +79,25 @@ impl DockerManager {
"Connected to Docker"
);
let data_root = config.data_path.clone();
let host_data_root = config.host_data_path();
if data_root != host_data_root {
info!(
data_root = %data_root.display(),
host_data_root = %host_data_root.display(),
"Server data directories are bind-mounted from a different host path",
);
}
let manager = Self {
client,
network_name: config.network.clone(),
network_name: config.docker.network.clone(),
data_root,
host_data_root,
command_streams: Arc::new(RwLock::new(HashMap::new())),
};
manager.ensure_network(&config.network_subnet).await?;
manager.ensure_network(&config.docker.network_subnet).await?;
Ok(manager)
}
@@ -81,6 +110,32 @@ impl DockerManager {
&self.network_name
}
/// Translate a daemon-local server data directory into the path the Docker
/// host must bind-mount. These differ when the daemon runs in a container.
pub fn host_bind_source(&self, data_path: &Path) -> PathBuf {
if self.data_root == self.host_data_root {
return data_path.to_path_buf();
}
match data_path.strip_prefix(&self.data_root) {
Ok(relative) => self.host_data_root.join(relative),
Err(_) => data_path.to_path_buf(),
}
}
/// Inverse of [`Self::host_bind_source`]: turn a bind-mount source reported
/// by Docker back into a path the daemon can read and write itself.
pub fn daemon_data_path(&self, host_path: &Path) -> PathBuf {
if self.data_root == self.host_data_root {
return host_path.to_path_buf();
}
match host_path.strip_prefix(&self.host_data_root) {
Ok(relative) => self.data_root.join(relative),
Err(_) => host_path.to_path_buf(),
}
}
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
&self.command_streams
}
+80
View File
@@ -0,0 +1,80 @@
use anyhow::Result;
use tracing::info;
use super::rcon::RconClient;
/// Player information from an ARK RCON `ListPlayers` response.
pub struct ArkPlayer {
pub name: String,
pub steamid: String,
}
/// Query an ARK server for its connected players.
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<Vec<ArkPlayer>> {
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
let response = client.command("ListPlayers").await?;
let players = parse_list_players_response(&response);
info!(count = players.len(), "ARK player list retrieved");
Ok(players)
}
/// Parses lines shaped like `0. PlayerName, 76561198000000000`.
fn parse_list_players_response(response: &str) -> Vec<ArkPlayer> {
let mut players = Vec::new();
for line in response.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// "No Players Connected"
if trimmed.eq_ignore_ascii_case("no players connected") {
break;
}
// Strip the "<index>. " prefix.
let entry = match trimmed.split_once('.') {
Some((index, rest)) if index.trim().chars().all(|c| c.is_ascii_digit()) => rest.trim(),
_ => continue,
};
let (name, steamid) = match entry.rsplit_once(',') {
Some((name, steamid)) => (name.trim(), steamid.trim()),
None => (entry, ""),
};
if name.is_empty() {
continue;
}
players.push(ArkPlayer {
name: name.to_string(),
steamid: steamid.to_string(),
});
}
players
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_connected_players() {
let response = "0. Alper, 76561198000000001\n1. Rezan, 76561198000000002\n";
let players = parse_list_players_response(response);
assert_eq!(players.len(), 2);
assert_eq!(players[0].name, "Alper");
assert_eq!(players[0].steamid, "76561198000000001");
assert_eq!(players[1].name, "Rezan");
}
#[test]
fn handles_empty_server() {
assert!(parse_list_players_response("No Players Connected\n").is_empty());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod rcon;
pub mod minecraft;
pub mod cs2;
pub mod ark;
+79 -6
View File
@@ -14,7 +14,7 @@ use tonic::{Request, Response, Status};
use tracing::{info, error, warn};
use crate::command::CommandDispatcher;
use crate::server::{ServerManager, PortMap};
use crate::server::{ServerManager, ServerRuntime, PortMap};
use crate::filesystem::FileSystem;
use crate::backup::BackupManager;
use crate::managed_mysql::ManagedMysqlManager;
@@ -110,6 +110,21 @@ impl DaemonServiceImpl {
Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok())
}
/// Host to reach a server's RCON port on. The container's own IP works both
/// when the daemon runs on the host and when it runs as a sibling container;
/// `127.0.0.1` only works in the former case.
async fn rcon_host(&self, uuid: &str, env: &HashMap<String, String>) -> String {
if let Some(host) = Self::env_value(env, &["RCON_HOST"]) {
return host;
}
self.server_manager
.docker()
.container_ip(uuid)
.await
.unwrap_or_else(|| "127.0.0.1".to_string())
}
fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
.unwrap_or_else(|| "changeme".to_string())
@@ -191,6 +206,12 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?;
let req = request.into_inner();
let runtime = ServerRuntime::from_request(
req.data_path,
req.stop_command,
req.stop_timeout_seconds,
);
self.server_manager
.create_server(
req.uuid.clone(),
@@ -201,6 +222,7 @@ impl DaemonService for DaemonServiceImpl {
req.startup_command,
req.environment,
Self::map_ports(&req.ports),
runtime,
)
.await
.map_err(|e| Status::from(e))?;
@@ -218,6 +240,12 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?;
let req = request.into_inner();
let runtime = ServerRuntime::from_request(
req.data_path,
req.stop_command,
req.stop_timeout_seconds,
);
let state = self.server_manager
.update_server(
req.uuid.clone(),
@@ -228,6 +256,7 @@ impl DaemonService for DaemonServiceImpl {
req.startup_command,
req.environment,
Self::map_ports(&req.ports),
runtime,
)
.await
.map_err(Status::from)?;
@@ -378,15 +407,29 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?;
let req = request.into_inner();
match req.action() {
let action = req.action();
let stop_command = if req.stop_command.trim().is_empty() {
None
} else {
Some(req.stop_command.as_str())
};
let stop_timeout = i64::from(req.stop_timeout_seconds);
match action {
PowerAction::Start => {
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
}
PowerAction::Stop => {
self.server_manager.stop_server(&req.uuid).await.map_err(Status::from)?;
self.server_manager
.stop_server(&req.uuid, stop_command, stop_timeout)
.await
.map_err(Status::from)?;
}
PowerAction::Restart => {
let _ = self.server_manager.stop_server(&req.uuid).await;
let _ = self
.server_manager
.stop_server(&req.uuid, stop_command, stop_timeout)
.await;
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
}
PowerAction::Kill => {
@@ -764,12 +807,42 @@ impl DaemonService for DaemonServiceImpl {
}
}
}
} else if image.contains("ark-server") || image.contains("ark-survival-evolved") {
max_from_runtime_env = Self::env_i32(&env, &["MAX_PLAYERS"]).unwrap_or(0);
let host = self.rcon_host(&uuid, &env).await;
let port = Self::env_u16(&env, &["RCON_PORT"]).unwrap_or(27020);
let password = Self::env_value(
&env,
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
)
.unwrap_or_default();
let address = format!("{}:{}", host, port);
match crate::game::ark::get_players(&address, &password).await {
Ok(players) => {
let mapped = players
.into_iter()
.map(|p| Player {
name: p.name,
uuid: p.steamid,
connected_at: 0,
})
.collect();
return Ok(Response::new(PlayerList {
players: mapped,
max_players: max_from_runtime_env,
}));
}
Err(e) => {
warn!(uuid = %uuid, error = %e, "ARK RCON player query failed");
}
}
} else if image.contains("csgo") || image.contains("cs2") {
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
.unwrap_or(0);
let host = Self::env_value(&env, &["RCON_HOST"])
.unwrap_or_else(|| "127.0.0.1".to_string());
let host = self.rcon_host(&uuid, &env).await;
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
let password = Self::cs2_rcon_password(&env);
let address = format!("{}:{}", host, port);
+15 -1
View File
@@ -28,6 +28,20 @@ const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
#[tokio::main]
async fn main() -> Result<()> {
// `--health-check` is what the container HEALTHCHECK runs: succeed only if
// the gRPC listener is actually accepting connections.
if std::env::args().any(|arg| arg == "--health-check") {
let config = config::DaemonConfig::load()?;
let address = format!("127.0.0.1:{}", config.grpc_port);
return match tokio::net::TcpStream::connect(&address).await {
Ok(_) => Ok(()),
Err(error) => {
eprintln!("daemon health check failed for {address}: {error}");
std::process::exit(1);
}
};
}
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
@@ -42,7 +56,7 @@ async fn main() -> Result<()> {
info!(grpc_port = config.grpc_port, "Configuration loaded");
// Initialize Docker
let docker = Arc::new(DockerManager::new(&config.docker).await?);
let docker = Arc::new(DockerManager::new(&config).await?);
info!("Docker manager initialized");
// Initialize server manager
+2 -2
View File
@@ -128,9 +128,9 @@ impl Scheduler {
"power" => {
match task.payload.as_str() {
"start" => self.server_manager.start_server(&task.server_uuid).await?,
"stop" => self.server_manager.stop_server(&task.server_uuid).await?,
"stop" => self.server_manager.stop_server(&task.server_uuid, None, 0).await?,
"restart" => {
let _ = self.server_manager.stop_server(&task.server_uuid).await;
let _ = self.server_manager.stop_server(&task.server_uuid, None, 0).await;
tokio::time::sleep(Duration::from_secs(3)).await;
self.server_manager.start_server(&task.server_uuid).await?;
}
+47 -4
View File
@@ -10,7 +10,7 @@ use std::os::unix::fs::PermissionsExt;
use crate::config::DaemonConfig;
use crate::docker::DockerManager;
use crate::error::DaemonError;
use super::state::{ServerState, ServerSpec, PortMap};
use super::state::{ServerState, ServerSpec, ServerRuntime, PortMap};
/// Manages all game server instances on this node.
pub struct ServerManager {
@@ -101,6 +101,7 @@ impl ServerManager {
startup_command: String,
environment: HashMap<String, String>,
ports: Vec<PortMap>,
runtime: ServerRuntime,
) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await;
if servers.contains_key(&uuid) {
@@ -122,6 +123,7 @@ impl ServerManager {
data_path,
state: ServerState::Installing,
container_id: None,
runtime,
};
servers.insert(uuid.clone(), spec);
@@ -154,6 +156,7 @@ impl ServerManager {
startup_command: String,
environment: HashMap<String, String>,
ports: Vec<PortMap>,
runtime: ServerRuntime,
) -> Result<ServerState, DaemonError> {
let existing = {
let servers = self.servers.read().await;
@@ -205,6 +208,7 @@ impl ServerManager {
data_path,
state: ServerState::Stopped,
container_id: None,
runtime: runtime.clone(),
};
if runtime_state
@@ -212,7 +216,20 @@ impl ServerManager {
.map(Self::is_running_state)
.unwrap_or(false)
{
if let Err(stop_error) = self.docker.stop_container(&uuid, 30).await {
let previous_runtime = existing
.as_ref()
.map(|spec| spec.runtime.clone())
.unwrap_or_else(|| runtime.clone());
if let Err(stop_error) = self
.docker
.stop_container_graceful(
&uuid,
previous_runtime.stop_command.as_deref(),
previous_runtime.stop_timeout_seconds.unwrap_or(0),
)
.await
{
warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill");
self.docker.kill_container(&uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
@@ -335,9 +352,18 @@ impl ServerManager {
}
/// Stop a server.
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
///
/// `stop_command` / `stop_timeout_seconds` override whatever was captured
/// when the container was created; pass `None` / `0` to use those defaults.
pub async fn stop_server(
&self,
uuid: &str,
stop_command: Option<&str>,
stop_timeout_seconds: i64,
) -> Result<(), DaemonError> {
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
let mut spec_runtime = ServerRuntime::default();
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
@@ -353,12 +379,29 @@ impl ServerManager {
});
}
previous_state = Some(spec.state.clone());
spec_runtime = spec.runtime.clone();
spec.state = ServerState::Stopping;
managed = true;
}
}
if let Err(e) = self.docker.stop_container(uuid, 30).await {
let effective_command = stop_command
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
.or_else(|| spec_runtime.stop_command.clone());
let effective_timeout = if stop_timeout_seconds > 0 {
stop_timeout_seconds
} else {
spec_runtime.stop_timeout_seconds.unwrap_or(0)
};
if let Err(e) = self
.docker
.stop_container_graceful(uuid, effective_command.as_deref(), effective_timeout)
.await
{
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod state;
pub mod manager;
pub use state::{ServerSpec, PortMap};
pub use state::{ServerSpec, ServerRuntime, PortMap};
pub use manager::ServerManager;
+42
View File
@@ -33,6 +33,46 @@ pub struct PortMap {
pub protocol: String, // "tcp" or "udp"
}
/// Per-game runtime knobs supplied by the panel. Mirrored into Docker labels so
/// they survive a daemon restart (see `docker::container`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerRuntime {
/// Mount point of the data directory inside the container. `None` means
/// "derive it from the image".
pub data_mount_path: Option<String>,
/// In-game command that shuts the server down cleanly (e.g. `stop`, `quit`).
pub stop_command: Option<String>,
/// Total budget for a graceful shutdown before the container gets killed.
pub stop_timeout_seconds: Option<i64>,
}
impl ServerRuntime {
pub fn from_request(
data_mount_path: String,
stop_command: String,
stop_timeout_seconds: i32,
) -> Self {
Self {
data_mount_path: non_empty(data_mount_path),
stop_command: non_empty(stop_command),
stop_timeout_seconds: if stop_timeout_seconds > 0 {
Some(stop_timeout_seconds as i64)
} else {
None
},
}
}
}
fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSpec {
pub uuid: String,
@@ -46,6 +86,8 @@ pub struct ServerSpec {
pub data_path: PathBuf,
pub state: ServerState,
pub container_id: Option<String>,
#[serde(default)]
pub runtime: ServerRuntime,
}
impl ServerSpec {