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>
This commit is contained in:
Vantz Stockwell
2026-02-15 12:07:01 -05:00
parent a62715409f
commit 590765fbbc
20 changed files with 8677 additions and 443 deletions

View File

@@ -1,7 +1,8 @@
use anyhow::Result;
use anyhow::{Context, Result};
use uuid::Uuid;
use crate::models::license::License;
use crate::models::license::{License, LicenseCheckInResponse};
use crate::services::encryption;
/// License validation and lifecycle management.
///
@@ -9,20 +10,36 @@ use crate::models::license::License;
/// 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
db: sqlx::PgPool,
}
impl LicenseService {
pub fn new(db: sqlx::PgPool) -> Self {
Self { db }
}
/// 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<Option<License>> {
// 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!()
pub async fn validate_license(&self, license_key: &str) -> Result<Option<License>> {
let license: Option<License> = sqlx::query_as(
"SELECT * FROM licenses WHERE license_key = $1 AND status = 'active'",
)
.bind(license_key)
.fetch_optional(&self.db)
.await
.context("Failed to query license")?;
if let Some(ref lic) = license {
// Check expiration
if let Some(expires_at) = lic.expires_at {
if expires_at < chrono::Utc::now() {
return Ok(None);
}
}
}
Ok(license)
}
/// Activate a license key: bind it to a server name and subdomain.
@@ -30,16 +47,123 @@ impl LicenseService {
/// Called during initial setup when a user first registers.
pub async fn activate_license(
&self,
_license_key: &str,
_server_name: &str,
_subdomain: &str,
license_key: &str,
server_name: &str,
subdomain: &str,
) -> Result<License> {
// 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!()
// Check license exists and is pending
let existing: Option<License> = sqlx::query_as(
"SELECT * FROM licenses WHERE license_key = $1 AND status = 'pending'",
)
.bind(license_key)
.fetch_optional(&self.db)
.await
.context("Failed to query license for activation")?;
if existing.is_none() {
anyhow::bail!("License key not found or already activated");
}
// Check subdomain availability
let subdomain_exists: Option<(bool,)> = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM licenses WHERE subdomain = $1) as exists",
)
.bind(subdomain)
.fetch_optional(&self.db)
.await
.context("Failed to check subdomain availability")?;
if subdomain_exists.map_or(false, |(exists,)| exists) {
anyhow::bail!("Subdomain already in use");
}
// Activate license
let license: License = sqlx::query_as(
"UPDATE licenses
SET status = 'active', server_name = $2, subdomain = $3, updated_at = NOW()
WHERE license_key = $1
RETURNING *",
)
.bind(license_key)
.bind(server_name)
.bind(subdomain)
.fetch_one(&self.db)
.await
.context("Failed to activate license")?;
// Create default system roles for this license
self.create_default_roles(license.id).await?;
tracing::info!(
"License activated: {} (subdomain: {}, server: {})",
license_key,
subdomain,
server_name
);
Ok(license)
}
/// Create default system roles for a newly activated license.
async fn create_default_roles(&self, license_id: Uuid) -> Result<()> {
// Owner role (full permissions)
sqlx::query(
"INSERT INTO roles (license_id, role_name, is_system_default, permissions)
VALUES ($1, 'Owner', true, $2)",
)
.bind(license_id)
.bind(serde_json::json!({
"wipes": ["read", "write", "execute"],
"maps": ["read", "write", "delete"],
"plugins": ["read", "write", "configure"],
"schedules": ["read", "write", "delete"],
"team": ["read", "write", "delete"],
"settings": ["read", "write"],
"logs": ["read"]
}))
.execute(&self.db)
.await
.context("Failed to create Owner role")?;
// Admin role (most permissions, no team deletion)
sqlx::query(
"INSERT INTO roles (license_id, role_name, is_system_default, permissions)
VALUES ($1, 'Admin', true, $2)",
)
.bind(license_id)
.bind(serde_json::json!({
"wipes": ["read", "write", "execute"],
"maps": ["read", "write"],
"plugins": ["read", "write", "configure"],
"schedules": ["read", "write"],
"team": ["read", "write"],
"settings": ["read"],
"logs": ["read"]
}))
.execute(&self.db)
.await
.context("Failed to create Admin role")?;
// Viewer role (read-only)
sqlx::query(
"INSERT INTO roles (license_id, role_name, is_system_default, permissions)
VALUES ($1, 'Viewer', true, $2)",
)
.bind(license_id)
.bind(serde_json::json!({
"wipes": ["read"],
"maps": ["read"],
"plugins": ["read"],
"schedules": ["read"],
"team": ["read"],
"settings": ["read"],
"logs": ["read"]
}))
.execute(&self.db)
.await
.context("Failed to create Viewer role")?;
Ok(())
}
/// Process a check-in from the Rust server plugin.
@@ -48,20 +172,82 @@ impl LicenseService {
/// including enabled modules and NATS connection token.
pub async fn check_in(
&self,
_license_key: &str,
_plugin_version: &str,
) -> Result<crate::models::license::LicenseCheckInResponse> {
// 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!()
license_key: &str,
plugin_version: &str,
) -> Result<LicenseCheckInResponse> {
// Validate license
let license = self.validate_license(license_key).await?;
if license.is_none() {
return Ok(LicenseCheckInResponse {
valid: false,
status: "invalid".to_string(),
modules_enabled: vec![],
nats_token: String::new(),
});
}
let license = license.unwrap();
// Update last-seen timestamp in server_connections
sqlx::query(
"INSERT INTO server_connections (license_id, plugin_version, plugin_last_seen)
VALUES ($1, $2, NOW())
ON CONFLICT (license_id)
DO UPDATE SET plugin_version = $2, plugin_last_seen = NOW()",
)
.bind(license.id)
.bind(plugin_version)
.execute(&self.db)
.await
.context("Failed to update plugin check-in")?;
// Generate NATS auth token (simple token based on license ID)
let nats_token = encryption::generate_token(32);
// Store token in DB for validation later
sqlx::query(
"UPDATE licenses SET nats_token = $2, updated_at = NOW() WHERE id = $1",
)
.bind(license.id)
.bind(&nats_token)
.execute(&self.db)
.await
.context("Failed to store NATS token")?;
tracing::debug!(
"License check-in: {} (version: {})",
license_key,
plugin_version
);
Ok(LicenseCheckInResponse {
valid: true,
status: license.status.clone(),
modules_enabled: license.modules_enabled.unwrap_or_default(),
nats_token,
})
}
/// Look up a license by its UUID.
pub async fn get_license_by_key(&self, _license_id: Uuid) -> Result<Option<License>> {
// TODO: Query license by ID from DB
// TODO: Return if found
todo!()
pub async fn get_license_by_id(&self, license_id: Uuid) -> Result<Option<License>> {
let license: Option<License> = sqlx::query_as("SELECT * FROM licenses WHERE id = $1")
.bind(license_id)
.fetch_optional(&self.db)
.await
.context("Failed to query license by ID")?;
Ok(license)
}
/// Look up a license by its key.
pub async fn get_license_by_key(&self, license_key: &str) -> Result<Option<License>> {
let license: Option<License> = sqlx::query_as("SELECT * FROM licenses WHERE license_key = $1")
.bind(license_key)
.fetch_optional(&self.db)
.await
.context("Failed to query license by key")?;
Ok(license)
}
}