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

@@ -8,6 +8,7 @@ import { BillingModule } from './billing/billing.module';
import { CatalogModule } from './catalog/catalog.module';
import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module';
import { EdgeModule } from './edge/edge.module';
import { ServersModule } from './servers/servers.module';
import { AppConfigModule } from './config/app-config.module';
@@ -44,6 +45,7 @@ import { LoggerModule } from 'nestjs-pino';
AuthModule,
CatalogModule,
BillingModule,
EdgeModule,
ServersModule,
NodesModule,
AdminModule,

View 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;
}
}

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

View 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 {}

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

View 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' },
});
}
}

View File

@@ -15,6 +15,7 @@ import type {
ServerResponse,
} from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
import { formatJoinAddress } from '@hexahost/dns';
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) {
return this.scheduling.getStartQueue(userId, serverId);
}
@@ -370,6 +454,9 @@ export class ServersService {
ramMb: server.ramMb,
idleShutdownEnabled: server.idleShutdownEnabled,
idleStopAt: server.idleStopAt?.toISOString() ?? null,
joinSlug: server.joinSlug,
joinHostname: formatJoinAddress(server.joinSlug, server.edition),
joinToStartEnabled: server.joinToStartEnabled,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
};