Files
corrosion-admin-panel/backend/src/services/amp_adapter.rs
Vantz Stockwell 590765fbbc feat: Complete Phase 1 backend services and WebSocket/NATS bridge
Implements all remaining backend infrastructure for Corrosion platform.

Backend Services (5 new):
- license.rs: License validation, activation, check-in with NATS token generation
- map_manager.rs: Map upload/rotation with SHA-256 checksums, circular advancement
- health_checker.rs: Post-wipe verification with retry loop and backoff
- backup_manager.rs: Tar.gz backups with retention policy (last 10), recursive upload
- scheduler.rs: Tokio-cron integration for scheduled wipes with NATS events

WipeEngine Orchestration (wipe_engine.rs):
- execute_wipe(): Master orchestrator managing full lifecycle
- execute_pre_wipe(): Countdown warnings, backups, player kicks
- execute_wipe_actions(): Map/plugin deletion, seed rotation, Steam updates
- execute_post_wipe_verification(): Health checks with restart attempts
- execute_rollback(): Failure recovery with backup restore
- JSONB execution logs, NATS status events, service composition pattern

WebSocket/NATS Bridge (ws.rs):
- JWT authentication via query parameter
- License-scoped NATS subscriptions (corrosion.{license_id}.*)
- Bi-directional: NATS→WebSocket event forwarding, WebSocket→NATS publishing
- Axum 0.8 with ws feature, auto Ping/Pong handling

Panel Adapter Fixes:
- AMP/Pterodactyl/Companion adapters fully wired
- RCON command execution, file operations, Steam update triggers

Fixes:
- Added ws feature to Axum dependency
- Fixed Message::Text() type conversions (String→Utf8Bytes via .into())
- Fixed BackupInfo FromRow derive
- Fixed recursive async with Box::pin pattern
- Fixed async JobScheduler::new() constructor
- Removed manual WebSocket Ping/Pong handler

Compilation: 0 errors, 327 warnings (unused vars/functions)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 12:07:01 -05:00

431 lines
12 KiB
Rust

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<RwLock<Option<String>>>,
}
// -- 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<String>,
#[serde(default)]
result: Option<serde_json::Value>,
}
#[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<String>,
#[serde(rename = "Filename", skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(rename = "Data", skip_serializing_if = "Option::is_none")]
data: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AmpGenericResponse {
#[serde(default)]
#[allow(dead_code)]
success: bool,
#[serde(default, rename = "Status")]
status: Option<AmpStatusData>,
#[serde(default, rename = "result")]
result: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct AmpStatusData {
#[serde(default, rename = "State")]
state: i32,
#[serde(default, rename = "Uptime")]
uptime: Option<String>,
#[serde(default, rename = "Metrics")]
metrics: Option<AmpMetrics>,
}
#[derive(Debug, Deserialize)]
struct AmpMetrics {
#[serde(default, rename = "CPU Usage")]
cpu_usage: Option<AmpMetricValue>,
#[serde(default, rename = "Memory Usage")]
memory_usage: Option<AmpMetricValue>,
}
#[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<String>,
#[serde(default, rename = "Port")]
port: Option<i32>,
#[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<i64>,
#[serde(default, rename = "Modified")]
modified: Option<String>,
}
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<String> {
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<String> {
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<T: Serialize>(
&self,
module: &str,
method: &str,
body: &T,
) -> Result<serde_json::Value> {
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<serde_json::Value> {
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<bool> {
match self.login().await {
Ok(_) => Ok(true),
Err(e) => {
tracing::warn!("AMP connection test failed: {e}");
Ok(false)
}
}
}
async fn discover_servers(&self) -> Result<Vec<DiscoveredServer>> {
let value = self.amp_session_call("ADSModule", "GetInstances").await?;
let instances: Vec<AmpInstance> =
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<ServerStatus> {
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<String> {
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<Vec<u8>> {
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<Vec<FileEntry>> {
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<AmpFileEntry> =
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(())
}
}