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>
431 lines
12 KiB
Rust
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(())
|
|
}
|
|
}
|