feat: Phase 4 module auto-installation + Phase 5 webstore backend
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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>
This commit is contained in:
447
backend/src/services/module_installer.rs
Normal file
447
backend/src/services/module_installer.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
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>,
|
||||
}
|
||||
Reference in New Issue
Block a user