Phase9
This commit is contained in:
@@ -93,6 +93,6 @@ API_PORT=3001
|
||||
WEB_PORT=3000
|
||||
|
||||
# --- WHMCS Integration (optional) ---
|
||||
WHMCS_INTEGRATION_ID=
|
||||
WHMCS_API_SECRET=
|
||||
WHMCS_INTEGRATION_ID=local-dev-whmcs
|
||||
WHMCS_API_SECRET=local-dev-whmcs-api-secret-32chars-minimum
|
||||
WHMCS_WEBHOOK_SECRET=
|
||||
|
||||
@@ -68,7 +68,7 @@ packages/
|
||||
ui/ Gemeinsame UI-Komponenten
|
||||
deploy/ Docker Compose, systemd, Ansible
|
||||
docs/ Architektur, ADR, Betrieb
|
||||
integrations/ WHMCS-Module (später)
|
||||
integrations/ WHMCS-Modul (`hexagamecloud`)
|
||||
```
|
||||
|
||||
## Befehle
|
||||
@@ -92,7 +92,7 @@ make build
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
**Phase 8** (DNS & Join-to-Start) — abgeschlossen.
|
||||
**Phase 9** (WHMCS & Produktion) — abgeschlossen.
|
||||
|
||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/dns": "workspace:*",
|
||||
"@hexahost/integration-auth": "workspace:*",
|
||||
"@hexahost/metering": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BillingModule } from './billing/billing.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
import { IntegrationsModule } from './integrations/integrations.module';
|
||||
import { EdgeModule } from './edge/edge.module';
|
||||
import { ServersModule } from './servers/servers.module';
|
||||
import { AppConfigModule } from './config/app-config.module';
|
||||
@@ -46,6 +47,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
CatalogModule,
|
||||
BillingModule,
|
||||
EdgeModule,
|
||||
IntegrationsModule,
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
function isWhmcsBilling(): boolean {
|
||||
return (process.env['BILLING_PROVIDER'] ?? 'internal') === 'whmcs';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -95,6 +99,10 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async assertCanStartServer(userId: string): Promise<void> {
|
||||
if (isWhmcsBilling()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureWalletForUser(userId);
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUnique({
|
||||
|
||||
@@ -138,6 +138,14 @@ export class EdgeService {
|
||||
};
|
||||
}
|
||||
|
||||
if (server.billingSuspendedAt) {
|
||||
return {
|
||||
...base,
|
||||
action: 'reject',
|
||||
message: server.billingSuspendReason ?? 'Server is billing suspended',
|
||||
};
|
||||
}
|
||||
|
||||
if (server.status === 'RUNNING' && backend) {
|
||||
return { ...base, action: 'proxy', backend };
|
||||
}
|
||||
|
||||
9
apps/api/src/integrations/integrations.module.ts
Normal file
9
apps/api/src/integrations/integrations.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WhmcsIntegrationModule } from './whmcs/whmcs-integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsIntegrationModule],
|
||||
exports: [WhmcsIntegrationModule],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
@@ -0,0 +1 @@
|
||||
export const INTEGRATIONS_REDIS = Symbol('INTEGRATIONS_REDIS');
|
||||
135
apps/api/src/integrations/whmcs/whmcs-integration.controller.ts
Normal file
135
apps/api/src/integrations/whmcs/whmcs-integration.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
whmcsChangePackageRequestSchema,
|
||||
whmcsProvisionServiceRequestSchema,
|
||||
whmcsSuspendRequestSchema,
|
||||
whmcsUpsertClientRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
||||
|
||||
import {
|
||||
WhmcsIntegrationGuard,
|
||||
type WhmcsAuthenticatedRequest,
|
||||
} from './whmcs-integration.guard';
|
||||
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
||||
|
||||
@Controller('integrations/whmcs')
|
||||
@UseGuards(WhmcsIntegrationGuard)
|
||||
@SkipThrottle()
|
||||
export class WhmcsIntegrationController {
|
||||
constructor(private readonly whmcs: WhmcsIntegrationService) {}
|
||||
|
||||
@Get('health')
|
||||
health(@Req() request: WhmcsAuthenticatedRequest) {
|
||||
return this.whmcs.getHealth(request.whmcsInstallation!);
|
||||
}
|
||||
|
||||
@Get('catalog/plans')
|
||||
listPlans() {
|
||||
return this.whmcs.listPlans();
|
||||
}
|
||||
|
||||
@Put('clients/:externalClientId')
|
||||
upsertClient(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalClientId') externalClientId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const parsed = whmcsUpsertClientRequestSchema.parse(body);
|
||||
return this.whmcs.upsertClient(
|
||||
request.whmcsInstallation!,
|
||||
externalClientId,
|
||||
parsed,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services')
|
||||
provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
|
||||
const parsed = whmcsProvisionServiceRequestSchema.parse(body);
|
||||
return this.whmcs.provisionService(
|
||||
request.whmcsInstallation!,
|
||||
parsed,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('services/:externalServiceId')
|
||||
getService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.getService(request.whmcsInstallation!, externalServiceId);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/suspend')
|
||||
suspendService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const parsed = whmcsSuspendRequestSchema.parse(body ?? {});
|
||||
return this.whmcs.suspendService(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
parsed.reason,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/unsuspend')
|
||||
unsuspendService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.unsuspendService(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/terminate')
|
||||
terminateService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.terminateService(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('services/:externalServiceId/change-package')
|
||||
changePackage(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const parsed = whmcsChangePackageRequestSchema.parse(body);
|
||||
return this.whmcs.changePackage(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
parsed,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {
|
||||
const value = request.headers[header];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
97
apps/api/src/integrations/whmcs/whmcs-integration.guard.ts
Normal file
97
apps/api/src/integrations/whmcs/whmcs-integration.guard.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
INTEGRATION_HEADERS,
|
||||
canonicalQuery,
|
||||
verifyRequestSignature,
|
||||
} from '@hexahost/integration-auth';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||
|
||||
export type WhmcsInstallationContext = {
|
||||
id: string;
|
||||
integrationId: string;
|
||||
apiSecret: string;
|
||||
};
|
||||
|
||||
export type WhmcsAuthenticatedRequest = FastifyRequest & {
|
||||
whmcsInstallation?: WhmcsInstallationContext;
|
||||
rawBody?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsIntegrationGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(INTEGRATIONS_REDIS) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<WhmcsAuthenticatedRequest>();
|
||||
const integrationId = headerValue(request, INTEGRATION_HEADERS.integrationId);
|
||||
const timestamp = headerValue(request, INTEGRATION_HEADERS.timestamp);
|
||||
const nonce = headerValue(request, INTEGRATION_HEADERS.nonce);
|
||||
const idempotencyKey = headerValue(request, INTEGRATION_HEADERS.idempotencyKey);
|
||||
const signature = headerValue(request, INTEGRATION_HEADERS.signature);
|
||||
|
||||
if (!integrationId || !timestamp || !nonce || !idempotencyKey || !signature) {
|
||||
throw new UnauthorizedException('Missing integration authentication headers');
|
||||
}
|
||||
|
||||
const installation = await this.prisma.whmcsInstallation.findFirst({
|
||||
where: { integrationId, isActive: true },
|
||||
select: { id: true, integrationId: true, apiSecret: true },
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
throw new UnauthorizedException('Unknown integration installation');
|
||||
}
|
||||
|
||||
const nonceKey = `integration:nonce:${installation.id}:${nonce}`;
|
||||
const nonceSet = await this.redis.set(nonceKey, '1', 'EX', 600, 'NX');
|
||||
if (nonceSet !== 'OK') {
|
||||
throw new UnauthorizedException('Replayed integration nonce');
|
||||
}
|
||||
|
||||
const body = typeof request.rawBody === 'string' ? request.rawBody : JSON.stringify(request.body ?? {});
|
||||
const query = canonicalQuery(
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
);
|
||||
|
||||
const valid = verifyRequestSignature(
|
||||
installation.apiSecret,
|
||||
{
|
||||
method: request.method,
|
||||
path: request.url.split('?')[0] ?? request.url,
|
||||
query,
|
||||
body,
|
||||
timestamp,
|
||||
nonce,
|
||||
idempotencyKey,
|
||||
},
|
||||
signature,
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid integration signature');
|
||||
}
|
||||
|
||||
request.whmcsInstallation = installation;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function headerValue(request: FastifyRequest, header: string): string | undefined {
|
||||
const value = request.headers[header];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
37
apps/api/src/integrations/whmcs/whmcs-integration.module.ts
Normal file
37
apps/api/src/integrations/whmcs/whmcs-integration.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { BillingModule } from '../../billing/billing.module';
|
||||
import { ServersModule } from '../../servers/servers.module';
|
||||
|
||||
import { WhmcsIntegrationController } from './whmcs-integration.controller';
|
||||
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
|
||||
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
||||
|
||||
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule, BillingModule],
|
||||
controllers: [WhmcsIntegrationController],
|
||||
providers: [
|
||||
WhmcsIntegrationService,
|
||||
WhmcsIntegrationGuard,
|
||||
{
|
||||
provide: INTEGRATIONS_REDIS,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Redis,
|
||||
useExisting: INTEGRATIONS_REDIS,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class WhmcsIntegrationModule {}
|
||||
509
apps/api/src/integrations/whmcs/whmcs-integration.service.ts
Normal file
509
apps/api/src/integrations/whmcs/whmcs-integration.service.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
WhmcsChangePackageRequest,
|
||||
WhmcsPlanCatalogResponse,
|
||||
WhmcsProvisionServiceRequest,
|
||||
WhmcsServiceResponse,
|
||||
WhmcsUpsertClientRequest,
|
||||
WhmcsUpsertClientResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import { formatJoinAddress } from '@hexahost/dns';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
import { BillingService } from '../../billing/billing.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ServersService } from '../../servers/servers.service';
|
||||
|
||||
import type { WhmcsInstallationContext } from './whmcs-integration.guard';
|
||||
|
||||
type InstallationRef = {
|
||||
id: string;
|
||||
integrationId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsIntegrationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serversService: ServersService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
getHealth(installation: WhmcsInstallationContext) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
integrationId: installation.integrationId,
|
||||
billingProvider: process.env['BILLING_PROVIDER'] ?? 'internal',
|
||||
};
|
||||
}
|
||||
|
||||
async listPlans(): Promise<WhmcsPlanCatalogResponse> {
|
||||
const plans = await this.prisma.plan.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
plans: plans.map((plan) => ({
|
||||
id: plan.id,
|
||||
slug: plan.slug,
|
||||
name: plan.name,
|
||||
maxRamMb: plan.maxRamMb,
|
||||
isFree: plan.isFree,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async upsertClient(
|
||||
installation: InstallationRef,
|
||||
externalClientId: string,
|
||||
input: WhmcsUpsertClientRequest,
|
||||
idempotencyKey: string,
|
||||
): Promise<WhmcsUpsertClientResponse> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as WhmcsUpsertClientResponse;
|
||||
}
|
||||
|
||||
const existingLink = await this.prisma.whmcsClientLink.findUnique({
|
||||
where: {
|
||||
installationId_externalClientId: {
|
||||
installationId: installation.id,
|
||||
externalClientId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: existingLink.userId },
|
||||
data: {
|
||||
displayName: input.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
const result = {
|
||||
userId: user.id,
|
||||
externalClientId,
|
||||
created: false,
|
||||
};
|
||||
|
||||
await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
username: input.username,
|
||||
displayName: input.displayName,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
await this.billing.ensureWalletForUser(user.id);
|
||||
|
||||
await this.prisma.whmcsClientLink.create({
|
||||
data: {
|
||||
installationId: installation.id,
|
||||
externalClientId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const result = {
|
||||
userId: user.id,
|
||||
externalClientId,
|
||||
created: true,
|
||||
};
|
||||
|
||||
await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async provisionService(
|
||||
installation: InstallationRef,
|
||||
input: WhmcsProvisionServiceRequest,
|
||||
idempotencyKey: string,
|
||||
): Promise<WhmcsServiceResponse> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as WhmcsServiceResponse;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.whmcsServiceLink.findUnique({
|
||||
where: {
|
||||
installationId_externalServiceId: {
|
||||
installationId: installation.id,
|
||||
externalServiceId: input.externalServiceId,
|
||||
},
|
||||
},
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.toServiceResponse(existing.externalServiceId, existing.status, existing.server);
|
||||
}
|
||||
|
||||
const clientLink = await this.prisma.whmcsClientLink.findUnique({
|
||||
where: {
|
||||
installationId_externalClientId: {
|
||||
installationId: installation.id,
|
||||
externalClientId: input.externalClientId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!clientLink) {
|
||||
throw new NotFoundException(`Unknown WHMCS client ${input.externalClientId}`);
|
||||
}
|
||||
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: { slug: input.planSlug, isActive: true },
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException(`Unknown plan slug ${input.planSlug}`);
|
||||
}
|
||||
|
||||
await this.billing.assertCanCreateServer(clientLink.userId, plan.id);
|
||||
|
||||
const server = await this.serversService.createServer(clientLink.userId, {
|
||||
name: input.serverName,
|
||||
edition: input.edition,
|
||||
softwareFamily: input.softwareFamily,
|
||||
minecraftVersion: input.minecraftVersion,
|
||||
eulaAccepted: true,
|
||||
planId: plan.id,
|
||||
ramMb: plan.maxRamMb,
|
||||
});
|
||||
|
||||
await this.prisma.whmcsServiceLink.create({
|
||||
data: {
|
||||
installationId: installation.id,
|
||||
externalServiceId: input.externalServiceId,
|
||||
externalProductId: input.externalProductId,
|
||||
serverId: server.id,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
const started = await this.serversService.startServerInternal(
|
||||
server.id,
|
||||
`whmcs:provision:${input.externalServiceId}`,
|
||||
);
|
||||
|
||||
await this.prisma.whmcsServiceLink.update({
|
||||
where: {
|
||||
installationId_externalServiceId: {
|
||||
installationId: installation.id,
|
||||
externalServiceId: input.externalServiceId,
|
||||
},
|
||||
},
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
input.externalServiceId,
|
||||
'ACTIVE',
|
||||
started.server as unknown as GameServer,
|
||||
);
|
||||
|
||||
await this.recordOperation(
|
||||
installation.id,
|
||||
idempotencyKey,
|
||||
'service:provision',
|
||||
response,
|
||||
input.externalServiceId,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getService(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
): Promise<WhmcsServiceResponse> {
|
||||
const link = await this.findServiceLink(installation.id, externalServiceId);
|
||||
return this.toServiceResponse(externalServiceId, link.status, link.server);
|
||||
}
|
||||
|
||||
async suspendService(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
reason: string | undefined,
|
||||
idempotencyKey: string,
|
||||
): Promise<WhmcsServiceResponse> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as WhmcsServiceResponse;
|
||||
}
|
||||
|
||||
const link = await this.findServiceLink(installation.id, externalServiceId);
|
||||
const server = link.server;
|
||||
|
||||
if (server.status === 'RUNNING') {
|
||||
await this.serversService.stopServer(server.userId, server.id);
|
||||
} else if (server.status === 'QUEUED') {
|
||||
await this.serversService.cancelQueuedStart(server.userId, server.id);
|
||||
}
|
||||
|
||||
const updatedServer = await this.prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
billingSuspendedAt: new Date(),
|
||||
billingSuspendReason: reason ?? 'whmcs-suspend',
|
||||
},
|
||||
});
|
||||
|
||||
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
status: 'SUSPENDED',
|
||||
suspendedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
updatedLink.status,
|
||||
updatedServer,
|
||||
);
|
||||
|
||||
await this.recordOperation(
|
||||
installation.id,
|
||||
idempotencyKey,
|
||||
'service:suspend',
|
||||
response,
|
||||
externalServiceId,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async unsuspendService(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<WhmcsServiceResponse> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as WhmcsServiceResponse;
|
||||
}
|
||||
|
||||
const link = await this.findServiceLink(installation.id, externalServiceId);
|
||||
|
||||
const updatedServer = await this.prisma.gameServer.update({
|
||||
where: { id: link.serverId },
|
||||
data: {
|
||||
billingSuspendedAt: null,
|
||||
billingSuspendReason: null,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
suspendedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
updatedLink.status,
|
||||
updatedServer,
|
||||
);
|
||||
|
||||
await this.recordOperation(
|
||||
installation.id,
|
||||
idempotencyKey,
|
||||
'service:unsuspend',
|
||||
response,
|
||||
externalServiceId,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async terminateService(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<WhmcsServiceResponse> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as WhmcsServiceResponse;
|
||||
}
|
||||
|
||||
const link = await this.findServiceLink(installation.id, externalServiceId);
|
||||
const server = link.server;
|
||||
|
||||
if (server.status === 'RUNNING') {
|
||||
await this.serversService.stopServer(server.userId, server.id);
|
||||
}
|
||||
|
||||
const updatedServer = await this.prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
status: 'DELETED',
|
||||
billingSuspendedAt: new Date(),
|
||||
billingSuspendReason: 'whmcs-terminate',
|
||||
},
|
||||
});
|
||||
|
||||
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
status: 'TERMINATED',
|
||||
terminatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
updatedLink.status,
|
||||
updatedServer,
|
||||
);
|
||||
|
||||
await this.recordOperation(
|
||||
installation.id,
|
||||
idempotencyKey,
|
||||
'service:terminate',
|
||||
response,
|
||||
externalServiceId,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async changePackage(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
input: WhmcsChangePackageRequest,
|
||||
idempotencyKey: string,
|
||||
): Promise<WhmcsServiceResponse> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as WhmcsServiceResponse;
|
||||
}
|
||||
|
||||
const link = await this.findServiceLink(installation.id, externalServiceId);
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: { slug: input.planSlug, isActive: true },
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException(`Unknown plan slug ${input.planSlug}`);
|
||||
}
|
||||
|
||||
const updatedServer = await this.prisma.gameServer.update({
|
||||
where: { id: link.serverId },
|
||||
data: {
|
||||
planId: plan.id,
|
||||
ramMb: input.ramMb ?? plan.maxRamMb,
|
||||
},
|
||||
});
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
link.status,
|
||||
updatedServer,
|
||||
);
|
||||
|
||||
await this.recordOperation(
|
||||
installation.id,
|
||||
idempotencyKey,
|
||||
'service:change-package',
|
||||
response,
|
||||
externalServiceId,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async findServiceLink(installationId: string, externalServiceId: string) {
|
||||
const link = await this.prisma.whmcsServiceLink.findUnique({
|
||||
where: {
|
||||
installationId_externalServiceId: {
|
||||
installationId,
|
||||
externalServiceId,
|
||||
},
|
||||
},
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
private async findCompletedOperation(installationId: string, idempotencyKey: string) {
|
||||
return this.prisma.integrationOperation.findUnique({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
}
|
||||
|
||||
private async recordOperation(
|
||||
installationId: string,
|
||||
idempotencyKey: string,
|
||||
operation: string,
|
||||
result: unknown,
|
||||
externalRef?: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.integrationOperation.create({
|
||||
data: {
|
||||
installationId,
|
||||
idempotencyKey,
|
||||
operation,
|
||||
externalRef,
|
||||
status: 'COMPLETED',
|
||||
result: result as object,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toServiceResponse(
|
||||
externalServiceId: string,
|
||||
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'TERMINATED',
|
||||
server: GameServer,
|
||||
): WhmcsServiceResponse {
|
||||
return {
|
||||
externalServiceId,
|
||||
status,
|
||||
server: {
|
||||
id: server.id,
|
||||
userId: server.userId,
|
||||
planId: server.planId,
|
||||
nodeId: server.nodeId,
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
version: server.version,
|
||||
edition: server.edition,
|
||||
softwareFamily: server.softwareFamily,
|
||||
minecraftVersion: server.minecraftVersion,
|
||||
eulaAccepted: server.eulaAccepted,
|
||||
hostPort: server.hostPort,
|
||||
containerId: server.containerId,
|
||||
dataPath: server.dataPath,
|
||||
activeWorldName: server.activeWorldName,
|
||||
ramMb: server.ramMb,
|
||||
idleShutdownEnabled: server.idleShutdownEnabled,
|
||||
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||
joinSlug: server.joinSlug,
|
||||
joinHostname: formatJoinAddress(server.joinSlug, server.edition),
|
||||
joinToStartEnabled: server.joinToStartEnabled,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,21 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
await app.register(websocket);
|
||||
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
fastify.addContentTypeParser(
|
||||
'application/json',
|
||||
{ parseAs: 'string' },
|
||||
(request, body, done) => {
|
||||
try {
|
||||
const rawBody = typeof body === 'string' ? body : body.toString('utf8');
|
||||
(request as { rawBody?: string }).rawBody = rawBody;
|
||||
done(null, rawBody ? JSON.parse(rawBody) : {});
|
||||
} catch (error) {
|
||||
done(error as Error, undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle(config.APP_NAME)
|
||||
.setDescription('HexaHost GameCloud REST API')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
@@ -93,6 +94,7 @@ export class ServersService {
|
||||
skipStop = false,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.assertServerNotBillingSuspended(server);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!skipStop && server.status === 'RUNNING') {
|
||||
@@ -107,6 +109,7 @@ export class ServersService {
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.assertServerNotBillingSuspended(server);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||
@@ -190,6 +193,7 @@ export class ServersService {
|
||||
throw new ConflictException('Server not found');
|
||||
}
|
||||
|
||||
this.assertServerNotBillingSuspended(server);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||
@@ -410,6 +414,14 @@ export class ServersService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private assertServerNotBillingSuspended(server: GameServer): void {
|
||||
if (server.billingSuspendedAt) {
|
||||
throw new ForbiddenException(
|
||||
server.billingSuspendReason ?? 'Server is suspended by billing',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordTransition(
|
||||
gameServerId: string,
|
||||
fromStatus: GameServerStatus | null,
|
||||
|
||||
29
apps/api/test/phase9.test.js
Normal file
29
apps/api/test/phase9.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('phase 9 api contract', () => {
|
||||
it('documents WHMCS integration routes and billing suspension', () => {
|
||||
const whmcsSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(whmcsSource.includes("'integrations/whmcs'"));
|
||||
assert.ok(whmcsSource.includes("'services/:externalServiceId/suspend'"));
|
||||
assert.ok(whmcsSource.includes('WhmcsIntegrationGuard'));
|
||||
|
||||
const billingSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'billing/billing.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(billingSource.includes('isWhmcsBilling'));
|
||||
|
||||
const serversSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/servers.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(serversSource.includes('assertServerNotBillingSuspended'));
|
||||
assert.ok(serversSource.includes('billingSuspendedAt'));
|
||||
});
|
||||
});
|
||||
10
apps/worker/src/handlers/dns-sync.ts
Normal file
10
apps/worker/src/handlers/dns-sync.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { pushPendingDnsRecords } from '@hexahost/dns';
|
||||
|
||||
import { logger } from '../logger';
|
||||
|
||||
export async function processDnsSyncTick(): Promise<void> {
|
||||
const synced = await pushPendingDnsRecords(25);
|
||||
if (synced > 0) {
|
||||
logger.info({ synced }, 'DNS records synchronized');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Job, Worker, type ConnectionOptions } from 'bullmq';
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processDnsSyncTick } from './handlers/dns-sync';
|
||||
import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing';
|
||||
import { processNodeHealthTick } from './handlers/node-health';
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
@@ -106,6 +107,9 @@ async function bootstrap(): Promise<void> {
|
||||
void processUsageMeteringTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'Usage metering tick failed');
|
||||
});
|
||||
void processDnsSyncTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'DNS sync tick failed');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,4 +20,4 @@ This directory will contain idempotent Ansible automation for game node provisio
|
||||
|
||||
## Status
|
||||
|
||||
Phase 0 placeholder. Node installation procedures are outlined in `docs/operations/installation.md` until playbooks land in Phase 9.
|
||||
Phase 9 playbooks provide a baseline for control plane and game node hosts. Extend with environment-specific inventories and secrets management.
|
||||
|
||||
48
deploy/ansible/control-plane.yml
Normal file
48
deploy/ansible/control-plane.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
- name: Provision control plane
|
||||
hosts: control_plane
|
||||
become: true
|
||||
vars:
|
||||
gamecloud_user: gamecloud
|
||||
gamecloud_home: /opt/hexahost-gamecloud
|
||||
tasks:
|
||||
- name: Install base packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- docker.io
|
||||
- docker-compose-plugin
|
||||
- postgresql-client
|
||||
- redis-tools
|
||||
- bind9-utils
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create application user
|
||||
ansible.builtin.user:
|
||||
name: "{{ gamecloud_user }}"
|
||||
system: true
|
||||
create_home: false
|
||||
|
||||
- name: Create application directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ gamecloud_home }}"
|
||||
state: directory
|
||||
owner: "{{ gamecloud_user }}"
|
||||
group: "{{ gamecloud_user }}"
|
||||
mode: "0750"
|
||||
|
||||
- name: Deploy compose stack placeholder
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ gamecloud_home }}/README.txt"
|
||||
owner: "{{ gamecloud_user }}"
|
||||
group: "{{ gamecloud_user }}"
|
||||
mode: "0640"
|
||||
content: |
|
||||
Deploy the production compose stack from deploy/compose on this host.
|
||||
Required services: PostgreSQL, Redis, MinIO, API, Worker, Web, Edge Gateway.
|
||||
|
||||
- name: Remind operator about DNS
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
Configure DNS_PROVIDER=rfc2136 and RFC2136_* variables on the API/worker hosts.
|
||||
Join hostnames must point to the edge gateway public IP.
|
||||
62
deploy/ansible/game-node.yml
Normal file
62
deploy/ansible/game-node.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
- name: Provision game node
|
||||
hosts: game_nodes
|
||||
become: true
|
||||
vars:
|
||||
node_agent_user: hgc-node
|
||||
node_data_dir: /var/lib/hgc
|
||||
port_range_start: 25565
|
||||
port_range_end: 25664
|
||||
tasks:
|
||||
- name: Install Docker
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- docker.io
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create node agent user
|
||||
ansible.builtin.user:
|
||||
name: "{{ node_agent_user }}"
|
||||
system: true
|
||||
create_home: false
|
||||
|
||||
- name: Create node data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ node_data_dir }}"
|
||||
state: directory
|
||||
owner: "{{ node_agent_user }}"
|
||||
group: "{{ node_agent_user }}"
|
||||
mode: "0750"
|
||||
|
||||
- name: Open Minecraft host port range
|
||||
ansible.builtin.ufw:
|
||||
rule: allow
|
||||
port: "{{ port_range_start }}:{{ port_range_end }}"
|
||||
proto: tcp
|
||||
|
||||
- name: Install node-agent systemd unit
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/hgc-node-agent.service
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
Description=HexaHost GameCloud Node Agent
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User={{ node_agent_user }}
|
||||
ExecStart=/usr/local/bin/hgc-node-agent
|
||||
Restart=on-failure
|
||||
Environment=NODE_DATA_DIR={{ node_data_dir }}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
notify: Reload systemd
|
||||
|
||||
handlers:
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
10
deploy/ansible/site.yml
Normal file
10
deploy/ansible/site.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
- name: HexaHost GameCloud site bootstrap
|
||||
hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- import_playbook: control-plane.yml
|
||||
when: "'control_plane' in group_names"
|
||||
|
||||
- import_playbook: game-node.yml
|
||||
when: "'game_nodes' in group_names"
|
||||
@@ -2,7 +2,50 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: Phase 8 — DNS and join-to-start ✅
|
||||
## Current phase: Phase 9 — WHMCS integration and production hardening ✅
|
||||
|
||||
### Phase 9 completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| WHMCS Prisma models | Done | Installations, client/service links, idempotent operations |
|
||||
| `@hexahost/integration-auth` | Done | HMAC-SHA256 request signing |
|
||||
| WHMCS Integration API | Done | `/api/v1/integrations/whmcs/*` with nonce replay protection |
|
||||
| Billing suspension | Done | `billingSuspendedAt`, blocks start/join-to-start |
|
||||
| Billing provider mode | Done | `BILLING_PROVIDER=internal\|whmcs` |
|
||||
| RFC2136 DNS provider | Done | `nsupdate` + worker DNS sync tick |
|
||||
| WHMCS PHP module | Done | Create/Suspend/Unsuspend/Terminate/ChangePackage |
|
||||
| Ansible playbooks | Done | `site.yml`, `control-plane.yml`, `game-node.yml` |
|
||||
| Operations docs | Done | `dns.md`, `control-plane.md`, `game-node.md` |
|
||||
|
||||
### Abnahmekriterium Phase 9
|
||||
|
||||
- [x] WHMCS can provision/suspend/unsuspend/terminate via signed API
|
||||
- [x] Production DNS provider abstraction with RFC2136
|
||||
- [x] Documented control plane + two-node deployment path
|
||||
- [x] Billing suspension enforced on panel and edge gateway
|
||||
- [ ] Manual E2E: WHMCS order → GameCloud server → suspend/unsuspend
|
||||
|
||||
### Local WHMCS API test
|
||||
|
||||
1. `pnpm db:migrate && pnpm db:seed` (creates `local-dev-whmcs` installation)
|
||||
2. Set `BILLING_PROVIDER=whmcs` optional for credit-bypass mode
|
||||
3. Sign requests with `WHMCS_API_SECRET` from seed output
|
||||
4. `PUT /api/v1/integrations/whmcs/clients/1` then `POST .../services`
|
||||
|
||||
### WHMCS module install
|
||||
|
||||
Copy `integrations/whmcs/modules/servers/hexagamecloud` to `/modules/servers/hexagamecloud` on your WHMCS host.
|
||||
|
||||
Server entry fields:
|
||||
|
||||
- Hostname: `https://api.example.net`
|
||||
- Username: integration ID (`WHMCS_INTEGRATION_ID`)
|
||||
- Password: API secret (`WHMCS_API_SECRET`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — DNS and join-to-start ✅
|
||||
|
||||
### Phase 8 completed
|
||||
|
||||
@@ -111,6 +154,6 @@ Last updated: 2026-06-26
|
||||
|
||||
## Phase 0 — Monorepo foundation ✅
|
||||
|
||||
### Next phase: Phase 9 — WHMCS integration and production hardening
|
||||
### Next phase: Post-MVP hardening
|
||||
|
||||
- Billing provider hooks, WHMCS module, production DNS (RFC2136)
|
||||
- Stripe webhooks, SSO (WHMCS Phase D), reconciliation dashboard, penetration test
|
||||
|
||||
69
docs/operations/control-plane.md
Normal file
69
docs/operations/control-plane.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Control plane operations
|
||||
|
||||
The control plane hosts the customer-facing panel, REST API, background worker, PostgreSQL, Redis, object storage, and the edge gateway.
|
||||
|
||||
## Recommended topology (production)
|
||||
|
||||
| Host | Role |
|
||||
|------|------|
|
||||
| `cp-01` | API, Web, Worker, Edge Gateway |
|
||||
| `cp-db` | PostgreSQL (managed or dedicated) |
|
||||
| `cp-cache` | Redis |
|
||||
| `cp-storage` | S3-compatible object storage |
|
||||
| `node-01`, `node-02` | Game nodes with node-agent |
|
||||
|
||||
Phase 9 acceptance target: **one control plane stack and two game nodes**.
|
||||
|
||||
## Core services
|
||||
|
||||
- **API** (`apps/api`) — public REST + internal integration endpoints
|
||||
- **Worker** (`apps/worker`) — queues, idle shutdown, metering, DNS sync
|
||||
- **Web** (`apps/web`) — customer panel
|
||||
- **Edge gateway** (`apps/edge-gateway`) — join-to-start TCP proxy
|
||||
|
||||
## Billing modes
|
||||
|
||||
| Mode | `BILLING_PROVIDER` | Commercial system of record |
|
||||
|------|--------------------|-----------------------------|
|
||||
| Internal credits | `internal` | GameCloud wallet |
|
||||
| WHMCS | `whmcs` | WHMCS invoices/subscriptions |
|
||||
|
||||
In WHMCS mode, provisioning and lifecycle are driven by `/api/v1/integrations/whmcs/*` with HMAC authentication.
|
||||
|
||||
## Secrets
|
||||
|
||||
Never commit production secrets. Rotate:
|
||||
|
||||
- `SESSION_SECRET`, `ENCRYPTION_KEY`
|
||||
- `EDGE_INTERNAL_API_KEY`
|
||||
- `WHMCS_API_SECRET` (per installation)
|
||||
- `RFC2136_KEY_SECRET`
|
||||
- Node enrollment tokens
|
||||
|
||||
## Backups
|
||||
|
||||
Back up at minimum:
|
||||
|
||||
1. PostgreSQL (full daily + WAL)
|
||||
2. Redis persistence snapshot (if used for non-transient state)
|
||||
3. Object storage bucket replication
|
||||
4. `/etc` and deployment configuration on control plane hosts
|
||||
|
||||
## Monitoring
|
||||
|
||||
See `deploy/monitoring/prometheus.yml` for scrape targets. Alert on:
|
||||
|
||||
- API health check failures
|
||||
- Worker queue depth / failed jobs
|
||||
- Node heartbeat staleness > 90s
|
||||
- DNS records in `FAILED` status
|
||||
|
||||
## Deployment
|
||||
|
||||
Use Ansible playbooks in `deploy/ansible/`:
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventories/production site.yml
|
||||
```
|
||||
|
||||
Import the production compose stack from `deploy/compose` after base OS provisioning.
|
||||
48
docs/operations/dns.md
Normal file
48
docs/operations/dns.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# DNS operations
|
||||
|
||||
HexaHost GameCloud stores desired DNS records in PostgreSQL (`server_dns_records`) and synchronizes them through a provider abstraction in `@hexahost/dns`.
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | `DNS_PROVIDER` | Use case |
|
||||
|----------|----------------|----------|
|
||||
| Local metadata | `local` (default) | Development — records exist only in the database |
|
||||
| RFC2136 dynamic updates | `rfc2136` | Production with BIND or compatible authoritative DNS |
|
||||
|
||||
## Production (RFC2136)
|
||||
|
||||
Set on API and worker hosts:
|
||||
|
||||
```env
|
||||
DNS_PROVIDER=rfc2136
|
||||
GAME_BASE_DOMAIN=play.example.net
|
||||
EDGE_PUBLIC_HOST=203.0.113.10
|
||||
EDGE_LISTEN_PORT=25565
|
||||
RFC2136_SERVER=ns1.internal.example.net
|
||||
RFC2136_KEY_NAME=gamecloud-updater.
|
||||
RFC2136_KEY_SECRET=base64-encoded-secret
|
||||
RFC2136_TTL=300
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- `nsupdate` (bind9-utils) installed on API/worker hosts
|
||||
- TSIG key authorized for A and SRV updates on `*.play.example.net`
|
||||
- Edge gateway reachable at `EDGE_PUBLIC_HOST:EDGE_LISTEN_PORT`
|
||||
|
||||
## Record layout
|
||||
|
||||
For each Java server with join slug `my-server-abc123`:
|
||||
|
||||
- `A` record: `my-server-abc123.play.example.net` → edge gateway IP
|
||||
- `SRV` record: `_minecraft._tcp.my-server-abc123.play.example.net` → edge hostname/port
|
||||
|
||||
## Worker sync
|
||||
|
||||
The worker runs a DNS sync tick every 60 seconds (`pushPendingDnsRecords`). Records start as `PENDING` when `DNS_PROVIDER=rfc2136` and move to `ACTIVE` or `FAILED`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. Check `server_dns_records.status` and `lastError` in PostgreSQL.
|
||||
2. Run `nsupdate -y` manually with the generated script from logs.
|
||||
3. Verify TSIG key name ends with `.` if your nameserver expects FQDN form.
|
||||
63
docs/operations/game-node.md
Normal file
63
docs/operations/game-node.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Game node operations
|
||||
|
||||
Game nodes run Docker workloads for Minecraft servers and connect to the control plane through the node-agent.
|
||||
|
||||
## Capacity
|
||||
|
||||
Each node exposes:
|
||||
|
||||
- `maxServers`, `maxRamMb`, `platformRamMb`
|
||||
- Host port pool: `portRangeStart`–`portRangeEnd` (default `25565`–`25664`)
|
||||
|
||||
The scheduler places new servers on the least-loaded eligible node.
|
||||
|
||||
## Enrollment
|
||||
|
||||
1. Create a `GameNode` row in the control plane database.
|
||||
2. Generate an enrollment token and distribute it securely to the node host.
|
||||
3. Start `node-agent` with matching `NODE_ID` and `NODE_TOKEN`.
|
||||
4. Confirm node status becomes `ONLINE` in the admin API.
|
||||
|
||||
## Port allocation
|
||||
|
||||
Host ports are unique per node (`@@unique([nodeId, hostPort])`). Do not manually assign ports outside the configured range.
|
||||
|
||||
## Draining
|
||||
|
||||
Before maintenance:
|
||||
|
||||
1. Set node status to `DRAINING` via admin API.
|
||||
2. Wait for running servers to stop or migrate.
|
||||
3. Perform maintenance.
|
||||
4. Return node to `ONLINE` or decommission.
|
||||
|
||||
## Firewall
|
||||
|
||||
Allow:
|
||||
|
||||
- Outbound WebSocket to control plane API
|
||||
- Inbound TCP on allocated Minecraft host port range
|
||||
- Docker bridge traffic locally
|
||||
|
||||
Deny:
|
||||
|
||||
- Public access to Docker API
|
||||
- Unrestricted inbound on management ports
|
||||
|
||||
## Ansible
|
||||
|
||||
Bootstrap with:
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventories/production deploy/ansible/game-node.yml
|
||||
```
|
||||
|
||||
Copy the `hgc-node-agent` binary to `/usr/local/bin/hgc-node-agent` and provide TLS materials via `NODE_TLS_CERT` / `NODE_TLS_KEY` or CA enrollment.
|
||||
|
||||
## Failure scenarios
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Node offline | Servers marked `UNKNOWN`; reschedule starts when node returns |
|
||||
| Port exhaustion | Provisioning fails with `NO_NODE_AVAILABLE`; add node or expand port range |
|
||||
| Disk full | Stop new provisions; prune unused images; expand volume |
|
||||
@@ -52,7 +52,10 @@ Handles global configuration:
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 0:** Directory placeholders only. Implementation follows MVP Phase 9 and WHMCS phases A–F documented in the master specification.
|
||||
**Phase 9 (WHMCS Phase A–B + production DNS)** — implemented.
|
||||
|
||||
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
|
||||
- Addon module: planned for global mappings and reconciliation UI
|
||||
|
||||
## Documentation (planned)
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
if (!defined('WHMCS')) {
|
||||
die('This file cannot be accessed directly');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/lib/ApiClient.php';
|
||||
|
||||
function hexagamecloud_MetaData(): array
|
||||
{
|
||||
return [
|
||||
'DisplayName' => 'HexaHost GameCloud',
|
||||
'APIVersion' => '1.1',
|
||||
'RequiresServer' => true,
|
||||
'DefaultNonSSLPort' => '443',
|
||||
'DefaultSSLPort' => '443',
|
||||
];
|
||||
}
|
||||
|
||||
function hexagamecloud_ConfigOptions(): array
|
||||
{
|
||||
return [
|
||||
'plan_slug' => [
|
||||
'FriendlyName' => 'GameCloud Plan Slug',
|
||||
'Type' => 'text',
|
||||
'Size' => '32',
|
||||
'Default' => 'free',
|
||||
'Description' => 'Maps to a GameCloud plan slug',
|
||||
],
|
||||
'minecraft_version' => [
|
||||
'FriendlyName' => 'Minecraft Version',
|
||||
'Type' => 'text',
|
||||
'Size' => '16',
|
||||
'Default' => '1.21.1',
|
||||
],
|
||||
'software_family' => [
|
||||
'FriendlyName' => 'Software Family',
|
||||
'Type' => 'dropdown',
|
||||
'Options' => 'VANILLA,PAPER,PURPUR,FABRIC,FORGE,NEOFORGE,QUILT',
|
||||
'Default' => 'VANILLA',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function hexagamecloud_CreateAccount(array $params): string
|
||||
{
|
||||
try {
|
||||
$client = new HexaGameCloudApiClient($params);
|
||||
$externalClientId = (string) $params['clientsdetails']['userid'];
|
||||
$externalServiceId = (string) $params['serviceid'];
|
||||
|
||||
$client->upsertClient($externalClientId, [
|
||||
'email' => $params['clientsdetails']['email'],
|
||||
'username' => $params['clientsdetails']['email'],
|
||||
'displayName' => trim(($params['clientsdetails']['firstname'] ?? '') . ' ' . ($params['clientsdetails']['lastname'] ?? '')),
|
||||
]);
|
||||
|
||||
$response = $client->provisionService([
|
||||
'externalServiceId' => $externalServiceId,
|
||||
'externalClientId' => $externalClientId,
|
||||
'externalProductId' => (string) ($params['pid'] ?? ''),
|
||||
'planSlug' => $params['configoption1'] ?: 'free',
|
||||
'serverName' => $params['domain'] ?: ('server-' . $externalServiceId),
|
||||
'minecraftVersion' => $params['configoption2'] ?: '1.21.1',
|
||||
'softwareFamily' => $params['configoption3'] ?: 'VANILLA',
|
||||
'edition' => 'JAVA',
|
||||
'eulaAccepted' => true,
|
||||
]);
|
||||
|
||||
return 'success';
|
||||
} catch (Throwable $exception) {
|
||||
logModuleCall(
|
||||
'hexagamecloud',
|
||||
__FUNCTION__,
|
||||
$params,
|
||||
$exception->getMessage(),
|
||||
$exception->getMessage(),
|
||||
['password', 'secret', 'apiSecret']
|
||||
);
|
||||
return $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_SuspendAccount(array $params): string
|
||||
{
|
||||
return hexagamecloud_lifecycleAction($params, 'suspend', ['reason' => 'Suspended in WHMCS']);
|
||||
}
|
||||
|
||||
function hexagamecloud_UnsuspendAccount(array $params): string
|
||||
{
|
||||
return hexagamecloud_lifecycleAction($params, 'unsuspend');
|
||||
}
|
||||
|
||||
function hexagamecloud_TerminateAccount(array $params): string
|
||||
{
|
||||
return hexagamecloud_lifecycleAction($params, 'terminate');
|
||||
}
|
||||
|
||||
function hexagamecloud_ChangePackage(array $params): string
|
||||
{
|
||||
try {
|
||||
$client = new HexaGameCloudApiClient($params);
|
||||
$client->changePackage((string) $params['serviceid'], [
|
||||
'planSlug' => $params['configoption1'] ?: 'free',
|
||||
]);
|
||||
return 'success';
|
||||
} catch (Throwable $exception) {
|
||||
logModuleCall('hexagamecloud', __FUNCTION__, $params, $exception->getMessage());
|
||||
return $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_TestConnection(array $params): array
|
||||
{
|
||||
try {
|
||||
$client = new HexaGameCloudApiClient($params);
|
||||
$health = $client->health();
|
||||
return [
|
||||
'success' => true,
|
||||
'error' => 'Connected to ' . ($health['integrationId'] ?? 'GameCloud'),
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
return ['success' => false, 'error' => $exception->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_lifecycleAction(array $params, string $action, array $body = []): string
|
||||
{
|
||||
try {
|
||||
$client = new HexaGameCloudApiClient($params);
|
||||
$serviceId = (string) $params['serviceid'];
|
||||
if ($action === 'suspend') {
|
||||
$client->suspendService($serviceId, $body);
|
||||
} elseif ($action === 'unsuspend') {
|
||||
$client->unsuspendService($serviceId);
|
||||
} else {
|
||||
$client->terminateService($serviceId);
|
||||
}
|
||||
return 'success';
|
||||
} catch (Throwable $exception) {
|
||||
logModuleCall('hexagamecloud', $action, $params, $exception->getMessage());
|
||||
return $exception->getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class HexaGameCloudApiClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $integrationId;
|
||||
private string $apiSecret;
|
||||
|
||||
public function __construct(array $params)
|
||||
{
|
||||
$host = rtrim((string) ($params['serverhostname'] ?? ''), '/');
|
||||
$this->baseUrl = $host;
|
||||
$this->integrationId = (string) ($params['serverusername'] ?? '');
|
||||
$this->apiSecret = (string) ($params['serverpassword'] ?? '');
|
||||
|
||||
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
|
||||
throw new RuntimeException('GameCloud server hostname, integration ID and API secret are required');
|
||||
}
|
||||
}
|
||||
|
||||
public function health(): array
|
||||
{
|
||||
return $this->request('GET', '/api/v1/integrations/whmcs/health');
|
||||
}
|
||||
|
||||
public function upsertClient(string $externalClientId, array $payload): array
|
||||
{
|
||||
return $this->request(
|
||||
'PUT',
|
||||
'/api/v1/integrations/whmcs/clients/' . rawurlencode($externalClientId),
|
||||
$payload,
|
||||
'client:' . $externalClientId
|
||||
);
|
||||
}
|
||||
|
||||
public function provisionService(array $payload): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/api/v1/integrations/whmcs/services',
|
||||
$payload,
|
||||
'service:provision:' . $payload['externalServiceId']
|
||||
);
|
||||
}
|
||||
|
||||
public function suspendService(string $externalServiceId, array $payload = []): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/suspend',
|
||||
$payload,
|
||||
'service:suspend:' . $externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public function unsuspendService(string $externalServiceId): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/unsuspend',
|
||||
[],
|
||||
'service:unsuspend:' . $externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public function terminateService(string $externalServiceId): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/terminate',
|
||||
[],
|
||||
'service:terminate:' . $externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public function changePackage(string $externalServiceId, array $payload): array
|
||||
{
|
||||
return $this->request(
|
||||
'PATCH',
|
||||
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/change-package',
|
||||
$payload,
|
||||
'service:change-package:' . $externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
|
||||
{
|
||||
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
|
||||
$timestamp = (string) (int) (microtime(true) * 1000);
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$idempotencyKey = $idempotencySuffix ?: $method . ':' . $path . ':' . $timestamp;
|
||||
|
||||
$signatureBase = implode("\n", [
|
||||
strtoupper($method),
|
||||
$path,
|
||||
'',
|
||||
hash('sha256', $bodyJson),
|
||||
$timestamp,
|
||||
$nonce,
|
||||
$idempotencyKey,
|
||||
]);
|
||||
|
||||
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
||||
|
||||
$ch = curl_init($this->baseUrl . $path);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Unable to initialize HTTP client');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'X-HGC-Integration-ID: ' . $this->integrationId,
|
||||
'X-HGC-Timestamp: ' . $timestamp,
|
||||
'X-HGC-Nonce: ' . $nonce,
|
||||
'X-HGC-Idempotency-Key: ' . $idempotencyKey,
|
||||
'X-HGC-Signature: ' . $signature,
|
||||
],
|
||||
CURLOPT_POSTFIELDS => $bodyJson,
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new RuntimeException('GameCloud request failed: ' . $curlError);
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if ($statusCode >= 400) {
|
||||
$message = is_array($decoded)
|
||||
? ($decoded['detail'] ?? $decoded['title'] ?? $responseBody)
|
||||
: $responseBody;
|
||||
throw new RuntimeException('GameCloud API error (' . $statusCode . '): ' . $message);
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -218,3 +218,21 @@ export {
|
||||
type EdgeStartRequest,
|
||||
type EdgeStartResponse,
|
||||
} from './join';
|
||||
|
||||
export {
|
||||
whmcsHealthResponseSchema,
|
||||
whmcsPlanCatalogResponseSchema,
|
||||
whmcsUpsertClientRequestSchema,
|
||||
whmcsUpsertClientResponseSchema,
|
||||
whmcsProvisionServiceRequestSchema,
|
||||
whmcsServiceResponseSchema,
|
||||
whmcsChangePackageRequestSchema,
|
||||
whmcsSuspendRequestSchema,
|
||||
type WhmcsHealthResponse,
|
||||
type WhmcsPlanCatalogResponse,
|
||||
type WhmcsProvisionServiceRequest,
|
||||
type WhmcsServiceResponse,
|
||||
type WhmcsUpsertClientRequest,
|
||||
type WhmcsUpsertClientResponse,
|
||||
type WhmcsChangePackageRequest,
|
||||
} from './whmcs';
|
||||
|
||||
71
packages/contracts/src/whmcs.ts
Normal file
71
packages/contracts/src/whmcs.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { serverResponseSchema } from './servers';
|
||||
|
||||
export const whmcsHealthResponseSchema = z.object({
|
||||
status: z.literal('ok'),
|
||||
integrationId: z.string(),
|
||||
billingProvider: z.string(),
|
||||
});
|
||||
|
||||
export const whmcsPlanCatalogItemSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
maxRamMb: z.number().int(),
|
||||
isFree: z.boolean(),
|
||||
});
|
||||
|
||||
export const whmcsPlanCatalogResponseSchema = z.object({
|
||||
plans: z.array(whmcsPlanCatalogItemSchema),
|
||||
});
|
||||
|
||||
export const whmcsUpsertClientRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3).max(32),
|
||||
displayName: z.string().max(64).optional(),
|
||||
});
|
||||
|
||||
export const whmcsUpsertClientResponseSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
externalClientId: z.string(),
|
||||
created: z.boolean(),
|
||||
});
|
||||
|
||||
export const whmcsProvisionServiceRequestSchema = z.object({
|
||||
externalServiceId: z.string().min(1).max(64),
|
||||
externalClientId: z.string().min(1).max(64),
|
||||
externalProductId: z.string().max(64).optional(),
|
||||
planSlug: z.string().min(1).max(64),
|
||||
serverName: z.string().min(1).max(64),
|
||||
minecraftVersion: z.string().min(1).max(32).default('1.21.1'),
|
||||
softwareFamily: z
|
||||
.enum(['VANILLA', 'PAPER', 'PURPUR', 'FABRIC', 'FORGE', 'NEOFORGE', 'QUILT', 'BEDROCK_DEDICATED'])
|
||||
.default('VANILLA'),
|
||||
edition: z.enum(['JAVA', 'BEDROCK']).default('JAVA'),
|
||||
eulaAccepted: z.literal(true),
|
||||
});
|
||||
|
||||
export const whmcsServiceResponseSchema = z.object({
|
||||
externalServiceId: z.string(),
|
||||
status: z.enum(['PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED']),
|
||||
server: serverResponseSchema,
|
||||
});
|
||||
|
||||
export const whmcsChangePackageRequestSchema = z.object({
|
||||
planSlug: z.string().min(1).max(64),
|
||||
ramMb: z.number().int().min(512).max(65536).optional(),
|
||||
});
|
||||
|
||||
export const whmcsSuspendRequestSchema = z.object({
|
||||
reason: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export type WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
|
||||
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
|
||||
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>;
|
||||
export type WhmcsUpsertClientResponse = z.infer<typeof whmcsUpsertClientResponseSchema>;
|
||||
export type WhmcsProvisionServiceRequest = z.infer<typeof whmcsProvisionServiceRequestSchema>;
|
||||
export type WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
|
||||
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
|
||||
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
||||
-- Phase 9: WHMCS integration and billing suspension
|
||||
|
||||
CREATE TYPE "WhmcsServiceStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED');
|
||||
CREATE TYPE "IntegrationOperationStatus" AS ENUM ('COMPLETED', 'FAILED');
|
||||
|
||||
ALTER TABLE "game_servers"
|
||||
ADD COLUMN IF NOT EXISTS "billingSuspendedAt" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "billingSuspendReason" TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "whmcs_installations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"integrationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"apiSecret" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "whmcs_installations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_installations_integrationId_key"
|
||||
ON "whmcs_installations"("integrationId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "whmcs_client_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"externalClientId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "whmcs_client_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_client_links_userId_key" ON "whmcs_client_links"("userId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_client_links_installationId_externalClientId_key"
|
||||
ON "whmcs_client_links"("installationId", "externalClientId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "whmcs_service_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"externalServiceId" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"externalProductId" TEXT,
|
||||
"status" "WhmcsServiceStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"suspendedAt" TIMESTAMPTZ(3),
|
||||
"terminatedAt" TIMESTAMPTZ(3),
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "whmcs_service_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_service_links_serverId_key" ON "whmcs_service_links"("serverId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_service_links_installationId_externalServiceId_key"
|
||||
ON "whmcs_service_links"("installationId", "externalServiceId");
|
||||
CREATE INDEX IF NOT EXISTS "whmcs_service_links_installationId_status_idx"
|
||||
ON "whmcs_service_links"("installationId", "status");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "integration_operations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"externalRef" TEXT,
|
||||
"status" "IntegrationOperationStatus" NOT NULL DEFAULT 'COMPLETED',
|
||||
"result" JSONB,
|
||||
"error" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "integration_operations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "integration_operations_idempotencyKey_key"
|
||||
ON "integration_operations"("idempotencyKey");
|
||||
CREATE INDEX IF NOT EXISTS "integration_operations_installationId_createdAt_idx"
|
||||
ON "integration_operations"("installationId", "createdAt");
|
||||
|
||||
ALTER TABLE "whmcs_client_links"
|
||||
ADD CONSTRAINT "whmcs_client_links_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "whmcs_client_links"
|
||||
ADD CONSTRAINT "whmcs_client_links_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "whmcs_service_links"
|
||||
ADD CONSTRAINT "whmcs_service_links_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "whmcs_service_links"
|
||||
ADD CONSTRAINT "whmcs_service_links_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "integration_operations"
|
||||
ADD CONSTRAINT "integration_operations_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -135,6 +135,18 @@ enum DnsRecordStatus {
|
||||
REMOVED
|
||||
}
|
||||
|
||||
enum WhmcsServiceStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
TERMINATED
|
||||
}
|
||||
|
||||
enum IntegrationOperationStatus {
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -156,6 +168,7 @@ model User {
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
totpCredential TotpCredential?
|
||||
creditWallet CreditWallet?
|
||||
whmcsClientLink WhmcsClientLink?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -300,6 +313,8 @@ model GameServer {
|
||||
lastMeteredAt DateTime? @db.Timestamptz(3)
|
||||
joinSlug String? @unique
|
||||
joinToStartEnabled Boolean @default(true)
|
||||
billingSuspendedAt DateTime? @db.Timestamptz(3)
|
||||
billingSuspendReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -316,6 +331,7 @@ model GameServer {
|
||||
startQueueEntry ServerStartQueueEntry?
|
||||
usageRecords UsageRecord[]
|
||||
dnsRecords ServerDnsRecord[]
|
||||
whmcsServiceLink WhmcsServiceLink?
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -684,3 +700,73 @@ model ServerDnsRecord {
|
||||
@@index([fqdn])
|
||||
@@map("server_dns_records")
|
||||
}
|
||||
|
||||
model WhmcsInstallation {
|
||||
id String @id @default(uuid())
|
||||
integrationId String @unique
|
||||
name String
|
||||
apiSecret String
|
||||
isActive Boolean @default(true)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
clientLinks WhmcsClientLink[]
|
||||
serviceLinks WhmcsServiceLink[]
|
||||
operations IntegrationOperation[]
|
||||
|
||||
@@map("whmcs_installations")
|
||||
}
|
||||
|
||||
model WhmcsClientLink {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
externalClientId String
|
||||
userId String @unique
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([installationId, externalClientId])
|
||||
@@map("whmcs_client_links")
|
||||
}
|
||||
|
||||
model WhmcsServiceLink {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
externalServiceId String
|
||||
serverId String @unique
|
||||
externalProductId String?
|
||||
status WhmcsServiceStatus @default(PENDING)
|
||||
suspendedAt DateTime? @db.Timestamptz(3)
|
||||
terminatedAt DateTime? @db.Timestamptz(3)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([installationId, externalServiceId])
|
||||
@@index([installationId, status])
|
||||
@@map("whmcs_service_links")
|
||||
}
|
||||
|
||||
model IntegrationOperation {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
idempotencyKey String @unique
|
||||
operation String
|
||||
externalRef String?
|
||||
status IntegrationOperationStatus @default(COMPLETED)
|
||||
result Json?
|
||||
error String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([installationId, createdAt])
|
||||
@@map("integration_operations")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ export const DEV_NODE_ENROLLMENT_TOKEN =
|
||||
export const DEV_NODE_2_ENROLLMENT_TOKEN =
|
||||
'local-dev-node2-enrollment-token-32ch';
|
||||
|
||||
export const DEV_WHMCS_INTEGRATION_ID = 'local-dev-whmcs';
|
||||
|
||||
export const DEV_WHMCS_API_SECRET =
|
||||
'local-dev-whmcs-api-secret-32chars-minimum';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -93,12 +98,28 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const whmcsInstallation = await prisma.whmcsInstallation.upsert({
|
||||
where: { integrationId: DEV_WHMCS_INTEGRATION_ID },
|
||||
create: {
|
||||
integrationId: DEV_WHMCS_INTEGRATION_ID,
|
||||
name: 'Local Development',
|
||||
apiSecret: DEV_WHMCS_API_SECRET,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
apiSecret: DEV_WHMCS_API_SECRET,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seed complete:');
|
||||
console.log(` Plan Free: ${freePlan.id}`);
|
||||
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
|
||||
console.log(` Dev node 2: ${devNode2.id} (${devNode2.name})`);
|
||||
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
|
||||
console.log(` Node 2 enrollment token: ${DEV_NODE_2_ENROLLMENT_TOKEN}`);
|
||||
console.log(` WHMCS integration: ${whmcsInstallation.integrationId}`);
|
||||
console.log(` WHMCS API secret: ${DEV_WHMCS_API_SECRET}`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ export type {
|
||||
CreditWallet,
|
||||
CreditTransaction,
|
||||
UsageRecord,
|
||||
WhmcsInstallation,
|
||||
WhmcsClientLink,
|
||||
WhmcsServiceLink,
|
||||
IntegrationOperation,
|
||||
Plan,
|
||||
FeatureFlag,
|
||||
SystemSetting,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,5 +5,14 @@ export {
|
||||
getEdgeListenPort,
|
||||
getEdgePublicHost,
|
||||
getPlayDomain,
|
||||
pushPendingDnsRecords,
|
||||
syncServerDnsRecords,
|
||||
} from './sync';
|
||||
export {
|
||||
createDnsProvider,
|
||||
resetDnsProviderCache,
|
||||
resolveDnsProviderName,
|
||||
type DnsProvider,
|
||||
type DnsProviderName,
|
||||
type DnsRecordSpec,
|
||||
} from './providers';
|
||||
|
||||
25
packages/dns/src/providers/index.ts
Normal file
25
packages/dns/src/providers/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { LocalDnsProvider } from './local';
|
||||
import { Rfc2136DnsProvider } from './rfc2136';
|
||||
import { resolveDnsProviderName, type DnsProvider } from './types';
|
||||
|
||||
let cachedProvider: DnsProvider | undefined;
|
||||
|
||||
export function createDnsProvider(): DnsProvider {
|
||||
if (cachedProvider) {
|
||||
return cachedProvider;
|
||||
}
|
||||
|
||||
cachedProvider =
|
||||
resolveDnsProviderName() === 'rfc2136'
|
||||
? new Rfc2136DnsProvider()
|
||||
: new LocalDnsProvider();
|
||||
|
||||
return cachedProvider;
|
||||
}
|
||||
|
||||
export function resetDnsProviderCache(): void {
|
||||
cachedProvider = undefined;
|
||||
}
|
||||
|
||||
export type { DnsProvider, DnsProviderName, DnsRecordSpec } from './types';
|
||||
export { resolveDnsProviderName } from './types';
|
||||
13
packages/dns/src/providers/local.ts
Normal file
13
packages/dns/src/providers/local.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { DnsProvider, DnsRecordSpec } from './types';
|
||||
|
||||
export class LocalDnsProvider implements DnsProvider {
|
||||
readonly name = 'local';
|
||||
|
||||
async syncRecord(_record: DnsRecordSpec): Promise<void> {
|
||||
// Metadata-only in development; records are stored in PostgreSQL.
|
||||
}
|
||||
|
||||
async removeRecord(_record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void> {
|
||||
// No external DNS in local mode.
|
||||
}
|
||||
}
|
||||
95
packages/dns/src/providers/rfc2136.ts
Normal file
95
packages/dns/src/providers/rfc2136.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import type { DnsProvider, DnsRecordSpec } from './types';
|
||||
|
||||
function getRfc2136Config() {
|
||||
return {
|
||||
server: process.env['RFC2136_SERVER'] ?? '',
|
||||
keyName: process.env['RFC2136_KEY_NAME'] ?? '',
|
||||
keySecret: process.env['RFC2136_KEY_SECRET'] ?? '',
|
||||
ttl: Number.parseInt(process.env['RFC2136_TTL'] ?? '300', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNsupdateScript(record: DnsRecordSpec, mode: 'add' | 'delete'): string {
|
||||
const config = getRfc2136Config();
|
||||
const ttl = record.ttl ?? config.ttl;
|
||||
const lines = [`server ${config.server}`, `key hmac-md5:${config.keyName} ${config.keySecret}`];
|
||||
|
||||
if (mode === 'delete') {
|
||||
if (record.recordType === 'SRV') {
|
||||
lines.push(`update delete ${record.fqdn} SRV`);
|
||||
} else {
|
||||
lines.push(`update delete ${record.fqdn} A`);
|
||||
}
|
||||
} else if (record.recordType === 'SRV') {
|
||||
const priority = record.srvPriority ?? 0;
|
||||
const weight = record.srvWeight ?? 5;
|
||||
const port = record.port ?? 25565;
|
||||
const target = record.target.endsWith('.') ? record.target : `${record.target}.`;
|
||||
lines.push(
|
||||
`update add ${record.fqdn} ${ttl} SRV ${priority} ${weight} ${port} ${target}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`update add ${record.fqdn} ${ttl} A ${record.target}`);
|
||||
}
|
||||
|
||||
lines.push('send');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function runNsupdate(script: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('nsupdate', ['-v'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `nsupdate exited with code ${code}`));
|
||||
});
|
||||
child.stdin.write(script);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export class Rfc2136DnsProvider implements DnsProvider {
|
||||
readonly name = 'rfc2136';
|
||||
|
||||
private assertConfigured(): void {
|
||||
const config = getRfc2136Config();
|
||||
if (!config.server || !config.keyName || !config.keySecret) {
|
||||
throw new Error(
|
||||
'RFC2136 DNS provider requires RFC2136_SERVER, RFC2136_KEY_NAME and RFC2136_KEY_SECRET',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async syncRecord(record: DnsRecordSpec): Promise<void> {
|
||||
this.assertConfigured();
|
||||
const script = buildNsupdateScript(record, 'add');
|
||||
await runNsupdate(script);
|
||||
}
|
||||
|
||||
async removeRecord(record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void> {
|
||||
this.assertConfigured();
|
||||
const script = buildNsupdateScript(
|
||||
{
|
||||
serverId: 'remove',
|
||||
fqdn: record.fqdn,
|
||||
recordType: record.recordType,
|
||||
target: '0.0.0.0',
|
||||
},
|
||||
'delete',
|
||||
);
|
||||
await runNsupdate(script);
|
||||
}
|
||||
}
|
||||
23
packages/dns/src/providers/types.ts
Normal file
23
packages/dns/src/providers/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface DnsRecordSpec {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
recordType: 'A' | 'SRV';
|
||||
target: string;
|
||||
port?: number;
|
||||
srvPriority?: number;
|
||||
srvWeight?: number;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface DnsProvider {
|
||||
readonly name: string;
|
||||
syncRecord(record: DnsRecordSpec): Promise<void>;
|
||||
removeRecord(record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void>;
|
||||
}
|
||||
|
||||
export type DnsProviderName = 'local' | 'rfc2136';
|
||||
|
||||
export function resolveDnsProviderName(): DnsProviderName {
|
||||
const value = (process.env['DNS_PROVIDER'] ?? 'local').toLowerCase();
|
||||
return value === 'rfc2136' ? 'rfc2136' : 'local';
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { prisma } from '@hexahost/database';
|
||||
import type { GameNode, GameServer } from '@hexahost/database';
|
||||
|
||||
import { createDnsProvider, resolveDnsProviderName } from './providers';
|
||||
import type { DnsRecordSpec } from './providers';
|
||||
import { buildJoinSlug } from './slug';
|
||||
|
||||
export function getPlayDomain(): string {
|
||||
@@ -21,6 +23,10 @@ function joinHostname(slug: string, playDomain = getPlayDomain()): string {
|
||||
return `${slug}.${playDomain}`;
|
||||
}
|
||||
|
||||
function initialDnsStatus(): 'PENDING' | 'ACTIVE' {
|
||||
return resolveDnsProviderName() === 'rfc2136' ? 'PENDING' : 'ACTIVE';
|
||||
}
|
||||
|
||||
async function upsertDnsRecord(input: {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
@@ -30,6 +36,8 @@ async function upsertDnsRecord(input: {
|
||||
srvPriority?: number;
|
||||
srvWeight?: number;
|
||||
}): Promise<void> {
|
||||
const status = initialDnsStatus();
|
||||
|
||||
await prisma.serverDnsRecord.upsert({
|
||||
where: {
|
||||
serverId_recordType_fqdn: {
|
||||
@@ -46,19 +54,75 @@ async function upsertDnsRecord(input: {
|
||||
port: input.port,
|
||||
srvPriority: input.srvPriority,
|
||||
srvWeight: input.srvWeight,
|
||||
status: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
status,
|
||||
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
|
||||
},
|
||||
update: {
|
||||
target: input.target,
|
||||
port: input.port,
|
||||
srvPriority: input.srvPriority,
|
||||
srvWeight: input.srvWeight,
|
||||
status,
|
||||
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toDnsRecordSpec(record: {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
recordType: string;
|
||||
target: string;
|
||||
port: number | null;
|
||||
srvPriority: number | null;
|
||||
srvWeight: number | null;
|
||||
}): DnsRecordSpec {
|
||||
return {
|
||||
serverId: record.serverId,
|
||||
fqdn: record.fqdn,
|
||||
recordType: record.recordType as 'A' | 'SRV',
|
||||
target: record.target,
|
||||
port: record.port ?? undefined,
|
||||
srvPriority: record.srvPriority ?? undefined,
|
||||
srvWeight: record.srvWeight ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pushPendingDnsRecords(limit = 50): Promise<number> {
|
||||
const provider = createDnsProvider();
|
||||
const pending = await prisma.serverDnsRecord.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
take: limit,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
});
|
||||
|
||||
let synced = 0;
|
||||
|
||||
for (const record of pending) {
|
||||
try {
|
||||
await provider.syncRecord(toDnsRecordSpec(record));
|
||||
await prisma.serverDnsRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
synced += 1;
|
||||
} catch (error) {
|
||||
await prisma.serverDnsRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
lastError: error instanceof Error ? error.message : 'DNS sync failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return synced;
|
||||
}
|
||||
|
||||
export async function syncServerDnsRecords(
|
||||
@@ -91,6 +155,12 @@ export async function syncServerDnsRecords(
|
||||
srvWeight: 5,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveDnsProviderName() === 'local') {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushPendingDnsRecords(10);
|
||||
}
|
||||
|
||||
async function ensureUniqueSlug(serverId: string, serverName: string): Promise<string> {
|
||||
|
||||
18
packages/integration-auth/package.json
Normal file
18
packages/integration-auth/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@hexahost/integration-auth",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
25
packages/integration-auth/src/hmac.test.ts
Normal file
25
packages/integration-auth/src/hmac.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { signRequest, verifyRequestSignature } from './hmac';
|
||||
|
||||
describe('integration HMAC', () => {
|
||||
it('signs and verifies canonical requests', () => {
|
||||
const parts = {
|
||||
method: 'POST',
|
||||
path: '/api/v1/integrations/whmcs/services',
|
||||
query: '',
|
||||
body: '{"externalServiceId":"42"}',
|
||||
timestamp: '1710000000000',
|
||||
nonce: 'nonce-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
};
|
||||
|
||||
const signature = signRequest('test-secret', parts);
|
||||
expect(
|
||||
verifyRequestSignature('test-secret', parts, signature, {
|
||||
now: 1_710_000_000_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(verifyRequestSignature('wrong', parts, signature)).toBe(false);
|
||||
});
|
||||
});
|
||||
81
packages/integration-auth/src/hmac.ts
Normal file
81
packages/integration-auth/src/hmac.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createHmac, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
export const INTEGRATION_HEADERS = {
|
||||
integrationId: 'x-hgc-integration-id',
|
||||
timestamp: 'x-hgc-timestamp',
|
||||
nonce: 'x-hgc-nonce',
|
||||
idempotencyKey: 'x-hgc-idempotency-key',
|
||||
signature: 'x-hgc-signature',
|
||||
correlationId: 'x-correlation-id',
|
||||
} as const;
|
||||
|
||||
export interface SignedRequestParts {
|
||||
method: string;
|
||||
path: string;
|
||||
query: string;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export function hashBody(body: string): string {
|
||||
return createHash('sha256').update(body).digest('hex');
|
||||
}
|
||||
|
||||
export function buildSignatureBase(parts: SignedRequestParts): string {
|
||||
return [
|
||||
parts.method.toUpperCase(),
|
||||
parts.path,
|
||||
parts.query,
|
||||
hashBody(parts.body),
|
||||
parts.timestamp,
|
||||
parts.nonce,
|
||||
parts.idempotencyKey,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function signRequest(secret: string, parts: SignedRequestParts): string {
|
||||
const base = buildSignatureBase(parts);
|
||||
return createHmac('sha256', secret).update(base).digest('hex');
|
||||
}
|
||||
|
||||
export function verifyRequestSignature(
|
||||
secret: string,
|
||||
parts: SignedRequestParts,
|
||||
providedSignature: string,
|
||||
options?: { maxSkewMs?: number; now?: number },
|
||||
): boolean {
|
||||
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
|
||||
const now = options?.now ?? Date.now();
|
||||
const timestampMs = Number.parseInt(parts.timestamp, 10);
|
||||
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Math.abs(now - timestampMs) > maxSkewMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = signRequest(secret, parts);
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function canonicalQuery(query: Record<string, string | string[] | undefined>): string {
|
||||
return Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => {
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(normalized ?? '')}`;
|
||||
})
|
||||
.sort()
|
||||
.join('&');
|
||||
}
|
||||
9
packages/integration-auth/src/index.ts
Normal file
9
packages/integration-auth/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
INTEGRATION_HEADERS,
|
||||
buildSignatureBase,
|
||||
canonicalQuery,
|
||||
hashBody,
|
||||
signRequest,
|
||||
verifyRequestSignature,
|
||||
type SignedRequestParts,
|
||||
} from './hmac';
|
||||
9
packages/integration-auth/tsconfig.json
Normal file
9
packages/integration-auth/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
'@hexahost/dns':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/dns
|
||||
'@hexahost/integration-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/integration-auth
|
||||
'@hexahost/metering':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/metering
|
||||
@@ -352,6 +355,21 @@ importers:
|
||||
|
||||
packages/eslint-config: {}
|
||||
|
||||
packages/integration-auth:
|
||||
devDependencies:
|
||||
'@hexahost/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.20.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.8
|
||||
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
|
||||
|
||||
packages/metering:
|
||||
devDependencies:
|
||||
'@hexahost/typescript-config':
|
||||
|
||||
Reference in New Issue
Block a user