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 { ConsoleSessionService } from '../servers/console/console-session.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; } const AGENT_STATE_MAP: Record = { 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(); constructor( private readonly prisma: PrismaService, private readonly serversService: ServersService, private readonly consoleSessionService: ConsoleSessionService, @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 { 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 { const node = await this.prisma.gameNode.findUnique({ where: { id: nodeId } }); const nextStatus = node?.status === 'UNREACHABLE' || node?.status === 'OFFLINE' ? 'ONLINE' : node?.status === 'MAINTENANCE' || node?.status === 'DRAINING' ? node.status : 'ONLINE'; await this.prisma.gameNode.update({ where: { id: nodeId }, data: { status: nextStatus, lastHeartbeatAt: new Date(), enrolledAt: new Date(), agentVersion, enrollmentTokenHash: null, }, }); } async handleAgentMessage(nodeId: string, raw: string): Promise { 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.log': this.handleServerLog(envelope); break; case 'server.operation.completed': await this.handleOperationCompleted(envelope); break; case 'server.operation.failed': await this.handleOperationFailed(envelope); break; case 'server.files.list.result': case 'server.files.read.result': case 'server.files.write.result': case 'server.files.delete.result': case 'server.files.archive.result': case 'server.files.unarchive.result': case 'server.command.result': await this.handleDirectResult(envelope); break; default: this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`); } } private async persistHeartbeat( nodeId: string, payload?: Record, ): Promise { const now = new Date(); const node = await this.prisma.gameNode.findUnique({ where: { id: nodeId } }); const nextStatus = node?.status === 'UNREACHABLE' ? 'ONLINE' : node?.status === 'MAINTENANCE' || node?.status === 'DRAINING' ? node.status : 'ONLINE'; await this.prisma.$transaction([ this.prisma.gameNode.update({ where: { id: nodeId }, data: { lastHeartbeatAt: now, status: nextStatus, agentVersion: typeof payload?.['agentVersion'] === 'string' ? payload['agentVersion'] : undefined, metadata: payload ? ({ cpuUsagePercent: payload['cpuUsagePercent'], memoryUsedBytes: payload['memoryUsedBytes'], memoryTotalBytes: payload['memoryTotalBytes'], diskUsedBytes: payload['diskUsedBytes'], diskTotalBytes: payload['diskTotalBytes'], runningServers: payload['runningServers'], } as Prisma.InputJsonValue) : undefined, }, }), this.prisma.gameNodeHeartbeat.create({ data: { nodeId, payload: (payload ?? undefined) as Prisma.InputJsonValue | undefined, }, }), ]); } private async handleServerState(envelope: AgentEnvelope): Promise { 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 handleServerLog(envelope: AgentEnvelope): void { const serverId = envelope.payload?.['serverId']; const line = envelope.payload?.['line'] ?? envelope.payload?.['message']; if (typeof serverId === 'string' && typeof line === 'string') { this.consoleSessionService.broadcast(serverId, line); } } private async handleDirectResult(envelope: AgentEnvelope): Promise { const messageId = envelope.correlationId ?? envelope.messageId; if (typeof messageId !== 'string') { return; } await this.publishNodeResponse(messageId, { success: true, result: envelope.payload?.['result'] ?? envelope.payload, }); } private async handleOperationCompleted( envelope: AgentEnvelope, ): Promise { const serverId = envelope.payload?.['serverId']; const operation = envelope.payload?.['operation'] ?? envelope.payload?.['resultCode']; const messageId = envelope.correlationId ?? envelope.messageId; const result = envelope.payload?.['result']; const isBridgeOperation = this.isBridgeOperation(operation); if (typeof serverId === 'string' && !isBridgeOperation) { 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, result, resultCode: typeof operation === 'string' ? operation : 'completed', }); } } private async handleOperationFailed(envelope: AgentEnvelope): Promise { const serverId = envelope.payload?.['serverId']; const operation = envelope.payload?.['operation'] ?? envelope.payload?.['resultCode']; const messageId = envelope.correlationId ?? envelope.messageId; const isBridgeOperation = this.isBridgeOperation(operation); if (typeof serverId === 'string' && !isBridgeOperation) { 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 isBridgeOperation(operation: unknown): boolean { if (typeof operation !== 'string') { return false; } const normalized = operation.replace(/^server\./, ''); return ( normalized === 'files.list' || normalized === 'files.read' || normalized === 'files.write' || normalized === 'command' || normalized === 'backup.prepare' || normalized === 'backup.release' || normalized === 'world.validate' || normalized === 'world.archive' || normalized === 'world.replace' || normalized === 'storage.upload' || normalized === 'storage.download' || normalized === 'addon.install' || normalized === 'addon.remove' ); } async publishNodeResponse( messageId: string, response: { success: boolean; result?: unknown; resultCode?: string; errorCode?: string; errorMessage?: string; }, ): Promise { 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; } } }