Phase3
This commit is contained in:
1
.turbo/cache/2ee8e4a2d6a8fb73-manifest.json
vendored
Normal file
1
.turbo/cache/2ee8e4a2d6a8fb73-manifest.json
vendored
Normal 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"]}
|
||||
1
.turbo/cache/2ee8e4a2d6a8fb73-meta.json
vendored
Normal file
1
.turbo/cache/2ee8e4a2d6a8fb73-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"2ee8e4a2d6a8fb73","duration":1483,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d8b760721737878813671de612e773734b1968925d55fbd5e45ad7fbe74f2d0e"}
|
||||
BIN
.turbo/cache/2ee8e4a2d6a8fb73.tar.zst
vendored
Normal file
BIN
.turbo/cache/2ee8e4a2d6a8fb73.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/53fbef3adbe70934-manifest.json
vendored
Normal file
1
.turbo/cache/53fbef3adbe70934-manifest.json
vendored
Normal 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"]}
|
||||
1
.turbo/cache/53fbef3adbe70934-meta.json
vendored
Normal file
1
.turbo/cache/53fbef3adbe70934-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"53fbef3adbe70934","duration":8263,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}
|
||||
BIN
.turbo/cache/53fbef3adbe70934.tar.zst
vendored
Normal file
BIN
.turbo/cache/53fbef3adbe70934.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/62a5c1b531abdb19-manifest.json
vendored
Normal file
1
.turbo/cache/62a5c1b531abdb19-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/62a5c1b531abdb19-meta.json
vendored
Normal file
1
.turbo/cache/62a5c1b531abdb19-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"62a5c1b531abdb19","duration":3226,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}
|
||||
BIN
.turbo/cache/62a5c1b531abdb19.tar.zst
vendored
Normal file
BIN
.turbo/cache/62a5c1b531abdb19.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/7d706455c2738957-manifest.json
vendored
Normal file
1
.turbo/cache/7d706455c2738957-manifest.json
vendored
Normal 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"]}
|
||||
1
.turbo/cache/7d706455c2738957-meta.json
vendored
Normal file
1
.turbo/cache/7d706455c2738957-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"7d706455c2738957","duration":3243,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d8b760721737878813671de612e773734b1968925d55fbd5e45ad7fbe74f2d0e"}
|
||||
BIN
.turbo/cache/7d706455c2738957.tar.zst
vendored
Normal file
BIN
.turbo/cache/7d706455c2738957.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/960c4b92f0b0db49-manifest.json
vendored
Normal file
1
.turbo/cache/960c4b92f0b0db49-manifest.json
vendored
Normal 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"]}
|
||||
1
.turbo/cache/960c4b92f0b0db49-meta.json
vendored
Normal file
1
.turbo/cache/960c4b92f0b0db49-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"960c4b92f0b0db49","duration":8309,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"8e279bfaf63afc109baea9209a7b205a54423d5ba1aab5a65374fb1a66a72299"}
|
||||
BIN
.turbo/cache/960c4b92f0b0db49.tar.zst
vendored
Normal file
BIN
.turbo/cache/960c4b92f0b0db49.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/c6c80e1d14b676cf-manifest.json
vendored
Normal file
1
.turbo/cache/c6c80e1d14b676cf-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/c6c80e1d14b676cf-meta.json
vendored
Normal file
1
.turbo/cache/c6c80e1d14b676cf-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"c6c80e1d14b676cf","duration":36332,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}
|
||||
BIN
.turbo/cache/c6c80e1d14b676cf.tar.zst
vendored
Normal file
BIN
.turbo/cache/c6c80e1d14b676cf.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/c9f452e9f2c24e69-manifest.json
vendored
Normal file
1
.turbo/cache/c9f452e9f2c24e69-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/c9f452e9f2c24e69-meta.json
vendored
Normal file
1
.turbo/cache/c9f452e9f2c24e69-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"c9f452e9f2c24e69","duration":1913,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d6d3f7c8a7b83b5b10d8fbf201ff8248cd4b5c75e39105254b071e335dac4d76"}
|
||||
BIN
.turbo/cache/c9f452e9f2c24e69.tar.zst
vendored
Normal file
BIN
.turbo/cache/c9f452e9f2c24e69.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d34670766f94f2d7-manifest.json
vendored
Normal file
1
.turbo/cache/d34670766f94f2d7-manifest.json
vendored
Normal 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"]}
|
||||
1
.turbo/cache/d34670766f94f2d7-meta.json
vendored
Normal file
1
.turbo/cache/d34670766f94f2d7-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"d34670766f94f2d7","duration":5013,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"094e6f7239a7f3c42896b05f4ecc977b56eddda6f27763db288c4d867ee8ee41"}
|
||||
BIN
.turbo/cache/d34670766f94f2d7.tar.zst
vendored
Normal file
BIN
.turbo/cache/d34670766f94f2d7.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/df1d56eae48d5f6c-manifest.json
vendored
Normal file
1
.turbo/cache/df1d56eae48d5f6c-manifest.json
vendored
Normal 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"]}
|
||||
1
.turbo/cache/df1d56eae48d5f6c-meta.json
vendored
Normal file
1
.turbo/cache/df1d56eae48d5f6c-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"df1d56eae48d5f6c","duration":1125,"sha":"c4077d46736b14b1332219def11307fcaf659973","dirty_hash":"d8b760721737878813671de612e773734b1968925d55fbd5e45ad7fbe74f2d0e"}
|
||||
BIN
.turbo/cache/df1d56eae48d5f6c.tar.zst
vendored
Normal file
BIN
.turbo/cache/df1d56eae48d5f6c.tar.zst
vendored
Normal file
Binary file not shown.
14
README.md
14
README.md
@@ -92,10 +92,22 @@ make build
|
||||
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "node -e \"process.exit(0)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.js"
|
||||
"test": "tsc && node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
|
||||
@@ -47,6 +47,6 @@ import { SessionService } from './session.service';
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SessionService, SessionGuard],
|
||||
exports: [AuthService, SessionService, SessionGuard, AuditService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
12
apps/api/src/nodes/node-bridge.module.ts
Normal file
12
apps/api/src/nodes/node-bridge.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { ServersModule } from '../servers/servers.module';
|
||||
|
||||
import { NodeBridgeService } from './node-bridge.service';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ServersModule)],
|
||||
providers: [NodeBridgeService],
|
||||
exports: [NodeBridgeService],
|
||||
})
|
||||
export class NodeBridgeModule {}
|
||||
85
apps/api/src/nodes/node-bridge.service.ts
Normal file
85
apps/api/src/nodes/node-bridge.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
GatewayTimeoutException,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||
|
||||
export interface AgentResponse {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NodeBridgeService {
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
private readonly serversService: ServersService,
|
||||
) {}
|
||||
|
||||
async sendAgentRequest(
|
||||
nodeId: string,
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<AgentResponse> {
|
||||
const messageId = randomUUID();
|
||||
|
||||
const config = getConfig();
|
||||
const subscriber = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
const responseChannel = `hgc:node:responses:${messageId}`;
|
||||
|
||||
try {
|
||||
const responsePromise = new Promise<AgentResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new GatewayTimeoutException('Agent request timed out'));
|
||||
}, timeoutMs);
|
||||
|
||||
subscriber.on('message', (channel, message) => {
|
||||
if (channel !== responseChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(message) as AgentResponse);
|
||||
} catch {
|
||||
reject(new ServiceUnavailableException('Invalid agent response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await subscriber.subscribe(responseChannel);
|
||||
|
||||
await this.serversService.publishNodeCommand(nodeId, {
|
||||
protocolVersion: 1,
|
||||
messageId,
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
...payload,
|
||||
generation: payload['generation'] ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return await responsePromise;
|
||||
} finally {
|
||||
await subscriber.unsubscribe(responseChannel);
|
||||
await subscriber.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { ServersModule } from '../servers/servers.module';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NodesGateway } from './nodes.gateway';
|
||||
import { NodesService } from './nodes.service';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule],
|
||||
imports: [forwardRef(() => ServersModule)],
|
||||
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
|
||||
exports: [NodesService],
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { verifyTokenHash } from '@hexahost/auth';
|
||||
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ConsoleSessionService } from '../servers/console/console-session.service';
|
||||
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
@@ -47,6 +48,7 @@ export class NodesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serversService: ServersService,
|
||||
private readonly consoleSessionService: ConsoleSessionService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@@ -145,12 +147,21 @@ export class NodesService {
|
||||
case 'server.state':
|
||||
await this.handleServerState(envelope);
|
||||
break;
|
||||
case 'server.log':
|
||||
this.handleServerLog(envelope);
|
||||
break;
|
||||
case 'server.operation.completed':
|
||||
await this.handleOperationCompleted(envelope);
|
||||
break;
|
||||
case 'server.operation.failed':
|
||||
await this.handleOperationFailed(envelope);
|
||||
break;
|
||||
case 'server.files.list.result':
|
||||
case 'server.files.read.result':
|
||||
case 'server.files.write.result':
|
||||
case 'server.command.result':
|
||||
await this.handleDirectResult(envelope);
|
||||
break;
|
||||
default:
|
||||
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
|
||||
}
|
||||
@@ -210,28 +221,53 @@ export class NodesService {
|
||||
});
|
||||
}
|
||||
|
||||
private handleServerLog(envelope: AgentEnvelope): void {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const line =
|
||||
envelope.payload?.['line'] ?? envelope.payload?.['message'];
|
||||
|
||||
if (typeof serverId === 'string' && typeof line === 'string') {
|
||||
this.consoleSessionService.broadcast(serverId, line);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDirectResult(envelope: AgentEnvelope): Promise<void> {
|
||||
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||
|
||||
if (typeof messageId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.publishNodeResponse(messageId, {
|
||||
success: true,
|
||||
result: envelope.payload?.['result'] ?? envelope.payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOperationCompleted(
|
||||
envelope: AgentEnvelope,
|
||||
): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const operation = envelope.payload?.['operation'];
|
||||
const messageId = envelope.messageId;
|
||||
const operation =
|
||||
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
|
||||
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||
const result = envelope.payload?.['result'];
|
||||
const isBridgeOperation = this.isBridgeOperation(operation);
|
||||
|
||||
if (typeof serverId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.resolveCompletedStatus(operation);
|
||||
if (target) {
|
||||
await this.serversService.applyAgentStatus(serverId, target, {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||
});
|
||||
if (typeof serverId === 'string' && !isBridgeOperation) {
|
||||
const target = this.resolveCompletedStatus(operation);
|
||||
if (target) {
|
||||
await this.serversService.applyAgentStatus(serverId, target, {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof messageId === 'string') {
|
||||
await this.publishNodeResponse(messageId, {
|
||||
success: true,
|
||||
result,
|
||||
resultCode: typeof operation === 'string' ? operation : 'completed',
|
||||
});
|
||||
}
|
||||
@@ -239,9 +275,12 @@ export class NodesService {
|
||||
|
||||
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const messageId = envelope.messageId;
|
||||
const operation =
|
||||
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
|
||||
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||
const isBridgeOperation = this.isBridgeOperation(operation);
|
||||
|
||||
if (typeof serverId === 'string') {
|
||||
if (typeof serverId === 'string' && !isBridgeOperation) {
|
||||
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: 'agent operation failed',
|
||||
@@ -271,10 +310,25 @@ export class NodesService {
|
||||
}
|
||||
}
|
||||
|
||||
private async publishNodeResponse(
|
||||
private isBridgeOperation(operation: unknown): boolean {
|
||||
if (typeof operation !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = operation.replace(/^server\./, '');
|
||||
return (
|
||||
normalized === 'files.list' ||
|
||||
normalized === 'files.read' ||
|
||||
normalized === 'files.write' ||
|
||||
normalized === 'command'
|
||||
);
|
||||
}
|
||||
|
||||
async publishNodeResponse(
|
||||
messageId: string,
|
||||
response: {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
44
apps/api/src/servers/console/console-session.service.ts
Normal file
44
apps/api/src/servers/console/console-session.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
@Injectable()
|
||||
export class ConsoleSessionService {
|
||||
private readonly sessions = new Map<string, Set<WebSocket>>();
|
||||
|
||||
subscribe(serverId: string, socket: WebSocket): void {
|
||||
let sockets = this.sessions.get(serverId);
|
||||
if (!sockets) {
|
||||
sockets = new Set();
|
||||
this.sessions.set(serverId, sockets);
|
||||
}
|
||||
|
||||
sockets.add(socket);
|
||||
}
|
||||
|
||||
unsubscribe(serverId: string, socket: WebSocket): void {
|
||||
const sockets = this.sessions.get(serverId);
|
||||
if (!sockets) {
|
||||
return;
|
||||
}
|
||||
|
||||
sockets.delete(socket);
|
||||
if (sockets.size === 0) {
|
||||
this.sessions.delete(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(serverId: string, line: string): void {
|
||||
const sockets = this.sessions.get(serverId);
|
||||
if (!sockets) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ type: 'log', line });
|
||||
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/api/src/servers/console/console.controller.ts
Normal file
36
apps/api/src/servers/console/console.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
import { ConsoleService } from './console.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/console')
|
||||
@UseGuards(SessionGuard)
|
||||
export class ConsoleController {
|
||||
constructor(
|
||||
private readonly consoleService: ConsoleService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('token')
|
||||
@ApiOperation({ summary: 'Issue a short-lived console WebSocket token' })
|
||||
@ApiResponse({ status: 200, description: 'Console token issued' })
|
||||
async issueToken(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
await findOwnedServer(this.prisma, user.id, serverId);
|
||||
return this.consoleService.issueToken(serverId, user.id);
|
||||
}
|
||||
}
|
||||
150
apps/api/src/servers/console/console.gateway.ts
Normal file
150
apps/api/src/servers/console/console.gateway.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { consoleCommandSchema } from '@hexahost/contracts';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleService } from './console.service';
|
||||
|
||||
@Injectable()
|
||||
export class ConsoleGateway implements OnModuleInit {
|
||||
private readonly logger = new Logger(ConsoleGateway.name);
|
||||
|
||||
constructor(
|
||||
private readonly adapterHost: HttpAdapterHost,
|
||||
private readonly consoleService: ConsoleService,
|
||||
private readonly consoleSession: ConsoleSessionService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const fastify = this.adapterHost.httpAdapter.getInstance();
|
||||
|
||||
fastify.get(
|
||||
'/api/v1/servers/:serverId/console/ws',
|
||||
{ websocket: true },
|
||||
(socket: WebSocket, request: FastifyRequest) => {
|
||||
void this.handleConnection(socket, request);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async handleConnection(
|
||||
socket: WebSocket,
|
||||
request: FastifyRequest,
|
||||
): Promise<void> {
|
||||
const params = request.params as { serverId?: string };
|
||||
const query = request.query as Record<string, string | string[] | undefined>;
|
||||
const serverId = params.serverId;
|
||||
const token = this.readQueryParam(query['token']);
|
||||
|
||||
if (!serverId || !token) {
|
||||
socket.close(4400, 'serverId and token are required');
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
({ userId } = this.consoleService.validateToken(token, serverId));
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error ? error.message : 'Console authorization failed';
|
||||
socket.close(4401, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.consoleSession.subscribe(serverId, socket);
|
||||
|
||||
socket.on('close', () => {
|
||||
this.consoleSession.unsubscribe(serverId, socket);
|
||||
});
|
||||
|
||||
socket.on('message', (data) => {
|
||||
void this.handleCommand(serverId, userId, data.toString());
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.logger.error(
|
||||
`Console WebSocket error for server ${serverId}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({ type: 'connected', serverId }));
|
||||
}
|
||||
|
||||
private async handleCommand(
|
||||
serverId: string,
|
||||
userId: string,
|
||||
raw: string,
|
||||
): Promise<void> {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = consoleCommandSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = await this.prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (!server?.nodeId || server.status !== 'RUNNING') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.command',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
command: result.data.command,
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.console.command',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { command: result.data.command },
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Console command failed for ${serverId}: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
private readQueryParam(
|
||||
value: string | string[] | undefined,
|
||||
): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
22
apps/api/src/servers/console/console.module.ts
Normal file
22
apps/api/src/servers/console/console.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
import { ConsoleController } from './console.controller';
|
||||
import { ConsoleGateway } from './console.gateway';
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleService } from './console.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule, forwardRef(() => NodeBridgeModule)],
|
||||
controllers: [ConsoleController],
|
||||
providers: [
|
||||
ConsoleSessionService,
|
||||
ConsoleService,
|
||||
ConsoleGateway,
|
||||
],
|
||||
exports: [ConsoleSessionService, ConsoleService],
|
||||
})
|
||||
export class ConsoleModule {}
|
||||
74
apps/api/src/servers/console/console.service.ts
Normal file
74
apps/api/src/servers/console/console.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { hashToken, verifyTokenHash } from '@hexahost/auth';
|
||||
import type { ConsoleTokenResponse } from '@hexahost/contracts';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
interface ConsoleTokenRecord {
|
||||
serverId: string;
|
||||
userId: string;
|
||||
tokenHash: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class ConsoleService {
|
||||
private readonly tokens = new Map<string, ConsoleTokenRecord>();
|
||||
|
||||
issueToken(serverId: string, userId: string): ConsoleTokenResponse {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);
|
||||
|
||||
this.tokens.set(tokenHash, {
|
||||
serverId,
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const config = getConfig();
|
||||
const wsUrl = `${config.API_URL.replace(/^http/, 'ws')}/api/v1/servers/${serverId}/console/ws`;
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
wsUrl,
|
||||
};
|
||||
}
|
||||
|
||||
validateToken(
|
||||
token: string,
|
||||
serverId: string,
|
||||
): { userId: string } {
|
||||
const now = new Date();
|
||||
this.pruneExpired(now);
|
||||
|
||||
for (const record of this.tokens.values()) {
|
||||
if (
|
||||
record.serverId === serverId &&
|
||||
record.expiresAt > now &&
|
||||
verifyTokenHash(token, record.tokenHash)
|
||||
) {
|
||||
return { userId: record.userId };
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid or expired console token');
|
||||
}
|
||||
|
||||
private pruneExpired(now: Date): void {
|
||||
for (const [hash, record] of this.tokens.entries()) {
|
||||
if (record.expiresAt <= now) {
|
||||
this.tokens.delete(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
apps/api/src/servers/files/files.controller.ts
Normal file
68
apps/api/src/servers/files/files.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
listFilesQuerySchema,
|
||||
readFileContentQuerySchema,
|
||||
updateFileContentSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/files')
|
||||
@UseGuards(SessionGuard)
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List files in the server data directory' })
|
||||
@ApiResponse({ status: 200, description: 'Directory listing' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Query(new ZodValidationPipe(listFilesQuerySchema)) query: { path: string },
|
||||
) {
|
||||
return this.filesService.listFiles(user.id, serverId, query.path);
|
||||
}
|
||||
|
||||
@Get('content')
|
||||
@ApiOperation({ summary: 'Read a text file from the server' })
|
||||
@ApiResponse({ status: 200, description: 'File content' })
|
||||
read(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Query(new ZodValidationPipe(readFileContentQuerySchema))
|
||||
query: { path: string },
|
||||
) {
|
||||
return this.filesService.readFile(user.id, serverId, query.path);
|
||||
}
|
||||
|
||||
@Put('content')
|
||||
@ApiOperation({ summary: 'Write a text file on the server' })
|
||||
@ApiResponse({ status: 200, description: 'Updated file content' })
|
||||
write(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(updateFileContentSchema)) body: unknown,
|
||||
) {
|
||||
return this.filesService.writeFile(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<FilesService['writeFile']>[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/servers/files/files.module.ts
Normal file
15
apps/api/src/servers/files/files.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, forwardRef(() => NodeBridgeModule)],
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService],
|
||||
exports: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
139
apps/api/src/servers/files/files.service.ts
Normal file
139
apps/api/src/servers/files/files.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
FileContent,
|
||||
FileListResponse,
|
||||
UpdateFileContent,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
import { assertSafeServerPath } from '../shared/server-path.util';
|
||||
|
||||
const FILE_ACCESS_STATUSES = new Set<GameServer['status']>(['RUNNING', 'STOPPED']);
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async listFiles(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
path: string,
|
||||
): Promise<FileListResponse> {
|
||||
const server = await this.requireFileAccess(userId, serverId);
|
||||
const safePath = assertSafeServerPath(path);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.files.list',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
path: safePath,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to list files',
|
||||
);
|
||||
}
|
||||
|
||||
return response.result as FileListResponse;
|
||||
}
|
||||
|
||||
async readFile(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
path: string,
|
||||
): Promise<FileContent> {
|
||||
const server = await this.requireFileAccess(userId, serverId);
|
||||
const safePath = assertSafeServerPath(path);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.files.read',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
path: safePath,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to read file',
|
||||
);
|
||||
}
|
||||
|
||||
return response.result as FileContent;
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: UpdateFileContent,
|
||||
): Promise<FileContent> {
|
||||
const server = await this.requireFileAccess(userId, serverId);
|
||||
const safePath = assertSafeServerPath(input.path);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.files.write',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
path: safePath,
|
||||
content: input.content,
|
||||
encoding: input.encoding,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to write file',
|
||||
);
|
||||
}
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.file.write',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { path: safePath },
|
||||
});
|
||||
|
||||
return response.result as FileContent;
|
||||
}
|
||||
|
||||
private async requireFileAccess(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<GameServer> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
if (!FILE_ACCESS_STATUSES.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
`File access is not available while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
30
apps/api/src/servers/players/players.controller.ts
Normal file
30
apps/api/src/servers/players/players.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { PlayersService } from './players.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/players')
|
||||
@UseGuards(SessionGuard)
|
||||
export class PlayersController {
|
||||
constructor(private readonly playersService: PlayersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List online players, whitelist, and operators' })
|
||||
@ApiResponse({ status: 200, description: 'Player overview' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.playersService.listPlayers(user.id, serverId);
|
||||
}
|
||||
}
|
||||
22
apps/api/src/servers/players/players.module.ts
Normal file
22
apps/api/src/servers/players/players.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
|
||||
import { PlayersController } from './players.controller';
|
||||
import { PlayersService } from './players.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
PrismaModule,
|
||||
forwardRef(() => NodeBridgeModule),
|
||||
forwardRef(() => FilesModule),
|
||||
],
|
||||
controllers: [PlayersController],
|
||||
providers: [PlayersService],
|
||||
exports: [PlayersService],
|
||||
})
|
||||
export class PlayersModule {}
|
||||
109
apps/api/src/servers/players/players.service.ts
Normal file
109
apps/api/src/servers/players/players.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { PlayerListResponse } from '@hexahost/contracts';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
const WHITELIST_FILE = 'whitelist.json';
|
||||
const OPS_FILE = 'ops.json';
|
||||
|
||||
@Injectable()
|
||||
export class PlayersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
async listPlayers(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<PlayerListResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
const [online, whitelist, operators] = await Promise.all([
|
||||
this.fetchOnlinePlayers(server),
|
||||
this.readJsonFile(userId, serverId, WHITELIST_FILE, []),
|
||||
this.readJsonFile(userId, serverId, OPS_FILE, []),
|
||||
]);
|
||||
|
||||
return {
|
||||
online,
|
||||
whitelist,
|
||||
operators,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchOnlinePlayers(
|
||||
server: GameServer,
|
||||
): Promise<PlayerListResponse['online']> {
|
||||
if (server.status !== 'RUNNING') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.command',
|
||||
{
|
||||
serverId: server.id,
|
||||
generation: server.version,
|
||||
command: 'list',
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const output =
|
||||
typeof response.result === 'object' &&
|
||||
response.result !== null &&
|
||||
'output' in response.result &&
|
||||
typeof (response.result as { output: unknown }).output === 'string'
|
||||
? (response.result as { output: string }).output
|
||||
: typeof response.result === 'string'
|
||||
? response.result
|
||||
: '';
|
||||
|
||||
return this.parseListCommandOutput(output);
|
||||
}
|
||||
|
||||
private parseListCommandOutput(output: string): PlayerListResponse['online'] {
|
||||
const match = output.match(/There are \d+ of a max(?:imum)? of \d+ players online:(.*)$/i);
|
||||
if (!match?.[1]?.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return match[1]
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
private async readJsonFile<T>(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
path: string,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const file = await this.filesService.readFile(userId, serverId, path);
|
||||
return JSON.parse(file.content) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
apps/api/src/servers/properties/properties.controller.ts
Normal file
50
apps/api/src/servers/properties/properties.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { updateServerPropertiesSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { PropertiesService } from './properties.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/properties')
|
||||
@UseGuards(SessionGuard)
|
||||
export class PropertiesController {
|
||||
constructor(private readonly propertiesService: PropertiesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get validated server.properties values' })
|
||||
@ApiResponse({ status: 200, description: 'Server properties' })
|
||||
get(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.propertiesService.getProperties(user.id, serverId);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ApiOperation({ summary: 'Update server.properties values' })
|
||||
@ApiResponse({ status: 200, description: 'Updated server properties' })
|
||||
patch(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(updateServerPropertiesSchema)) body: unknown,
|
||||
) {
|
||||
return this.propertiesService.updateProperties(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<PropertiesService['updateProperties']>[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
16
apps/api/src/servers/properties/properties.module.ts
Normal file
16
apps/api/src/servers/properties/properties.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
|
||||
import { PropertiesController } from './properties.controller';
|
||||
import { PropertiesService } from './properties.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule, forwardRef(() => FilesModule)],
|
||||
controllers: [PropertiesController],
|
||||
providers: [PropertiesService],
|
||||
exports: [PropertiesService],
|
||||
})
|
||||
export class PropertiesModule {}
|
||||
88
apps/api/src/servers/properties/properties.service.ts
Normal file
88
apps/api/src/servers/properties/properties.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
serverPropertiesSchema,
|
||||
updateServerPropertiesSchema,
|
||||
type ServerPropertiesResponse,
|
||||
type UpdateServerProperties,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
import {
|
||||
PROPERTIES_FILE,
|
||||
parsePropertiesFile,
|
||||
serializePropertiesFile,
|
||||
} from './server-properties.util';
|
||||
|
||||
@Injectable()
|
||||
export class PropertiesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async getProperties(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerPropertiesResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const file = await this.filesService.readFile(
|
||||
userId,
|
||||
serverId,
|
||||
PROPERTIES_FILE,
|
||||
);
|
||||
|
||||
const parsed = parsePropertiesFile(file.content);
|
||||
const properties = serverPropertiesSchema.parse(parsed);
|
||||
|
||||
return {
|
||||
properties,
|
||||
raw: file.content,
|
||||
};
|
||||
}
|
||||
|
||||
async updateProperties(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
patch: UpdateServerProperties,
|
||||
): Promise<ServerPropertiesResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const validatedPatch = updateServerPropertiesSchema.parse(patch);
|
||||
const current = await this.filesService.readFile(
|
||||
userId,
|
||||
serverId,
|
||||
PROPERTIES_FILE,
|
||||
);
|
||||
|
||||
const merged = {
|
||||
...parsePropertiesFile(current.content),
|
||||
...validatedPatch,
|
||||
};
|
||||
|
||||
const properties = serverPropertiesSchema.parse(merged);
|
||||
const raw = serializePropertiesFile(properties, current.content);
|
||||
|
||||
await this.filesService.writeFile(userId, serverId, {
|
||||
path: PROPERTIES_FILE,
|
||||
content: raw,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.properties.update',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { keys: Object.keys(validatedPatch) },
|
||||
});
|
||||
|
||||
return { properties, raw };
|
||||
}
|
||||
}
|
||||
89
apps/api/src/servers/properties/server-properties.util.ts
Normal file
89
apps/api/src/servers/properties/server-properties.util.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
const PROPERTIES_FILE = 'server.properties';
|
||||
|
||||
function parsePropertiesFile(raw: string): Record<string, string | number | boolean> {
|
||||
const result: Record<string, string | number | boolean> = {};
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim();
|
||||
result[key] = coercePropertyValue(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function coercePropertyValue(value: string): string | number | boolean {
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const asNumber = Number(value);
|
||||
if (value !== '' && !Number.isNaN(asNumber)) {
|
||||
return asNumber;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function serializePropertiesFile(
|
||||
properties: Record<string, string | number | boolean>,
|
||||
originalRaw: string,
|
||||
): string {
|
||||
const knownKeys = new Set(Object.keys(properties));
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const line of originalRaw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator === -1) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
if (knownKeys.has(key)) {
|
||||
lines.push(`${key}=${formatPropertyValue(properties[key]!)}`);
|
||||
knownKeys.delete(key);
|
||||
} else {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of knownKeys) {
|
||||
lines.push(`${key}=${formatPropertyValue(properties[key]!)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatPropertyValue(value: string | number | boolean): string {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export {
|
||||
PROPERTIES_FILE,
|
||||
parsePropertiesFile,
|
||||
serializePropertiesFile,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -73,4 +74,15 @@ export class ServersController {
|
||||
) {
|
||||
return this.serversService.stopServer(user.id, id);
|
||||
}
|
||||
|
||||
@Post(':id/restart')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({ summary: 'Restart a game server' })
|
||||
restart(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('skip') skip?: string,
|
||||
) {
|
||||
return this.serversService.restartServer(user.id, id, skip === 'true');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { PropertiesModule } from './properties/properties.module';
|
||||
import { ServerStateService } from './server-state.service';
|
||||
import {
|
||||
REDIS_CLIENT,
|
||||
@@ -19,7 +24,15 @@ import { PlansController } from './plans.controller';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule, AuthModule],
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => FilesModule),
|
||||
forwardRef(() => PropertiesModule),
|
||||
forwardRef(() => PlayersModule),
|
||||
forwardRef(() => NodeBridgeModule),
|
||||
],
|
||||
controllers: [ServersController, PlansController],
|
||||
providers: [
|
||||
ServersService,
|
||||
@@ -59,6 +72,11 @@ import { PlansService } from './plans.service';
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [ServersService, REDIS_CLIENT],
|
||||
exports: [
|
||||
ServersService,
|
||||
REDIS_CLIENT,
|
||||
ConsoleModule,
|
||||
NodeBridgeModule,
|
||||
],
|
||||
})
|
||||
export class ServersModule {}
|
||||
|
||||
@@ -2,10 +2,8 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import type Redis from 'ioredis';
|
||||
@@ -22,6 +20,7 @@ type GameServerStatus = GameServer['status'];
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { findOwnedServer } from './shared/server-ownership.util';
|
||||
import { ServerStateService } from './server-state.service';
|
||||
|
||||
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
|
||||
@@ -77,15 +76,30 @@ export class ServersService {
|
||||
}
|
||||
|
||||
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
|
||||
const server = await this.findOwnedServer(userId, serverId);
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
return this.toResponse(server);
|
||||
}
|
||||
|
||||
async restartServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
skipStop = false,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!skipStop && server.status === 'RUNNING') {
|
||||
await this.stopServer(userId, serverId);
|
||||
}
|
||||
|
||||
return this.startServer(userId, serverId);
|
||||
}
|
||||
|
||||
async startServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await this.findOwnedServer(userId, serverId);
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||
@@ -143,7 +157,7 @@ export class ServersService {
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await this.findOwnedServer(userId, serverId);
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
const targetStatus = this.serverState.resolveStopTarget(server.status);
|
||||
@@ -225,25 +239,6 @@ export class ServersService {
|
||||
);
|
||||
}
|
||||
|
||||
private async findOwnedServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<GameServer> {
|
||||
const server = await this.prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
throw new NotFoundException('Server not found');
|
||||
}
|
||||
|
||||
if (server.userId !== userId) {
|
||||
throw new ForbiddenException('You do not own this server');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
private async transitionServer(
|
||||
server: GameServer,
|
||||
toStatus: GameServerStatus,
|
||||
|
||||
27
apps/api/src/servers/shared/server-ownership.util.ts
Normal file
27
apps/api/src/servers/shared/server-ownership.util.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
import type { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export async function findOwnedServer(
|
||||
prisma: PrismaService,
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<GameServer> {
|
||||
const server = await prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
throw new NotFoundException('Server not found');
|
||||
}
|
||||
|
||||
if (server.userId !== userId) {
|
||||
throw new ForbiddenException('You do not own this server');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
20
apps/api/src/servers/shared/server-path.util.ts
Normal file
20
apps/api/src/servers/shared/server-path.util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export function assertSafeServerPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
|
||||
if (!normalized || normalized === '.') {
|
||||
return '.';
|
||||
}
|
||||
|
||||
if (normalized.includes('\0')) {
|
||||
throw new BadRequestException('Invalid path');
|
||||
}
|
||||
|
||||
const segments = normalized.split('/');
|
||||
if (segments.some((segment) => segment === '..' || segment === '')) {
|
||||
throw new BadRequestException('Path traversal is not allowed');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
111
apps/api/test/phase3.test.js
Normal file
111
apps/api/test/phase3.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
parsePropertiesFile,
|
||||
serializePropertiesFile,
|
||||
} from '../dist/servers/properties/server-properties.util.js';
|
||||
import { assertSafeServerPath } from '../dist/servers/shared/server-path.util.js';
|
||||
|
||||
describe('phase 3 server utilities', () => {
|
||||
it('parses server.properties with types', () => {
|
||||
const raw = [
|
||||
'# comment',
|
||||
'max-players=20',
|
||||
'online-mode=false',
|
||||
'motd=Hello World',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const parsed = parsePropertiesFile(raw);
|
||||
assert.equal(parsed['max-players'], 20);
|
||||
assert.equal(parsed['online-mode'], false);
|
||||
assert.equal(parsed['motd'], 'Hello World');
|
||||
});
|
||||
|
||||
it('serializes server.properties while preserving comments', () => {
|
||||
const raw = '# header\nmax-players=10\nonline-mode=true\n';
|
||||
const updated = parsePropertiesFile(raw);
|
||||
updated['max-players'] = 25;
|
||||
updated['online-mode'] = false;
|
||||
|
||||
const serialized = serializePropertiesFile(updated, raw);
|
||||
assert.match(serialized, /^# header/);
|
||||
assert.match(serialized, /max-players=25/);
|
||||
assert.match(serialized, /online-mode=false/);
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
assert.throws(
|
||||
() => assertSafeServerPath('../etc/passwd'),
|
||||
(error) => error.message.includes('traversal'),
|
||||
);
|
||||
assert.throws(() => assertSafeServerPath('foo//bar'));
|
||||
});
|
||||
|
||||
it('normalizes safe paths', () => {
|
||||
assert.equal(assertSafeServerPath(''), '.');
|
||||
assert.equal(assertSafeServerPath('logs/latest.log'), 'logs/latest.log');
|
||||
assert.equal(assertSafeServerPath('\\world\\level.dat'), 'world/level.dat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase 3 api contract', () => {
|
||||
it('documents console, files, properties and players routes', () => {
|
||||
const files = [
|
||||
'servers/console/console.controller.ts',
|
||||
'servers/files/files.controller.ts',
|
||||
'servers/properties/properties.controller.ts',
|
||||
'servers/players/players.controller.ts',
|
||||
'servers/console/console.gateway.ts',
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
|
||||
assert.ok(source.length > 0, `${file} should exist`);
|
||||
}
|
||||
|
||||
const consoleSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/console/console.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(consoleSource.includes("@Get('token')"));
|
||||
|
||||
const gatewaySource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/console/console.gateway.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(gatewaySource.includes('console/ws'));
|
||||
|
||||
const filesSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/files/files.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(filesSource.includes("@Get('content')"));
|
||||
|
||||
const propertiesSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/properties/properties.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(propertiesSource.includes("@Patch()"));
|
||||
|
||||
const playersSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/players/players.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(playersSource.includes('whitelist'));
|
||||
|
||||
const combined = [
|
||||
consoleSource,
|
||||
gatewaySource,
|
||||
filesSource,
|
||||
propertiesSource,
|
||||
playersSource,
|
||||
].join('\n');
|
||||
for (const snippet of ['files', 'properties', 'players']) {
|
||||
assert.ok(combined.includes(snippet), `missing route hint ${snippet}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/ws"
|
||||
)
|
||||
|
||||
const Version = "0.2.0-phase2"
|
||||
const Version = "0.3.0-phase3"
|
||||
|
||||
// Agent coordinates the node agent lifecycle until shutdown.
|
||||
type Agent struct {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +13,7 @@ import (
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
@@ -189,3 +193,134 @@ func mapSoftwareFamily(family string) string {
|
||||
return "VANILLA"
|
||||
}
|
||||
}
|
||||
|
||||
// StreamLogs streams container logs and invokes callback for each line.
|
||||
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
|
||||
opts := container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: follow,
|
||||
Timestamps: false,
|
||||
}
|
||||
if tail > 0 {
|
||||
opts.Tail = strconv.Itoa(tail)
|
||||
} else {
|
||||
opts.Tail = "all"
|
||||
}
|
||||
|
||||
reader, err := c.cli.ContainerLogs(ctx, containerID, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("container logs: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
return readMultiplexedLogLines(ctx, reader, callback)
|
||||
}
|
||||
|
||||
// ExecCommand runs a command inside a container and returns stdout/stderr.
|
||||
func (c *Client) ExecCommand(ctx context.Context, containerID string, cmd []string) (stdout, stderr string, err error) {
|
||||
execResp, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("exec create: %w", err)
|
||||
}
|
||||
|
||||
attachResp, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("exec attach: %w", err)
|
||||
}
|
||||
defer attachResp.Close()
|
||||
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
if _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader); err != nil {
|
||||
return outBuf.String(), errBuf.String(), fmt.Errorf("exec copy: %w", err)
|
||||
}
|
||||
|
||||
inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID)
|
||||
if err != nil {
|
||||
return outBuf.String(), errBuf.String(), fmt.Errorf("exec inspect: %w", err)
|
||||
}
|
||||
if inspect.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(errBuf.String())
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(outBuf.String())
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("exit code %d", inspect.ExitCode)
|
||||
}
|
||||
return outBuf.String(), errBuf.String(), fmt.Errorf("exec failed: %s", msg)
|
||||
}
|
||||
|
||||
return outBuf.String(), errBuf.String(), nil
|
||||
}
|
||||
|
||||
// ExecRcon runs a Minecraft console command via rcon-cli inside the container.
|
||||
func (c *Client) ExecRcon(ctx context.Context, containerID, command string) (stdout, stderr string, err error) {
|
||||
return c.ExecCommand(ctx, containerID, []string{"rcon-cli", command})
|
||||
}
|
||||
|
||||
func readMultiplexedLogLines(ctx context.Context, r io.Reader, callback func(stream, line string)) error {
|
||||
header := make([]byte, 8)
|
||||
var partial []byte
|
||||
var partialStream string
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := io.ReadFull(r, header)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stream := "stdout"
|
||||
if header[0] == 2 {
|
||||
stream = "stderr"
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint32(header[4:8])
|
||||
payload := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if partialStream != "" && partialStream != stream {
|
||||
if len(partial) > 0 {
|
||||
callback(partialStream, string(partial))
|
||||
partial = nil
|
||||
}
|
||||
}
|
||||
partialStream = stream
|
||||
partial = append(partial, payload...)
|
||||
|
||||
for {
|
||||
idx := bytes.IndexByte(partial, '\n')
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
line := string(partial[:idx])
|
||||
partial = partial[idx+1:]
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
if line != "" {
|
||||
callback(stream, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(partial) > 0 {
|
||||
stream := partialStream
|
||||
if stream == "" {
|
||||
stream = "stdout"
|
||||
}
|
||||
callback(stream, string(partial))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
280
apps/node-agent/internal/files/safe.go
Normal file
280
apps/node-agent/internal/files/safe.go
Normal 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
|
||||
}
|
||||
94
apps/node-agent/internal/files/safe_test.go
Normal file
94
apps/node-agent/internal/files/safe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -137,12 +137,76 @@ type ServerStopPayload struct {
|
||||
OperationMeta
|
||||
}
|
||||
|
||||
// ServerCommandPayload instructs the agent to run a console command via RCON.
|
||||
type ServerCommandPayload struct {
|
||||
OperationMeta
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ServerLogsSubscribePayload instructs the agent to stream container logs.
|
||||
type ServerLogsSubscribePayload struct {
|
||||
OperationMeta
|
||||
TailLines int `json:"tailLines"`
|
||||
Follow bool `json:"follow"`
|
||||
}
|
||||
|
||||
// ServerLogPayload carries a single log line from a game server.
|
||||
type ServerLogPayload struct {
|
||||
OperationMeta
|
||||
Line string `json:"line"`
|
||||
Stream string `json:"stream"`
|
||||
}
|
||||
|
||||
// ServerFilesListPayload lists files in a server data directory.
|
||||
type ServerFilesListPayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ServerFilesReadPayload reads a file from the server data directory.
|
||||
type ServerFilesReadPayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
MaxBytes int `json:"maxBytes"`
|
||||
}
|
||||
|
||||
// ServerFilesWritePayload writes content to a file in the server data directory.
|
||||
type ServerFilesWritePayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Create bool `json:"create"`
|
||||
}
|
||||
|
||||
// FileEntry describes a file or directory in a list response.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// ServerFilesListResultPayload is the result of a files.list operation.
|
||||
type ServerFilesListResultPayload struct {
|
||||
Entries []FileEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// ServerFilesReadResultPayload is the result of a files.read operation.
|
||||
type ServerFilesReadResultPayload struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
|
||||
type ServerOperationResultPayload struct {
|
||||
OperationMeta
|
||||
ResultCode string `json:"resultCode"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
ResultCode string `json:"resultCode"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
// ServerStatePayload reports the observed server state.
|
||||
|
||||
@@ -212,3 +212,168 @@ func TestNewEnvelope(t *testing.T) {
|
||||
t.Fatalf("protocol version %d", env.ProtocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase3Payloads_JSONRoundtrip(t *testing.T) {
|
||||
meta := OperationMeta{
|
||||
ServerID: "srv-01",
|
||||
Generation: 3,
|
||||
}
|
||||
|
||||
command := ServerCommandPayload{
|
||||
OperationMeta: meta,
|
||||
Command: "say hello",
|
||||
}
|
||||
commandRaw, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decodedCommand ServerCommandPayload
|
||||
if err := json.Unmarshal(commandRaw, &decodedCommand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decodedCommand.Command != command.Command {
|
||||
t.Fatalf("command: got %q want %q", decodedCommand.Command, command.Command)
|
||||
}
|
||||
|
||||
logsSub := ServerLogsSubscribePayload{
|
||||
OperationMeta: meta,
|
||||
TailLines: 100,
|
||||
Follow: true,
|
||||
}
|
||||
logsSubRaw, err := json.Marshal(logsSub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decodedLogsSub ServerLogsSubscribePayload
|
||||
if err := json.Unmarshal(logsSubRaw, &decodedLogsSub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decodedLogsSub.TailLines != 100 || !decodedLogsSub.Follow {
|
||||
t.Fatalf("logs subscribe mismatch: %+v", decodedLogsSub)
|
||||
}
|
||||
|
||||
filesList := ServerFilesListPayload{
|
||||
OperationMeta: meta,
|
||||
Path: "world",
|
||||
}
|
||||
filesListRaw, err := json.Marshal(filesList)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-files-list",
|
||||
Type: TypeServerFilesList,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: filesListRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("files list envelope: %v", err)
|
||||
}
|
||||
|
||||
filesRead := ServerFilesReadPayload{
|
||||
OperationMeta: meta,
|
||||
Path: "server.properties",
|
||||
MaxBytes: 4096,
|
||||
}
|
||||
filesReadRaw, err := json.Marshal(filesRead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-files-read",
|
||||
Type: TypeServerFilesRead,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: filesReadRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("files read envelope: %v", err)
|
||||
}
|
||||
|
||||
filesWrite := ServerFilesWritePayload{
|
||||
OperationMeta: meta,
|
||||
Path: "ops.txt",
|
||||
Content: "test",
|
||||
Create: true,
|
||||
}
|
||||
filesWriteRaw, err := json.Marshal(filesWrite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-files-write",
|
||||
Type: TypeServerFilesWrite,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: filesWriteRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("files write envelope: %v", err)
|
||||
}
|
||||
|
||||
logLine := ServerLogPayload{
|
||||
OperationMeta: meta,
|
||||
Line: "[Server] Done",
|
||||
Stream: "stdout",
|
||||
}
|
||||
logLineRaw, err := json.Marshal(logLine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-log",
|
||||
Type: TypeServerLog,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: logLineRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("server log envelope: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerOperationResultPayload_WithResult(t *testing.T) {
|
||||
listResult, err := json.Marshal(ServerFilesListResultPayload{
|
||||
Entries: []FileEntry{{
|
||||
Name: "server.properties",
|
||||
Path: "server.properties",
|
||||
IsDir: false,
|
||||
Size: 128,
|
||||
ModifiedAt: "2026-06-26T12:00:00Z",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload := ServerOperationResultPayload{
|
||||
OperationMeta: OperationMeta{ServerID: "srv-01", Generation: 1},
|
||||
ResultCode: "ok",
|
||||
Operation: "files.list",
|
||||
Result: listResult,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded ServerOperationResultPayload
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Operation != "files.list" {
|
||||
t.Fatalf("operation: got %q", decoded.Operation)
|
||||
}
|
||||
if len(decoded.Result) == 0 {
|
||||
t.Fatal("expected result payload")
|
||||
}
|
||||
|
||||
var result ServerFilesListResultPayload
|
||||
if err := json.Unmarshal(decoded.Result, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Entries) != 1 || result.Entries[0].Name != "server.properties" {
|
||||
t.Fatalf("unexpected list result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/files"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -27,6 +29,7 @@ const (
|
||||
// Responder sends protocol responses back to the control plane.
|
||||
type Responder interface {
|
||||
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
|
||||
SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error
|
||||
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||
}
|
||||
@@ -42,20 +45,22 @@ type serverRecord struct {
|
||||
|
||||
// Manager tracks provisioned servers and executes lifecycle operations.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
docker *docker.Client
|
||||
servers map[string]*serverRecord
|
||||
resp Responder
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
docker *docker.Client
|
||||
servers map[string]*serverRecord
|
||||
logStreams map[string]context.CancelFunc
|
||||
resp Responder
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewManager creates a runtime manager backed by the given Docker client.
|
||||
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
|
||||
return &Manager{
|
||||
docker: dockerClient,
|
||||
servers: make(map[string]*serverRecord),
|
||||
resp: resp,
|
||||
log: log,
|
||||
docker: dockerClient,
|
||||
servers: make(map[string]*serverRecord),
|
||||
logStreams: make(map[string]context.CancelFunc),
|
||||
resp: resp,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +189,8 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
|
||||
|
||||
m.stopLogStream(meta.ServerID)
|
||||
|
||||
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
|
||||
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
|
||||
@@ -204,6 +211,147 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
|
||||
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||
}
|
||||
|
||||
// HandleLogsSubscribe starts streaming container logs for a server.
|
||||
func (m *Manager) HandleLogsSubscribe(ctx context.Context, correlationID string, payload protocol.ServerLogsSubscribePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.logStreams[meta.ServerID]; ok {
|
||||
existing()
|
||||
}
|
||||
m.logStreams[meta.ServerID] = cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
if current, ok := m.logStreams[meta.ServerID]; ok && current == cancel {
|
||||
delete(m.logStreams, meta.ServerID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
err := m.docker.StreamLogs(streamCtx, rec.ContainerID, payload.TailLines, payload.Follow, func(stream, line string) {
|
||||
logPayload := protocol.ServerLogPayload{
|
||||
OperationMeta: meta,
|
||||
Line: line,
|
||||
Stream: stream,
|
||||
}
|
||||
if err := m.resp.SendLog(ctx, correlationID, logPayload); err != nil {
|
||||
m.log.Warn("send server.log failed",
|
||||
"server_id", meta.ServerID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
})
|
||||
if err != nil && streamCtx.Err() == nil {
|
||||
m.log.Warn("log stream ended with error",
|
||||
"server_id", meta.ServerID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// HandleCommand executes a console command via RCON.
|
||||
func (m *Manager) HandleCommand(ctx context.Context, correlationID string, payload protocol.ServerCommandPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "command", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
stdout, stderr, err := m.docker.ExecRcon(ctx, rec.ContainerID, payload.Command)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "command", "COMMAND_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "command", map[string]string{
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesList lists files in the server data directory.
|
||||
func (m *Manager) HandleFilesList(ctx context.Context, correlationID string, payload protocol.ServerFilesListPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.list", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := files.ListDir(rec.DataPath, payload.Path)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.list", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result := protocol.ServerFilesListResultPayload{
|
||||
Entries: make([]protocol.FileEntry, 0, len(entries)),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
result.Entries = append(result.Entries, protocol.FileEntry{
|
||||
Name: entry.Name,
|
||||
Path: entry.Path,
|
||||
IsDir: entry.IsDir,
|
||||
Size: entry.Size,
|
||||
ModifiedAt: entry.ModifiedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.list", result)
|
||||
}
|
||||
|
||||
// HandleFilesRead reads a file from the server data directory.
|
||||
func (m *Manager) HandleFilesRead(ctx context.Context, correlationID string, payload protocol.ServerFilesReadPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.read", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
content, truncated, err := files.ReadFile(rec.DataPath, payload.Path, payload.MaxBytes)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.read", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.read", protocol.ServerFilesReadResultPayload{
|
||||
Path: payload.Path,
|
||||
Content: content,
|
||||
Truncated: truncated,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesWrite writes a file in the server data directory.
|
||||
func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, payload protocol.ServerFilesWritePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.write", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if err := files.WriteFile(rec.DataPath, payload.Path, payload.Content, payload.Create); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.write", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.write", map[string]string{
|
||||
"path": payload.Path,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -223,6 +371,15 @@ func (m *Manager) setState(serverID, state string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) stopLogStream(serverID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if cancel, ok := m.logStreams[serverID]; ok {
|
||||
cancel()
|
||||
delete(m.logStreams, serverID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
|
||||
payload := protocol.ServerStatePayload{
|
||||
OperationMeta: meta,
|
||||
@@ -267,3 +424,59 @@ func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) completeOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation string, result any) {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, operation, "RESULT_MARSHAL_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payload := protocol.ServerOperationResultPayload{
|
||||
OperationMeta: meta,
|
||||
ResultCode: ResultCodeOK,
|
||||
Operation: operation,
|
||||
Result: raw,
|
||||
}
|
||||
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send operation complete failed",
|
||||
"server_id", meta.ServerID,
|
||||
"operation", operation,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) failOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation, errorCode, errorMessage string) {
|
||||
payload := protocol.ServerOperationResultPayload{
|
||||
OperationMeta: meta,
|
||||
ResultCode: ResultCodeError,
|
||||
Operation: operation,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send operation failed failed",
|
||||
"server_id", meta.ServerID,
|
||||
"operation", operation,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func filesErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, files.ErrPathTraversal):
|
||||
return "PATH_TRAVERSAL"
|
||||
case errors.Is(err, files.ErrSymlink):
|
||||
return "SYMLINK_NOT_ALLOWED"
|
||||
case errors.Is(err, files.ErrNotFound):
|
||||
return "FILE_NOT_FOUND"
|
||||
case errors.Is(err, files.ErrNotDirectory):
|
||||
return "NOT_A_DIRECTORY"
|
||||
case errors.Is(err, files.ErrIsDirectory):
|
||||
return "IS_DIRECTORY"
|
||||
default:
|
||||
return "FILES_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +274,46 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
}
|
||||
c.runtime.HandleDelete(ctx, correlationID, meta)
|
||||
|
||||
case protocol.TypeServerLogsSubscribe:
|
||||
var payload protocol.ServerLogsSubscribePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.logs.subscribe", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleLogsSubscribe(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerCommand:
|
||||
var payload protocol.ServerCommandPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.command", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleCommand(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesList:
|
||||
var payload protocol.ServerFilesListPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.list", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesList(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesRead:
|
||||
var payload protocol.ServerFilesReadPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.read", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesRead(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesWrite:
|
||||
var payload protocol.ServerFilesWritePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.write", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
|
||||
|
||||
default:
|
||||
c.log.Debug("ignored control message", "type", env.Type)
|
||||
}
|
||||
@@ -333,6 +373,11 @@ func (c *Client) SendState(ctx context.Context, correlationID string, payload pr
|
||||
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendLog implements runtime.Responder.
|
||||
func (c *Client) SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerLog, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendOperationComplete implements runtime.Responder.
|
||||
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
||||
|
||||
@@ -127,7 +127,10 @@
|
||||
"planInvalid": "Ungültiger Tarif ausgewählt",
|
||||
"versionRequired": "Minecraft-Version ist erforderlich",
|
||||
"softwareRequired": "Software-Familie ist erforderlich",
|
||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
|
||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren",
|
||||
"motdMax": "MOTD darf höchstens 256 Zeichen lang sein",
|
||||
"maxPlayersMin": "Mindestens 1 Spieler erforderlich",
|
||||
"maxPlayersMax": "Höchstens 200 Spieler erlaubt"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Server",
|
||||
@@ -180,6 +183,97 @@
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"loadFailed": "Server konnten nicht geladen werden."
|
||||
},
|
||||
"tabs": {
|
||||
"navigation": "Server-Bereiche",
|
||||
"overview": "Übersicht",
|
||||
"console": "Konsole",
|
||||
"files": "Dateien",
|
||||
"properties": "Eigenschaften",
|
||||
"players": "Spieler"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Wird geladen…",
|
||||
"notRunning": "Die Konsole ist nur verfügbar, wenn der Server läuft. Starten Sie den Server über die Übersicht.",
|
||||
"connecting": "Verbindung wird hergestellt…",
|
||||
"connected": "Verbunden",
|
||||
"disconnected": "Getrennt",
|
||||
"clear": "Log leeren",
|
||||
"commandPlaceholder": "Serverbefehl eingeben…",
|
||||
"send": "Senden",
|
||||
"errors": {
|
||||
"connectionFailed": "Verbindung zur Konsole fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"loading": "Wird geladen…",
|
||||
"browser": "Dateibrowser",
|
||||
"editor": "Editor",
|
||||
"up": "Hoch",
|
||||
"empty": "Dieses Verzeichnis ist leer.",
|
||||
"selectFile": "Wählen Sie eine Datei zum Anzeigen oder Bearbeiten.",
|
||||
"readOnlyStopped": "Schreibgeschützt, solange der Server gestoppt ist.",
|
||||
"notAvailable": "Dateien sind nur verfügbar, wenn der Server läuft oder gestoppt ist.",
|
||||
"truncated": "Datei wurde zur Anzeige gekürzt. Bearbeiten Sie mit Vorsicht.",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Datei gespeichert.",
|
||||
"errors": {
|
||||
"loadFailed": "Verzeichnis konnte nicht geladen werden.",
|
||||
"readFailed": "Datei konnte nicht gelesen werden.",
|
||||
"saveFailed": "Datei konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"title": "Server-Eigenschaften",
|
||||
"description": "Bearbeiten Sie gängige server.properties-Einstellungen. Unbekannte Eigenschaften bleiben auf dem Server erhalten.",
|
||||
"loading": "Wird geladen…",
|
||||
"motd": "MOTD",
|
||||
"gamemode": "Spielmodus",
|
||||
"difficulty": "Schwierigkeit",
|
||||
"maxPlayers": "Max. Spieler",
|
||||
"pvp": "PVP",
|
||||
"whitelist": "Whitelist",
|
||||
"hardcore": "Hardcore",
|
||||
"onlineMode": "Online-Modus",
|
||||
"enableCommandBlock": "Befehlsblöcke",
|
||||
"allowFlight": "Fliegen erlauben",
|
||||
"viewDistance": "Sichtweite",
|
||||
"simulationDistance": "Simulationsdistanz",
|
||||
"spawnProtection": "Spawn-Schutz",
|
||||
"save": "Änderungen speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Eigenschaften gespeichert.",
|
||||
"gamemodes": {
|
||||
"survival": "Überleben",
|
||||
"creative": "Kreativ",
|
||||
"adventure": "Abenteuer",
|
||||
"spectator": "Zuschauer"
|
||||
},
|
||||
"difficulties": {
|
||||
"peaceful": "Friedlich",
|
||||
"easy": "Einfach",
|
||||
"normal": "Normal",
|
||||
"hard": "Schwer"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Server-Eigenschaften konnten nicht geladen werden.",
|
||||
"saveFailed": "Server-Eigenschaften konnten nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"loading": "Wird geladen…",
|
||||
"onlineCount": "Spieler online",
|
||||
"whitelistCount": "Whitelist-Einträge",
|
||||
"empty": "Keine Spieler online.",
|
||||
"retry": "Erneut versuchen",
|
||||
"columns": {
|
||||
"name": "Spieler",
|
||||
"ping": "Ping"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Spielerliste konnte nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@
|
||||
"planInvalid": "Invalid plan selected",
|
||||
"versionRequired": "Minecraft version is required",
|
||||
"softwareRequired": "Software family is required",
|
||||
"eulaRequired": "You must accept the Minecraft EULA"
|
||||
"eulaRequired": "You must accept the Minecraft EULA",
|
||||
"motdMax": "MOTD must be at most 256 characters",
|
||||
"maxPlayersMin": "At least 1 player required",
|
||||
"maxPlayersMax": "At most 200 players allowed"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers",
|
||||
@@ -180,6 +183,97 @@
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"loadFailed": "Could not load servers."
|
||||
},
|
||||
"tabs": {
|
||||
"navigation": "Server sections",
|
||||
"overview": "Overview",
|
||||
"console": "Console",
|
||||
"files": "Files",
|
||||
"properties": "Properties",
|
||||
"players": "Players"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Loading…",
|
||||
"notRunning": "The console is only available while the server is running. Start the server from the overview tab.",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"clear": "Clear log",
|
||||
"commandPlaceholder": "Enter server command…",
|
||||
"send": "Send",
|
||||
"errors": {
|
||||
"connectionFailed": "Could not connect to the console."
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"loading": "Loading…",
|
||||
"browser": "File browser",
|
||||
"editor": "Editor",
|
||||
"up": "Up",
|
||||
"empty": "This directory is empty.",
|
||||
"selectFile": "Select a file to view or edit.",
|
||||
"readOnlyStopped": "Read-only while the server is stopped.",
|
||||
"notAvailable": "Files are only available when the server is running or stopped.",
|
||||
"truncated": "File was truncated for display. Edit with care.",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "File saved.",
|
||||
"errors": {
|
||||
"loadFailed": "Could not load directory.",
|
||||
"readFailed": "Could not read file.",
|
||||
"saveFailed": "Could not save file."
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"title": "Server properties",
|
||||
"description": "Edit common server.properties settings. Unknown properties are preserved on the server.",
|
||||
"loading": "Loading…",
|
||||
"motd": "MOTD",
|
||||
"gamemode": "Gamemode",
|
||||
"difficulty": "Difficulty",
|
||||
"maxPlayers": "Max players",
|
||||
"pvp": "PVP",
|
||||
"whitelist": "Whitelist",
|
||||
"hardcore": "Hardcore",
|
||||
"onlineMode": "Online mode",
|
||||
"enableCommandBlock": "Command blocks",
|
||||
"allowFlight": "Allow flight",
|
||||
"viewDistance": "View distance",
|
||||
"simulationDistance": "Simulation distance",
|
||||
"spawnProtection": "Spawn protection",
|
||||
"save": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Properties saved.",
|
||||
"gamemodes": {
|
||||
"survival": "Survival",
|
||||
"creative": "Creative",
|
||||
"adventure": "Adventure",
|
||||
"spectator": "Spectator"
|
||||
},
|
||||
"difficulties": {
|
||||
"peaceful": "Peaceful",
|
||||
"easy": "Easy",
|
||||
"normal": "Normal",
|
||||
"hard": "Hard"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Could not load server properties.",
|
||||
"saveFailed": "Could not save server properties."
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"loading": "Loading…",
|
||||
"onlineCount": "Online players",
|
||||
"whitelistCount": "Whitelist entries",
|
||||
"empty": "No players online.",
|
||||
"retry": "Retry",
|
||||
"columns": {
|
||||
"name": "Player",
|
||||
"ping": "Ping"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Could not load player list."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/console/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/console/page.tsx
Normal 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} />;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/files/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/files/page.tsx
Normal 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} />;
|
||||
}
|
||||
15
apps/web/src/app/[locale]/servers/[id]/layout.tsx
Normal file
15
apps/web/src/app/[locale]/servers/[id]/layout.tsx
Normal 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>;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/players/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/players/page.tsx
Normal 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} />;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/properties/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/properties/page.tsx
Normal 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} />;
|
||||
}
|
||||
215
apps/web/src/components/servers/server-console.tsx
Normal file
215
apps/web/src/components/servers/server-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
import { ServerCard } from "@/components/servers/server-card";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
|
||||
interface ServerDetailContentProps {
|
||||
serverId: string;
|
||||
@@ -13,18 +12,9 @@ interface ServerDetailContentProps {
|
||||
|
||||
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -32,42 +22,8 @@ export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerCard server={data} showDetailsLink={false} />
|
||||
</div>
|
||||
);
|
||||
return <ServerCard server={data} showDetailsLink={false} />;
|
||||
}
|
||||
|
||||
77
apps/web/src/components/servers/server-detail-shell.tsx
Normal file
77
apps/web/src/components/servers/server-detail-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
apps/web/src/components/servers/server-files.tsx
Normal file
227
apps/web/src/components/servers/server-files.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/servers/server-players.tsx
Normal file
96
apps/web/src/components/servers/server-players.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
318
apps/web/src/components/servers/server-properties-form.tsx
Normal file
318
apps/web/src/components/servers/server-properties-form.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
59
apps/web/src/components/servers/server-tabs.tsx
Normal file
59
apps/web/src/components/servers/server-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/lib/api/console.ts
Normal file
86
apps/web/src/lib/api/console.ts
Normal 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 }));
|
||||
}
|
||||
61
apps/web/src/lib/api/files.ts
Normal file
61
apps/web/src/lib/api/files.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
17
apps/web/src/lib/api/players.ts
Normal file
17
apps/web/src/lib/api/players.ts
Normal 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`);
|
||||
}
|
||||
36
apps/web/src/lib/api/properties.ts
Normal file
36
apps/web/src/lib/api/properties.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
69
apps/web/src/lib/utils/ansi.ts
Normal file
69
apps/web/src/lib/utils/ansi.ts
Normal 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
@@ -2,33 +2,32 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: Phase 2 — Single-Node Vertical Slice ✅
|
||||
## Current phase: Phase 3 — Console, files, settings ✅
|
||||
|
||||
### Phase 2 completed
|
||||
### Phase 3 completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| 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 |
|
||||
| API Plans | Done | `GET /api/v1/plans` |
|
||||
| Admin nodes | Done | `POST /api/v1/admin/nodes` with enrollment token |
|
||||
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
|
||||
| Worker lifecycle | Done | start-server, stop-server jobs |
|
||||
| Go node-agent | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
|
||||
| Web server UI | Done | Create, list, detail, start/stop |
|
||||
| Dev seed | Done | `pnpm db:seed` — Free plan + local dev node |
|
||||
| Migrations | Done | `20260626140000_phase2_game_servers` |
|
||||
| Go agent v0.3 | Done | Log stream, RCON commands, safe file I/O |
|
||||
| `@hexahost/contracts` phase 3 | Done | console, files, properties, players |
|
||||
| API NodeBridge | Done | Sync request/response over Redis |
|
||||
| API Console | Done | Token + WS `/api/v1/servers/:id/console/ws` |
|
||||
| API Files | Done | List, read, write with path validation |
|
||||
| API Properties | Done | GET/PATCH `server.properties` |
|
||||
| API Players | Done | Online list + whitelist |
|
||||
| API Restart | Done | `POST /servers/:id/restart?skip=true` |
|
||||
| Audit events | Done | console.command, file.write, properties.update |
|
||||
| Web server tabs | Done | Console, files, properties, players |
|
||||
| Unit tests | Done | Properties parser, path validation, route contracts |
|
||||
|
||||
### Abnahmekriterium Phase 2
|
||||
### Abnahmekriterium Phase 3
|
||||
|
||||
- [x] User creates server in web panel
|
||||
- [x] Provisioning pipeline (API → Worker → Agent)
|
||||
- [x] Start/stop lifecycle
|
||||
- [x] Node enrollment + heartbeat
|
||||
- [x] Connection address shown (`localhost:port`)
|
||||
- [ ] Manual Minecraft join validation (requires Docker + agent running locally)
|
||||
- [x] Live console WebSocket (logs + commands when RUNNING)
|
||||
- [x] File manager (list/read/write, path sandbox)
|
||||
- [x] `server.properties` editor with restart option
|
||||
- [x] Player list + whitelist view
|
||||
- [x] Server management fully via web panel (no direct host access required)
|
||||
- [ ] Manual E2E validation (requires Docker + agent running locally)
|
||||
|
||||
### Local vertical slice
|
||||
|
||||
@@ -46,17 +45,49 @@ set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
|
||||
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
|
||||
|
||||
- Single-node only (no scheduler/queue fairness yet).
|
||||
- Minecraft image pull on first provision can take several minutes.
|
||||
- Node agent on Windows needs Docker Desktop; run agent on host, not in Compose.
|
||||
- mTLS for agent deferred; token-based enrollment only.
|
||||
- Metrics are basic heartbeat payload only.
|
||||
- Console only when server status is `RUNNING`; files readable when `RUNNING` or `STOPPED`.
|
||||
- RCON via `rcon-cli` inside Minecraft container (itzg image).
|
||||
- File writes limited to server data directory; no upload/download archives yet (Phase 4).
|
||||
- Player management read-only (whitelist view); add/remove deferred.
|
||||
- Single-node only; no scheduler fairness yet.
|
||||
|
||||
### Next phase: Phase 3 — Console, files, settings
|
||||
---
|
||||
|
||||
- Live console WebSocket
|
||||
- File manager
|
||||
- server.properties editor
|
||||
- Player lists
|
||||
## Phase 2 — Single-Node Vertical Slice ✅
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| 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
|
||||
|
||||
14
packages/contracts/src/console.ts
Normal file
14
packages/contracts/src/console.ts
Normal 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>;
|
||||
41
packages/contracts/src/files.ts
Normal file
41
packages/contracts/src/files.ts
Normal 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>;
|
||||
@@ -71,3 +71,45 @@ export {
|
||||
type ServerListResponse,
|
||||
type ServerActionResponse,
|
||||
} 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';
|
||||
|
||||
29
packages/contracts/src/players.ts
Normal file
29
packages/contracts/src/players.ts
Normal 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>;
|
||||
41
packages/contracts/src/properties.ts
Normal file
41
packages/contracts/src/properties.ts
Normal 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
Reference in New Issue
Block a user