Phase9
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
smueller
2026-06-26 13:46:25 +02:00
parent b335f6a497
commit ed8334328e
49 changed files with 2219 additions and 16 deletions

View File

@@ -5,5 +5,14 @@ export {
getEdgeListenPort,
getEdgePublicHost,
getPlayDomain,
pushPendingDnsRecords,
syncServerDnsRecords,
} from './sync';
export {
createDnsProvider,
resetDnsProviderCache,
resolveDnsProviderName,
type DnsProvider,
type DnsProviderName,
type DnsRecordSpec,
} from './providers';

View 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';

View 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.
}
}

View 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);
}
}

View 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';
}

View File

@@ -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> {