Phase8
This commit is contained in:
@@ -68,6 +68,12 @@ NODE_TLS_SKIP_VERIFY=true
|
|||||||
|
|
||||||
# --- Edge Gateway (Phase 8) ---
|
# --- Edge Gateway (Phase 8) ---
|
||||||
EDGE_GATEWAY_ENABLED=false
|
EDGE_GATEWAY_ENABLED=false
|
||||||
|
EDGE_LISTEN_ADDR=:25565
|
||||||
|
EDGE_LISTEN_PORT=25565
|
||||||
|
EDGE_PUBLIC_HOST=127.0.0.1
|
||||||
|
EDGE_INTERNAL_API_KEY=local-dev-edge-key-change-me-32chars
|
||||||
|
EDGE_START_WAIT_SECONDS=180
|
||||||
|
EDGE_POLL_INTERVAL_MS=2000
|
||||||
|
|
||||||
# --- Catalog Providers ---
|
# --- Catalog Providers ---
|
||||||
MODRINTH_USER_AGENT=HexaHostGameCloud/0.1.0 (contact@example.net)
|
MODRINTH_USER_AGENT=HexaHostGameCloud/0.1.0 (contact@example.net)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ make build
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
**Phase 7** (Idle Shutdown & Free-Tier) — abgeschlossen.
|
**Phase 8** (DNS & Join-to-Start) — abgeschlossen.
|
||||||
|
|
||||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/contracts": "workspace:*",
|
"@hexahost/contracts": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
|
"@hexahost/dns": "workspace:*",
|
||||||
"@hexahost/metering": "workspace:*",
|
"@hexahost/metering": "workspace:*",
|
||||||
"@hexahost/scheduler": "workspace:*",
|
"@hexahost/scheduler": "workspace:*",
|
||||||
"@hexahost/storage": "workspace:*",
|
"@hexahost/storage": "workspace:*",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { BillingModule } from './billing/billing.module';
|
|||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
import { EdgeModule } from './edge/edge.module';
|
||||||
import { ServersModule } from './servers/servers.module';
|
import { ServersModule } from './servers/servers.module';
|
||||||
import { AppConfigModule } from './config/app-config.module';
|
import { AppConfigModule } from './config/app-config.module';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
EdgeModule,
|
||||||
ServersModule,
|
ServersModule,
|
||||||
NodesModule,
|
NodesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
|||||||
30
apps/api/src/edge/edge-internal.guard.ts
Normal file
30
apps/api/src/edge/edge-internal.guard.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
const EDGE_KEY_HEADER = 'x-hexahost-edge-key';
|
||||||
|
|
||||||
|
function getEdgeInternalApiKey(): string {
|
||||||
|
return (
|
||||||
|
process.env['EDGE_INTERNAL_API_KEY'] ??
|
||||||
|
'local-dev-edge-key-change-me-32chars'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EdgeInternalGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const provided = request.headers[EDGE_KEY_HEADER];
|
||||||
|
|
||||||
|
if (typeof provided !== 'string' || provided !== getEdgeInternalApiKey()) {
|
||||||
|
throw new UnauthorizedException('Invalid edge internal API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/api/src/edge/edge.controller.ts
Normal file
32
apps/api/src/edge/edge.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
import type { EdgeResolveResponse, EdgeStartResponse } from '@hexahost/contracts';
|
||||||
|
import { edgeStartRequestSchema } from '@hexahost/contracts';
|
||||||
|
|
||||||
|
import { EdgeInternalGuard } from './edge-internal.guard';
|
||||||
|
import { EdgeService } from './edge.service';
|
||||||
|
|
||||||
|
@Controller('internal/edge/v1')
|
||||||
|
@UseGuards(EdgeInternalGuard)
|
||||||
|
@SkipThrottle()
|
||||||
|
export class EdgeController {
|
||||||
|
constructor(private readonly edgeService: EdgeService) {}
|
||||||
|
|
||||||
|
@Get('resolve/:slug')
|
||||||
|
resolve(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Query('clientIp') clientIp?: string,
|
||||||
|
): Promise<EdgeResolveResponse> {
|
||||||
|
return this.edgeService.resolveSlug(slug, clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('start/:slug')
|
||||||
|
start(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
): Promise<EdgeStartResponse> {
|
||||||
|
const parsed = edgeStartRequestSchema.parse(body);
|
||||||
|
return this.edgeService.requestJoinStart(slug, parsed.clientIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/api/src/edge/edge.module.ts
Normal file
16
apps/api/src/edge/edge.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ServersModule } from '../servers/servers.module';
|
||||||
|
|
||||||
|
import { EdgeController } from './edge.controller';
|
||||||
|
import { EdgeInternalGuard } from './edge-internal.guard';
|
||||||
|
import { EdgeService } from './edge.service';
|
||||||
|
import { JoinService } from './join.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ServersModule],
|
||||||
|
controllers: [EdgeController],
|
||||||
|
providers: [EdgeService, JoinService, EdgeInternalGuard],
|
||||||
|
exports: [JoinService, EdgeService],
|
||||||
|
})
|
||||||
|
export class EdgeModule {}
|
||||||
199
apps/api/src/edge/edge.service.ts
Normal file
199
apps/api/src/edge/edge.service.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
import type { EdgeResolveResponse, EdgeStartResponse } from '@hexahost/contracts';
|
||||||
|
import type { GameServer } from '@hexahost/database';
|
||||||
|
import { getPlayDomain } from '@hexahost/dns';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ServersService, REDIS_CLIENT } from '../servers/servers.service';
|
||||||
|
|
||||||
|
import { JoinService } from './join.service';
|
||||||
|
|
||||||
|
const STARTABLE_STATUSES = new Set<GameServer['status']>(['STOPPED', 'UNKNOWN']);
|
||||||
|
const WAIT_STATUSES = new Set<GameServer['status']>([
|
||||||
|
'STARTING',
|
||||||
|
'QUEUED',
|
||||||
|
'PROVISIONING',
|
||||||
|
'INSTALLING',
|
||||||
|
'STOPPING',
|
||||||
|
]);
|
||||||
|
const REJECT_STATUSES = new Set<GameServer['status']>([
|
||||||
|
'DRAFT',
|
||||||
|
'ERROR',
|
||||||
|
'DELETING',
|
||||||
|
'DELETED',
|
||||||
|
'BACKING_UP',
|
||||||
|
'RESTORING',
|
||||||
|
]);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EdgeService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly joinService: JoinService,
|
||||||
|
private readonly serversService: ServersService,
|
||||||
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveSlug(slug: string, clientIp?: string): Promise<EdgeResolveResponse> {
|
||||||
|
const server = await this.prisma.gameServer.findFirst({
|
||||||
|
where: { joinSlug: slug },
|
||||||
|
include: { node: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new NotFoundException(`Unknown join slug: ${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildResolveResponse(server, clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestJoinStart(
|
||||||
|
slug: string,
|
||||||
|
clientIp: string,
|
||||||
|
): Promise<EdgeStartResponse> {
|
||||||
|
if (!(await this.checkStartRateLimit(slug, clientIp))) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Join-to-start rate limit exceeded',
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await this.prisma.gameServer.findFirst({
|
||||||
|
where: { joinSlug: slug },
|
||||||
|
include: { node: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new NotFoundException(`Unknown join slug: ${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.joinToStartEnabled) {
|
||||||
|
throw new BadRequestException('Join-to-start is disabled for this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = await this.buildResolveResponse(server, clientIp);
|
||||||
|
|
||||||
|
if (initial.action === 'proxy') {
|
||||||
|
return { resolve: initial, started: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initial.action === 'reject') {
|
||||||
|
return { resolve: initial, started: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initial.action === 'wait') {
|
||||||
|
return { resolve: initial, started: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = await this.serversService.startServerInternal(
|
||||||
|
server.id,
|
||||||
|
`join-to-start:${clientIp}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshed = await this.prisma.gameServer.findUniqueOrThrow({
|
||||||
|
where: { id: server.id },
|
||||||
|
include: { node: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolve: await this.buildResolveResponse(refreshed, clientIp),
|
||||||
|
started: true,
|
||||||
|
jobId: started.jobId,
|
||||||
|
queuePosition: started.queuePosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildResolveResponse(
|
||||||
|
server: GameServer & { node: { hostname: string } | null },
|
||||||
|
clientIp?: string,
|
||||||
|
): Promise<EdgeResolveResponse> {
|
||||||
|
const backend =
|
||||||
|
server.node && server.hostPort !== null
|
||||||
|
? { host: server.node.hostname, port: server.hostPort }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
serverId: server.id,
|
||||||
|
slug: server.joinSlug ?? '',
|
||||||
|
status: server.status,
|
||||||
|
joinToStartEnabled: server.joinToStartEnabled,
|
||||||
|
edition: server.edition,
|
||||||
|
backend,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (REJECT_STATUSES.has(server.status)) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
action: 'reject',
|
||||||
|
message: `Server is ${server.status.toLowerCase()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status === 'RUNNING' && backend) {
|
||||||
|
return { ...base, action: 'proxy', backend };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.joinToStartEnabled) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
action: 'reject',
|
||||||
|
message: 'Join-to-start is disabled; start the server from the panel',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WAIT_STATUSES.has(server.status)) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
action: 'wait',
|
||||||
|
message: `Server is ${server.status.toLowerCase()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STARTABLE_STATUSES.has(server.status)) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
action: 'start',
|
||||||
|
message: clientIp ? `Start requested from ${clientIp}` : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
action: 'reject',
|
||||||
|
message: `Cannot join while server is ${server.status.toLowerCase()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkStartRateLimit(slug: string, clientIp: string): Promise<boolean> {
|
||||||
|
const slugKey = `edge:start:slug:${slug}`;
|
||||||
|
const ipKey = `edge:start:ip:${clientIp}`;
|
||||||
|
|
||||||
|
const slugCount = await this.redis.incr(slugKey);
|
||||||
|
if (slugCount === 1) {
|
||||||
|
await this.redis.expire(slugKey, 60);
|
||||||
|
}
|
||||||
|
if (slugCount > 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipCount = await this.redis.incr(ipKey);
|
||||||
|
if (ipCount === 1) {
|
||||||
|
await this.redis.expire(ipKey, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipCount <= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayDomain(): string {
|
||||||
|
return getPlayDomain();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/edge/join.service.ts
Normal file
31
apps/api/src/edge/join.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { assignJoinAddress, formatJoinAddress, getPlayDomain } from '@hexahost/dns';
|
||||||
|
import type { GameServer, ServerDnsRecord } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JoinService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
getPlayDomain(): string {
|
||||||
|
return getPlayDomain();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatJoinHostname(server: Pick<GameServer, 'joinSlug' | 'edition'>): string | null {
|
||||||
|
return formatJoinAddress(server.joinSlug, server.edition);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureJoinAddress(serverId: string): Promise<string | null> {
|
||||||
|
const result = await assignJoinAddress(serverId);
|
||||||
|
return result?.joinHostname ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDnsRecords(serverId: string): Promise<ServerDnsRecord[]> {
|
||||||
|
return this.prisma.serverDnsRecord.findMany({
|
||||||
|
where: { serverId },
|
||||||
|
orderBy: { recordType: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
ServerResponse,
|
ServerResponse,
|
||||||
} from '@hexahost/contracts';
|
} from '@hexahost/contracts';
|
||||||
import type { GameServer } from '@hexahost/database';
|
import type { GameServer } from '@hexahost/database';
|
||||||
|
import { formatJoinAddress } from '@hexahost/dns';
|
||||||
|
|
||||||
type GameServerStatus = GameServer['status'];
|
type GameServerStatus = GameServer['status'];
|
||||||
|
|
||||||
@@ -177,6 +178,89 @@ export class ServersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startServerInternal(
|
||||||
|
serverId: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<ServerActionResponse> {
|
||||||
|
const server = await this.prisma.gameServer.findUnique({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ConflictException('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
|
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||||
|
this.serverState.assertTransition(server.status, targetStatus);
|
||||||
|
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
|
||||||
|
if (targetStatus === 'STARTING') {
|
||||||
|
await this.billing.assertCanStartServer(server.userId);
|
||||||
|
|
||||||
|
const canStart = await this.scheduling.canStartServer(server);
|
||||||
|
if (!canStart) {
|
||||||
|
const queued = await this.scheduling.enqueueStart(
|
||||||
|
server,
|
||||||
|
server.userId,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
server: this.toResponse(queued.server),
|
||||||
|
queuePosition: queued.queuePosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.transitionServer(
|
||||||
|
server,
|
||||||
|
targetStatus,
|
||||||
|
reason,
|
||||||
|
null,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
let jobId: string | undefined;
|
||||||
|
|
||||||
|
if (targetStatus === 'PROVISIONING') {
|
||||||
|
const job = await this.provisioningQueue.add(
|
||||||
|
'provision-server',
|
||||||
|
{ serverId, correlationId },
|
||||||
|
{ jobId: correlationId },
|
||||||
|
);
|
||||||
|
jobId = job.id;
|
||||||
|
} else {
|
||||||
|
const job = await this.lifecycleQueue.add(
|
||||||
|
'start-server',
|
||||||
|
{ serverId, action: 'start', correlationId },
|
||||||
|
{ jobId: correlationId },
|
||||||
|
);
|
||||||
|
jobId = job.id;
|
||||||
|
|
||||||
|
if (updated.nodeId) {
|
||||||
|
await this.publishNodeCommand(updated.nodeId, {
|
||||||
|
protocolVersion: 1,
|
||||||
|
messageId: randomUUID(),
|
||||||
|
correlationId,
|
||||||
|
type: 'server.start',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
serverId,
|
||||||
|
generation: updated.version,
|
||||||
|
desiredGeneration: updated.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: this.toResponse(updated),
|
||||||
|
jobId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getStartQueue(userId: string, serverId: string) {
|
async getStartQueue(userId: string, serverId: string) {
|
||||||
return this.scheduling.getStartQueue(userId, serverId);
|
return this.scheduling.getStartQueue(userId, serverId);
|
||||||
}
|
}
|
||||||
@@ -370,6 +454,9 @@ export class ServersService {
|
|||||||
ramMb: server.ramMb,
|
ramMb: server.ramMb,
|
||||||
idleShutdownEnabled: server.idleShutdownEnabled,
|
idleShutdownEnabled: server.idleShutdownEnabled,
|
||||||
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||||
|
joinSlug: server.joinSlug,
|
||||||
|
joinHostname: formatJoinAddress(server.joinSlug, server.edition),
|
||||||
|
joinToStartEnabled: server.joinToStartEnabled,
|
||||||
createdAt: server.createdAt.toISOString(),
|
createdAt: server.createdAt.toISOString(),
|
||||||
updatedAt: server.updatedAt.toISOString(),
|
updatedAt: server.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
30
apps/api/test/phase8.test.js
Normal file
30
apps/api/test/phase8.test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
describe('phase 8 api contract', () => {
|
||||||
|
it('documents internal edge routes and join fields', () => {
|
||||||
|
const edgeSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'edge/edge.controller.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(edgeSource.includes("'resolve/:slug'"));
|
||||||
|
assert.ok(edgeSource.includes("'start/:slug'"));
|
||||||
|
assert.ok(edgeSource.includes('EdgeInternalGuard'));
|
||||||
|
|
||||||
|
const edgeServiceSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'edge/edge.service.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(edgeServiceSource.includes('requestJoinStart'));
|
||||||
|
assert.ok(edgeServiceSource.includes('checkStartRateLimit'));
|
||||||
|
|
||||||
|
const serversSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'servers/servers.service.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(serversSource.includes('startServerInternal'));
|
||||||
|
assert.ok(serversSource.includes('joinHostname'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hexahost/gamecloud/edge-gateway/internal/config"
|
||||||
|
"github.com/hexahost/gamecloud/edge-gateway/internal/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -12,18 +18,21 @@ func main() {
|
|||||||
|
|
||||||
enabled := os.Getenv("EDGE_GATEWAY_ENABLED")
|
enabled := os.Getenv("EDGE_GATEWAY_ENABLED")
|
||||||
if enabled != "true" && enabled != "1" {
|
if enabled != "true" && enabled != "1" {
|
||||||
log.Info("edge gateway disabled for MVP",
|
log.Info("edge gateway disabled",
|
||||||
"feature", "edge-gateway",
|
"feature", "edge-gateway",
|
||||||
"status", "not_implemented",
|
"hint", "set EDGE_GATEWAY_ENABLED=true to start the TCP join-to-start gateway",
|
||||||
"message", "Join-to-Start TCP/UDP gateway is planned for Phase 8; set EDGE_GATEWAY_ENABLED=true to acknowledge early enablement",
|
|
||||||
)
|
)
|
||||||
os.Exit(0)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("edge gateway not implemented in MVP",
|
cfg := config.Load()
|
||||||
"feature", "edge-gateway",
|
gateway := proxy.New(cfg, log)
|
||||||
"status", "not_implemented",
|
|
||||||
"roadmap", "Phase 8 – DNS and Join-to-Start",
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
)
|
defer stop()
|
||||||
os.Exit(0)
|
|
||||||
|
if err := gateway.ListenAndServe(ctx); err != nil {
|
||||||
|
log.Error("edge gateway stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
apps/edge-gateway/internal/config/config.go
Normal file
37
apps/edge-gateway/internal/config/config.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string
|
||||||
|
APIURL string
|
||||||
|
EdgeKey string
|
||||||
|
PlayDomain string
|
||||||
|
StartWait time.Duration
|
||||||
|
PollInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
startWaitSec, _ := strconv.Atoi(envOr("EDGE_START_WAIT_SECONDS", "180"))
|
||||||
|
pollMs, _ := strconv.Atoi(envOr("EDGE_POLL_INTERVAL_MS", "2000"))
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
ListenAddr: envOr("EDGE_LISTEN_ADDR", ":25565"),
|
||||||
|
APIURL: envOr("API_URL", "http://localhost:3001"),
|
||||||
|
EdgeKey: envOr("EDGE_INTERNAL_API_KEY", "local-dev-edge-key-change-me-32chars"),
|
||||||
|
PlayDomain: envOr("GAME_BASE_DOMAIN", "play.example.net"),
|
||||||
|
StartWait: time.Duration(startWaitSec) * time.Second,
|
||||||
|
PollInterval: time.Duration(pollMs) * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
119
apps/edge-gateway/internal/controlplane/client.go
Normal file
119
apps/edge-gateway/internal/controlplane/client.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package controlplane
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backend struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveResponse struct {
|
||||||
|
ServerID string `json:"serverId"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
JoinToStartEnabled bool `json:"joinToStartEnabled"`
|
||||||
|
Edition string `json:"edition"`
|
||||||
|
Backend *Backend `json:"backend"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartResponse struct {
|
||||||
|
Resolve ResolveResponse `json:"resolve"`
|
||||||
|
Started bool `json:"started"`
|
||||||
|
JobID string `json:"jobId,omitempty"`
|
||||||
|
QueuePosition int `json:"queuePosition,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
edgeKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, edgeKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
edgeKey: edgeKey,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Resolve(slug, clientIP string) (*ResolveResponse, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/internal/edge/v1/resolve/%s", c.baseURL, url.PathEscape(slug))
|
||||||
|
if clientIP != "" {
|
||||||
|
endpoint = endpoint + "?clientIp=" + url.QueryEscape(clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.applyHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, fmt.Errorf("unknown slug")
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("resolve failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ResolveResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start(slug, clientIP string) (*StartResponse, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/internal/edge/v1/start/%s", c.baseURL, url.PathEscape(slug))
|
||||||
|
payload, err := json.Marshal(map[string]string{"clientIp": clientIP})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.applyHeaders(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("start failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result StartResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applyHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("X-HexaHost-Edge-Key", c.edgeKey)
|
||||||
|
}
|
||||||
174
apps/edge-gateway/internal/mc/handshake.go
Normal file
174
apps/edge-gateway/internal/mc/handshake.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package mc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handshake struct {
|
||||||
|
ProtocolVersion int32
|
||||||
|
ServerAddress string
|
||||||
|
ServerPort uint16
|
||||||
|
NextState int32
|
||||||
|
Raw []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadHandshake(r io.Reader) (*Handshake, error) {
|
||||||
|
raw, err := readPacket(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := newReader(raw)
|
||||||
|
packetID, err := reader.readVarInt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if packetID != 0 {
|
||||||
|
return nil, fmt.Errorf("unexpected packet id %d", packetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocolVersion, err := reader.readVarInt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
serverAddress, err := reader.readString()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
portBytes, err := reader.readBytes(2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
serverPort := binary.BigEndian.Uint16(portBytes)
|
||||||
|
|
||||||
|
nextState, err := reader.readVarInt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handshake{
|
||||||
|
ProtocolVersion: protocolVersion,
|
||||||
|
ServerAddress: serverAddress,
|
||||||
|
ServerPort: serverPort,
|
||||||
|
NextState: nextState,
|
||||||
|
Raw: raw,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractSlug(hostname, playDomain string) string {
|
||||||
|
host := strings.ToLower(strings.Split(hostname, "\x00")[0])
|
||||||
|
domain := strings.ToLower(playDomain)
|
||||||
|
suffix := "." + domain
|
||||||
|
if !strings.HasSuffix(host, suffix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slug := strings.TrimSuffix(host, suffix)
|
||||||
|
if slug == "" || strings.Contains(slug, ".") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPacket(r io.Reader) ([]byte, error) {
|
||||||
|
length, err := readVarIntFrom(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if length <= 0 || length > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("invalid packet length %d", length)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type packetReader struct {
|
||||||
|
buf *bytes.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReader(data []byte) *packetReader {
|
||||||
|
return &packetReader{buf: bytes.NewReader(data)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *packetReader) readVarInt() (int32, error) {
|
||||||
|
return readVarIntFrom(p.buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *packetReader) readString() (string, error) {
|
||||||
|
length, err := p.readVarInt()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if length < 0 || length > 255 {
|
||||||
|
return "", fmt.Errorf("invalid string length %d", length)
|
||||||
|
}
|
||||||
|
data, err := p.readBytes(int(length))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *packetReader) readBytes(n int) ([]byte, error) {
|
||||||
|
out := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(p.buf, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readVarIntFrom(r io.Reader) (int32, error) {
|
||||||
|
var numRead int
|
||||||
|
var result int32
|
||||||
|
|
||||||
|
for {
|
||||||
|
var value [1]byte
|
||||||
|
if _, err := io.ReadFull(r, value[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
result |= int32(value[0]&0x7F) << (7 * numRead)
|
||||||
|
numRead++
|
||||||
|
if numRead > 5 {
|
||||||
|
return 0, fmt.Errorf("varint too big")
|
||||||
|
}
|
||||||
|
if value[0]&0x80 == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WritePacket(w io.Writer, payload []byte) error {
|
||||||
|
if err := writeVarIntTo(w, int32(len(payload))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := w.Write(payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVarIntTo(w io.Writer, value int32) error {
|
||||||
|
for {
|
||||||
|
temp := byte(value & 0x7F)
|
||||||
|
value >>= 7
|
||||||
|
if value != 0 {
|
||||||
|
temp |= 0x80
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte{temp}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
59
apps/edge-gateway/internal/mc/handshake_test.go
Normal file
59
apps/edge-gateway/internal/mc/handshake_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package mc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractSlug(t *testing.T) {
|
||||||
|
if got := ExtractSlug("abc.play.example.net", "play.example.net"); got != "abc" {
|
||||||
|
t.Fatalf("got %q want abc", got)
|
||||||
|
}
|
||||||
|
if got := ExtractSlug("other.net", "play.example.net"); got != "" {
|
||||||
|
t.Fatalf("expected empty slug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadHandshake(t *testing.T) {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
writeVarInt(&payload, 0)
|
||||||
|
writeVarInt(&payload, 763)
|
||||||
|
writeString(&payload, "demo.play.example.net")
|
||||||
|
payload.WriteByte(0x63)
|
||||||
|
payload.WriteByte(0xdd)
|
||||||
|
writeVarInt(&payload, 2)
|
||||||
|
|
||||||
|
var packet bytes.Buffer
|
||||||
|
writeVarInt(&packet, int32(payload.Len()))
|
||||||
|
packet.Write(payload.Bytes())
|
||||||
|
|
||||||
|
hs, err := ReadHandshake(&packet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read handshake: %v", err)
|
||||||
|
}
|
||||||
|
if hs.ServerAddress != "demo.play.example.net" {
|
||||||
|
t.Fatalf("address %q", hs.ServerAddress)
|
||||||
|
}
|
||||||
|
if hs.ServerPort != 25565 {
|
||||||
|
t.Fatalf("port %d", hs.ServerPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVarInt(buf *bytes.Buffer, value int32) {
|
||||||
|
for {
|
||||||
|
temp := byte(value & 0x7F)
|
||||||
|
value >>= 7
|
||||||
|
if value != 0 {
|
||||||
|
temp |= 0x80
|
||||||
|
}
|
||||||
|
buf.WriteByte(temp)
|
||||||
|
if value == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeString(buf *bytes.Buffer, value string) {
|
||||||
|
writeVarInt(buf, int32(len(value)))
|
||||||
|
buf.WriteString(value)
|
||||||
|
}
|
||||||
160
apps/edge-gateway/internal/proxy/gateway.go
Normal file
160
apps/edge-gateway/internal/proxy/gateway.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hexahost/gamecloud/edge-gateway/internal/config"
|
||||||
|
"github.com/hexahost/gamecloud/edge-gateway/internal/controlplane"
|
||||||
|
"github.com/hexahost/gamecloud/edge-gateway/internal/mc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Gateway struct {
|
||||||
|
cfg config.Config
|
||||||
|
client *controlplane.Client
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config, log *slog.Logger) *Gateway {
|
||||||
|
return &Gateway{
|
||||||
|
cfg: cfg,
|
||||||
|
client: controlplane.NewClient(cfg.APIURL, cfg.EdgeKey),
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) ListenAndServe(ctx context.Context) error {
|
||||||
|
listener, err := net.Listen("tcp", g.cfg.ListenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
g.log.Info("edge gateway listening",
|
||||||
|
"addr", g.cfg.ListenAddr,
|
||||||
|
"playDomain", g.cfg.PlayDomain,
|
||||||
|
"api", g.cfg.APIURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = listener.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
g.log.Error("accept failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go g.handleConnection(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) handleConnection(clientConn net.Conn) {
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
clientIP := clientConn.RemoteAddr().String()
|
||||||
|
if host, _, err := net.SplitHostPort(clientIP); err == nil {
|
||||||
|
clientIP = host
|
||||||
|
}
|
||||||
|
|
||||||
|
handshake, err := mc.ReadHandshake(clientConn)
|
||||||
|
if err != nil {
|
||||||
|
g.log.Warn("handshake failed", "error", err, "client", clientIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := mc.ExtractSlug(handshake.ServerAddress, g.cfg.PlayDomain)
|
||||||
|
if slug == "" {
|
||||||
|
g.log.Warn("unknown hostname", "address", handshake.ServerAddress, "client", clientIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve, err := g.client.Resolve(slug, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
g.log.Warn("resolve failed", "slug", slug, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch resolve.Action {
|
||||||
|
case "start":
|
||||||
|
startResp, err := g.client.Start(slug, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
g.log.Warn("join start failed", "slug", slug, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve = &startResp.Resolve
|
||||||
|
case "wait":
|
||||||
|
resolve, err = g.waitForProxy(slug, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
g.log.Warn("wait for server failed", "slug", slug, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "reject":
|
||||||
|
g.log.Info("connection rejected", "slug", slug, "message", resolve.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolve.Action != "proxy" || resolve.Backend == nil {
|
||||||
|
g.log.Warn("no backend available", "slug", slug, "action", resolve.Action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backendAddr := fmt.Sprintf("%s:%d", resolve.Backend.Host, resolve.Backend.Port)
|
||||||
|
backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
g.log.Error("backend dial failed", "slug", slug, "backend", backendAddr, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer backendConn.Close()
|
||||||
|
|
||||||
|
if err := mc.WritePacket(backendConn, handshake.Raw); err != nil {
|
||||||
|
g.log.Error("replay handshake failed", "slug", slug, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.log.Info("proxying connection",
|
||||||
|
"slug", slug,
|
||||||
|
"client", clientIP,
|
||||||
|
"backend", backendAddr,
|
||||||
|
"status", resolve.Status,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipe(clientConn, backendConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) waitForProxy(slug, clientIP string) (*controlplane.ResolveResponse, error) {
|
||||||
|
deadline := time.Now().Add(g.cfg.StartWait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
resolve, err := g.client.Resolve(slug, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resolve.Action == "proxy" {
|
||||||
|
return resolve, nil
|
||||||
|
}
|
||||||
|
if resolve.Action == "reject" {
|
||||||
|
return resolve, fmt.Errorf(resolve.Message)
|
||||||
|
}
|
||||||
|
time.Sleep(g.cfg.PollInterval)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("timed out waiting for server to start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipe(left, right net.Conn) {
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go func() { _, err := io.Copy(right, left); errCh <- err }()
|
||||||
|
go func() { _, err := io.Copy(left, right); errCh <- err }()
|
||||||
|
<-errCh
|
||||||
|
}
|
||||||
@@ -61,7 +61,8 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
|
|||||||
server.status,
|
server.status,
|
||||||
);
|
);
|
||||||
const address =
|
const address =
|
||||||
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
server.joinHostname ??
|
||||||
|
(server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export interface Server {
|
|||||||
ramMb: number;
|
ramMb: number;
|
||||||
idleShutdownEnabled: boolean;
|
idleShutdownEnabled: boolean;
|
||||||
idleStopAt: string | null;
|
idleStopAt: string | null;
|
||||||
|
joinSlug: string | null;
|
||||||
|
joinHostname: string | null;
|
||||||
|
joinToStartEnabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@hexahost/catalog": "workspace:*",
|
"@hexahost/catalog": "workspace:*",
|
||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
|
"@hexahost/dns": "workspace:*",
|
||||||
"@hexahost/metering": "workspace:*",
|
"@hexahost/metering": "workspace:*",
|
||||||
"@hexahost/scheduler": "workspace:*",
|
"@hexahost/scheduler": "workspace:*",
|
||||||
"@hexahost/storage": "workspace:*",
|
"@hexahost/storage": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { prisma } from '@hexahost/database';
|
import { prisma } from '@hexahost/database';
|
||||||
|
import { assignJoinAddress } from '@hexahost/dns';
|
||||||
import { selectNodeForAllocation } from '@hexahost/scheduler';
|
import { selectNodeForAllocation } from '@hexahost/scheduler';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -11,7 +12,6 @@ import { loadNodeSnapshots } from '../node-snapshots';
|
|||||||
import { publishNodeCommand, waitForNodeResponse } from '../redis';
|
import { publishNodeCommand, waitForNodeResponse } from '../redis';
|
||||||
import { transitionServerStatus } from '../server-state';
|
import { transitionServerStatus } from '../server-state';
|
||||||
|
|
||||||
const BASE_HOST_PORT = 25565;
|
|
||||||
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000;
|
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
export const provisionServerJobSchema = z.object({
|
export const provisionServerJobSchema = z.object({
|
||||||
@@ -23,6 +23,11 @@ function buildDataPath(serverId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findAvailableHostPort(nodeId: string): Promise<number> {
|
async function findAvailableHostPort(nodeId: string): Promise<number> {
|
||||||
|
const node = await prisma.gameNode.findUniqueOrThrow({
|
||||||
|
where: { id: nodeId },
|
||||||
|
select: { portRangeStart: true, portRangeEnd: true },
|
||||||
|
});
|
||||||
|
|
||||||
const [allocations, servers] = await Promise.all([
|
const [allocations, servers] = await Promise.all([
|
||||||
prisma.gameServerAllocation.findMany({
|
prisma.gameServerAllocation.findMany({
|
||||||
where: { nodeId, status: 'ACTIVE' },
|
where: { nodeId, status: 'ACTIVE' },
|
||||||
@@ -44,9 +49,12 @@ async function findAvailableHostPort(nodeId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = BASE_HOST_PORT;
|
let port = node.portRangeStart;
|
||||||
while (usedPorts.has(port)) {
|
while (usedPorts.has(port)) {
|
||||||
port += 1;
|
port += 1;
|
||||||
|
if (port > node.portRangeEnd) {
|
||||||
|
throw new Error(`No free host port in range ${node.portRangeStart}-${node.portRangeEnd}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return port;
|
return port;
|
||||||
@@ -171,6 +179,8 @@ export async function processProvisionServerJob(job: Job): Promise<{ status: str
|
|||||||
correlationId: messageId,
|
correlationId: messageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await assignJoinAddress(serverId);
|
||||||
|
|
||||||
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
|
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
|
||||||
return { status: 'stopped' };
|
return { status: 'stopped' };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,48 @@
|
|||||||
|
|
||||||
Last updated: 2026-06-26
|
Last updated: 2026-06-26
|
||||||
|
|
||||||
## Current phase: Phase 7 — Idle shutdown and free tier ✅
|
## Current phase: Phase 8 — DNS and join-to-start ✅
|
||||||
|
|
||||||
|
### Phase 8 completed
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Prisma join/DNS | Done | `joinSlug`, `joinToStartEnabled`, `ServerDnsRecord`, node port ranges |
|
||||||
|
| `@hexahost/dns` | Done | Slug builder, DNS record sync (DB metadata), join address assignment |
|
||||||
|
| API internal edge | Done | `GET/POST /internal/edge/v1/resolve|start/:slug` with API key + rate limits |
|
||||||
|
| Join-to-start start | Done | Edge triggers `startServerInternal` with billing/queue checks |
|
||||||
|
| Worker provision hook | Done | Assigns join slug + DNS after provision completes |
|
||||||
|
| Edge gateway (Go) | Done | TCP listener, Minecraft handshake, proxy + join-to-start |
|
||||||
|
| Web | Done | Join hostname on server card |
|
||||||
|
| Contracts | Done | `join.ts` edge resolve/start schemas, server join fields |
|
||||||
|
|
||||||
|
### Abnahmekriterium Phase 8
|
||||||
|
|
||||||
|
- [x] Stable join hostname per server (`slug.play.example.net`)
|
||||||
|
- [x] DNS metadata stored (A + SRV for Java)
|
||||||
|
- [x] Edge gateway resolves slug and can start stopped servers
|
||||||
|
- [x] Rate limiting on join-to-start (3/min per slug, 10/min per IP)
|
||||||
|
- [x] Credits/tariff checks on edge-triggered start
|
||||||
|
- [ ] Manual E2E: connect via fixed hostname while server stopped
|
||||||
|
|
||||||
|
### Local test
|
||||||
|
|
||||||
|
1. `pnpm db:migrate && pnpm db:seed`
|
||||||
|
2. Set in `.env`: `EDGE_GATEWAY_ENABLED=true`, matching `EDGE_INTERNAL_API_KEY`
|
||||||
|
3. `pnpm dev` (API + worker) and `cd apps/edge-gateway && go run ./cmd/gateway`
|
||||||
|
4. Provision server → note `joinHostname` on dashboard
|
||||||
|
5. Stop server → connect with Minecraft client to join hostname (or `slug.play.localhost` with hosts file)
|
||||||
|
6. Edge starts server and proxies connection when `RUNNING`
|
||||||
|
|
||||||
|
### Edge env defaults (`.env.example`)
|
||||||
|
|
||||||
|
- `GAME_BASE_DOMAIN=play.example.net`
|
||||||
|
- `EDGE_LISTEN_ADDR=:25565`
|
||||||
|
- `EDGE_INTERNAL_API_KEY=local-dev-edge-key-change-me-32chars`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Idle shutdown and free tier ✅
|
||||||
|
|
||||||
### Phase 7 completed
|
### Phase 7 completed
|
||||||
|
|
||||||
@@ -70,6 +111,6 @@ Last updated: 2026-06-26
|
|||||||
|
|
||||||
## Phase 0 — Monorepo foundation ✅
|
## Phase 0 — Monorepo foundation ✅
|
||||||
|
|
||||||
### Next phase: Phase 8 — DNS and join-to-start
|
### Next phase: Phase 9 — WHMCS integration and production hardening
|
||||||
|
|
||||||
- Port pools, DNS-SRV, edge gateway, start on connect
|
- Billing provider hooks, WHMCS module, production DNS (RFC2136)
|
||||||
|
|||||||
@@ -205,3 +205,16 @@ export {
|
|||||||
type WalletResponse,
|
type WalletResponse,
|
||||||
type UsageListResponse,
|
type UsageListResponse,
|
||||||
} from './billing';
|
} 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(),
|
ramMb: z.number().int(),
|
||||||
idleShutdownEnabled: z.boolean(),
|
idleShutdownEnabled: z.boolean(),
|
||||||
idleStopAt: z.string().datetime().nullable(),
|
idleStopAt: z.string().datetime().nullable(),
|
||||||
|
joinSlug: z.string().nullable(),
|
||||||
|
joinHostname: z.string().nullable(),
|
||||||
|
joinToStartEnabled: z.boolean(),
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
updatedAt: 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
|
EXPIRE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DnsRecordStatus {
|
||||||
|
PENDING
|
||||||
|
ACTIVE
|
||||||
|
FAILED
|
||||||
|
REMOVED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -291,6 +298,8 @@ model GameServer {
|
|||||||
idleStopAt DateTime? @db.Timestamptz(3)
|
idleStopAt DateTime? @db.Timestamptz(3)
|
||||||
runningSince DateTime? @db.Timestamptz(3)
|
runningSince DateTime? @db.Timestamptz(3)
|
||||||
lastMeteredAt DateTime? @db.Timestamptz(3)
|
lastMeteredAt DateTime? @db.Timestamptz(3)
|
||||||
|
joinSlug String? @unique
|
||||||
|
joinToStartEnabled Boolean @default(true)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@ -306,6 +315,7 @@ model GameServer {
|
|||||||
installedAddons InstalledAddon[]
|
installedAddons InstalledAddon[]
|
||||||
startQueueEntry ServerStartQueueEntry?
|
startQueueEntry ServerStartQueueEntry?
|
||||||
usageRecords UsageRecord[]
|
usageRecords UsageRecord[]
|
||||||
|
dnsRecords ServerDnsRecord[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([nodeId])
|
@@index([nodeId])
|
||||||
@@ -359,6 +369,8 @@ model GameNode {
|
|||||||
maxServers Int @default(10)
|
maxServers Int @default(10)
|
||||||
maxRamMb Int @default(16384)
|
maxRamMb Int @default(16384)
|
||||||
platformRamMb Int @default(2048)
|
platformRamMb Int @default(2048)
|
||||||
|
portRangeStart Int @default(25565)
|
||||||
|
portRangeEnd Int @default(25664)
|
||||||
activeServers Int @default(0)
|
activeServers Int @default(0)
|
||||||
lastHeartbeatAt DateTime? @db.Timestamptz(3)
|
lastHeartbeatAt DateTime? @db.Timestamptz(3)
|
||||||
agentVersion String?
|
agentVersion String?
|
||||||
@@ -650,3 +662,25 @@ model InstalledAddon {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("installed_addons")
|
@@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,
|
maxServers: 10,
|
||||||
maxRamMb: 16384,
|
maxRamMb: 16384,
|
||||||
platformRamMb: 2048,
|
platformRamMb: 2048,
|
||||||
|
portRangeStart: 25565,
|
||||||
|
portRangeEnd: 25664,
|
||||||
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
|
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -64,6 +66,8 @@ async function main(): Promise<void> {
|
|||||||
hostname: 'localhost',
|
hostname: 'localhost',
|
||||||
maxRamMb: 16384,
|
maxRamMb: 16384,
|
||||||
platformRamMb: 2048,
|
platformRamMb: 2048,
|
||||||
|
portRangeStart: 25565,
|
||||||
|
portRangeEnd: 25664,
|
||||||
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
|
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type {
|
|||||||
BackupSchedule,
|
BackupSchedule,
|
||||||
WorldUpload,
|
WorldUpload,
|
||||||
InstalledAddon,
|
InstalledAddon,
|
||||||
|
ServerDnsRecord,
|
||||||
GameNode,
|
GameNode,
|
||||||
GameNodeHeartbeat,
|
GameNodeHeartbeat,
|
||||||
ServerStartQueueEntry,
|
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"]
|
||||||
|
}
|
||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
|||||||
'@hexahost/database':
|
'@hexahost/database':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/database
|
version: link:../../packages/database
|
||||||
|
'@hexahost/dns':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/dns
|
||||||
'@hexahost/metering':
|
'@hexahost/metering':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/metering
|
version: link:../../packages/metering
|
||||||
@@ -190,6 +193,9 @@ importers:
|
|||||||
'@hexahost/database':
|
'@hexahost/database':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/database
|
version: link:../../packages/database
|
||||||
|
'@hexahost/dns':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/dns
|
||||||
'@hexahost/metering':
|
'@hexahost/metering':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/metering
|
version: link:../../packages/metering
|
||||||
@@ -325,6 +331,25 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/dns:
|
||||||
|
dependencies:
|
||||||
|
'@hexahost/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../database
|
||||||
|
devDependencies:
|
||||||
|
'@hexahost/typescript-config':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../typescript-config
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.10.2
|
||||||
|
version: 22.20.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.0.8
|
||||||
|
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
|
||||||
|
|
||||||
packages/eslint-config: {}
|
packages/eslint-config: {}
|
||||||
|
|
||||||
packages/metering:
|
packages/metering:
|
||||||
|
|||||||
Reference in New Issue
Block a user