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>
254 lines
8.0 KiB
Rust
254 lines
8.0 KiB
Rust
use anyhow::{Context, Result};
|
|
use uuid::Uuid;
|
|
|
|
use crate::models::license::{License, LicenseCheckInResponse};
|
|
use crate::services::encryption;
|
|
|
|
/// 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 {
|
|
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>> {
|
|
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.
|
|
///
|
|
/// Called during initial setup when a user first registers.
|
|
pub async fn activate_license(
|
|
&self,
|
|
license_key: &str,
|
|
server_name: &str,
|
|
subdomain: &str,
|
|
) -> Result<License> {
|
|
// 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.
|
|
///
|
|
/// 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<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_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)
|
|
}
|
|
}
|