Phase2
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:01:25 +02:00
parent 58961000eb
commit c4077d4673
93 changed files with 4099 additions and 165 deletions

View File

@@ -52,13 +52,13 @@ RFC2136_SERVER=
RFC2136_KEY_NAME=
RFC2136_KEY_SECRET=
# --- Node Agent ---
# --- Node Agent (local dev) ---
NODE_CA_CERT=
NODE_CA_KEY=
NODE_AGENT_PUBLIC_URL=wss://api.example.net/api/v1/nodes/ws
NODE_ID=00000000-0000-4000-8000-000000000001
NODE_TOKEN=local-dev-enrollment-token-change-me-32chars
NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
NODE_HEARTBEAT_TIMEOUT=30s
NODE_ID=local-dev-node
NODE_TOKEN=
NODE_AGENT_HEALTH_ADDR=:9100
NODE_HEARTBEAT_INTERVAL=10s
NODE_DATA_DIR=./data/nodes

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"0382620cc4ac4da5","duration":37384,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}

BIN
.turbo/cache/0382620cc4ac4da5.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/.turbo/turbo-test.log":{"size":1535,"mtime_nanos":1782467249303891900,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"1b666f158bf3f029","duration":1590,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}

BIN
.turbo/cache/1b666f158bf3f029.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"31c16e772c63a2b4","duration":1866,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}

BIN
.turbo/cache/31c16e772c63a2b4.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":834,"mtime_nanos":1782467249057816600,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"3a0225fe82615136","duration":1346,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}

BIN
.turbo/cache/3a0225fe82615136.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"4e9bccc61dd3f9ca","duration":4166,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}

BIN
.turbo/cache/4e9bccc61dd3f9ca.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782467193493731600,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782467193376146900,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"6776418ac2a936fa","duration":7206,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}

BIN
.turbo/cache/6776418ac2a936fa.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/dist/app.module.d.ts":{"size":213,"mtime_nanos":1782467199599741300,"mode":420,"is_dir":false},"apps/api/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/nodes.service.js":{"size":9756,"mtime_nanos":1782467199573636800,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.d.ts":{"size":517,"mtime_nanos":1782467199260331600,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.d.ts":{"size":521,"mtime_nanos":1782467199228801200,"mode":420,"is_dir":false},"apps/api/dist/main.d.ts.map":{"size":102,"mtime_nanos":1782467200210723400,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.gateway.d.ts.map":{"size":484,"mtime_nanos":1782467200243986300,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.module.d.ts":{"size":79,"mtime_nanos":1782467200222903600,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.service.d.ts.map":{"size":1387,"mtime_nanos":1782467199581619800,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.d.ts.map":{"size":155,"mtime_nanos":1782467199369096700,"mode":420,"is_dir":false},"apps/api/dist/nodes/node-command.subscriber.d.ts.map":{"size":481,"mtime_nanos":1782467200258830600,"mode":420,"is_dir":false},"apps/api/dist/servers":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/nodes.module.d.ts.map":{"size":147,"mtime_nanos":1782467200220823700,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.gateway.d.ts":{"size":505,"mtime_nanos":1782467200244898700,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.d.ts.map":{"size":269,"mtime_nanos":1782467199228801200,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.js.map":{"size":1277,"mtime_nanos":1782467199365033400,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.js":{"size":1917,"mtime_nanos":1782467199221559800,"mode":420,"is_dir":false},"apps/api/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1782467201691248800,"mode":420,"is_dir":false},"apps/api/dist/nodes/nodes.service.d.ts":{"size":1432,"mtime_nanos":1782467199582327100,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.d.ts":{"size":83,"mtime_nanos":1782467199369542500,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.js.map":{"size":604,"mtime_nanos":1782467199254032400,"mode":420,"is_dir":false},"apps/api/dist/servers/servers.module.js":{"size":3398,"mtime_nanos":1782467199366161300,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.service.js.map":{"size":891,"mtime_nanos":1782467199218839100,"mode":420,"is_dir":false},"apps/api/dist/app.module.d.ts.map":{"size":244,"mtime_nanos":1782467199598237900,"mode":420,"is_dir":false},"apps/api/dist/nodes":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/nodes.service.js.map":{"size":7107,"mtime_nanos":1782467199572178000,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.js":{"size":1936,"mtime_nanos":1782467199255627900,"mode":420,"is_dir":false},"apps/api/dist/nodes/node-command.subscriber.d.ts":{"size":460,"mtime_nanos":1782467200260524300,"mode":420,"is_dir":false},"apps/api/dist/servers/plans.controller.d.ts.map":{"size":273,"mtime_nanos":1782467199259052700,"mode":420,"is_dir":false},"apps/api/dist/main.d.ts":{"size":45,"mtime_nanos":1782467200212827900,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-build.log","apps/api/dist","apps/api/dist/app.module.d.ts","apps/api/dist/app.module.d.ts.map","apps/api/dist/main.d.ts","apps/api/dist/main.d.ts.map","apps/api/dist/nodes","apps/api/dist/nodes/node-command.subscriber.d.ts","apps/api/dist/nodes/node-command.subscriber.d.ts.map","apps/api/dist/nodes/nodes.gateway.d.ts","apps/api/dist/nodes/nodes.gateway.d.ts.map","apps/api/dist/nodes/nodes.module.d.ts","apps/api/dist/nodes/nodes.module.d.ts.map","apps/api/dist/nodes/nodes.service.d.ts","apps/api/dist/nodes/nodes.service.d.ts.map","apps/api/dist/nodes/nodes.service.js","apps/api/dist/nodes/nodes.service.js.map","apps/api/dist/servers","apps/api/dist/servers/plans.controller.d.ts","apps/api/dist/servers/plans.controller.d.ts.map","apps/api/dist/servers/plans.controller.js","apps/api/dist/servers/plans.controller.js.map","apps/api/dist/servers/plans.service.d.ts","apps/api/dist/servers/plans.service.d.ts.map","apps/api/dist/servers/plans.service.js","apps/api/dist/servers/plans.service.js.map","apps/api/dist/servers/servers.module.d.ts","apps/api/dist/servers/servers.module.d.ts.map","apps/api/dist/servers/servers.module.js","apps/api/dist/servers/servers.module.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"6c069006830d24bd","duration":8187,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"1dc69509c46e02b077522f0df13aacf39c3394566efa48caabe96f6f9ee4ff66"}

BIN
.turbo/cache/6c069006830d24bd.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782467242691749600,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"7dadc3899adb4c2b","duration":1230,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}

BIN
.turbo/cache/7dadc3899adb4c2b.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":460,"mtime_nanos":1782467245029657300,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"8df65e7e54d2443e","duration":3566,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}

BIN
.turbo/cache/8df65e7e54d2443e.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782467247694523100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782467193376146900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"f882e318a8d243e9","duration":6219,"sha":"58961000eb4965a4384fc5875dad728adea33dbd","dirty_hash":"8f0a815f2ebb951c698f0eb35f558a2a8d052664b24cdabb4519d48989674571"}

BIN
.turbo/cache/f882e318a8d243e9.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -92,9 +92,28 @@ make build
## Aktueller Stand
**Phase 1** (Authentifizierung) — abgeschlossen.
**Phase 2** (Single-Node Vertical Slice) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details und nächste Schritte.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
### Minecraft-Server lokal testen
```bash
pnpm dev:infra
pnpm db:migrate # oder db:push bei frischer DB
pnpm db:seed # Free-Plan + Dev-Node
pnpm dev
# Separates Terminal — Node Agent (Go 1.23 + Docker Desktop)
cd apps/node-agent
# NODE_ID, NODE_TOKEN, NODE_AGENT_PUBLIC_URL aus .env.example setzen
make run
```
1. Registrieren / einloggen unter http://localhost:3000
2. Server erstellen unter `/servers/new`
3. Starten — Adresse `localhost:<port>` im Panel
4. Minecraft: Multiplayer → `localhost:<port>`
### Auth lokal testen

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4",
"@fastify/websocket": "^11.0.2",
"@hexahost/auth": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
@@ -31,6 +32,7 @@
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ws": "^8.18.0",
"zod": "^3.24.1"
},
"devDependencies": {
@@ -38,6 +40,7 @@
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@types/node": "^22.10.7",
"@types/ws": "^8.5.14",
"pino-pretty": "^13.0.0",
"typescript": "^5.7.3"
}

View File

@@ -0,0 +1,45 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AdminNodesService } from './admin-nodes.service';
import type { CreateNodeResponse } from './admin-nodes.service';
const createNodeRequestSchema = z.object({
name: z.string().min(1).max(64),
hostname: z.string().min(1).max(255),
maxServers: z.number().int().min(1).max(1000).optional(),
});
@ApiTags('admin')
@Controller('admin/nodes')
@UseGuards(SessionGuard)
export class AdminNodesController {
constructor(private readonly adminNodesService: AdminNodesService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new game node (super admin only)' })
@ApiResponse({ status: 201, description: 'Node created with enrollment token' })
create(
@CurrentUser() user: { roles: string[] },
@Body(new ZodValidationPipe(createNodeRequestSchema)) body: unknown,
): Promise<CreateNodeResponse> {
this.adminNodesService.assertSuperAdmin(user.roles);
return this.adminNodesService.createNode(
body as Parameters<AdminNodesService['createNode']>[0],
);
}
}

View File

@@ -0,0 +1,63 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
generateSecureToken,
hashToken,
UserRole,
} from '@hexahost/auth';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateNodeInput {
name: string;
hostname: string;
maxServers?: number;
}
export interface CreateNodeResponse {
node: {
id: string;
name: string;
hostname: string;
status: string;
maxServers: number;
createdAt: string;
};
enrollmentToken: string;
}
@Injectable()
export class AdminNodesService {
constructor(private readonly prisma: PrismaService) {}
assertSuperAdmin(roles: string[]): void {
if (!roles.includes(UserRole.SUPER_ADMIN)) {
throw new ForbiddenException('Super administrator access required');
}
}
async createNode(input: CreateNodeInput): Promise<CreateNodeResponse> {
const enrollmentToken = generateSecureToken();
const node = await this.prisma.gameNode.create({
data: {
name: input.name,
hostname: input.hostname,
maxServers: input.maxServers ?? 10,
enrollmentTokenHash: hashToken(enrollmentToken),
},
});
return {
node: {
id: node.id,
name: node.name,
hostname: node.hostname,
status: node.status,
maxServers: node.maxServers,
createdAt: node.createdAt.toISOString(),
},
enrollmentToken,
};
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AdminNodesController } from './admin-nodes.controller';
import { AdminNodesService } from './admin-nodes.service';
@Module({
imports: [AuthModule],
controllers: [AdminNodesController],
providers: [AdminNodesService],
})
export class AdminModule {}

View File

@@ -4,6 +4,9 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module';
import { ServersModule } from './servers/servers.module';
import { AppConfigModule } from './config/app-config.module';
import { APP_GUARD } from '@nestjs/core';
@@ -37,6 +40,9 @@ import { LoggerModule } from 'nestjs-pino';
AppConfigModule,
HealthModule,
AuthModule,
ServersModule,
NodesModule,
AdminModule,
],
providers: [
{

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import cookie from '@fastify/cookie';
import websocket from '@fastify/websocket';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
@@ -41,6 +42,8 @@ async function bootstrap(): Promise<void> {
secret: config.SESSION_SECRET,
});
await app.register(websocket);
const swaggerConfig = new DocumentBuilder()
.setTitle(config.APP_NAME)
.setDescription('HexaHost GameCloud REST API')

View File

@@ -0,0 +1,64 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { NODE_COMMANDS_CHANNEL } from '../servers/servers.service';
import { NodesService } from './nodes.service';
interface NodeCommandMessage {
nodeId: string;
envelope: unknown;
}
@Injectable()
export class NodeCommandSubscriber implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(NodeCommandSubscriber.name);
private subscriber?: Redis;
constructor(private readonly nodesService: NodesService) {}
async onModuleInit(): Promise<void> {
const config = getConfig();
this.subscriber = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
await this.subscriber.subscribe(NODE_COMMANDS_CHANNEL);
this.subscriber.on('message', (channel, message) => {
if (channel !== NODE_COMMANDS_CHANNEL) {
return;
}
try {
const parsed = JSON.parse(message) as NodeCommandMessage;
if (!parsed.nodeId || parsed.envelope === undefined) {
this.logger.warn('Ignoring malformed node command message');
return;
}
this.nodesService.forwardCommand(parsed.nodeId, parsed.envelope);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to process node command: ${detail}`);
}
});
}
async onModuleDestroy(): Promise<void> {
if (!this.subscriber) {
return;
}
await this.subscriber.unsubscribe(NODE_COMMANDS_CHANNEL);
await this.subscriber.quit();
}
}

View File

@@ -0,0 +1,84 @@
import {
Injectable,
Logger,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import type { FastifyRequest } from 'fastify';
import type { WebSocket } from 'ws';
import { NodesService } from './nodes.service';
@Injectable()
export class NodesGateway implements OnModuleInit {
private readonly logger = new Logger(NodesGateway.name);
constructor(
private readonly adapterHost: HttpAdapterHost,
private readonly nodesService: NodesService,
) {}
onModuleInit(): void {
const fastify = this.adapterHost.httpAdapter.getInstance();
fastify.get(
'/api/v1/nodes/ws',
{ websocket: true },
(socket: WebSocket, request: FastifyRequest) => {
void this.handleConnection(socket, request);
},
);
}
private async handleConnection(
socket: WebSocket,
request: FastifyRequest,
): Promise<void> {
const query = request.query as Record<string, string | string[] | undefined>;
const nodeId = this.readQueryParam(query['nodeId']);
const token = this.readQueryParam(query['token']);
if (!nodeId || !token) {
socket.close(4400, 'nodeId and token are required');
return;
}
try {
await this.nodesService.validateEnrollment(nodeId, token);
} catch (error) {
const reason =
error instanceof UnauthorizedException
? 'Invalid enrollment credentials'
: 'Enrollment validation failed';
socket.close(4401, reason);
return;
}
this.nodesService.registerConnection(nodeId, socket);
socket.on('close', () => {
this.nodesService.unregisterConnection(nodeId, socket);
});
socket.on('message', (data) => {
void this.nodesService.handleAgentMessage(nodeId, data.toString());
});
socket.on('error', (error) => {
this.logger.error(`WebSocket error for node ${nodeId}: ${error.message}`);
});
socket.send(JSON.stringify(this.nodesService.createHelloAck(nodeId)));
}
private readQueryParam(
value: string | string[] | undefined,
): string | undefined {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
import { NodeCommandSubscriber } from './node-command.subscriber';
import { NodesGateway } from './nodes.gateway';
import { NodesService } from './nodes.service';
@Module({
imports: [ServersModule],
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
exports: [NodesService],
})
export class NodesModule {}

View File

@@ -0,0 +1,305 @@
import { randomUUID } from 'node:crypto';
import {
Injectable,
Logger,
UnauthorizedException,
Inject,
} from '@nestjs/common';
import type { WebSocket } from 'ws';
import type Redis from 'ioredis';
import { verifyTokenHash } from '@hexahost/auth';
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
type GameServerStatus = GameServer['status'];
interface AgentEnvelope {
protocolVersion?: number;
messageId?: string;
correlationId?: string;
type?: string;
timestamp?: string;
payload?: Record<string, unknown>;
}
const AGENT_STATE_MAP: Record<string, GameServerStatus> = {
draft: 'DRAFT',
provisioning: 'PROVISIONING',
installing: 'INSTALLING',
stopped: 'STOPPED',
starting: 'STARTING',
running: 'RUNNING',
stopping: 'STOPPING',
error: 'ERROR',
deleting: 'DELETING',
deleted: 'DELETED',
};
@Injectable()
export class NodesService {
private readonly logger = new Logger(NodesService.name);
private readonly connections = new Map<string, WebSocket>();
constructor(
private readonly prisma: PrismaService,
private readonly serversService: ServersService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
registerConnection(nodeId: string, socket: WebSocket): void {
const existing = this.connections.get(nodeId);
if (existing && existing !== socket) {
existing.close(4000, 'replaced by new connection');
}
this.connections.set(nodeId, socket);
}
unregisterConnection(nodeId: string, socket: WebSocket): void {
const current = this.connections.get(nodeId);
if (current === socket) {
this.connections.delete(nodeId);
}
}
forwardCommand(nodeId: string, envelope: unknown): void {
const socket = this.connections.get(nodeId);
if (!socket || socket.readyState !== socket.OPEN) {
this.logger.warn(`No active WebSocket for node ${nodeId}`);
return;
}
socket.send(JSON.stringify(envelope));
}
createHelloAck(nodeId: string): AgentEnvelope {
return {
protocolVersion: 1,
messageId: randomUUID(),
type: 'agent.hello',
timestamp: new Date().toISOString(),
payload: {
accepted: true,
nodeId,
},
};
}
async validateEnrollment(nodeId: string, token: string): Promise<GameNode> {
const node = await this.prisma.gameNode.findFirst({
where: {
OR: [{ id: nodeId }, { name: nodeId }],
},
});
if (!node?.enrollmentTokenHash) {
throw new UnauthorizedException('Node not enrolled');
}
if (!verifyTokenHash(token, node.enrollmentTokenHash)) {
throw new UnauthorizedException('Invalid enrollment token');
}
return node;
}
async markNodeOnline(nodeId: string, agentVersion?: string): Promise<void> {
await this.prisma.gameNode.update({
where: { id: nodeId },
data: {
status: 'ONLINE',
lastHeartbeatAt: new Date(),
enrolledAt: new Date(),
agentVersion,
enrollmentTokenHash: null,
},
});
}
async handleAgentMessage(nodeId: string, raw: string): Promise<void> {
let envelope: AgentEnvelope;
try {
envelope = JSON.parse(raw) as AgentEnvelope;
} catch {
this.logger.warn(`Invalid JSON from node ${nodeId}`);
return;
}
switch (envelope.type) {
case 'agent.hello':
await this.markNodeOnline(
nodeId,
typeof envelope.payload?.['agentVersion'] === 'string'
? envelope.payload['agentVersion']
: undefined,
);
break;
case 'agent.heartbeat':
await this.persistHeartbeat(nodeId, envelope.payload);
break;
case 'server.state':
await this.handleServerState(envelope);
break;
case 'server.operation.completed':
await this.handleOperationCompleted(envelope);
break;
case 'server.operation.failed':
await this.handleOperationFailed(envelope);
break;
default:
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
}
}
private async persistHeartbeat(
nodeId: string,
payload?: Record<string, unknown>,
): Promise<void> {
const now = new Date();
await this.prisma.$transaction([
this.prisma.gameNode.update({
where: { id: nodeId },
data: {
lastHeartbeatAt: now,
status: 'ONLINE',
agentVersion:
typeof payload?.['agentVersion'] === 'string'
? payload['agentVersion']
: undefined,
},
}),
this.prisma.gameNodeHeartbeat.create({
data: {
nodeId,
payload: (payload ?? undefined) as Prisma.InputJsonValue | undefined,
},
}),
]);
}
private async handleServerState(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const state = envelope.payload?.['state'];
if (typeof serverId !== 'string' || typeof state !== 'string') {
return;
}
const mapped = AGENT_STATE_MAP[state.toLowerCase()];
if (!mapped) {
return;
}
await this.serversService.applyAgentStatus(serverId, mapped, {
correlationId: envelope.correlationId,
reason: 'agent server.state',
errorCode:
typeof envelope.payload?.['errorCode'] === 'string'
? envelope.payload['errorCode']
: undefined,
errorMessage:
typeof envelope.payload?.['errorMessage'] === 'string'
? envelope.payload['errorMessage']
: undefined,
});
}
private async handleOperationCompleted(
envelope: AgentEnvelope,
): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const operation = envelope.payload?.['operation'];
const messageId = envelope.messageId;
if (typeof serverId !== 'string') {
return;
}
const target = this.resolveCompletedStatus(operation);
if (target) {
await this.serversService.applyAgentStatus(serverId, target, {
correlationId: envelope.correlationId,
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
});
}
if (typeof messageId === 'string') {
await this.publishNodeResponse(messageId, {
success: true,
resultCode: typeof operation === 'string' ? operation : 'completed',
});
}
}
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const messageId = envelope.messageId;
if (typeof serverId === 'string') {
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
correlationId: envelope.correlationId,
reason: 'agent operation failed',
errorCode:
typeof envelope.payload?.['errorCode'] === 'string'
? envelope.payload['errorCode']
: undefined,
errorMessage:
typeof envelope.payload?.['errorMessage'] === 'string'
? envelope.payload['errorMessage']
: undefined,
});
}
if (typeof messageId === 'string') {
await this.publishNodeResponse(messageId, {
success: false,
errorCode:
typeof envelope.payload?.['errorCode'] === 'string'
? envelope.payload['errorCode']
: 'OPERATION_FAILED',
errorMessage:
typeof envelope.payload?.['errorMessage'] === 'string'
? envelope.payload['errorMessage']
: 'Agent operation failed',
});
}
}
private async publishNodeResponse(
messageId: string,
response: {
success: boolean;
resultCode?: string;
errorCode?: string;
errorMessage?: string;
},
): Promise<void> {
await this.redis.publish(
`hgc:node:responses:${messageId}`,
JSON.stringify(response),
);
}
private resolveCompletedStatus(operation: unknown): GameServerStatus | null {
switch (operation) {
case 'provision':
return 'STOPPED';
case 'install':
return 'STOPPED';
case 'start':
return 'RUNNING';
case 'stop':
return 'STOPPED';
case 'delete':
return 'DELETED';
default:
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { SessionGuard } from '../auth/guards/session.guard';
import { PlansService } from './plans.service';
@ApiTags('plans')
@Controller('plans')
@UseGuards(SessionGuard)
export class PlansController {
constructor(private readonly plansService: PlansService) {}
@Get()
@ApiOperation({ summary: 'List active server plans' })
list() {
return this.plansService.listActivePlans();
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PlansService {
constructor(private readonly prisma: PrismaService) {}
async listActivePlans() {
const plans = await this.prisma.plan.findMany({
where: { isActive: true },
orderBy: { maxRamMb: 'asc' },
});
return {
plans: plans.map((plan) => ({
id: plan.id,
name: plan.name,
slug: plan.slug,
description: plan.description,
isFree: plan.isFree,
maxRamMb: plan.maxRamMb,
maxCpuCores: plan.maxCpuCores,
maxStorageMb: plan.maxStorageMb,
})),
};
}
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
DRAFT: ['PROVISIONING', 'DELETING'],
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
STOPPED: ['STARTING', 'PROVISIONING', 'DELETING'],
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
RUNNING: ['STOPPING', 'ERROR', 'DELETING'],
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
DELETING: ['DELETED'],
DELETED: [],
};
const IN_PROGRESS_STATUSES: GameServerStatus[] = [
'PROVISIONING',
'INSTALLING',
'STARTING',
'STOPPING',
'DELETING',
];
@Injectable()
export class ServerStateService {
assertTransition(
fromStatus: GameServerStatus,
toStatus: GameServerStatus,
): void {
const allowed = ALLOWED_TRANSITIONS[fromStatus];
if (!allowed.includes(toStatus)) {
throw new BadRequestException(
`Invalid state transition from ${fromStatus} to ${toStatus}`,
);
}
}
assertNotInProgress(status: GameServerStatus): void {
if (IN_PROGRESS_STATUSES.includes(status)) {
throw new BadRequestException('Server operation already in progress');
}
}
resolveStartTarget(status: GameServerStatus): GameServerStatus {
switch (status) {
case 'DRAFT':
case 'ERROR':
return 'PROVISIONING';
case 'STOPPED':
return 'STARTING';
default:
throw new BadRequestException(
`Cannot start server while in status ${status}`,
);
}
}
resolveStopTarget(status: GameServerStatus): GameServerStatus {
if (status !== 'RUNNING') {
throw new BadRequestException(
`Cannot stop server while in status ${status}`,
);
}
return 'STOPPING';
}
}

View File

@@ -0,0 +1,76 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { createServerRequestSchema } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { ServersService } from './servers.service';
@ApiTags('servers')
@Controller('servers')
@UseGuards(SessionGuard)
export class ServersController {
constructor(private readonly serversService: ServersService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a game server' })
@ApiResponse({ status: 201, description: 'Server created' })
create(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(createServerRequestSchema)) body: unknown,
) {
return this.serversService.createServer(
user.id,
body as Parameters<ServersService['createServer']>[1],
);
}
@Get()
@ApiOperation({ summary: 'List owned game servers' })
list(@CurrentUser() user: { id: string }) {
return this.serversService.listServers(user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a game server by id' })
get(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.getServer(user.id, id);
}
@Post(':id/start')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Start a game server' })
start(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.startServer(user.id, id);
}
@Post(':id/stop')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Stop a game server' })
stop(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.stopServer(user.id, id);
}
}

View File

@@ -0,0 +1,64 @@
import { Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { AuthModule } from '../auth/auth.module';
import { AppConfigModule } from '../config/app-config.module';
import { ServerStateService } from './server-state.service';
import {
REDIS_CLIENT,
SERVER_LIFECYCLE_QUEUE,
SERVER_PROVISIONING_QUEUE,
ServersService,
} from './servers.service';
import { ServersController } from './servers.controller';
import { PlansController } from './plans.controller';
import { PlansService } from './plans.service';
@Module({
imports: [AppConfigModule, AuthModule],
controllers: [ServersController, PlansController],
providers: [
ServersService,
ServerStateService,
PlansService,
{
provide: REDIS_CLIENT,
useFactory: () => {
const config = getConfig();
return new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
},
},
{
provide: SERVER_LIFECYCLE_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-lifecycle', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
{
provide: SERVER_PROVISIONING_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-provisioning', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
],
exports: [ServersService, REDIS_CLIENT],
})
export class ServersModule {}

View File

@@ -0,0 +1,327 @@
import { randomUUID } from 'node:crypto';
import {
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type Redis from 'ioredis';
import type {
CreateServerRequest,
ServerActionResponse,
ServerListResponse,
ServerResponse,
} from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
import { PrismaService } from '../prisma/prisma.service';
import { ServerStateService } from './server-state.service';
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
export const SERVER_LIFECYCLE_QUEUE = Symbol('SERVER_LIFECYCLE_QUEUE');
export const SERVER_PROVISIONING_QUEUE = Symbol('SERVER_PROVISIONING_QUEUE');
export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands' as const;
@Injectable()
export class ServersService {
constructor(
private readonly prisma: PrismaService,
private readonly serverState: ServerStateService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(SERVER_LIFECYCLE_QUEUE)
private readonly lifecycleQueue: Queue,
@Inject(SERVER_PROVISIONING_QUEUE)
private readonly provisioningQueue: Queue,
) {}
async createServer(
userId: string,
input: CreateServerRequest,
): Promise<ServerResponse> {
const server = await this.prisma.gameServer.create({
data: {
userId,
name: input.name,
edition: input.edition,
softwareFamily: input.softwareFamily,
minecraftVersion: input.minecraftVersion,
eulaAccepted: input.eulaAccepted,
ramMb: input.ramMb ?? 1024,
planId: input.planId,
status: 'DRAFT',
},
});
await this.recordTransition(server.id, null, 'DRAFT', 'created', userId);
return this.toResponse(server);
}
async listServers(userId: string): Promise<ServerListResponse> {
const servers = await this.prisma.gameServer.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return {
servers: servers.map((server) => this.toResponse(server)),
};
}
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
const server = await this.findOwnedServer(userId, serverId);
return this.toResponse(server);
}
async startServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status);
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
const updated = await this.transitionServer(
server,
targetStatus,
'start requested',
userId,
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 stopServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStopTarget(server.status);
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
const updated = await this.transitionServer(
server,
targetStatus,
'stop requested',
userId,
correlationId,
);
const job = await this.lifecycleQueue.add(
'stop-server',
{ serverId, action: 'stop', correlationId },
{ jobId: correlationId },
);
if (updated.nodeId) {
await this.publishNodeCommand(updated.nodeId, {
protocolVersion: 1,
messageId: randomUUID(),
correlationId,
type: 'server.stop',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: updated.version,
},
});
}
return {
server: this.toResponse(updated),
jobId: job.id,
};
}
async publishNodeCommand(
nodeId: string,
envelope: Record<string, unknown>,
): Promise<void> {
await this.redis.publish(
NODE_COMMANDS_CHANNEL,
JSON.stringify({ nodeId, envelope }),
);
}
async applyAgentStatus(
serverId: string,
nextStatus: GameServerStatus,
metadata?: {
correlationId?: string;
reason?: string;
errorCode?: string;
errorMessage?: string;
},
): Promise<void> {
const server = await this.prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server || server.status === nextStatus) {
return;
}
this.serverState.assertTransition(server.status, nextStatus);
await this.transitionServer(
server,
nextStatus,
metadata?.reason ?? 'agent reported state',
null,
metadata?.correlationId,
metadata?.errorCode,
metadata?.errorMessage,
);
}
private async findOwnedServer(
userId: string,
serverId: string,
): Promise<GameServer> {
const server = await this.prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server) {
throw new NotFoundException('Server not found');
}
if (server.userId !== userId) {
throw new ForbiddenException('You do not own this server');
}
return server;
}
private async transitionServer(
server: GameServer,
toStatus: GameServerStatus,
reason: string,
actorId: string | null,
correlationId?: string,
errorCode?: string,
errorMessage?: string,
): Promise<GameServer> {
if (server.status === toStatus) {
throw new ConflictException('Server is already in the requested state');
}
const updated = await this.prisma.gameServer.update({
where: { id: server.id },
data: {
status: toStatus,
version: { increment: 1 },
},
});
await this.recordTransition(
server.id,
server.status,
toStatus,
reason,
actorId,
correlationId,
errorCode,
errorMessage,
);
return updated;
}
private async recordTransition(
gameServerId: string,
fromStatus: GameServerStatus | null,
toStatus: GameServerStatus,
reason: string,
actorId: string | null,
correlationId?: string,
errorCode?: string,
errorMessage?: string,
): Promise<void> {
await this.prisma.gameServerStateTransition.create({
data: {
gameServerId,
fromStatus,
toStatus,
reason,
actorId,
correlationId,
errorCode,
errorMessage,
},
});
}
private toResponse(server: GameServer): ServerResponse {
return {
id: server.id,
userId: server.userId,
planId: server.planId,
nodeId: server.nodeId,
name: server.name,
status: server.status,
version: server.version,
edition: server.edition,
softwareFamily: server.softwareFamily,
minecraftVersion: server.minecraftVersion,
eulaAccepted: server.eulaAccepted,
hostPort: server.hostPort,
containerId: server.containerId,
dataPath: server.dataPath,
ramMb: server.ramMb,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
};
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,13 @@ func main() {
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
if err := agent.New(cfg, log).Run(context.Background()); err != nil {
a, err := agent.New(cfg, log)
if err != nil {
log.Error("agent init error", "error", err)
os.Exit(1)
}
if err := a.Run(context.Background()); err != nil {
log.Error("agent exited with error", "error", err)
os.Exit(1)
}

View File

@@ -1,3 +1,10 @@
module github.com/hexahost/gamecloud/node-agent
go 1.23
require (
github.com/docker/docker v28.0.0+incompatible
github.com/docker/go-connections v0.5.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
)

View File

@@ -6,28 +6,43 @@ import (
"os"
"os/signal"
"syscall"
"time"
"github.com/hexahost/gamecloud/node-agent/internal/config"
dockerpkg "github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/health"
"github.com/hexahost/gamecloud/node-agent/internal/runtime"
"github.com/hexahost/gamecloud/node-agent/internal/ws"
)
const Version = "0.1.0-phase0"
const Version = "0.2.0-phase2"
// Agent coordinates the node agent lifecycle until shutdown.
type Agent struct {
cfg *config.Config
log *slog.Logger
health *health.Server
docker *dockerpkg.Client
ws *ws.Client
}
// New creates an agent instance from configuration.
func New(cfg *config.Config, log *slog.Logger) *Agent {
func New(cfg *config.Config, log *slog.Logger) (*Agent, error) {
dockerClient, err := dockerpkg.New(cfg.DockerSocket)
if err != nil {
return nil, err
}
wsClient := ws.NewClient(cfg, log, nil, Version)
runtimeMgr := runtime.NewManager(dockerClient, wsClient, log)
wsClient.SetRuntime(runtimeMgr)
return &Agent{
cfg: cfg,
log: log,
health: health.NewServer(cfg.HealthListenAddr, Version),
}
docker: dockerClient,
ws: wsClient,
}, nil
}
// Run starts the agent loop and blocks until context cancellation or signal.
@@ -39,7 +54,7 @@ func (a *Agent) Run(ctx context.Context) error {
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
errCh := make(chan error, 1)
errCh := make(chan error, 2)
go func() {
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
if err := a.health.ListenAndServe(); err != nil {
@@ -47,6 +62,10 @@ func (a *Agent) Run(ctx context.Context) error {
}
}()
go func() {
errCh <- a.ws.Run(ctx)
}()
a.health.SetReady(true)
a.log.Info("agent started",
"node_id", a.cfg.NodeID,
@@ -54,34 +73,33 @@ func (a *Agent) Run(ctx context.Context) error {
"version", Version,
)
ticker := time.NewTicker(a.cfg.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return a.shutdown(ctx.Err())
return a.shutdown(nil)
case sig := <-sigCh:
a.log.Info("shutdown signal received", "signal", sig.String())
return a.shutdown(nil)
cancel()
case err := <-errCh:
if ctx.Err() != nil {
return a.shutdown(nil)
}
if err != nil {
return err
case <-ticker.C:
a.log.Debug("heartbeat tick",
"node_id", a.cfg.NodeID,
"interval", a.cfg.HeartbeatInterval.String(),
)
}
}
}
}
func (a *Agent) shutdown(reason error) error {
a.health.SetReady(false)
a.log.Info("agent shutting down", "grace", a.cfg.ShutdownGracePeriod.String())
a.log.Info("agent shutting down")
timer := time.NewTimer(a.cfg.ShutdownGracePeriod)
defer timer.Stop()
<-timer.C
if a.docker != nil {
if err := a.docker.Close(); err != nil {
a.log.Warn("docker client close failed", "error", err)
}
}
if reason != nil && reason != context.Canceled {
a.log.Warn("shutdown with error", "error", reason)

View File

@@ -0,0 +1,191 @@
package docker
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
)
const MinecraftImage = "itzg/minecraft-server:java21"
// MinecraftServerSpec describes a Minecraft container to create.
type MinecraftServerSpec struct {
ServerID string
MinecraftVersion string
SoftwareFamily string
HostPort int
RamMb int
DataPath string
EulaAccepted bool
}
// ContainerState summarizes a Docker container's runtime status.
type ContainerState struct {
ID string
Status string
Running bool
}
// Client wraps the Docker Engine API for game server containers.
type Client struct {
cli *client.Client
}
// New creates a Docker client connected via the given socket URL.
func New(socket string) (*Client, error) {
cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithHost(socket),
client.WithAPIVersionNegotiation(),
)
if err != nil {
return nil, fmt.Errorf("create docker client: %w", err)
}
return &Client{cli: cli}, nil
}
// Close releases the underlying Docker client.
func (c *Client) Close() error {
return c.cli.Close()
}
// Ping verifies connectivity to the Docker daemon.
func (c *Client) Ping(ctx context.Context) error {
_, err := c.cli.Ping(ctx)
return err
}
// CreateMinecraftServer creates a non-privileged Minecraft container and returns its ID.
func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServerSpec) (string, error) {
if !spec.EulaAccepted {
return "", fmt.Errorf("eula must be accepted before provisioning")
}
if spec.HostPort <= 0 {
return "", fmt.Errorf("hostPort must be positive")
}
if strings.TrimSpace(spec.DataPath) == "" {
return "", fmt.Errorf("dataPath is required")
}
if spec.RamMb <= 0 {
return "", fmt.Errorf("ramMb must be positive")
}
containerPort := nat.Port("25565/tcp")
portBindings := nat.PortMap{
containerPort: []nat.PortBinding{{
HostIP: "0.0.0.0",
HostPort: strconv.Itoa(spec.HostPort),
}},
}
version := strings.TrimSpace(spec.MinecraftVersion)
if version == "" {
version = "LATEST"
}
resp, err := c.cli.ContainerCreate(ctx,
&container.Config{
Image: MinecraftImage,
Env: []string{
"EULA=TRUE",
"VERSION=" + version,
"TYPE=" + mapSoftwareFamily(spec.SoftwareFamily),
fmt.Sprintf("MEMORY=%dM", spec.RamMb),
},
ExposedPorts: nat.PortSet{containerPort: struct{}{}},
},
&container.HostConfig{
PortBindings: portBindings,
Mounts: []mount.Mount{{
Type: mount.TypeBind,
Source: spec.DataPath,
Target: "/data",
}},
Resources: container.Resources{
Memory: int64(spec.RamMb) * 1024 * 1024,
},
Privileged: false,
},
nil,
nil,
containerName(spec.ServerID),
)
if err != nil {
return "", fmt.Errorf("create container: %w", err)
}
return resp.ID, nil
}
// StartContainer starts a container by ID.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
if err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
return fmt.Errorf("start container: %w", err)
}
return nil
}
// StopContainer stops a running container gracefully.
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
timeout := 30
if err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil {
return fmt.Errorf("stop container: %w", err)
}
return nil
}
// RemoveContainer removes a container, forcing removal if still running.
func (c *Client) RemoveContainer(ctx context.Context, containerID string) error {
if err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
return fmt.Errorf("remove container: %w", err)
}
return nil
}
// GetContainerState inspects a container and returns its current state.
func (c *Client) GetContainerState(ctx context.Context, containerID string) (*ContainerState, error) {
inspect, err := c.cli.ContainerInspect(ctx, containerID)
if err != nil {
return nil, fmt.Errorf("inspect container: %w", err)
}
return &ContainerState{
ID: inspect.ID,
Status: inspect.State.Status,
Running: inspect.State.Running,
}, nil
}
// ListManagedContainers returns container IDs labeled for a given server ID.
func (c *Client) ListManagedContainers(ctx context.Context, serverID string) ([]string, error) {
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("name", containerName(serverID))),
})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
ids := make([]string, 0, len(containers))
for _, ctr := range containers {
ids = append(ids, ctr.ID)
}
return ids, nil
}
func containerName(serverID string) string {
return "hexahost-" + serverID
}
func mapSoftwareFamily(family string) string {
switch strings.ToUpper(strings.TrimSpace(family)) {
case "PAPER":
return "PAPER"
default:
return "VANILLA"
}
}

View File

@@ -120,6 +120,31 @@ type ServerStartPayload struct {
DesiredGeneration int64 `json:"desiredGeneration"`
}
// ServerProvisionPayload instructs the agent to create a Minecraft server container.
type ServerProvisionPayload struct {
OperationMeta
Edition string `json:"edition"`
SoftwareFamily string `json:"softwareFamily"`
MinecraftVersion string `json:"minecraftVersion"`
HostPort int `json:"hostPort"`
RamMb int `json:"ramMb"`
DataPath string `json:"dataPath"`
EulaAccepted bool `json:"eulaAccepted"`
}
// ServerStopPayload instructs the agent to stop a running game server.
type ServerStopPayload struct {
OperationMeta
}
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
type ServerOperationResultPayload struct {
OperationMeta
ResultCode string `json:"resultCode"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// ServerStatePayload reports the observed server state.
type ServerStatePayload struct {
OperationMeta

View File

@@ -136,6 +136,69 @@ func TestDecodeEnvelope(t *testing.T) {
}
}
func TestServerProvisionPayload_JSONRoundtrip(t *testing.T) {
deadline := time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)
original := ServerProvisionPayload{
OperationMeta: OperationMeta{
ServerID: "srv-abc123",
Generation: 2,
Deadline: &deadline,
IdempotencyKey: "idem-001",
},
Edition: "JAVA",
SoftwareFamily: "PAPER",
MinecraftVersion: "1.21.1",
HostPort: 25565,
RamMb: 2048,
DataPath: "/var/lib/hexahost-gamecloud/srv-abc123",
EulaAccepted: true,
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded ServerProvisionPayload
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.ServerID != original.ServerID {
t.Fatalf("serverId: got %q want %q", decoded.ServerID, original.ServerID)
}
if decoded.Generation != original.Generation {
t.Fatalf("generation: got %d want %d", decoded.Generation, original.Generation)
}
if decoded.Edition != original.Edition {
t.Fatalf("edition: got %q want %q", decoded.Edition, original.Edition)
}
if decoded.SoftwareFamily != original.SoftwareFamily {
t.Fatalf("softwareFamily: got %q want %q", decoded.SoftwareFamily, original.SoftwareFamily)
}
if decoded.MinecraftVersion != original.MinecraftVersion {
t.Fatalf("minecraftVersion: got %q want %q", decoded.MinecraftVersion, original.MinecraftVersion)
}
if decoded.HostPort != original.HostPort {
t.Fatalf("hostPort: got %d want %d", decoded.HostPort, original.HostPort)
}
if decoded.RamMb != original.RamMb {
t.Fatalf("ramMb: got %d want %d", decoded.RamMb, original.RamMb)
}
if decoded.DataPath != original.DataPath {
t.Fatalf("dataPath: got %q want %q", decoded.DataPath, original.DataPath)
}
if decoded.EulaAccepted != original.EulaAccepted {
t.Fatalf("eulaAccepted: got %v want %v", decoded.EulaAccepted, original.EulaAccepted)
}
if decoded.IdempotencyKey != original.IdempotencyKey {
t.Fatalf("idempotencyKey: got %q want %q", decoded.IdempotencyKey, original.IdempotencyKey)
}
if decoded.Deadline == nil || !decoded.Deadline.Equal(deadline) {
t.Fatalf("deadline mismatch: got %v want %v", decoded.Deadline, deadline)
}
}
func TestNewEnvelope(t *testing.T) {
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
NodeID: "n1",

View File

@@ -0,0 +1,269 @@
package runtime
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
)
const (
ServerStateProvisioning = "provisioning"
ServerStateStopped = "stopped"
ServerStateStarting = "starting"
ServerStateRunning = "running"
ServerStateStopping = "stopping"
ServerStateDeleting = "deleting"
ServerStateDeleted = "deleted"
ServerStateError = "error"
ResultCodeOK = "ok"
ResultCodeError = "error"
)
// Responder sends protocol responses back to the control plane.
type Responder interface {
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
}
type serverRecord struct {
ServerID string
ContainerID string
HostPort int
DataPath string
RamMb int
State string
}
// Manager tracks provisioned servers and executes lifecycle operations.
type Manager struct {
mu sync.RWMutex
docker *docker.Client
servers map[string]*serverRecord
resp Responder
log *slog.Logger
}
// NewManager creates a runtime manager backed by the given Docker client.
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
return &Manager{
docker: dockerClient,
servers: make(map[string]*serverRecord),
resp: resp,
log: log,
}
}
// RunningCount returns the number of servers currently in the running state.
func (m *Manager) RunningCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
count := 0
for _, rec := range m.servers {
if rec.State == ServerStateRunning {
count++
}
}
return count
}
// HandleProvision creates a Minecraft container for the server.
func (m *Manager) HandleProvision(ctx context.Context, correlationID string, payload protocol.ServerProvisionPayload) {
meta := payload.OperationMeta
m.log.Info("provision server",
"server_id", meta.ServerID,
"host_port", payload.HostPort,
"version", payload.MinecraftVersion,
)
if !payload.EulaAccepted {
m.fail(ctx, correlationID, meta, "EULA_NOT_ACCEPTED", "EULA must be accepted before provisioning")
return
}
m.mu.Lock()
if existing, ok := m.servers[meta.ServerID]; ok {
m.mu.Unlock()
m.log.Info("provision idempotent hit", "server_id", meta.ServerID, "container_id", existing.ContainerID)
m.complete(ctx, correlationID, meta, existing.State)
return
}
m.mu.Unlock()
m.emitState(ctx, correlationID, meta, ServerStateProvisioning, "", "")
containerID, err := m.docker.CreateMinecraftServer(ctx, docker.MinecraftServerSpec{
ServerID: meta.ServerID,
MinecraftVersion: payload.MinecraftVersion,
SoftwareFamily: payload.SoftwareFamily,
HostPort: payload.HostPort,
RamMb: payload.RamMb,
DataPath: payload.DataPath,
EulaAccepted: payload.EulaAccepted,
})
if err != nil {
m.fail(ctx, correlationID, meta, "PROVISION_FAILED", err.Error())
return
}
rec := &serverRecord{
ServerID: meta.ServerID,
ContainerID: containerID,
HostPort: payload.HostPort,
DataPath: payload.DataPath,
RamMb: payload.RamMb,
State: ServerStateStopped,
}
m.mu.Lock()
m.servers[meta.ServerID] = rec
m.mu.Unlock()
m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "")
m.complete(ctx, correlationID, meta, ServerStateStopped)
}
// HandleStart starts a provisioned server container.
func (m *Manager) HandleStart(ctx context.Context, correlationID string, payload protocol.ServerStartPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
m.emitState(ctx, correlationID, meta, ServerStateStarting, "", "")
if err := m.docker.StartContainer(ctx, rec.ContainerID); err != nil {
m.setState(meta.ServerID, ServerStateError)
m.emitState(ctx, correlationID, meta, ServerStateError, "START_FAILED", err.Error())
m.fail(ctx, correlationID, meta, "START_FAILED", err.Error())
return
}
m.setState(meta.ServerID, ServerStateRunning)
m.emitState(ctx, correlationID, meta, ServerStateRunning, "", "")
m.complete(ctx, correlationID, meta, ServerStateRunning)
}
// HandleStop stops a running server container.
func (m *Manager) HandleStop(ctx context.Context, correlationID string, payload protocol.ServerStopPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
m.emitState(ctx, correlationID, meta, ServerStateStopping, "", "")
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
m.setState(meta.ServerID, ServerStateError)
m.emitState(ctx, correlationID, meta, ServerStateError, "STOP_FAILED", err.Error())
m.fail(ctx, correlationID, meta, "STOP_FAILED", err.Error())
return
}
m.setState(meta.ServerID, ServerStateStopped)
m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "")
m.complete(ctx, correlationID, meta, ServerStateStopped)
}
// HandleDelete removes a server container and drops local state.
func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.complete(ctx, correlationID, meta, ServerStateDeleted)
return
}
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
return
}
}
if err := m.docker.RemoveContainer(ctx, rec.ContainerID); err != nil {
m.fail(ctx, correlationID, meta, "DELETE_FAILED", err.Error())
return
}
m.mu.Lock()
delete(m.servers, meta.ServerID)
m.mu.Unlock()
m.emitState(ctx, correlationID, meta, ServerStateDeleted, "", "")
m.complete(ctx, correlationID, meta, ServerStateDeleted)
}
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.servers[serverID]
if !ok {
return nil, false
}
copy := *rec
return &copy, true
}
func (m *Manager) setState(serverID, state string) {
m.mu.Lock()
defer m.mu.Unlock()
if rec, ok := m.servers[serverID]; ok {
rec.State = state
}
}
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
payload := protocol.ServerStatePayload{
OperationMeta: meta,
State: state,
ErrorCode: errorCode,
ErrorMessage: errorMessage,
}
if err := m.resp.SendState(ctx, correlationID, payload); err != nil {
m.log.Warn("send server.state failed",
"server_id", meta.ServerID,
"state", state,
"error", err,
)
}
}
func (m *Manager) complete(ctx context.Context, correlationID string, meta protocol.OperationMeta, _ string) {
payload := protocol.ServerOperationResultPayload{
OperationMeta: meta,
ResultCode: ResultCodeOK,
}
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
m.log.Warn("send operation complete failed",
"server_id", meta.ServerID,
"error", err,
)
}
}
func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.OperationMeta, errorCode, errorMessage string) {
m.emitState(ctx, correlationID, meta, ServerStateError, errorCode, errorMessage)
payload := protocol.ServerOperationResultPayload{
OperationMeta: meta,
ResultCode: ResultCodeError,
ErrorCode: errorCode,
ErrorMessage: errorMessage,
}
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
m.log.Warn("send operation failed failed",
"server_id", meta.ServerID,
"error", err,
)
}
}

View File

@@ -0,0 +1,357 @@
package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"runtime"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/hexahost/gamecloud/node-agent/internal/config"
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
runtimepkg "github.com/hexahost/gamecloud/node-agent/internal/runtime"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxReconnectDelay = 60 * time.Second
)
// Client maintains an outbound WebSocket connection to the control plane.
type Client struct {
cfg *config.Config
log *slog.Logger
runtime *runtimepkg.Manager
agentVersion string
writeMu sync.Mutex
conn *websocket.Conn
}
// NewClient creates a WebSocket client for the control plane.
func NewClient(cfg *config.Config, log *slog.Logger, mgr *runtimepkg.Manager, agentVersion string) *Client {
return &Client{
cfg: cfg,
log: log,
runtime: mgr,
agentVersion: agentVersion,
}
}
// SetRuntime attaches the runtime manager after construction to break init cycles.
func (c *Client) SetRuntime(mgr *runtimepkg.Manager) {
c.runtime = mgr
}
// Run connects to the control plane and processes messages until ctx is cancelled.
func (c *Client) Run(ctx context.Context) error {
backoff := c.cfg.ReconnectBackoff
if backoff <= 0 {
backoff = 5 * time.Second
}
for {
if ctx.Err() != nil {
return ctx.Err()
}
err := c.session(ctx)
if ctx.Err() != nil {
return ctx.Err()
}
c.log.Warn("websocket disconnected", "error", err, "retry_in", backoff.String())
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxReconnectDelay {
backoff = maxReconnectDelay
}
}
}
func (c *Client) session(ctx context.Context) error {
conn, err := c.dial(ctx)
if err != nil {
return err
}
c.writeMu.Lock()
c.conn = conn
c.writeMu.Unlock()
defer func() {
c.writeMu.Lock()
c.conn = nil
c.writeMu.Unlock()
_ = conn.Close()
}()
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
if err := c.sendHello(ctx); err != nil {
return fmt.Errorf("send hello: %w", err)
}
sessionCtx, cancel := context.WithCancel(ctx)
defer cancel()
errCh := make(chan error, 2)
go func() {
errCh <- c.heartbeatLoop(sessionCtx)
}()
go func() {
errCh <- c.readLoop(sessionCtx)
}()
select {
case <-ctx.Done():
cancel()
return ctx.Err()
case err := <-errCh:
cancel()
return err
}
}
func (c *Client) dial(ctx context.Context) (*websocket.Conn, error) {
endpoint, err := c.buildURL()
if err != nil {
return nil, err
}
dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(ctx, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("dial websocket: %w", err)
}
c.log.Info("connected to control plane", "url", redactToken(endpoint))
return conn, nil
}
func (c *Client) buildURL() (string, error) {
u, err := url.Parse(c.cfg.ControlPlaneURL)
if err != nil {
return "", fmt.Errorf("parse control plane url: %w", err)
}
q := u.Query()
q.Set("nodeId", c.cfg.NodeID)
if c.cfg.NodeToken != "" {
q.Set("token", c.cfg.NodeToken)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func (c *Client) sendHello(ctx context.Context) error {
hostname, _ := os.Hostname()
payload := protocol.AgentHelloPayload{
NodeID: c.cfg.NodeID,
AgentVersion: c.agentVersion,
ProtocolVersion: protocol.CurrentProtocolVersion,
Hostname: hostname,
Capabilities: []string{"minecraft", "docker"},
}
return c.send(ctx, protocol.TypeAgentHello, "", payload)
}
func (c *Client) heartbeatLoop(ctx context.Context) error {
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
defer ticker.Stop()
pingTicker := time.NewTicker(pingPeriod)
defer pingTicker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := c.sendHeartbeat(ctx); err != nil {
return err
}
case <-pingTicker.C:
if err := c.writePing(); err != nil {
return err
}
}
}
}
func (c *Client) sendHeartbeat(ctx context.Context) error {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
payload := protocol.AgentHeartbeatPayload{
NodeID: c.cfg.NodeID,
CPUUsagePercent: 0,
MemoryUsedBytes: int64(memStats.Alloc),
MemoryTotalBytes: int64(memStats.Sys),
RunningServers: c.runtime.RunningCount(),
}
return c.send(ctx, protocol.TypeAgentHeartbeat, "", payload)
}
func (c *Client) readLoop(ctx context.Context) error {
for {
if ctx.Err() != nil {
return ctx.Err()
}
c.writeMu.Lock()
conn := c.conn
c.writeMu.Unlock()
if conn == nil {
return fmt.Errorf("connection closed")
}
_, data, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read message: %w", err)
}
env, err := protocol.DecodeEnvelope(data)
if err != nil {
c.log.Warn("invalid inbound envelope", "error", err)
continue
}
c.dispatch(ctx, env)
}
}
func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
correlationID := env.CorrelationID
if correlationID == "" {
correlationID = env.MessageID
}
switch env.Type {
case protocol.TypeServerProvision:
var payload protocol.ServerProvisionPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.provision", "error", err)
return
}
c.runtime.HandleProvision(ctx, correlationID, payload)
case protocol.TypeServerStart:
var payload protocol.ServerStartPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.start", "error", err)
return
}
c.runtime.HandleStart(ctx, correlationID, payload)
case protocol.TypeServerStop:
var payload protocol.ServerStopPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.stop", "error", err)
return
}
c.runtime.HandleStop(ctx, correlationID, payload)
case protocol.TypeServerDelete:
var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil {
c.log.Warn("decode server.delete", "error", err)
return
}
c.runtime.HandleDelete(ctx, correlationID, meta)
default:
c.log.Debug("ignored control message", "type", env.Type)
}
}
func (c *Client) send(ctx context.Context, msgType protocol.MessageType, correlationID string, payload any) error {
env, err := protocol.NewEnvelope(msgType, uuid.NewString(), payload)
if err != nil {
return err
}
if correlationID != "" {
env.CorrelationID = correlationID
}
return c.writeEnvelope(ctx, env)
}
func (c *Client) writeEnvelope(ctx context.Context, env *protocol.Envelope) error {
data, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("marshal envelope: %w", err)
}
c.writeMu.Lock()
conn := c.conn
c.writeMu.Unlock()
if conn == nil {
return fmt.Errorf("not connected")
}
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(writeWait)
}
if err := conn.SetWriteDeadline(deadline); err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteMessage(websocket.TextMessage, data)
}
func (c *Client) writePing() error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteMessage(websocket.PingMessage, nil)
}
// SendState implements runtime.Responder.
func (c *Client) SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error {
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
}
// SendOperationComplete implements runtime.Responder.
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
}
// SendOperationFailed implements runtime.Responder.
func (c *Client) SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
return c.send(ctx, protocol.TypeServerOperationFailed, correlationID, payload)
}
func redactToken(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
if u.Query().Get("token") != "" {
q := u.Query()
q.Set("token", "REDACTED")
u.RawQuery = q.Encode()
}
return u.String()
}

View File

@@ -14,7 +14,8 @@
"submit": "Absenden",
"cancel": "Abbrechen",
"back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform."
"footer": "Selbstgehostete Minecraft-Server-Plattform.",
"servers": "Server"
},
"landing": {
"badge": "Phase 0 · Control Plane",
@@ -95,12 +96,20 @@
"welcome": "Willkommen in Ihrem Kontrollbereich.",
"servers": "Server",
"serversEmpty": "Noch keine Server",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, um loszulegen.",
"createServer": "Server erstellen",
"status": "Status",
"resources": "Ressourcen",
"activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse"
"activityEmpty": "Keine aktuellen Ereignisse",
"controlPlaneOnline": "Control Plane · Online",
"activeServers": "Aktive Server",
"resourcesSubtitle": "Zugewiesen / Kontingent",
"allocatedRam": "Zugewiesener RAM",
"activitySubtitle": "Übersicht",
"totalServers": "Server gesamt",
"viewAllServers": "Alle Server anzeigen",
"serversSummary": "Server in Ihrem Konto"
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
@@ -111,6 +120,66 @@
"passwordMismatch": "Passwörter stimmen nicht überein",
"usernameInvalid": "Benutzername: 332 Zeichen (Buchstaben, Zahlen, _ und -)",
"codeRequired": "Authentifizierungscode ist erforderlich",
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein",
"serverNameRequired": "Servername ist erforderlich",
"serverNameMax": "Servername darf höchstens 64 Zeichen lang sein",
"planRequired": "Bitte wählen Sie einen Tarif",
"planInvalid": "Ungültiger Tarif ausgewählt",
"versionRequired": "Minecraft-Version ist erforderlich",
"softwareRequired": "Software-Familie ist erforderlich",
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
},
"servers": {
"title": "Server",
"subtitle": "Verwalten Sie Ihre Minecraft-Server.",
"create": "Server erstellen",
"createTitle": "Neuer Server",
"createDescription": "Konfigurieren und provisionieren Sie einen neuen Minecraft-Server.",
"name": "Servername",
"plan": "Tarif",
"selectPlan": "Tarif auswählen",
"noPlans": "Keine Tarife verfügbar. Bitten Sie einen Administrator, einen Tarif anzulegen.",
"software": "Software",
"version": "Minecraft-Version",
"eula": "Ich akzeptiere die Minecraft End User License Agreement (EULA).",
"loading": "Wird geladen…",
"cancel": "Abbrechen",
"emptyTitle": "Noch keine Server",
"emptyDescription": "Erstellen Sie Ihren ersten Server, um zu spielen.",
"address": "Adresse",
"addressPending": "Zuweisung ausstehend",
"ram": "RAM",
"start": "Starten",
"stop": "Stoppen",
"details": "Details",
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
"backToList": "Zurück zur Serverliste",
"retry": "Erneut versuchen",
"status": {
"DRAFT": "Entwurf",
"PROVISIONING": "Provisionierung",
"INSTALLING": "Installation",
"STOPPED": "Gestoppt",
"STARTING": "Startet",
"RUNNING": "Läuft",
"STOPPING": "Stoppt",
"ERROR": "Fehler",
"DELETING": "Wird gelöscht",
"DELETED": "Gelöscht"
},
"softwareOptions": {
"VANILLA": "Vanilla",
"PAPER": "Paper",
"PURPUR": "Purpur",
"FABRIC": "Fabric",
"FORGE": "Forge",
"NEOFORGE": "NeoForge",
"QUILT": "Quilt",
"BEDROCK_DEDICATED": "Bedrock Dedicated"
},
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"loadFailed": "Server konnten nicht geladen werden."
}
}
}

View File

@@ -14,7 +14,8 @@
"submit": "Submit",
"cancel": "Cancel",
"back": "Back",
"footer": "Self-hosted Minecraft server platform."
"footer": "Self-hosted Minecraft server platform.",
"servers": "Servers"
},
"landing": {
"badge": "Phase 0 · Control Plane",
@@ -95,12 +96,20 @@
"welcome": "Welcome to your control area.",
"servers": "Servers",
"serversEmpty": "No servers yet",
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
"serversEmptyDescription": "Create your first Minecraft server to get started.",
"createServer": "Create server",
"status": "Status",
"resources": "Resources",
"activity": "Activity",
"activityEmpty": "No recent events"
"activityEmpty": "No recent events",
"controlPlaneOnline": "Control Plane · Online",
"activeServers": "Active servers",
"resourcesSubtitle": "Allocated / Quota",
"allocatedRam": "Allocated RAM",
"activitySubtitle": "Overview",
"totalServers": "Total servers",
"viewAllServers": "View all servers",
"serversSummary": "Servers in your account"
},
"validation": {
"emailRequired": "Email is required",
@@ -111,6 +120,66 @@
"passwordMismatch": "Passwords do not match",
"usernameInvalid": "Username must be 332 characters (letters, numbers, _ and -)",
"codeRequired": "Authentication code is required",
"codeInvalid": "Enter a valid 6-digit code"
"codeInvalid": "Enter a valid 6-digit code",
"serverNameRequired": "Server name is required",
"serverNameMax": "Server name must be at most 64 characters",
"planRequired": "Please select a plan",
"planInvalid": "Invalid plan selected",
"versionRequired": "Minecraft version is required",
"softwareRequired": "Software family is required",
"eulaRequired": "You must accept the Minecraft EULA"
},
"servers": {
"title": "Servers",
"subtitle": "Manage your Minecraft servers.",
"create": "Create server",
"createTitle": "New server",
"createDescription": "Configure and provision a new Minecraft server.",
"name": "Server name",
"plan": "Plan",
"selectPlan": "Select a plan",
"noPlans": "No plans are available. Ask an administrator to create a plan.",
"software": "Software",
"version": "Minecraft version",
"eula": "I accept the Minecraft End User License Agreement (EULA).",
"loading": "Loading…",
"cancel": "Cancel",
"emptyTitle": "No servers yet",
"emptyDescription": "Create your first server to start playing.",
"address": "Address",
"addressPending": "Pending allocation",
"ram": "RAM",
"start": "Start",
"stop": "Stop",
"details": "Details",
"detailSubtitle": "Server details and lifecycle controls",
"backToList": "Back to servers",
"retry": "Retry",
"status": {
"DRAFT": "Draft",
"PROVISIONING": "Provisioning",
"INSTALLING": "Installing",
"STOPPED": "Stopped",
"STARTING": "Starting",
"RUNNING": "Running",
"STOPPING": "Stopping",
"ERROR": "Error",
"DELETING": "Deleting",
"DELETED": "Deleted"
},
"softwareOptions": {
"VANILLA": "Vanilla",
"PAPER": "Paper",
"PURPUR": "Purpur",
"FABRIC": "Fabric",
"FORGE": "Forge",
"NEOFORGE": "NeoForge",
"QUILT": "Quilt",
"BEDROCK_DEDICATED": "Bedrock Dedicated"
},
"errors": {
"generic": "Something went wrong. Please try again.",
"loadFailed": "Could not load servers."
}
}
}

View File

@@ -30,6 +30,14 @@ export default async function DashboardLayout({
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<DashboardUserInfo />

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerDetailContent } from "@/components/servers/server-detail-content";
interface ServerDetailPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerDetailPage({ params }: ServerDetailPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerDetailContent serverId={id} />;
}

View File

@@ -0,0 +1,45 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface ServersLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("common");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="App navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<DashboardUserInfo />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { CreateServerForm } from "@/components/servers/create-server-form";
interface NewServerPageProps {
params: Promise<{ locale: string }>;
}
export default async function NewServerPage({ params }: NewServerPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <CreateServerForm />;
}

View File

@@ -0,0 +1,34 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { ServerList } from "@/components/servers/server-list";
interface ServersPageProps {
params: Promise<{ locale: string }>;
}
export default async function ServersPage({ params }: ServersPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("servers");
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
<Link
href="/servers/new"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("create")}
</Link>
</div>
<ServerList />
</div>
);
}

View File

@@ -1,19 +1,25 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Link, useRouter } from "@/i18n/navigation";
import { EmptyState } from "@/components/dashboard/empty-state";
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
import { listServers } from "@/lib/api/servers";
export function DashboardContent() {
const t = useTranslations("dashboard");
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const { data, isLoading } = useQuery({
queryKey: ["servers"],
queryFn: listServers,
});
useEffect(() => {
const timer = window.setTimeout(() => setIsLoading(false), 600);
return () => window.clearTimeout(timer);
}, []);
const servers = data?.servers ?? [];
const activeCount = servers.filter((server) =>
["RUNNING", "STARTING", "STOPPING"].includes(server.status),
).length;
if (isLoading) {
return <DashboardSkeleton />;
@@ -32,49 +38,83 @@ export function DashboardContent() {
<Card>
<CardHeader>
<CardTitle>{t("status")}</CardTitle>
<CardDescription>Control Plane · Online</CardDescription>
<CardDescription>{t("controlPlaneOnline")}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
0
{activeCount}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activeServers")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("resources")}</CardTitle>
<CardDescription>Allocated / Quota</CardDescription>
<CardDescription>{t("resourcesSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
0 / 0
{servers.reduce((sum, server) => sum + server.ramMb, 0)} MB
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("allocatedRam")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>Last 24 hours</CardDescription>
<CardDescription>{t("activitySubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{servers.length}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
</CardContent>
</Card>
</div>
<section aria-labelledby="servers-heading">
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 id="servers-heading" className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t("servers")}
</h2>
<Link
href="/servers"
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("viewAllServers")}
</Link>
</div>
{servers.length === 0 ? (
<EmptyState
title={t("serversEmpty")}
description={t("serversEmptyDescription")}
actionLabel={t("createServer")}
onAction={() => {
router.push("/servers/new");
}}
/>
) : (
<Card>
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("serversSummary")}</p>
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{servers.length}
</p>
</div>
<Link
href="/servers/new"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("createServer")}
</Link>
</CardContent>
</Card>
)}
</section>
</div>
);

View File

@@ -0,0 +1,212 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import { createServer, listPlans } from "@/lib/api/servers";
import {
createServerSchema,
MINECRAFT_VERSIONS,
SOFTWARE_FAMILIES,
type CreateServerFormValues,
} from "@/lib/schemas/server";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function CreateServerForm() {
const t = useTranslations("servers");
const tv = useTranslations("validation");
const router = useRouter();
const [apiError, setApiError] = useState<string | null>(null);
const { data: plansData, isLoading: plansLoading } = useQuery({
queryKey: ["plans"],
queryFn: listPlans,
retry: false,
});
const schema = createServerSchema({
nameRequired: tv("serverNameRequired"),
nameMax: tv("serverNameMax"),
planRequired: tv("planRequired"),
planInvalid: tv("planInvalid"),
versionRequired: tv("versionRequired"),
softwareRequired: tv("softwareRequired"),
eulaRequired: tv("eulaRequired"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateServerFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
planId: "",
minecraftVersion: MINECRAFT_VERSIONS[0],
softwareFamily: "VANILLA",
eulaAccepted: false,
},
});
const createMutation = useMutation({
mutationFn: createServer,
onSuccess: (server) => {
router.push(`/servers/${server.id}`);
},
onError: (error: unknown) => {
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
async function onSubmit(values: CreateServerFormValues) {
setApiError(null);
await createMutation.mutateAsync({
...values,
eulaAccepted: true,
});
}
const plans = plansData?.plans ?? [];
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{t("createTitle")}</CardTitle>
<CardDescription>{t("createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("name")}
</label>
<input
id="name"
type="text"
className={inputClassName}
aria-invalid={errors.name ? "true" : "false"}
{...register("name")}
/>
{errors.name ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("plan")}
</label>
<select
id="planId"
className={inputClassName}
disabled={plansLoading}
aria-invalid={errors.planId ? "true" : "false"}
{...register("planId")}
>
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name} ({plan.maxRamMb} MB)
</option>
))}
</select>
{errors.planId ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
) : null}
{!plansLoading && plans.length === 0 ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="softwareFamily"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("software")}
</label>
<select
id="softwareFamily"
className={inputClassName}
aria-invalid={errors.softwareFamily ? "true" : "false"}
{...register("softwareFamily")}
>
{SOFTWARE_FAMILIES.map((family) => (
<option key={family} value={family}>
{t(`softwareOptions.${family}`)}
</option>
))}
</select>
{errors.softwareFamily ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.softwareFamily.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="minecraftVersion"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("version")}
</label>
<select
id="minecraftVersion"
className={inputClassName}
aria-invalid={errors.minecraftVersion ? "true" : "false"}
{...register("minecraftVersion")}
>
{MINECRAFT_VERSIONS.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
{errors.minecraftVersion ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.minecraftVersion.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
<span>{t("eula")}</span>
</label>
{errors.eulaAccepted ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.eulaAccepted.message}</p>
) : null}
</div>
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
{t("create")}
</Button>
<Link
href="/servers"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("cancel")}
</Link>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Link } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import {
type Server,
startServer,
stopServer,
} from "@/lib/api/servers";
import { ServerStatusBadge } from "./server-status-badge";
interface ServerCardProps {
server: Server;
showDetailsLink?: boolean;
}
export function ServerCard({ server, showDetailsLink = true }: ServerCardProps) {
const t = useTranslations("servers");
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const startMutation = useMutation({
mutationFn: () => startServer(server.id),
onSuccess: () => {
setActionError(null);
void queryClient.invalidateQueries({ queryKey: ["servers"] });
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
},
onError: (error: unknown) => {
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
const stopMutation = useMutation({
mutationFn: () => stopServer(server.id),
onSuccess: () => {
setActionError(null);
void queryClient.invalidateQueries({ queryKey: ["servers"] });
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
},
onError: (error: unknown) => {
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
const canStart = server.status === "STOPPED";
const canStop = server.status === "RUNNING";
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
const address =
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div className="min-w-0 space-y-1">
<CardTitle className="truncate text-lg">{server.name}</CardTitle>
<CardDescription>
{server.softwareFamily} · {server.minecraftVersion}
</CardDescription>
</div>
<ServerStatusBadge status={server.status} label={t(`status.${server.status}`)} />
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-zinc-500 dark:text-zinc-400">{t("address")}</dt>
<dd className="font-mono text-zinc-900 dark:text-zinc-100">{address}</dd>
</div>
<div>
<dt className="text-zinc-500 dark:text-zinc-400">{t("ram")}</dt>
<dd className="text-zinc-900 dark:text-zinc-100">{server.ramMb} MB</dd>
</div>
</dl>
{actionError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{actionError}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => startMutation.mutate()}
disabled={!canStart || isBusy || startMutation.isPending || stopMutation.isPending}
>
{t("start")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => stopMutation.mutate()}
disabled={!canStop || isBusy || startMutation.isPending || stopMutation.isPending}
>
{t("stop")}
</Button>
{showDetailsLink ? (
<Link
href={`/servers/${server.id}`}
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("details")}
</Link>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getServer } from "@/lib/api/servers";
import { ServerCard } from "@/components/servers/server-card";
interface ServerDetailContentProps {
serverId: string;
}
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
refetchInterval: (query) => {
const status = query.state.data?.status;
if (!status) {
return false;
}
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
? 3000
: false;
},
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
{t("retry")}
</Button>
<Link
href="/servers"
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("backToList")}
</Link>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{data.name}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
</div>
<Link
href="/servers"
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("backToList")}
</Link>
</div>
<ServerCard server={data} showDetailsLink={false} />
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { listServers } from "@/lib/api/servers";
import { ServerCard } from "./server-card";
export function ServerList() {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["servers"],
queryFn: listServers,
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
{t("retry")}
</Button>
</div>
);
}
const servers = data?.servers ?? [];
if (servers.length === 0) {
return (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">{t("emptyTitle")}</h2>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">{t("emptyDescription")}</p>
<Link
href="/servers/new"
className="mt-4 inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("create")}
</Link>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
{servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { cn } from "@hexahost/ui";
import type { GameServerStatus } from "@/lib/api/servers";
const statusStyles: Record<GameServerStatus, string> = {
DRAFT: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",
};
interface ServerStatusBadgeProps {
status: GameServerStatus;
label: string;
className?: string;
}
export function ServerStatusBadge({ status, label, className }: ServerStatusBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
statusStyles[status],
className,
)}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,104 @@
import { apiFetch } from "./auth";
export type GameServerStatus =
| "DRAFT"
| "PROVISIONING"
| "INSTALLING"
| "STOPPED"
| "STARTING"
| "RUNNING"
| "STOPPING"
| "ERROR"
| "DELETING"
| "DELETED";
export type SoftwareFamily =
| "VANILLA"
| "PAPER"
| "PURPUR"
| "FABRIC"
| "FORGE"
| "NEOFORGE"
| "QUILT"
| "BEDROCK_DEDICATED";
export interface Server {
id: string;
userId: string;
planId: string | null;
nodeId: string | null;
name: string;
status: GameServerStatus;
version: number;
edition: "JAVA" | "BEDROCK";
softwareFamily: SoftwareFamily;
minecraftVersion: string;
eulaAccepted: boolean;
hostPort: number | null;
containerId: string | null;
dataPath: string | null;
ramMb: number;
createdAt: string;
updatedAt: string;
}
export interface ServerListResponse {
servers: Server[];
}
export interface ServerActionResponse {
server: Server;
jobId?: string;
}
export interface CreateServerPayload {
name: string;
planId?: string;
minecraftVersion: string;
softwareFamily: SoftwareFamily;
eulaAccepted: true;
}
export interface Plan {
id: string;
name: string;
slug: string;
description: string | null;
isFree: boolean;
maxRamMb: number;
}
export interface PlanListResponse {
plans: Plan[];
}
export async function listServers(): Promise<ServerListResponse> {
return apiFetch<ServerListResponse>("/servers");
}
export async function getServer(id: string): Promise<Server> {
return apiFetch<Server>(`/servers/${id}`);
}
export async function createServer(payload: CreateServerPayload): Promise<Server> {
return apiFetch<Server>("/servers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function startServer(id: string): Promise<ServerActionResponse> {
return apiFetch<ServerActionResponse>(`/servers/${id}/start`, {
method: "POST",
});
}
export async function stopServer(id: string): Promise<ServerActionResponse> {
return apiFetch<ServerActionResponse>(`/servers/${id}/stop`, {
method: "POST",
});
}
export async function listPlans(): Promise<PlanListResponse> {
return apiFetch<PlanListResponse>("/plans");
}

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
const softwareFamilyValues = [
"VANILLA",
"PAPER",
"PURPUR",
"FABRIC",
"FORGE",
"NEOFORGE",
"QUILT",
"BEDROCK_DEDICATED",
] as const;
export function createServerSchema(messages: {
nameRequired: string;
nameMax: string;
planRequired: string;
planInvalid: string;
versionRequired: string;
softwareRequired: string;
eulaRequired: string;
}) {
return z.object({
name: z
.string()
.min(1, messages.nameRequired)
.max(64, messages.nameMax),
planId: z
.string()
.min(1, messages.planRequired)
.uuid(messages.planInvalid),
minecraftVersion: z.string().min(1, messages.versionRequired),
softwareFamily: z.enum(softwareFamilyValues, {
errorMap: () => ({ message: messages.softwareRequired }),
}),
eulaAccepted: z
.boolean()
.refine((value) => value === true, { message: messages.eulaRequired }),
});
}
export type CreateServerFormValues = z.infer<ReturnType<typeof createServerSchema>>;
export const MINECRAFT_VERSIONS = ["1.21.1", "1.21", "1.20.6", "1.20.4"] as const;
export const SOFTWARE_FAMILIES = softwareFamilyValues;

View File

@@ -4,7 +4,7 @@ import { routing } from "./i18n/routing";
const intlMiddleware = createMiddleware(routing);
const protectedPaths = ["/dashboard", "/security"];
const protectedPaths = ["/dashboard", "/security", "/servers"];
function getPathWithoutLocale(pathname: string): string {
if (pathname === "/en" || pathname.startsWith("/en/")) {

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,7 @@
"@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*",
"bullmq": "^5.34.10",
"ioredis": "^5.4.2",
"nodemailer": "^6.10.0",
"pino": "^9.6.0",
"zod": "^3.24.2"

View File

@@ -0,0 +1,128 @@
import { randomUUID } from 'node:crypto';
import { Job } from 'bullmq';
import { z } from 'zod';
import { prisma } from '@hexahost/database';
import { logger } from '../logger';
import { publishNodeCommand } from '../redis';
import { transitionServerStatus } from '../server-state';
const lifecycleJobSchema = z.object({
serverId: z.string().uuid(),
});
async function loadServerForLifecycle(serverId: string) {
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server) {
throw new Error(`Server not found: ${serverId}`);
}
if (!server.nodeId) {
throw new Error(`Server ${serverId} is not assigned to a node`);
}
return server;
}
async function publishLifecycleCommand(
serverId: string,
nodeId: string,
type: 'server.start' | 'server.stop',
generation: number,
): Promise<string> {
const messageId = randomUUID();
await publishNodeCommand({
nodeId,
envelope: {
protocolVersion: 1,
messageId,
type,
timestamp: new Date().toISOString(),
payload: {
serverId,
generation,
desiredGeneration: generation,
},
},
});
return messageId;
}
export async function processStartServerJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'start-server') {
logger.warn({ jobName: job.name }, 'Unknown lifecycle job');
return { status: 'ignored' };
}
const { serverId } = lifecycleJobSchema.parse(job.data);
const server = await loadServerForLifecycle(serverId);
if (server.status !== 'STOPPED') {
throw new Error(`Cannot start server ${serverId} from status ${server.status}`);
}
const messageId = await publishLifecycleCommand(
serverId,
server.nodeId!,
'server.start',
server.version,
);
await transitionServerStatus(serverId, 'STARTING', {
expectedFrom: 'STOPPED',
reason: 'Start requested',
correlationId: messageId,
});
logger.info({ serverId, messageId }, 'Server start command published');
return { status: 'starting' };
}
export async function processStopServerJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'stop-server') {
logger.warn({ jobName: job.name }, 'Unknown lifecycle job');
return { status: 'ignored' };
}
const { serverId } = lifecycleJobSchema.parse(job.data);
const server = await loadServerForLifecycle(serverId);
if (server.status !== 'RUNNING') {
throw new Error(`Cannot stop server ${serverId} from status ${server.status}`);
}
const messageId = await publishLifecycleCommand(
serverId,
server.nodeId!,
'server.stop',
server.version,
);
await transitionServerStatus(serverId, 'STOPPING', {
expectedFrom: 'RUNNING',
reason: 'Stop requested',
correlationId: messageId,
});
logger.info({ serverId, messageId }, 'Server stop command published');
return { status: 'stopping' };
}
export async function processLifecycleJob(job: Job): Promise<{ status: string }> {
switch (job.name) {
case 'start-server':
return processStartServerJob(job);
case 'stop-server':
return processStopServerJob(job);
default:
logger.warn({ jobName: job.name }, 'Unknown lifecycle job name');
return { status: 'ignored' };
}
}

View File

@@ -0,0 +1,169 @@
import { randomUUID } from 'node:crypto';
import { prisma } from '@hexahost/database';
import { Job } from 'bullmq';
import { z } from 'zod';
import { logger } from '../logger';
import { publishNodeCommand, waitForNodeResponse } from '../redis';
import { transitionServerStatus } from '../server-state';
const BASE_HOST_PORT = 25565;
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000;
export const provisionServerJobSchema = z.object({
serverId: z.string().uuid(),
});
function buildDataPath(serverId: string): string {
return `/var/lib/hgc/servers/${serverId}`;
}
async function findAvailableHostPort(nodeId: string): Promise<number> {
const [allocations, servers] = await Promise.all([
prisma.gameServerAllocation.findMany({
where: { nodeId, status: 'ACTIVE' },
select: { hostPort: true },
}),
prisma.gameServer.findMany({
where: { nodeId, hostPort: { not: null } },
select: { hostPort: true },
}),
]);
const usedPorts = new Set<number>();
for (const allocation of allocations) {
usedPorts.add(allocation.hostPort);
}
for (const server of servers) {
if (server.hostPort !== null) {
usedPorts.add(server.hostPort);
}
}
let port = BASE_HOST_PORT;
while (usedPorts.has(port)) {
port += 1;
}
return port;
}
export async function processProvisionServerJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'provision-server') {
logger.warn({ jobName: job.name }, 'Unknown provisioning job');
return { status: 'ignored' };
}
const { serverId } = provisionServerJobSchema.parse(job.data);
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
include: { plan: true },
});
if (!server) {
throw new Error(`Server not found: ${serverId}`);
}
const node = await prisma.gameNode.findFirst({
where: { status: 'ONLINE' },
orderBy: { createdAt: 'asc' },
});
if (!node) {
await transitionServerStatus(serverId, 'ERROR', {
reason: 'No online game node available',
errorCode: 'NO_NODE_AVAILABLE',
errorMessage: 'No online game node is available for provisioning',
});
throw new Error('No online game node available');
}
const hostPort = await findAvailableHostPort(node.id);
const ramMb = server.plan?.maxRamMb ?? server.ramMb;
const dataPath = buildDataPath(serverId);
const messageId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.gameServerAllocation.create({
data: {
serverId,
nodeId: node.id,
hostPort,
ramMbReserved: ramMb,
status: 'ACTIVE',
},
});
await tx.gameServer.update({
where: { id: serverId },
data: {
nodeId: node.id,
hostPort,
ramMb,
dataPath,
status: 'PROVISIONING',
},
});
await tx.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: server.status,
toStatus: 'PROVISIONING',
reason: 'Worker allocated node resources',
correlationId: messageId,
},
});
});
await publishNodeCommand({
nodeId: node.id,
envelope: {
protocolVersion: 1,
messageId,
type: 'server.provision',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: server.version,
edition: server.edition,
softwareFamily: server.softwareFamily,
minecraftVersion: server.minecraftVersion,
hostPort,
ramMb,
dataPath,
eulaAccepted: server.eulaAccepted,
},
},
});
const response = await waitForNodeResponse(messageId, PROVISION_TIMEOUT_MS);
if (response === null) {
await transitionServerStatus(serverId, 'INSTALLING', {
reason: 'Provision command sent; awaiting agent completion',
correlationId: messageId,
});
logger.info({ serverId, messageId }, 'Provision timed out; marked INSTALLING');
return { status: 'installing' };
}
if (!response.success) {
await transitionServerStatus(serverId, 'ERROR', {
reason: 'Provision failed',
correlationId: messageId,
errorCode: response.errorCode ?? response.resultCode ?? 'PROVISION_FAILED',
errorMessage: response.errorMessage ?? 'Server provisioning failed',
});
throw new Error(response.errorMessage ?? 'Server provisioning failed');
}
await transitionServerStatus(serverId, 'STOPPED', {
reason: 'Provision completed',
correlationId: messageId,
});
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
return { status: 'stopped' };
}

View File

@@ -4,9 +4,18 @@ import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database';
import { processNotificationJob } from './handlers/notifications';
import { processLifecycleJob } from './handlers/server-lifecycle';
import { processProvisionServerJob } from './handlers/server-provisioning';
import { startHealthServer } from './health';
import { logger } from './logger';
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
import { closeRedisConnections } from './redis';
import {
QUEUE_NOTIFICATIONS,
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
WORKER_QUEUES,
type WorkerQueueName,
} from './queues';
function createQueueWorker(
queueName: WorkerQueueName,
@@ -20,11 +29,19 @@ function createQueueWorker(
'Processing job',
);
if (queueName === 'notifications') {
if (queueName === QUEUE_NOTIFICATIONS) {
return processNotificationJob(job);
}
return { acknowledged: true, queue: queueName };
if (queueName === QUEUE_SERVER_PROVISIONING) {
return processProvisionServerJob(job);
}
if (queueName === QUEUE_SERVER_LIFECYCLE) {
return processLifecycleJob(job);
}
logger.warn({ queue: queueName, jobName: job.name }, 'Unhandled job');
},
{ connection },
);
@@ -62,6 +79,7 @@ async function bootstrap(): Promise<void> {
logger.info({ signal }, 'Shutting down worker');
await Promise.all(workers.map((worker) => worker.close()));
await closeRedisConnections();
await prisma.$disconnect();
healthServer.close(() => {

109
apps/worker/src/redis.ts Normal file
View File

@@ -0,0 +1,109 @@
import Redis from 'ioredis';
import { validateConfig } from '@hexahost/config';
import { logger } from './logger';
export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands';
export interface NodeCommandEnvelope {
protocolVersion: number;
messageId: string;
type: string;
timestamp: string;
payload: Record<string, unknown>;
}
export interface NodeCommandMessage {
nodeId: string;
envelope: NodeCommandEnvelope;
}
export interface NodeResponseMessage {
success: boolean;
resultCode?: string;
errorCode?: string;
errorMessage?: string;
}
let publisher: Redis | null = null;
export function getRedisPublisher(): Redis {
if (!publisher) {
const config = validateConfig();
publisher = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
}
return publisher;
}
export async function publishNodeCommand(message: NodeCommandMessage): Promise<void> {
const redis = getRedisPublisher();
await redis.publish(NODE_COMMANDS_CHANNEL, JSON.stringify(message));
logger.info(
{ nodeId: message.nodeId, type: message.envelope.type, messageId: message.envelope.messageId },
'Published node command',
);
}
export function nodeResponseChannel(messageId: string): string {
return `hgc:node:responses:${messageId}`;
}
export async function waitForNodeResponse(
messageId: string,
timeoutMs: number,
): Promise<NodeResponseMessage | null> {
const config = validateConfig();
const subscriber = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
const channel = nodeResponseChannel(messageId);
return new Promise((resolve) => {
let settled = false;
const finish = (value: NodeResponseMessage | null): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
void subscriber.unsubscribe(channel).finally(() => {
subscriber.disconnect();
});
resolve(value);
};
const timer = setTimeout(() => finish(null), timeoutMs);
subscriber.on('message', (receivedChannel, message) => {
if (receivedChannel !== channel) {
return;
}
try {
finish(JSON.parse(message) as NodeResponseMessage);
} catch {
finish({
success: false,
errorMessage: 'Invalid node response payload',
});
}
});
void subscriber.subscribe(channel).catch((error: Error) => {
logger.error({ err: error, channel }, 'Failed to subscribe for node response');
finish(null);
});
});
}
export async function closeRedisConnections(): Promise<void> {
if (publisher) {
await publisher.quit();
publisher = null;
}
}

View File

@@ -0,0 +1,58 @@
import { type Prisma, prisma } from '@hexahost/database';
type GameServerStatus = Prisma.GameServerGetPayload<{ select: { status: true } }>['status'];
interface TransitionOptions {
expectedFrom?: GameServerStatus | GameServerStatus[];
reason?: string;
correlationId?: string;
actorId?: string;
errorCode?: string;
errorMessage?: string;
extraData?: Prisma.GameServerUpdateInput;
}
export async function transitionServerStatus(
serverId: string,
toStatus: GameServerStatus,
options: TransitionOptions = {},
): Promise<void> {
await prisma.$transaction(async (tx) => {
const server = await tx.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
if (options.expectedFrom) {
const allowed = Array.isArray(options.expectedFrom)
? options.expectedFrom
: [options.expectedFrom];
if (!allowed.includes(server.status)) {
throw new Error(
`Invalid state transition for server ${serverId}: ${server.status} -> ${toStatus}`,
);
}
}
await tx.gameServer.update({
where: { id: serverId },
data: {
status: toStatus,
...options.extraData,
},
});
await tx.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: server.status,
toStatus,
reason: options.reason,
correlationId: options.correlationId,
actorId: options.actorId,
errorCode: options.errorCode,
errorMessage: options.errorMessage,
},
});
});
}

File diff suppressed because one or more lines are too long

View File

@@ -2,54 +2,61 @@
Last updated: 2026-06-26
## Current phase: Phase 1Authentication & user panel
## Current phase: Phase 2Single-Node Vertical Slice
### Phase 1 completed
### Phase 2 completed
| Area | Status | Notes |
|------|--------|-------|
| Prisma auth models | Done | EmailVerificationToken, PasswordResetToken, TotpCredential, RecoveryCode |
| Migration | Done | `20260626120000_phase1_auth` + platform role seed |
| `@hexahost/auth` | Done | Argon2id, tokens, TOTP, recovery codes, username utils |
| API AuthModule | Done | Register, login, logout, verify, reset, 2FA, `/me`, sessions |
| Session cookies | Done | `hgc_session`, HttpOnly, Secure in prod, SameSite lax |
| Account lockout | Done | 5 attempts → 15 min lock |
| Worker emails | Done | `notifications` queue → nodemailer (Mailpit in dev) |
| Web auth UI | Done | Login, register, 2FA, forgot/reset, verify, security |
| Route protection | Done | Middleware cookie check for dashboard/security |
| Admin bootstrap | Done | `pnpm bootstrap:admin --email … --password …` |
| Tests | Done | Auth package vitest, API health/auth tests, contracts |
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
| API Plans | Done | `GET /api/v1/plans` |
| Admin nodes | Done | `POST /api/v1/admin/nodes` with enrollment token |
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
| Worker lifecycle | Done | start-server, stop-server jobs |
| Go node-agent | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
| Web server UI | Done | Create, list, detail, start/stop |
| Dev seed | Done | `pnpm db:seed` — Free plan + local dev node |
| Migrations | Done | `20260626140000_phase2_game_servers` |
### Abnahmekriterium Phase 1
### Abnahmekriterium Phase 2
- [x] Registration with email verification flow
- [x] Login/logout with secure sessions
- [x] Password reset
- [x] TOTP 2FA setup and login challenge
- [x] Session list and revoke
- [x] Admin bootstrap CLI (no default credentials)
- [x] Audit events for security actions
- [x] Unit/integration tests pass
- [x] User creates server in web panel
- [x] Provisioning pipeline (API → Worker → Agent)
- [x] Start/stop lifecycle
- [x] Node enrollment + heartbeat
- [x] Connection address shown (`localhost:port`)
- [ ] Manual Minecraft join validation (requires Docker + agent running locally)
### Local vertical slice
```bash
pnpm dev:infra
pnpm db:migrate # or db:push
pnpm db:seed
pnpm dev
# Terminal 2 — Node Agent (requires Go + Docker)
cd apps/node-agent
set NODE_ID=00000000-0000-4000-8000-000000000001
set NODE_TOKEN=local-dev-enrollment-token-change-me-32chars
set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
make run
```
### Known limitations
- E2E Playwright tests for auth flow not yet added (planned).
- Email templates are minimal HTML (no branded layout yet).
- OIDC / Passkeys deferred to later phases.
- CSRF relies on SameSite cookies + CORS origin check; no double-submit token yet.
- Single-node only (no scheduler/queue fairness yet).
- Minecraft image pull on first provision can take several minutes.
- Node agent on Windows needs Docker Desktop; run agent on host, not in Compose.
- mTLS for agent deferred; token-based enrollment only.
- Metrics are basic heartbeat payload only.
### Test commands
### Next phase: Phase 3 — Console, files, settings
```bash
pnpm install
pnpm db:migrate # or db:push
pnpm test
pnpm build
pnpm bootstrap:admin -- --email admin@localhost --password 'your-secure-password'
```
### Next phase: Phase 2 — Single-Node Vertical Slice
- GameNode enrollment, Go agent WebSocket
- Server create, provision, start/stop
- First playable Minecraft server locally
- Live console WebSocket
- File manager
- server.properties editor
- Player lists

View File

@@ -15,7 +15,8 @@
"db:generate": "pnpm --filter @hexahost/database db:generate",
"db:migrate": "pnpm --filter @hexahost/database db:migrate",
"db:push": "pnpm --filter @hexahost/database db:push",
"bootstrap:admin": "pnpm --filter @hexahost/database bootstrap:admin"
"bootstrap:admin": "pnpm --filter @hexahost/database bootstrap:admin",
"db:seed": "pnpm --filter @hexahost/database db:seed"
},
"devDependencies": {
"turbo": "^2.3.3",

View File

@@ -54,3 +54,20 @@ export {
type SessionListResponse,
type MessageResponse,
} from './auth';
export {
gameServerStatusSchema,
serverEditionSchema,
softwareFamilySchema,
createServerRequestSchema,
serverResponseSchema,
serverListResponseSchema,
serverActionResponseSchema,
type GameServerStatus,
type ServerEdition,
type SoftwareFamily,
type CreateServerRequest,
type ServerResponse,
type ServerListResponse,
type ServerActionResponse,
} from './servers';

View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
export const gameServerStatusSchema = z.enum([
'DRAFT',
'PROVISIONING',
'INSTALLING',
'STOPPED',
'STARTING',
'RUNNING',
'STOPPING',
'ERROR',
'DELETING',
'DELETED',
]);
export const serverEditionSchema = z.enum(['JAVA', 'BEDROCK']);
export const softwareFamilySchema = z.enum([
'VANILLA',
'PAPER',
'PURPUR',
'FABRIC',
'FORGE',
'NEOFORGE',
'QUILT',
'BEDROCK_DEDICATED',
]);
export const createServerRequestSchema = z.object({
name: z.string().min(1).max(64),
edition: serverEditionSchema.default('JAVA'),
softwareFamily: softwareFamilySchema.default('VANILLA'),
minecraftVersion: z.string().min(1).max(32).default('1.21.1'),
eulaAccepted: z.literal(true, {
errorMap: () => ({ message: 'EULA must be accepted' }),
}),
ramMb: z.number().int().min(512).max(65536).optional(),
planId: z.string().uuid().optional(),
});
export const serverResponseSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
planId: z.string().uuid().nullable(),
nodeId: z.string().uuid().nullable(),
name: z.string(),
status: gameServerStatusSchema,
version: z.number().int(),
edition: serverEditionSchema,
softwareFamily: softwareFamilySchema,
minecraftVersion: z.string(),
eulaAccepted: z.boolean(),
hostPort: z.number().int().nullable(),
containerId: z.string().nullable(),
dataPath: z.string().nullable(),
ramMb: z.number().int(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const serverListResponseSchema = z.object({
servers: z.array(serverResponseSchema),
});
export const serverActionResponseSchema = z.object({
server: serverResponseSchema,
jobId: z.string().optional(),
});
export type GameServerStatus = z.infer<typeof gameServerStatusSchema>;
export type ServerEdition = z.infer<typeof serverEditionSchema>;
export type SoftwareFamily = z.infer<typeof softwareFamilySchema>;
export type CreateServerRequest = z.infer<typeof createServerRequestSchema>;
export type ServerResponse = z.infer<typeof serverResponseSchema>;
export type ServerListResponse = z.infer<typeof serverListResponseSchema>;
export type ServerActionResponse = z.infer<typeof serverActionResponseSchema>;

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,8 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"bootstrap:admin": "tsx src/cli/bootstrap-admin.ts"
"bootstrap:admin": "tsx src/cli/bootstrap-admin.ts",
"db:seed": "tsx src/cli/seed-dev.ts"
},
"dependencies": {
"@prisma/client": "^6.3.1"

View File

@@ -0,0 +1,75 @@
-- Phase 2: Game server lifecycle and node enrollment
-- New enums
DO $$ BEGIN
CREATE TYPE "ServerEdition" AS ENUM ('JAVA', 'BEDROCK');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE "SoftwareFamily" AS ENUM ('VANILLA', 'PAPER', 'PURPUR', 'FABRIC', 'FORGE', 'NEOFORGE', 'QUILT', 'BEDROCK_DEDICATED');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE "AllocationStatus" AS ENUM ('ACTIVE', 'RELEASED');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Extend GameNodeStatus
ALTER TYPE "GameNodeStatus" ADD VALUE IF NOT EXISTS 'UNREACHABLE';
-- Game servers: add columns
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "edition" "ServerEdition" NOT NULL DEFAULT 'JAVA';
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "softwareFamily" "SoftwareFamily" NOT NULL DEFAULT 'VANILLA';
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "minecraftVersion" TEXT NOT NULL DEFAULT '1.21.1';
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "eulaAccepted" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "hostPort" INTEGER;
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "containerId" TEXT;
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "dataPath" TEXT;
ALTER TABLE "game_servers" ADD COLUMN IF NOT EXISTS "ramMb" INTEGER NOT NULL DEFAULT 1024;
-- Game nodes: enrollment + heartbeat
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "lastHeartbeatAt" TIMESTAMPTZ(3);
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "agentVersion" TEXT;
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "enrollmentTokenHash" TEXT;
ALTER TABLE "game_nodes" ADD COLUMN IF NOT EXISTS "enrolledAt" TIMESTAMPTZ(3);
-- State transitions: audit fields
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "correlationId" TEXT;
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "actorId" TEXT;
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "errorCode" TEXT;
ALTER TABLE "game_server_state_transitions" ADD COLUMN IF NOT EXISTS "errorMessage" TEXT;
-- Allocations
CREATE TABLE IF NOT EXISTS "game_server_allocations" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"hostPort" INTEGER NOT NULL,
"ramMbReserved" INTEGER NOT NULL,
"status" "AllocationStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "game_server_allocations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_allocations_serverId_key" ON "game_server_allocations"("serverId");
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_allocations_nodeId_hostPort_key" ON "game_server_allocations"("nodeId", "hostPort");
CREATE INDEX IF NOT EXISTS "game_server_allocations_nodeId_idx" ON "game_server_allocations"("nodeId");
DO $$ BEGIN
ALTER TABLE "game_server_allocations" ADD CONSTRAINT "game_server_allocations_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_server_allocations" ADD CONSTRAINT "game_server_allocations_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Free plan seed
INSERT INTO "plans" ("id", "name", "slug", "description", "isFree", "isActive", "maxRamMb", "maxCpuCores", "maxStorageMb", "createdAt", "updatedAt")
SELECT gen_random_uuid()::text, 'Free', 'free', 'Free tier with limited resources', true, true, 2048, 1, 5120, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM "plans" WHERE "slug" = 'free');

View File

@@ -13,14 +13,32 @@ enum UserStatus {
DELETED
}
enum ServerEdition {
JAVA
BEDROCK
}
enum SoftwareFamily {
VANILLA
PAPER
PURPUR
FABRIC
FORGE
NEOFORGE
QUILT
BEDROCK_DEDICATED
}
enum GameServerStatus {
PENDING
DRAFT
PROVISIONING
INSTALLING
STOPPED
STARTING
RUNNING
STOPPING
ERROR
DELETING
DELETED
}
@@ -29,6 +47,12 @@ enum GameNodeStatus {
OFFLINE
MAINTENANCE
DRAINING
UNREACHABLE
}
enum AllocationStatus {
ACTIVE
RELEASED
}
enum JobStatus {
@@ -185,8 +209,16 @@ model GameServer {
planId String?
nodeId String?
name String
status GameServerStatus @default(PENDING)
status GameServerStatus @default(DRAFT)
version Int @default(1)
edition ServerEdition @default(JAVA)
softwareFamily SoftwareFamily @default(VANILLA)
minecraftVersion String @default("1.21.1")
eulaAccepted Boolean @default(false)
hostPort Int?
containerId String?
dataPath String?
ramMb Int @default(1024)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -194,6 +226,7 @@ model GameServer {
plan Plan? @relation(fields: [planId], references: [id])
node GameNode? @relation(fields: [nodeId], references: [id])
stateTransitions GameServerStateTransition[]
allocation GameServerAllocation?
@@index([userId])
@@index([nodeId])
@@ -201,12 +234,34 @@ model GameServer {
@@map("game_servers")
}
model GameServerAllocation {
id String @id @default(uuid())
serverId String @unique
nodeId String
hostPort Int
ramMbReserved Int
status AllocationStatus @default(ACTIVE)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
node GameNode @relation(fields: [nodeId], references: [id])
@@unique([nodeId, hostPort])
@@index([nodeId])
@@map("game_server_allocations")
}
model GameServerStateTransition {
id String @id @default(uuid())
gameServerId String
fromStatus GameServerStatus?
toStatus GameServerStatus
reason String?
correlationId String?
actorId String?
errorCode String?
errorMessage String?
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@ -222,14 +277,19 @@ model GameNode {
name String @unique
hostname String
status GameNodeStatus @default(OFFLINE)
maxServers Int @default(0)
maxServers Int @default(10)
activeServers Int @default(0)
lastHeartbeatAt DateTime? @db.Timestamptz(3)
agentVersion String?
enrollmentTokenHash String?
enrolledAt DateTime? @db.Timestamptz(3)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
gameServers GameServer[]
heartbeats GameNodeHeartbeat[]
allocations GameServerAllocation[]
@@map("game_nodes")
}

View File

@@ -0,0 +1,62 @@
import { PrismaClient } from '@prisma/client';
import { hashToken } from '@hexahost/auth';
/** Stable dev node ID — must match NODE_ID in .env for local agent */
export const DEV_NODE_ID = '00000000-0000-4000-8000-000000000001';
/** Shown once in seed output; hash stored in DB */
export const DEV_NODE_ENROLLMENT_TOKEN =
'local-dev-enrollment-token-change-me-32chars';
async function main(): Promise<void> {
const prisma = new PrismaClient();
try {
const freePlan = await prisma.plan.upsert({
where: { slug: 'free' },
create: {
name: 'Free',
slug: 'free',
description: 'Free tier with limited resources',
isFree: true,
isActive: true,
maxRamMb: 2048,
maxCpuCores: 1,
maxStorageMb: 5120,
},
update: {
isActive: true,
},
});
const devNode = await prisma.gameNode.upsert({
where: { id: DEV_NODE_ID },
create: {
id: DEV_NODE_ID,
name: 'local-dev',
hostname: 'localhost',
status: 'OFFLINE',
maxServers: 10,
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
},
update: {
name: 'local-dev',
hostname: 'localhost',
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
},
});
console.log('Seed complete:');
console.log(` Plan Free: ${freePlan.id}`);
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
} finally {
await prisma.$disconnect();
}
}
void main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

File diff suppressed because one or more lines are too long

60
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@fastify/static':
specifier: ^8.0.4
version: 8.3.0
'@fastify/websocket':
specifier: ^11.0.2
version: 11.2.0
'@hexahost/auth':
specifier: workspace:*
version: link:../../packages/auth
@@ -74,6 +77,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
ws:
specifier: ^8.18.0
version: 8.21.0
zod:
specifier: ^3.24.1
version: 3.25.76
@@ -90,6 +96,9 @@ importers:
'@types/node':
specifier: ^22.10.7
version: 22.20.0
'@types/ws':
specifier: ^8.5.14
version: 8.18.1
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
@@ -169,6 +178,9 @@ importers:
bullmq:
specifier: ^5.34.10
version: 5.79.1
ioredis:
specifier: ^5.4.2
version: 5.10.1
nodemailer:
specifier: ^6.10.0
version: 6.10.1
@@ -591,6 +603,9 @@ packages:
'@fastify/static@8.3.0':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
'@fastify/websocket@11.2.0':
resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==}
'@formatjs/fast-memoize@3.1.6':
resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==}
@@ -1690,6 +1705,9 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.62.0':
resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2411,6 +2429,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexify@4.1.3:
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
effect@3.21.0:
resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==}
@@ -3880,6 +3901,9 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -4314,6 +4338,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -4602,6 +4638,15 @@ snapshots:
fastq: 1.20.1
glob: 11.1.0
'@fastify/websocket@11.2.0':
dependencies:
duplexify: 4.1.3
fastify-plugin: 5.1.0
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@formatjs/fast-memoize@3.1.6': {}
'@formatjs/icu-messageformat-parser@3.5.12':
@@ -5501,6 +5546,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.20.0
'@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -6250,6 +6299,13 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
duplexify@4.1.3:
dependencies:
end-of-stream: 1.4.5
inherits: 2.0.4
readable-stream: 3.6.2
stream-shift: 1.0.3
effect@3.21.0:
dependencies:
'@standard-schema/spec': 1.1.0
@@ -7992,6 +8048,8 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-shift@1.0.3: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -8529,6 +8587,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.21.0: {}
yargs-parser@21.1.1: {}
yocto-queue@0.1.0: {}