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

@@ -0,0 +1 @@
{"files":{"apps/api/.turbo/turbo-test.log":{"size":1537,"mtime_nanos":1782468825650721100,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"2ee8e4a2d6a8fb73","duration":1483,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d8b760721737878813671de612e773734b1968925d55fbd5e45ad7fbe74f2d0e"}

BIN
.turbo/cache/2ee8e4a2d6a8fb73.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/nodes/node-bridge.module.d.ts.map":{"size":161,"mtime_nanos":1782468777708604300,"mode":420,"is_dir":false},"apps/api/dist/servers":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1782468780362511900,"mode":420,"is_dir":false},"apps/api/dist/servers/console/console.module.d.ts":{"size":83,"mtime_nanos":1782468777727812400,"mode":420,"is_dir":false},"apps/api/dist/nodes/node-bridge.module.d.ts":{"size":90,"mtime_nanos":1782468777709602900,"mode":420,"is_dir":false},"apps/api/dist/servers/players/players.module.d.ts":{"size":83,"mtime_nanos":1782468777742795700,"mode":420,"is_dir":false},"apps/api/dist/servers/files/files.module.d.ts":{"size":79,"mtime_nanos":1782468777733748000,"mode":420,"is_dir":false},"apps/api/dist/nodes":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/servers/players/players.module.d.ts.map":{"size":164,"mtime_nanos":1782468777741253500,"mode":420,"is_dir":false},"apps/api/dist/servers/properties/properties.module.d.ts":{"size":89,"mtime_nanos":1782468777752437600,"mode":420,"is_dir":false},"apps/api/dist/servers/properties":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/servers/console":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/servers/properties/properties.module.d.ts.map":{"size":175,"mtime_nanos":1782468777751142900,"mode":420,"is_dir":false},"apps/api/dist/servers/files":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"apps/api/dist/servers/files/files.module.d.ts.map":{"size":158,"mtime_nanos":1782468777733009300,"mode":420,"is_dir":false},"apps/api/dist/servers/console/console.module.d.ts.map":{"size":164,"mtime_nanos":1782468777726663400,"mode":420,"is_dir":false},"apps/api/dist/servers/players":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["apps/api/.turbo/turbo-build.log","apps/api/dist","apps/api/dist/nodes","apps/api/dist/nodes/node-bridge.module.d.ts","apps/api/dist/nodes/node-bridge.module.d.ts.map","apps/api/dist/servers","apps/api/dist/servers/console","apps/api/dist/servers/console/console.module.d.ts","apps/api/dist/servers/console/console.module.d.ts.map","apps/api/dist/servers/files","apps/api/dist/servers/files/files.module.d.ts","apps/api/dist/servers/files/files.module.d.ts.map","apps/api/dist/servers/players","apps/api/dist/servers/players/players.module.d.ts","apps/api/dist/servers/players/players.module.d.ts.map","apps/api/dist/servers/properties","apps/api/dist/servers/properties/properties.module.d.ts","apps/api/dist/servers/properties/properties.module.d.ts.map"]}

View File

@@ -0,0 +1 @@
{"hash":"53fbef3adbe70934","duration":8263,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}

BIN
.turbo/cache/53fbef3adbe70934.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"62a5c1b531abdb19","duration":3226,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}

BIN
.turbo/cache/62a5c1b531abdb19.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":459,"mtime_nanos":1782468827402210700,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"7d706455c2738957","duration":3243,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d8b760721737878813671de612e773734b1968925d55fbd5e45ad7fbe74f2d0e"}

BIN
.turbo/cache/7d706455c2738957.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/.turbo/turbo-build.log":{"size":102,"mtime_nanos":1782468897043653700,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-build.log"]}

View File

@@ -0,0 +1 @@
{"hash":"960c4b92f0b0db49","duration":8309,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"8e279bfaf63afc109baea9209a7b205a54423d5ba1aab5a65374fb1a66a72299"}

BIN
.turbo/cache/960c4b92f0b0db49.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"c6c80e1d14b676cf","duration":36332,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}

BIN
.turbo/cache/c6c80e1d14b676cf.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"c9f452e9f2c24e69","duration":1913,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}

BIN
.turbo/cache/c9f452e9f2c24e69.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/.turbo/turbo-test.log":{"size":2452,"mtime_nanos":1782469003136978900,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"d34670766f94f2d7","duration":5013,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"094e6f7239a7f3c42896b05f4ecc977b56eddda6f27763db288c4d867ee8ee41"}

BIN
.turbo/cache/d34670766f94f2d7.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782468825277596800,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"df1d56eae48d5f6c","duration":1125,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d8b760721737878813671de612e773734b1968925d55fbd5e45ad7fbe74f2d0e"}

BIN
.turbo/cache/df1d56eae48d5f6c.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -92,10 +92,22 @@ make build
## Aktueller Stand ## Aktueller Stand
**Phase 2** (Single-Node Vertical Slice) — abgeschlossen. **Phase 3** (Konsole, Dateien, Einstellungen) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details. Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
### Server-Verwaltung im Panel
Nach dem Start eines Servers stehen unter `/servers/[id]` folgende Bereiche zur Verfügung:
| Tab | Funktion |
|-----|----------|
| Übersicht | Status, Start/Stop/Restart, Verbindungsadresse |
| Konsole | Live-Logs (WebSocket), Befehle per RCON (nur `RUNNING`) |
| Dateien | Verzeichnisliste, Dateien lesen/schreiben |
| Eigenschaften | `server.properties` bearbeiten, optional Neustart |
| Spieler | Online-Spieler und Whitelist (Lesen) |
### Minecraft-Server lokal testen ### Minecraft-Server lokal testen
```bash ```bash

View File

@@ -9,7 +9,7 @@
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
"lint": "node -e \"process.exit(0)\"", "lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js" "test": "tsc && node --test test/**/*.test.js"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@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 {} 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'; import { ServersModule } from '../servers/servers.module';
@@ -7,7 +7,7 @@ import { NodesGateway } from './nodes.gateway';
import { NodesService } from './nodes.service'; import { NodesService } from './nodes.service';
@Module({ @Module({
imports: [ServersModule], imports: [forwardRef(() => ServersModule)],
providers: [NodesService, NodesGateway, NodeCommandSubscriber], providers: [NodesService, NodesGateway, NodeCommandSubscriber],
exports: [NodesService], exports: [NodesService],
}) })

View File

@@ -13,6 +13,7 @@ import { verifyTokenHash } from '@hexahost/auth';
import type { GameServer, GameNode, Prisma } from '@hexahost/database'; import type { GameServer, GameNode, Prisma } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ConsoleSessionService } from '../servers/console/console-session.service';
import { REDIS_CLIENT, ServersService } from '../servers/servers.service'; import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
type GameServerStatus = GameServer['status']; type GameServerStatus = GameServer['status'];
@@ -47,6 +48,7 @@ export class NodesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly serversService: ServersService, private readonly serversService: ServersService,
private readonly consoleSessionService: ConsoleSessionService,
@Inject(REDIS_CLIENT) private readonly redis: Redis, @Inject(REDIS_CLIENT) private readonly redis: Redis,
) {} ) {}
@@ -145,12 +147,21 @@ export class NodesService {
case 'server.state': case 'server.state':
await this.handleServerState(envelope); await this.handleServerState(envelope);
break; break;
case 'server.log':
this.handleServerLog(envelope);
break;
case 'server.operation.completed': case 'server.operation.completed':
await this.handleOperationCompleted(envelope); await this.handleOperationCompleted(envelope);
break; break;
case 'server.operation.failed': case 'server.operation.failed':
await this.handleOperationFailed(envelope); await this.handleOperationFailed(envelope);
break; 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: default:
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`); 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( private async handleOperationCompleted(
envelope: AgentEnvelope, envelope: AgentEnvelope,
): Promise<void> { ): Promise<void> {
const serverId = envelope.payload?.['serverId']; const serverId = envelope.payload?.['serverId'];
const operation = envelope.payload?.['operation']; const operation =
const messageId = envelope.messageId; 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') { if (typeof serverId === 'string' && !isBridgeOperation) {
return; const target = this.resolveCompletedStatus(operation);
} if (target) {
await this.serversService.applyAgentStatus(serverId, target, {
const target = this.resolveCompletedStatus(operation); correlationId: envelope.correlationId,
if (target) { reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
await this.serversService.applyAgentStatus(serverId, target, { });
correlationId: envelope.correlationId, }
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
});
} }
if (typeof messageId === 'string') { if (typeof messageId === 'string') {
await this.publishNodeResponse(messageId, { await this.publishNodeResponse(messageId, {
success: true, success: true,
result,
resultCode: typeof operation === 'string' ? operation : 'completed', resultCode: typeof operation === 'string' ? operation : 'completed',
}); });
} }
@@ -239,9 +275,12 @@ export class NodesService {
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> { private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId']; 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', { await this.serversService.applyAgentStatus(serverId, 'ERROR', {
correlationId: envelope.correlationId, correlationId: envelope.correlationId,
reason: 'agent operation failed', 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, messageId: string,
response: { response: {
success: boolean; success: boolean;
result?: unknown;
resultCode?: string; resultCode?: string;
errorCode?: string; errorCode?: string;
errorMessage?: 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, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post, Post,
Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -73,4 +74,15 @@ export class ServersController {
) { ) {
return this.serversService.stopServer(user.id, id); 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 { Queue } from 'bullmq';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { getConfig } from '@hexahost/config'; import { getConfig } from '@hexahost/config';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.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 { ServerStateService } from './server-state.service';
import { import {
REDIS_CLIENT, REDIS_CLIENT,
@@ -19,7 +24,15 @@ import { PlansController } from './plans.controller';
import { PlansService } from './plans.service'; import { PlansService } from './plans.service';
@Module({ @Module({
imports: [AppConfigModule, AuthModule], imports: [
AppConfigModule,
AuthModule,
forwardRef(() => ConsoleModule),
forwardRef(() => FilesModule),
forwardRef(() => PropertiesModule),
forwardRef(() => PlayersModule),
forwardRef(() => NodeBridgeModule),
],
controllers: [ServersController, PlansController], controllers: [ServersController, PlansController],
providers: [ providers: [
ServersService, ServersService,
@@ -59,6 +72,11 @@ import { PlansService } from './plans.service';
}, },
}, },
], ],
exports: [ServersService, REDIS_CLIENT], exports: [
ServersService,
REDIS_CLIENT,
ConsoleModule,
NodeBridgeModule,
],
}) })
export class ServersModule {} export class ServersModule {}

View File

@@ -2,10 +2,8 @@ import { randomUUID } from 'node:crypto';
import { import {
ConflictException, ConflictException,
ForbiddenException,
Inject, Inject,
Injectable, Injectable,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import type Redis from 'ioredis'; import type Redis from 'ioredis';
@@ -22,6 +20,7 @@ type GameServerStatus = GameServer['status'];
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { findOwnedServer } from './shared/server-ownership.util';
import { ServerStateService } from './server-state.service'; import { ServerStateService } from './server-state.service';
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT'); export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
@@ -77,15 +76,30 @@ export class ServersService {
} }
async getServer(userId: string, serverId: string): Promise<ServerResponse> { 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); 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( async startServer(
userId: string, userId: string,
serverId: string, serverId: string,
): Promise<ServerActionResponse> { ): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId); const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status); this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status); const targetStatus = this.serverState.resolveStartTarget(server.status);
@@ -143,7 +157,7 @@ export class ServersService {
userId: string, userId: string,
serverId: string, serverId: string,
): Promise<ServerActionResponse> { ): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId); const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status); this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStopTarget(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( private async transitionServer(
server: GameServer, server: GameServer,
toStatus: GameServerStatus, 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" "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. // Agent coordinates the node agent lifecycle until shutdown.
type Agent struct { type Agent struct {

View File

@@ -1,8 +1,11 @@
package docker package docker
import ( import (
"bytes"
"context" "context"
"encoding/binary"
"fmt" "fmt"
"io"
"strconv" "strconv"
"strings" "strings"
@@ -10,6 +13,7 @@ import (
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
) )
@@ -189,3 +193,134 @@ func mapSoftwareFamily(family string) string {
return "VANILLA" 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 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. // ServerOperationResultPayload reports the outcome of a lifecycle operation.
type ServerOperationResultPayload struct { type ServerOperationResultPayload struct {
OperationMeta OperationMeta
ResultCode string `json:"resultCode"` ResultCode string `json:"resultCode"`
ErrorCode string `json:"errorCode,omitempty"` Operation string `json:"operation,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"` Result json.RawMessage `json:"result,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
} }
// ServerStatePayload reports the observed server state. // ServerStatePayload reports the observed server state.

View File

@@ -212,3 +212,168 @@ func TestNewEnvelope(t *testing.T) {
t.Fatalf("protocol version %d", env.ProtocolVersion) 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 ( import (
"context" "context"
"fmt" "encoding/json"
"errors"
"log/slog" "log/slog"
"sync" "sync"
"github.com/hexahost/gamecloud/node-agent/internal/docker" "github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/files"
"github.com/hexahost/gamecloud/node-agent/internal/protocol" "github.com/hexahost/gamecloud/node-agent/internal/protocol"
) )
@@ -27,6 +29,7 @@ const (
// Responder sends protocol responses back to the control plane. // Responder sends protocol responses back to the control plane.
type Responder interface { type Responder interface {
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error 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 SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
SendOperationFailed(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. // Manager tracks provisioned servers and executes lifecycle operations.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
docker *docker.Client docker *docker.Client
servers map[string]*serverRecord servers map[string]*serverRecord
resp Responder logStreams map[string]context.CancelFunc
log *slog.Logger resp Responder
log *slog.Logger
} }
// NewManager creates a runtime manager backed by the given Docker client. // NewManager creates a runtime manager backed by the given Docker client.
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager { func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
return &Manager{ return &Manager{
docker: dockerClient, docker: dockerClient,
servers: make(map[string]*serverRecord), servers: make(map[string]*serverRecord),
resp: resp, logStreams: make(map[string]context.CancelFunc),
log: log, 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.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
m.stopLogStream(meta.ServerID)
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running { if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil { if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error()) 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) 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) { func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() 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) { func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
payload := protocol.ServerStatePayload{ payload := protocol.ServerStatePayload{
OperationMeta: meta, 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) 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: default:
c.log.Debug("ignored control message", "type", env.Type) 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) 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. // SendOperationComplete implements runtime.Responder.
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error { func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload) return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)

View File

@@ -127,7 +127,10 @@
"planInvalid": "Ungültiger Tarif ausgewählt", "planInvalid": "Ungültiger Tarif ausgewählt",
"versionRequired": "Minecraft-Version ist erforderlich", "versionRequired": "Minecraft-Version ist erforderlich",
"softwareRequired": "Software-Familie 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": { "servers": {
"title": "Server", "title": "Server",
@@ -180,6 +183,97 @@
"errors": { "errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", "generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"loadFailed": "Server konnten nicht geladen werden." "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", "planInvalid": "Invalid plan selected",
"versionRequired": "Minecraft version is required", "versionRequired": "Minecraft version is required",
"softwareRequired": "Software family 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": { "servers": {
"title": "Servers", "title": "Servers",
@@ -180,6 +183,97 @@
"errors": { "errors": {
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",
"loadFailed": "Could not load servers." "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 { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getServer } from "@/lib/api/servers";
import { ServerCard } from "@/components/servers/server-card"; import { ServerCard } from "@/components/servers/server-card";
import { getServer } from "@/lib/api/servers";
interface ServerDetailContentProps { interface ServerDetailContentProps {
serverId: string; serverId: string;
@@ -13,18 +12,9 @@ interface ServerDetailContentProps {
export function ServerDetailContent({ serverId }: ServerDetailContentProps) { export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
const t = useTranslations("servers"); const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ["server", serverId], queryKey: ["server", serverId],
queryFn: () => getServer(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) { if (isLoading) {
@@ -32,42 +22,8 @@ export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
} }
if (isError || !data) { if (isError || !data) {
return ( return null;
<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 ( return <ServerCard server={data} showDetailsLink={false} />;
<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>
);
} }

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

View File

@@ -2,33 +2,32 @@
Last updated: 2026-06-26 Last updated: 2026-06-26
## Current phase: Phase 2Single-Node Vertical Slice ## Current phase: Phase 3Console, files, settings
### Phase 2 completed ### Phase 3 completed
| Area | Status | Notes | | Area | Status | Notes |
|------|--------|-------| |------|--------|-------|
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment | | Go agent v0.3 | Done | Log stream, RCON commands, safe file I/O |
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action | | `@hexahost/contracts` phase 3 | Done | console, files, properties, players |
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs | | API NodeBridge | Done | Sync request/response over Redis |
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge | | API Console | Done | Token + WS `/api/v1/servers/:id/console/ws` |
| API Plans | Done | `GET /api/v1/plans` | | API Files | Done | List, read, write with path validation |
| Admin nodes | Done | `POST /api/v1/admin/nodes` with enrollment token | | API Properties | Done | GET/PATCH `server.properties` |
| Worker provisioning | Done | Port allocation, Redis commands, response wait | | API Players | Done | Online list + whitelist |
| Worker lifecycle | Done | start-server, stop-server jobs | | API Restart | Done | `POST /servers/:id/restart?skip=true` |
| Go node-agent | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) | | Audit events | Done | console.command, file.write, properties.update |
| Web server UI | Done | Create, list, detail, start/stop | | Web server tabs | Done | Console, files, properties, players |
| Dev seed | Done | `pnpm db:seed` — Free plan + local dev node | | Unit tests | Done | Properties parser, path validation, route contracts |
| Migrations | Done | `20260626140000_phase2_game_servers` |
### Abnahmekriterium Phase 2 ### Abnahmekriterium Phase 3
- [x] User creates server in web panel - [x] Live console WebSocket (logs + commands when RUNNING)
- [x] Provisioning pipeline (API → Worker → Agent) - [x] File manager (list/read/write, path sandbox)
- [x] Start/stop lifecycle - [x] `server.properties` editor with restart option
- [x] Node enrollment + heartbeat - [x] Player list + whitelist view
- [x] Connection address shown (`localhost:port`) - [x] Server management fully via web panel (no direct host access required)
- [ ] Manual Minecraft join validation (requires Docker + agent running locally) - [ ] Manual E2E validation (requires Docker + agent running locally)
### Local vertical slice ### Local vertical slice
@@ -46,17 +45,49 @@ set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
make run make run
``` ```
1. Create and start a server under `/servers/new`
2. Open **Console** tab — live logs, send commands via RCON
3. Open **Files** — browse server directory, edit files
4. Open **Properties** — change `server.properties`, optional restart
5. Open **Players** — online players and whitelist
### Known limitations ### Known limitations
- Single-node only (no scheduler/queue fairness yet). - Console only when server status is `RUNNING`; files readable when `RUNNING` or `STOPPED`.
- Minecraft image pull on first provision can take several minutes. - RCON via `rcon-cli` inside Minecraft container (itzg image).
- Node agent on Windows needs Docker Desktop; run agent on host, not in Compose. - File writes limited to server data directory; no upload/download archives yet (Phase 4).
- mTLS for agent deferred; token-based enrollment only. - Player management read-only (whitelist view); add/remove deferred.
- Metrics are basic heartbeat payload only. - Single-node only; no scheduler fairness yet.
### Next phase: Phase 3 — Console, files, settings ---
- Live console WebSocket ## Phase 2 — Single-Node Vertical Slice ✅
- File manager
- server.properties editor | Area | Status | Notes |
- Player lists |------|--------|-------|
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
| Go node-agent v0.2 | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
| Web server UI | Done | Create, list, detail, start/stop |
| Migration | Done | `20260626140000_phase2_game_servers` |
---
## Phase 1 — Authentication ✅
Session cookies, register/login/logout, email verify, password reset, TOTP 2FA, recovery codes, audit baseline. See `docs/adr/0002-session-authentication.md`.
---
## Phase 0 — Monorepo foundation ✅
pnpm + Turborepo monorepo, PostgreSQL/Redis/MinIO/Mailpit compose, CI, ADRs.
### Next phase: Phase 4 — Worlds & backups
- S3-compatible storage (MinIO)
- World upload/download
- Backup jobs and restore

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const consoleTokenResponseSchema = z.object({
token: z.string(),
expiresAt: z.string().datetime(),
wsUrl: z.string(),
});
export const consoleCommandSchema = z.object({
command: z.string().min(1).max(256),
});
export type ConsoleTokenResponse = z.infer<typeof consoleTokenResponseSchema>;
export type ConsoleCommand = z.infer<typeof consoleCommandSchema>;

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
export const listFilesQuerySchema = z.object({
path: z.string().default('.'),
});
export const fileEntrySchema = z.object({
name: z.string(),
path: z.string(),
type: z.enum(['file', 'directory']),
size: z.number().int().nonnegative().optional(),
modifiedAt: z.string().datetime().optional(),
});
export const fileListResponseSchema = z.object({
path: z.string(),
entries: z.array(fileEntrySchema),
});
export const fileContentSchema = z.object({
path: z.string(),
content: z.string(),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
});
export const readFileContentQuerySchema = z.object({
path: z.string().min(1),
});
export const updateFileContentSchema = z.object({
path: z.string().min(1),
content: z.string(),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
});
export type ListFilesQuery = z.infer<typeof listFilesQuerySchema>;
export type FileEntry = z.infer<typeof fileEntrySchema>;
export type FileListResponse = z.infer<typeof fileListResponseSchema>;
export type FileContent = z.infer<typeof fileContentSchema>;
export type ReadFileContentQuery = z.infer<typeof readFileContentQuerySchema>;
export type UpdateFileContent = z.infer<typeof updateFileContentSchema>;

View File

@@ -71,3 +71,45 @@ export {
type ServerListResponse, type ServerListResponse,
type ServerActionResponse, type ServerActionResponse,
} from './servers'; } from './servers';
export {
listFilesQuerySchema,
fileEntrySchema,
fileListResponseSchema,
fileContentSchema,
readFileContentQuerySchema,
updateFileContentSchema,
type ListFilesQuery,
type FileEntry,
type FileListResponse,
type FileContent,
type ReadFileContentQuery,
type UpdateFileContent,
} from './files';
export {
consoleTokenResponseSchema,
consoleCommandSchema,
type ConsoleTokenResponse,
type ConsoleCommand,
} from './console';
export {
serverPropertiesSchema,
updateServerPropertiesSchema,
serverPropertiesResponseSchema,
type ServerProperties,
type UpdateServerProperties,
type ServerPropertiesResponse,
} from './properties';
export {
onlinePlayerSchema,
whitelistEntrySchema,
operatorEntrySchema,
playerListResponseSchema,
type OnlinePlayer,
type WhitelistEntry,
type OperatorEntry,
type PlayerListResponse,
} from './players';

View File

@@ -0,0 +1,29 @@
import { z } from 'zod';
export const onlinePlayerSchema = z.object({
name: z.string(),
uuid: z.string().uuid().optional(),
});
export const whitelistEntrySchema = z.object({
uuid: z.string().uuid(),
name: z.string(),
});
export const operatorEntrySchema = z.object({
uuid: z.string().uuid(),
name: z.string(),
level: z.number().int().min(0).max(4).optional(),
bypassesPlayerLimit: z.boolean().optional(),
});
export const playerListResponseSchema = z.object({
online: z.array(onlinePlayerSchema),
whitelist: z.array(whitelistEntrySchema),
operators: z.array(operatorEntrySchema),
});
export type OnlinePlayer = z.infer<typeof onlinePlayerSchema>;
export type WhitelistEntry = z.infer<typeof whitelistEntrySchema>;
export type OperatorEntry = z.infer<typeof operatorEntrySchema>;
export type PlayerListResponse = z.infer<typeof playerListResponseSchema>;

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
export const serverPropertiesSchema = z
.object({
'motd': z.string().max(256).optional(),
'gamemode': z
.enum(['survival', 'creative', 'adventure', 'spectator'])
.optional(),
'difficulty': z.enum(['peaceful', 'easy', 'normal', 'hard']).optional(),
'hardcore': z.boolean().optional(),
'max-players': z.number().int().min(1).max(1000).optional(),
'pvp': z.boolean().optional(),
'white-list': z.boolean().optional(),
'enable-command-block': z.boolean().optional(),
'view-distance': z.number().int().min(3).max(32).optional(),
'simulation-distance': z.number().int().min(3).max(32).optional(),
'spawn-protection': z.number().int().min(0).optional(),
'allow-flight': z.boolean().optional(),
'online-mode': z.boolean().optional(),
'enable-query': z.boolean().optional(),
'enable-rcon': z.boolean().optional(),
'level-seed': z.string().max(64).optional(),
'level-name': z.string().max(64).optional(),
'resource-pack': z.string().url().optional().or(z.literal('')),
'resource-pack-sha1': z.string().max(64).optional(),
'allow-nether': z.boolean().optional(),
})
.catchall(z.union([z.string(), z.number(), z.boolean()]));
export const updateServerPropertiesSchema = serverPropertiesSchema.partial();
export const serverPropertiesResponseSchema = z.object({
properties: serverPropertiesSchema,
raw: z.string(),
});
export type ServerProperties = z.infer<typeof serverPropertiesSchema>;
export type UpdateServerProperties = z.infer<typeof updateServerPropertiesSchema>;
export type ServerPropertiesResponse = z.infer<
typeof serverPropertiesResponseSchema
>;

File diff suppressed because one or more lines are too long