Phase8
This commit is contained in:
@@ -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,
|
||||
|
||||
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,
|
||||
} 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(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user