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

View File

@@ -14,7 +14,7 @@ import (
"github.com/hexahost/gamecloud/node-agent/internal/ws"
)
const Version = "0.2.0-phase2"
const Version = "0.3.0-phase3"
// Agent coordinates the node agent lifecycle until shutdown.
type Agent struct {

View File

@@ -1,8 +1,11 @@
package docker
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"strconv"
"strings"
@@ -10,6 +13,7 @@ import (
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
)
@@ -189,3 +193,134 @@ func mapSoftwareFamily(family string) string {
return "VANILLA"
}
}
// StreamLogs streams container logs and invokes callback for each line.
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
opts := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: follow,
Timestamps: false,
}
if tail > 0 {
opts.Tail = strconv.Itoa(tail)
} else {
opts.Tail = "all"
}
reader, err := c.cli.ContainerLogs(ctx, containerID, opts)
if err != nil {
return fmt.Errorf("container logs: %w", err)
}
defer reader.Close()
return readMultiplexedLogLines(ctx, reader, callback)
}
// ExecCommand runs a command inside a container and returns stdout/stderr.
func (c *Client) ExecCommand(ctx context.Context, containerID string, cmd []string) (stdout, stderr string, err error) {
execResp, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", "", fmt.Errorf("exec create: %w", err)
}
attachResp, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
if err != nil {
return "", "", fmt.Errorf("exec attach: %w", err)
}
defer attachResp.Close()
var outBuf, errBuf bytes.Buffer
if _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader); err != nil {
return outBuf.String(), errBuf.String(), fmt.Errorf("exec copy: %w", err)
}
inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
return outBuf.String(), errBuf.String(), fmt.Errorf("exec inspect: %w", err)
}
if inspect.ExitCode != 0 {
msg := strings.TrimSpace(errBuf.String())
if msg == "" {
msg = strings.TrimSpace(outBuf.String())
}
if msg == "" {
msg = fmt.Sprintf("exit code %d", inspect.ExitCode)
}
return outBuf.String(), errBuf.String(), fmt.Errorf("exec failed: %s", msg)
}
return outBuf.String(), errBuf.String(), nil
}
// ExecRcon runs a Minecraft console command via rcon-cli inside the container.
func (c *Client) ExecRcon(ctx context.Context, containerID, command string) (stdout, stderr string, err error) {
return c.ExecCommand(ctx, containerID, []string{"rcon-cli", command})
}
func readMultiplexedLogLines(ctx context.Context, r io.Reader, callback func(stream, line string)) error {
header := make([]byte, 8)
var partial []byte
var partialStream string
for {
if err := ctx.Err(); err != nil {
return err
}
_, err := io.ReadFull(r, header)
if err == io.EOF {
break
}
if err != nil {
return err
}
stream := "stdout"
if header[0] == 2 {
stream = "stderr"
}
size := binary.BigEndian.Uint32(header[4:8])
payload := make([]byte, size)
if _, err := io.ReadFull(r, payload); err != nil {
return err
}
if partialStream != "" && partialStream != stream {
if len(partial) > 0 {
callback(partialStream, string(partial))
partial = nil
}
}
partialStream = stream
partial = append(partial, payload...)
for {
idx := bytes.IndexByte(partial, '\n')
if idx < 0 {
break
}
line := string(partial[:idx])
partial = partial[idx+1:]
line = strings.TrimSuffix(line, "\r")
if line != "" {
callback(stream, line)
}
}
}
if len(partial) > 0 {
stream := partialStream
if stream == "" {
stream = "stdout"
}
callback(stream, string(partial))
}
return nil
}

View File

@@ -0,0 +1,280 @@
package files
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const DefaultMaxReadBytes = 1 << 20 // 1MB
var (
ErrPathTraversal = errors.New("path escapes data root")
ErrSymlink = errors.New("symlinks are not allowed")
ErrNotFound = errors.New("file not found")
ErrNotDirectory = errors.New("path is not a directory")
ErrIsDirectory = errors.New("path is a directory")
)
// DirEntry describes a file or directory entry returned by ListDir.
type DirEntry struct {
Name string
Path string
IsDir bool
Size int64
ModifiedAt time.Time
}
// ResolvePath resolves relativePath under dataRoot and rejects path traversal.
func ResolvePath(dataRoot, relativePath string) (string, error) {
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {
return "", fmt.Errorf("resolve data root: %w", err)
}
rel := strings.TrimPrefix(relativePath, "/")
rel = strings.TrimPrefix(rel, `\`)
rel = filepath.Clean(rel)
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", ErrPathTraversal
}
resolved := filepath.Join(root, rel)
resolved, err = filepath.Abs(resolved)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) {
return "", ErrPathTraversal
}
return resolved, nil
}
// ListDir lists entries in a directory relative to dataRoot.
func ListDir(dataRoot, relativePath string) ([]DirEntry, error) {
dirPath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return nil, err
}
if err := rejectSymlinks(dataRoot, dirPath); err != nil {
return nil, err
}
info, err := os.Lstat(dirPath)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, err
}
if !info.IsDir() {
return nil, ErrNotDirectory
}
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, err
}
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {
return nil, err
}
result := make([]DirEntry, 0, len(entries))
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if err := rejectSymlinks(root, entryPath); err != nil {
continue
}
entryInfo, err := entry.Info()
if err != nil {
return nil, err
}
rel, err := filepath.Rel(root, entryPath)
if err != nil {
return nil, err
}
rel = filepath.ToSlash(rel)
result = append(result, DirEntry{
Name: entry.Name(),
Path: rel,
IsDir: entry.IsDir(),
Size: entryInfo.Size(),
ModifiedAt: entryInfo.ModTime().UTC(),
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].IsDir != result[j].IsDir {
return result[i].IsDir
}
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
})
return result, nil
}
// ReadFile reads file content relative to dataRoot up to maxBytes.
func ReadFile(dataRoot, relativePath string, maxBytes int) (content string, truncated bool, err error) {
if maxBytes <= 0 {
maxBytes = DefaultMaxReadBytes
}
filePath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return "", false, err
}
if err := rejectSymlinks(dataRoot, filePath); err != nil {
return "", false, err
}
info, err := os.Lstat(filePath)
if err != nil {
if os.IsNotExist(err) {
return "", false, ErrNotFound
}
return "", false, err
}
if info.IsDir() {
return "", false, ErrIsDirectory
}
if info.Mode()&os.ModeSymlink != 0 {
return "", false, ErrSymlink
}
f, err := os.Open(filePath)
if err != nil {
return "", false, err
}
defer f.Close()
limited := io.LimitReader(f, int64(maxBytes)+1)
data, err := io.ReadAll(limited)
if err != nil {
return "", false, err
}
if len(data) > maxBytes {
truncated = true
data = data[:maxBytes]
}
return string(data), truncated, nil
}
// WriteFile atomically writes content to a path relative to dataRoot.
func WriteFile(dataRoot, relativePath, content string, create bool) error {
filePath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return err
}
if _, err := os.Lstat(filePath); err != nil {
if os.IsNotExist(err) {
if !create {
return ErrNotFound
}
} else {
return err
}
} else if !create {
if err := rejectSymlinks(dataRoot, filePath); err != nil {
return err
}
}
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".hexahost-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
if _, err := tmp.WriteString(content); err != nil {
cleanup()
return err
}
if err := tmp.Sync(); err != nil {
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpPath, filePath); err != nil {
cleanup()
return err
}
return nil
}
func rejectSymlinks(dataRoot, target string) error {
root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil {
return err
}
target, err = filepath.Abs(target)
if err != nil {
return err
}
if target == root {
info, err := os.Lstat(target)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %s", ErrSymlink, target)
}
return nil
}
rel, err := filepath.Rel(root, target)
if err != nil {
return err
}
current := root
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if part == "" || part == "." {
continue
}
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%w: %s", ErrSymlink, current)
}
}
return nil
}

View File

@@ -0,0 +1,94 @@
package files
import (
"os"
"path/filepath"
"testing"
)
func TestResolvePath_BlocksTraversal(t *testing.T) {
root := t.TempDir()
tests := []string{
"../outside",
"../../etc/passwd",
"foo/../../outside",
}
for _, rel := range tests {
t.Run(rel, func(t *testing.T) {
_, err := ResolvePath(root, rel)
if err == nil {
t.Fatalf("expected path traversal error for %q", rel)
}
if err != ErrPathTraversal {
t.Fatalf("expected ErrPathTraversal, got %v", err)
}
})
}
}
func TestResolvePath_AllowsNestedPaths(t *testing.T) {
root := t.TempDir()
nested := filepath.Join(root, "world", "region")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
resolved, err := ResolvePath(root, "world/region")
if err != nil {
t.Fatalf("resolve nested path: %v", err)
}
if resolved != nested {
t.Fatalf("resolved %q want %q", resolved, nested)
}
}
func TestReadFile_BlocksTraversal(t *testing.T) {
root := t.TempDir()
secret := filepath.Join(filepath.Dir(root), "secret.txt")
if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(secret) })
_, _, err := ReadFile(root, "../"+filepath.Base(secret))
if err == nil {
t.Fatal("expected path traversal error")
}
if err != ErrPathTraversal {
t.Fatalf("expected ErrPathTraversal, got %v", err)
}
}
func TestWriteFile_AtomicWrite(t *testing.T) {
root := t.TempDir()
rel := "server.properties"
if err := WriteFile(root, rel, "motd=Hello", true); err != nil {
t.Fatalf("write file: %v", err)
}
content, truncated, err := ReadFile(root, rel, 0)
if err != nil {
t.Fatalf("read file: %v", err)
}
if truncated {
t.Fatal("did not expect truncated read")
}
if content != "motd=Hello" {
t.Fatalf("content %q want %q", content, "motd=Hello")
}
}
func TestListDir_RejectsTraversal(t *testing.T) {
root := t.TempDir()
_, err := ListDir(root, "..")
if err == nil {
t.Fatal("expected path traversal error")
}
if err != ErrPathTraversal {
t.Fatalf("expected ErrPathTraversal, got %v", err)
}
}

View File

@@ -137,12 +137,76 @@ type ServerStopPayload struct {
OperationMeta
}
// ServerCommandPayload instructs the agent to run a console command via RCON.
type ServerCommandPayload struct {
OperationMeta
Command string `json:"command"`
}
// ServerLogsSubscribePayload instructs the agent to stream container logs.
type ServerLogsSubscribePayload struct {
OperationMeta
TailLines int `json:"tailLines"`
Follow bool `json:"follow"`
}
// ServerLogPayload carries a single log line from a game server.
type ServerLogPayload struct {
OperationMeta
Line string `json:"line"`
Stream string `json:"stream"`
}
// ServerFilesListPayload lists files in a server data directory.
type ServerFilesListPayload struct {
OperationMeta
Path string `json:"path"`
}
// ServerFilesReadPayload reads a file from the server data directory.
type ServerFilesReadPayload struct {
OperationMeta
Path string `json:"path"`
MaxBytes int `json:"maxBytes"`
}
// ServerFilesWritePayload writes content to a file in the server data directory.
type ServerFilesWritePayload struct {
OperationMeta
Path string `json:"path"`
Content string `json:"content"`
Create bool `json:"create"`
}
// FileEntry describes a file or directory in a list response.
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
// ServerFilesListResultPayload is the result of a files.list operation.
type ServerFilesListResultPayload struct {
Entries []FileEntry `json:"entries"`
}
// ServerFilesReadResultPayload is the result of a files.read operation.
type ServerFilesReadResultPayload struct {
Path string `json:"path"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
}
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
type ServerOperationResultPayload struct {
OperationMeta
ResultCode string `json:"resultCode"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ResultCode string `json:"resultCode"`
Operation string `json:"operation,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// ServerStatePayload reports the observed server state.

View File

@@ -212,3 +212,168 @@ func TestNewEnvelope(t *testing.T) {
t.Fatalf("protocol version %d", env.ProtocolVersion)
}
}
func TestPhase3Payloads_JSONRoundtrip(t *testing.T) {
meta := OperationMeta{
ServerID: "srv-01",
Generation: 3,
}
command := ServerCommandPayload{
OperationMeta: meta,
Command: "say hello",
}
commandRaw, err := json.Marshal(command)
if err != nil {
t.Fatal(err)
}
var decodedCommand ServerCommandPayload
if err := json.Unmarshal(commandRaw, &decodedCommand); err != nil {
t.Fatal(err)
}
if decodedCommand.Command != command.Command {
t.Fatalf("command: got %q want %q", decodedCommand.Command, command.Command)
}
logsSub := ServerLogsSubscribePayload{
OperationMeta: meta,
TailLines: 100,
Follow: true,
}
logsSubRaw, err := json.Marshal(logsSub)
if err != nil {
t.Fatal(err)
}
var decodedLogsSub ServerLogsSubscribePayload
if err := json.Unmarshal(logsSubRaw, &decodedLogsSub); err != nil {
t.Fatal(err)
}
if decodedLogsSub.TailLines != 100 || !decodedLogsSub.Follow {
t.Fatalf("logs subscribe mismatch: %+v", decodedLogsSub)
}
filesList := ServerFilesListPayload{
OperationMeta: meta,
Path: "world",
}
filesListRaw, err := json.Marshal(filesList)
if err != nil {
t.Fatal(err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-files-list",
Type: TypeServerFilesList,
Timestamp: time.Now().UTC(),
Payload: filesListRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("files list envelope: %v", err)
}
filesRead := ServerFilesReadPayload{
OperationMeta: meta,
Path: "server.properties",
MaxBytes: 4096,
}
filesReadRaw, err := json.Marshal(filesRead)
if err != nil {
t.Fatal(err)
}
env = &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-files-read",
Type: TypeServerFilesRead,
Timestamp: time.Now().UTC(),
Payload: filesReadRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("files read envelope: %v", err)
}
filesWrite := ServerFilesWritePayload{
OperationMeta: meta,
Path: "ops.txt",
Content: "test",
Create: true,
}
filesWriteRaw, err := json.Marshal(filesWrite)
if err != nil {
t.Fatal(err)
}
env = &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-files-write",
Type: TypeServerFilesWrite,
Timestamp: time.Now().UTC(),
Payload: filesWriteRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("files write envelope: %v", err)
}
logLine := ServerLogPayload{
OperationMeta: meta,
Line: "[Server] Done",
Stream: "stdout",
}
logLineRaw, err := json.Marshal(logLine)
if err != nil {
t.Fatal(err)
}
env = &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-log",
Type: TypeServerLog,
Timestamp: time.Now().UTC(),
Payload: logLineRaw,
}
if err := env.Validate(); err != nil {
t.Fatalf("server log envelope: %v", err)
}
}
func TestServerOperationResultPayload_WithResult(t *testing.T) {
listResult, err := json.Marshal(ServerFilesListResultPayload{
Entries: []FileEntry{{
Name: "server.properties",
Path: "server.properties",
IsDir: false,
Size: 128,
ModifiedAt: "2026-06-26T12:00:00Z",
}},
})
if err != nil {
t.Fatal(err)
}
payload := ServerOperationResultPayload{
OperationMeta: OperationMeta{ServerID: "srv-01", Generation: 1},
ResultCode: "ok",
Operation: "files.list",
Result: listResult,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded ServerOperationResultPayload
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Operation != "files.list" {
t.Fatalf("operation: got %q", decoded.Operation)
}
if len(decoded.Result) == 0 {
t.Fatal("expected result payload")
}
var result ServerFilesListResultPayload
if err := json.Unmarshal(decoded.Result, &result); err != nil {
t.Fatal(err)
}
if len(result.Entries) != 1 || result.Entries[0].Name != "server.properties" {
t.Fatalf("unexpected list result: %+v", result)
}
}

View File

@@ -2,11 +2,13 @@ package runtime
import (
"context"
"fmt"
"encoding/json"
"errors"
"log/slog"
"sync"
"github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/files"
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
)
@@ -27,6 +29,7 @@ const (
// Responder sends protocol responses back to the control plane.
type Responder interface {
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
}
@@ -42,20 +45,22 @@ type serverRecord struct {
// Manager tracks provisioned servers and executes lifecycle operations.
type Manager struct {
mu sync.RWMutex
docker *docker.Client
servers map[string]*serverRecord
resp Responder
log *slog.Logger
mu sync.RWMutex
docker *docker.Client
servers map[string]*serverRecord
logStreams map[string]context.CancelFunc
resp Responder
log *slog.Logger
}
// NewManager creates a runtime manager backed by the given Docker client.
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
return &Manager{
docker: dockerClient,
servers: make(map[string]*serverRecord),
resp: resp,
log: log,
docker: dockerClient,
servers: make(map[string]*serverRecord),
logStreams: make(map[string]context.CancelFunc),
resp: resp,
log: log,
}
}
@@ -184,6 +189,8 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
m.stopLogStream(meta.ServerID)
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
@@ -204,6 +211,147 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
m.complete(ctx, correlationID, meta, ServerStateDeleted)
}
// HandleLogsSubscribe starts streaming container logs for a server.
func (m *Manager) HandleLogsSubscribe(ctx context.Context, correlationID string, payload protocol.ServerLogsSubscribePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
streamCtx, cancel := context.WithCancel(ctx)
m.mu.Lock()
if existing, ok := m.logStreams[meta.ServerID]; ok {
existing()
}
m.logStreams[meta.ServerID] = cancel
m.mu.Unlock()
go func() {
defer func() {
m.mu.Lock()
if current, ok := m.logStreams[meta.ServerID]; ok && current == cancel {
delete(m.logStreams, meta.ServerID)
}
m.mu.Unlock()
}()
err := m.docker.StreamLogs(streamCtx, rec.ContainerID, payload.TailLines, payload.Follow, func(stream, line string) {
logPayload := protocol.ServerLogPayload{
OperationMeta: meta,
Line: line,
Stream: stream,
}
if err := m.resp.SendLog(ctx, correlationID, logPayload); err != nil {
m.log.Warn("send server.log failed",
"server_id", meta.ServerID,
"error", err,
)
}
})
if err != nil && streamCtx.Err() == nil {
m.log.Warn("log stream ended with error",
"server_id", meta.ServerID,
"error", err,
)
}
}()
}
// HandleCommand executes a console command via RCON.
func (m *Manager) HandleCommand(ctx context.Context, correlationID string, payload protocol.ServerCommandPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "command", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
stdout, stderr, err := m.docker.ExecRcon(ctx, rec.ContainerID, payload.Command)
if err != nil {
m.failOperation(ctx, correlationID, meta, "command", "COMMAND_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "command", map[string]string{
"stdout": stdout,
"stderr": stderr,
})
}
// HandleFilesList lists files in the server data directory.
func (m *Manager) HandleFilesList(ctx context.Context, correlationID string, payload protocol.ServerFilesListPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.list", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
entries, err := files.ListDir(rec.DataPath, payload.Path)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.list", filesErrorCode(err), err.Error())
return
}
result := protocol.ServerFilesListResultPayload{
Entries: make([]protocol.FileEntry, 0, len(entries)),
}
for _, entry := range entries {
result.Entries = append(result.Entries, protocol.FileEntry{
Name: entry.Name,
Path: entry.Path,
IsDir: entry.IsDir,
Size: entry.Size,
ModifiedAt: entry.ModifiedAt.Format("2006-01-02T15:04:05Z"),
})
}
m.completeOperation(ctx, correlationID, meta, "files.list", result)
}
// HandleFilesRead reads a file from the server data directory.
func (m *Manager) HandleFilesRead(ctx context.Context, correlationID string, payload protocol.ServerFilesReadPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.read", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
content, truncated, err := files.ReadFile(rec.DataPath, payload.Path, payload.MaxBytes)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.read", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.read", protocol.ServerFilesReadResultPayload{
Path: payload.Path,
Content: content,
Truncated: truncated,
})
}
// HandleFilesWrite writes a file in the server data directory.
func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, payload protocol.ServerFilesWritePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.write", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if err := files.WriteFile(rec.DataPath, payload.Path, payload.Content, payload.Create); err != nil {
m.failOperation(ctx, correlationID, meta, "files.write", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.write", map[string]string{
"path": payload.Path,
})
}
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -223,6 +371,15 @@ func (m *Manager) setState(serverID, state string) {
}
}
func (m *Manager) stopLogStream(serverID string) {
m.mu.Lock()
defer m.mu.Unlock()
if cancel, ok := m.logStreams[serverID]; ok {
cancel()
delete(m.logStreams, serverID)
}
}
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
payload := protocol.ServerStatePayload{
OperationMeta: meta,
@@ -267,3 +424,59 @@ func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.
)
}
}
func (m *Manager) completeOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation string, result any) {
raw, err := json.Marshal(result)
if err != nil {
m.failOperation(ctx, correlationID, meta, operation, "RESULT_MARSHAL_FAILED", err.Error())
return
}
payload := protocol.ServerOperationResultPayload{
OperationMeta: meta,
ResultCode: ResultCodeOK,
Operation: operation,
Result: raw,
}
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
m.log.Warn("send operation complete failed",
"server_id", meta.ServerID,
"operation", operation,
"error", err,
)
}
}
func (m *Manager) failOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation, errorCode, errorMessage string) {
payload := protocol.ServerOperationResultPayload{
OperationMeta: meta,
ResultCode: ResultCodeError,
Operation: operation,
ErrorCode: errorCode,
ErrorMessage: errorMessage,
}
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
m.log.Warn("send operation failed failed",
"server_id", meta.ServerID,
"operation", operation,
"error", err,
)
}
}
func filesErrorCode(err error) string {
switch {
case errors.Is(err, files.ErrPathTraversal):
return "PATH_TRAVERSAL"
case errors.Is(err, files.ErrSymlink):
return "SYMLINK_NOT_ALLOWED"
case errors.Is(err, files.ErrNotFound):
return "FILE_NOT_FOUND"
case errors.Is(err, files.ErrNotDirectory):
return "NOT_A_DIRECTORY"
case errors.Is(err, files.ErrIsDirectory):
return "IS_DIRECTORY"
default:
return "FILES_ERROR"
}
}

View File

@@ -274,6 +274,46 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
}
c.runtime.HandleDelete(ctx, correlationID, meta)
case protocol.TypeServerLogsSubscribe:
var payload protocol.ServerLogsSubscribePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.logs.subscribe", "error", err)
return
}
c.runtime.HandleLogsSubscribe(ctx, env.MessageID, payload)
case protocol.TypeServerCommand:
var payload protocol.ServerCommandPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.command", "error", err)
return
}
c.runtime.HandleCommand(ctx, env.MessageID, payload)
case protocol.TypeServerFilesList:
var payload protocol.ServerFilesListPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.list", "error", err)
return
}
c.runtime.HandleFilesList(ctx, env.MessageID, payload)
case protocol.TypeServerFilesRead:
var payload protocol.ServerFilesReadPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.read", "error", err)
return
}
c.runtime.HandleFilesRead(ctx, env.MessageID, payload)
case protocol.TypeServerFilesWrite:
var payload protocol.ServerFilesWritePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.write", "error", err)
return
}
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
default:
c.log.Debug("ignored control message", "type", env.Type)
}
@@ -333,6 +373,11 @@ func (c *Client) SendState(ctx context.Context, correlationID string, payload pr
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
}
// SendLog implements runtime.Responder.
func (c *Client) SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error {
return c.send(ctx, protocol.TypeServerLog, correlationID, payload)
}
// SendOperationComplete implements runtime.Responder.
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)

View File

@@ -127,7 +127,10 @@
"planInvalid": "Ungültiger Tarif ausgewählt",
"versionRequired": "Minecraft-Version ist erforderlich",
"softwareRequired": "Software-Familie ist erforderlich",
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren",
"motdMax": "MOTD darf höchstens 256 Zeichen lang sein",
"maxPlayersMin": "Mindestens 1 Spieler erforderlich",
"maxPlayersMax": "Höchstens 200 Spieler erlaubt"
},
"servers": {
"title": "Server",
@@ -180,6 +183,97 @@
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"loadFailed": "Server konnten nicht geladen werden."
},
"tabs": {
"navigation": "Server-Bereiche",
"overview": "Übersicht",
"console": "Konsole",
"files": "Dateien",
"properties": "Eigenschaften",
"players": "Spieler"
},
"console": {
"loading": "Wird geladen…",
"notRunning": "Die Konsole ist nur verfügbar, wenn der Server läuft. Starten Sie den Server über die Übersicht.",
"connecting": "Verbindung wird hergestellt…",
"connected": "Verbunden",
"disconnected": "Getrennt",
"clear": "Log leeren",
"commandPlaceholder": "Serverbefehl eingeben…",
"send": "Senden",
"errors": {
"connectionFailed": "Verbindung zur Konsole fehlgeschlagen."
}
},
"files": {
"loading": "Wird geladen…",
"browser": "Dateibrowser",
"editor": "Editor",
"up": "Hoch",
"empty": "Dieses Verzeichnis ist leer.",
"selectFile": "Wählen Sie eine Datei zum Anzeigen oder Bearbeiten.",
"readOnlyStopped": "Schreibgeschützt, solange der Server gestoppt ist.",
"notAvailable": "Dateien sind nur verfügbar, wenn der Server läuft oder gestoppt ist.",
"truncated": "Datei wurde zur Anzeige gekürzt. Bearbeiten Sie mit Vorsicht.",
"save": "Speichern",
"saving": "Wird gespeichert…",
"saved": "Datei gespeichert.",
"errors": {
"loadFailed": "Verzeichnis konnte nicht geladen werden.",
"readFailed": "Datei konnte nicht gelesen werden.",
"saveFailed": "Datei konnte nicht gespeichert werden."
}
},
"properties": {
"title": "Server-Eigenschaften",
"description": "Bearbeiten Sie gängige server.properties-Einstellungen. Unbekannte Eigenschaften bleiben auf dem Server erhalten.",
"loading": "Wird geladen…",
"motd": "MOTD",
"gamemode": "Spielmodus",
"difficulty": "Schwierigkeit",
"maxPlayers": "Max. Spieler",
"pvp": "PVP",
"whitelist": "Whitelist",
"hardcore": "Hardcore",
"onlineMode": "Online-Modus",
"enableCommandBlock": "Befehlsblöcke",
"allowFlight": "Fliegen erlauben",
"viewDistance": "Sichtweite",
"simulationDistance": "Simulationsdistanz",
"spawnProtection": "Spawn-Schutz",
"save": "Änderungen speichern",
"saving": "Wird gespeichert…",
"saved": "Eigenschaften gespeichert.",
"gamemodes": {
"survival": "Überleben",
"creative": "Kreativ",
"adventure": "Abenteuer",
"spectator": "Zuschauer"
},
"difficulties": {
"peaceful": "Friedlich",
"easy": "Einfach",
"normal": "Normal",
"hard": "Schwer"
},
"errors": {
"loadFailed": "Server-Eigenschaften konnten nicht geladen werden.",
"saveFailed": "Server-Eigenschaften konnten nicht gespeichert werden."
}
},
"players": {
"loading": "Wird geladen…",
"onlineCount": "Spieler online",
"whitelistCount": "Whitelist-Einträge",
"empty": "Keine Spieler online.",
"retry": "Erneut versuchen",
"columns": {
"name": "Spieler",
"ping": "Ping"
},
"errors": {
"loadFailed": "Spielerliste konnte nicht geladen werden."
}
}
}
}

View File

@@ -127,7 +127,10 @@
"planInvalid": "Invalid plan selected",
"versionRequired": "Minecraft version is required",
"softwareRequired": "Software family is required",
"eulaRequired": "You must accept the Minecraft EULA"
"eulaRequired": "You must accept the Minecraft EULA",
"motdMax": "MOTD must be at most 256 characters",
"maxPlayersMin": "At least 1 player required",
"maxPlayersMax": "At most 200 players allowed"
},
"servers": {
"title": "Servers",
@@ -180,6 +183,97 @@
"errors": {
"generic": "Something went wrong. Please try again.",
"loadFailed": "Could not load servers."
},
"tabs": {
"navigation": "Server sections",
"overview": "Overview",
"console": "Console",
"files": "Files",
"properties": "Properties",
"players": "Players"
},
"console": {
"loading": "Loading…",
"notRunning": "The console is only available while the server is running. Start the server from the overview tab.",
"connecting": "Connecting…",
"connected": "Connected",
"disconnected": "Disconnected",
"clear": "Clear log",
"commandPlaceholder": "Enter server command…",
"send": "Send",
"errors": {
"connectionFailed": "Could not connect to the console."
}
},
"files": {
"loading": "Loading…",
"browser": "File browser",
"editor": "Editor",
"up": "Up",
"empty": "This directory is empty.",
"selectFile": "Select a file to view or edit.",
"readOnlyStopped": "Read-only while the server is stopped.",
"notAvailable": "Files are only available when the server is running or stopped.",
"truncated": "File was truncated for display. Edit with care.",
"save": "Save",
"saving": "Saving…",
"saved": "File saved.",
"errors": {
"loadFailed": "Could not load directory.",
"readFailed": "Could not read file.",
"saveFailed": "Could not save file."
}
},
"properties": {
"title": "Server properties",
"description": "Edit common server.properties settings. Unknown properties are preserved on the server.",
"loading": "Loading…",
"motd": "MOTD",
"gamemode": "Gamemode",
"difficulty": "Difficulty",
"maxPlayers": "Max players",
"pvp": "PVP",
"whitelist": "Whitelist",
"hardcore": "Hardcore",
"onlineMode": "Online mode",
"enableCommandBlock": "Command blocks",
"allowFlight": "Allow flight",
"viewDistance": "View distance",
"simulationDistance": "Simulation distance",
"spawnProtection": "Spawn protection",
"save": "Save changes",
"saving": "Saving…",
"saved": "Properties saved.",
"gamemodes": {
"survival": "Survival",
"creative": "Creative",
"adventure": "Adventure",
"spectator": "Spectator"
},
"difficulties": {
"peaceful": "Peaceful",
"easy": "Easy",
"normal": "Normal",
"hard": "Hard"
},
"errors": {
"loadFailed": "Could not load server properties.",
"saveFailed": "Could not save server properties."
}
},
"players": {
"loading": "Loading…",
"onlineCount": "Online players",
"whitelistCount": "Whitelist entries",
"empty": "No players online.",
"retry": "Retry",
"columns": {
"name": "Player",
"ping": "Ping"
},
"errors": {
"loadFailed": "Could not load player list."
}
}
}
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerConsole } from "@/components/servers/server-console";
interface ServerConsolePageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerConsolePage({ params }: ServerConsolePageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerConsole serverId={id} />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerFiles } from "@/components/servers/server-files";
interface ServerFilesPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerFilesPage({ params }: ServerFilesPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerFiles serverId={id} />;
}

View File

@@ -0,0 +1,15 @@
import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { ServerDetailShell } from "@/components/servers/server-detail-shell";
interface ServerDetailLayoutProps {
children: ReactNode;
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerDetailLayout({ children, params }: ServerDetailLayoutProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerDetailShell serverId={id}>{children}</ServerDetailShell>;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerPlayers } from "@/components/servers/server-players";
interface ServerPlayersPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerPlayersPage({ params }: ServerPlayersPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerPlayers serverId={id} />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerPropertiesForm } from "@/components/servers/server-properties-form";
interface ServerPropertiesPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerPropertiesPage({ params }: ServerPropertiesPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerPropertiesForm serverId={id} />;
}

View File

@@ -0,0 +1,215 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError } from "@/lib/api/auth";
import {
createConsoleWebSocket,
getConsoleToken,
sendConsoleCommand,
} from "@/lib/api/console";
import { getServer } from "@/lib/api/servers";
import { parseAnsiLine } from "@/lib/utils/ansi";
const MAX_LOG_LINES = 500;
interface LogLine {
id: number;
segments: ReturnType<typeof parseAnsiLine>;
}
interface ServerConsoleProps {
serverId: string;
}
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 font-mono text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function ServerConsole({ serverId }: ServerConsoleProps) {
const t = useTranslations("servers.console");
const [lines, setLines] = useState<LogLine[]>([]);
const [command, setCommand] = useState("");
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const lineIdRef = useRef(0);
const socketRef = useRef<WebSocket | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const appendLine = useCallback((line: string) => {
lineIdRef.current += 1;
setLines((prev) => {
const next = [
...prev,
{ id: lineIdRef.current, segments: parseAnsiLine(line) },
];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
}, []);
useEffect(() => {
if (autoScrollRef.current) {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [lines]);
useEffect(() => {
if (!server || server.status !== "RUNNING") {
return;
}
let cancelled = false;
async function connect() {
setConnecting(true);
setError(null);
try {
const { token } = await getConsoleToken(serverId);
if (cancelled) {
return;
}
const socket = createConsoleWebSocket(serverId, token, {
onOpen: () => {
if (!cancelled) {
setConnected(true);
setConnecting(false);
}
},
onClose: () => {
setConnected(false);
setConnecting(false);
},
onError: () => {
if (!cancelled) {
setError(t("errors.connectionFailed"));
setConnecting(false);
}
},
onLine: (line) => {
appendLine(line);
},
});
socketRef.current = socket;
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError ? (err.detail ?? err.title) : t("errors.connectionFailed"),
);
setConnecting(false);
}
}
}
void connect();
return () => {
cancelled = true;
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
setConnecting(false);
};
}, [server, serverId, appendLine, t]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = command.trim();
if (!trimmed || !socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
return;
}
sendConsoleCommand(socketRef.current, trimmed);
appendLine(`> ${trimmed}`);
setCommand("");
}
if (!server) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (server.status !== "RUNNING") {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<p className="text-sm text-amber-800 dark:text-amber-200">{t("notRunning")}</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{connecting
? t("connecting")
: connected
? t("connected")
: t("disconnected")}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => setLines([])}
disabled={lines.length === 0}
>
{t("clear")}
</Button>
</div>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
<div
className="h-96 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-950 p-3 font-mono text-xs leading-relaxed dark:border-zinc-800"
onScroll={(event) => {
const target = event.currentTarget;
const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 32;
autoScrollRef.current = atBottom;
}}
>
<pre className="whitespace-pre-wrap break-words text-zinc-100">
<code>
{lines.map((line) => (
<div key={line.id}>
{line.segments.map((segment, index) => (
<span key={index} className={segment.className}>
{segment.text}
</span>
))}
</div>
))}
<div ref={logEndRef} />
</code>
</pre>
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder={t("commandPlaceholder")}
className={inputClassName}
disabled={!connected}
autoComplete="off"
spellCheck={false}
/>
<Button type="submit" disabled={!connected || !command.trim()}>
{t("send")}
</Button>
</form>
</div>
);
}

View File

@@ -3,9 +3,8 @@
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getServer } from "@/lib/api/servers";
import { ServerCard } from "@/components/servers/server-card";
import { getServer } from "@/lib/api/servers";
interface ServerDetailContentProps {
serverId: string;
@@ -13,18 +12,9 @@ interface ServerDetailContentProps {
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
refetchInterval: (query) => {
const status = query.state.data?.status;
if (!status) {
return false;
}
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
? 3000
: false;
},
});
if (isLoading) {
@@ -32,42 +22,8 @@ export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
}
if (isError || !data) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
{t("retry")}
</Button>
<Link
href="/servers"
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("backToList")}
</Link>
</div>
</div>
);
return null;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{data.name}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
</div>
<Link
href="/servers"
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("backToList")}
</Link>
</div>
<ServerCard server={data} showDetailsLink={false} />
</div>
);
return <ServerCard server={data} showDetailsLink={false} />;
}

View File

@@ -0,0 +1,77 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { getServer } from "@/lib/api/servers";
import { ServerTabs } from "./server-tabs";
interface ServerDetailShellProps {
serverId: string;
children: ReactNode;
}
export function ServerDetailShell({ serverId, children }: ServerDetailShellProps) {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
refetchInterval: (query) => {
const status = query.state.data?.status;
if (!status) {
return false;
}
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
? 3000
: false;
},
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
{t("retry")}
</Button>
<Link
href="/servers"
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("backToList")}
</Link>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{data.name}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
</div>
<Link
href="/servers"
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("backToList")}
</Link>
</div>
<ServerTabs serverId={serverId} />
{children}
</div>
);
}

View File

@@ -0,0 +1,227 @@
"use client";
import { Button } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/api/auth";
import { listFiles, readFile, writeFile } from "@/lib/api/files";
import { getServer } from "@/lib/api/servers";
interface ServerFilesProps {
serverId: string;
}
const textareaClassName =
"min-h-80 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function ServerFiles({ serverId }: ServerFilesProps) {
const t = useTranslations("servers.files");
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState("");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const [fileVersion, setFileVersion] = useState<number | undefined>();
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const canAccessFiles =
server?.status === "RUNNING" || server?.status === "STOPPED";
const {
data: listing,
isLoading: listingLoading,
isError: listingError,
} = useQuery({
queryKey: ["server-files", serverId, currentPath],
queryFn: () => listFiles(serverId, currentPath),
enabled: Boolean(server && canAccessFiles),
});
const {
data: fileData,
isLoading: fileLoading,
isError: fileError,
} = useQuery({
queryKey: ["server-file", serverId, selectedFile],
queryFn: () => readFile(serverId, selectedFile!),
enabled: Boolean(selectedFile && canAccessFiles),
});
useEffect(() => {
if (fileData) {
setEditorContent(fileData.content);
setFileVersion(fileData.version);
}
}, [fileData]);
const saveMutation = useMutation({
mutationFn: () =>
writeFile(serverId, {
path: selectedFile!,
content: editorContent,
version: fileVersion,
}),
onSuccess: (result) => {
setError(null);
setSaveSuccess(true);
setFileVersion(result.version);
void queryClient.invalidateQueries({ queryKey: ["server-file", serverId, selectedFile] });
setTimeout(() => setSaveSuccess(false), 2000);
},
onError: (err: unknown) => {
setSaveSuccess(false);
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed"));
},
});
function handleSelectEntry(path: string, isDir: boolean) {
setError(null);
setSaveSuccess(false);
if (isDir) {
setCurrentPath(path);
setSelectedFile(null);
setEditorContent("");
return;
}
setSelectedFile(path);
setEditorContent("");
setFileVersion(undefined);
}
function handleNavigateUp() {
if (!currentPath) {
return;
}
const segments = currentPath.split("/").filter(Boolean);
segments.pop();
setCurrentPath(segments.join("/"));
setSelectedFile(null);
setEditorContent("");
setError(null);
}
if (!server) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (!canAccessFiles) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<p className="text-sm text-amber-800 dark:text-amber-200">{t("notAvailable")}</p>
</div>
);
}
const isReadOnly = server.status === "STOPPED";
return (
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("browser")}</h2>
{currentPath ? (
<Button size="sm" variant="secondary" onClick={handleNavigateUp}>
{t("up")}
</Button>
) : null}
</div>
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
/{currentPath || ""}
</p>
{isReadOnly ? (
<p className="text-xs text-amber-700 dark:text-amber-300">{t("readOnlyStopped")}</p>
) : null}
{listingLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : listingError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>
) : (
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{(listing?.entries ?? []).length === 0 ? (
<li className="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">{t("empty")}</li>
) : (
(listing?.entries ?? []).map((entry) => (
<li key={entry.path}>
<button
type="button"
onClick={() => handleSelectEntry(entry.path, entry.isDir)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<span className="w-8 shrink-0 text-xs text-zinc-400">
{entry.isDir ? "DIR" : "FILE"}
</span>
<span className="truncate text-zinc-900 dark:text-zinc-100">{entry.name}</span>
{!entry.isDir ? (
<span className="ml-auto text-xs text-zinc-500">{entry.size} B</span>
) : null}
</button>
</li>
))
)}
</ul>
)}
</div>
<div className="space-y-3">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("editor")}</h2>
{!selectedFile ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("selectFile")}</p>
) : fileLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : fileError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.readFailed")}</p>
) : (
<div className="space-y-3">
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
{selectedFile}
</p>
{fileData?.truncated ? (
<p className="text-xs text-amber-700 dark:text-amber-300">{t("truncated")}</p>
) : null}
<textarea
className={textareaClassName}
value={editorContent}
onChange={(event) => {
setEditorContent(event.target.value);
setSaveSuccess(false);
}}
readOnly={isReadOnly}
spellCheck={false}
/>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
{saveSuccess ? (
<p className="text-sm text-green-600 dark:text-green-400">{t("saved")}</p>
) : null}
{!isReadOnly ? (
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? t("saving") : t("save")}
</Button>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { getPlayers } from "@/lib/api/players";
interface ServerPlayersProps {
serverId: string;
}
export function ServerPlayers({ serverId }: ServerPlayersProps) {
const t = useTranslations("servers.players");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["server-players", serverId],
queryFn: () => getPlayers(serverId),
refetchInterval: 15000,
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<button
type="button"
onClick={() => void refetch()}
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("retry")}
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("onlineCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.online.length}
<span className="text-base font-normal text-zinc-500"> / {data.maxPlayers}</span>
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("whitelistCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.whitelistCount}
</p>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.name")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.ping")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
{data.online.length === 0 ? (
<tr>
<td
colSpan={2}
className="px-4 py-6 text-center text-zinc-500 dark:text-zinc-400"
>
{t("empty")}
</td>
</tr>
) : (
data.online.map((player) => (
<tr key={player.uuid ?? player.name}>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{player.name}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">
{player.ping !== undefined ? `${player.ping} ms` : "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,318 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ApiError } from "@/lib/api/auth";
import {
getProperties,
updateProperties,
type Difficulty,
type GameMode,
type ServerProperties,
} from "@/lib/api/properties";
interface ServerPropertiesFormProps {
serverId: string;
}
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
const GAME_MODES: GameMode[] = ["survival", "creative", "adventure", "spectator"];
const DIFFICULTIES: Difficulty[] = ["peaceful", "easy", "normal", "hard"];
function createPropertiesSchema(messages: {
motdMax: string;
maxPlayersMin: string;
maxPlayersMax: string;
}) {
return z.object({
motd: z.string().max(256, messages.motdMax),
gamemode: z.enum(["survival", "creative", "adventure", "spectator"]),
difficulty: z.enum(["peaceful", "easy", "normal", "hard"]),
maxPlayers: z.coerce.number().int().min(1, messages.maxPlayersMin).max(200, messages.maxPlayersMax),
pvp: z.boolean(),
whitelist: z.boolean(),
hardcore: z.boolean().optional(),
onlineMode: z.boolean().optional(),
enableCommandBlock: z.boolean().optional(),
viewDistance: z.coerce.number().int().min(3).max(32).optional(),
simulationDistance: z.coerce.number().int().min(3).max(32).optional(),
spawnProtection: z.coerce.number().int().min(0).max(64).optional(),
allowFlight: z.boolean().optional(),
});
}
type PropertiesFormValues = z.infer<ReturnType<typeof createPropertiesSchema>>;
export function ServerPropertiesForm({ serverId }: ServerPropertiesFormProps) {
const t = useTranslations("servers.properties");
const tv = useTranslations("validation");
const queryClient = useQueryClient();
const [apiError, setApiError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const schema = createPropertiesSchema({
motdMax: tv("motdMax"),
maxPlayersMin: tv("maxPlayersMin"),
maxPlayersMax: tv("maxPlayersMax"),
});
const { data, isLoading, isError } = useQuery({
queryKey: ["server-properties", serverId],
queryFn: () => getProperties(serverId),
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<PropertiesFormValues>({
resolver: zodResolver(schema),
defaultValues: {
motd: "",
gamemode: "survival",
difficulty: "normal",
maxPlayers: 20,
pvp: true,
whitelist: false,
hardcore: false,
onlineMode: true,
enableCommandBlock: false,
viewDistance: 10,
simulationDistance: 10,
spawnProtection: 16,
allowFlight: false,
},
});
useEffect(() => {
if (data) {
reset(mapPropertiesToForm(data));
}
}, [data, reset]);
const saveMutation = useMutation({
mutationFn: (values: PropertiesFormValues) => updateProperties(serverId, values),
onSuccess: (result) => {
setApiError(null);
setSaved(true);
reset(mapPropertiesToForm(result));
void queryClient.invalidateQueries({ queryKey: ["server-properties", serverId] });
setTimeout(() => setSaved(false), 2000);
},
onError: (error: unknown) => {
setSaved(false);
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.saveFailed"));
},
});
async function onSubmit(values: PropertiesFormValues) {
setApiError(null);
await saveMutation.mutateAsync(values);
}
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return <p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>;
}
return (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2">
<label htmlFor="motd" className={labelClassName}>
{t("motd")}
</label>
<input id="motd" type="text" className={inputClassName} {...register("motd")} />
{errors.motd ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.motd.message}</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="gamemode" className={labelClassName}>
{t("gamemode")}
</label>
<select id="gamemode" className={inputClassName} {...register("gamemode")}>
{GAME_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`gamemodes.${mode}`)}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label htmlFor="difficulty" className={labelClassName}>
{t("difficulty")}
</label>
<select id="difficulty" className={inputClassName} {...register("difficulty")}>
{DIFFICULTIES.map((level) => (
<option key={level} value={level}>
{t(`difficulties.${level}`)}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<label htmlFor="maxPlayers" className={labelClassName}>
{t("maxPlayers")}
</label>
<input
id="maxPlayers"
type="number"
min={1}
max={200}
className={inputClassName}
{...register("maxPlayers")}
/>
{errors.maxPlayers ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.maxPlayers.message}</p>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="h-4 w-4 rounded border-zinc-300" {...register("pvp")} />
{t("pvp")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("whitelist")}
/>
{t("whitelist")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("hardcore")}
/>
{t("hardcore")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("onlineMode")}
/>
{t("onlineMode")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("enableCommandBlock")}
/>
{t("enableCommandBlock")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("allowFlight")}
/>
{t("allowFlight")}
</label>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<label htmlFor="viewDistance" className={labelClassName}>
{t("viewDistance")}
</label>
<input
id="viewDistance"
type="number"
min={3}
max={32}
className={inputClassName}
{...register("viewDistance")}
/>
</div>
<div className="space-y-2">
<label htmlFor="simulationDistance" className={labelClassName}>
{t("simulationDistance")}
</label>
<input
id="simulationDistance"
type="number"
min={3}
max={32}
className={inputClassName}
{...register("simulationDistance")}
/>
</div>
<div className="space-y-2">
<label htmlFor="spawnProtection" className={labelClassName}>
{t("spawnProtection")}
</label>
<input
id="spawnProtection"
type="number"
min={0}
max={64}
className={inputClassName}
{...register("spawnProtection")}
/>
</div>
</div>
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
{saved ? (
<p className="text-sm text-green-600 dark:text-green-400">{t("saved")}</p>
) : null}
<Button type="submit" disabled={isSubmitting || saveMutation.isPending || !isDirty}>
{isSubmitting || saveMutation.isPending ? t("saving") : t("save")}
</Button>
</form>
</CardContent>
</Card>
);
}
function mapPropertiesToForm(properties: ServerProperties): PropertiesFormValues {
return {
motd: properties.motd,
gamemode: properties.gamemode,
difficulty: properties.difficulty,
maxPlayers: properties.maxPlayers,
pvp: properties.pvp,
whitelist: properties.whitelist,
hardcore: properties.hardcore ?? false,
onlineMode: properties.onlineMode ?? true,
enableCommandBlock: properties.enableCommandBlock ?? false,
viewDistance: properties.viewDistance ?? 10,
simulationDistance: properties.simulationDistance ?? 10,
spawnProtection: properties.spawnProtection ?? 16,
allowFlight: properties.allowFlight ?? false,
};
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
interface ServerTabsProps {
serverId: string;
}
const tabs = [
{ key: "overview", suffix: "" },
{ key: "console", suffix: "/console" },
{ key: "files", suffix: "/files" },
{ key: "properties", suffix: "/properties" },
{ key: "players", suffix: "/players" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {
const t = useTranslations("servers.tabs");
const pathname = usePathname();
const basePath = `/servers/${serverId}`;
function isActive(suffix: string): boolean {
const href = `${basePath}${suffix}`;
if (suffix === "") {
return pathname === basePath;
}
return pathname === href || pathname.startsWith(`${href}/`);
}
return (
<nav
aria-label={t("navigation")}
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
{tabs.map((tab) => {
const href = `${basePath}${tab.suffix}`;
const active = isActive(tab.suffix);
return (
<Link
key={tab.key}
href={href}
className={[
"inline-flex items-center border-b-2 px-3 py-2 text-sm font-medium transition-colors",
active
? "border-sky-500 text-sky-600 dark:text-sky-400"
: "border-transparent text-zinc-600 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-100",
].join(" ")}
aria-current={active ? "page" : undefined}
>
{t(tab.key)}
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,86 @@
import { getApiUrl } from "../env";
import { apiFetch } from "./auth";
export interface ConsoleTokenResponse {
token: string;
expiresAt: string;
}
export interface ConsoleWebSocketHandlers {
onOpen?: () => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
onLine?: (line: string, stream?: string) => void;
}
interface ConsoleLogMessage {
type?: string;
line?: string;
stream?: string;
message?: string;
}
function getConsoleWebSocketUrl(serverId: string, token: string): string {
const apiUrl = new URL(getApiUrl());
apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
const basePath = apiUrl.pathname.replace(/\/$/, "");
apiUrl.pathname = `${basePath}/servers/${serverId}/console/ws`;
apiUrl.search = new URLSearchParams({ token }).toString();
return apiUrl.toString();
}
export async function getConsoleToken(serverId: string): Promise<ConsoleTokenResponse> {
return apiFetch<ConsoleTokenResponse>(`/servers/${serverId}/console/token`);
}
export function createConsoleWebSocket(
serverId: string,
token: string,
handlers: ConsoleWebSocketHandlers,
): WebSocket {
const socket = new WebSocket(getConsoleWebSocketUrl(serverId, token));
socket.addEventListener("open", () => {
handlers.onOpen?.();
});
socket.addEventListener("close", (event) => {
handlers.onClose?.(event);
});
socket.addEventListener("error", (event) => {
handlers.onError?.(event);
});
socket.addEventListener("message", (event) => {
const raw = typeof event.data === "string" ? event.data : "";
try {
const parsed = JSON.parse(raw) as ConsoleLogMessage;
if (parsed.type === "log" && parsed.line) {
handlers.onLine?.(parsed.line, parsed.stream);
return;
}
if (parsed.line) {
handlers.onLine?.(parsed.line, parsed.stream);
return;
}
if (parsed.message) {
handlers.onLine?.(parsed.message, parsed.stream);
return;
}
} catch {
// Plain text log line
}
if (raw) {
handlers.onLine?.(raw);
}
});
return socket;
}
export function sendConsoleCommand(socket: WebSocket, command: string): void {
socket.send(JSON.stringify({ command }));
}

View File

@@ -0,0 +1,61 @@
import { apiFetch } from "./auth";
export interface FileEntry {
name: string;
path: string;
isDir: boolean;
size: number;
modifiedAt?: string;
}
export interface ListFilesResponse {
path: string;
entries: FileEntry[];
}
export interface FileContentResponse {
path: string;
content: string;
truncated?: boolean;
version?: number;
}
export interface WriteFilePayload {
path: string;
content: string;
version?: number;
}
export async function listFiles(
serverId: string,
path = "",
): Promise<ListFilesResponse> {
const params = new URLSearchParams();
if (path) {
params.set("path", path);
}
const query = params.toString();
return apiFetch<ListFilesResponse>(
`/servers/${serverId}/files${query ? `?${query}` : ""}`,
);
}
export async function readFile(
serverId: string,
path: string,
): Promise<FileContentResponse> {
const params = new URLSearchParams({ path });
return apiFetch<FileContentResponse>(
`/servers/${serverId}/files/content?${params.toString()}`,
);
}
export async function writeFile(
serverId: string,
payload: WriteFilePayload,
): Promise<FileContentResponse> {
return apiFetch<FileContentResponse>(`/servers/${serverId}/files/content`, {
method: "PUT",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,17 @@
import { apiFetch } from "./auth";
export interface OnlinePlayer {
name: string;
uuid?: string;
ping?: number;
}
export interface PlayerListResponse {
online: OnlinePlayer[];
maxPlayers: number;
whitelistCount: number;
}
export async function getPlayers(serverId: string): Promise<PlayerListResponse> {
return apiFetch<PlayerListResponse>(`/servers/${serverId}/players`);
}

View File

@@ -0,0 +1,36 @@
import { apiFetch } from "./auth";
export type GameMode = "survival" | "creative" | "adventure" | "spectator";
export type Difficulty = "peaceful" | "easy" | "normal" | "hard";
export interface ServerProperties {
motd: string;
gamemode: GameMode;
difficulty: Difficulty;
maxPlayers: number;
pvp: boolean;
whitelist: boolean;
hardcore?: boolean;
onlineMode?: boolean;
enableCommandBlock?: boolean;
viewDistance?: number;
simulationDistance?: number;
spawnProtection?: number;
allowFlight?: boolean;
}
export type UpdateServerPropertiesPayload = Partial<ServerProperties>;
export async function getProperties(serverId: string): Promise<ServerProperties> {
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`);
}
export async function updateProperties(
serverId: string,
payload: UpdateServerPropertiesPayload,
): Promise<ServerProperties> {
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,69 @@
const ANSI_PATTERN =
/\u001b\[[0-9;]*m|\u001b\]8;;.*?\u001b\\|\u001b\][0-9];.*?\u0007/g;
const ANSI_COLOR_MAP: Record<string, string> = {
"30": "text-zinc-400",
"31": "text-red-400",
"32": "text-green-400",
"33": "text-yellow-400",
"34": "text-blue-400",
"35": "text-purple-400",
"36": "text-cyan-400",
"37": "text-zinc-200",
"90": "text-zinc-500",
"91": "text-red-300",
"92": "text-green-300",
"93": "text-yellow-300",
"94": "text-blue-300",
"95": "text-purple-300",
"96": "text-cyan-300",
"97": "text-zinc-100",
};
export function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
export interface AnsiSegment {
text: string;
className?: string;
}
export function parseAnsiLine(line: string): AnsiSegment[] {
const segments: AnsiSegment[] = [];
let currentClass: string | undefined;
let buffer = "";
const regex = /\u001b\[([0-9;]*)m/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
const text = line.slice(lastIndex, match.index);
if (text) {
segments.push({ text, className: currentClass });
}
}
const codes = match[1].split(";").filter(Boolean);
if (codes.length === 0 || codes.includes("0")) {
currentClass = undefined;
} else {
const colorCode = codes.find((code) => ANSI_COLOR_MAP[code]);
currentClass = colorCode ? ANSI_COLOR_MAP[colorCode] : currentClass;
}
lastIndex = regex.lastIndex;
}
buffer = line.slice(lastIndex).replace(ANSI_PATTERN, "");
if (buffer) {
segments.push({ text: buffer, className: currentClass });
}
if (segments.length === 0) {
return [{ text: stripAnsi(line) }];
}
return segments;
}

File diff suppressed because one or more lines are too long