Phase9
This commit is contained in:
@@ -218,3 +218,21 @@ export {
|
||||
type EdgeStartRequest,
|
||||
type EdgeStartResponse,
|
||||
} from './join';
|
||||
|
||||
export {
|
||||
whmcsHealthResponseSchema,
|
||||
whmcsPlanCatalogResponseSchema,
|
||||
whmcsUpsertClientRequestSchema,
|
||||
whmcsUpsertClientResponseSchema,
|
||||
whmcsProvisionServiceRequestSchema,
|
||||
whmcsServiceResponseSchema,
|
||||
whmcsChangePackageRequestSchema,
|
||||
whmcsSuspendRequestSchema,
|
||||
type WhmcsHealthResponse,
|
||||
type WhmcsPlanCatalogResponse,
|
||||
type WhmcsProvisionServiceRequest,
|
||||
type WhmcsServiceResponse,
|
||||
type WhmcsUpsertClientRequest,
|
||||
type WhmcsUpsertClientResponse,
|
||||
type WhmcsChangePackageRequest,
|
||||
} from './whmcs';
|
||||
|
||||
71
packages/contracts/src/whmcs.ts
Normal file
71
packages/contracts/src/whmcs.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { serverResponseSchema } from './servers';
|
||||
|
||||
export const whmcsHealthResponseSchema = z.object({
|
||||
status: z.literal('ok'),
|
||||
integrationId: z.string(),
|
||||
billingProvider: z.string(),
|
||||
});
|
||||
|
||||
export const whmcsPlanCatalogItemSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
maxRamMb: z.number().int(),
|
||||
isFree: z.boolean(),
|
||||
});
|
||||
|
||||
export const whmcsPlanCatalogResponseSchema = z.object({
|
||||
plans: z.array(whmcsPlanCatalogItemSchema),
|
||||
});
|
||||
|
||||
export const whmcsUpsertClientRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3).max(32),
|
||||
displayName: z.string().max(64).optional(),
|
||||
});
|
||||
|
||||
export const whmcsUpsertClientResponseSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
externalClientId: z.string(),
|
||||
created: z.boolean(),
|
||||
});
|
||||
|
||||
export const whmcsProvisionServiceRequestSchema = z.object({
|
||||
externalServiceId: z.string().min(1).max(64),
|
||||
externalClientId: z.string().min(1).max(64),
|
||||
externalProductId: z.string().max(64).optional(),
|
||||
planSlug: z.string().min(1).max(64),
|
||||
serverName: z.string().min(1).max(64),
|
||||
minecraftVersion: z.string().min(1).max(32).default('1.21.1'),
|
||||
softwareFamily: z
|
||||
.enum(['VANILLA', 'PAPER', 'PURPUR', 'FABRIC', 'FORGE', 'NEOFORGE', 'QUILT', 'BEDROCK_DEDICATED'])
|
||||
.default('VANILLA'),
|
||||
edition: z.enum(['JAVA', 'BEDROCK']).default('JAVA'),
|
||||
eulaAccepted: z.literal(true),
|
||||
});
|
||||
|
||||
export const whmcsServiceResponseSchema = z.object({
|
||||
externalServiceId: z.string(),
|
||||
status: z.enum(['PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED']),
|
||||
server: serverResponseSchema,
|
||||
});
|
||||
|
||||
export const whmcsChangePackageRequestSchema = z.object({
|
||||
planSlug: z.string().min(1).max(64),
|
||||
ramMb: z.number().int().min(512).max(65536).optional(),
|
||||
});
|
||||
|
||||
export const whmcsSuspendRequestSchema = z.object({
|
||||
reason: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export type WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
|
||||
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
|
||||
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>;
|
||||
export type WhmcsUpsertClientResponse = z.infer<typeof whmcsUpsertClientResponseSchema>;
|
||||
export type WhmcsProvisionServiceRequest = z.infer<typeof whmcsProvisionServiceRequestSchema>;
|
||||
export type WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
|
||||
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
|
||||
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
||||
-- Phase 9: WHMCS integration and billing suspension
|
||||
|
||||
CREATE TYPE "WhmcsServiceStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED');
|
||||
CREATE TYPE "IntegrationOperationStatus" AS ENUM ('COMPLETED', 'FAILED');
|
||||
|
||||
ALTER TABLE "game_servers"
|
||||
ADD COLUMN IF NOT EXISTS "billingSuspendedAt" TIMESTAMPTZ(3),
|
||||
ADD COLUMN IF NOT EXISTS "billingSuspendReason" TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "whmcs_installations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"integrationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"apiSecret" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "whmcs_installations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_installations_integrationId_key"
|
||||
ON "whmcs_installations"("integrationId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "whmcs_client_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"externalClientId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "whmcs_client_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_client_links_userId_key" ON "whmcs_client_links"("userId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_client_links_installationId_externalClientId_key"
|
||||
ON "whmcs_client_links"("installationId", "externalClientId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "whmcs_service_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"externalServiceId" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"externalProductId" TEXT,
|
||||
"status" "WhmcsServiceStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"suspendedAt" TIMESTAMPTZ(3),
|
||||
"terminatedAt" TIMESTAMPTZ(3),
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "whmcs_service_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_service_links_serverId_key" ON "whmcs_service_links"("serverId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_service_links_installationId_externalServiceId_key"
|
||||
ON "whmcs_service_links"("installationId", "externalServiceId");
|
||||
CREATE INDEX IF NOT EXISTS "whmcs_service_links_installationId_status_idx"
|
||||
ON "whmcs_service_links"("installationId", "status");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "integration_operations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"externalRef" TEXT,
|
||||
"status" "IntegrationOperationStatus" NOT NULL DEFAULT 'COMPLETED',
|
||||
"result" JSONB,
|
||||
"error" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "integration_operations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "integration_operations_idempotencyKey_key"
|
||||
ON "integration_operations"("idempotencyKey");
|
||||
CREATE INDEX IF NOT EXISTS "integration_operations_installationId_createdAt_idx"
|
||||
ON "integration_operations"("installationId", "createdAt");
|
||||
|
||||
ALTER TABLE "whmcs_client_links"
|
||||
ADD CONSTRAINT "whmcs_client_links_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "whmcs_client_links"
|
||||
ADD CONSTRAINT "whmcs_client_links_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "whmcs_service_links"
|
||||
ADD CONSTRAINT "whmcs_service_links_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "whmcs_service_links"
|
||||
ADD CONSTRAINT "whmcs_service_links_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "integration_operations"
|
||||
ADD CONSTRAINT "integration_operations_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -135,6 +135,18 @@ enum DnsRecordStatus {
|
||||
REMOVED
|
||||
}
|
||||
|
||||
enum WhmcsServiceStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
TERMINATED
|
||||
}
|
||||
|
||||
enum IntegrationOperationStatus {
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -156,6 +168,7 @@ model User {
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
totpCredential TotpCredential?
|
||||
creditWallet CreditWallet?
|
||||
whmcsClientLink WhmcsClientLink?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -300,6 +313,8 @@ model GameServer {
|
||||
lastMeteredAt DateTime? @db.Timestamptz(3)
|
||||
joinSlug String? @unique
|
||||
joinToStartEnabled Boolean @default(true)
|
||||
billingSuspendedAt DateTime? @db.Timestamptz(3)
|
||||
billingSuspendReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -316,6 +331,7 @@ model GameServer {
|
||||
startQueueEntry ServerStartQueueEntry?
|
||||
usageRecords UsageRecord[]
|
||||
dnsRecords ServerDnsRecord[]
|
||||
whmcsServiceLink WhmcsServiceLink?
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -684,3 +700,73 @@ model ServerDnsRecord {
|
||||
@@index([fqdn])
|
||||
@@map("server_dns_records")
|
||||
}
|
||||
|
||||
model WhmcsInstallation {
|
||||
id String @id @default(uuid())
|
||||
integrationId String @unique
|
||||
name String
|
||||
apiSecret String
|
||||
isActive Boolean @default(true)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
clientLinks WhmcsClientLink[]
|
||||
serviceLinks WhmcsServiceLink[]
|
||||
operations IntegrationOperation[]
|
||||
|
||||
@@map("whmcs_installations")
|
||||
}
|
||||
|
||||
model WhmcsClientLink {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
externalClientId String
|
||||
userId String @unique
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([installationId, externalClientId])
|
||||
@@map("whmcs_client_links")
|
||||
}
|
||||
|
||||
model WhmcsServiceLink {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
externalServiceId String
|
||||
serverId String @unique
|
||||
externalProductId String?
|
||||
status WhmcsServiceStatus @default(PENDING)
|
||||
suspendedAt DateTime? @db.Timestamptz(3)
|
||||
terminatedAt DateTime? @db.Timestamptz(3)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([installationId, externalServiceId])
|
||||
@@index([installationId, status])
|
||||
@@map("whmcs_service_links")
|
||||
}
|
||||
|
||||
model IntegrationOperation {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
idempotencyKey String @unique
|
||||
operation String
|
||||
externalRef String?
|
||||
status IntegrationOperationStatus @default(COMPLETED)
|
||||
result Json?
|
||||
error String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([installationId, createdAt])
|
||||
@@map("integration_operations")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ export const DEV_NODE_ENROLLMENT_TOKEN =
|
||||
export const DEV_NODE_2_ENROLLMENT_TOKEN =
|
||||
'local-dev-node2-enrollment-token-32ch';
|
||||
|
||||
export const DEV_WHMCS_INTEGRATION_ID = 'local-dev-whmcs';
|
||||
|
||||
export const DEV_WHMCS_API_SECRET =
|
||||
'local-dev-whmcs-api-secret-32chars-minimum';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -93,12 +98,28 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const whmcsInstallation = await prisma.whmcsInstallation.upsert({
|
||||
where: { integrationId: DEV_WHMCS_INTEGRATION_ID },
|
||||
create: {
|
||||
integrationId: DEV_WHMCS_INTEGRATION_ID,
|
||||
name: 'Local Development',
|
||||
apiSecret: DEV_WHMCS_API_SECRET,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
apiSecret: DEV_WHMCS_API_SECRET,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seed complete:');
|
||||
console.log(` Plan Free: ${freePlan.id}`);
|
||||
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
|
||||
console.log(` Dev node 2: ${devNode2.id} (${devNode2.name})`);
|
||||
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
|
||||
console.log(` Node 2 enrollment token: ${DEV_NODE_2_ENROLLMENT_TOKEN}`);
|
||||
console.log(` WHMCS integration: ${whmcsInstallation.integrationId}`);
|
||||
console.log(` WHMCS API secret: ${DEV_WHMCS_API_SECRET}`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ export type {
|
||||
CreditWallet,
|
||||
CreditTransaction,
|
||||
UsageRecord,
|
||||
WhmcsInstallation,
|
||||
WhmcsClientLink,
|
||||
WhmcsServiceLink,
|
||||
IntegrationOperation,
|
||||
Plan,
|
||||
FeatureFlag,
|
||||
SystemSetting,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,5 +5,14 @@ export {
|
||||
getEdgeListenPort,
|
||||
getEdgePublicHost,
|
||||
getPlayDomain,
|
||||
pushPendingDnsRecords,
|
||||
syncServerDnsRecords,
|
||||
} from './sync';
|
||||
export {
|
||||
createDnsProvider,
|
||||
resetDnsProviderCache,
|
||||
resolveDnsProviderName,
|
||||
type DnsProvider,
|
||||
type DnsProviderName,
|
||||
type DnsRecordSpec,
|
||||
} from './providers';
|
||||
|
||||
25
packages/dns/src/providers/index.ts
Normal file
25
packages/dns/src/providers/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { LocalDnsProvider } from './local';
|
||||
import { Rfc2136DnsProvider } from './rfc2136';
|
||||
import { resolveDnsProviderName, type DnsProvider } from './types';
|
||||
|
||||
let cachedProvider: DnsProvider | undefined;
|
||||
|
||||
export function createDnsProvider(): DnsProvider {
|
||||
if (cachedProvider) {
|
||||
return cachedProvider;
|
||||
}
|
||||
|
||||
cachedProvider =
|
||||
resolveDnsProviderName() === 'rfc2136'
|
||||
? new Rfc2136DnsProvider()
|
||||
: new LocalDnsProvider();
|
||||
|
||||
return cachedProvider;
|
||||
}
|
||||
|
||||
export function resetDnsProviderCache(): void {
|
||||
cachedProvider = undefined;
|
||||
}
|
||||
|
||||
export type { DnsProvider, DnsProviderName, DnsRecordSpec } from './types';
|
||||
export { resolveDnsProviderName } from './types';
|
||||
13
packages/dns/src/providers/local.ts
Normal file
13
packages/dns/src/providers/local.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { DnsProvider, DnsRecordSpec } from './types';
|
||||
|
||||
export class LocalDnsProvider implements DnsProvider {
|
||||
readonly name = 'local';
|
||||
|
||||
async syncRecord(_record: DnsRecordSpec): Promise<void> {
|
||||
// Metadata-only in development; records are stored in PostgreSQL.
|
||||
}
|
||||
|
||||
async removeRecord(_record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void> {
|
||||
// No external DNS in local mode.
|
||||
}
|
||||
}
|
||||
95
packages/dns/src/providers/rfc2136.ts
Normal file
95
packages/dns/src/providers/rfc2136.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import type { DnsProvider, DnsRecordSpec } from './types';
|
||||
|
||||
function getRfc2136Config() {
|
||||
return {
|
||||
server: process.env['RFC2136_SERVER'] ?? '',
|
||||
keyName: process.env['RFC2136_KEY_NAME'] ?? '',
|
||||
keySecret: process.env['RFC2136_KEY_SECRET'] ?? '',
|
||||
ttl: Number.parseInt(process.env['RFC2136_TTL'] ?? '300', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNsupdateScript(record: DnsRecordSpec, mode: 'add' | 'delete'): string {
|
||||
const config = getRfc2136Config();
|
||||
const ttl = record.ttl ?? config.ttl;
|
||||
const lines = [`server ${config.server}`, `key hmac-md5:${config.keyName} ${config.keySecret}`];
|
||||
|
||||
if (mode === 'delete') {
|
||||
if (record.recordType === 'SRV') {
|
||||
lines.push(`update delete ${record.fqdn} SRV`);
|
||||
} else {
|
||||
lines.push(`update delete ${record.fqdn} A`);
|
||||
}
|
||||
} else if (record.recordType === 'SRV') {
|
||||
const priority = record.srvPriority ?? 0;
|
||||
const weight = record.srvWeight ?? 5;
|
||||
const port = record.port ?? 25565;
|
||||
const target = record.target.endsWith('.') ? record.target : `${record.target}.`;
|
||||
lines.push(
|
||||
`update add ${record.fqdn} ${ttl} SRV ${priority} ${weight} ${port} ${target}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`update add ${record.fqdn} ${ttl} A ${record.target}`);
|
||||
}
|
||||
|
||||
lines.push('send');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function runNsupdate(script: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('nsupdate', ['-v'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `nsupdate exited with code ${code}`));
|
||||
});
|
||||
child.stdin.write(script);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export class Rfc2136DnsProvider implements DnsProvider {
|
||||
readonly name = 'rfc2136';
|
||||
|
||||
private assertConfigured(): void {
|
||||
const config = getRfc2136Config();
|
||||
if (!config.server || !config.keyName || !config.keySecret) {
|
||||
throw new Error(
|
||||
'RFC2136 DNS provider requires RFC2136_SERVER, RFC2136_KEY_NAME and RFC2136_KEY_SECRET',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async syncRecord(record: DnsRecordSpec): Promise<void> {
|
||||
this.assertConfigured();
|
||||
const script = buildNsupdateScript(record, 'add');
|
||||
await runNsupdate(script);
|
||||
}
|
||||
|
||||
async removeRecord(record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void> {
|
||||
this.assertConfigured();
|
||||
const script = buildNsupdateScript(
|
||||
{
|
||||
serverId: 'remove',
|
||||
fqdn: record.fqdn,
|
||||
recordType: record.recordType,
|
||||
target: '0.0.0.0',
|
||||
},
|
||||
'delete',
|
||||
);
|
||||
await runNsupdate(script);
|
||||
}
|
||||
}
|
||||
23
packages/dns/src/providers/types.ts
Normal file
23
packages/dns/src/providers/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface DnsRecordSpec {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
recordType: 'A' | 'SRV';
|
||||
target: string;
|
||||
port?: number;
|
||||
srvPriority?: number;
|
||||
srvWeight?: number;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface DnsProvider {
|
||||
readonly name: string;
|
||||
syncRecord(record: DnsRecordSpec): Promise<void>;
|
||||
removeRecord(record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void>;
|
||||
}
|
||||
|
||||
export type DnsProviderName = 'local' | 'rfc2136';
|
||||
|
||||
export function resolveDnsProviderName(): DnsProviderName {
|
||||
const value = (process.env['DNS_PROVIDER'] ?? 'local').toLowerCase();
|
||||
return value === 'rfc2136' ? 'rfc2136' : 'local';
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { prisma } from '@hexahost/database';
|
||||
import type { GameNode, GameServer } from '@hexahost/database';
|
||||
|
||||
import { createDnsProvider, resolveDnsProviderName } from './providers';
|
||||
import type { DnsRecordSpec } from './providers';
|
||||
import { buildJoinSlug } from './slug';
|
||||
|
||||
export function getPlayDomain(): string {
|
||||
@@ -21,6 +23,10 @@ function joinHostname(slug: string, playDomain = getPlayDomain()): string {
|
||||
return `${slug}.${playDomain}`;
|
||||
}
|
||||
|
||||
function initialDnsStatus(): 'PENDING' | 'ACTIVE' {
|
||||
return resolveDnsProviderName() === 'rfc2136' ? 'PENDING' : 'ACTIVE';
|
||||
}
|
||||
|
||||
async function upsertDnsRecord(input: {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
@@ -30,6 +36,8 @@ async function upsertDnsRecord(input: {
|
||||
srvPriority?: number;
|
||||
srvWeight?: number;
|
||||
}): Promise<void> {
|
||||
const status = initialDnsStatus();
|
||||
|
||||
await prisma.serverDnsRecord.upsert({
|
||||
where: {
|
||||
serverId_recordType_fqdn: {
|
||||
@@ -46,21 +54,77 @@ async function upsertDnsRecord(input: {
|
||||
port: input.port,
|
||||
srvPriority: input.srvPriority,
|
||||
srvWeight: input.srvWeight,
|
||||
status: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
status,
|
||||
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
|
||||
},
|
||||
update: {
|
||||
target: input.target,
|
||||
port: input.port,
|
||||
srvPriority: input.srvPriority,
|
||||
srvWeight: input.srvWeight,
|
||||
status: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
status,
|
||||
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toDnsRecordSpec(record: {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
recordType: string;
|
||||
target: string;
|
||||
port: number | null;
|
||||
srvPriority: number | null;
|
||||
srvWeight: number | null;
|
||||
}): DnsRecordSpec {
|
||||
return {
|
||||
serverId: record.serverId,
|
||||
fqdn: record.fqdn,
|
||||
recordType: record.recordType as 'A' | 'SRV',
|
||||
target: record.target,
|
||||
port: record.port ?? undefined,
|
||||
srvPriority: record.srvPriority ?? undefined,
|
||||
srvWeight: record.srvWeight ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pushPendingDnsRecords(limit = 50): Promise<number> {
|
||||
const provider = createDnsProvider();
|
||||
const pending = await prisma.serverDnsRecord.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
take: limit,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
});
|
||||
|
||||
let synced = 0;
|
||||
|
||||
for (const record of pending) {
|
||||
try {
|
||||
await provider.syncRecord(toDnsRecordSpec(record));
|
||||
await prisma.serverDnsRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
synced += 1;
|
||||
} catch (error) {
|
||||
await prisma.serverDnsRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
lastError: error instanceof Error ? error.message : 'DNS sync failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return synced;
|
||||
}
|
||||
|
||||
export async function syncServerDnsRecords(
|
||||
server: GameServer & { node: GameNode | null },
|
||||
playDomain = getPlayDomain(),
|
||||
@@ -91,6 +155,12 @@ export async function syncServerDnsRecords(
|
||||
srvWeight: 5,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveDnsProviderName() === 'local') {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushPendingDnsRecords(10);
|
||||
}
|
||||
|
||||
async function ensureUniqueSlug(serverId: string, serverName: string): Promise<string> {
|
||||
|
||||
18
packages/integration-auth/package.json
Normal file
18
packages/integration-auth/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@hexahost/integration-auth",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
25
packages/integration-auth/src/hmac.test.ts
Normal file
25
packages/integration-auth/src/hmac.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { signRequest, verifyRequestSignature } from './hmac';
|
||||
|
||||
describe('integration HMAC', () => {
|
||||
it('signs and verifies canonical requests', () => {
|
||||
const parts = {
|
||||
method: 'POST',
|
||||
path: '/api/v1/integrations/whmcs/services',
|
||||
query: '',
|
||||
body: '{"externalServiceId":"42"}',
|
||||
timestamp: '1710000000000',
|
||||
nonce: 'nonce-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
};
|
||||
|
||||
const signature = signRequest('test-secret', parts);
|
||||
expect(
|
||||
verifyRequestSignature('test-secret', parts, signature, {
|
||||
now: 1_710_000_000_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(verifyRequestSignature('wrong', parts, signature)).toBe(false);
|
||||
});
|
||||
});
|
||||
81
packages/integration-auth/src/hmac.ts
Normal file
81
packages/integration-auth/src/hmac.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createHmac, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
export const INTEGRATION_HEADERS = {
|
||||
integrationId: 'x-hgc-integration-id',
|
||||
timestamp: 'x-hgc-timestamp',
|
||||
nonce: 'x-hgc-nonce',
|
||||
idempotencyKey: 'x-hgc-idempotency-key',
|
||||
signature: 'x-hgc-signature',
|
||||
correlationId: 'x-correlation-id',
|
||||
} as const;
|
||||
|
||||
export interface SignedRequestParts {
|
||||
method: string;
|
||||
path: string;
|
||||
query: string;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export function hashBody(body: string): string {
|
||||
return createHash('sha256').update(body).digest('hex');
|
||||
}
|
||||
|
||||
export function buildSignatureBase(parts: SignedRequestParts): string {
|
||||
return [
|
||||
parts.method.toUpperCase(),
|
||||
parts.path,
|
||||
parts.query,
|
||||
hashBody(parts.body),
|
||||
parts.timestamp,
|
||||
parts.nonce,
|
||||
parts.idempotencyKey,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function signRequest(secret: string, parts: SignedRequestParts): string {
|
||||
const base = buildSignatureBase(parts);
|
||||
return createHmac('sha256', secret).update(base).digest('hex');
|
||||
}
|
||||
|
||||
export function verifyRequestSignature(
|
||||
secret: string,
|
||||
parts: SignedRequestParts,
|
||||
providedSignature: string,
|
||||
options?: { maxSkewMs?: number; now?: number },
|
||||
): boolean {
|
||||
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
|
||||
const now = options?.now ?? Date.now();
|
||||
const timestampMs = Number.parseInt(parts.timestamp, 10);
|
||||
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Math.abs(now - timestampMs) > maxSkewMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = signRequest(secret, parts);
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function canonicalQuery(query: Record<string, string | string[] | undefined>): string {
|
||||
return Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => {
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(normalized ?? '')}`;
|
||||
})
|
||||
.sort()
|
||||
.join('&');
|
||||
}
|
||||
9
packages/integration-auth/src/index.ts
Normal file
9
packages/integration-auth/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
INTEGRATION_HEADERS,
|
||||
buildSignatureBase,
|
||||
canonicalQuery,
|
||||
hashBody,
|
||||
signRequest,
|
||||
verifyRequestSignature,
|
||||
type SignedRequestParts,
|
||||
} from './hmac';
|
||||
9
packages/integration-auth/tsconfig.json
Normal file
9
packages/integration-auth/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user