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

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