Phase3
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -47,6 +47,6 @@ import { SessionService } from './session.service';
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SessionService, SessionGuard],
|
||||
exports: [AuthService, SessionService, SessionGuard, AuditService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
12
apps/api/src/nodes/node-bridge.module.ts
Normal file
12
apps/api/src/nodes/node-bridge.module.ts
Normal 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 {}
|
||||
85
apps/api/src/nodes/node-bridge.service.ts
Normal file
85
apps/api/src/nodes/node-bridge.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
44
apps/api/src/servers/console/console-session.service.ts
Normal file
44
apps/api/src/servers/console/console-session.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/api/src/servers/console/console.controller.ts
Normal file
36
apps/api/src/servers/console/console.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
150
apps/api/src/servers/console/console.gateway.ts
Normal file
150
apps/api/src/servers/console/console.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/api/src/servers/console/console.module.ts
Normal file
22
apps/api/src/servers/console/console.module.ts
Normal 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 {}
|
||||
74
apps/api/src/servers/console/console.service.ts
Normal file
74
apps/api/src/servers/console/console.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
apps/api/src/servers/files/files.controller.ts
Normal file
68
apps/api/src/servers/files/files.controller.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/servers/files/files.module.ts
Normal file
15
apps/api/src/servers/files/files.module.ts
Normal 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 {}
|
||||
139
apps/api/src/servers/files/files.service.ts
Normal file
139
apps/api/src/servers/files/files.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
apps/api/src/servers/players/players.controller.ts
Normal file
30
apps/api/src/servers/players/players.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
apps/api/src/servers/players/players.module.ts
Normal file
22
apps/api/src/servers/players/players.module.ts
Normal 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 {}
|
||||
109
apps/api/src/servers/players/players.service.ts
Normal file
109
apps/api/src/servers/players/players.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
apps/api/src/servers/properties/properties.controller.ts
Normal file
50
apps/api/src/servers/properties/properties.controller.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
16
apps/api/src/servers/properties/properties.module.ts
Normal file
16
apps/api/src/servers/properties/properties.module.ts
Normal 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 {}
|
||||
88
apps/api/src/servers/properties/properties.service.ts
Normal file
88
apps/api/src/servers/properties/properties.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
89
apps/api/src/servers/properties/server-properties.util.ts
Normal file
89
apps/api/src/servers/properties/server-properties.util.ts
Normal 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,
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
apps/api/src/servers/shared/server-ownership.util.ts
Normal file
27
apps/api/src/servers/shared/server-ownership.util.ts
Normal 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;
|
||||
}
|
||||
20
apps/api/src/servers/shared/server-path.util.ts
Normal file
20
apps/api/src/servers/shared/server-path.util.ts
Normal 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;
|
||||
}
|
||||
111
apps/api/test/phase3.test.js
Normal file
111
apps/api/test/phase3.test.js
Normal 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
Reference in New Issue
Block a user