feat: wire daemon console/files/config/players and improve runtime fallbacks

This commit is contained in:
2026-02-22 12:09:07 +00:00
parent 614d25c189
commit 44c439e2f9
12 changed files with 1793 additions and 96 deletions
+65 -17
View File
@@ -8,6 +8,7 @@ use bollard::container::{
use bollard::image::CreateImageOptions;
use bollard::models::{HostConfig, PortBinding};
use futures::StreamExt;
use tokio::time::{sleep, Duration};
use tracing::info;
use crate::docker::DockerManager;
@@ -21,6 +22,56 @@ pub fn container_name(server_uuid: &str) -> String {
}
impl DockerManager {
async fn run_exec(&self, container_name: &str, cmd: Vec<String>) -> Result<String> {
let exec = self
.client()
.create_exec(
container_name,
bollard::exec::CreateExecOptions::<String> {
cmd: Some(cmd),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
},
)
.await?;
let mut captured = String::new();
match self.client()
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
.await?
{
bollard::exec::StartExecResults::Attached { mut output, .. } => {
while let Some(chunk) = output.next().await {
let chunk = chunk?;
captured.push_str(&chunk.to_string());
}
}
bollard::exec::StartExecResults::Detached => {}
}
// Wait briefly for completion and collect exit code.
for _ in 0..30 {
let status = self.client().inspect_exec(&exec.id).await?;
if !status.running.unwrap_or(false) {
let code = status.exit_code.unwrap_or(0);
if code == 0 {
return Ok(captured);
}
return Err(anyhow::anyhow!("exec command failed with exit code {}", code));
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow::anyhow!("exec command timeout"))
}
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()])
.await
}
/// Pull a Docker image if not already present.
pub async fn pull_image(&self, image: &str) -> Result<()> {
info!(image = %image, "Pulling Docker image");
@@ -241,23 +292,20 @@ impl DockerManager {
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
let name = container_name(server_uuid);
let exec = self
.client()
.create_exec(
&name,
bollard::exec::CreateExecOptions {
cmd: Some(vec!["sh", "-c", &format!("echo '{}' > /proc/1/fd/0", command)]),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
},
)
.await?;
// Preferred path for Minecraft-like images where rcon-cli is available.
if self
.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
.await
.is_ok()
{
return Ok(());
}
self.client()
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
.await?;
Ok(())
// Generic fallback: write directly to PID 1 stdin.
let escaped = command.replace('\'', "'\"'\"'");
let shell_cmd = format!("printf '%s\\n' '{}' > /proc/1/fd/0", escaped);
self.run_exec(&name, vec!["sh".to_string(), "-c".to_string(), shell_cmd])
.await
.map(|_| ())
}
}
+197 -6
View File
@@ -1,11 +1,12 @@
use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
use std::collections::HashMap;
use futures::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status};
use tracing::{info, error};
use tracing::{info, error, warn};
use crate::server::{ServerManager, PortMap};
use crate::filesystem::FileSystem;
@@ -241,9 +242,6 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?;
let uuid = request.into_inner().uuid;
// Verify server exists
let _ = self.server_manager.get_server(&uuid).await.map_err(Status::from)?;
let (tx, rx) = tokio::sync::mpsc::channel(256);
let docker = self.server_manager.docker().clone();
@@ -482,10 +480,151 @@ impl DaemonService for DaemonServiceImpl {
request: Request<ServerIdentifier>,
) -> Result<Response<PlayerList>, Status> {
self.check_auth(&request)?;
// TODO: implement game-specific player queries (RCON)
let uuid = request.into_inner().uuid;
let fs = self.get_fs(&uuid);
let properties = match fs.read_file("server.properties").await {
Ok(data) => parse_properties_map(&String::from_utf8_lossy(&data)),
Err(_) => HashMap::new(),
};
let max_from_properties = properties
.get("max-players")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
let rcon_enabled_from_properties = properties
.get("enable-rcon")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let rcon_password_from_properties = properties
.get("rcon.password")
.filter(|v| !v.trim().is_empty())
.cloned();
let rcon_port_from_properties = properties
.get("rcon.port")
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(25575);
// Try RCON-based player discovery for known games when runtime spec exists.
if let Ok(spec) = self.server_manager.get_server(&uuid).await {
let image = spec.docker_image.to_lowercase();
if image.contains("minecraft") {
let password_from_env = spec
.environment
.get("RCON_PASSWORD")
.or_else(|| spec.environment.get("MCRCON_PASSWORD"))
.filter(|v| !v.trim().is_empty());
let password = password_from_env
.cloned()
.or_else(|| {
if rcon_enabled_from_properties {
rcon_password_from_properties.clone()
} else {
None
}
});
if let Some(password) = password {
let host = spec
.environment
.get("RCON_HOST")
.filter(|v| !v.trim().is_empty())
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = spec
.environment
.get("RCON_PORT")
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(rcon_port_from_properties);
let address = format!("{}:{}", host, port);
match crate::game::minecraft::get_players(&address, &password).await {
Ok((players, max)) => {
let mapped = players
.into_iter()
.map(|p| Player {
name: p.name,
uuid: String::new(),
connected_at: 0,
})
.collect();
return Ok(Response::new(PlayerList {
players: mapped,
max_players: max as i32,
}));
}
Err(e) => {
warn!(uuid = %uuid, error = %e, "Minecraft RCON player query failed");
}
}
}
} else if image.contains("csgo") || image.contains("cs2") {
if let Some(password) = spec
.environment
.get("SRCDS_RCONPW")
.or_else(|| spec.environment.get("RCON_PASSWORD"))
.filter(|v| !v.trim().is_empty())
{
let host = spec
.environment
.get("RCON_HOST")
.filter(|v| !v.trim().is_empty())
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = spec
.environment
.get("RCON_PORT")
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(27015);
let address = format!("{}:{}", host, port);
match crate::game::cs2::get_players(&address, password).await {
Ok((players, max)) => {
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 as i32,
}));
}
Err(e) => {
warn!(uuid = %uuid, error = %e, "CS2 RCON player query failed");
}
}
}
}
}
// Fallback for restarted daemon / missing runtime spec:
// try querying `rcon-cli list` inside the container and parse output.
if let Ok(output) = self.server_manager.docker().rcon_command(&uuid, "list").await {
let (names, max) = parse_minecraft_list_output(&output);
if !names.is_empty() || max > 0 {
let mapped = names
.into_iter()
.map(|name| Player {
name,
uuid: String::new(),
connected_at: 0,
})
.collect();
return Ok(Response::new(PlayerList {
players: mapped,
max_players: if max > 0 { max } else { max_from_properties },
}));
}
}
Ok(Response::new(PlayerList {
players: vec![],
max_players: 0,
max_players: max_from_properties,
}))
}
}
@@ -509,3 +648,55 @@ fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
0.0
}
}
fn parse_properties_map(content: &str) -> HashMap<String, String> {
let mut props = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') {
continue;
}
let mut parts = trimmed.splitn(2, '=');
let Some(key) = parts.next() else { continue };
let Some(value) = parts.next() else { continue };
props.insert(key.trim().to_string(), value.trim().to_string());
}
props
}
fn parse_minecraft_list_output(output: &str) -> (Vec<String>, i32) {
let mut max_players = 0i32;
let mut names = Vec::new();
// Typical response:
// "There are 1 of a max of 20 players online: player1, player2"
let parts: Vec<&str> = output.splitn(2, ':').collect();
if let Some(header) = parts.first() {
let mut first_number_seen = false;
for token in header.split_whitespace() {
if let Ok(value) = token.parse::<i32>() {
if !first_number_seen {
first_number_seen = true;
} else {
max_players = value;
break;
}
}
}
}
if parts.len() > 1 {
let players = parts[1].trim();
if !players.is_empty() {
for name in players.split(',') {
let clean = name.trim();
if !clean.is_empty() {
names.push(clean.to_string());
}
}
}
}
(names, max_players)
}
+40 -32
View File
@@ -130,28 +130,32 @@ impl ServerManager {
/// Start a server.
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await;
let spec = servers
.get_mut(uuid)
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
if !spec.can_transition_to(&ServerState::Starting) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "starting".to_string(),
});
let mut managed = false;
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
if !spec.can_transition_to(&ServerState::Starting) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "starting".to_string(),
});
}
spec.state = ServerState::Starting;
managed = true;
}
}
spec.state = ServerState::Starting;
drop(servers);
self.docker.start_container(uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to start container: {}", e))
})?;
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Running;
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Running;
}
} else {
info!(uuid = %uuid, "Started container without managed runtime state");
}
Ok(())
@@ -159,28 +163,32 @@ impl ServerManager {
/// Stop a server.
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await;
let spec = servers
.get_mut(uuid)
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
if !spec.can_transition_to(&ServerState::Stopping) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "stopping".to_string(),
});
let mut managed = false;
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
if !spec.can_transition_to(&ServerState::Stopping) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "stopping".to_string(),
});
}
spec.state = ServerState::Stopping;
managed = true;
}
}
spec.state = ServerState::Stopping;
drop(servers);
self.docker.stop_container(uuid, 30).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop container: {}", e))
})?;
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Stopped;
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Stopped;
}
} else {
info!(uuid = %uuid, "Stopped container without managed runtime state");
}
Ok(())