367 lines
10 KiB
TypeScript
367 lines
10 KiB
TypeScript
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<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,
|
|
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<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.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.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<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 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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'
|
|
);
|
|
}
|
|
|
|
async publishNodeResponse(
|
|
messageId: string,
|
|
response: {
|
|
success: boolean;
|
|
result?: unknown;
|
|
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;
|
|
}
|
|
}
|
|
}
|