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.
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

This commit is contained in:
smueller
2026-06-30 13:17:12 +02:00
parent 4b20efe4bc
commit 316679a913
34 changed files with 1174 additions and 30 deletions

View File

@@ -34,3 +34,5 @@ Created on activation (`sql/install.sql`):
- `POST /integrations/whmcs/reconcile`
See [docs/integrations/whmcs/addon-module.md](../../../../docs/integrations/whmcs/addon-module.md).
Product mappings: [product-mappings.md](../../../../docs/integrations/whmcs/product-mappings.md). mTLS: [mtls.md](../../../../docs/integrations/whmcs/mtls.md).

View File

@@ -11,8 +11,10 @@ if (!defined('WHMCS')) {
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.0.0';
const HEXAGAMECLOUD_ADDON_VERSION = '1.1.0';
function hexagamecloud_config(): array
{
@@ -60,6 +62,32 @@ function hexagamecloud_config(): array
'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',
],
],
];
}
@@ -98,6 +126,25 @@ function hexagamecloud_deactivate(): array
];
}
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';
@@ -127,6 +174,21 @@ function hexagamecloud_output(array $vars): void
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;
@@ -143,7 +205,9 @@ function hexagamecloud_create_client(array $vars): HexaGameCloudAddonApiClient
throw new RuntimeException('Configure API URL, Integration ID and API Secret in addon settings.');
}
return new HexaGameCloudAddonApiClient($apiUrl, $integrationId, $apiSecret);
$mtls = HexaGameCloudMtlsConfig::fromAddonSettings($vars);
return new HexaGameCloudAddonApiClient($apiUrl, $integrationId, $apiSecret, $mtls);
}
function hexagamecloud_page_dashboard(
@@ -386,6 +450,51 @@ function hexagamecloud_expand_blocks(string $html, array $data): string
$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;
}
@@ -394,3 +503,93 @@ 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;
}

View File

@@ -4,14 +4,19 @@ declare(strict_types=1);
final class HexaGameCloudAddonApiClient
{
private array $mtls = [];
public function __construct(
private readonly string $baseUrl,
private readonly string $integrationId,
private readonly string $apiSecret,
array $mtls = [],
) {
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
throw new RuntimeException('API URL, integration ID and secret are required');
}
$this->mtls = $mtls;
}
public function health(): array
@@ -58,6 +63,36 @@ final class HexaGameCloudAddonApiClient
);
}
public function listPlans(): array
{
return $this->request('GET', '/api/v1/integrations/whmcs/catalog/plans');
}
public function validatePlan(string $planSlug): array
{
return $this->request('POST', '/api/v1/integrations/whmcs/catalog/validate-plan', [
'planSlug' => $planSlug,
]);
}
public function registerMtlsFingerprint(string $fingerprint, ?string $subject = null): array
{
return $this->request(
'PUT',
'/api/v1/integrations/whmcs/mtls/fingerprint',
array_filter([
'fingerprint' => $fingerprint,
'subject' => $subject,
]),
'mtls:register:' . date('Y-m-d')
);
}
public function mtlsStatus(): array
{
return $this->request('GET', '/api/v1/integrations/whmcs/mtls/status');
}
private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array
{
$pathParts = explode('?', $pathWithQuery, 2);
@@ -92,6 +127,20 @@ final class HexaGameCloudAddonApiClient
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
$requestUrl = rtrim($this->baseUrl, '/') . $path . ($canonicalQuery !== '' ? ('?' . $canonicalQuery) : '');
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'X-HGC-Integration-ID: ' . $this->integrationId,
'X-HGC-Timestamp: ' . $timestamp,
'X-HGC-Nonce: ' . $nonce,
'X-HGC-Idempotency-Key: ' . $idempotencyKey,
'X-HGC-Signature: ' . $signature,
];
if ($this->mtls !== [] && class_exists('HexaGameCloudMtlsConfig')) {
$headers = array_merge($headers, HexaGameCloudMtlsConfig::fingerprintHeader($this->mtls));
}
$ch = curl_init($requestUrl);
if ($ch === false) {
throw new RuntimeException('Unable to initialize HTTP client');
@@ -101,18 +150,14 @@ final class HexaGameCloudAddonApiClient
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-HGC-Integration-ID: ' . $this->integrationId,
'X-HGC-Timestamp: ' . $timestamp,
'X-HGC-Nonce: ' . $nonce,
'X-HGC-Idempotency-Key: ' . $idempotencyKey,
'X-HGC-Signature: ' . $signature,
],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $bodyJson,
]);
if ($this->mtls !== [] && class_exists('HexaGameCloudMtlsConfig')) {
HexaGameCloudMtlsConfig::applyToCurl($ch, $this->mtls);
}
$responseBody = curl_exec($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use WHMCS\Database\Capsule;
final class HexaGameCloudProductMappingStorage
{
public function listMappings(): array
{
return Capsule::table('mod_hexagamecloud_product_mappings')
->orderBy('whmcs_product_id', 'asc')
->get()
->map(static fn ($row) => (array) $row)
->all();
}
public function upsertMapping(array $input): void
{
Capsule::table('mod_hexagamecloud_product_mappings')->updateOrInsert(
['whmcs_product_id' => (int) $input['whmcs_product_id']],
[
'whmcs_product_name' => (string) ($input['whmcs_product_name'] ?? ''),
'plan_slug' => (string) $input['plan_slug'],
'default_edition' => (string) ($input['default_edition'] ?? 'JAVA'),
'default_software' => (string) ($input['default_software'] ?? 'VANILLA'),
'default_version' => (string) ($input['default_version'] ?? '1.21.1'),
'is_active' => !empty($input['is_active']) ? 1 : 0,
'updated_at' => gmdate('Y-m-d H:i:s'),
]
);
}
public function deleteMapping(int $productId): void
{
Capsule::table('mod_hexagamecloud_product_mappings')
->where('whmcs_product_id', $productId)
->delete();
}
public function listWhmcsProducts(): array
{
return Capsule::table('tblproducts')
->where('servertype', 'hexagamecloud')
->orderBy('name', 'asc')
->get(['id', 'name', 'gid'])
->map(static fn ($row) => (array) $row)
->all();
}
}

View File

@@ -44,6 +44,21 @@ CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_reconciliation` (
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_product_mappings` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`whmcs_product_id` int unsigned NOT NULL,
`whmcs_product_name` varchar(128) NOT NULL DEFAULT '',
`plan_slug` varchar(64) NOT NULL,
`default_edition` varchar(16) NOT NULL DEFAULT 'JAVA',
`default_software` varchar(32) NOT NULL DEFAULT 'VANILLA',
`default_version` varchar(16) NOT NULL DEFAULT '1.21.1',
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `whmcs_product_id` (`whmcs_product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `mod_hexagamecloud_installations` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());
INSERT IGNORE INTO `mod_hexagamecloud_sync_cursors` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_product_mappings` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`whmcs_product_id` int unsigned NOT NULL,
`whmcs_product_name` varchar(128) NOT NULL DEFAULT '',
`plan_slug` varchar(64) NOT NULL,
`default_edition` varchar(16) NOT NULL DEFAULT 'JAVA',
`default_software` varchar(32) NOT NULL DEFAULT 'VANILLA',
`default_version` varchar(16) NOT NULL DEFAULT '1.21.1',
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `whmcs_product_id` (`whmcs_product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -7,6 +7,8 @@
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
<a href="{{modulelink}}&action=events" class="btn btn-default">Events</a>
<a href="{{modulelink}}&action=reconciliation" class="btn btn-default">Reconciliation</a>
<a href="{{modulelink}}&action=mappings" class="btn btn-default">Mappings</a>
<a href="{{modulelink}}&action=mtls" class="btn btn-default">mTLS</a>
<a href="{{modulelink}}&action=poll" class="btn btn-primary">Poll events now</a>
</p>
@@ -49,6 +51,8 @@
<tr><th>Last reconciliation</th><td>{{stats.lastReconciliationAt}}</td></tr>
<tr><th>Last event sync</th><td>{{stats.lastEventSyncAt}}</td></tr>
<tr><th>Last error</th><td>{{lastError}}</td></tr>
<tr><th>mTLS enabled (GameCloud)</th><td>{{stats.mtlsEnabled}}</td></tr>
<tr><th>mTLS fingerprint registered</th><td>{{stats.mtlsFingerprintRegistered}}</td></tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,58 @@
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">Product mappings</h3></div>
<div class="panel-body">
<p>
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
<a href="{{modulelink}}&action=mappings" class="btn btn-primary">Mappings</a>
<a href="{{modulelink}}&action=mtls" class="btn btn-default">mTLS</a>
</p>
<h4>Add or update mapping</h4>
<form method="post" action="{{modulelink}}&action=save_mapping" class="form-horizontal">
<div class="form-group">
<label class="col-sm-3 control-label">WHMCS product</label>
<div class="col-sm-9">
<select name="whmcs_product_id" class="form-control" required>
{{mappings.product_options}}
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">GameCloud plan slug</label>
<div class="col-sm-9">
<select name="plan_slug" class="form-control" required>
{{mappings.plan_options}}
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Default version</label>
<div class="col-sm-9"><input type="text" name="default_version" value="1.21.1" class="form-control"></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Default software</label>
<div class="col-sm-9">
<select name="default_software" class="form-control">
<option>VANILLA</option><option>PAPER</option><option>PURPUR</option>
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<label><input type="checkbox" name="is_active" value="1" checked> Active</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-success">Save mapping</button>
</div>
</div>
</form>
<h4>Current mappings</h4>
<table class="table table-bordered">
<thead><tr><th>Product ID</th><th>Name</th><th>Plan</th><th>Software</th><th>Active</th><th></th></tr></thead>
<tbody>{{mappings.rows}}</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,39 @@
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">mTLS client certificate</h3></div>
<div class="panel-body">
<p>
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
<a href="{{modulelink}}&action=mappings" class="btn btn-default">Mappings</a>
</p>
<table class="table table-striped">
<tr><th>GameCloud mTLS enforced</th><td>{{status.mtlsEnabled}}</td></tr>
<tr><th>Fingerprint registered</th><td>{{status.fingerprintRegistered}}</td></tr>
<tr><th>Preview</th><td>{{status.fingerprintPreview}}</td></tr>
<tr><th>Subject</th><td>{{status.subject}}</td></tr>
<tr><th>Configured in addon</th><td>{{fingerprint_configured}}</td></tr>
</table>
<form method="post" action="{{modulelink}}&action=register_mtls" class="form-horizontal">
<div class="form-group">
<label class="col-sm-3 control-label">SHA-256 fingerprint</label>
<div class="col-sm-9">
<input type="text" name="fingerprint" class="form-control" placeholder="hex without colons" required>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Subject CN (optional)</label>
<div class="col-sm-9">
<input type="text" name="subject" class="form-control" placeholder="whmcs-integration-client">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-primary">Register fingerprint on GameCloud</button>
</div>
</div>
</form>
<p class="help-block">Enable <code>INTEGRATION_MTLS_ENABLED=true</code> on GameCloud and terminate TLS at your reverse proxy with client certificate verification.</p>
</div>
</div>