Files
smueller 316679a913
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 18s
Enhance WHMCS integration with mTLS support and product mapping features. Added mTLS configuration options, updated API endpoints for mTLS status and fingerprint registration, and implemented product validation API. Updated database schema and documentation accordingly.
2026-06-30 13:17:12 +02:00

596 lines
22 KiB
PHP

<?php
declare(strict_types=1);
use WHMCS\Database\Capsule;
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
require_once __DIR__ . '/lib/ApiClient.php';
require_once __DIR__ . '/lib/ModuleStorage.php';
require_once __DIR__ . '/lib/EventPoller.php';
require_once __DIR__ . '/lib/ProductMappingStorage.php';
require_once __DIR__ . '/../../../lib/MtlsConfig.php';
const HEXAGAMECLOUD_ADDON_VERSION = '1.1.0';
function hexagamecloud_config(): array
{
return [
'name' => 'HexaHost GameCloud',
'description' => 'Global GameCloud integration — dashboard, reconciliation, and event polling.',
'version' => HEXAGAMECLOUD_ADDON_VERSION,
'author' => 'HexaHost',
'language' => 'english',
'fields' => [
'api_url' => [
'FriendlyName' => 'GameCloud API URL',
'Type' => 'text',
'Size' => '128',
'Default' => 'https://api.example.net',
'Description' => 'Base URL of the GameCloud API (no trailing slash)',
],
'integration_id' => [
'FriendlyName' => 'Integration ID',
'Type' => 'text',
'Size' => '64',
'Description' => 'Matches WHMCS_INTEGRATION_ID on GameCloud',
],
'api_secret' => [
'FriendlyName' => 'API Secret',
'Type' => 'password',
'Size' => '128',
'Description' => 'HMAC signing secret — stored encrypted by WHMCS',
],
'sso_origin' => [
'FriendlyName' => 'SSO Panel Origin',
'Type' => 'text',
'Size' => '128',
'Default' => 'https://panel.example.net',
'Description' => 'Customer panel URL for SSO redirects',
],
'event_poll_limit' => [
'FriendlyName' => 'Event poll batch size',
'Type' => 'text',
'Size' => '4',
'Default' => '50',
],
'auto_reconcile' => [
'FriendlyName' => 'Auto-reconcile on daily cron',
'Type' => 'yesno',
'Description' => 'Run dry reconciliation during daily event poll',
],
'use_mtls' => [
'FriendlyName' => 'Use mTLS client certificate',
'Type' => 'yesno',
'Description' => 'Requires INTEGRATION_MTLS_ENABLED=true on GameCloud',
],
'mtls_cert_path' => [
'FriendlyName' => 'mTLS client certificate path',
'Type' => 'text',
'Size' => '256',
],
'mtls_key_path' => [
'FriendlyName' => 'mTLS client private key path',
'Type' => 'text',
'Size' => '256',
],
'mtls_ca_path' => [
'FriendlyName' => 'mTLS CA bundle path',
'Type' => 'text',
'Size' => '256',
],
'mtls_cert_fingerprint' => [
'FriendlyName' => 'Client certificate SHA-256 fingerprint',
'Type' => 'text',
'Size' => '128',
'Description' => 'openssl x509 -in client.crt -outform DER | openssl dgst -sha256',
],
],
];
}
function hexagamecloud_activate(): array
{
try {
$sql = file_get_contents(__DIR__ . '/sql/install.sql');
if ($sql === false) {
throw new RuntimeException('Unable to read install.sql');
}
foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) {
if ($statement !== '') {
Capsule::connection()->statement($statement);
}
}
return [
'status' => 'success',
'description' => 'HexaHost GameCloud addon activated. Copy hooks/hexagamecloud.php to includes/hooks/ for cron polling.',
];
} catch (Throwable $exception) {
return [
'status' => 'error',
'description' => 'Activation failed: ' . $exception->getMessage(),
];
}
}
function hexagamecloud_deactivate(): array
{
return [
'status' => 'success',
'description' => 'Addon deactivated. Module tables were kept for audit history.',
];
}
function hexagamecloud_upgrade(array $vars): void
{
if (!function_exists('hexagamecloud_getModuleVersion')) {
return;
}
$current = hexagamecloud_getModuleVersion();
if (version_compare($current, '1.1.0', '<')) {
$upgrade = file_get_contents(__DIR__ . '/sql/upgrade-1.1.0.sql');
if ($upgrade !== false) {
foreach (array_filter(array_map('trim', explode(';', $upgrade))) as $statement) {
if ($statement !== '') {
Capsule::connection()->statement($statement);
}
}
}
}
}
function hexagamecloud_output(array $vars): void
{
$action = $_GET['action'] ?? 'dashboard';
$modulelink = $vars['modulelink'];
$storage = new HexaGameCloudModuleStorage();
try {
$client = hexagamecloud_create_client($vars);
} catch (Throwable $exception) {
hexagamecloud_render_error($vars, $exception->getMessage());
return;
}
switch ($action) {
case 'events':
hexagamecloud_page_events($vars, $client, $storage, $modulelink);
break;
case 'reconciliation':
hexagamecloud_page_reconciliation($vars, $client, $storage, $modulelink);
break;
case 'poll':
hexagamecloud_action_poll($vars, $client, $storage, $modulelink);
break;
case 'reconcile':
hexagamecloud_action_reconcile($vars, $client, $storage, $modulelink);
break;
case 'ack':
hexagamecloud_action_ack($vars, $client, $storage, $modulelink);
break;
case 'mappings':
hexagamecloud_page_mappings($vars, $client, $modulelink);
break;
case 'save_mapping':
hexagamecloud_action_save_mapping($vars, $client, $modulelink);
break;
case 'delete_mapping':
hexagamecloud_action_delete_mapping($vars, $modulelink);
break;
case 'mtls':
hexagamecloud_page_mtls($vars, $client, $modulelink);
break;
case 'register_mtls':
hexagamecloud_action_register_mtls($vars, $client, $modulelink);
break;
default:
hexagamecloud_page_dashboard($vars, $client, $storage, $modulelink);
break;
}
}
function hexagamecloud_create_client(array $vars): HexaGameCloudAddonApiClient
{
$apiUrl = trim((string) ($vars['api_url'] ?? ''));
$integrationId = trim((string) ($vars['integration_id'] ?? ''));
$apiSecret = trim((string) ($vars['api_secret'] ?? ''));
if ($apiUrl === '' || $integrationId === '' || $apiSecret === '') {
throw new RuntimeException('Configure API URL, Integration ID and API Secret in addon settings.');
}
$mtls = HexaGameCloudMtlsConfig::fromAddonSettings($vars);
return new HexaGameCloudAddonApiClient($apiUrl, $integrationId, $apiSecret, $mtls);
}
function hexagamecloud_page_dashboard(
array $vars,
HexaGameCloudAddonApiClient $client,
HexaGameCloudModuleStorage $storage,
string $modulelink
): void {
$health = $client->health();
$stats = $client->dashboardStats();
$accounts = $client->listAccounts();
$unlinked = hexagamecloud_count_unlinked_whmcs_services($accounts['accounts'] ?? []);
$orphans = 0;
foreach ($accounts['accounts'] ?? [] as $account) {
if (($account['linkStatus'] ?? '') === 'TERMINATED' && ($account['gameCloudStatus'] ?? '') !== 'DELETED') {
$orphans++;
}
}
$storage->recordOperation('dashboard:view', 'success', [
'integrationId' => $stats['integrationId'] ?? null,
]);
echo hexagamecloud_render_template('dashboard', [
'modulelink' => $modulelink,
'version' => HEXAGAMECLOUD_ADDON_VERSION,
'health' => $health,
'stats' => $stats,
'linkedServices' => count($accounts['accounts'] ?? []),
'unlinkedWhmcsServices' => $unlinked,
'driftCount' => $orphans,
'lastError' => $storage->getLastError(),
]);
}
function hexagamecloud_page_events(
array $vars,
HexaGameCloudAddonApiClient $client,
HexaGameCloudModuleStorage $storage,
string $modulelink
): void {
$localEvents = $storage->listLocalEvents(100);
$pendingCount = $storage->countLocalEventsByStatus('pending');
echo hexagamecloud_render_template('events', [
'modulelink' => $modulelink,
'localEvents' => $localEvents,
'pendingCount' => $pendingCount,
'deadLetterCount' => $storage->countLocalEventsByStatus('dead_letter'),
]);
}
function hexagamecloud_page_reconciliation(
array $vars,
HexaGameCloudAddonApiClient $client,
HexaGameCloudModuleStorage $storage,
string $modulelink
): void {
$runs = $storage->listReconciliationRuns(20);
$dryRun = isset($_GET['dry']) ? true : false;
$liveResult = null;
if (isset($_GET['run'])) {
try {
$liveResult = $client->reconcileAll([
'dryRun' => $dryRun,
'autoRepair' => !$dryRun && !empty($_GET['repair']),
]);
$storage->saveReconciliationRun($liveResult, $dryRun ? 'dry_run' : 'live');
} catch (Throwable $exception) {
$storage->setLastError($exception->getMessage());
$liveResult = ['error' => $exception->getMessage()];
}
}
echo hexagamecloud_render_template('reconciliation', [
'modulelink' => $modulelink,
'runs' => $runs,
'liveResult' => $liveResult,
'dryRun' => $dryRun,
]);
}
function hexagamecloud_action_poll(
array $vars,
HexaGameCloudAddonApiClient $client,
HexaGameCloudModuleStorage $storage,
string $modulelink
): void {
$limit = (int) ($vars['event_poll_limit'] ?? 50);
$poller = new HexaGameCloudEventPoller($client, $storage, max(1, min($limit, 100)));
try {
$result = $poller->pollAndAcknowledge();
$storage->recordOperation('events:poll', 'success', $result);
} catch (Throwable $exception) {
$storage->setLastError($exception->getMessage());
$storage->recordOperation('events:poll', 'error', ['message' => $exception->getMessage()]);
}
header('Location: ' . $modulelink . '&action=events');
exit;
}
function hexagamecloud_action_reconcile(
array $vars,
HexaGameCloudAddonApiClient $client,
HexaGameCloudModuleStorage $storage,
string $modulelink
): void {
header('Location: ' . $modulelink . '&action=reconciliation&run=1&dry=1');
exit;
}
function hexagamecloud_action_ack(
array $vars,
HexaGameCloudAddonApiClient $client,
HexaGameCloudModuleStorage $storage,
string $modulelink
): void {
$eventId = (int) ($_POST['local_event_id'] ?? 0);
$deadLetter = !empty($_POST['dead_letter']);
$local = $storage->getLocalEvent($eventId);
if ($local && !empty($local['remote_event_id'])) {
try {
$client->acknowledgeEvents([$local['remote_event_id']], $deadLetter, 'Manual ack from WHMCS addon');
$storage->updateLocalEventStatus($eventId, $deadLetter ? 'dead_letter' : 'acknowledged');
} catch (Throwable $exception) {
$storage->setLastError($exception->getMessage());
}
}
header('Location: ' . $modulelink . '&action=events');
exit;
}
function hexagamecloud_count_unlinked_whmcs_services(array $linkedAccounts): int
{
$linkedIds = [];
foreach ($linkedAccounts as $account) {
if (!empty($account['externalServiceId'])) {
$linkedIds[(string) $account['externalServiceId']] = true;
}
}
$hosting = Capsule::table('tblhosting')
->join('tblproducts', 'tblproducts.id', '=', 'tblhosting.packageid')
->where('tblproducts.servertype', 'hexagamecloud')
->whereIn('tblhosting.domainstatus', ['Active', 'Suspended', 'Pending'])
->select('tblhosting.id')
->get();
$unlinked = 0;
foreach ($hosting as $row) {
if (!isset($linkedIds[(string) $row->id])) {
$unlinked++;
}
}
return $unlinked;
}
function hexagamecloud_render_template(string $name, array $data): string
{
$path = __DIR__ . '/templates/' . $name . '.tpl';
if (!is_readable($path)) {
return '<div class="alert alert-danger">Template missing: ' . htmlspecialchars($name) . '</div>';
}
$html = file_get_contents($path);
if ($html === false) {
return '';
}
foreach ($data as $key => $value) {
$placeholder = '{{' . $key . '}}';
if (is_scalar($value) || $value === null) {
$html = str_replace($placeholder, htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
}
}
return hexagamecloud_expand_blocks($html, $data);
}
function hexagamecloud_expand_blocks(string $html, array $data): string
{
if (!empty($data['stats']) && is_array($data['stats'])) {
foreach ($data['stats'] as $key => $value) {
$html = str_replace('{{stats.' . $key . '}}', htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
}
}
if (!empty($data['health']) && is_array($data['health'])) {
foreach ($data['health'] as $key => $value) {
$html = str_replace('{{health.' . $key . '}}', htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
}
}
if (!empty($data['localEvents']) && is_array($data['localEvents'])) {
$rows = '';
foreach ($data['localEvents'] as $event) {
$rows .= '<tr>';
$rows .= '<td>' . (int) $event['id'] . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $event['event_type'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $event['status'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td><code>' . htmlspecialchars((string) $event['payload'], ENT_QUOTES, 'UTF-8') . '</code></td>';
$rows .= '<td>' . htmlspecialchars((string) $event['created_at'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td><form method="post" action="' . htmlspecialchars((string) $data['modulelink'], ENT_QUOTES, 'UTF-8') . '&action=ack">';
$rows .= '<input type="hidden" name="local_event_id" value="' . (int) $event['id'] . '">';
$rows .= '<button type="submit" class="btn btn-default btn-xs">Ack</button> ';
$rows .= '<button type="submit" name="dead_letter" value="1" class="btn btn-danger btn-xs">Dead letter</button>';
$rows .= '</form></td>';
$rows .= '</tr>';
}
$html = str_replace('{{events.rows}}', $rows, $html);
}
if (!empty($data['runs']) && is_array($data['runs'])) {
$rows = '';
foreach ($data['runs'] as $run) {
$rows .= '<tr>';
$rows .= '<td>' . htmlspecialchars((string) $run['run_id'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $run['mode'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td>' . (int) $run['issue_count'] . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $run['created_at'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '</tr>';
}
$html = str_replace('{{reconciliation.rows}}', $rows, $html);
}
if (!empty($data['liveResult']) && is_array($data['liveResult'])) {
$html = str_replace(
'{{reconciliation.result}}',
'<pre>' . htmlspecialchars(json_encode($data['liveResult'], JSON_PRETTY_PRINT), ENT_QUOTES, 'UTF-8') . '</pre>',
$html
);
} else {
$html = str_replace('{{reconciliation.result}}', '', $html);
}
if (!empty($data['status']) && is_array($data['status'])) {
foreach ($data['status'] as $key => $value) {
$display = is_bool($value) ? ($value ? 'yes' : 'no') : (string) $value;
$html = str_replace('{{status.' . $key . '}}', htmlspecialchars($display, ENT_QUOTES, 'UTF-8'), $html);
}
}
if (!empty($data['products']) && is_array($data['products'])) {
$productOptions = '';
foreach ($data['products'] as $product) {
$productOptions .= '<option value="' . (int) $product['id'] . '">'
. htmlspecialchars((string) $product['name'], ENT_QUOTES, 'UTF-8')
. ' (#' . (int) $product['id'] . ')</option>';
}
$html = str_replace('{{mappings.product_options}}', $productOptions, $html);
}
if (!empty($data['plans']['plans']) && is_array($data['plans']['plans'])) {
$planOptions = '';
foreach ($data['plans']['plans'] as $plan) {
$planOptions .= '<option value="' . htmlspecialchars((string) $plan['slug'], ENT_QUOTES, 'UTF-8') . '">'
. htmlspecialchars((string) $plan['name'], ENT_QUOTES, 'UTF-8')
. ' (' . htmlspecialchars((string) $plan['slug'], ENT_QUOTES, 'UTF-8') . ')</option>';
}
$html = str_replace('{{mappings.plan_options}}', $planOptions, $html);
}
if (!empty($data['mappings']) && is_array($data['mappings'])) {
$rows = '';
foreach ($data['mappings'] as $mapping) {
$rows .= '<tr>';
$rows .= '<td>' . (int) $mapping['whmcs_product_id'] . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $mapping['whmcs_product_name'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $mapping['plan_slug'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td>' . htmlspecialchars((string) $mapping['default_software'], ENT_QUOTES, 'UTF-8') . '</td>';
$rows .= '<td>' . ((int) $mapping['is_active'] === 1 ? 'yes' : 'no') . '</td>';
$rows .= '<td><a class="btn btn-danger btn-xs" href="'
. htmlspecialchars((string) $data['modulelink'], ENT_QUOTES, 'UTF-8')
. '&action=delete_mapping&product_id=' . (int) $mapping['whmcs_product_id']
. '">Delete</a></td>';
$rows .= '</tr>';
}
$html = str_replace('{{mappings.rows}}', $rows, $html);
}
return $html;
}
function hexagamecloud_render_error(array $vars, string $message): void
{
echo '<div class="alert alert-danger"><strong>GameCloud addon error:</strong> ' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</div>';
echo '<p>Configure the addon under <em>Setup → Addon Modules → HexaHost GameCloud</em>.</p>';
}
function hexagamecloud_page_mappings(array $vars, HexaGameCloudAddonApiClient $client, string $modulelink): void
{
$mappingStorage = new HexaGameCloudProductMappingStorage();
$plans = $client->listPlans();
$products = $mappingStorage->listWhmcsProducts();
$mappings = $mappingStorage->listMappings();
$validation = null;
if (!empty($_GET['validate'])) {
try {
$validation = $client->validatePlan((string) $_GET['validate']);
} catch (Throwable $exception) {
$validation = ['valid' => false, 'error' => $exception->getMessage()];
}
}
echo hexagamecloud_render_template('mappings', [
'modulelink' => $modulelink,
'plans' => $plans,
'products' => $products,
'mappings' => $mappings,
'validation' => $validation,
]);
}
function hexagamecloud_action_save_mapping(array $vars, HexaGameCloudAddonApiClient $client, string $modulelink): void
{
$mappingStorage = new HexaGameCloudProductMappingStorage();
$planSlug = trim((string) ($_POST['plan_slug'] ?? ''));
try {
$validation = $client->validatePlan($planSlug);
if (empty($validation['valid'])) {
throw new RuntimeException('Invalid GameCloud plan slug: ' . $planSlug);
}
$mappingStorage->upsertMapping([
'whmcs_product_id' => (int) ($_POST['whmcs_product_id'] ?? 0),
'whmcs_product_name' => (string) ($_POST['whmcs_product_name'] ?? ''),
'plan_slug' => $planSlug,
'default_edition' => (string) ($_POST['default_edition'] ?? 'JAVA'),
'default_software' => (string) ($_POST['default_software'] ?? 'VANILLA'),
'default_version' => (string) ($_POST['default_version'] ?? '1.21.1'),
'is_active' => !empty($_POST['is_active']),
]);
} catch (Throwable $exception) {
echo '<div class="alert alert-danger">' . htmlspecialchars($exception->getMessage(), ENT_QUOTES, 'UTF-8') . '</div>';
hexagamecloud_page_mappings($vars, $client, $modulelink);
return;
}
header('Location: ' . $modulelink . '&action=mappings');
exit;
}
function hexagamecloud_action_delete_mapping(array $vars, string $modulelink): void
{
$mappingStorage = new HexaGameCloudProductMappingStorage();
$mappingStorage->deleteMapping((int) ($_GET['product_id'] ?? 0));
header('Location: ' . $modulelink . '&action=mappings');
exit;
}
function hexagamecloud_page_mtls(array $vars, HexaGameCloudAddonApiClient $client, string $modulelink): void
{
$status = $client->mtlsStatus();
echo hexagamecloud_render_template('mtls', [
'modulelink' => $modulelink,
'status' => $status,
'fingerprint_configured' => trim((string) ($vars['mtls_cert_fingerprint'] ?? '')),
]);
}
function hexagamecloud_action_register_mtls(array $vars, HexaGameCloudAddonApiClient $client, string $modulelink): void
{
$fingerprint = trim((string) ($_POST['fingerprint'] ?? $vars['mtls_cert_fingerprint'] ?? ''));
$subject = trim((string) ($_POST['subject'] ?? ''));
try {
$client->registerMtlsFingerprint($fingerprint, $subject !== '' ? $subject : null);
} catch (Throwable $exception) {
echo '<div class="alert alert-danger">' . htmlspecialchars($exception->getMessage(), ENT_QUOTES, 'UTF-8') . '</div>';
hexagamecloud_page_mtls($vars, $client, $modulelink);
return;
}
header('Location: ' . $modulelink . '&action=mtls');
exit;
}