Phase9
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user