Files
corrosion-admin-panel/backend/src/services/module_installer.rs
Vantz Stockwell e86f4d9ad5
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
feat: Phase 4 module auto-installation + Phase 5 webstore backend
Phase 4 Contributions (Agent Golf):
- Module auto-installation service (module_installer.rs)
- NATS subject pattern for module installation commands
- Companion agent contract documentation
- API endpoint: POST /api/modules/install

Phase 5 XO Direct Touch:
- Webstore subscription API (PayPal recurring billing)
  * POST /api/webstore/subscription/create
  * GET /api/webstore/subscription
  * POST /api/webstore/subscription/cancel
  * POST /api/webstore/subscription/webhook
- Store configuration API (CRUD for store settings)
  * GET /api/webstore/config
  * PUT /api/webstore/config
- Store category/item management APIs (multi-tenant CRUD)
  * GET/POST/PUT/DELETE /api/webstore/categories
  * GET/POST/PUT/DELETE /api/webstore/items
- Public store API (customer-facing, subdomain-scoped)
  * GET /api/public-store/:subdomain
  * GET /api/public-store/:subdomain/items
  * POST /api/public-store/:subdomain/purchase
  * POST /api/public-store/:subdomain/webhook
- Transaction history API
  * GET /api/webstore/transactions
- Delivery system (NATS command execution on purchase)
- Migrations: payment_orders, webstore_subscriptions, store_config, store_items, store_transactions

Security:
- JWT auth + license_id scoping on admin endpoints
- Subdomain → license_id mapping on public endpoints
- Purchase limit enforcement
- Command injection prevention via placeholder replacement

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 14:53:38 -05:00

448 lines
14 KiB
Rust

use anyhow::{Context, Result};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use super::amp_adapter::AmpAdapter;
use super::encryption;
use super::nats_bridge::NatsBridge;
use super::pterodactyl_adapter::PterodactylAdapter;
/// Default timeout for module installation operations (60 seconds).
const MODULE_INSTALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Module installation orchestrator.
///
/// Handles the full lifecycle of module deployment to game servers:
/// 1. Verify module is purchased by the license
/// 2. Fetch module metadata (plugin file URL)
/// 3. Determine server connection type (AMP, Pterodactyl, Companion)
/// 4. Dispatch installation command to appropriate adapter
/// 5. Update installation status in database
pub struct ModuleInstaller {
db: PgPool,
nats: Arc<NatsBridge>,
encryption_key: String,
}
impl ModuleInstaller {
pub fn new(db: PgPool, nats: Arc<NatsBridge>, encryption_key: String) -> Self {
Self { db, nats, encryption_key }
}
/// Orchestrate module installation for a license.
///
/// This is the main entry point. Returns immediately after validating
/// purchase and dispatching installation. The actual installation happens
/// asynchronously and status is tracked in the database.
pub async fn install_module(
&self,
license_id: Uuid,
module_id: Uuid,
) -> Result<()> {
// 1. Verify module is purchased
let purchased = self.verify_module_purchase(license_id, module_id).await?;
if !purchased {
anyhow::bail!("Module not purchased by this license");
}
// 2. Get module metadata
let module = self.get_module_metadata(module_id).await?;
// 3. Get server connection info
let connection = self.get_server_connection(license_id).await?;
// 4. Create or update installation record (status = installing)
self.upsert_installation_status(license_id, module_id, "installing", None)
.await?;
// 5. Dispatch installation based on connection type
let result = match connection.connection_type.as_str() {
"amp" => {
self.install_via_amp(
&connection,
&module.plugin_file_url,
&module.slug,
)
.await
}
"pterodactyl" => {
self.install_via_pterodactyl(
&connection,
&module.plugin_file_url,
&module.slug,
)
.await
}
"bare_metal" => {
self.install_via_companion(
license_id,
&module.plugin_file_url,
&module.slug,
)
.await
}
_ => anyhow::bail!("Unknown connection type: {}", connection.connection_type),
};
// 6. Update status based on result
match result {
Ok(_) => {
self.upsert_installation_status(
license_id,
module_id,
"installed",
None,
)
.await?;
tracing::info!(
"Module {} installed successfully for license {}",
module.slug,
license_id
);
}
Err(e) => {
let error_msg = e.to_string();
self.upsert_installation_status(
license_id,
module_id,
"failed",
Some(&error_msg),
)
.await?;
tracing::error!(
"Module {} installation failed for license {}: {}",
module.slug,
license_id,
error_msg
);
return Err(e);
}
}
Ok(())
}
/// Get installation status for a module.
pub async fn get_installation_status(
&self,
license_id: Uuid,
module_id: Uuid,
) -> Result<Option<ModuleInstallationStatus>> {
let status = sqlx::query_as::<_, ModuleInstallationStatus>(
"SELECT status, installed_at, error_message \
FROM module_installations \
WHERE license_id = $1 AND module_id = $2",
)
.bind(license_id)
.bind(module_id)
.fetch_optional(&self.db)
.await
.context("Failed to fetch installation status")?;
Ok(status)
}
// ────────────────────────────────────────────────────────────
// Private implementation methods
// ────────────────────────────────────────────────────────────
/// Verify that the module has been purchased by this license.
async fn verify_module_purchase(
&self,
license_id: Uuid,
module_id: Uuid,
) -> Result<bool> {
let purchased: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM module_purchases \
WHERE license_id = $1 AND module_id = $2)",
)
.bind(license_id)
.bind(module_id)
.fetch_one(&self.db)
.await
.context("Failed to verify module purchase")?;
Ok(purchased.0)
}
/// Fetch module metadata from the database.
async fn get_module_metadata(&self, module_id: Uuid) -> Result<ModuleMetadata> {
let module = sqlx::query_as::<_, ModuleMetadata>(
"SELECT slug, plugin_file_url FROM modules WHERE id = $1",
)
.bind(module_id)
.fetch_optional(&self.db)
.await
.context("Failed to fetch module metadata")?
.ok_or_else(|| anyhow::anyhow!("Module not found"))?;
if module.plugin_file_url.is_none() {
anyhow::bail!("Module does not have a plugin file URL");
}
Ok(module)
}
/// Get server connection details for a license.
async fn get_server_connection(&self, license_id: Uuid) -> Result<ServerConnectionInfo> {
let connection = sqlx::query_as::<_, ServerConnectionInfo>(
"SELECT connection_type, panel_api_endpoint, panel_api_key_encrypted, \
panel_server_identifier \
FROM server_connections \
WHERE license_id = $1",
)
.bind(license_id)
.fetch_optional(&self.db)
.await
.context("Failed to fetch server connection")?
.ok_or_else(|| anyhow::anyhow!("Server connection not found"))?;
Ok(connection)
}
/// Create or update installation status record.
async fn upsert_installation_status(
&self,
license_id: Uuid,
module_id: Uuid,
status: &str,
error_message: Option<&str>,
) -> Result<()> {
let now = if status == "installed" {
Some(chrono::Utc::now())
} else {
None
};
sqlx::query(
"INSERT INTO module_installations (license_id, module_id, status, installed_at, error_message) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (license_id, module_id) \
DO UPDATE SET status = $3, installed_at = $4, error_message = $5",
)
.bind(license_id)
.bind(module_id)
.bind(status)
.bind(now)
.bind(error_message)
.execute(&self.db)
.await
.context("Failed to update installation status")?;
Ok(())
}
/// Install via AMP panel adapter.
async fn install_via_amp(
&self,
connection: &ServerConnectionInfo,
plugin_url: &Option<String>,
module_slug: &str,
) -> Result<()> {
let api_endpoint = connection
.panel_api_endpoint
.as_ref()
.ok_or_else(|| anyhow::anyhow!("AMP endpoint not configured"))?;
let encrypted_key = connection
.panel_api_key_encrypted
.as_ref()
.ok_or_else(|| anyhow::anyhow!("AMP API key not configured"))?;
// Decrypt API key
let api_key = encryption::decrypt(encrypted_key, &self.encryption_key)
.context("Failed to decrypt AMP API key")?;
// Extract credentials (format: "username:password")
let parts: Vec<&str> = api_key.split(':').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid AMP credentials format");
}
let adapter = AmpAdapter::new(
api_endpoint.clone(),
parts[0].to_string(),
parts[1].to_string(),
);
// Download plugin file
let plugin_data = self
.download_plugin_file(plugin_url.as_ref().unwrap())
.await?;
// Determine filename from slug
let filename = format!("{}.cs", module_slug.replace('-', ""));
// Upload to oxide/plugins/ directory
let target_path = format!("oxide/plugins/{}", filename);
let server_id = connection
.panel_server_identifier
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Panel server ID not configured"))?;
adapter
.put_file(server_id, &target_path, &plugin_data)
.await
.context("Failed to upload plugin to AMP server")?;
// Reload plugins via console command
adapter
.send_command(server_id, "oxide.reload *")
.await
.context("Failed to reload plugins")?;
Ok(())
}
/// Install via Pterodactyl panel adapter.
async fn install_via_pterodactyl(
&self,
connection: &ServerConnectionInfo,
plugin_url: &Option<String>,
module_slug: &str,
) -> Result<()> {
let api_endpoint = connection
.panel_api_endpoint
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Pterodactyl endpoint not configured"))?;
let encrypted_key = connection
.panel_api_key_encrypted
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Pterodactyl API key not configured"))?;
// Decrypt API key
let api_key = encryption::decrypt(encrypted_key, &self.encryption_key)
.context("Failed to decrypt Pterodactyl API key")?;
let adapter = PterodactylAdapter::new(api_endpoint.clone(), api_key);
// Download plugin file
let plugin_data = self
.download_plugin_file(plugin_url.as_ref().unwrap())
.await?;
// Determine filename from slug
let filename = format!("{}.cs", module_slug.replace('-', ""));
// Upload to oxide/plugins/ directory
let target_path = format!("oxide/plugins/{}", filename);
let server_id = connection
.panel_server_identifier
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Panel server ID not configured"))?;
adapter
.put_file(server_id, &target_path, &plugin_data)
.await
.context("Failed to upload plugin to Pterodactyl server")?;
// Reload plugins via console command
adapter
.send_command(server_id, "oxide.reload *")
.await
.context("Failed to reload plugins")?;
Ok(())
}
/// Install via companion agent (bare metal).
async fn install_via_companion(
&self,
license_id: Uuid,
plugin_url: &Option<String>,
module_slug: &str,
) -> Result<()> {
let filename = format!("{}.cs", module_slug.replace('-', ""));
let target_path = format!("oxide/plugins/{}", filename);
// Build NATS command payload
#[derive(serde::Serialize)]
struct ModuleInstallCommand {
module_id: String,
download_url: String,
filename: String,
target_path: String,
}
let cmd = ModuleInstallCommand {
module_id: module_slug.to_string(),
download_url: plugin_url.as_ref().unwrap().clone(),
filename,
target_path,
};
// Publish to NATS subject
let subject = format!("corrosion.{}.cmd.module.install", license_id);
self.nats
.request_json::<ModuleInstallCommand, ModuleInstallResult>(
&subject,
&cmd,
MODULE_INSTALL_TIMEOUT,
)
.await
.context("Companion agent module install request timed out or failed")
.and_then(|result| {
if result.success {
Ok(())
} else {
anyhow::bail!("Companion agent reported failure: {}", result.error.unwrap_or_default())
}
})
}
/// Download plugin file from a URL.
async fn download_plugin_file(&self, url: &str) -> Result<Vec<u8>> {
let response = reqwest::get(url)
.await
.context("Failed to download plugin file")?;
if !response.status().is_success() {
anyhow::bail!(
"Plugin download failed with status: {}",
response.status()
);
}
let bytes = response
.bytes()
.await
.context("Failed to read plugin file bytes")?;
Ok(bytes.to_vec())
}
}
// ────────────────────────────────────────────────────────────
// DTOs
// ────────────────────────────────────────────────────────────
#[derive(Debug, sqlx::FromRow)]
struct ModuleMetadata {
slug: String,
plugin_file_url: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
struct ServerConnectionInfo {
connection_type: String,
panel_api_endpoint: Option<String>,
panel_api_key_encrypted: Option<String>,
panel_server_identifier: Option<String>,
}
#[derive(Debug, sqlx::FromRow, serde::Serialize)]
pub struct ModuleInstallationStatus {
pub status: String,
pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
pub error_message: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ModuleInstallResult {
success: bool,
error: Option<String>,
}