diff --git a/backend/src/services/amp_adapter.rs b/backend/src/services/amp_adapter.rs new file mode 100644 index 0000000..ffea9e6 --- /dev/null +++ b/backend/src/services/amp_adapter.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use async_trait::async_trait; + +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. Implements the unified PanelAdapter trait +/// so the wipe engine and other services remain panel-agnostic. +pub struct AmpAdapter { + pub api_endpoint: String, + pub api_key: String, +} + +impl AmpAdapter { + pub fn new(api_endpoint: String, api_key: String) -> Self { + Self { + api_endpoint, + api_key, + } + } +} + +#[async_trait] +impl PanelAdapter for AmpAdapter { + async fn test_connection(&self) -> Result { + // TODO: POST to AMP API /API/Core/Login, verify session token returned + todo!() + } + + async fn discover_servers(&self) -> Result> { + // TODO: GET instances from AMP API, map to DiscoveredServer + todo!() + } + + async fn get_server_status(&self, _server_id: &str) -> Result { + // TODO: Query AMP instance status endpoint + todo!() + } + + async fn start_server(&self, _server_id: &str) -> Result<()> { + // TODO: POST to AMP API /API/Core/Start + todo!() + } + + async fn stop_server(&self, _server_id: &str) -> Result<()> { + // TODO: POST to AMP API /API/Core/Stop + todo!() + } + + async fn restart_server(&self, _server_id: &str) -> Result<()> { + // TODO: POST to AMP API /API/Core/Restart + todo!() + } + + async fn send_command(&self, _server_id: &str, _command: &str) -> Result { + // TODO: POST to AMP API /API/Core/SendConsoleMessage + todo!() + } + + async fn get_file(&self, _server_id: &str, _path: &str) -> Result> { + // TODO: GET file contents via AMP file manager API + todo!() + } + + async fn put_file(&self, _server_id: &str, _path: &str, _data: &[u8]) -> Result<()> { + // TODO: Upload file via AMP file manager API + todo!() + } + + async fn delete_file(&self, _server_id: &str, _path: &str) -> Result<()> { + // TODO: DELETE file via AMP file manager API + todo!() + } + + async fn list_files(&self, _server_id: &str, _path: &str) -> Result> { + // TODO: GET directory listing from AMP file manager API + todo!() + } + + async fn trigger_steam_update(&self, _server_id: &str) -> Result<()> { + // TODO: POST to AMP API /API/Core/Update to trigger SteamCMD + todo!() + } +} diff --git a/backend/src/services/backup_manager.rs b/backend/src/services/backup_manager.rs new file mode 100644 index 0000000..c1bf8c8 --- /dev/null +++ b/backend/src/services/backup_manager.rs @@ -0,0 +1,78 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +/// Metadata for a stored backup. +#[derive(Debug, Clone, Serialize)] +pub struct BackupInfo { + pub id: Uuid, + pub license_id: Uuid, + pub wipe_history_id: Option, + pub storage_path: String, + pub size_bytes: i64, + pub created_at: DateTime, +} + +/// Pre-wipe backup and rollback management. +/// +/// Creates snapshots of server state (map, save data, plugin data, +/// config files) before a wipe executes. Backups are used by the +/// wipe engine's rollback mechanism if post-wipe verification fails. +pub struct BackupManager { + // TODO: Add fields: + // - db: sqlx::PgPool + // - storage_base_path: String +} + +impl BackupManager { + /// Create a pre-wipe backup of the server's current state. + /// + /// Captures: map/save files, plugin data directories, server.cfg, + /// and any other files configured for backup in the wipe profile. + /// Returns a backup reference ID stored in wipe_history. + pub async fn create_backup( + &self, + _license_id: Uuid, + _wipe_history_id: Uuid, + ) -> Result { + // TODO: Resolve PanelAdapter for the server + // TODO: Download save files (map, sav, player data) via adapter + // TODO: Download plugin data directories via adapter + // TODO: Download server.cfg via adapter + // TODO: Bundle into archive (tar.gz) + // TODO: Write archive to backup storage + // TODO: Insert backup record in DB + // TODO: Return backup reference string + todo!() + } + + /// Restore a previously created backup to the server. + /// + /// Used during rollback when post-wipe verification fails. + pub async fn restore_backup(&self, _backup_reference: &str) -> Result<()> { + // TODO: Load backup record from DB by reference + // TODO: Read backup archive from storage + // TODO: Extract archive contents + // TODO: Upload files back to server via PanelAdapter + // TODO: Log restoration details + todo!() + } + + /// List all backups for a license, ordered by creation date (newest first). + pub async fn list_backups(&self, _license_id: Uuid) -> Result> { + // TODO: Query backup records from DB for this license + // TODO: Return sorted list + todo!() + } + + /// Clean up old backups beyond the configured retention period/count. + pub async fn cleanup_old_backups(&self, _license_id: Uuid) -> Result { + // TODO: Load retention config (max count or max age) + // TODO: Query backups older than retention threshold + // TODO: Delete backup files from storage + // TODO: Delete backup records from DB + // TODO: Return count of deleted backups + todo!() + } +} diff --git a/backend/src/services/cloudflare.rs b/backend/src/services/cloudflare.rs new file mode 100644 index 0000000..81af7f0 --- /dev/null +++ b/backend/src/services/cloudflare.rs @@ -0,0 +1,46 @@ +use anyhow::Result; + +/// Cloudflare DNS management service. +/// +/// Manages DNS records for tenant subdomains (e.g., myserver.corrosion.gg) +/// and custom domains. Uses the Cloudflare API v4 to create and manage +/// CNAME records pointing to the platform's load balancer. +pub struct CloudflareService { + // TODO: Add fields: + // - api_token: String + // - zone_id: String (Cloudflare zone for the base domain) + // - base_domain: String (e.g., "corrosion.gg") +} + +impl CloudflareService { + /// Create a subdomain CNAME record for a new license. + /// + /// Creates: {subdomain}.{base_domain} -> platform LB + pub async fn create_subdomain(&self, _subdomain: &str) -> Result { + // TODO: POST /zones/{zone_id}/dns_records + // TODO: Type: CNAME, Name: {subdomain}, Content: platform target + // TODO: Proxied: true (orange cloud) + // TODO: Return the DNS record ID for later management + todo!() + } + + /// Delete a subdomain CNAME record. + /// + /// Called when a license is deactivated or transferred. + pub async fn delete_subdomain(&self, _dns_record_id: &str) -> Result<()> { + // TODO: DELETE /zones/{zone_id}/dns_records/{dns_record_id} + todo!() + } + + /// Add a custom domain with CNAME verification. + /// + /// Returns the CNAME target that the customer needs to configure + /// on their own DNS provider. + pub async fn add_custom_domain(&self, _custom_domain: &str) -> Result { + // TODO: Generate verification CNAME target + // TODO: Create verification DNS record in Cloudflare + // TODO: Optionally configure SSL for custom hostname via Cloudflare + // TODO: Return the CNAME target for customer DNS setup + todo!() + } +} diff --git a/backend/src/services/companion_adapter.rs b/backend/src/services/companion_adapter.rs new file mode 100644 index 0000000..71d865a --- /dev/null +++ b/backend/src/services/companion_adapter.rs @@ -0,0 +1,92 @@ +use anyhow::Result; +use async_trait::async_trait; +use uuid::Uuid; + +use super::panel_adapter::{DiscoveredServer, FileEntry, PanelAdapter, ServerStatus}; + +/// Companion Agent adapter. +/// +/// Unlike AMP and Pterodactyl which use REST APIs, the Companion Agent +/// runs alongside the game server and communicates via NATS JetStream. +/// Commands are published to the agent's subject namespace and responses +/// are received on reply subjects. This enables direct server management +/// without a panel intermediary. +pub struct CompanionAdapter { + pub nats: async_nats::Client, + pub license_id: Uuid, +} + +impl CompanionAdapter { + pub fn new(nats: async_nats::Client, license_id: Uuid) -> Self { + Self { nats, license_id } + } +} + +#[async_trait] +impl PanelAdapter for CompanionAdapter { + async fn test_connection(&self) -> Result { + // TODO: Publish heartbeat request to corrosion.{license_id}.agent.ping, + // await reply within timeout + todo!() + } + + async fn discover_servers(&self) -> Result> { + // TODO: Request server info from agent via NATS request/reply. + // Companion manages exactly one server, so this returns a single-element vec. + todo!() + } + + async fn get_server_status(&self, _server_id: &str) -> Result { + // TODO: NATS request to corrosion.{license_id}.agent.status + todo!() + } + + async fn start_server(&self, _server_id: &str) -> Result<()> { + // TODO: NATS request to corrosion.{license_id}.agent.start + todo!() + } + + async fn stop_server(&self, _server_id: &str) -> Result<()> { + // TODO: NATS request to corrosion.{license_id}.agent.stop + todo!() + } + + async fn restart_server(&self, _server_id: &str) -> Result<()> { + // TODO: NATS request to corrosion.{license_id}.agent.restart + todo!() + } + + async fn send_command(&self, _server_id: &str, _command: &str) -> Result { + // TODO: NATS request to corrosion.{license_id}.agent.command + // with command payload, await RCON response + todo!() + } + + async fn get_file(&self, _server_id: &str, _path: &str) -> Result> { + // TODO: NATS request to corrosion.{license_id}.agent.file.get + // Agent reads local file and returns contents + todo!() + } + + async fn put_file(&self, _server_id: &str, _path: &str, _data: &[u8]) -> Result<()> { + // TODO: NATS publish to corrosion.{license_id}.agent.file.put + // May need chunking for large files + todo!() + } + + async fn delete_file(&self, _server_id: &str, _path: &str) -> Result<()> { + // TODO: NATS request to corrosion.{license_id}.agent.file.delete + todo!() + } + + async fn list_files(&self, _server_id: &str, _path: &str) -> Result> { + // TODO: NATS request to corrosion.{license_id}.agent.file.list + todo!() + } + + async fn trigger_steam_update(&self, _server_id: &str) -> Result<()> { + // TODO: NATS request to corrosion.{license_id}.agent.update + // Agent runs SteamCMD locally + todo!() + } +} diff --git a/backend/src/services/discord.rs b/backend/src/services/discord.rs new file mode 100644 index 0000000..2748785 --- /dev/null +++ b/backend/src/services/discord.rs @@ -0,0 +1,87 @@ +use anyhow::Result; +use serde::Serialize; + +/// Discord webhook embed payload. +#[derive(Debug, Clone, Serialize)] +pub struct DiscordEmbed { + pub title: String, + pub description: String, + pub color: u32, + pub fields: Vec, + pub timestamp: Option, +} + +/// Field within a Discord embed. +#[derive(Debug, Clone, Serialize)] +pub struct DiscordEmbedField { + pub name: String, + pub value: String, + pub inline: bool, +} + +/// Discord webhook notification service. +/// +/// Sends rich embed messages to Discord channels via webhook URLs. +/// Used for wipe announcements, completion notifications, failure +/// alerts, and crash recovery notifications. +pub struct DiscordNotifier { + // TODO: Add fields: + // - webhook_url: String + // - server_name: String (for embed context) +} + +impl DiscordNotifier { + /// Send a generic notification with a custom embed. + pub async fn send_notification(&self, _embed: DiscordEmbed) -> Result<()> { + // TODO: POST to webhook_url with JSON payload { embeds: [embed] } + // TODO: Handle rate limiting (429 responses with Retry-After) + todo!() + } + + /// Send a wipe-starting announcement. + pub async fn send_wipe_start(&self, _wipe_type: &str, _eta_minutes: u32) -> Result<()> { + // TODO: Build embed with orange color, wipe type, countdown info + // TODO: Include server name, expected completion time + // TODO: Call send_notification + todo!() + } + + /// Send a wipe-completed announcement. + pub async fn send_wipe_complete( + &self, + _wipe_type: &str, + _duration_seconds: u64, + _new_map: Option<&str>, + _new_seed: Option, + ) -> Result<()> { + // TODO: Build embed with green color, wipe summary + // TODO: Include new map/seed info, connect URL + // TODO: Call send_notification + todo!() + } + + /// Send a wipe-failed alert. + pub async fn send_wipe_failed( + &self, + _wipe_type: &str, + _error: &str, + _rolled_back: bool, + ) -> Result<()> { + // TODO: Build embed with red color, failure details + // TODO: Include rollback status, error message + // TODO: Call send_notification + todo!() + } + + /// Send a crash detection/recovery alert. + pub async fn send_crash_alert( + &self, + _crash_count: u32, + _auto_recovered: bool, + ) -> Result<()> { + // TODO: Build embed with red/yellow color based on severity + // TODO: Include crash count, recovery status, uptime before crash + // TODO: Call send_notification + todo!() + } +} diff --git a/backend/src/services/encryption.rs b/backend/src/services/encryption.rs new file mode 100644 index 0000000..197e711 --- /dev/null +++ b/backend/src/services/encryption.rs @@ -0,0 +1,46 @@ +use anyhow::Result; + +/// AES-256-GCM encryption utilities. +/// +/// Used to encrypt sensitive data at rest: panel API keys, companion +/// agent tokens, webhook URLs, and other secrets stored in the database. +/// Keys are derived from the application's ENCRYPTION_KEY environment +/// variable. + +/// Encrypt a plaintext string using AES-256-GCM. +/// +/// Returns a base64-encoded string containing the nonce + ciphertext. +/// The nonce is randomly generated and prepended to the ciphertext +/// before encoding. +pub fn encrypt(_plaintext: &str, _key: &str) -> Result { + // TODO: Derive 256-bit key from key string (SHA-256 or HKDF) + // TODO: Generate random 96-bit nonce + // TODO: Encrypt plaintext with AES-256-GCM + // TODO: Prepend nonce to ciphertext + // TODO: Base64-encode the result + // TODO: Return encoded string + todo!() +} + +/// Decrypt an AES-256-GCM encrypted string. +/// +/// Expects a base64-encoded string containing nonce + ciphertext +/// (as produced by `encrypt`). +pub fn decrypt(_ciphertext: &str, _key: &str) -> Result { + // TODO: Base64-decode the input + // TODO: Split nonce (first 12 bytes) from ciphertext + // TODO: Derive 256-bit key from key string (same as encrypt) + // TODO: Decrypt with AES-256-GCM + // TODO: Return plaintext string + todo!() +} + +/// Generate a cryptographically random token string. +/// +/// Returns a hex-encoded random string of the specified byte length. +/// Used for generating API keys, agent tokens, and session secrets. +pub fn generate_token(_length: usize) -> String { + // TODO: Generate `length` random bytes using rand/OsRng + // TODO: Hex-encode and return + todo!() +} diff --git a/backend/src/services/health_checker.rs b/backend/src/services/health_checker.rs new file mode 100644 index 0000000..73d0849 --- /dev/null +++ b/backend/src/services/health_checker.rs @@ -0,0 +1,63 @@ +use anyhow::Result; + +/// Post-wipe server health verification. +/// +/// Called by the wipe engine after a wipe completes to verify the server +/// came back up correctly. Checks are configurable via PostWipeConfig +/// and failures can trigger rollback or retry logic. +pub struct HealthChecker { + // TODO: Add fields: + // - db: sqlx::PgPool + // - max_retries: u32 + // - check_timeout: std::time::Duration +} + +impl HealthChecker { + /// Run all configured health checks against the server. + /// + /// Returns Ok(true) if all checks pass, Ok(false) if checks fail + /// after exhausting retries. Returns Err only on unexpected errors. + pub async fn verify_server_health( + &self, + _server_id: &str, + _license_id: uuid::Uuid, + ) -> Result { + // TODO: Resolve PanelAdapter for this server + // TODO: Wait for server to report is_running=true + // TODO: Run each configured check (map, plugins, slots) + // TODO: Retry on failure up to max_retries with backoff + // TODO: Return aggregate pass/fail + todo!() + } + + /// Verify the correct map is loaded on the server. + /// + /// Sends RCON command to query the current level and compares + /// against the expected map from the wipe profile. + pub async fn check_map(&self, _server_id: &str, _expected_map: &str) -> Result { + // TODO: Send "status" or "serverinfo" via RCON + // TODO: Parse response for current level/map name + // TODO: Compare against expected_map + todo!() + } + + /// Verify all required plugins are loaded and responding. + pub async fn check_plugins( + &self, + _server_id: &str, + _expected_plugins: &[String], + ) -> Result { + // TODO: Send "plugins" or "oxide.plugins" via RCON + // TODO: Parse response for loaded plugin list + // TODO: Check all expected_plugins are present + todo!() + } + + /// Verify the server is accepting player connections. + pub async fn check_player_slots(&self, _server_id: &str) -> Result { + // TODO: Query server status via PanelAdapter or RCON + // TODO: Verify max_players > 0 and server is connectable + // TODO: Optionally perform A2S query against game port + todo!() + } +} diff --git a/backend/src/services/license.rs b/backend/src/services/license.rs new file mode 100644 index 0000000..8a18a24 --- /dev/null +++ b/backend/src/services/license.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use uuid::Uuid; + +use crate::models::license::License; + +/// License validation and lifecycle management. +/// +/// Handles license key validation, activation (binding a key to a server), +/// periodic check-ins from the Rust plugin, and license lookups. +/// All license state is stored in PostgreSQL. +pub struct LicenseService { + // TODO: Add fields: + // - db: sqlx::PgPool +} + +impl LicenseService { + /// Validate a license key and return its current status. + /// + /// Checks: key exists, not expired, not revoked, modules enabled. + pub async fn validate_license(&self, _license_key: &str) -> Result> { + // TODO: Query license by key from DB + // TODO: Check status is 'active' + // TODO: Check expires_at is in the future (if set) + // TODO: Return Some(license) if valid, None if not found + todo!() + } + + /// Activate a license key: bind it to a server name and subdomain. + /// + /// Called during initial setup when a user first registers. + pub async fn activate_license( + &self, + _license_key: &str, + _server_name: &str, + _subdomain: &str, + ) -> Result { + // TODO: Validate license key exists and is in 'pending' status + // TODO: Check subdomain availability + // TODO: Update license record: status='active', server_name, subdomain + // TODO: Create default roles for this license + // TODO: Return updated license + todo!() + } + + /// Process a check-in from the Rust server plugin. + /// + /// Updates last-seen timestamp and returns current license state + /// including enabled modules and NATS connection token. + pub async fn check_in( + &self, + _license_key: &str, + _plugin_version: &str, + ) -> Result { + // TODO: Validate license + // TODO: Update plugin_last_seen timestamp on server_connections + // TODO: Generate or refresh NATS auth token for this license + // TODO: Return LicenseCheckInResponse with modules and token + todo!() + } + + /// Look up a license by its UUID. + pub async fn get_license_by_key(&self, _license_id: Uuid) -> Result> { + // TODO: Query license by ID from DB + // TODO: Return if found + todo!() + } +} diff --git a/backend/src/services/map_manager.rs b/backend/src/services/map_manager.rs new file mode 100644 index 0000000..5c575d7 --- /dev/null +++ b/backend/src/services/map_manager.rs @@ -0,0 +1,65 @@ +use anyhow::Result; +use uuid::Uuid; + +/// Map upload, storage, and rotation management. +/// +/// Handles custom map files (.map) for Rust servers. Maps are stored +/// in object storage with metadata in the database. Supports rotation +/// strategies where different maps are used on successive wipes. +pub struct MapManager { + // TODO: Add fields: + // - db: sqlx::PgPool + // - storage_base_path: String (or S3 client for cloud storage) +} + +impl MapManager { + /// Upload a new map file to storage and register it in the database. + pub async fn upload_map( + &self, + _license_id: Uuid, + _display_name: &str, + _filename: &str, + _data: &[u8], + ) -> Result { + // TODO: Compute checksum of map data + // TODO: Write file to storage (local volume or S3) + // TODO: Insert map_entry record in DB + // TODO: Return the new map ID + todo!() + } + + /// Delete a map from storage and the database. + pub async fn delete_map(&self, _map_id: Uuid) -> Result<()> { + // TODO: Load map_entry from DB + // TODO: Delete file from storage + // TODO: Delete DB record + // TODO: Update any wipe profiles referencing this map + todo!() + } + + /// Get the current map rotation for a license (ordered list of map IDs). + pub async fn get_rotation(&self, _license_id: Uuid) -> Result> { + // TODO: Query map rotation config from DB + // TODO: Return ordered list of map IDs in rotation + todo!() + } + + /// Advance the rotation to the next map in sequence. + /// Returns the map ID that should be used for the next wipe. + pub async fn advance_rotation(&self, _license_id: Uuid) -> Result { + // TODO: Get current rotation position from DB + // TODO: Advance to next map (wrap around to first) + // TODO: Update rotation position in DB + // TODO: Return the next map ID + todo!() + } + + /// Generate a time-limited signed URL for downloading a map file. + /// Used by the companion agent or panel adapter to fetch map files. + pub async fn generate_signed_url(&self, _map_id: Uuid) -> Result { + // TODO: Load map_entry from DB + // TODO: Generate signed URL with expiration (e.g., 15 minutes) + // TODO: Return the URL + todo!() + } +} diff --git a/backend/src/services/nats_bridge.rs b/backend/src/services/nats_bridge.rs new file mode 100644 index 0000000..a27c86e --- /dev/null +++ b/backend/src/services/nats_bridge.rs @@ -0,0 +1,81 @@ +use anyhow::Result; + +// -- JetStream stream name constants -- + +/// Stream for wipe lifecycle events (scheduled, started, completed, failed, rolled_back). +pub const STREAM_WIPE_EVENTS: &str = "CORROSION_WIPE"; + +/// Stream for server telemetry (stats, status changes, crash events). +pub const STREAM_SERVER_TELEMETRY: &str = "CORROSION_TELEMETRY"; + +/// Stream for agent commands and responses (start, stop, file ops). +pub const STREAM_AGENT_COMMANDS: &str = "CORROSION_AGENT"; + +/// Stream for Steam update detection events. +pub const STREAM_STEAM_UPDATES: &str = "CORROSION_STEAM"; + +/// Stream for notification delivery (Discord, Pushbullet, email). +pub const STREAM_NOTIFICATIONS: &str = "CORROSION_NOTIFY"; + +/// Stream for license lifecycle events (activated, expired, check-in). +pub const STREAM_LICENSE_EVENTS: &str = "CORROSION_LICENSE"; + +/// NATS JetStream pub/sub bridge. +/// +/// Centralizes all NATS interactions: stream/consumer setup, publishing +/// events, and subscribing to subjects. All services go through the +/// bridge rather than holding their own NATS client references, ensuring +/// consistent subject naming and stream configuration. +pub struct NatsBridge { + pub client: async_nats::Client, +} + +impl NatsBridge { + pub fn new(client: async_nats::Client) -> Self { + Self { client } + } + + /// Publish a message to a NATS subject. + pub async fn publish(&self, _subject: &str, _payload: &[u8]) -> Result<()> { + // TODO: Publish via self.client.publish(subject, payload) + // TODO: Optionally publish to JetStream if subject belongs to a stream + todo!() + } + + /// Subscribe to a NATS subject and return a message stream. + pub async fn subscribe( + &self, + _subject: &str, + ) -> Result { + // TODO: Subscribe via self.client.subscribe(subject) + // TODO: For durable consumers, use JetStream consumer API + todo!() + } + + /// Initialize all JetStream streams and consumers. + /// + /// Called once at application startup. Creates streams if they don't + /// exist, updates configuration if streams already exist. + pub async fn setup_streams(&self) -> Result<()> { + // TODO: Get JetStream context from client + // TODO: Create/update CORROSION_WIPE stream + // subjects: ["corrosion.*.wipe.>"] + // retention: WorkQueue, max_age: 7 days + // TODO: Create/update CORROSION_TELEMETRY stream + // subjects: ["corrosion.*.telemetry.>"] + // retention: Limits, max_age: 24 hours + // TODO: Create/update CORROSION_AGENT stream + // subjects: ["corrosion.*.agent.>"] + // retention: WorkQueue, max_age: 1 hour + // TODO: Create/update CORROSION_STEAM stream + // subjects: ["corrosion.steam.>"] + // retention: Limits, max_age: 30 days + // TODO: Create/update CORROSION_NOTIFY stream + // subjects: ["corrosion.*.notify.>"] + // retention: WorkQueue, max_age: 1 day + // TODO: Create/update CORROSION_LICENSE stream + // subjects: ["corrosion.license.>"] + // retention: Limits, max_age: 90 days + todo!() + } +} diff --git a/backend/src/services/pterodactyl_adapter.rs b/backend/src/services/pterodactyl_adapter.rs new file mode 100644 index 0000000..c636f90 --- /dev/null +++ b/backend/src/services/pterodactyl_adapter.rs @@ -0,0 +1,87 @@ +use anyhow::Result; +use async_trait::async_trait; + +use super::panel_adapter::{DiscoveredServer, FileEntry, PanelAdapter, ServerStatus}; + +/// Pterodactyl panel adapter. +/// +/// Communicates with Pterodactyl (or Pelican) panels via their REST API. +/// Uses Application API keys for server management and Client API keys +/// for console/file operations. Implements the unified PanelAdapter trait. +pub struct PterodactylAdapter { + pub api_endpoint: String, + pub api_key: String, +} + +impl PterodactylAdapter { + pub fn new(api_endpoint: String, api_key: String) -> Self { + Self { + api_endpoint, + api_key, + } + } +} + +#[async_trait] +impl PanelAdapter for PterodactylAdapter { + async fn test_connection(&self) -> Result { + // TODO: GET /api/application/servers to verify API key validity + todo!() + } + + async fn discover_servers(&self) -> Result> { + // TODO: Paginate /api/application/servers, filter for Rust egg, map to DiscoveredServer + todo!() + } + + async fn get_server_status(&self, _server_id: &str) -> Result { + // TODO: GET /api/client/servers/{id}/resources for live stats + todo!() + } + + async fn start_server(&self, _server_id: &str) -> Result<()> { + // TODO: POST /api/client/servers/{id}/power with signal=start + todo!() + } + + async fn stop_server(&self, _server_id: &str) -> Result<()> { + // TODO: POST /api/client/servers/{id}/power with signal=stop + todo!() + } + + async fn restart_server(&self, _server_id: &str) -> Result<()> { + // TODO: POST /api/client/servers/{id}/power with signal=restart + todo!() + } + + async fn send_command(&self, _server_id: &str, _command: &str) -> Result { + // TODO: POST /api/client/servers/{id}/command + todo!() + } + + async fn get_file(&self, _server_id: &str, _path: &str) -> Result> { + // TODO: GET /api/client/servers/{id}/files/contents?file={path} + todo!() + } + + async fn put_file(&self, _server_id: &str, _path: &str, _data: &[u8]) -> Result<()> { + // TODO: POST /api/client/servers/{id}/files/write?file={path} + todo!() + } + + async fn delete_file(&self, _server_id: &str, _path: &str) -> Result<()> { + // TODO: POST /api/client/servers/{id}/files/delete with body + todo!() + } + + async fn list_files(&self, _server_id: &str, _path: &str) -> Result> { + // TODO: GET /api/client/servers/{id}/files/list?directory={path} + todo!() + } + + async fn trigger_steam_update(&self, _server_id: &str) -> Result<()> { + // TODO: Pterodactyl doesn't have native SteamCMD trigger — send console command + // or use startup command reinstall via /api/client/servers/{id}/settings/reinstall + todo!() + } +} diff --git a/backend/src/services/pushbullet.rs b/backend/src/services/pushbullet.rs new file mode 100644 index 0000000..887a3e9 --- /dev/null +++ b/backend/src/services/pushbullet.rs @@ -0,0 +1,30 @@ +use anyhow::Result; + +/// Pushbullet notification service. +/// +/// Sends push notifications to server administrators via the Pushbullet +/// API. Used as a secondary notification channel alongside Discord for +/// critical alerts that need to reach admins on their mobile devices. +pub struct PushbulletNotifier { + // TODO: Add fields: + // - api_key: String + // - default_device_iden: Option (target specific device, or all) +} + +impl PushbulletNotifier { + /// Send a text notification (note type push). + pub async fn send_notification(&self, _title: &str, _body: &str) -> Result<()> { + // TODO: POST to https://api.pushbullet.com/v2/pushes + // TODO: Body: { type: "note", title, body } + // TODO: Auth: Access-Token header with api_key + todo!() + } + + /// Send a link notification with a clickable URL. + pub async fn send_link(&self, _title: &str, _body: &str, _url: &str) -> Result<()> { + // TODO: POST to https://api.pushbullet.com/v2/pushes + // TODO: Body: { type: "link", title, body, url } + // TODO: Auth: Access-Token header with api_key + todo!() + } +} diff --git a/backend/src/services/scheduler.rs b/backend/src/services/scheduler.rs new file mode 100644 index 0000000..c6a63ec --- /dev/null +++ b/backend/src/services/scheduler.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Cron-based wipe schedule management. +/// +/// Manages wipe schedules backed by `tokio-cron-scheduler`. Each schedule +/// maps a cron expression + timezone to a wipe profile. When a schedule +/// fires, it creates a wipe_history record and dispatches execution to +/// the WipeEngine. +pub struct SchedulerService { + // TODO: Add fields: + // - db: sqlx::PgPool + // - nats: async_nats::Client + // - scheduler: tokio_cron_scheduler::JobScheduler +} + +impl SchedulerService { + /// Start the scheduler, loading all active schedules from the database. + pub async fn start(&self) -> Result<()> { + // TODO: Query all active wipe_schedules from DB + // TODO: Register each as a cron job in tokio-cron-scheduler + // TODO: Start the scheduler event loop + todo!() + } + + /// Register a wipe schedule as a cron job. + pub async fn register_wipe_schedule(&self, _schedule_id: Uuid) -> Result<()> { + // TODO: Load WipeSchedule from DB + // TODO: Parse cron_expression with timezone + // TODO: Create tokio-cron-scheduler job that: + // 1. Creates a wipe_history record + // 2. Publishes wipe execution event to NATS + // TODO: Store job handle for later removal + todo!() + } + + /// Remove a schedule from the running scheduler. + pub async fn remove_schedule(&self, _schedule_id: Uuid) -> Result<()> { + // TODO: Look up job handle by schedule_id + // TODO: Remove from tokio-cron-scheduler + todo!() + } + + /// Calculate the next N scheduled run times for a given schedule. + pub async fn get_next_runs( + &self, + _schedule_id: Uuid, + _count: usize, + ) -> Result>> { + // TODO: Load cron_expression and timezone from DB + // TODO: Iterate cron expression to compute next N fire times + // TODO: Convert to UTC and return + todo!() + } +} diff --git a/backend/src/services/steam_watcher.rs b/backend/src/services/steam_watcher.rs new file mode 100644 index 0000000..35c7625 --- /dev/null +++ b/backend/src/services/steam_watcher.rs @@ -0,0 +1,53 @@ +use anyhow::Result; + +/// Steam App ID for the Rust Dedicated Server. +pub const RUST_SERVER_APP_ID: u32 = 258550; + +/// Steam build ID polling service. +/// +/// Periodically checks the Steam Web API for new Rust Dedicated Server +/// builds. When a new build is detected (build ID changes), it publishes +/// an event to NATS so that servers configured for auto-update-on-force-wipe +/// can be notified and trigger their wipe schedules. +pub struct SteamUpdateWatcher { + // TODO: Add fields: + // - nats: async_nats::Client + // - db: sqlx::PgPool + // - poll_interval: std::time::Duration + // - last_known_build_id: Option +} + +impl SteamUpdateWatcher { + /// Start the polling loop. Runs indefinitely, checking for updates + /// at the configured interval. + pub async fn start_polling(&self) -> Result<()> { + // TODO: Loop on poll_interval + // TODO: Call check_for_update each iteration + // TODO: If new build detected, call handle_update_detected + todo!() + } + + /// Check Steam Web API for the current build ID of the Rust server app. + /// + /// Returns the build ID string if an update is detected (differs from + /// last known), or None if unchanged. + pub async fn check_for_update(&self) -> Result> { + // TODO: GET https://api.steampowered.com/ISteamApps/UpToDateCheck/v1/ + // ?appid={RUST_SERVER_APP_ID}&version=0 + // TODO: Parse response for build ID + // TODO: Compare against last_known_build_id + // TODO: Return Some(new_id) if changed, None if same + todo!() + } + + /// Handle a detected Steam update: persist the new build ID, + /// publish NATS event for downstream consumers. + pub async fn handle_update_detected(&self, _new_build_id: &str) -> Result<()> { + // TODO: Update last_known_build_id + // TODO: Persist to DB for crash recovery + // TODO: Publish to NATS subject corrosion.steam.update_detected + // with payload { app_id, old_build_id, new_build_id, detected_at } + // TODO: Log the detection + todo!() + } +} diff --git a/backend/src/services/wipe_engine.rs b/backend/src/services/wipe_engine.rs new file mode 100644 index 0000000..9ead5d3 --- /dev/null +++ b/backend/src/services/wipe_engine.rs @@ -0,0 +1,88 @@ +use anyhow::Result; +use uuid::Uuid; + +/// Core wipe orchestration engine. +/// +/// Coordinates the full wipe lifecycle: pre-wipe steps (backups, warnings, +/// player kicks), the wipe itself (map swap, plugin data cleanup, seed +/// rotation), and post-wipe verification (health checks, rollback on +/// failure). Works through the PanelAdapter trait so it is panel-agnostic. +/// +/// All wipe executions are logged to the wipe_history table with full +/// execution traces for audit and debugging. +pub struct WipeEngine { + pub db: sqlx::PgPool, + pub nats: async_nats::Client, +} + +impl WipeEngine { + pub fn new(db: sqlx::PgPool, nats: async_nats::Client) -> Self { + Self { db, nats } + } + + /// Execute a full wipe sequence for the given wipe history record. + /// + /// Orchestrates: pre-wipe -> wipe actions -> post-wipe verification. + /// On failure at any stage, triggers rollback if configured. + pub async fn execute_wipe(&self, _wipe_history_id: Uuid) -> Result<()> { + // TODO: Load wipe history + profile + schedule from DB + // TODO: Resolve the PanelAdapter for this server + // TODO: Run pre-wipe -> wipe -> post-wipe pipeline + // TODO: Update wipe_history status throughout + // TODO: Publish NATS events for real-time UI updates + todo!() + } + + /// Execute pre-wipe steps: countdown warnings, player kicks, backups, + /// Discord/Pushbullet announcements, custom commands. + pub async fn execute_pre_wipe(&self, _wipe_history_id: Uuid) -> Result<()> { + // TODO: Load PreWipeConfig from wipe profile + // TODO: Send countdown warnings via RCON at configured intervals + // TODO: Create backup if backup_before_wipe is true + // TODO: Kick players with configured message + // TODO: Run final save command + // TODO: Send Discord/Pushbullet pre-announce notifications + // TODO: Execute custom_commands_before + todo!() + } + + /// Execute the actual wipe actions: stop server, delete/swap map, + /// wipe plugin data, update seed, restart server. + pub async fn execute_wipe_actions(&self, _wipe_history_id: Uuid) -> Result<()> { + // TODO: Stop the server via PanelAdapter + // TODO: Delete map/save files based on wipe type + // TODO: Wipe plugin data files based on plugin wipe flags + // TODO: Update seed if configured for rotation + // TODO: Upload new custom map if map rotation is active + // TODO: Update server.cfg with new settings + // TODO: Trigger Steam update if force wipe + // TODO: Start the server + todo!() + } + + /// Execute post-wipe verification: health checks on server startup, + /// map correctness, plugin loading, player slot availability. + pub async fn execute_post_wipe_verification(&self, _wipe_history_id: Uuid) -> Result<()> { + // TODO: Wait for server to report running via PanelAdapter + // TODO: Verify correct map loaded (check level name via RCON) + // TODO: Verify all required plugins loaded + // TODO: Verify player slots are open + // TODO: Retry up to max_restart_attempts on failure + // TODO: Send Discord/Pushbullet post-announce notifications + // TODO: Execute post_wipe_commands + todo!() + } + + /// Execute rollback: restore pre-wipe backup, restart server, + /// notify operators of failure. + pub async fn execute_rollback(&self, _wipe_history_id: Uuid) -> Result<()> { + // TODO: Load backup_reference from wipe_history + // TODO: Stop the server + // TODO: Restore backup via BackupManager + // TODO: Start the server + // TODO: Verify server health post-rollback + // TODO: Update wipe_history status to 'rolled_back' + // TODO: Send failure notifications via Discord/Pushbullet + todo!() + } +}