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

@@ -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');
}
}

View File

@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
import type { WhmcsDashboardStatsResponse } from '@hexahost/contracts';
import { isIntegrationMtlsEnabled } from '@hexahost/integration-auth';
import { getBillingProvider } from '../../billing/billing-provider';
import { PrismaService } from '../../prisma/prisma.service';
@@ -24,6 +26,7 @@ export class WhmcsDashboardService {
exportedUsagePeriods,
syncCursor,
lastReconciledLink,
mtlsRow,
] = await Promise.all([
this.prisma.whmcsClientLink.count({
where: { installationId: installation.id },
@@ -54,6 +57,10 @@ export class WhmcsDashboardService {
orderBy: { lastReconciledAt: 'desc' },
select: { lastReconciledAt: true },
}),
this.prisma.whmcsInstallation.findUniqueOrThrow({
where: { id: installation.id },
select: { mtlsClientFingerprint: true, mtlsClientSubject: true },
}),
]);
const suspendedServices = await this.prisma.whmcsServiceLink.count({
@@ -79,6 +86,8 @@ export class WhmcsDashboardService {
eventsCursor: syncCursor?.eventsCursor ?? null,
lastEventSyncAt: syncCursor?.updatedAt.toISOString() ?? null,
lastReconciliationAt: lastReconciledLink?.lastReconciledAt?.toISOString() ?? null,
mtlsEnabled: isIntegrationMtlsEnabled(),
mtlsFingerprintRegistered: Boolean(mtlsRow.mtlsClientFingerprint),
};
}
}

View File

@@ -19,10 +19,12 @@ import {
whmcsImportServiceRequestSchema,
whmcsProvisionServiceRequestSchema,
whmcsReconcileRequestSchema,
whmcsRegisterMtlsRequestSchema,
whmcsSuspendRequestSchema,
whmcsUpsertClientRequestSchema,
whmcsUsageAdjustmentRequestSchema,
whmcsUsageQuerySchema,
whmcsValidatePlanRequestSchema,
} from '@hexahost/contracts';
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
@@ -35,6 +37,7 @@ import {
import { WhmcsIntegrationService } from './whmcs-integration.service';
import { WhmcsDashboardService } from './whmcs-dashboard.service';
import { WhmcsEventsService } from './whmcs-events.service';
import { WhmcsMtlsService } from './whmcs-mtls.service';
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
import { WhmcsUsageService } from './whmcs-usage.service';
@@ -49,6 +52,7 @@ export class WhmcsIntegrationController {
private readonly events: WhmcsEventsService,
private readonly usage: WhmcsUsageService,
private readonly dashboard: WhmcsDashboardService,
private readonly mtls: WhmcsMtlsService,
) {}
@Get('dashboard/stats')
@@ -66,6 +70,30 @@ export class WhmcsIntegrationController {
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')
upsertClient(
@Req() request: WhmcsAuthenticatedRequest,

View File

@@ -11,11 +11,13 @@ import type Redis from 'ioredis';
import {
INTEGRATION_HEADERS,
canonicalQuery,
isIntegrationMtlsEnabled,
verifyRequestSignature,
} from '@hexahost/integration-auth';
import { PrismaService } from '../../prisma/prisma.service';
import { assertIntegrationMtls } from './integration-mtls.util';
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
export type WhmcsInstallationContext = {
@@ -50,7 +52,12 @@ export class WhmcsIntegrationGuard implements CanActivate {
const installation = await this.prisma.whmcsInstallation.findFirst({
where: { integrationId, isActive: true },
select: { id: true, integrationId: true, apiSecret: true },
select: {
id: true,
integrationId: true,
apiSecret: true,
mtlsClientFingerprint: true,
},
});
if (!installation) {
@@ -86,7 +93,20 @@ export class WhmcsIntegrationGuard implements CanActivate {
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;
}
}

View File

@@ -13,6 +13,7 @@ import { WhmcsEventsService } from './whmcs-events.service';
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
import { WhmcsIntegrationService } from './whmcs-integration.service';
import { WhmcsMtlsService } from './whmcs-mtls.service';
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
import { WhmcsUsageService } from './whmcs-usage.service';
@@ -27,6 +28,7 @@ export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
WhmcsEventsService,
WhmcsUsageService,
WhmcsDashboardService,
WhmcsMtlsService,
WhmcsIntegrationGuard,
{
provide: INTEGRATIONS_REDIS,

View File

@@ -12,6 +12,7 @@ import type {
WhmcsServiceResponse,
WhmcsUpsertClientRequest,
WhmcsUpsertClientResponse,
WhmcsValidatePlanResponse,
} from '@hexahost/contracts';
import { formatJoinAddress } from '@hexahost/dns';
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(
installation: InstallationRef,
externalClientId: string,

View 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,
};
}
}

View 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;
}
});
});