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

This commit is contained in:
smueller
2026-06-26 13:33:03 +02:00
parent ab21f53cdd
commit b335f6a497
39 changed files with 1477 additions and 20 deletions

View File

@@ -205,3 +205,16 @@ export {
type WalletResponse,
type UsageListResponse,
} from './billing';
export {
edgeBackendSchema,
edgeActionSchema,
edgeResolveResponseSchema,
edgeStartRequestSchema,
edgeStartResponseSchema,
type EdgeBackend,
type EdgeAction,
type EdgeResolveResponse,
type EdgeStartRequest,
type EdgeStartResponse,
} from './join';

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { gameServerStatusSchema, serverEditionSchema } from './servers';
export const edgeBackendSchema = z.object({
host: z.string(),
port: z.number().int(),
});
export const edgeActionSchema = z.enum(['proxy', 'start', 'wait', 'reject']);
export const edgeResolveResponseSchema = z.object({
serverId: z.string().uuid(),
slug: z.string(),
status: gameServerStatusSchema,
joinToStartEnabled: z.boolean(),
edition: serverEditionSchema,
backend: edgeBackendSchema.nullable(),
action: edgeActionSchema,
message: z.string().optional(),
});
export const edgeStartRequestSchema = z.object({
clientIp: z.string().min(1).max(64),
});
export const edgeStartResponseSchema = z.object({
resolve: edgeResolveResponseSchema,
started: z.boolean(),
jobId: z.string().optional(),
queuePosition: z.number().int().optional(),
});
export type EdgeBackend = z.infer<typeof edgeBackendSchema>;
export type EdgeAction = z.infer<typeof edgeActionSchema>;
export type EdgeResolveResponse = z.infer<typeof edgeResolveResponseSchema>;
export type EdgeStartRequest = z.infer<typeof edgeStartRequestSchema>;
export type EdgeStartResponse = z.infer<typeof edgeStartResponseSchema>;

View File

@@ -61,6 +61,9 @@ export const serverResponseSchema = z.object({
ramMb: z.number().int(),
idleShutdownEnabled: z.boolean(),
idleStopAt: z.string().datetime().nullable(),
joinSlug: z.string().nullable(),
joinHostname: z.string().nullable(),
joinToStartEnabled: z.boolean(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
-- Phase 8: DNS metadata and join-to-start
CREATE TYPE "DnsRecordStatus" AS ENUM ('PENDING', 'ACTIVE', 'FAILED', 'REMOVED');
ALTER TABLE "game_servers"
ADD COLUMN IF NOT EXISTS "joinSlug" TEXT,
ADD COLUMN IF NOT EXISTS "joinToStartEnabled" BOOLEAN NOT NULL DEFAULT true;
CREATE UNIQUE INDEX IF NOT EXISTS "game_servers_joinSlug_key" ON "game_servers"("joinSlug");
ALTER TABLE "game_nodes"
ADD COLUMN IF NOT EXISTS "portRangeStart" INTEGER NOT NULL DEFAULT 25565,
ADD COLUMN IF NOT EXISTS "portRangeEnd" INTEGER NOT NULL DEFAULT 25664;
CREATE TABLE IF NOT EXISTS "server_dns_records" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"fqdn" TEXT NOT NULL,
"recordType" TEXT NOT NULL,
"target" TEXT NOT NULL,
"port" INTEGER,
"srvPriority" INTEGER,
"srvWeight" INTEGER,
"status" "DnsRecordStatus" NOT NULL DEFAULT 'PENDING',
"lastSyncedAt" TIMESTAMPTZ(3),
"lastError" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "server_dns_records_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "server_dns_records_serverId_recordType_fqdn_key"
ON "server_dns_records"("serverId", "recordType", "fqdn");
CREATE INDEX IF NOT EXISTS "server_dns_records_fqdn_idx" ON "server_dns_records"("fqdn");
ALTER TABLE "server_dns_records"
ADD CONSTRAINT "server_dns_records_serverId_fkey"
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -128,6 +128,13 @@ enum CreditTransactionType {
EXPIRE
}
enum DnsRecordStatus {
PENDING
ACTIVE
FAILED
REMOVED
}
model User {
id String @id @default(uuid())
email String @unique
@@ -291,6 +298,8 @@ model GameServer {
idleStopAt DateTime? @db.Timestamptz(3)
runningSince DateTime? @db.Timestamptz(3)
lastMeteredAt DateTime? @db.Timestamptz(3)
joinSlug String? @unique
joinToStartEnabled Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -306,6 +315,7 @@ model GameServer {
installedAddons InstalledAddon[]
startQueueEntry ServerStartQueueEntry?
usageRecords UsageRecord[]
dnsRecords ServerDnsRecord[]
@@index([userId])
@@index([nodeId])
@@ -359,6 +369,8 @@ model GameNode {
maxServers Int @default(10)
maxRamMb Int @default(16384)
platformRamMb Int @default(2048)
portRangeStart Int @default(25565)
portRangeEnd Int @default(25664)
activeServers Int @default(0)
lastHeartbeatAt DateTime? @db.Timestamptz(3)
agentVersion String?
@@ -650,3 +662,25 @@ model InstalledAddon {
@@index([status])
@@map("installed_addons")
}
model ServerDnsRecord {
id String @id @default(uuid())
serverId String
fqdn String
recordType String
target String
port Int?
srvPriority Int?
srvWeight Int?
status DnsRecordStatus @default(PENDING)
lastSyncedAt DateTime? @db.Timestamptz(3)
lastError String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, recordType, fqdn])
@@index([fqdn])
@@map("server_dns_records")
}

View File

@@ -57,6 +57,8 @@ async function main(): Promise<void> {
maxServers: 10,
maxRamMb: 16384,
platformRamMb: 2048,
portRangeStart: 25565,
portRangeEnd: 25664,
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
},
update: {
@@ -64,6 +66,8 @@ async function main(): Promise<void> {
hostname: 'localhost',
maxRamMb: 16384,
platformRamMb: 2048,
portRangeStart: 25565,
portRangeEnd: 25664,
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
},
});

View File

@@ -17,6 +17,7 @@ export type {
BackupSchedule,
WorldUpload,
InstalledAddon,
ServerDnsRecord,
GameNode,
GameNodeHeartbeat,
ServerStartQueueEntry,

File diff suppressed because one or more lines are too long

21
packages/dns/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,9 @@
export { buildJoinSlug, extractSlugFromHostname } from './slug';
export {
assignJoinAddress,
formatJoinAddress,
getEdgeListenPort,
getEdgePublicHost,
getPlayDomain,
syncServerDnsRecords,
} from './sync';

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

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}