Phase8
This commit is contained in:
21
packages/dns/package.json
Normal file
21
packages/dns/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@hexahost/dns",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hexahost/database": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
9
packages/dns/src/index.ts
Normal file
9
packages/dns/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { buildJoinSlug, extractSlugFromHostname } from './slug';
|
||||
export {
|
||||
assignJoinAddress,
|
||||
formatJoinAddress,
|
||||
getEdgeListenPort,
|
||||
getEdgePublicHost,
|
||||
getPlayDomain,
|
||||
syncServerDnsRecords,
|
||||
} from './sync';
|
||||
20
packages/dns/src/slug.test.ts
Normal file
20
packages/dns/src/slug.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildJoinSlug, extractSlugFromHostname } from './slug';
|
||||
|
||||
describe('buildJoinSlug', () => {
|
||||
it('sanitizes server names', () => {
|
||||
const slug = buildJoinSlug('My Cool Server!', '00000000-0000-4000-8000-000000000099');
|
||||
expect(slug).toMatch(/^my-cool-server-000000$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSlugFromHostname', () => {
|
||||
it('extracts slug from play domain hostname', () => {
|
||||
expect(extractSlugFromHostname('abc.play.example.net', 'play.example.net')).toBe('abc');
|
||||
});
|
||||
|
||||
it('rejects unknown domains', () => {
|
||||
expect(extractSlugFromHostname('abc.other.net', 'play.example.net')).toBeNull();
|
||||
});
|
||||
});
|
||||
34
packages/dns/src/slug.ts
Normal file
34
packages/dns/src/slug.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export function buildJoinSlug(serverName: string, serverId: string): string {
|
||||
const base =
|
||||
serverName
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 24) || 'server';
|
||||
|
||||
const suffix = serverId.replace(/-/g, '').slice(0, 6);
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
export function extractSlugFromHostname(hostname: string, playDomain: string): string | null {
|
||||
const normalizedHost = hostname.split('\0')[0]?.toLowerCase() ?? '';
|
||||
const normalizedDomain = playDomain.toLowerCase();
|
||||
|
||||
if (normalizedHost === normalizedDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix = `.${normalizedDomain}`;
|
||||
if (!normalizedHost.endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slug = normalizedHost.slice(0, -suffix.length);
|
||||
if (!slug || slug.includes('.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
158
packages/dns/src/sync.ts
Normal file
158
packages/dns/src/sync.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { prisma } from '@hexahost/database';
|
||||
import type { GameNode, GameServer } from '@hexahost/database';
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function upsertDnsRecord(input: {
|
||||
serverId: string;
|
||||
fqdn: string;
|
||||
recordType: string;
|
||||
target: string;
|
||||
port?: number;
|
||||
srvPriority?: number;
|
||||
srvWeight?: number;
|
||||
}): Promise<void> {
|
||||
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: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
target: input.target,
|
||||
port: input.port,
|
||||
srvPriority: input.srvPriority,
|
||||
srvWeight: input.srvWeight,
|
||||
status: 'ACTIVE',
|
||||
lastSyncedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
9
packages/dns/tsconfig.json
Normal file
9
packages/dns/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