feat: wire daemon console/files/config/players and improve runtime fallbacks
This commit is contained in:
@@ -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(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user