Phase9
This commit is contained in:
@@ -93,6 +93,6 @@ API_PORT=3001
|
|||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
|
|
||||||
# --- WHMCS Integration (optional) ---
|
# --- WHMCS Integration (optional) ---
|
||||||
WHMCS_INTEGRATION_ID=
|
WHMCS_INTEGRATION_ID=local-dev-whmcs
|
||||||
WHMCS_API_SECRET=
|
WHMCS_API_SECRET=local-dev-whmcs-api-secret-32chars-minimum
|
||||||
WHMCS_WEBHOOK_SECRET=
|
WHMCS_WEBHOOK_SECRET=
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ packages/
|
|||||||
ui/ Gemeinsame UI-Komponenten
|
ui/ Gemeinsame UI-Komponenten
|
||||||
deploy/ Docker Compose, systemd, Ansible
|
deploy/ Docker Compose, systemd, Ansible
|
||||||
docs/ Architektur, ADR, Betrieb
|
docs/ Architektur, ADR, Betrieb
|
||||||
integrations/ WHMCS-Module (später)
|
integrations/ WHMCS-Modul (`hexagamecloud`)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
@@ -92,7 +92,7 @@ make build
|
|||||||
|
|
||||||
## Aktueller Stand
|
## 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.
|
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@hexahost/contracts": "workspace:*",
|
"@hexahost/contracts": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
"@hexahost/dns": "workspace:*",
|
"@hexahost/dns": "workspace:*",
|
||||||
|
"@hexahost/integration-auth": "workspace:*",
|
||||||
"@hexahost/metering": "workspace:*",
|
"@hexahost/metering": "workspace:*",
|
||||||
"@hexahost/scheduler": "workspace:*",
|
"@hexahost/scheduler": "workspace:*",
|
||||||
"@hexahost/storage": "workspace:*",
|
"@hexahost/storage": "workspace:*",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { BillingModule } from './billing/billing.module';
|
|||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
import { IntegrationsModule } from './integrations/integrations.module';
|
||||||
import { EdgeModule } from './edge/edge.module';
|
import { EdgeModule } from './edge/edge.module';
|
||||||
import { ServersModule } from './servers/servers.module';
|
import { ServersModule } from './servers/servers.module';
|
||||||
import { AppConfigModule } from './config/app-config.module';
|
import { AppConfigModule } from './config/app-config.module';
|
||||||
@@ -46,6 +47,7 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
CatalogModule,
|
CatalogModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
EdgeModule,
|
EdgeModule,
|
||||||
|
IntegrationsModule,
|
||||||
ServersModule,
|
ServersModule,
|
||||||
NodesModule,
|
NodesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
|
|||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
function isWhmcsBilling(): boolean {
|
||||||
|
return (process.env['BILLING_PROVIDER'] ?? 'internal') === 'whmcs';
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -95,6 +99,10 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async assertCanStartServer(userId: string): Promise<void> {
|
async assertCanStartServer(userId: string): Promise<void> {
|
||||||
|
if (isWhmcsBilling()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.ensureWalletForUser(userId);
|
await this.ensureWalletForUser(userId);
|
||||||
|
|
||||||
const wallet = await this.prisma.creditWallet.findUnique({
|
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) {
|
if (server.status === 'RUNNING' && backend) {
|
||||||
return { ...base, action: 'proxy', 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);
|
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()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle(config.APP_NAME)
|
.setTitle(config.APP_NAME)
|
||||||
.setDescription('HexaHost GameCloud REST API')
|
.setDescription('HexaHost GameCloud REST API')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -93,6 +94,7 @@ export class ServersService {
|
|||||||
skipStop = false,
|
skipStop = false,
|
||||||
): Promise<ServerActionResponse> {
|
): Promise<ServerActionResponse> {
|
||||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
|
this.assertServerNotBillingSuspended(server);
|
||||||
this.serverState.assertNotInProgress(server.status);
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
if (!skipStop && server.status === 'RUNNING') {
|
if (!skipStop && server.status === 'RUNNING') {
|
||||||
@@ -107,6 +109,7 @@ export class ServersService {
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
): Promise<ServerActionResponse> {
|
): Promise<ServerActionResponse> {
|
||||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
|
this.assertServerNotBillingSuspended(server);
|
||||||
this.serverState.assertNotInProgress(server.status);
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||||
@@ -190,6 +193,7 @@ export class ServersService {
|
|||||||
throw new ConflictException('Server not found');
|
throw new ConflictException('Server not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertServerNotBillingSuspended(server);
|
||||||
this.serverState.assertNotInProgress(server.status);
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||||
@@ -410,6 +414,14 @@ export class ServersService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertServerNotBillingSuspended(server: GameServer): void {
|
||||||
|
if (server.billingSuspendedAt) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
server.billingSuspendReason ?? 'Server is suspended by billing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async recordTransition(
|
private async recordTransition(
|
||||||
gameServerId: string,
|
gameServerId: string,
|
||||||
fromStatus: GameServerStatus | null,
|
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 { validateConfig } from '@hexahost/config';
|
||||||
import { prisma } from '@hexahost/database';
|
import { prisma } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { processDnsSyncTick } from './handlers/dns-sync';
|
||||||
import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing';
|
import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing';
|
||||||
import { processNodeHealthTick } from './handlers/node-health';
|
import { processNodeHealthTick } from './handlers/node-health';
|
||||||
import { processNotificationJob } from './handlers/notifications';
|
import { processNotificationJob } from './handlers/notifications';
|
||||||
@@ -106,6 +107,9 @@ async function bootstrap(): Promise<void> {
|
|||||||
void processUsageMeteringTick().catch((error: Error) => {
|
void processUsageMeteringTick().catch((error: Error) => {
|
||||||
logger.error({ err: error }, 'Usage metering tick failed');
|
logger.error({ err: error }, 'Usage metering tick failed');
|
||||||
});
|
});
|
||||||
|
void processDnsSyncTick().catch((error: Error) => {
|
||||||
|
logger.error({ err: error }, 'DNS sync tick failed');
|
||||||
|
});
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
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
|
## 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
|
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
|
### Phase 8 completed
|
||||||
|
|
||||||
@@ -111,6 +154,6 @@ Last updated: 2026-06-26
|
|||||||
|
|
||||||
## Phase 0 — Monorepo foundation ✅
|
## 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
|
## 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)
|
## 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 EdgeStartRequest,
|
||||||
type EdgeStartResponse,
|
type EdgeStartResponse,
|
||||||
} from './join';
|
} 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
|
REMOVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WhmcsServiceStatus {
|
||||||
|
PENDING
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
TERMINATED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IntegrationOperationStatus {
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -156,6 +168,7 @@ model User {
|
|||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
totpCredential TotpCredential?
|
totpCredential TotpCredential?
|
||||||
creditWallet CreditWallet?
|
creditWallet CreditWallet?
|
||||||
|
whmcsClientLink WhmcsClientLink?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -300,6 +313,8 @@ model GameServer {
|
|||||||
lastMeteredAt DateTime? @db.Timestamptz(3)
|
lastMeteredAt DateTime? @db.Timestamptz(3)
|
||||||
joinSlug String? @unique
|
joinSlug String? @unique
|
||||||
joinToStartEnabled Boolean @default(true)
|
joinToStartEnabled Boolean @default(true)
|
||||||
|
billingSuspendedAt DateTime? @db.Timestamptz(3)
|
||||||
|
billingSuspendReason String?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@ -316,6 +331,7 @@ model GameServer {
|
|||||||
startQueueEntry ServerStartQueueEntry?
|
startQueueEntry ServerStartQueueEntry?
|
||||||
usageRecords UsageRecord[]
|
usageRecords UsageRecord[]
|
||||||
dnsRecords ServerDnsRecord[]
|
dnsRecords ServerDnsRecord[]
|
||||||
|
whmcsServiceLink WhmcsServiceLink?
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([nodeId])
|
@@index([nodeId])
|
||||||
@@ -684,3 +700,73 @@ model ServerDnsRecord {
|
|||||||
@@index([fqdn])
|
@@index([fqdn])
|
||||||
@@map("server_dns_records")
|
@@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 =
|
export const DEV_NODE_2_ENROLLMENT_TOKEN =
|
||||||
'local-dev-node2-enrollment-token-32ch';
|
'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> {
|
async function main(): Promise<void> {
|
||||||
const prisma = new PrismaClient();
|
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('Seed complete:');
|
||||||
console.log(` Plan Free: ${freePlan.id}`);
|
console.log(` Plan Free: ${freePlan.id}`);
|
||||||
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
|
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
|
||||||
console.log(` Dev node 2: ${devNode2.id} (${devNode2.name})`);
|
console.log(` Dev node 2: ${devNode2.id} (${devNode2.name})`);
|
||||||
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
|
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
|
||||||
console.log(` Node 2 enrollment token: ${DEV_NODE_2_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 {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export type {
|
|||||||
CreditWallet,
|
CreditWallet,
|
||||||
CreditTransaction,
|
CreditTransaction,
|
||||||
UsageRecord,
|
UsageRecord,
|
||||||
|
WhmcsInstallation,
|
||||||
|
WhmcsClientLink,
|
||||||
|
WhmcsServiceLink,
|
||||||
|
IntegrationOperation,
|
||||||
Plan,
|
Plan,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,5 +5,14 @@ export {
|
|||||||
getEdgeListenPort,
|
getEdgeListenPort,
|
||||||
getEdgePublicHost,
|
getEdgePublicHost,
|
||||||
getPlayDomain,
|
getPlayDomain,
|
||||||
|
pushPendingDnsRecords,
|
||||||
syncServerDnsRecords,
|
syncServerDnsRecords,
|
||||||
} from './sync';
|
} 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 { prisma } from '@hexahost/database';
|
||||||
import type { GameNode, GameServer } from '@hexahost/database';
|
import type { GameNode, GameServer } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { createDnsProvider, resolveDnsProviderName } from './providers';
|
||||||
|
import type { DnsRecordSpec } from './providers';
|
||||||
import { buildJoinSlug } from './slug';
|
import { buildJoinSlug } from './slug';
|
||||||
|
|
||||||
export function getPlayDomain(): string {
|
export function getPlayDomain(): string {
|
||||||
@@ -21,6 +23,10 @@ function joinHostname(slug: string, playDomain = getPlayDomain()): string {
|
|||||||
return `${slug}.${playDomain}`;
|
return `${slug}.${playDomain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialDnsStatus(): 'PENDING' | 'ACTIVE' {
|
||||||
|
return resolveDnsProviderName() === 'rfc2136' ? 'PENDING' : 'ACTIVE';
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertDnsRecord(input: {
|
async function upsertDnsRecord(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
fqdn: string;
|
fqdn: string;
|
||||||
@@ -30,6 +36,8 @@ async function upsertDnsRecord(input: {
|
|||||||
srvPriority?: number;
|
srvPriority?: number;
|
||||||
srvWeight?: number;
|
srvWeight?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const status = initialDnsStatus();
|
||||||
|
|
||||||
await prisma.serverDnsRecord.upsert({
|
await prisma.serverDnsRecord.upsert({
|
||||||
where: {
|
where: {
|
||||||
serverId_recordType_fqdn: {
|
serverId_recordType_fqdn: {
|
||||||
@@ -46,19 +54,75 @@ async function upsertDnsRecord(input: {
|
|||||||
port: input.port,
|
port: input.port,
|
||||||
srvPriority: input.srvPriority,
|
srvPriority: input.srvPriority,
|
||||||
srvWeight: input.srvWeight,
|
srvWeight: input.srvWeight,
|
||||||
status: 'ACTIVE',
|
status,
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
target: input.target,
|
target: input.target,
|
||||||
port: input.port,
|
port: input.port,
|
||||||
srvPriority: input.srvPriority,
|
srvPriority: input.srvPriority,
|
||||||
srvWeight: input.srvWeight,
|
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',
|
status: 'ACTIVE',
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
lastError: null,
|
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(
|
export async function syncServerDnsRecords(
|
||||||
@@ -91,6 +155,12 @@ export async function syncServerDnsRecords(
|
|||||||
srvWeight: 5,
|
srvWeight: 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolveDnsProviderName() === 'local') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushPendingDnsRecords(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureUniqueSlug(serverId: string, serverName: string): Promise<string> {
|
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':
|
'@hexahost/dns':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/dns
|
version: link:../../packages/dns
|
||||||
|
'@hexahost/integration-auth':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/integration-auth
|
||||||
'@hexahost/metering':
|
'@hexahost/metering':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/metering
|
version: link:../../packages/metering
|
||||||
@@ -352,6 +355,21 @@ importers:
|
|||||||
|
|
||||||
packages/eslint-config: {}
|
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:
|
packages/metering:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@hexahost/typescript-config':
|
'@hexahost/typescript-config':
|
||||||
|
|||||||
Reference in New Issue
Block a user