229 lines
5.6 KiB
TypeScript
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()}`;
|
|
}
|