Files
HexaHost-GameCloud/packages/dns/src/sync.ts
smueller ed8334328e
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
Phase9
2026-06-26 13:46:25 +02:00

229 lines
5.6 KiB
TypeScript

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 {
return process.env['GAME_BASE_DOMAIN'] ?? 'play.example.net';
}
export function getEdgePublicHost(): string {
return process.env['EDGE_PUBLIC_HOST'] ?? '127.0.0.1';
}
export function getEdgeListenPort(): number {
const raw = process.env['EDGE_LISTEN_PORT'] ?? '25565';
const port = Number.parseInt(raw, 10);
return Number.isFinite(port) ? port : 25565;
}
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;
recordType: string;
target: string;
port?: number;
srvPriority?: number;
srvWeight?: number;
}): Promise<void> {
const status = initialDnsStatus();
await prisma.serverDnsRecord.upsert({
where: {
serverId_recordType_fqdn: {
serverId: input.serverId,
recordType: input.recordType,
fqdn: input.fqdn,
},
},
create: {
serverId: input.serverId,
fqdn: input.fqdn,
recordType: input.recordType,
target: input.target,
port: input.port,
srvPriority: input.srvPriority,
srvWeight: input.srvWeight,
status,
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
},
update: {
target: input.target,
port: input.port,
srvPriority: input.srvPriority,
srvWeight: input.srvWeight,
status,
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
lastError: null,
},
});
}
function toDnsRecordSpec(record: {
serverId: string;
fqdn: string;
recordType: string;
target: string;
port: number | null;
srvPriority: number | null;
srvWeight: number | null;
}): DnsRecordSpec {
return {
serverId: record.serverId,
fqdn: record.fqdn,
recordType: record.recordType as 'A' | 'SRV',
target: record.target,
port: record.port ?? undefined,
srvPriority: record.srvPriority ?? undefined,
srvWeight: record.srvWeight ?? undefined,
};
}
export async function pushPendingDnsRecords(limit = 50): Promise<number> {
const provider = createDnsProvider();
const pending = await prisma.serverDnsRecord.findMany({
where: { status: 'PENDING' },
take: limit,
orderBy: { updatedAt: 'asc' },
});
let synced = 0;
for (const record of pending) {
try {
await provider.syncRecord(toDnsRecordSpec(record));
await prisma.serverDnsRecord.update({
where: { id: record.id },
data: {
status: 'ACTIVE',
lastSyncedAt: new Date(),
lastError: null,
},
});
synced += 1;
} catch (error) {
await prisma.serverDnsRecord.update({
where: { id: record.id },
data: {
status: 'FAILED',
lastError: error instanceof Error ? error.message : 'DNS sync failed',
},
});
}
}
return synced;
}
export async function syncServerDnsRecords(
server: GameServer & { node: GameNode | null },
playDomain = getPlayDomain(),
): Promise<void> {
if (!server.joinSlug) {
return;
}
const fqdn = joinHostname(server.joinSlug, playDomain);
const edgeTarget = getEdgePublicHost();
const edgePort = getEdgeListenPort();
await upsertDnsRecord({
serverId: server.id,
fqdn,
recordType: 'A',
target: edgeTarget,
});
if (server.edition === 'JAVA') {
await upsertDnsRecord({
serverId: server.id,
fqdn: `_minecraft._tcp.${fqdn}`,
recordType: 'SRV',
target: fqdn,
port: edgePort,
srvPriority: 0,
srvWeight: 5,
});
}
if (resolveDnsProviderName() === 'local') {
return;
}
await pushPendingDnsRecords(10);
}
async function ensureUniqueSlug(serverId: string, serverName: string): Promise<string> {
let slug = buildJoinSlug(serverName, serverId);
let attempt = 0;
while (
await prisma.gameServer.findFirst({
where: { joinSlug: slug, NOT: { id: serverId } },
select: { id: true },
})
) {
attempt += 1;
slug = `${buildJoinSlug(serverName, serverId)}-${attempt}`;
}
return slug;
}
export async function assignJoinAddress(
serverId: string,
options?: { playDomain?: string },
): Promise<{ joinSlug: string; joinHostname: string } | null> {
const playDomain = options?.playDomain ?? getPlayDomain();
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
include: { node: true },
});
if (!server || !server.nodeId || server.hostPort === null) {
return null;
}
const joinSlug = server.joinSlug ?? (await ensureUniqueSlug(serverId, server.name));
const updated =
server.joinSlug === joinSlug
? server
: await prisma.gameServer.update({
where: { id: serverId },
data: { joinSlug },
include: { node: true },
});
await syncServerDnsRecords(updated, playDomain);
return {
joinSlug,
joinHostname: joinHostname(joinSlug, playDomain),
};
}
export function formatJoinAddress(
joinSlug: string | null,
edition: GameServer['edition'],
playDomain = getPlayDomain(),
): string | null {
if (!joinSlug) {
return null;
}
const hostname = joinHostname(joinSlug, playDomain);
return edition === 'JAVA' ? hostname : `${hostname}:${getEdgeListenPort()}`;
}