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.
This commit is contained in:
@@ -97,3 +97,6 @@ WEB_PORT=3000
|
|||||||
WHMCS_INTEGRATION_ID=local-dev-whmcs
|
WHMCS_INTEGRATION_ID=local-dev-whmcs
|
||||||
WHMCS_API_SECRET=local-dev-whmcs-api-secret-32chars-minimum
|
WHMCS_API_SECRET=local-dev-whmcs-api-secret-32chars-minimum
|
||||||
WHMCS_WEBHOOK_SECRET=
|
WHMCS_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# mTLS for WHMCS integration API (production)
|
||||||
|
INTEGRATION_MTLS_ENABLED=false
|
||||||
|
|||||||
78
apps/api/src/integrations/whmcs/integration-mtls.util.ts
Normal file
78
apps/api/src/integrations/whmcs/integration-mtls.util.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import {
|
||||||
|
INTEGRATION_MTLS_HEADERS,
|
||||||
|
isIntegrationMtlsEnabled,
|
||||||
|
normalizeCertificateFingerprint,
|
||||||
|
verifyClientCertificateFingerprint,
|
||||||
|
} from '@hexahost/integration-auth';
|
||||||
|
|
||||||
|
export function extractClientCertFingerprint(request: FastifyRequest): string | undefined {
|
||||||
|
const headerCandidates = [
|
||||||
|
INTEGRATION_MTLS_HEADERS.clientFingerprint,
|
||||||
|
'x-ssl-client-sha256',
|
||||||
|
'x-ssl-client-fingerprint',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const header of headerCandidates) {
|
||||||
|
const value = request.headers[header];
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return normalizeCertificateFingerprint(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = request.socket as {
|
||||||
|
getPeerCertificate?: () => { fingerprint256?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof socket.getPeerCertificate === 'function') {
|
||||||
|
const certificate = socket.getPeerCertificate();
|
||||||
|
if (certificate?.fingerprint256) {
|
||||||
|
return normalizeCertificateFingerprint(certificate.fingerprint256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractClientCertSubject(request: FastifyRequest): string | undefined {
|
||||||
|
const value = request.headers[INTEGRATION_MTLS_HEADERS.clientSubject];
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = request.socket as {
|
||||||
|
getPeerCertificate?: () => { subject?: { CN?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof socket.getPeerCertificate === 'function') {
|
||||||
|
const certificate = socket.getPeerCertificate();
|
||||||
|
if (certificate?.subject?.CN) {
|
||||||
|
return certificate.subject.CN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIntegrationMtls(
|
||||||
|
request: FastifyRequest,
|
||||||
|
expectedFingerprint: string | null | undefined,
|
||||||
|
): void {
|
||||||
|
if (!isIntegrationMtlsEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedFingerprint) {
|
||||||
|
throw new Error('INTEGRATION_MTLS_REQUIRED_BUT_UNREGISTERED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provided = extractClientCertFingerprint(request);
|
||||||
|
if (!provided) {
|
||||||
|
throw new Error('INTEGRATION_MTLS_CLIENT_CERT_MISSING');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyClientCertificateFingerprint(provided, expectedFingerprint)) {
|
||||||
|
throw new Error('INTEGRATION_MTLS_CLIENT_CERT_MISMATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import type { WhmcsDashboardStatsResponse } from '@hexahost/contracts';
|
import type { WhmcsDashboardStatsResponse } from '@hexahost/contracts';
|
||||||
|
|
||||||
|
import { isIntegrationMtlsEnabled } from '@hexahost/integration-auth';
|
||||||
|
|
||||||
import { getBillingProvider } from '../../billing/billing-provider';
|
import { getBillingProvider } from '../../billing/billing-provider';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export class WhmcsDashboardService {
|
|||||||
exportedUsagePeriods,
|
exportedUsagePeriods,
|
||||||
syncCursor,
|
syncCursor,
|
||||||
lastReconciledLink,
|
lastReconciledLink,
|
||||||
|
mtlsRow,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.whmcsClientLink.count({
|
this.prisma.whmcsClientLink.count({
|
||||||
where: { installationId: installation.id },
|
where: { installationId: installation.id },
|
||||||
@@ -54,6 +57,10 @@ export class WhmcsDashboardService {
|
|||||||
orderBy: { lastReconciledAt: 'desc' },
|
orderBy: { lastReconciledAt: 'desc' },
|
||||||
select: { lastReconciledAt: true },
|
select: { lastReconciledAt: true },
|
||||||
}),
|
}),
|
||||||
|
this.prisma.whmcsInstallation.findUniqueOrThrow({
|
||||||
|
where: { id: installation.id },
|
||||||
|
select: { mtlsClientFingerprint: true, mtlsClientSubject: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const suspendedServices = await this.prisma.whmcsServiceLink.count({
|
const suspendedServices = await this.prisma.whmcsServiceLink.count({
|
||||||
@@ -79,6 +86,8 @@ export class WhmcsDashboardService {
|
|||||||
eventsCursor: syncCursor?.eventsCursor ?? null,
|
eventsCursor: syncCursor?.eventsCursor ?? null,
|
||||||
lastEventSyncAt: syncCursor?.updatedAt.toISOString() ?? null,
|
lastEventSyncAt: syncCursor?.updatedAt.toISOString() ?? null,
|
||||||
lastReconciliationAt: lastReconciledLink?.lastReconciledAt?.toISOString() ?? null,
|
lastReconciliationAt: lastReconciledLink?.lastReconciledAt?.toISOString() ?? null,
|
||||||
|
mtlsEnabled: isIntegrationMtlsEnabled(),
|
||||||
|
mtlsFingerprintRegistered: Boolean(mtlsRow.mtlsClientFingerprint),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
whmcsImportServiceRequestSchema,
|
whmcsImportServiceRequestSchema,
|
||||||
whmcsProvisionServiceRequestSchema,
|
whmcsProvisionServiceRequestSchema,
|
||||||
whmcsReconcileRequestSchema,
|
whmcsReconcileRequestSchema,
|
||||||
|
whmcsRegisterMtlsRequestSchema,
|
||||||
whmcsSuspendRequestSchema,
|
whmcsSuspendRequestSchema,
|
||||||
whmcsUpsertClientRequestSchema,
|
whmcsUpsertClientRequestSchema,
|
||||||
whmcsUsageAdjustmentRequestSchema,
|
whmcsUsageAdjustmentRequestSchema,
|
||||||
whmcsUsageQuerySchema,
|
whmcsUsageQuerySchema,
|
||||||
|
whmcsValidatePlanRequestSchema,
|
||||||
} from '@hexahost/contracts';
|
} from '@hexahost/contracts';
|
||||||
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
||||||
import { WhmcsDashboardService } from './whmcs-dashboard.service';
|
import { WhmcsDashboardService } from './whmcs-dashboard.service';
|
||||||
import { WhmcsEventsService } from './whmcs-events.service';
|
import { WhmcsEventsService } from './whmcs-events.service';
|
||||||
|
import { WhmcsMtlsService } from './whmcs-mtls.service';
|
||||||
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
|
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
|
||||||
import { WhmcsUsageService } from './whmcs-usage.service';
|
import { WhmcsUsageService } from './whmcs-usage.service';
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ export class WhmcsIntegrationController {
|
|||||||
private readonly events: WhmcsEventsService,
|
private readonly events: WhmcsEventsService,
|
||||||
private readonly usage: WhmcsUsageService,
|
private readonly usage: WhmcsUsageService,
|
||||||
private readonly dashboard: WhmcsDashboardService,
|
private readonly dashboard: WhmcsDashboardService,
|
||||||
|
private readonly mtls: WhmcsMtlsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('dashboard/stats')
|
@Get('dashboard/stats')
|
||||||
@@ -66,6 +70,30 @@ export class WhmcsIntegrationController {
|
|||||||
return this.whmcs.listPlans();
|
return this.whmcs.listPlans();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('catalog/validate-plan')
|
||||||
|
validatePlan(@Body() body: unknown) {
|
||||||
|
const parsed = whmcsValidatePlanRequestSchema.parse(body);
|
||||||
|
return this.whmcs.validatePlanSlug(parsed.planSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('mtls/status')
|
||||||
|
mtlsStatus(@Req() request: WhmcsAuthenticatedRequest) {
|
||||||
|
return this.mtls.getMtlsStatus(request.whmcsInstallation!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('mtls/fingerprint')
|
||||||
|
registerMtlsFingerprint(
|
||||||
|
@Req() request: WhmcsAuthenticatedRequest,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
const parsed = whmcsRegisterMtlsRequestSchema.parse(body);
|
||||||
|
return this.mtls.registerFingerprint(
|
||||||
|
request.whmcsInstallation!,
|
||||||
|
parsed,
|
||||||
|
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Put('clients/:externalClientId')
|
@Put('clients/:externalClientId')
|
||||||
upsertClient(
|
upsertClient(
|
||||||
@Req() request: WhmcsAuthenticatedRequest,
|
@Req() request: WhmcsAuthenticatedRequest,
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import type Redis from 'ioredis';
|
|||||||
import {
|
import {
|
||||||
INTEGRATION_HEADERS,
|
INTEGRATION_HEADERS,
|
||||||
canonicalQuery,
|
canonicalQuery,
|
||||||
|
isIntegrationMtlsEnabled,
|
||||||
verifyRequestSignature,
|
verifyRequestSignature,
|
||||||
} from '@hexahost/integration-auth';
|
} from '@hexahost/integration-auth';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
import { assertIntegrationMtls } from './integration-mtls.util';
|
||||||
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||||
|
|
||||||
export type WhmcsInstallationContext = {
|
export type WhmcsInstallationContext = {
|
||||||
@@ -50,7 +52,12 @@ export class WhmcsIntegrationGuard implements CanActivate {
|
|||||||
|
|
||||||
const installation = await this.prisma.whmcsInstallation.findFirst({
|
const installation = await this.prisma.whmcsInstallation.findFirst({
|
||||||
where: { integrationId, isActive: true },
|
where: { integrationId, isActive: true },
|
||||||
select: { id: true, integrationId: true, apiSecret: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
integrationId: true,
|
||||||
|
apiSecret: true,
|
||||||
|
mtlsClientFingerprint: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!installation) {
|
if (!installation) {
|
||||||
@@ -86,7 +93,20 @@ export class WhmcsIntegrationGuard implements CanActivate {
|
|||||||
throw new UnauthorizedException('Invalid integration signature');
|
throw new UnauthorizedException('Invalid integration signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
request.whmcsInstallation = installation;
|
if (isIntegrationMtlsEnabled()) {
|
||||||
|
try {
|
||||||
|
assertIntegrationMtls(request, installation.mtlsClientFingerprint);
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof Error ? error.message : 'INTEGRATION_MTLS_FAILED';
|
||||||
|
throw new UnauthorizedException(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.whmcsInstallation = {
|
||||||
|
id: installation.id,
|
||||||
|
integrationId: installation.integrationId,
|
||||||
|
apiSecret: installation.apiSecret,
|
||||||
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { WhmcsEventsService } from './whmcs-events.service';
|
|||||||
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||||
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
|
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
|
||||||
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
||||||
|
import { WhmcsMtlsService } from './whmcs-mtls.service';
|
||||||
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
|
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
|
||||||
import { WhmcsUsageService } from './whmcs-usage.service';
|
import { WhmcsUsageService } from './whmcs-usage.service';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
|||||||
WhmcsEventsService,
|
WhmcsEventsService,
|
||||||
WhmcsUsageService,
|
WhmcsUsageService,
|
||||||
WhmcsDashboardService,
|
WhmcsDashboardService,
|
||||||
|
WhmcsMtlsService,
|
||||||
WhmcsIntegrationGuard,
|
WhmcsIntegrationGuard,
|
||||||
{
|
{
|
||||||
provide: INTEGRATIONS_REDIS,
|
provide: INTEGRATIONS_REDIS,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
WhmcsServiceResponse,
|
WhmcsServiceResponse,
|
||||||
WhmcsUpsertClientRequest,
|
WhmcsUpsertClientRequest,
|
||||||
WhmcsUpsertClientResponse,
|
WhmcsUpsertClientResponse,
|
||||||
|
WhmcsValidatePlanResponse,
|
||||||
} from '@hexahost/contracts';
|
} from '@hexahost/contracts';
|
||||||
import { formatJoinAddress } from '@hexahost/dns';
|
import { formatJoinAddress } from '@hexahost/dns';
|
||||||
import type { GameServer } from '@hexahost/database';
|
import type { GameServer } from '@hexahost/database';
|
||||||
@@ -61,6 +62,28 @@ export class WhmcsIntegrationService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validatePlanSlug(planSlug: string): Promise<WhmcsValidatePlanResponse> {
|
||||||
|
const plan = await this.prisma.plan.findFirst({
|
||||||
|
where: { slug: planSlug, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return { valid: false, planSlug, plan: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
planSlug,
|
||||||
|
plan: {
|
||||||
|
id: plan.id,
|
||||||
|
slug: plan.slug,
|
||||||
|
name: plan.name,
|
||||||
|
maxRamMb: plan.maxRamMb,
|
||||||
|
isFree: plan.isFree,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async upsertClient(
|
async upsertClient(
|
||||||
installation: InstallationRef,
|
installation: InstallationRef,
|
||||||
externalClientId: string,
|
externalClientId: string,
|
||||||
|
|||||||
93
apps/api/src/integrations/whmcs/whmcs-mtls.service.ts
Normal file
93
apps/api/src/integrations/whmcs/whmcs-mtls.service.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { WhmcsRegisterMtlsRequest } from '@hexahost/contracts';
|
||||||
|
import { normalizeCertificateFingerprint } from '@hexahost/integration-auth';
|
||||||
|
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
type InstallationRef = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WhmcsMtlsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async registerFingerprint(
|
||||||
|
installation: InstallationRef,
|
||||||
|
input: WhmcsRegisterMtlsRequest,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<{
|
||||||
|
integrationId: string;
|
||||||
|
fingerprint: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
registered: boolean;
|
||||||
|
}> {
|
||||||
|
const existingOp = await this.prisma.integrationOperation.findUnique({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
});
|
||||||
|
if (existingOp?.result) {
|
||||||
|
return existingOp.result as {
|
||||||
|
integrationId: string;
|
||||||
|
fingerprint: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
registered: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = normalizeCertificateFingerprint(input.fingerprint);
|
||||||
|
|
||||||
|
const updated = await this.prisma.whmcsInstallation.update({
|
||||||
|
where: { id: installation.id },
|
||||||
|
data: {
|
||||||
|
mtlsClientFingerprint: fingerprint,
|
||||||
|
mtlsClientSubject: input.subject ?? null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
integrationId: true,
|
||||||
|
mtlsClientFingerprint: true,
|
||||||
|
mtlsClientSubject: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
integrationId: updated.integrationId,
|
||||||
|
fingerprint: updated.mtlsClientFingerprint,
|
||||||
|
subject: updated.mtlsClientSubject,
|
||||||
|
registered: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.integrationOperation.create({
|
||||||
|
data: {
|
||||||
|
installationId: installation.id,
|
||||||
|
idempotencyKey,
|
||||||
|
operation: 'mtls:register',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
result: result as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMtlsStatus(installation: InstallationRef) {
|
||||||
|
const row = await this.prisma.whmcsInstallation.findUniqueOrThrow({
|
||||||
|
where: { id: installation.id },
|
||||||
|
select: {
|
||||||
|
integrationId: true,
|
||||||
|
mtlsClientFingerprint: true,
|
||||||
|
mtlsClientSubject: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
integrationId: row.integrationId,
|
||||||
|
mtlsEnabled: process.env['INTEGRATION_MTLS_ENABLED'] === 'true',
|
||||||
|
fingerprintRegistered: Boolean(row.mtlsClientFingerprint),
|
||||||
|
fingerprintPreview: row.mtlsClientFingerprint
|
||||||
|
? `${row.mtlsClientFingerprint.slice(0, 8)}…${row.mtlsClientFingerprint.slice(-8)}`
|
||||||
|
: null,
|
||||||
|
subject: row.mtlsClientSubject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/api/test/whmcs-mtls-mapping.test.js
Normal file
67
apps/api/test/whmcs-mtls-mapping.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isIntegrationMtlsEnabled,
|
||||||
|
normalizeCertificateFingerprint,
|
||||||
|
verifyClientCertificateFingerprint,
|
||||||
|
} from '@hexahost/integration-auth';
|
||||||
|
|
||||||
|
describe('integration mTLS utilities', () => {
|
||||||
|
it('normalizes fingerprint colons', () => {
|
||||||
|
const normalized = normalizeCertificateFingerprint('AB:CD:EF');
|
||||||
|
assert.equal(normalized, 'abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies matching fingerprints', () => {
|
||||||
|
assert.equal(
|
||||||
|
verifyClientCertificateFingerprint('ab:cd', 'abcd'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('whmcs mtls and product mapping contract', () => {
|
||||||
|
it('documents mTLS guard and API routes', () => {
|
||||||
|
const guard = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.guard.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(guard.includes('isIntegrationMtlsEnabled'));
|
||||||
|
assert.ok(guard.includes('assertIntegrationMtls'));
|
||||||
|
|
||||||
|
const controller = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(controller.includes("'mtls/status'"));
|
||||||
|
assert.ok(controller.includes("'mtls/fingerprint'"));
|
||||||
|
assert.ok(controller.includes("'catalog/validate-plan'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents addon product mapping and shared resolver', () => {
|
||||||
|
const addon = readFileSync(
|
||||||
|
join(process.cwd(), '..', '..', 'integrations/whmcs/modules/addons/hexagamecloud/hexagamecloud.php'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(addon.includes('hexagamecloud_page_mappings'));
|
||||||
|
assert.ok(addon.includes('ProductMappingStorage'));
|
||||||
|
|
||||||
|
const resolver = readFileSync(
|
||||||
|
join(process.cwd(), '..', '..', 'integrations/whmcs/lib/ProductMapping.php'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(resolver.includes('resolvePlanSlug'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults mTLS disabled outside production flag', () => {
|
||||||
|
const previous = process.env['INTEGRATION_MTLS_ENABLED'];
|
||||||
|
delete process.env['INTEGRATION_MTLS_ENABLED'];
|
||||||
|
assert.equal(isIntegrationMtlsEnabled(), false);
|
||||||
|
if (previous !== undefined) {
|
||||||
|
process.env['INTEGRATION_MTLS_ENABLED'] = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
17
deploy/nginx/integration-mtls.conf.example
Normal file
17
deploy/nginx/integration-mtls.conf.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Example nginx snippet for GameCloud WHMCS integration mTLS
|
||||||
|
# Place inside the server {} block that proxies to the API upstream.
|
||||||
|
|
||||||
|
ssl_client_certificate /etc/ssl/gamecloud/integration-ca.crt;
|
||||||
|
ssl_verify_client optional;
|
||||||
|
|
||||||
|
location /api/v1/integrations/whmcs/ {
|
||||||
|
proxy_pass http://gamecloud_api;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# OpenSSL 1.1.1+ / nginx 1.15.3+
|
||||||
|
proxy_set_header X-HGC-Client-Cert-Fingerprint $ssl_client_fingerprint;
|
||||||
|
proxy_set_header X-HGC-Client-Cert-Subject $ssl_client_s_dn;
|
||||||
|
}
|
||||||
@@ -2,7 +2,40 @@
|
|||||||
|
|
||||||
Last updated: 2026-06-26
|
Last updated: 2026-06-26
|
||||||
|
|
||||||
## Current phase: WHMCS Addon Dashboard ✅
|
## Current phase: WHMCS hardening — mTLS + Product mappings ✅
|
||||||
|
|
||||||
|
### mTLS integration API
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Optional enforcement | Done | `INTEGRATION_MTLS_ENABLED=true` |
|
||||||
|
| Fingerprint binding | Done | Per `WhmcsInstallation` |
|
||||||
|
| Guard | Done | HMAC + mTLS layered |
|
||||||
|
| API | Done | `GET/PUT .../mtls/*` |
|
||||||
|
| PHP clients | Done | Addon + server module curl mTLS |
|
||||||
|
| nginx example | Done | `deploy/nginx/integration-mtls.conf.example` |
|
||||||
|
| Docs | Done | `docs/integrations/whmcs/mtls.md` |
|
||||||
|
|
||||||
|
### Product mapping UI
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Addon mappings page | Done | WHMCS product → GameCloud plan |
|
||||||
|
| Validation API | Done | `POST /catalog/validate-plan` |
|
||||||
|
| WHMCS table | Done | `mod_hexagamecloud_product_mappings` |
|
||||||
|
| Server module hook | Done | `ProductMapping.php` resolver |
|
||||||
|
| Docs | Done | `docs/integrations/whmcs/product-mappings.md` |
|
||||||
|
|
||||||
|
### Abnahmekriterium
|
||||||
|
|
||||||
|
- [x] Addon maps WHMCS products to validated GameCloud plan slugs
|
||||||
|
- [x] Provisioning uses mapping when active
|
||||||
|
- [x] mTLS can be enabled without breaking dev (HMAC-only default)
|
||||||
|
- [ ] Manual E2E: nginx mTLS + WHMCS provision
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WHMCS Addon Dashboard ✅
|
||||||
|
|
||||||
### WHMCS Addon module completed
|
### WHMCS Addon module completed
|
||||||
|
|
||||||
@@ -266,4 +299,4 @@ Server entry fields:
|
|||||||
|
|
||||||
### Next phase: Post-MVP hardening
|
### Next phase: Post-MVP hardening
|
||||||
|
|
||||||
- Penetration test, product mapping UI in addon, mTLS for integration API
|
- Penetration test, configurable option mappings, IP allowlist for integration API
|
||||||
|
|||||||
62
docs/integrations/whmcs/mtls.md
Normal file
62
docs/integrations/whmcs/mtls.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# WHMCS integration mTLS
|
||||||
|
|
||||||
|
GameCloud integration requests use **HMAC signatures** always. In production you can additionally require a **client TLS certificate**.
|
||||||
|
|
||||||
|
## Enable on GameCloud
|
||||||
|
|
||||||
|
```env
|
||||||
|
INTEGRATION_MTLS_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, every `/api/v1/integrations/whmcs/*` request must:
|
||||||
|
|
||||||
|
1. Pass HMAC validation (unchanged)
|
||||||
|
2. Present a client certificate whose SHA-256 fingerprint matches the installation record
|
||||||
|
|
||||||
|
## Register client fingerprint
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/v1/integrations/whmcs/mtls/fingerprint
|
||||||
|
X-HGC-Client-Cert-Fingerprint: <optional when registering via proxy>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fingerprint": "ab12…",
|
||||||
|
"subject": "whmcs-integration-client"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the WHMCS addon page **mTLS → Register fingerprint**.
|
||||||
|
|
||||||
|
Generate fingerprint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 -in client.crt -outform DER | openssl dgst -sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse proxy (recommended)
|
||||||
|
|
||||||
|
Terminate mTLS at nginx and forward the fingerprint:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
ssl_verify_client optional;
|
||||||
|
proxy_set_header X-HGC-Client-Cert-Fingerprint $ssl_client_fingerprint;
|
||||||
|
proxy_set_header X-HGC-Client-Cert-Subject $ssl_client_s_dn;
|
||||||
|
```
|
||||||
|
|
||||||
|
See `deploy/nginx/integration-mtls.conf.example`.
|
||||||
|
|
||||||
|
## WHMCS PHP client
|
||||||
|
|
||||||
|
In the addon module enable **Use mTLS client certificate** and set paths to:
|
||||||
|
|
||||||
|
- Client certificate PEM
|
||||||
|
- Client private key PEM
|
||||||
|
- CA bundle (GameCloud or internal CA)
|
||||||
|
|
||||||
|
The PHP client sends `X-HGC-Client-Cert-Fingerprint` when configured.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Leave `INTEGRATION_MTLS_ENABLED=false` (default). HMAC-only auth matches local dev workflow.
|
||||||
31
docs/integrations/whmcs/product-mappings.md
Normal file
31
docs/integrations/whmcs/product-mappings.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# WHMCS product mappings
|
||||||
|
|
||||||
|
Map WHMCS products (`servertype=hexagamecloud`) to GameCloud plan slugs without relying on product config option text fields alone.
|
||||||
|
|
||||||
|
## Addon UI
|
||||||
|
|
||||||
|
**Addons → HexaHost GameCloud → Mappings**
|
||||||
|
|
||||||
|
1. Select a WHMCS product
|
||||||
|
2. Choose a GameCloud plan from the live catalog (`GET /catalog/plans`)
|
||||||
|
3. Set default Minecraft version and software family
|
||||||
|
4. Save — validated via `POST /catalog/validate-plan`
|
||||||
|
|
||||||
|
Mappings are stored in `mod_hexagamecloud_product_mappings`.
|
||||||
|
|
||||||
|
## Server module behavior
|
||||||
|
|
||||||
|
On `CreateAccount` and `ChangePackage`, the provisioning module resolves:
|
||||||
|
|
||||||
|
- `planSlug` from mapping table (active row) or falls back to config option 1
|
||||||
|
- `minecraftVersion` / `softwareFamily` from mapping defaults or config options
|
||||||
|
|
||||||
|
Shared resolver: `integrations/whmcs/lib/ProductMapping.php`
|
||||||
|
|
||||||
|
## Dry-run validation
|
||||||
|
|
||||||
|
Before saving, the addon calls GameCloud to ensure the plan slug exists and is active. Invalid slugs are rejected — WHMCS product names alone are never used for mapping keys.
|
||||||
|
|
||||||
|
## Import / export
|
||||||
|
|
||||||
|
Mappings are WHMCS-local. Export the `mod_hexagamecloud_product_mappings` table for backup when migrating WHMCS hosts.
|
||||||
72
integrations/whmcs/lib/MtlsConfig.php
Normal file
72
integrations/whmcs/lib/MtlsConfig.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class HexaGameCloudMtlsConfig
|
||||||
|
{
|
||||||
|
public static function fromAddonSettings(array $vars): array
|
||||||
|
{
|
||||||
|
if (empty($vars['use_mtls'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::normalize([
|
||||||
|
'cert' => (string) ($vars['mtls_cert_path'] ?? ''),
|
||||||
|
'key' => (string) ($vars['mtls_key_path'] ?? ''),
|
||||||
|
'ca' => (string) ($vars['mtls_ca_path'] ?? ''),
|
||||||
|
'fingerprint' => (string) ($vars['mtls_cert_fingerprint'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromServerParams(array $params): array
|
||||||
|
{
|
||||||
|
if (empty($params['configoptions']['use_mtls']) && empty($params['configoption5'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::normalize([
|
||||||
|
'cert' => (string) ($params['configoptions']['mtls_cert_path'] ?? $params['configoption5'] ?? ''),
|
||||||
|
'key' => (string) ($params['configoptions']['mtls_key_path'] ?? $params['configoption6'] ?? ''),
|
||||||
|
'ca' => (string) ($params['configoptions']['mtls_ca_path'] ?? $params['configoption7'] ?? ''),
|
||||||
|
'fingerprint' => (string) ($params['configoptions']['mtls_cert_fingerprint'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function applyToCurl($ch, array $mtls): void
|
||||||
|
{
|
||||||
|
if ($mtls === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($mtls['cert']) && is_readable($mtls['cert'])) {
|
||||||
|
curl_setopt($ch, CURLOPT_SSLCERT, $mtls['cert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($mtls['key']) && is_readable($mtls['key'])) {
|
||||||
|
curl_setopt($ch, CURLOPT_SSLKEY, $mtls['key']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($mtls['ca']) && is_readable($mtls['ca'])) {
|
||||||
|
curl_setopt($ch, CURLOPT_CAINFO, $mtls['ca']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fingerprintHeader(array $mtls): array
|
||||||
|
{
|
||||||
|
if (empty($mtls['fingerprint'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fingerprint = strtolower(str_replace(':', '', $mtls['fingerprint']));
|
||||||
|
return ['X-HGC-Client-Cert-Fingerprint: ' . $fingerprint];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalize(array $input): array
|
||||||
|
{
|
||||||
|
if ($input['cert'] === '' && $input['key'] === '' && $input['ca'] === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
integrations/whmcs/lib/ProductMapping.php
Normal file
75
integrations/whmcs/lib/ProductMapping.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use WHMCS\Database\Capsule;
|
||||||
|
|
||||||
|
final class HexaGameCloudProductMapping
|
||||||
|
{
|
||||||
|
public static function resolvePlanSlug(array $params, string $fallback = 'free'): string
|
||||||
|
{
|
||||||
|
$productId = (int) ($params['pid'] ?? 0);
|
||||||
|
if ($productId <= 0) {
|
||||||
|
return $params['configoption1'] ?: $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mapping = Capsule::table('mod_hexagamecloud_product_mappings')
|
||||||
|
->where('whmcs_product_id', $productId)
|
||||||
|
->where('is_active', 1)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($mapping && !empty($mapping->plan_slug)) {
|
||||||
|
return (string) $mapping->plan_slug;
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Mapping table may not exist before addon activation
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params['configoption1'] ?: $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveSoftwareFamily(array $params, string $fallback = 'VANILLA'): string
|
||||||
|
{
|
||||||
|
$productId = (int) ($params['pid'] ?? 0);
|
||||||
|
if ($productId <= 0) {
|
||||||
|
return $params['configoption3'] ?: $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mapping = Capsule::table('mod_hexagamecloud_product_mappings')
|
||||||
|
->where('whmcs_product_id', $productId)
|
||||||
|
->where('is_active', 1)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($mapping && !empty($mapping->default_software)) {
|
||||||
|
return (string) $mapping->default_software;
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params['configoption3'] ?: $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveMinecraftVersion(array $params, string $fallback = '1.21.1'): string
|
||||||
|
{
|
||||||
|
$productId = (int) ($params['pid'] ?? 0);
|
||||||
|
if ($productId <= 0) {
|
||||||
|
return $params['configoption2'] ?: $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mapping = Capsule::table('mod_hexagamecloud_product_mappings')
|
||||||
|
->where('whmcs_product_id', $productId)
|
||||||
|
->where('is_active', 1)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($mapping && !empty($mapping->default_version)) {
|
||||||
|
return (string) $mapping->default_version;
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params['configoption2'] ?: $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,3 +34,5 @@ Created on activation (`sql/install.sql`):
|
|||||||
- `POST /integrations/whmcs/reconcile`
|
- `POST /integrations/whmcs/reconcile`
|
||||||
|
|
||||||
See [docs/integrations/whmcs/addon-module.md](../../../../docs/integrations/whmcs/addon-module.md).
|
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).
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ if (!defined('WHMCS')) {
|
|||||||
require_once __DIR__ . '/lib/ApiClient.php';
|
require_once __DIR__ . '/lib/ApiClient.php';
|
||||||
require_once __DIR__ . '/lib/ModuleStorage.php';
|
require_once __DIR__ . '/lib/ModuleStorage.php';
|
||||||
require_once __DIR__ . '/lib/EventPoller.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
|
function hexagamecloud_config(): array
|
||||||
{
|
{
|
||||||
@@ -60,6 +62,32 @@ function hexagamecloud_config(): array
|
|||||||
'Type' => 'yesno',
|
'Type' => 'yesno',
|
||||||
'Description' => 'Run dry reconciliation during daily event poll',
|
'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
|
function hexagamecloud_output(array $vars): void
|
||||||
{
|
{
|
||||||
$action = $_GET['action'] ?? 'dashboard';
|
$action = $_GET['action'] ?? 'dashboard';
|
||||||
@@ -127,6 +174,21 @@ function hexagamecloud_output(array $vars): void
|
|||||||
case 'ack':
|
case 'ack':
|
||||||
hexagamecloud_action_ack($vars, $client, $storage, $modulelink);
|
hexagamecloud_action_ack($vars, $client, $storage, $modulelink);
|
||||||
break;
|
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:
|
default:
|
||||||
hexagamecloud_page_dashboard($vars, $client, $storage, $modulelink);
|
hexagamecloud_page_dashboard($vars, $client, $storage, $modulelink);
|
||||||
break;
|
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.');
|
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(
|
function hexagamecloud_page_dashboard(
|
||||||
@@ -386,6 +450,51 @@ function hexagamecloud_expand_blocks(string $html, array $data): string
|
|||||||
$html = str_replace('{{reconciliation.result}}', '', $html);
|
$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;
|
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 '<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>';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
final class HexaGameCloudAddonApiClient
|
final class HexaGameCloudAddonApiClient
|
||||||
{
|
{
|
||||||
|
private array $mtls = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $baseUrl,
|
private readonly string $baseUrl,
|
||||||
private readonly string $integrationId,
|
private readonly string $integrationId,
|
||||||
private readonly string $apiSecret,
|
private readonly string $apiSecret,
|
||||||
|
array $mtls = [],
|
||||||
) {
|
) {
|
||||||
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
|
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
|
||||||
throw new RuntimeException('API URL, integration ID and secret are required');
|
throw new RuntimeException('API URL, integration ID and secret are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->mtls = $mtls;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function health(): array
|
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
|
private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array
|
||||||
{
|
{
|
||||||
$pathParts = explode('?', $pathWithQuery, 2);
|
$pathParts = explode('?', $pathWithQuery, 2);
|
||||||
@@ -92,6 +127,20 @@ final class HexaGameCloudAddonApiClient
|
|||||||
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
||||||
$requestUrl = rtrim($this->baseUrl, '/') . $path . ($canonicalQuery !== '' ? ('?' . $canonicalQuery) : '');
|
$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);
|
$ch = curl_init($requestUrl);
|
||||||
if ($ch === false) {
|
if ($ch === false) {
|
||||||
throw new RuntimeException('Unable to initialize HTTP client');
|
throw new RuntimeException('Unable to initialize HTTP client');
|
||||||
@@ -101,18 +150,14 @@ final class HexaGameCloudAddonApiClient
|
|||||||
CURLOPT_CUSTOMREQUEST => $method,
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => $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,
|
|
||||||
],
|
|
||||||
CURLOPT_POSTFIELDS => $bodyJson,
|
CURLOPT_POSTFIELDS => $bodyJson,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->mtls !== [] && class_exists('HexaGameCloudMtlsConfig')) {
|
||||||
|
HexaGameCloudMtlsConfig::applyToCurl($ch, $this->mtls);
|
||||||
|
}
|
||||||
|
|
||||||
$responseBody = curl_exec($ch);
|
$responseBody = curl_exec($ch);
|
||||||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,21 @@ CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_reconciliation` (
|
|||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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_installations` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());
|
||||||
|
|
||||||
INSERT IGNORE INTO `mod_hexagamecloud_sync_cursors` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());
|
INSERT IGNORE INTO `mod_hexagamecloud_sync_cursors` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
|
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
|
||||||
<a href="{{modulelink}}&action=events" class="btn btn-default">Events</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=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>
|
<a href="{{modulelink}}&action=poll" class="btn btn-primary">Poll events now</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@
|
|||||||
<tr><th>Last reconciliation</th><td>{{stats.lastReconciliationAt}}</td></tr>
|
<tr><th>Last reconciliation</th><td>{{stats.lastReconciliationAt}}</td></tr>
|
||||||
<tr><th>Last event sync</th><td>{{stats.lastEventSyncAt}}</td></tr>
|
<tr><th>Last event sync</th><td>{{stats.lastEventSyncAt}}</td></tr>
|
||||||
<tr><th>Last error</th><td>{{lastError}}</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -5,6 +5,7 @@ if (!defined('WHMCS')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/ApiClient.php';
|
require_once __DIR__ . '/lib/ApiClient.php';
|
||||||
|
require_once __DIR__ . '/../../../lib/ProductMapping.php';
|
||||||
|
|
||||||
function hexagamecloud_MetaData(): array
|
function hexagamecloud_MetaData(): array
|
||||||
{
|
{
|
||||||
@@ -59,10 +60,10 @@ function hexagamecloud_CreateAccount(array $params): string
|
|||||||
'externalServiceId' => $externalServiceId,
|
'externalServiceId' => $externalServiceId,
|
||||||
'externalClientId' => $externalClientId,
|
'externalClientId' => $externalClientId,
|
||||||
'externalProductId' => (string) ($params['pid'] ?? ''),
|
'externalProductId' => (string) ($params['pid'] ?? ''),
|
||||||
'planSlug' => $params['configoption1'] ?: 'free',
|
'planSlug' => HexaGameCloudProductMapping::resolvePlanSlug($params),
|
||||||
'serverName' => $params['domain'] ?: ('server-' . $externalServiceId),
|
'serverName' => $params['domain'] ?: ('server-' . $externalServiceId),
|
||||||
'minecraftVersion' => $params['configoption2'] ?: '1.21.1',
|
'minecraftVersion' => HexaGameCloudProductMapping::resolveMinecraftVersion($params),
|
||||||
'softwareFamily' => $params['configoption3'] ?: 'VANILLA',
|
'softwareFamily' => HexaGameCloudProductMapping::resolveSoftwareFamily($params),
|
||||||
'edition' => 'JAVA',
|
'edition' => 'JAVA',
|
||||||
'eulaAccepted' => true,
|
'eulaAccepted' => true,
|
||||||
]);
|
]);
|
||||||
@@ -101,7 +102,7 @@ function hexagamecloud_ChangePackage(array $params): string
|
|||||||
try {
|
try {
|
||||||
$client = new HexaGameCloudApiClient($params);
|
$client = new HexaGameCloudApiClient($params);
|
||||||
$client->changePackage((string) $params['serviceid'], [
|
$client->changePackage((string) $params['serviceid'], [
|
||||||
'planSlug' => $params['configoption1'] ?: 'free',
|
'planSlug' => HexaGameCloudProductMapping::resolvePlanSlug($params),
|
||||||
]);
|
]);
|
||||||
return 'success';
|
return 'success';
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ final class HexaGameCloudApiClient
|
|||||||
private string $baseUrl;
|
private string $baseUrl;
|
||||||
private string $integrationId;
|
private string $integrationId;
|
||||||
private string $apiSecret;
|
private string $apiSecret;
|
||||||
|
private array $mtls = [];
|
||||||
|
|
||||||
public function __construct(array $params)
|
public function __construct(array $params)
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,12 @@ final class HexaGameCloudApiClient
|
|||||||
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
|
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
|
||||||
throw new RuntimeException('GameCloud server hostname, integration ID and API secret are required');
|
throw new RuntimeException('GameCloud server hostname, integration ID and API secret are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mtlsLib = __DIR__ . '/../../../lib/MtlsConfig.php';
|
||||||
|
if (is_readable($mtlsLib)) {
|
||||||
|
require_once $mtlsLib;
|
||||||
|
$this->mtls = HexaGameCloudMtlsConfig::fromServerParams($params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function health(): array
|
public function health(): array
|
||||||
@@ -200,6 +207,20 @@ final class HexaGameCloudApiClient
|
|||||||
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
||||||
$requestUrl = $this->baseUrl . $path . ($canonicalQuery !== '' ? ('?' . $canonicalQuery) : '');
|
$requestUrl = $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);
|
$ch = curl_init($requestUrl);
|
||||||
if ($ch === false) {
|
if ($ch === false) {
|
||||||
throw new RuntimeException('Unable to initialize HTTP client');
|
throw new RuntimeException('Unable to initialize HTTP client');
|
||||||
@@ -209,18 +230,14 @@ final class HexaGameCloudApiClient
|
|||||||
CURLOPT_CUSTOMREQUEST => $method,
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => $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,
|
|
||||||
],
|
|
||||||
CURLOPT_POSTFIELDS => $bodyJson,
|
CURLOPT_POSTFIELDS => $bodyJson,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->mtls !== [] && class_exists('HexaGameCloudMtlsConfig')) {
|
||||||
|
HexaGameCloudMtlsConfig::applyToCurl($ch, $this->mtls);
|
||||||
|
}
|
||||||
|
|
||||||
$responseBody = curl_exec($ch);
|
$responseBody = curl_exec($ch);
|
||||||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ export {
|
|||||||
whmcsUsageResponseSchema,
|
whmcsUsageResponseSchema,
|
||||||
whmcsUsageAdjustmentRequestSchema,
|
whmcsUsageAdjustmentRequestSchema,
|
||||||
whmcsDashboardStatsResponseSchema,
|
whmcsDashboardStatsResponseSchema,
|
||||||
|
whmcsRegisterMtlsRequestSchema,
|
||||||
|
whmcsMtlsStatusResponseSchema,
|
||||||
|
whmcsValidatePlanRequestSchema,
|
||||||
|
whmcsValidatePlanResponseSchema,
|
||||||
type WhmcsReconcileRequest,
|
type WhmcsReconcileRequest,
|
||||||
type WhmcsReconcileResponse,
|
type WhmcsReconcileResponse,
|
||||||
type WhmcsListAccountsResponse,
|
type WhmcsListAccountsResponse,
|
||||||
@@ -264,4 +268,7 @@ export {
|
|||||||
type WhmcsUsageResponse,
|
type WhmcsUsageResponse,
|
||||||
type WhmcsUsageAdjustmentRequest,
|
type WhmcsUsageAdjustmentRequest,
|
||||||
type WhmcsDashboardStatsResponse,
|
type WhmcsDashboardStatsResponse,
|
||||||
|
type WhmcsRegisterMtlsRequest,
|
||||||
|
type WhmcsMtlsStatusResponse,
|
||||||
|
type WhmcsValidatePlanResponse,
|
||||||
} from './whmcs';
|
} from './whmcs';
|
||||||
|
|||||||
@@ -284,6 +284,35 @@ export const whmcsDashboardStatsResponseSchema = z.object({
|
|||||||
eventsCursor: z.string().uuid().nullable(),
|
eventsCursor: z.string().uuid().nullable(),
|
||||||
lastEventSyncAt: z.string().datetime().nullable(),
|
lastEventSyncAt: z.string().datetime().nullable(),
|
||||||
lastReconciliationAt: z.string().datetime().nullable(),
|
lastReconciliationAt: z.string().datetime().nullable(),
|
||||||
|
mtlsEnabled: z.boolean(),
|
||||||
|
mtlsFingerprintRegistered: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type WhmcsDashboardStatsResponse = z.infer<typeof whmcsDashboardStatsResponseSchema>;
|
export type WhmcsDashboardStatsResponse = z.infer<typeof whmcsDashboardStatsResponseSchema>;
|
||||||
|
|
||||||
|
export const whmcsRegisterMtlsRequestSchema = z.object({
|
||||||
|
fingerprint: z.string().min(32).max(128),
|
||||||
|
subject: z.string().max(256).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const whmcsMtlsStatusResponseSchema = z.object({
|
||||||
|
integrationId: z.string(),
|
||||||
|
mtlsEnabled: z.boolean(),
|
||||||
|
fingerprintRegistered: z.boolean(),
|
||||||
|
fingerprintPreview: z.string().nullable(),
|
||||||
|
subject: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const whmcsValidatePlanRequestSchema = z.object({
|
||||||
|
planSlug: z.string().min(1).max(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const whmcsValidatePlanResponseSchema = z.object({
|
||||||
|
valid: z.boolean(),
|
||||||
|
planSlug: z.string(),
|
||||||
|
plan: whmcsPlanCatalogItemSchema.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WhmcsRegisterMtlsRequest = z.infer<typeof whmcsRegisterMtlsRequestSchema>;
|
||||||
|
export type WhmcsMtlsStatusResponse = z.infer<typeof whmcsMtlsStatusResponseSchema>;
|
||||||
|
export type WhmcsValidatePlanResponse = z.infer<typeof whmcsValidatePlanResponseSchema>;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
|||||||
|
-- mTLS client certificate binding for WHMCS installations
|
||||||
|
|
||||||
|
ALTER TABLE "whmcs_installations"
|
||||||
|
ADD COLUMN "mtlsClientFingerprint" TEXT,
|
||||||
|
ADD COLUMN "mtlsClientSubject" TEXT;
|
||||||
@@ -735,6 +735,8 @@ model WhmcsInstallation {
|
|||||||
integrationId String @unique
|
integrationId String @unique
|
||||||
name String
|
name String
|
||||||
apiSecret String
|
apiSecret String
|
||||||
|
mtlsClientFingerprint String?
|
||||||
|
mtlsClientSubject String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,3 +7,11 @@ export {
|
|||||||
verifyRequestSignature,
|
verifyRequestSignature,
|
||||||
type SignedRequestParts,
|
type SignedRequestParts,
|
||||||
} from './hmac';
|
} from './hmac';
|
||||||
|
|
||||||
|
export {
|
||||||
|
INTEGRATION_MTLS_HEADERS,
|
||||||
|
normalizeCertificateFingerprint,
|
||||||
|
fingerprintFromCertificateDer,
|
||||||
|
verifyClientCertificateFingerprint,
|
||||||
|
isIntegrationMtlsEnabled,
|
||||||
|
} from './mtls';
|
||||||
|
|||||||
36
packages/integration-auth/src/mtls.ts
Normal file
36
packages/integration-auth/src/mtls.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export const INTEGRATION_MTLS_HEADERS = {
|
||||||
|
clientFingerprint: 'x-hgc-client-cert-fingerprint',
|
||||||
|
clientSubject: 'x-hgc-client-cert-subject',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Normalizes nginx, Traefik, or OpenSSL fingerprint formats to lowercase hex without colons. */
|
||||||
|
export function normalizeCertificateFingerprint(value: string): string {
|
||||||
|
return value.replace(/:/g, '').replace(/\s/g, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintFromCertificateDer(der: Buffer): string {
|
||||||
|
return createHash('sha256').update(der).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyClientCertificateFingerprint(
|
||||||
|
provided: string,
|
||||||
|
expected: string,
|
||||||
|
): boolean {
|
||||||
|
const normalizedProvided = normalizeCertificateFingerprint(provided);
|
||||||
|
const normalizedExpected = normalizeCertificateFingerprint(expected);
|
||||||
|
|
||||||
|
if (normalizedProvided.length !== normalizedExpected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedBuffer = Buffer.from(normalizedProvided, 'utf8');
|
||||||
|
const expectedBuffer = Buffer.from(normalizedExpected, 'utf8');
|
||||||
|
|
||||||
|
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIntegrationMtlsEnabled(): boolean {
|
||||||
|
return process.env['INTEGRATION_MTLS_ENABLED'] === 'true';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user