Phase8
This commit is contained in:
@@ -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';
|
||||
|
||||
38
packages/contracts/src/join.ts
Normal file
38
packages/contracts/src/join.ts
Normal 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>;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
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