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:
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 { 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user