use anyhow::{Context, Result}; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; use super::panel_adapter::{DiscoveredServer, FileEntry, PanelAdapter, ServerStatus}; /// AMP (Application Management Panel) adapter. /// /// Communicates with AMP instances via their REST API to manage /// Rust game servers. AMP uses session-based auth: POST to /// /API/Core/Login returns a sessionID that must be sent (UPPERCASED) /// in all subsequent request bodies. All endpoints are POST at /// /API/{Module}/{Method}. pub struct AmpAdapter { http: Client, pub api_endpoint: String, username: String, password: String, session_id: Arc>>, } // -- AMP API request/response types -- #[derive(Debug, Serialize)] struct AmpLoginRequest { username: String, password: String, token: String, #[serde(rename = "rememberMe")] remember_me: bool, } #[derive(Debug, Deserialize)] struct AmpLoginResponse { success: bool, #[serde(default, rename = "sessionID")] session_id: Option, #[serde(default)] result: Option, } #[derive(Debug, Serialize)] struct AmpSessionRequest { #[serde(rename = "SESSIONID")] session_id: String, } #[derive(Debug, Serialize)] struct AmpConsoleRequest { #[serde(rename = "SESSIONID")] session_id: String, message: String, } #[derive(Debug, Serialize)] struct AmpFileRequest { #[serde(rename = "SESSIONID")] session_id: String, #[serde(rename = "Directory", skip_serializing_if = "Option::is_none")] directory: Option, #[serde(rename = "Filename", skip_serializing_if = "Option::is_none")] filename: Option, #[serde(rename = "Data", skip_serializing_if = "Option::is_none")] data: Option, } #[derive(Debug, Deserialize)] struct AmpGenericResponse { #[serde(default)] #[allow(dead_code)] success: bool, #[serde(default, rename = "Status")] status: Option, #[serde(default, rename = "result")] result: Option, } #[derive(Debug, Deserialize)] struct AmpStatusData { #[serde(default, rename = "State")] state: i32, #[serde(default, rename = "Uptime")] uptime: Option, #[serde(default, rename = "Metrics")] metrics: Option, } #[derive(Debug, Deserialize)] struct AmpMetrics { #[serde(default, rename = "CPU Usage")] cpu_usage: Option, #[serde(default, rename = "Memory Usage")] memory_usage: Option, } #[derive(Debug, Deserialize)] struct AmpMetricValue { #[serde(default, rename = "RawValue")] raw_value: f64, } #[derive(Debug, Deserialize)] struct AmpInstance { #[serde(rename = "InstanceID")] instance_id: String, #[serde(rename = "InstanceName")] instance_name: String, #[serde(default, rename = "IP")] ip: Option, #[serde(default, rename = "Port")] port: Option, #[serde(default, rename = "Running")] running: bool, } #[derive(Debug, Deserialize)] struct AmpFileEntry { #[serde(rename = "Filename")] filename: String, #[serde(rename = "IsDirectory")] is_directory: bool, #[serde(default, rename = "SizeBytes")] size_bytes: Option, #[serde(default, rename = "Modified")] modified: Option, } impl AmpAdapter { pub fn new(api_endpoint: String, username: String, password: String) -> Self { Self { http: Client::new(), api_endpoint: api_endpoint.trim_end_matches('/').to_string(), username, password, session_id: Arc::new(RwLock::new(None)), } } /// Authenticate with AMP and store the session ID. async fn login(&self) -> Result { let url = format!("{}/API/Core/Login", self.api_endpoint); let body = AmpLoginRequest { username: self.username.clone(), password: self.password.clone(), token: String::new(), remember_me: false, }; let response = self .http .post(&url) .json(&body) .send() .await .context("Failed to connect to AMP API")?; let login: AmpLoginResponse = response .json() .await .context("Failed to parse AMP login response")?; if !login.success { anyhow::bail!("AMP login failed — check credentials"); } let sid = login .session_id .context("AMP login succeeded but no sessionID returned")? .to_uppercase(); let mut lock = self.session_id.write().await; *lock = Some(sid.clone()); Ok(sid) } /// Get a valid session ID, logging in if necessary. async fn get_session_id(&self) -> Result { let existing = self.session_id.read().await.clone(); match existing { Some(sid) => Ok(sid), None => self.login().await, } } /// Make an authenticated POST request to AMP. Retries once on auth failure. async fn amp_post( &self, module: &str, method: &str, body: &T, ) -> Result { let url = format!("{}/API/{}/{}", self.api_endpoint, module, method); let response = self .http .post(&url) .json(body) .send() .await .with_context(|| format!("AMP API call failed: {module}/{method}"))?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { // Session expired — re-login and retry let sid = self.login().await?; tracing::debug!("AMP session expired, re-authenticated: {}", &sid[..8]); // We need to re-serialize with new session ID — caller must retry. anyhow::bail!("AMP session expired, please retry"); } let value: serde_json::Value = response .json() .await .with_context(|| format!("Failed to parse AMP response for {module}/{method}"))?; Ok(value) } /// Convenience: make a session-only request (no extra params). async fn amp_session_call( &self, module: &str, method: &str, ) -> Result { let sid = self.get_session_id().await?; let body = AmpSessionRequest { session_id: sid }; self.amp_post(module, method, &body).await } } #[async_trait] impl PanelAdapter for AmpAdapter { async fn test_connection(&self) -> Result { match self.login().await { Ok(_) => Ok(true), Err(e) => { tracing::warn!("AMP connection test failed: {e}"); Ok(false) } } } async fn discover_servers(&self) -> Result> { let value = self.amp_session_call("ADSModule", "GetInstances").await?; let instances: Vec = serde_json::from_value(value.get("result").cloned().unwrap_or_default()) .unwrap_or_default(); Ok(instances .into_iter() .map(|i| DiscoveredServer { panel_server_id: i.instance_id, name: i.instance_name, ip: i.ip, port: i.port, game_port: None, status: if i.running { "running".to_string() } else { "stopped".to_string() }, }) .collect()) } async fn get_server_status(&self, _server_id: &str) -> Result { let value = self.amp_session_call("Core", "GetStatus").await?; let resp: AmpGenericResponse = serde_json::from_value(value).context("Failed to parse AMP status")?; let status = resp.status.unwrap_or(AmpStatusData { state: 0, uptime: None, metrics: None, }); // AMP state codes: 0=Stopped, 5=PreStart, 10=Configuring, 20=Starting, 30=Ready, 40=Restarting let is_running = status.state >= 30; let (cpu, mem) = status.metrics.map_or((None, None), |m| { ( m.cpu_usage.map(|v| v.raw_value), m.memory_usage.map(|v| v.raw_value as i64), ) }); // Parse uptime string "HH:MM:SS" to seconds let uptime_secs = status.uptime.and_then(|u| { let parts: Vec<&str> = u.split(':').collect(); if parts.len() == 3 { let h: i64 = parts[0].parse().ok()?; let m: i64 = parts[1].parse().ok()?; let s: i64 = parts[2].parse().ok()?; Some(h * 3600 + m * 60 + s) } else { None } }); Ok(ServerStatus { is_running, cpu_usage: cpu, memory_usage_mb: mem, uptime_seconds: uptime_secs, }) } async fn start_server(&self, _server_id: &str) -> Result<()> { self.amp_session_call("Core", "Start").await?; Ok(()) } async fn stop_server(&self, _server_id: &str) -> Result<()> { self.amp_session_call("Core", "Stop").await?; Ok(()) } async fn restart_server(&self, _server_id: &str) -> Result<()> { self.amp_session_call("Core", "Restart").await?; Ok(()) } async fn send_command(&self, _server_id: &str, command: &str) -> Result { let sid = self.get_session_id().await?; let body = AmpConsoleRequest { session_id: sid, message: command.to_string(), }; let value = self.amp_post("Core", "SendConsoleMessage", &body).await?; Ok(value .get("result") .and_then(|v| v.as_str()) .unwrap_or("OK") .to_string()) } async fn get_file(&self, _server_id: &str, path: &str) -> Result> { let sid = self.get_session_id().await?; let body = AmpFileRequest { session_id: sid, directory: None, filename: Some(path.to_string()), data: None, }; let value = self .amp_post("FileManagerPlugin", "GetFileContents", &body) .await?; let contents = value .get("result") .and_then(|v| v.as_str()) .unwrap_or_default(); Ok(contents.as_bytes().to_vec()) } async fn put_file(&self, _server_id: &str, path: &str, data: &[u8]) -> Result<()> { let sid = self.get_session_id().await?; let content = String::from_utf8_lossy(data).to_string(); let body = AmpFileRequest { session_id: sid, directory: None, filename: Some(path.to_string()), data: Some(content), }; self.amp_post("FileManagerPlugin", "WriteFileChunk", &body) .await?; Ok(()) } async fn delete_file(&self, _server_id: &str, path: &str) -> Result<()> { let sid = self.get_session_id().await?; let body = AmpFileRequest { session_id: sid, directory: None, filename: Some(path.to_string()), data: None, }; self.amp_post("FileManagerPlugin", "TrashFile", &body) .await?; Ok(()) } async fn list_files(&self, _server_id: &str, path: &str) -> Result> { let sid = self.get_session_id().await?; let body = AmpFileRequest { session_id: sid, directory: Some(path.to_string()), filename: None, data: None, }; let value = self .amp_post("FileManagerPlugin", "GetDirectoryListing", &body) .await?; let entries: Vec = serde_json::from_value(value.get("result").cloned().unwrap_or_default()) .unwrap_or_default(); Ok(entries .into_iter() .map(|e| FileEntry { name: e.filename.clone(), path: format!("{}/{}", path.trim_end_matches('/'), e.filename), is_directory: e.is_directory, size_bytes: e.size_bytes, modified_at: e.modified, }) .collect()) } async fn trigger_steam_update(&self, _server_id: &str) -> Result<()> { self.amp_session_call("Core", "Update").await?; Ok(()) } }