This commit is contained in:
2026-07-21 22:06:10 +00:00
parent afc64b83c1
commit 5215560ede
25 changed files with 4364 additions and 333 deletions
+223 -3
View File
@@ -1,26 +1,38 @@
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use bollard::container::{
AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
StopContainerOptions, StatsOptions, Stats,
AttachContainerOptions, Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
StopContainerOptions, StatsOptions, Stats, UploadToContainerOptions,
};
use bollard::image::CreateImageOptions;
use bollard::models::{HostConfig, PortBinding};
use bollard::models::{HostConfig, MountPointTypeEnum, PortBinding};
use futures::StreamExt;
use tokio::time::{sleep, Duration};
use tracing::{debug, info};
use crate::docker::DockerManager;
use crate::server::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");
pub fn container_name(server_uuid: &str) -> String {
format!("{}{}", CONTAINER_PREFIX, server_uuid)
}
fn uuid_from_container_name(name: &str) -> Option<String> {
let trimmed = name.trim_start_matches('/');
trimmed
.strip_prefix(CONTAINER_PREFIX)
.filter(|uuid| !uuid.is_empty())
.map(str::to_string)
}
fn container_data_path_for_image(image: &str) -> &'static str {
let normalized = image.to_ascii_lowercase();
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
@@ -29,9 +41,30 @@ fn container_data_path_for_image(image: &str) -> &'static str {
if normalized.contains("cm2network/csgo") {
return "/home/steam/csgo-dedicated";
}
if normalized.contains("spritsail/fivem") {
return "/config";
}
if normalized.contains("wolveix/satisfactory-server") {
return "/config";
}
"/data"
}
fn is_wolveix_satisfactory_image(image: &str) -> bool {
image
.to_ascii_lowercase()
.contains("wolveix/satisfactory-server")
}
fn server_state_from_container_status(status: &str) -> ServerState {
match status {
"running" | "restarting" | "paused" => ServerState::Running,
"created" | "exited" => ServerState::Stopped,
"dead" => ServerState::Error,
_ => ServerState::Error,
}
}
impl DockerManager {
async fn attach_command_stream(
&self,
@@ -136,6 +169,33 @@ impl DockerManager {
Err(anyhow::anyhow!("exec command timeout"))
}
async fn patch_satisfactory_run_script(&self, container_name: &str) -> Result<()> {
let mut archive = Vec::new();
{
let mut builder = tar::Builder::new(&mut archive);
let bytes = SATISFACTORY_RUN_SH.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append_data(&mut header, "run.sh", Cursor::new(bytes))?;
builder.finish()?;
}
self.client()
.upload_to_container(
container_name,
Some(UploadToContainerOptions {
path: "/home/steam",
no_overwrite_dir_non_dir: "false",
}),
archive.into(),
)
.await?;
Ok(())
}
pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result<String> {
let name = container_name(server_uuid);
self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
@@ -248,6 +308,10 @@ impl DockerManager {
let options = CreateContainerOptions { name: name.as_str(), platform: None };
let response = self.client().create_container(Some(options), config).await?;
if is_wolveix_satisfactory_image(&spec.docker_image) {
self.patch_satisfactory_run_script(&name).await?;
}
info!(container_id = %response.id, uuid = %spec.uuid, "Container created");
Ok(response.id)
}
@@ -380,6 +444,162 @@ impl DockerManager {
Ok((image, env_map))
}
/// Discover existing managed containers and rebuild in-memory server specs.
pub async fn recover_managed_server_specs(&self, data_root: &Path) -> Result<Vec<ServerSpec>> {
let containers = self
.client()
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await?;
let mut recovered = Vec::new();
for container in containers {
let uuid = container
.names
.as_ref()
.into_iter()
.flatten()
.find_map(|name| uuid_from_container_name(name));
let Some(uuid) = uuid else {
continue;
};
let info = self.client().inspect_container(&container_name(&uuid), None).await?;
let image = info
.config
.as_ref()
.and_then(|cfg| cfg.image.clone())
.unwrap_or_default();
let data_mount_path = info
.mounts
.as_ref()
.and_then(|mounts| {
mounts.iter().find_map(|mount| {
if mount.typ != Some(MountPointTypeEnum::BIND) {
return None;
}
mount.source.as_ref().map(PathBuf::from)
})
})
.unwrap_or_else(|| data_root.join(&uuid));
let data_destination = info
.mounts
.as_ref()
.and_then(|mounts| {
mounts.iter().find_map(|mount| {
if mount.typ != Some(MountPointTypeEnum::BIND) {
return None;
}
mount.destination.clone()
})
})
.unwrap_or_else(|| container_data_path_for_image(&image).to_string());
let startup_command = info
.config
.as_ref()
.and_then(|cfg| {
let working_dir = cfg.working_dir.as_deref().unwrap_or_default();
if working_dir != data_destination {
return None;
}
cfg.cmd.as_ref().map(|cmd| cmd.join(" "))
})
.unwrap_or_default();
let mut environment = HashMap::new();
if let Some(env_vars) = info
.config
.as_ref()
.and_then(|cfg| cfg.env.clone())
{
for entry in env_vars {
if let Some((key, value)) = entry.split_once('=') {
environment.insert(key.to_string(), value.to_string());
}
}
}
let ports = info
.host_config
.as_ref()
.and_then(|cfg| cfg.port_bindings.as_ref())
.map(|bindings| {
bindings
.iter()
.flat_map(|(container_port, host_bindings)| {
let (container_port, protocol) = match container_port.split_once('/') {
Some((port, protocol)) => (port, protocol),
None => return Vec::new(),
};
let Ok(container_port_num) = container_port.parse::<u16>() else {
return Vec::new();
};
host_bindings
.as_ref()
.into_iter()
.flatten()
.filter_map(|binding| {
let host_port = binding.host_port.as_deref()?.parse::<u16>().ok()?;
Some(crate::server::PortMap {
host_port,
container_port: container_port_num,
protocol: protocol.to_string(),
})
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let memory_limit = info
.host_config
.as_ref()
.and_then(|cfg| cfg.memory)
.unwrap_or_default();
let cpu_limit = info
.host_config
.as_ref()
.and_then(|cfg| cfg.nano_cpus)
.map(|nano_cpus| (nano_cpus / 10_000_000) as i32)
.unwrap_or_default();
let state = info
.state
.as_ref()
.and_then(|state| state.status.as_ref())
.map(|status| server_state_from_container_status(&format!("{status:?}").to_lowercase()))
.unwrap_or(ServerState::Error);
recovered.push(ServerSpec {
uuid,
docker_image: image,
memory_limit,
disk_limit: 0,
cpu_limit,
startup_command,
environment,
ports,
data_path: data_mount_path,
state,
container_id: info.id,
});
}
Ok(recovered)
}
/// Stream container logs (stdout + stderr). Returns an owned stream.
pub fn stream_logs(
self: &Arc<Self>,
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
set -e
NUMCHECK='^[0-9]+$'
MSGWARNING="\033[0;33mWARNING:\033[0m"
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
SERVERGAMEPORT="7777"
fi
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
SERVERMESSAGINGPORT="8888"
fi
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
AUTOSAVENUM="5"
fi
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
MAXOBJECTS="2162688"
fi
printf "Setting max objects to %s\n" "$MAXOBJECTS"
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
MAXTICKRATE="30"
fi
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
TIMEOUT="30"
fi
printf "Setting timeout to %s\n" "$TIMEOUT"
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
MAXPLAYERS="4"
fi
printf "Setting max players to %s\n" "$MAXPLAYERS"
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
printf "Disabling seasonal events\n"
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
else
DISABLESEASONALEVENTS=""
fi
if [[ "$MULTIHOME" != "" ]]; then
if [[ "$MULTIHOME" == "::" ]]; then
printf "Multihome will accept IPv4 and IPv6 connections\n"
fi
printf "Setting multihome to %s\n" "$MULTIHOME"
MULTIHOME="-multihome=$MULTIHOME"
fi
ini_args=(
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"$DISABLESEASONALEVENTS"
"$MULTIHOME"
)
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
SKIPUPDATE="false"
fi
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
STEAMBETAPASSWORD=""
if [[ -n "${STEAMBETAID}" ]]; then
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
STEAMBETAFLAG="$STEAMBETAID"
if [[ -n "${STEAMBETAKEY}" ]]; then
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
printf "Beta password provided\n"
fi
elif [[ "${STEAMBETA,,}" == "true" ]]; then
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
STEAMBETAFLAG="experimental"
else
STEAMBETAFLAG=""
fi
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
fi
printf "\nDownloading the latest version of the game...\n"
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
fi
if [[ -n "$STEAMBETAFLAG" ]]; then
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
else
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
fi
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
else
printf "Skipping update as flag is set\n"
fi
printf "Launching game server\n\n"
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
rm -rf "$GAMESAVESDIR"
ln -sf "/config/saved" "$GAMESAVESDIR"
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "FactoryServer launch script is missing.\n"
exit 1
fi
cd /config/gamefiles || exit 1
chmod +x FactoryServer.sh || true
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
sleep 2
satisfactory_pid=$(ps --ppid ${!} o pid=)
shutdown() {
printf "\nReceived SIGINT. Shutting down.\n"
kill -INT $satisfactory_pid 2>/dev/null
}
trap shutdown SIGINT SIGTERM
wait
+22
View File
@@ -303,6 +303,28 @@ impl DaemonService for DaemonServiceImpl {
}))
}
async fn import_database_sql(
&self,
request: Request<ImportDatabaseSqlRequest>,
) -> Result<Response<Empty>, Status> {
self.check_auth(&request)?;
let req = request.into_inner();
if req.database_name.trim().is_empty() {
return Err(Status::invalid_argument("Database name is required"));
}
if req.sql.trim().is_empty() {
return Err(Status::invalid_argument("SQL payload is required"));
}
self.managed_mysql
.import_sql(req.database_name.trim(), &req.sql)
.await
.map_err(Status::from)?;
Ok(Response::new(Empty {}))
}
async fn update_database_password(
&self,
request: Request<UpdateDatabasePasswordRequest>,
+3
View File
@@ -49,6 +49,9 @@ async fn main() -> Result<()> {
let server_manager = Arc::new(ServerManager::new(docker, &config));
info!("Server manager initialized");
let recovered_servers = server_manager.recover_existing_servers().await?;
info!(recovered_servers, "Recovered managed servers from Docker");
// Initialize shared command dispatcher (single command pipeline for all games/sources)
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
info!("Command dispatcher initialized");
+94
View File
@@ -1,7 +1,9 @@
use std::io::ErrorKind;
use std::process::Stdio;
use reqwest::Url;
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tonic::Status;
use uuid::Uuid;
@@ -164,6 +166,28 @@ impl ManagedMysqlManager {
.await
}
pub async fn import_sql(
&self,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let database_name = database_name.trim();
if database_name.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database name is required".to_string(),
));
}
if sql.trim().is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"SQL payload is required".to_string(),
));
}
self.run_sql_script(config, database_name, sql).await
}
pub async fn delete_database(
&self,
database_name: &str,
@@ -242,6 +266,76 @@ impl ManagedMysqlManager {
Err(ManagedMysqlError::ClientMissing)
}
async fn run_sql_script(
&self,
config: &ManagedMysqlRuntimeConfig,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let binaries = match config.client_bin.as_deref() {
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
_ => vec!["mariadb".to_string(), "mysql".to_string()],
};
let mut missing_binary = false;
for binary in binaries {
let child = Command::new(&binary)
.args([
"--protocol=TCP",
"--batch",
"--skip-column-names",
"-h",
&config.admin_host,
"-P",
&config.admin_port.to_string(),
"-u",
&config.admin_username,
database_name,
])
.env("MYSQL_PWD", &config.admin_password)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match child {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(sql.as_bytes()).await?;
}
let output = child.wait_with_output().await?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{} exited with status {}", binary, output.status)
};
return Err(ManagedMysqlError::CommandFailed(message));
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing_binary = true;
continue;
}
Err(error) => return Err(ManagedMysqlError::Io(error)),
}
}
if missing_binary {
return Err(ManagedMysqlError::ClientMissing);
}
Err(ManagedMysqlError::ClientMissing)
}
}
fn resolve_runtime_config(
+30
View File
@@ -49,6 +49,32 @@ impl ServerManager {
}
}
/// Rebuild in-memory server specs from existing managed Docker containers.
pub async fn recover_existing_servers(&self) -> Result<usize, DaemonError> {
let recovered = self
.docker
.recover_managed_server_specs(&self.data_root)
.await
.map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?;
let recovered_count = recovered.len();
let mut servers = self.servers.write().await;
servers.clear();
for spec in recovered {
self.ensure_server_data_dir(&spec.data_path).await?;
info!(
uuid = %spec.uuid,
state = %spec.state,
image = %spec.docker_image,
"Recovered managed server from Docker runtime"
);
servers.insert(spec.uuid.clone(), spec);
}
Ok(recovered_count)
}
/// Get server spec by UUID.
pub async fn get_server(&self, uuid: &str) -> Result<ServerSpec, DaemonError> {
let servers = self.servers.read().await;
@@ -200,6 +226,10 @@ impl ServerManager {
})?;
}
self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| {
DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e))
})?;
match self.docker.create_container(&desired_spec).await {
Ok(container_id) => {
desired_spec.container_id = Some(container_id);