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

This commit is contained in:
smueller
2026-06-26 12:17:26 +02:00
parent c4077d4673
commit 9b061c3ee7
92 changed files with 4128 additions and 146 deletions

View File

@@ -9,7 +9,7 @@
"start:prod": "node dist/main.js",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js"
"test": "tsc && node --test test/**/*.test.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",

View File

@@ -47,6 +47,6 @@ import { SessionService } from './session.service';
},
},
],
exports: [AuthService, SessionService, SessionGuard],
exports: [AuthService, SessionService, SessionGuard, AuditService],
})
export class AuthModule {}

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
import { NodeBridgeService } from './node-bridge.service';
@Module({
imports: [forwardRef(() => ServersModule)],
providers: [NodeBridgeService],
exports: [NodeBridgeService],
})
export class NodeBridgeModule {}

View File

@@ -0,0 +1,85 @@
import { randomUUID } from 'node:crypto';
import {
GatewayTimeoutException,
Inject,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
export interface AgentResponse {
success: boolean;
result?: unknown;
resultCode?: string;
errorCode?: string;
errorMessage?: string;
}
@Injectable()
export class NodeBridgeService {
constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis,
private readonly serversService: ServersService,
) {}
async sendAgentRequest(
nodeId: string,
type: string,
payload: Record<string, unknown>,
timeoutMs = 30_000,
): Promise<AgentResponse> {
const messageId = randomUUID();
const config = getConfig();
const subscriber = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
const responseChannel = `hgc:node:responses:${messageId}`;
try {
const responsePromise = new Promise<AgentResponse>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new GatewayTimeoutException('Agent request timed out'));
}, timeoutMs);
subscriber.on('message', (channel, message) => {
if (channel !== responseChannel) {
return;
}
clearTimeout(timer);
try {
resolve(JSON.parse(message) as AgentResponse);
} catch {
reject(new ServiceUnavailableException('Invalid agent response'));
}
});
});
await subscriber.subscribe(responseChannel);
await this.serversService.publishNodeCommand(nodeId, {
protocolVersion: 1,
messageId,
type,
timestamp: new Date().toISOString(),
payload: {
...payload,
generation: payload['generation'] ?? 0,
},
});
return await responsePromise;
} finally {
await subscriber.unsubscribe(responseChannel);
await subscriber.quit();
}
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
@@ -7,7 +7,7 @@ import { NodesGateway } from './nodes.gateway';
import { NodesService } from './nodes.service';
@Module({
imports: [ServersModule],
imports: [forwardRef(() => ServersModule)],
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
exports: [NodesService],
})

View File

@@ -13,6 +13,7 @@ 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'];
@@ -47,6 +48,7 @@ export class NodesService {
constructor(
private readonly prisma: PrismaService,
private readonly serversService: ServersService,
private readonly consoleSessionService: ConsoleSessionService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
@@ -145,12 +147,21 @@ export class NodesService {
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'}`);
}
@@ -210,28 +221,53 @@ export class NodesService {
});
}
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'];
const messageId = envelope.messageId;
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') {
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 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',
});
}
@@ -239,9 +275,12 @@ export class NodesService {
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const messageId = envelope.messageId;
const operation =
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
const messageId = envelope.correlationId ?? envelope.messageId;
const isBridgeOperation = this.isBridgeOperation(operation);
if (typeof serverId === 'string') {
if (typeof serverId === 'string' && !isBridgeOperation) {
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
correlationId: envelope.correlationId,
reason: 'agent operation failed',
@@ -271,10 +310,25 @@ export class NodesService {
}
}
private async publishNodeResponse(
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'
);
}
async publishNodeResponse(
messageId: string,
response: {
success: boolean;
result?: unknown;
resultCode?: string;
errorCode?: string;
errorMessage?: string;

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import type { WebSocket } from 'ws';
@Injectable()
export class ConsoleSessionService {
private readonly sessions = new Map<string, Set<WebSocket>>();
subscribe(serverId: string, socket: WebSocket): void {
let sockets = this.sessions.get(serverId);
if (!sockets) {
sockets = new Set();
this.sessions.set(serverId, sockets);
}
sockets.add(socket);
}
unsubscribe(serverId: string, socket: WebSocket): void {
const sockets = this.sessions.get(serverId);
if (!sockets) {
return;
}
sockets.delete(socket);
if (sockets.size === 0) {
this.sessions.delete(serverId);
}
}
broadcast(serverId: string, line: string): void {
const sockets = this.sessions.get(serverId);
if (!sockets) {
return;
}
const payload = JSON.stringify({ type: 'log', line });
for (const socket of sockets) {
if (socket.readyState === socket.OPEN) {
socket.send(payload);
}
}
}
}

View File

@@ -0,0 +1,36 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { ConsoleService } from './console.service';
@ApiTags('servers')
@Controller('servers/:serverId/console')
@UseGuards(SessionGuard)
export class ConsoleController {
constructor(
private readonly consoleService: ConsoleService,
private readonly prisma: PrismaService,
) {}
@Get('token')
@ApiOperation({ summary: 'Issue a short-lived console WebSocket token' })
@ApiResponse({ status: 200, description: 'Console token issued' })
async issueToken(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
await findOwnedServer(this.prisma, user.id, serverId);
return this.consoleService.issueToken(serverId, user.id);
}
}

View File

@@ -0,0 +1,150 @@
import {
Injectable,
Logger,
OnModuleInit,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import type { FastifyRequest } from 'fastify';
import type { WebSocket } from 'ws';
import { consoleCommandSchema } from '@hexahost/contracts';
import { AuditService } from '../../auth/audit.service';
import { NodeBridgeService } from '../../nodes/node-bridge.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { ConsoleSessionService } from './console-session.service';
import { ConsoleService } from './console.service';
@Injectable()
export class ConsoleGateway implements OnModuleInit {
private readonly logger = new Logger(ConsoleGateway.name);
constructor(
private readonly adapterHost: HttpAdapterHost,
private readonly consoleService: ConsoleService,
private readonly consoleSession: ConsoleSessionService,
private readonly nodeBridge: NodeBridgeService,
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
) {}
onModuleInit(): void {
const fastify = this.adapterHost.httpAdapter.getInstance();
fastify.get(
'/api/v1/servers/:serverId/console/ws',
{ websocket: true },
(socket: WebSocket, request: FastifyRequest) => {
void this.handleConnection(socket, request);
},
);
}
private async handleConnection(
socket: WebSocket,
request: FastifyRequest,
): Promise<void> {
const params = request.params as { serverId?: string };
const query = request.query as Record<string, string | string[] | undefined>;
const serverId = params.serverId;
const token = this.readQueryParam(query['token']);
if (!serverId || !token) {
socket.close(4400, 'serverId and token are required');
return;
}
let userId: string;
try {
({ userId } = this.consoleService.validateToken(token, serverId));
await findOwnedServer(this.prisma, userId, serverId);
} catch (error) {
const reason =
error instanceof Error ? error.message : 'Console authorization failed';
socket.close(4401, reason);
return;
}
this.consoleSession.subscribe(serverId, socket);
socket.on('close', () => {
this.consoleSession.unsubscribe(serverId, socket);
});
socket.on('message', (data) => {
void this.handleCommand(serverId, userId, data.toString());
});
socket.on('error', (error) => {
this.logger.error(
`Console WebSocket error for server ${serverId}: ${error.message}`,
);
});
socket.send(JSON.stringify({ type: 'connected', serverId }));
}
private async handleCommand(
serverId: string,
userId: string,
raw: string,
): Promise<void> {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = consoleCommandSchema.safeParse(parsed);
if (!result.success) {
return;
}
const server = await this.prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server?.nodeId || server.status !== 'RUNNING') {
return;
}
try {
await this.nodeBridge.sendAgentRequest(
server.nodeId,
'server.command',
{
serverId,
generation: server.version,
command: result.data.command,
},
15_000,
);
await this.auditService.record({
action: 'server.console.command',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { command: result.data.command },
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.logger.warn(`Console command failed for ${serverId}: ${detail}`);
}
}
private readQueryParam(
value: string | string[] | undefined,
): string | undefined {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
}

View File

@@ -0,0 +1,22 @@
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { ConsoleController } from './console.controller';
import { ConsoleGateway } from './console.gateway';
import { ConsoleSessionService } from './console-session.service';
import { ConsoleService } from './console.service';
@Module({
imports: [AuthModule, PrismaModule, forwardRef(() => NodeBridgeModule)],
controllers: [ConsoleController],
providers: [
ConsoleSessionService,
ConsoleService,
ConsoleGateway,
],
exports: [ConsoleSessionService, ConsoleService],
})
export class ConsoleModule {}

View File

@@ -0,0 +1,74 @@
import { randomBytes } from 'node:crypto';
import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { hashToken, verifyTokenHash } from '@hexahost/auth';
import type { ConsoleTokenResponse } from '@hexahost/contracts';
import { getConfig } from '@hexahost/config';
interface ConsoleTokenRecord {
serverId: string;
userId: string;
tokenHash: string;
expiresAt: Date;
}
const TOKEN_TTL_MS = 5 * 60 * 1000;
@Injectable()
export class ConsoleService {
private readonly tokens = new Map<string, ConsoleTokenRecord>();
issueToken(serverId: string, userId: string): ConsoleTokenResponse {
const token = randomBytes(32).toString('base64url');
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);
this.tokens.set(tokenHash, {
serverId,
userId,
tokenHash,
expiresAt,
});
const config = getConfig();
const wsUrl = `${config.API_URL.replace(/^http/, 'ws')}/api/v1/servers/${serverId}/console/ws`;
return {
token,
expiresAt: expiresAt.toISOString(),
wsUrl,
};
}
validateToken(
token: string,
serverId: string,
): { userId: string } {
const now = new Date();
this.pruneExpired(now);
for (const record of this.tokens.values()) {
if (
record.serverId === serverId &&
record.expiresAt > now &&
verifyTokenHash(token, record.tokenHash)
) {
return { userId: record.userId };
}
}
throw new UnauthorizedException('Invalid or expired console token');
}
private pruneExpired(now: Date): void {
for (const [hash, record] of this.tokens.entries()) {
if (record.expiresAt <= now) {
this.tokens.delete(hash);
}
}
}
}

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
listFilesQuerySchema,
readFileContentQuerySchema,
updateFileContentSchema,
} 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 { FilesService } from './files.service';
@ApiTags('servers')
@Controller('servers/:serverId/files')
@UseGuards(SessionGuard)
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get()
@ApiOperation({ summary: 'List files in the server data directory' })
@ApiResponse({ status: 200, description: 'Directory listing' })
list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Query(new ZodValidationPipe(listFilesQuerySchema)) query: { path: string },
) {
return this.filesService.listFiles(user.id, serverId, query.path);
}
@Get('content')
@ApiOperation({ summary: 'Read a text file from the server' })
@ApiResponse({ status: 200, description: 'File content' })
read(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Query(new ZodValidationPipe(readFileContentQuerySchema))
query: { path: string },
) {
return this.filesService.readFile(user.id, serverId, query.path);
}
@Put('content')
@ApiOperation({ summary: 'Write a text file on the server' })
@ApiResponse({ status: 200, description: 'Updated file content' })
write(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(updateFileContentSchema)) body: unknown,
) {
return this.filesService.writeFile(
user.id,
serverId,
body as Parameters<FilesService['writeFile']>[2],
);
}
}

View File

@@ -0,0 +1,15 @@
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
@Module({
imports: [AuthModule, forwardRef(() => NodeBridgeModule)],
controllers: [FilesController],
providers: [FilesService],
exports: [FilesService],
})
export class FilesModule {}

View File

@@ -0,0 +1,139 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import type {
FileContent,
FileListResponse,
UpdateFileContent,
} from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
import { AuditService } from '../../auth/audit.service';
import { NodeBridgeService } from '../../nodes/node-bridge.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { assertSafeServerPath } from '../shared/server-path.util';
const FILE_ACCESS_STATUSES = new Set<GameServer['status']>(['RUNNING', 'STOPPED']);
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaService,
private readonly nodeBridge: NodeBridgeService,
private readonly auditService: AuditService,
) {}
async listFiles(
userId: string,
serverId: string,
path: string,
): Promise<FileListResponse> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.list',
{
serverId,
generation: server.version,
path: safePath,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to list files',
);
}
return response.result as FileListResponse;
}
async readFile(
userId: string,
serverId: string,
path: string,
): Promise<FileContent> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.read',
{
serverId,
generation: server.version,
path: safePath,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to read file',
);
}
return response.result as FileContent;
}
async writeFile(
userId: string,
serverId: string,
input: UpdateFileContent,
): Promise<FileContent> {
const server = await this.requireFileAccess(userId, serverId);
const safePath = assertSafeServerPath(input.path);
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.files.write',
{
serverId,
generation: server.version,
path: safePath,
content: input.content,
encoding: input.encoding,
},
);
if (!response.success) {
throw new ServiceUnavailableException(
response.errorMessage ?? 'Failed to write file',
);
}
await this.auditService.record({
action: 'server.file.write',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { path: safePath },
});
return response.result as FileContent;
}
private async requireFileAccess(
userId: string,
serverId: string,
): Promise<GameServer> {
const server = await findOwnedServer(this.prisma, userId, serverId);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
if (!FILE_ACCESS_STATUSES.has(server.status)) {
throw new BadRequestException(
`File access is not available while server is ${server.status}`,
);
}
return server;
}
}

View File

@@ -0,0 +1,30 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { PlayersService } from './players.service';
@ApiTags('servers')
@Controller('servers/:serverId/players')
@UseGuards(SessionGuard)
export class PlayersController {
constructor(private readonly playersService: PlayersService) {}
@Get()
@ApiOperation({ summary: 'List online players, whitelist, and operators' })
@ApiResponse({ status: 200, description: 'Player overview' })
list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.playersService.listPlayers(user.id, serverId);
}
}

View File

@@ -0,0 +1,22 @@
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { FilesModule } from '../files/files.module';
import { PlayersController } from './players.controller';
import { PlayersService } from './players.service';
@Module({
imports: [
AuthModule,
PrismaModule,
forwardRef(() => NodeBridgeModule),
forwardRef(() => FilesModule),
],
controllers: [PlayersController],
providers: [PlayersService],
exports: [PlayersService],
})
export class PlayersModule {}

View File

@@ -0,0 +1,109 @@
import {
BadRequestException,
Injectable,
} from '@nestjs/common';
import type { PlayerListResponse } from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
import { NodeBridgeService } from '../../nodes/node-bridge.service';
import { PrismaService } from '../../prisma/prisma.service';
import { FilesService } from '../files/files.service';
import { findOwnedServer } from '../shared/server-ownership.util';
const WHITELIST_FILE = 'whitelist.json';
const OPS_FILE = 'ops.json';
@Injectable()
export class PlayersService {
constructor(
private readonly prisma: PrismaService,
private readonly nodeBridge: NodeBridgeService,
private readonly filesService: FilesService,
) {}
async listPlayers(
userId: string,
serverId: string,
): Promise<PlayerListResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
const [online, whitelist, operators] = await Promise.all([
this.fetchOnlinePlayers(server),
this.readJsonFile(userId, serverId, WHITELIST_FILE, []),
this.readJsonFile(userId, serverId, OPS_FILE, []),
]);
return {
online,
whitelist,
operators,
};
}
private async fetchOnlinePlayers(
server: GameServer,
): Promise<PlayerListResponse['online']> {
if (server.status !== 'RUNNING') {
return [];
}
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId!,
'server.command',
{
serverId: server.id,
generation: server.version,
command: 'list',
},
10_000,
);
if (!response.success) {
return [];
}
const output =
typeof response.result === 'object' &&
response.result !== null &&
'output' in response.result &&
typeof (response.result as { output: unknown }).output === 'string'
? (response.result as { output: string }).output
: typeof response.result === 'string'
? response.result
: '';
return this.parseListCommandOutput(output);
}
private parseListCommandOutput(output: string): PlayerListResponse['online'] {
const match = output.match(/There are \d+ of a max(?:imum)? of \d+ players online:(.*)$/i);
if (!match?.[1]?.trim()) {
return [];
}
return match[1]
.split(',')
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({ name }));
}
private async readJsonFile<T>(
userId: string,
serverId: string,
path: string,
fallback: T,
): Promise<T> {
try {
const file = await this.filesService.readFile(userId, serverId, path);
return JSON.parse(file.content) as T;
} catch {
return fallback;
}
}
}

View File

@@ -0,0 +1,50 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { updateServerPropertiesSchema } 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 { PropertiesService } from './properties.service';
@ApiTags('servers')
@Controller('servers/:serverId/properties')
@UseGuards(SessionGuard)
export class PropertiesController {
constructor(private readonly propertiesService: PropertiesService) {}
@Get()
@ApiOperation({ summary: 'Get validated server.properties values' })
@ApiResponse({ status: 200, description: 'Server properties' })
get(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
) {
return this.propertiesService.getProperties(user.id, serverId);
}
@Patch()
@ApiOperation({ summary: 'Update server.properties values' })
@ApiResponse({ status: 200, description: 'Updated server properties' })
patch(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body(new ZodValidationPipe(updateServerPropertiesSchema)) body: unknown,
) {
return this.propertiesService.updateProperties(
user.id,
serverId,
body as Parameters<PropertiesService['updateProperties']>[2],
);
}
}

View File

@@ -0,0 +1,16 @@
import { Module, forwardRef } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { FilesModule } from '../files/files.module';
import { PropertiesController } from './properties.controller';
import { PropertiesService } from './properties.service';
@Module({
imports: [AuthModule, PrismaModule, forwardRef(() => FilesModule)],
controllers: [PropertiesController],
providers: [PropertiesService],
exports: [PropertiesService],
})
export class PropertiesModule {}

View File

@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import {
serverPropertiesSchema,
updateServerPropertiesSchema,
type ServerPropertiesResponse,
type UpdateServerProperties,
} from '@hexahost/contracts';
import { AuditService } from '../../auth/audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { FilesService } from '../files/files.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import {
PROPERTIES_FILE,
parsePropertiesFile,
serializePropertiesFile,
} from './server-properties.util';
@Injectable()
export class PropertiesService {
constructor(
private readonly prisma: PrismaService,
private readonly filesService: FilesService,
private readonly auditService: AuditService,
) {}
async getProperties(
userId: string,
serverId: string,
): Promise<ServerPropertiesResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const file = await this.filesService.readFile(
userId,
serverId,
PROPERTIES_FILE,
);
const parsed = parsePropertiesFile(file.content);
const properties = serverPropertiesSchema.parse(parsed);
return {
properties,
raw: file.content,
};
}
async updateProperties(
userId: string,
serverId: string,
patch: UpdateServerProperties,
): Promise<ServerPropertiesResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const validatedPatch = updateServerPropertiesSchema.parse(patch);
const current = await this.filesService.readFile(
userId,
serverId,
PROPERTIES_FILE,
);
const merged = {
...parsePropertiesFile(current.content),
...validatedPatch,
};
const properties = serverPropertiesSchema.parse(merged);
const raw = serializePropertiesFile(properties, current.content);
await this.filesService.writeFile(userId, serverId, {
path: PROPERTIES_FILE,
content: raw,
encoding: 'utf-8',
});
await this.auditService.record({
action: 'server.properties.update',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { keys: Object.keys(validatedPatch) },
});
return { properties, raw };
}
}

View File

@@ -0,0 +1,89 @@
const PROPERTIES_FILE = 'server.properties';
function parsePropertiesFile(raw: string): Record<string, string | number | boolean> {
const result: Record<string, string | number | boolean> = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const separator = trimmed.indexOf('=');
if (separator === -1) {
continue;
}
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
result[key] = coercePropertyValue(value);
}
return result;
}
function coercePropertyValue(value: string): string | number | boolean {
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
const asNumber = Number(value);
if (value !== '' && !Number.isNaN(asNumber)) {
return asNumber;
}
return value;
}
function serializePropertiesFile(
properties: Record<string, string | number | boolean>,
originalRaw: string,
): string {
const knownKeys = new Set(Object.keys(properties));
const lines: string[] = [];
for (const line of originalRaw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
lines.push(line);
continue;
}
const separator = trimmed.indexOf('=');
if (separator === -1) {
lines.push(line);
continue;
}
const key = trimmed.slice(0, separator).trim();
if (knownKeys.has(key)) {
lines.push(`${key}=${formatPropertyValue(properties[key]!)}`);
knownKeys.delete(key);
} else {
lines.push(line);
}
}
for (const key of knownKeys) {
lines.push(`${key}=${formatPropertyValue(properties[key]!)}`);
}
return lines.join('\n');
}
function formatPropertyValue(value: string | number | boolean): string {
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
}
export {
PROPERTIES_FILE,
parsePropertiesFile,
serializePropertiesFile,
};

View File

@@ -7,6 +7,7 @@ import {
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -73,4 +74,15 @@ export class ServersController {
) {
return this.serversService.stopServer(user.id, id);
}
@Post(':id/restart')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Restart a game server' })
restart(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Query('skip') skip?: string,
) {
return this.serversService.restartServer(user.id, id, skip === 'true');
}
}

View File

@@ -1,12 +1,17 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { AuthModule } from '../auth/auth.module';
import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module';
import { ConsoleModule } from './console/console.module';
import { FilesModule } from './files/files.module';
import { PlayersModule } from './players/players.module';
import { PropertiesModule } from './properties/properties.module';
import { ServerStateService } from './server-state.service';
import {
REDIS_CLIENT,
@@ -19,7 +24,15 @@ import { PlansController } from './plans.controller';
import { PlansService } from './plans.service';
@Module({
imports: [AppConfigModule, AuthModule],
imports: [
AppConfigModule,
AuthModule,
forwardRef(() => ConsoleModule),
forwardRef(() => FilesModule),
forwardRef(() => PropertiesModule),
forwardRef(() => PlayersModule),
forwardRef(() => NodeBridgeModule),
],
controllers: [ServersController, PlansController],
providers: [
ServersService,
@@ -59,6 +72,11 @@ import { PlansService } from './plans.service';
},
},
],
exports: [ServersService, REDIS_CLIENT],
exports: [
ServersService,
REDIS_CLIENT,
ConsoleModule,
NodeBridgeModule,
],
})
export class ServersModule {}

View File

@@ -2,10 +2,8 @@ import { randomUUID } from 'node:crypto';
import {
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type Redis from 'ioredis';
@@ -22,6 +20,7 @@ type GameServerStatus = GameServer['status'];
import { PrismaService } from '../prisma/prisma.service';
import { findOwnedServer } from './shared/server-ownership.util';
import { ServerStateService } from './server-state.service';
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
@@ -77,15 +76,30 @@ export class ServersService {
}
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
const server = await this.findOwnedServer(userId, serverId);
const server = await findOwnedServer(this.prisma, userId, serverId);
return this.toResponse(server);
}
async restartServer(
userId: string,
serverId: string,
skipStop = false,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (!skipStop && server.status === 'RUNNING') {
await this.stopServer(userId, serverId);
}
return this.startServer(userId, serverId);
}
async startServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId);
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status);
@@ -143,7 +157,7 @@ export class ServersService {
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId);
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStopTarget(server.status);
@@ -225,25 +239,6 @@ export class ServersService {
);
}
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,

View File

@@ -0,0 +1,27 @@
import {
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import type { GameServer } from '@hexahost/database';
import type { PrismaService } from '../../prisma/prisma.service';
export async function findOwnedServer(
prisma: PrismaService,
userId: string,
serverId: string,
): Promise<GameServer> {
const server = await 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;
}

View File

@@ -0,0 +1,20 @@
import { BadRequestException } from '@nestjs/common';
export function assertSafeServerPath(path: string): string {
const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized === '.') {
return '.';
}
if (normalized.includes('\0')) {
throw new BadRequestException('Invalid path');
}
const segments = normalized.split('/');
if (segments.some((segment) => segment === '..' || segment === '')) {
throw new BadRequestException('Path traversal is not allowed');
}
return normalized;
}

View File

@@ -0,0 +1,111 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
parsePropertiesFile,
serializePropertiesFile,
} from '../dist/servers/properties/server-properties.util.js';
import { assertSafeServerPath } from '../dist/servers/shared/server-path.util.js';
describe('phase 3 server utilities', () => {
it('parses server.properties with types', () => {
const raw = [
'# comment',
'max-players=20',
'online-mode=false',
'motd=Hello World',
'',
].join('\n');
const parsed = parsePropertiesFile(raw);
assert.equal(parsed['max-players'], 20);
assert.equal(parsed['online-mode'], false);
assert.equal(parsed['motd'], 'Hello World');
});
it('serializes server.properties while preserving comments', () => {
const raw = '# header\nmax-players=10\nonline-mode=true\n';
const updated = parsePropertiesFile(raw);
updated['max-players'] = 25;
updated['online-mode'] = false;
const serialized = serializePropertiesFile(updated, raw);
assert.match(serialized, /^# header/);
assert.match(serialized, /max-players=25/);
assert.match(serialized, /online-mode=false/);
});
it('rejects path traversal', () => {
assert.throws(
() => assertSafeServerPath('../etc/passwd'),
(error) => error.message.includes('traversal'),
);
assert.throws(() => assertSafeServerPath('foo//bar'));
});
it('normalizes safe paths', () => {
assert.equal(assertSafeServerPath(''), '.');
assert.equal(assertSafeServerPath('logs/latest.log'), 'logs/latest.log');
assert.equal(assertSafeServerPath('\\world\\level.dat'), 'world/level.dat');
});
});
describe('phase 3 api contract', () => {
it('documents console, files, properties and players routes', () => {
const files = [
'servers/console/console.controller.ts',
'servers/files/files.controller.ts',
'servers/properties/properties.controller.ts',
'servers/players/players.controller.ts',
'servers/console/console.gateway.ts',
];
for (const file of files) {
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
assert.ok(source.length > 0, `${file} should exist`);
}
const consoleSource = readFileSync(
join(process.cwd(), 'src', 'servers/console/console.controller.ts'),
'utf8',
);
assert.ok(consoleSource.includes("@Get('token')"));
const gatewaySource = readFileSync(
join(process.cwd(), 'src', 'servers/console/console.gateway.ts'),
'utf8',
);
assert.ok(gatewaySource.includes('console/ws'));
const filesSource = readFileSync(
join(process.cwd(), 'src', 'servers/files/files.controller.ts'),
'utf8',
);
assert.ok(filesSource.includes("@Get('content')"));
const propertiesSource = readFileSync(
join(process.cwd(), 'src', 'servers/properties/properties.controller.ts'),
'utf8',
);
assert.ok(propertiesSource.includes("@Patch()"));
const playersSource = readFileSync(
join(process.cwd(), 'src', 'servers/players/players.controller.ts'),
'utf8',
);
assert.ok(playersSource.includes('whitelist'));
const combined = [
consoleSource,
gatewaySource,
filesSource,
propertiesSource,
playersSource,
].join('\n');
for (const snippet of ['files', 'properties', 'players']) {
assert.ok(combined.includes(snippet), `missing route hint ${snippet}`);
}
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long