From 5c11050eca4f7ecc0493d99e876e081767155938 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 14 Feb 2026 21:41:58 -0500 Subject: [PATCH] =?UTF-8?q?scaffold:=20Backend=20core=20=E2=80=94=20Cargo.?= =?UTF-8?q?toml,=20main.rs,=20config,=20models,=20panel=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Axum server entry point, AppConfig, AppState, ApiError, all model structs (auth, license, server, wipe), and the PanelAdapter trait that abstracts AMP/Pterodactyl/companion connections. Co-Authored-By: Claude Opus 4.6 --- backend/Cargo.toml | 68 ++++++++++++++++ backend/src/config/mod.rs | 79 ++++++++++++++++++ backend/src/main.rs | 85 ++++++++++++++++++++ backend/src/models/auth.rs | 74 +++++++++++++++++ backend/src/models/error.rs | 111 ++++++++++++++++++++++++++ backend/src/models/license.rs | 68 ++++++++++++++++ backend/src/models/mod.rs | 5 ++ backend/src/models/server.rs | 99 +++++++++++++++++++++++ backend/src/models/wipe.rs | 102 +++++++++++++++++++++++ backend/src/services/mod.rs | 16 ++++ backend/src/services/panel_adapter.rs | 78 ++++++++++++++++++ 11 files changed, 785 insertions(+) create mode 100644 backend/Cargo.toml create mode 100644 backend/src/config/mod.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/models/auth.rs create mode 100644 backend/src/models/error.rs create mode 100644 backend/src/models/license.rs create mode 100644 backend/src/models/mod.rs create mode 100644 backend/src/models/server.rs create mode 100644 backend/src/models/wipe.rs create mode 100644 backend/src/services/mod.rs create mode 100644 backend/src/services/panel_adapter.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..d7a6788 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "corrosion-api" +version = "0.1.0" +edition = "2021" +description = "Corrosion — Rust Server Management Platform API" +license = "Proprietary" + +[dependencies] +# Web framework +axum = { version = "0.8", features = ["macros", "multipart"] } +axum-extra = { version = "0.10", features = ["typed-header"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "limit", "fs"] } +hyper = { version = "1", features = ["full"] } + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate"] } + +# Messaging +async-nats = "0.38" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Auth +jsonwebtoken = "9" +argon2 = "0.5" +totp-rs = { version = "5", features = ["qr", "gen_secret"] } + +# Encryption +aes-gcm = "0.10" +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" + +# HTTP client (panel APIs, Cloudflare, Steam, PayPal) +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } + +# Email +lettre = { version = "0.11", features = ["tokio1-rustls-tls"] } + +# Scheduling +tokio-cron-scheduler = "0.13" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# UUID +uuid = { version = "1", features = ["v4", "serde"] } + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# Config +dotenvy = "0.15" + +# Error handling +thiserror = "2" +anyhow = "1" + +# Async trait support +async-trait = "0.1" diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs new file mode 100644 index 0000000..7eefa30 --- /dev/null +++ b/backend/src/config/mod.rs @@ -0,0 +1,79 @@ +use anyhow::{Context, Result}; + +/// Application configuration loaded from environment variables +#[derive(Clone)] +pub struct AppConfig { + // Database + pub database_url: String, + pub database_max_connections: u32, + + // NATS + pub nats_url: String, + + // Auth + pub jwt_secret: String, + pub jwt_access_expiry_seconds: i64, + pub jwt_refresh_expiry_seconds: i64, + + // Encryption + pub encryption_key: String, + + // Cloudflare + pub cloudflare_api_token: String, + pub cloudflare_zone_id: String, + pub base_domain: String, + + // Steam + pub steam_api_key: String, + + // Email (SMTP) + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_username: String, + pub smtp_password: String, + pub smtp_from: String, + + // Server + pub api_port: u16, + pub frontend_url: String, +} + +impl AppConfig { + pub fn from_env() -> Result { + Ok(Self { + database_url: env_required("DATABASE_URL")?, + database_max_connections: env_or_default("DATABASE_MAX_CONNECTIONS", 20), + nats_url: env_or_default_str("NATS_URL", "nats://localhost:4222"), + jwt_secret: env_required("JWT_SECRET")?, + jwt_access_expiry_seconds: env_or_default("JWT_ACCESS_EXPIRY_SECONDS", 900), + jwt_refresh_expiry_seconds: env_or_default("JWT_REFRESH_EXPIRY_SECONDS", 604800), + encryption_key: env_required("ENCRYPTION_KEY")?, + cloudflare_api_token: env_or_default_str("CLOUDFLARE_API_TOKEN", ""), + cloudflare_zone_id: env_or_default_str("CLOUDFLARE_ZONE_ID", ""), + base_domain: env_or_default_str("BASE_DOMAIN", "corrosionmgmt.com"), + steam_api_key: env_or_default_str("STEAM_API_KEY", ""), + smtp_host: env_or_default_str("SMTP_HOST", "localhost"), + smtp_port: env_or_default("SMTP_PORT", 587), + smtp_username: env_or_default_str("SMTP_USERNAME", ""), + smtp_password: env_or_default_str("SMTP_PASSWORD", ""), + smtp_from: env_or_default_str("SMTP_FROM", "noreply@corrosionmgmt.com"), + api_port: env_or_default("API_PORT", 3000), + frontend_url: env_or_default_str("FRONTEND_URL", "http://localhost:5174"), + }) + } +} + +fn env_required(key: &str) -> Result { + std::env::var(key).with_context(|| format!("Missing required env var: {key}")) +} + +fn env_or_default(key: &str, default: T) -> T { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn env_or_default_str(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..bb932b7 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use axum::Router; +use sqlx::postgres::PgPoolOptions; +use tokio::net::TcpListener; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod api; +mod config; +mod db; +mod middleware; +mod models; +mod services; + +use config::AppConfig; + +/// Shared application state available to all handlers +pub struct AppState { + pub db: sqlx::PgPool, + pub nats: async_nats::Client, + pub config: AppConfig, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load environment variables + dotenvy::dotenv().ok(); + + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + "corrosion_api=debug,tower_http=debug,axum=trace".into() + })) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config = AppConfig::from_env()?; + + // Database connection pool + let db = PgPoolOptions::new() + .max_connections(config.database_max_connections) + .connect(&config.database_url) + .await?; + + tracing::info!("Connected to PostgreSQL"); + + // Run migrations + sqlx::migrate!("./migrations").run(&db).await?; + tracing::info!("Database migrations applied"); + + // NATS connection + let nats = async_nats::connect(&config.nats_url).await?; + tracing::info!("Connected to NATS at {}", config.nats_url); + + let state = Arc::new(AppState { db, nats, config }); + + // Build router + let app = Router::new() + .nest("/api/auth", api::auth::router()) + .nest("/api/servers", api::servers::router()) + .nest("/api/wipes", api::wipes::router()) + .nest("/api/maps", api::maps::router()) + .nest("/api/plugins", api::plugins::router()) + .nest("/api/panels", api::panels::router()) + .nest("/api/schedules", api::schedules::router()) + .nest("/api/logs", api::logs::router()) + .nest("/api/public", api::public::router()) + .nest("/api/team", api::team::router()) + .nest("/api/notifications", api::notifications::router()) + .nest("/api/license", api::license::router()) + .nest("/api/store", api::store::router()) + .layer(CorsLayer::permissive()) // TODO: Restrict in production + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let bind_addr = "0.0.0.0:3000"; + let listener = TcpListener::bind(bind_addr).await?; + tracing::info!("Corrosion API listening on {}", bind_addr); + + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/backend/src/models/auth.rs b/backend/src/models/auth.rs new file mode 100644 index 0000000..bb4107e --- /dev/null +++ b/backend/src/models/auth.rs @@ -0,0 +1,74 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// User record from the database +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct User { + pub id: Uuid, + pub email: String, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + #[serde(skip_serializing)] + pub totp_secret: Option, + pub totp_enabled: bool, + #[serde(skip_serializing)] + pub backup_codes: Option>, + pub email_verified: bool, + pub created_at: DateTime, + pub last_login_at: Option>, +} + +/// JWT claims +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, // user_id + pub email: String, + pub license_id: Option, + pub role: Option, + pub exp: i64, + pub iat: i64, + pub token_type: String, // "access" or "refresh" +} + +/// Login request +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +/// Login response +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub access_token: String, + pub refresh_token: String, + pub requires_totp: bool, + pub user: UserPublic, +} + +/// Public user info (no sensitive fields) +#[derive(Debug, Serialize)] +pub struct UserPublic { + pub id: Uuid, + pub email: String, + pub username: String, + pub totp_enabled: bool, + pub email_verified: bool, +} + +/// Registration request +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub username: String, + pub password: String, + pub license_key: String, +} + +/// TOTP verification request +#[derive(Debug, Deserialize)] +pub struct TotpRequest { + pub code: String, +} diff --git a/backend/src/models/error.rs b/backend/src/models/error.rs new file mode 100644 index 0000000..b94a532 --- /dev/null +++ b/backend/src/models/error.rs @@ -0,0 +1,111 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +/// Unified API error type +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("Not found: {0}")] + NotFound(String), + + #[error("Bad request: {0}")] + BadRequest(String), + + #[error("Unauthorized")] + Unauthorized, + + #[error("Forbidden")] + Forbidden, + + #[error("Conflict: {0}")] + Conflict(String), + + #[error("License invalid or expired")] + LicenseInvalid, + + #[error("Module not enabled: {0}")] + ModuleNotEnabled(String), + + #[error("Rate limited")] + RateLimited, + + #[error("Internal error: {0}")] + Internal(String), + + #[error(transparent)] + Database(#[from] sqlx::Error), + + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +#[derive(Serialize)] +struct ErrorResponse { + error: String, + message: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, error_type, message) = match &self { + ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()), + ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()), + ApiError::Unauthorized => { + (StatusCode::UNAUTHORIZED, "unauthorized", "Unauthorized".to_string()) + } + ApiError::Forbidden => { + (StatusCode::FORBIDDEN, "forbidden", "Forbidden".to_string()) + } + ApiError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()), + ApiError::LicenseInvalid => ( + StatusCode::PAYMENT_REQUIRED, + "license_invalid", + "License invalid or expired".to_string(), + ), + ApiError::ModuleNotEnabled(module) => ( + StatusCode::PAYMENT_REQUIRED, + "module_not_enabled", + format!("Module not enabled: {module}"), + ), + ApiError::RateLimited => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + "Too many requests".to_string(), + ), + ApiError::Internal(msg) => { + tracing::error!("Internal error: {msg}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Internal server error".to_string(), + ) + } + ApiError::Database(e) => { + tracing::error!("Database error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Internal server error".to_string(), + ) + } + ApiError::Anyhow(e) => { + tracing::error!("Unexpected error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Internal server error".to_string(), + ) + } + }; + + let body = ErrorResponse { + error: error_type.to_string(), + message, + }; + + (status, axum::Json(body)).into_response() + } +} + +/// Result alias for API handlers +pub type ApiResult = Result; diff --git a/backend/src/models/license.rs b/backend/src/models/license.rs new file mode 100644 index 0000000..cfadfb0 --- /dev/null +++ b/backend/src/models/license.rs @@ -0,0 +1,68 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// License record +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct License { + pub id: Uuid, + pub license_key: String, + pub status: String, + pub owner_user_id: Uuid, + pub server_name: Option, + pub subdomain: Option, + pub custom_domain: Option, + pub modules_enabled: Option>, + pub webstore_active: bool, + pub webstore_subscription_id: Option, + pub created_at: DateTime, + pub expires_at: Option>, +} + +/// Team member with role info +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct TeamMember { + pub id: Uuid, + pub license_id: Uuid, + pub user_id: Uuid, + pub role_id: Uuid, + pub invited_by: Uuid, + pub accepted_at: Option>, + pub created_at: DateTime, +} + +/// Role with permissions +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Role { + pub id: Uuid, + pub license_id: Option, + pub role_name: String, + pub is_system_default: bool, + pub is_cloned_from: Option, + pub permissions: serde_json::Value, + pub created_at: DateTime, +} + +/// License activation request +#[derive(Debug, Deserialize)] +pub struct ActivateLicenseRequest { + pub license_key: String, + pub server_name: String, + pub subdomain: String, +} + +/// License check-in from plugin +#[derive(Debug, Deserialize)] +pub struct LicenseCheckIn { + pub license_key: String, + pub plugin_version: String, +} + +/// License check-in response +#[derive(Debug, Serialize)] +pub struct LicenseCheckInResponse { + pub valid: bool, + pub status: String, + pub modules_enabled: Vec, + pub nats_token: String, +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..6f98205 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod error; +pub mod license; +pub mod server; +pub mod wipe; diff --git a/backend/src/models/server.rs b/backend/src/models/server.rs new file mode 100644 index 0000000..5aa01ec --- /dev/null +++ b/backend/src/models/server.rs @@ -0,0 +1,99 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Server connection details +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct ServerConnection { + pub id: Uuid, + pub license_id: Uuid, + pub connection_type: String, + pub panel_api_endpoint: Option, + #[serde(skip_serializing)] + pub panel_api_key_encrypted: Option, + pub panel_server_identifier: Option, + #[serde(skip_serializing)] + pub companion_agent_token: Option, + pub companion_last_seen: Option>, + pub plugin_last_seen: Option>, + pub server_ip: Option, + pub server_port: Option, + pub game_port: Option, + pub connection_status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Server configuration +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct ServerConfig { + pub id: Uuid, + pub license_id: Uuid, + pub server_name: String, + pub max_players: Option, + pub world_size: Option, + pub current_seed: Option, + pub current_map_id: Option, + pub server_description: Option, + pub server_url: Option, + pub server_header_image: Option, + pub tags: Option>, + pub auto_restart_enabled: bool, + pub auto_restart_cron: Option, + pub auto_restart_timezone: Option, + pub crash_recovery_enabled: bool, + pub crash_recovery_max_attempts: i32, + pub crash_recovery_cooldown_minutes: i32, + pub force_wipe_eligible: bool, + pub auto_update_on_force_wipe: bool, + pub config_overrides: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Real-time server stats from plugin +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerStats { + pub license_id: Uuid, + pub player_count: i32, + pub max_players: i32, + pub fps: f64, + pub entity_count: i32, + pub uptime_seconds: i32, + pub memory_usage_mb: i32, + pub recorded_at: DateTime, +} + +/// Plugin registry entry +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct PluginEntry { + pub id: Uuid, + pub license_id: Uuid, + pub plugin_name: String, + pub plugin_version: Option, + pub source: String, + pub umod_slug: Option, + pub is_installed: bool, + pub is_loaded: bool, + pub config_json: Option, + pub data_path: Option, + pub wipe_on_map: bool, + pub wipe_on_bp: bool, + pub wipe_on_full: bool, + pub never_wipe: bool, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// Game admin (in-game SteamID-based admin) +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct GameAdmin { + pub id: Uuid, + pub license_id: Uuid, + pub steam_id: String, + pub display_name: String, + pub admin_level: String, + pub permissions: Option, + pub added_by: Uuid, + pub created_at: DateTime, +} diff --git a/backend/src/models/wipe.rs b/backend/src/models/wipe.rs new file mode 100644 index 0000000..48c46f7 --- /dev/null +++ b/backend/src/models/wipe.rs @@ -0,0 +1,102 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Wipe profile with pre/post configuration +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct WipeProfile { + pub id: Uuid, + pub license_id: Uuid, + pub profile_name: String, + pub description: Option, + pub pre_wipe_config: serde_json::Value, + pub post_wipe_config: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Wipe schedule +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct WipeSchedule { + pub id: Uuid, + pub license_id: Uuid, + pub wipe_profile_id: Uuid, + pub schedule_name: String, + pub wipe_type: String, + pub cron_expression: String, + pub timezone: String, + pub wipe_blueprints: bool, + pub is_active: bool, + pub next_scheduled_run: Option>, + pub created_at: DateTime, +} + +/// Wipe execution history +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct WipeHistory { + pub id: Uuid, + pub license_id: Uuid, + pub wipe_schedule_id: Option, + pub wipe_profile_id: Uuid, + pub wipe_type: String, + pub trigger_type: String, + pub status: String, + pub started_at: Option>, + pub completed_at: Option>, + pub map_used: Option, + pub plugins_wiped: Option>, + pub plugins_preserved: Option>, + pub backup_reference: Option, + pub error_message: Option, + pub execution_log: Option, + pub created_at: DateTime, +} + +/// Map in the library +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct MapEntry { + pub id: Uuid, + pub license_id: Uuid, + pub filename: String, + pub display_name: String, + pub storage_path: String, + pub file_size_bytes: i64, + pub map_type: String, + pub seed: Option, + pub world_size: Option, + pub thumbnail_path: Option, + pub checksum: String, + pub uploaded_at: DateTime, +} + +/// Pre-wipe config structure (typed version of JSONB) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreWipeConfig { + pub enabled: bool, + pub backup_before_wipe: bool, + pub countdown_warnings: Vec, + pub countdown_unit: String, + pub countdown_messages: Option>, + pub kick_players_before_wipe: bool, + pub kick_message: String, + pub run_final_save: bool, + pub discord_pre_announce: bool, + pub pushbullet_notify: bool, + pub custom_commands_before: Vec, +} + +/// Post-wipe config structure (typed version of JSONB) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostWipeConfig { + pub enabled: bool, + pub verify_server_started: bool, + pub verify_correct_map: bool, + pub verify_plugins_loaded: bool, + pub verify_player_slots_open: bool, + pub max_restart_attempts: i32, + pub health_check_timeout_seconds: i32, + pub discord_post_announce: bool, + pub pushbullet_notify: bool, + pub rollback_on_failure: bool, + pub post_wipe_commands: Vec, +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..8512f45 --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,16 @@ +pub mod panel_adapter; +pub mod amp_adapter; +pub mod pterodactyl_adapter; +pub mod companion_adapter; +pub mod wipe_engine; +pub mod scheduler; +pub mod steam_watcher; +pub mod map_manager; +pub mod backup_manager; +pub mod health_checker; +pub mod discord; +pub mod pushbullet; +pub mod nats_bridge; +pub mod license; +pub mod cloudflare; +pub mod encryption; diff --git a/backend/src/services/panel_adapter.rs b/backend/src/services/panel_adapter.rs new file mode 100644 index 0000000..ec7327d --- /dev/null +++ b/backend/src/services/panel_adapter.rs @@ -0,0 +1,78 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +/// Discovered server from a panel's auto-discovery +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveredServer { + pub panel_server_id: String, + pub name: String, + pub ip: Option, + pub port: Option, + pub game_port: Option, + pub status: String, +} + +/// Server status from panel query +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerStatus { + pub is_running: bool, + pub cpu_usage: Option, + pub memory_usage_mb: Option, + pub uptime_seconds: Option, +} + +/// File entry from directory listing +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEntry { + pub name: String, + pub path: String, + pub is_directory: bool, + pub size_bytes: Option, + pub modified_at: Option, +} + +/// The core abstraction for server hosting panels. +/// +/// Each panel type (AMP, Pterodactyl, Companion Agent) implements this trait. +/// The wipe engine, scheduler, and all server management operations call +/// through this interface — they never know or care which panel is backing +/// the server. +#[async_trait] +pub trait PanelAdapter: Send + Sync { + /// Test that the panel connection is working + async fn test_connection(&self) -> Result; + + /// Discover all game servers managed by this panel + async fn discover_servers(&self) -> Result>; + + /// Get the current status of a server + async fn get_server_status(&self, server_id: &str) -> Result; + + /// Start a stopped server + async fn start_server(&self, server_id: &str) -> Result<()>; + + /// Stop a running server + async fn stop_server(&self, server_id: &str) -> Result<()>; + + /// Restart a server (stop + start) + async fn restart_server(&self, server_id: &str) -> Result<()>; + + /// Send a console command to the server + async fn send_command(&self, server_id: &str, command: &str) -> Result; + + /// Read a file from the server filesystem + async fn get_file(&self, server_id: &str, path: &str) -> Result>; + + /// Write a file to the server filesystem + async fn put_file(&self, server_id: &str, path: &str, data: &[u8]) -> Result<()>; + + /// Delete a file from the server filesystem + async fn delete_file(&self, server_id: &str, path: &str) -> Result<()>; + + /// List files in a directory on the server + async fn list_files(&self, server_id: &str, path: &str) -> Result>; + + /// Trigger a SteamCMD update on the server + async fn trigger_steam_update(&self, server_id: &str) -> Result<()>; +}