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
|
## Aktueller Stand
|
||||||
|
|
||||||
**Phase 2** (Single-Node Vertical Slice) — abgeschlossen.
|
**Phase 3** (Konsole, Dateien, Einstellungen) — abgeschlossen.
|
||||||
|
|
||||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||||
|
|
||||||
|
### Server-Verwaltung im Panel
|
||||||
|
|
||||||
|
Nach dem Start eines Servers stehen unter `/servers/[id]` folgende Bereiche zur Verfügung:
|
||||||
|
|
||||||
|
| Tab | Funktion |
|
||||||
|
|-----|----------|
|
||||||
|
| Übersicht | Status, Start/Stop/Restart, Verbindungsadresse |
|
||||||
|
| Konsole | Live-Logs (WebSocket), Befehle per RCON (nur `RUNNING`) |
|
||||||
|
| Dateien | Verzeichnisliste, Dateien lesen/schreiben |
|
||||||
|
| Eigenschaften | `server.properties` bearbeiten, optional Neustart |
|
||||||
|
| Spieler | Online-Spieler und Whitelist (Lesen) |
|
||||||
|
|
||||||
### Minecraft-Server lokal testen
|
### Minecraft-Server lokal testen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"lint": "node -e \"process.exit(0)\"",
|
"lint": "node -e \"process.exit(0)\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test test/**/*.test.js"
|
"test": "tsc && node --test test/**/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
|||||||
@@ -47,6 +47,6 @@ import { SessionService } from './session.service';
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [AuthService, SessionService, SessionGuard],
|
exports: [AuthService, SessionService, SessionGuard, AuditService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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';
|
import { ServersModule } from '../servers/servers.module';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import { NodesGateway } from './nodes.gateway';
|
|||||||
import { NodesService } from './nodes.service';
|
import { NodesService } from './nodes.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ServersModule],
|
imports: [forwardRef(() => ServersModule)],
|
||||||
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
|
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
|
||||||
exports: [NodesService],
|
exports: [NodesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { verifyTokenHash } from '@hexahost/auth';
|
|||||||
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
|
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ConsoleSessionService } from '../servers/console/console-session.service';
|
||||||
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||||
|
|
||||||
type GameServerStatus = GameServer['status'];
|
type GameServerStatus = GameServer['status'];
|
||||||
@@ -47,6 +48,7 @@ export class NodesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly serversService: ServersService,
|
private readonly serversService: ServersService,
|
||||||
|
private readonly consoleSessionService: ConsoleSessionService,
|
||||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -145,12 +147,21 @@ export class NodesService {
|
|||||||
case 'server.state':
|
case 'server.state':
|
||||||
await this.handleServerState(envelope);
|
await this.handleServerState(envelope);
|
||||||
break;
|
break;
|
||||||
|
case 'server.log':
|
||||||
|
this.handleServerLog(envelope);
|
||||||
|
break;
|
||||||
case 'server.operation.completed':
|
case 'server.operation.completed':
|
||||||
await this.handleOperationCompleted(envelope);
|
await this.handleOperationCompleted(envelope);
|
||||||
break;
|
break;
|
||||||
case 'server.operation.failed':
|
case 'server.operation.failed':
|
||||||
await this.handleOperationFailed(envelope);
|
await this.handleOperationFailed(envelope);
|
||||||
break;
|
break;
|
||||||
|
case 'server.files.list.result':
|
||||||
|
case 'server.files.read.result':
|
||||||
|
case 'server.files.write.result':
|
||||||
|
case 'server.command.result':
|
||||||
|
await this.handleDirectResult(envelope);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
|
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
|
||||||
}
|
}
|
||||||
@@ -210,17 +221,40 @@ export class NodesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleServerLog(envelope: AgentEnvelope): void {
|
||||||
|
const serverId = envelope.payload?.['serverId'];
|
||||||
|
const line =
|
||||||
|
envelope.payload?.['line'] ?? envelope.payload?.['message'];
|
||||||
|
|
||||||
|
if (typeof serverId === 'string' && typeof line === 'string') {
|
||||||
|
this.consoleSessionService.broadcast(serverId, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDirectResult(envelope: AgentEnvelope): Promise<void> {
|
||||||
|
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||||
|
|
||||||
|
if (typeof messageId !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.publishNodeResponse(messageId, {
|
||||||
|
success: true,
|
||||||
|
result: envelope.payload?.['result'] ?? envelope.payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async handleOperationCompleted(
|
private async handleOperationCompleted(
|
||||||
envelope: AgentEnvelope,
|
envelope: AgentEnvelope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const serverId = envelope.payload?.['serverId'];
|
const serverId = envelope.payload?.['serverId'];
|
||||||
const operation = envelope.payload?.['operation'];
|
const operation =
|
||||||
const messageId = envelope.messageId;
|
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
|
||||||
|
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||||
if (typeof serverId !== 'string') {
|
const result = envelope.payload?.['result'];
|
||||||
return;
|
const isBridgeOperation = this.isBridgeOperation(operation);
|
||||||
}
|
|
||||||
|
|
||||||
|
if (typeof serverId === 'string' && !isBridgeOperation) {
|
||||||
const target = this.resolveCompletedStatus(operation);
|
const target = this.resolveCompletedStatus(operation);
|
||||||
if (target) {
|
if (target) {
|
||||||
await this.serversService.applyAgentStatus(serverId, target, {
|
await this.serversService.applyAgentStatus(serverId, target, {
|
||||||
@@ -228,10 +262,12 @@ export class NodesService {
|
|||||||
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof messageId === 'string') {
|
if (typeof messageId === 'string') {
|
||||||
await this.publishNodeResponse(messageId, {
|
await this.publishNodeResponse(messageId, {
|
||||||
success: true,
|
success: true,
|
||||||
|
result,
|
||||||
resultCode: typeof operation === 'string' ? operation : 'completed',
|
resultCode: typeof operation === 'string' ? operation : 'completed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,9 +275,12 @@ export class NodesService {
|
|||||||
|
|
||||||
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
|
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
|
||||||
const serverId = envelope.payload?.['serverId'];
|
const serverId = envelope.payload?.['serverId'];
|
||||||
const messageId = envelope.messageId;
|
const operation =
|
||||||
|
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
|
||||||
|
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||||
|
const isBridgeOperation = this.isBridgeOperation(operation);
|
||||||
|
|
||||||
if (typeof serverId === 'string') {
|
if (typeof serverId === 'string' && !isBridgeOperation) {
|
||||||
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
|
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
|
||||||
correlationId: envelope.correlationId,
|
correlationId: envelope.correlationId,
|
||||||
reason: 'agent operation failed',
|
reason: 'agent operation failed',
|
||||||
@@ -271,10 +310,25 @@ export class NodesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishNodeResponse(
|
private isBridgeOperation(operation: unknown): boolean {
|
||||||
|
if (typeof operation !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = operation.replace(/^server\./, '');
|
||||||
|
return (
|
||||||
|
normalized === 'files.list' ||
|
||||||
|
normalized === 'files.read' ||
|
||||||
|
normalized === 'files.write' ||
|
||||||
|
normalized === 'command'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishNodeResponse(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
result?: unknown;
|
||||||
resultCode?: string;
|
resultCode?: string;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|||||||
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,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -73,4 +74,15 @@ export class ServersController {
|
|||||||
) {
|
) {
|
||||||
return this.serversService.stopServer(user.id, id);
|
return this.serversService.stopServer(user.id, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/restart')
|
||||||
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@ApiOperation({ summary: 'Restart a game server' })
|
||||||
|
restart(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Query('skip') skip?: string,
|
||||||
|
) {
|
||||||
|
return this.serversService.restartServer(user.id, id, skip === 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
import { getConfig } from '@hexahost/config';
|
import { getConfig } from '@hexahost/config';
|
||||||
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||||
import { AppConfigModule } from '../config/app-config.module';
|
import { AppConfigModule } from '../config/app-config.module';
|
||||||
|
|
||||||
|
import { ConsoleModule } from './console/console.module';
|
||||||
|
import { FilesModule } from './files/files.module';
|
||||||
|
import { PlayersModule } from './players/players.module';
|
||||||
|
import { PropertiesModule } from './properties/properties.module';
|
||||||
import { ServerStateService } from './server-state.service';
|
import { ServerStateService } from './server-state.service';
|
||||||
import {
|
import {
|
||||||
REDIS_CLIENT,
|
REDIS_CLIENT,
|
||||||
@@ -19,7 +24,15 @@ import { PlansController } from './plans.controller';
|
|||||||
import { PlansService } from './plans.service';
|
import { PlansService } from './plans.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AppConfigModule, AuthModule],
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
AuthModule,
|
||||||
|
forwardRef(() => ConsoleModule),
|
||||||
|
forwardRef(() => FilesModule),
|
||||||
|
forwardRef(() => PropertiesModule),
|
||||||
|
forwardRef(() => PlayersModule),
|
||||||
|
forwardRef(() => NodeBridgeModule),
|
||||||
|
],
|
||||||
controllers: [ServersController, PlansController],
|
controllers: [ServersController, PlansController],
|
||||||
providers: [
|
providers: [
|
||||||
ServersService,
|
ServersService,
|
||||||
@@ -59,6 +72,11 @@ import { PlansService } from './plans.service';
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [ServersService, REDIS_CLIENT],
|
exports: [
|
||||||
|
ServersService,
|
||||||
|
REDIS_CLIENT,
|
||||||
|
ConsoleModule,
|
||||||
|
NodeBridgeModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ServersModule {}
|
export class ServersModule {}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import type Redis from 'ioredis';
|
import type Redis from 'ioredis';
|
||||||
@@ -22,6 +20,7 @@ type GameServerStatus = GameServer['status'];
|
|||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
import { findOwnedServer } from './shared/server-ownership.util';
|
||||||
import { ServerStateService } from './server-state.service';
|
import { ServerStateService } from './server-state.service';
|
||||||
|
|
||||||
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
|
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
|
||||||
@@ -77,15 +76,30 @@ export class ServersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
|
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
|
||||||
const server = await this.findOwnedServer(userId, serverId);
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
return this.toResponse(server);
|
return this.toResponse(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restartServer(
|
||||||
|
userId: string,
|
||||||
|
serverId: string,
|
||||||
|
skipStop = false,
|
||||||
|
): Promise<ServerActionResponse> {
|
||||||
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
|
if (!skipStop && server.status === 'RUNNING') {
|
||||||
|
await this.stopServer(userId, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.startServer(userId, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
async startServer(
|
async startServer(
|
||||||
userId: string,
|
userId: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
): Promise<ServerActionResponse> {
|
): Promise<ServerActionResponse> {
|
||||||
const server = await this.findOwnedServer(userId, serverId);
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
this.serverState.assertNotInProgress(server.status);
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
const targetStatus = this.serverState.resolveStartTarget(server.status);
|
||||||
@@ -143,7 +157,7 @@ export class ServersService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
): Promise<ServerActionResponse> {
|
): Promise<ServerActionResponse> {
|
||||||
const server = await this.findOwnedServer(userId, serverId);
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
this.serverState.assertNotInProgress(server.status);
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
const targetStatus = this.serverState.resolveStopTarget(server.status);
|
const targetStatus = this.serverState.resolveStopTarget(server.status);
|
||||||
@@ -225,25 +239,6 @@ export class ServersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOwnedServer(
|
|
||||||
userId: string,
|
|
||||||
serverId: string,
|
|
||||||
): Promise<GameServer> {
|
|
||||||
const server = await this.prisma.gameServer.findUnique({
|
|
||||||
where: { id: serverId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
throw new NotFoundException('Server not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (server.userId !== userId) {
|
|
||||||
throw new ForbiddenException('You do not own this server');
|
|
||||||
}
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async transitionServer(
|
private async transitionServer(
|
||||||
server: GameServer,
|
server: GameServer,
|
||||||
toStatus: GameServerStatus,
|
toStatus: GameServerStatus,
|
||||||
|
|||||||
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"
|
"github.com/hexahost/gamecloud/node-agent/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.2.0-phase2"
|
const Version = "0.3.0-phase3"
|
||||||
|
|
||||||
// Agent coordinates the node agent lifecycle until shutdown.
|
// Agent coordinates the node agent lifecycle until shutdown.
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,6 +13,7 @@ import (
|
|||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,3 +193,134 @@ func mapSoftwareFamily(family string) string {
|
|||||||
return "VANILLA"
|
return "VANILLA"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamLogs streams container logs and invokes callback for each line.
|
||||||
|
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
|
||||||
|
opts := container.LogsOptions{
|
||||||
|
ShowStdout: true,
|
||||||
|
ShowStderr: true,
|
||||||
|
Follow: follow,
|
||||||
|
Timestamps: false,
|
||||||
|
}
|
||||||
|
if tail > 0 {
|
||||||
|
opts.Tail = strconv.Itoa(tail)
|
||||||
|
} else {
|
||||||
|
opts.Tail = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := c.cli.ContainerLogs(ctx, containerID, opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("container logs: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
return readMultiplexedLogLines(ctx, reader, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecCommand runs a command inside a container and returns stdout/stderr.
|
||||||
|
func (c *Client) ExecCommand(ctx context.Context, containerID string, cmd []string) (stdout, stderr string, err error) {
|
||||||
|
execResp, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
|
||||||
|
Cmd: cmd,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("exec create: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachResp, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("exec attach: %w", err)
|
||||||
|
}
|
||||||
|
defer attachResp.Close()
|
||||||
|
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
if _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader); err != nil {
|
||||||
|
return outBuf.String(), errBuf.String(), fmt.Errorf("exec copy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return outBuf.String(), errBuf.String(), fmt.Errorf("exec inspect: %w", err)
|
||||||
|
}
|
||||||
|
if inspect.ExitCode != 0 {
|
||||||
|
msg := strings.TrimSpace(errBuf.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = strings.TrimSpace(outBuf.String())
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("exit code %d", inspect.ExitCode)
|
||||||
|
}
|
||||||
|
return outBuf.String(), errBuf.String(), fmt.Errorf("exec failed: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outBuf.String(), errBuf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecRcon runs a Minecraft console command via rcon-cli inside the container.
|
||||||
|
func (c *Client) ExecRcon(ctx context.Context, containerID, command string) (stdout, stderr string, err error) {
|
||||||
|
return c.ExecCommand(ctx, containerID, []string{"rcon-cli", command})
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMultiplexedLogLines(ctx context.Context, r io.Reader, callback func(stream, line string)) error {
|
||||||
|
header := make([]byte, 8)
|
||||||
|
var partial []byte
|
||||||
|
var partialStream string
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.ReadFull(r, header)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stream := "stdout"
|
||||||
|
if header[0] == 2 {
|
||||||
|
stream = "stderr"
|
||||||
|
}
|
||||||
|
|
||||||
|
size := binary.BigEndian.Uint32(header[4:8])
|
||||||
|
payload := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if partialStream != "" && partialStream != stream {
|
||||||
|
if len(partial) > 0 {
|
||||||
|
callback(partialStream, string(partial))
|
||||||
|
partial = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partialStream = stream
|
||||||
|
partial = append(partial, payload...)
|
||||||
|
|
||||||
|
for {
|
||||||
|
idx := bytes.IndexByte(partial, '\n')
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line := string(partial[:idx])
|
||||||
|
partial = partial[idx+1:]
|
||||||
|
line = strings.TrimSuffix(line, "\r")
|
||||||
|
if line != "" {
|
||||||
|
callback(stream, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(partial) > 0 {
|
||||||
|
stream := partialStream
|
||||||
|
if stream == "" {
|
||||||
|
stream = "stdout"
|
||||||
|
}
|
||||||
|
callback(stream, string(partial))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
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,10 +137,74 @@ type ServerStopPayload struct {
|
|||||||
OperationMeta
|
OperationMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerCommandPayload instructs the agent to run a console command via RCON.
|
||||||
|
type ServerCommandPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerLogsSubscribePayload instructs the agent to stream container logs.
|
||||||
|
type ServerLogsSubscribePayload struct {
|
||||||
|
OperationMeta
|
||||||
|
TailLines int `json:"tailLines"`
|
||||||
|
Follow bool `json:"follow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerLogPayload carries a single log line from a game server.
|
||||||
|
type ServerLogPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
Line string `json:"line"`
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFilesListPayload lists files in a server data directory.
|
||||||
|
type ServerFilesListPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFilesReadPayload reads a file from the server data directory.
|
||||||
|
type ServerFilesReadPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
Path string `json:"path"`
|
||||||
|
MaxBytes int `json:"maxBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFilesWritePayload writes content to a file in the server data directory.
|
||||||
|
type ServerFilesWritePayload struct {
|
||||||
|
OperationMeta
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Create bool `json:"create"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry describes a file or directory in a list response.
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModifiedAt string `json:"modifiedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFilesListResultPayload is the result of a files.list operation.
|
||||||
|
type ServerFilesListResultPayload struct {
|
||||||
|
Entries []FileEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFilesReadResultPayload is the result of a files.read operation.
|
||||||
|
type ServerFilesReadResultPayload struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
}
|
||||||
|
|
||||||
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
|
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
|
||||||
type ServerOperationResultPayload struct {
|
type ServerOperationResultPayload struct {
|
||||||
OperationMeta
|
OperationMeta
|
||||||
ResultCode string `json:"resultCode"`
|
ResultCode string `json:"resultCode"`
|
||||||
|
Operation string `json:"operation,omitempty"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
ErrorCode string `json:"errorCode,omitempty"`
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,3 +212,168 @@ func TestNewEnvelope(t *testing.T) {
|
|||||||
t.Fatalf("protocol version %d", env.ProtocolVersion)
|
t.Fatalf("protocol version %d", env.ProtocolVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPhase3Payloads_JSONRoundtrip(t *testing.T) {
|
||||||
|
meta := OperationMeta{
|
||||||
|
ServerID: "srv-01",
|
||||||
|
Generation: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
command := ServerCommandPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
Command: "say hello",
|
||||||
|
}
|
||||||
|
commandRaw, err := json.Marshal(command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decodedCommand ServerCommandPayload
|
||||||
|
if err := json.Unmarshal(commandRaw, &decodedCommand); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decodedCommand.Command != command.Command {
|
||||||
|
t.Fatalf("command: got %q want %q", decodedCommand.Command, command.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
logsSub := ServerLogsSubscribePayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
TailLines: 100,
|
||||||
|
Follow: true,
|
||||||
|
}
|
||||||
|
logsSubRaw, err := json.Marshal(logsSub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decodedLogsSub ServerLogsSubscribePayload
|
||||||
|
if err := json.Unmarshal(logsSubRaw, &decodedLogsSub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decodedLogsSub.TailLines != 100 || !decodedLogsSub.Follow {
|
||||||
|
t.Fatalf("logs subscribe mismatch: %+v", decodedLogsSub)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesList := ServerFilesListPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
Path: "world",
|
||||||
|
}
|
||||||
|
filesListRaw, err := json.Marshal(filesList)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := &Envelope{
|
||||||
|
ProtocolVersion: CurrentProtocolVersion,
|
||||||
|
MessageID: "msg-files-list",
|
||||||
|
Type: TypeServerFilesList,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Payload: filesListRaw,
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("files list envelope: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesRead := ServerFilesReadPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
Path: "server.properties",
|
||||||
|
MaxBytes: 4096,
|
||||||
|
}
|
||||||
|
filesReadRaw, err := json.Marshal(filesRead)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env = &Envelope{
|
||||||
|
ProtocolVersion: CurrentProtocolVersion,
|
||||||
|
MessageID: "msg-files-read",
|
||||||
|
Type: TypeServerFilesRead,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Payload: filesReadRaw,
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("files read envelope: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesWrite := ServerFilesWritePayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
Path: "ops.txt",
|
||||||
|
Content: "test",
|
||||||
|
Create: true,
|
||||||
|
}
|
||||||
|
filesWriteRaw, err := json.Marshal(filesWrite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env = &Envelope{
|
||||||
|
ProtocolVersion: CurrentProtocolVersion,
|
||||||
|
MessageID: "msg-files-write",
|
||||||
|
Type: TypeServerFilesWrite,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Payload: filesWriteRaw,
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("files write envelope: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logLine := ServerLogPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
Line: "[Server] Done",
|
||||||
|
Stream: "stdout",
|
||||||
|
}
|
||||||
|
logLineRaw, err := json.Marshal(logLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env = &Envelope{
|
||||||
|
ProtocolVersion: CurrentProtocolVersion,
|
||||||
|
MessageID: "msg-log",
|
||||||
|
Type: TypeServerLog,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Payload: logLineRaw,
|
||||||
|
}
|
||||||
|
if err := env.Validate(); err != nil {
|
||||||
|
t.Fatalf("server log envelope: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerOperationResultPayload_WithResult(t *testing.T) {
|
||||||
|
listResult, err := json.Marshal(ServerFilesListResultPayload{
|
||||||
|
Entries: []FileEntry{{
|
||||||
|
Name: "server.properties",
|
||||||
|
Path: "server.properties",
|
||||||
|
IsDir: false,
|
||||||
|
Size: 128,
|
||||||
|
ModifiedAt: "2026-06-26T12:00:00Z",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := ServerOperationResultPayload{
|
||||||
|
OperationMeta: OperationMeta{ServerID: "srv-01", Generation: 1},
|
||||||
|
ResultCode: "ok",
|
||||||
|
Operation: "files.list",
|
||||||
|
Result: listResult,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded ServerOperationResultPayload
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Operation != "files.list" {
|
||||||
|
t.Fatalf("operation: got %q", decoded.Operation)
|
||||||
|
}
|
||||||
|
if len(decoded.Result) == 0 {
|
||||||
|
t.Fatal("expected result payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ServerFilesListResultPayload
|
||||||
|
if err := json.Unmarshal(decoded.Result, &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Entries) != 1 || result.Entries[0].Name != "server.properties" {
|
||||||
|
t.Fatalf("unexpected list result: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package runtime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||||
|
"github.com/hexahost/gamecloud/node-agent/internal/files"
|
||||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ const (
|
|||||||
// Responder sends protocol responses back to the control plane.
|
// Responder sends protocol responses back to the control plane.
|
||||||
type Responder interface {
|
type Responder interface {
|
||||||
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
|
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
|
||||||
|
SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error
|
||||||
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||||
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
docker *docker.Client
|
docker *docker.Client
|
||||||
servers map[string]*serverRecord
|
servers map[string]*serverRecord
|
||||||
|
logStreams map[string]context.CancelFunc
|
||||||
resp Responder
|
resp Responder
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -54,6 +58,7 @@ func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *
|
|||||||
return &Manager{
|
return &Manager{
|
||||||
docker: dockerClient,
|
docker: dockerClient,
|
||||||
servers: make(map[string]*serverRecord),
|
servers: make(map[string]*serverRecord),
|
||||||
|
logStreams: make(map[string]context.CancelFunc),
|
||||||
resp: resp,
|
resp: resp,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,8 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
|
|||||||
|
|
||||||
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
|
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
|
||||||
|
|
||||||
|
m.stopLogStream(meta.ServerID)
|
||||||
|
|
||||||
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
|
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
|
||||||
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||||
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
|
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
|
||||||
@@ -204,6 +211,147 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
|
|||||||
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleLogsSubscribe starts streaming container logs for a server.
|
||||||
|
func (m *Manager) HandleLogsSubscribe(ctx context.Context, correlationID string, payload protocol.ServerLogsSubscribePayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamCtx, cancel := context.WithCancel(ctx)
|
||||||
|
m.mu.Lock()
|
||||||
|
if existing, ok := m.logStreams[meta.ServerID]; ok {
|
||||||
|
existing()
|
||||||
|
}
|
||||||
|
m.logStreams[meta.ServerID] = cancel
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
if current, ok := m.logStreams[meta.ServerID]; ok && current == cancel {
|
||||||
|
delete(m.logStreams, meta.ServerID)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := m.docker.StreamLogs(streamCtx, rec.ContainerID, payload.TailLines, payload.Follow, func(stream, line string) {
|
||||||
|
logPayload := protocol.ServerLogPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
Line: line,
|
||||||
|
Stream: stream,
|
||||||
|
}
|
||||||
|
if err := m.resp.SendLog(ctx, correlationID, logPayload); err != nil {
|
||||||
|
m.log.Warn("send server.log failed",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil && streamCtx.Err() == nil {
|
||||||
|
m.log.Warn("log stream ended with error",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCommand executes a console command via RCON.
|
||||||
|
func (m *Manager) HandleCommand(ctx context.Context, correlationID string, payload protocol.ServerCommandPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "command", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, stderr, err := m.docker.ExecRcon(ctx, rec.ContainerID, payload.Command)
|
||||||
|
if err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "command", "COMMAND_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.completeOperation(ctx, correlationID, meta, "command", map[string]string{
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFilesList lists files in the server data directory.
|
||||||
|
func (m *Manager) HandleFilesList(ctx context.Context, correlationID string, payload protocol.ServerFilesListPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "files.list", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := files.ListDir(rec.DataPath, payload.Path)
|
||||||
|
if err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "files.list", filesErrorCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := protocol.ServerFilesListResultPayload{
|
||||||
|
Entries: make([]protocol.FileEntry, 0, len(entries)),
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
result.Entries = append(result.Entries, protocol.FileEntry{
|
||||||
|
Name: entry.Name,
|
||||||
|
Path: entry.Path,
|
||||||
|
IsDir: entry.IsDir,
|
||||||
|
Size: entry.Size,
|
||||||
|
ModifiedAt: entry.ModifiedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
m.completeOperation(ctx, correlationID, meta, "files.list", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFilesRead reads a file from the server data directory.
|
||||||
|
func (m *Manager) HandleFilesRead(ctx context.Context, correlationID string, payload protocol.ServerFilesReadPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "files.read", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, truncated, err := files.ReadFile(rec.DataPath, payload.Path, payload.MaxBytes)
|
||||||
|
if err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "files.read", filesErrorCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.completeOperation(ctx, correlationID, meta, "files.read", protocol.ServerFilesReadResultPayload{
|
||||||
|
Path: payload.Path,
|
||||||
|
Content: content,
|
||||||
|
Truncated: truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFilesWrite writes a file in the server data directory.
|
||||||
|
func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, payload protocol.ServerFilesWritePayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "files.write", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := files.WriteFile(rec.DataPath, payload.Path, payload.Content, payload.Create); err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "files.write", filesErrorCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.completeOperation(ctx, correlationID, meta, "files.write", map[string]string{
|
||||||
|
"path": payload.Path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -223,6 +371,15 @@ func (m *Manager) setState(serverID, state string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) stopLogStream(serverID string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if cancel, ok := m.logStreams[serverID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(m.logStreams, serverID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
|
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
|
||||||
payload := protocol.ServerStatePayload{
|
payload := protocol.ServerStatePayload{
|
||||||
OperationMeta: meta,
|
OperationMeta: meta,
|
||||||
@@ -267,3 +424,59 @@ func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) completeOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation string, result any) {
|
||||||
|
raw, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, operation, "RESULT_MARSHAL_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := protocol.ServerOperationResultPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
ResultCode: ResultCodeOK,
|
||||||
|
Operation: operation,
|
||||||
|
Result: raw,
|
||||||
|
}
|
||||||
|
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
|
||||||
|
m.log.Warn("send operation complete failed",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"operation", operation,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) failOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation, errorCode, errorMessage string) {
|
||||||
|
payload := protocol.ServerOperationResultPayload{
|
||||||
|
OperationMeta: meta,
|
||||||
|
ResultCode: ResultCodeError,
|
||||||
|
Operation: operation,
|
||||||
|
ErrorCode: errorCode,
|
||||||
|
ErrorMessage: errorMessage,
|
||||||
|
}
|
||||||
|
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
|
||||||
|
m.log.Warn("send operation failed failed",
|
||||||
|
"server_id", meta.ServerID,
|
||||||
|
"operation", operation,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filesErrorCode(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, files.ErrPathTraversal):
|
||||||
|
return "PATH_TRAVERSAL"
|
||||||
|
case errors.Is(err, files.ErrSymlink):
|
||||||
|
return "SYMLINK_NOT_ALLOWED"
|
||||||
|
case errors.Is(err, files.ErrNotFound):
|
||||||
|
return "FILE_NOT_FOUND"
|
||||||
|
case errors.Is(err, files.ErrNotDirectory):
|
||||||
|
return "NOT_A_DIRECTORY"
|
||||||
|
case errors.Is(err, files.ErrIsDirectory):
|
||||||
|
return "IS_DIRECTORY"
|
||||||
|
default:
|
||||||
|
return "FILES_ERROR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -274,6 +274,46 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
|||||||
}
|
}
|
||||||
c.runtime.HandleDelete(ctx, correlationID, meta)
|
c.runtime.HandleDelete(ctx, correlationID, meta)
|
||||||
|
|
||||||
|
case protocol.TypeServerLogsSubscribe:
|
||||||
|
var payload protocol.ServerLogsSubscribePayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.logs.subscribe", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleLogsSubscribe(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerCommand:
|
||||||
|
var payload protocol.ServerCommandPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.command", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleCommand(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerFilesList:
|
||||||
|
var payload protocol.ServerFilesListPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.files.list", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleFilesList(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerFilesRead:
|
||||||
|
var payload protocol.ServerFilesReadPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.files.read", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleFilesRead(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerFilesWrite:
|
||||||
|
var payload protocol.ServerFilesWritePayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.files.write", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
c.log.Debug("ignored control message", "type", env.Type)
|
c.log.Debug("ignored control message", "type", env.Type)
|
||||||
}
|
}
|
||||||
@@ -333,6 +373,11 @@ func (c *Client) SendState(ctx context.Context, correlationID string, payload pr
|
|||||||
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendLog implements runtime.Responder.
|
||||||
|
func (c *Client) SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error {
|
||||||
|
return c.send(ctx, protocol.TypeServerLog, correlationID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
// SendOperationComplete implements runtime.Responder.
|
// SendOperationComplete implements runtime.Responder.
|
||||||
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||||
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
||||||
|
|||||||
@@ -127,7 +127,10 @@
|
|||||||
"planInvalid": "Ungültiger Tarif ausgewählt",
|
"planInvalid": "Ungültiger Tarif ausgewählt",
|
||||||
"versionRequired": "Minecraft-Version ist erforderlich",
|
"versionRequired": "Minecraft-Version ist erforderlich",
|
||||||
"softwareRequired": "Software-Familie ist erforderlich",
|
"softwareRequired": "Software-Familie ist erforderlich",
|
||||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
|
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren",
|
||||||
|
"motdMax": "MOTD darf höchstens 256 Zeichen lang sein",
|
||||||
|
"maxPlayersMin": "Mindestens 1 Spieler erforderlich",
|
||||||
|
"maxPlayersMax": "Höchstens 200 Spieler erlaubt"
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Server",
|
"title": "Server",
|
||||||
@@ -180,6 +183,97 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||||
"loadFailed": "Server konnten nicht geladen werden."
|
"loadFailed": "Server konnten nicht geladen werden."
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"navigation": "Server-Bereiche",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"console": "Konsole",
|
||||||
|
"files": "Dateien",
|
||||||
|
"properties": "Eigenschaften",
|
||||||
|
"players": "Spieler"
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"notRunning": "Die Konsole ist nur verfügbar, wenn der Server läuft. Starten Sie den Server über die Übersicht.",
|
||||||
|
"connecting": "Verbindung wird hergestellt…",
|
||||||
|
"connected": "Verbunden",
|
||||||
|
"disconnected": "Getrennt",
|
||||||
|
"clear": "Log leeren",
|
||||||
|
"commandPlaceholder": "Serverbefehl eingeben…",
|
||||||
|
"send": "Senden",
|
||||||
|
"errors": {
|
||||||
|
"connectionFailed": "Verbindung zur Konsole fehlgeschlagen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"browser": "Dateibrowser",
|
||||||
|
"editor": "Editor",
|
||||||
|
"up": "Hoch",
|
||||||
|
"empty": "Dieses Verzeichnis ist leer.",
|
||||||
|
"selectFile": "Wählen Sie eine Datei zum Anzeigen oder Bearbeiten.",
|
||||||
|
"readOnlyStopped": "Schreibgeschützt, solange der Server gestoppt ist.",
|
||||||
|
"notAvailable": "Dateien sind nur verfügbar, wenn der Server läuft oder gestoppt ist.",
|
||||||
|
"truncated": "Datei wurde zur Anzeige gekürzt. Bearbeiten Sie mit Vorsicht.",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Wird gespeichert…",
|
||||||
|
"saved": "Datei gespeichert.",
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Verzeichnis konnte nicht geladen werden.",
|
||||||
|
"readFailed": "Datei konnte nicht gelesen werden.",
|
||||||
|
"saveFailed": "Datei konnte nicht gespeichert werden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"title": "Server-Eigenschaften",
|
||||||
|
"description": "Bearbeiten Sie gängige server.properties-Einstellungen. Unbekannte Eigenschaften bleiben auf dem Server erhalten.",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"motd": "MOTD",
|
||||||
|
"gamemode": "Spielmodus",
|
||||||
|
"difficulty": "Schwierigkeit",
|
||||||
|
"maxPlayers": "Max. Spieler",
|
||||||
|
"pvp": "PVP",
|
||||||
|
"whitelist": "Whitelist",
|
||||||
|
"hardcore": "Hardcore",
|
||||||
|
"onlineMode": "Online-Modus",
|
||||||
|
"enableCommandBlock": "Befehlsblöcke",
|
||||||
|
"allowFlight": "Fliegen erlauben",
|
||||||
|
"viewDistance": "Sichtweite",
|
||||||
|
"simulationDistance": "Simulationsdistanz",
|
||||||
|
"spawnProtection": "Spawn-Schutz",
|
||||||
|
"save": "Änderungen speichern",
|
||||||
|
"saving": "Wird gespeichert…",
|
||||||
|
"saved": "Eigenschaften gespeichert.",
|
||||||
|
"gamemodes": {
|
||||||
|
"survival": "Überleben",
|
||||||
|
"creative": "Kreativ",
|
||||||
|
"adventure": "Abenteuer",
|
||||||
|
"spectator": "Zuschauer"
|
||||||
|
},
|
||||||
|
"difficulties": {
|
||||||
|
"peaceful": "Friedlich",
|
||||||
|
"easy": "Einfach",
|
||||||
|
"normal": "Normal",
|
||||||
|
"hard": "Schwer"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Server-Eigenschaften konnten nicht geladen werden.",
|
||||||
|
"saveFailed": "Server-Eigenschaften konnten nicht gespeichert werden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"players": {
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"onlineCount": "Spieler online",
|
||||||
|
"whitelistCount": "Whitelist-Einträge",
|
||||||
|
"empty": "Keine Spieler online.",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"columns": {
|
||||||
|
"name": "Spieler",
|
||||||
|
"ping": "Ping"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Spielerliste konnte nicht geladen werden."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,10 @@
|
|||||||
"planInvalid": "Invalid plan selected",
|
"planInvalid": "Invalid plan selected",
|
||||||
"versionRequired": "Minecraft version is required",
|
"versionRequired": "Minecraft version is required",
|
||||||
"softwareRequired": "Software family is required",
|
"softwareRequired": "Software family is required",
|
||||||
"eulaRequired": "You must accept the Minecraft EULA"
|
"eulaRequired": "You must accept the Minecraft EULA",
|
||||||
|
"motdMax": "MOTD must be at most 256 characters",
|
||||||
|
"maxPlayersMin": "At least 1 player required",
|
||||||
|
"maxPlayersMax": "At most 200 players allowed"
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Servers",
|
"title": "Servers",
|
||||||
@@ -180,6 +183,97 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Something went wrong. Please try again.",
|
"generic": "Something went wrong. Please try again.",
|
||||||
"loadFailed": "Could not load servers."
|
"loadFailed": "Could not load servers."
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"navigation": "Server sections",
|
||||||
|
"overview": "Overview",
|
||||||
|
"console": "Console",
|
||||||
|
"files": "Files",
|
||||||
|
"properties": "Properties",
|
||||||
|
"players": "Players"
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"notRunning": "The console is only available while the server is running. Start the server from the overview tab.",
|
||||||
|
"connecting": "Connecting…",
|
||||||
|
"connected": "Connected",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"clear": "Clear log",
|
||||||
|
"commandPlaceholder": "Enter server command…",
|
||||||
|
"send": "Send",
|
||||||
|
"errors": {
|
||||||
|
"connectionFailed": "Could not connect to the console."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"browser": "File browser",
|
||||||
|
"editor": "Editor",
|
||||||
|
"up": "Up",
|
||||||
|
"empty": "This directory is empty.",
|
||||||
|
"selectFile": "Select a file to view or edit.",
|
||||||
|
"readOnlyStopped": "Read-only while the server is stopped.",
|
||||||
|
"notAvailable": "Files are only available when the server is running or stopped.",
|
||||||
|
"truncated": "File was truncated for display. Edit with care.",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "File saved.",
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Could not load directory.",
|
||||||
|
"readFailed": "Could not read file.",
|
||||||
|
"saveFailed": "Could not save file."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"title": "Server properties",
|
||||||
|
"description": "Edit common server.properties settings. Unknown properties are preserved on the server.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"motd": "MOTD",
|
||||||
|
"gamemode": "Gamemode",
|
||||||
|
"difficulty": "Difficulty",
|
||||||
|
"maxPlayers": "Max players",
|
||||||
|
"pvp": "PVP",
|
||||||
|
"whitelist": "Whitelist",
|
||||||
|
"hardcore": "Hardcore",
|
||||||
|
"onlineMode": "Online mode",
|
||||||
|
"enableCommandBlock": "Command blocks",
|
||||||
|
"allowFlight": "Allow flight",
|
||||||
|
"viewDistance": "View distance",
|
||||||
|
"simulationDistance": "Simulation distance",
|
||||||
|
"spawnProtection": "Spawn protection",
|
||||||
|
"save": "Save changes",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "Properties saved.",
|
||||||
|
"gamemodes": {
|
||||||
|
"survival": "Survival",
|
||||||
|
"creative": "Creative",
|
||||||
|
"adventure": "Adventure",
|
||||||
|
"spectator": "Spectator"
|
||||||
|
},
|
||||||
|
"difficulties": {
|
||||||
|
"peaceful": "Peaceful",
|
||||||
|
"easy": "Easy",
|
||||||
|
"normal": "Normal",
|
||||||
|
"hard": "Hard"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Could not load server properties.",
|
||||||
|
"saveFailed": "Could not save server properties."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"players": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"onlineCount": "Online players",
|
||||||
|
"whitelistCount": "Whitelist entries",
|
||||||
|
"empty": "No players online.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"columns": {
|
||||||
|
"name": "Player",
|
||||||
|
"ping": "Ping"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Could not load player list."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Button } from "@hexahost/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Link } from "@/i18n/navigation";
|
|
||||||
import { getServer } from "@/lib/api/servers";
|
|
||||||
import { ServerCard } from "@/components/servers/server-card";
|
import { ServerCard } from "@/components/servers/server-card";
|
||||||
|
import { getServer } from "@/lib/api/servers";
|
||||||
|
|
||||||
interface ServerDetailContentProps {
|
interface ServerDetailContentProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -13,18 +12,9 @@ interface ServerDetailContentProps {
|
|||||||
|
|
||||||
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||||
const t = useTranslations("servers");
|
const t = useTranslations("servers");
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["server", serverId],
|
queryKey: ["server", serverId],
|
||||||
queryFn: () => getServer(serverId),
|
queryFn: () => getServer(serverId),
|
||||||
refetchInterval: (query) => {
|
|
||||||
const status = query.state.data?.status;
|
|
||||||
if (!status) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
|
||||||
? 3000
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -32,42 +22,8 @@ export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
return null;
|
||||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
|
||||||
{t("retry")}
|
|
||||||
</Button>
|
|
||||||
<Link
|
|
||||||
href="/servers"
|
|
||||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
{t("backToList")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <ServerCard server={data} showDetailsLink={false} />;
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
|
||||||
{data.name}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/servers"
|
|
||||||
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
|
||||||
>
|
|
||||||
{t("backToList")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ServerCard server={data} showDetailsLink={false} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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 |
|
| Area | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
|
| Go agent v0.3 | Done | Log stream, RCON commands, safe file I/O |
|
||||||
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
|
| `@hexahost/contracts` phase 3 | Done | console, files, properties, players |
|
||||||
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
|
| API NodeBridge | Done | Sync request/response over Redis |
|
||||||
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
|
| API Console | Done | Token + WS `/api/v1/servers/:id/console/ws` |
|
||||||
| API Plans | Done | `GET /api/v1/plans` |
|
| API Files | Done | List, read, write with path validation |
|
||||||
| Admin nodes | Done | `POST /api/v1/admin/nodes` with enrollment token |
|
| API Properties | Done | GET/PATCH `server.properties` |
|
||||||
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
|
| API Players | Done | Online list + whitelist |
|
||||||
| Worker lifecycle | Done | start-server, stop-server jobs |
|
| API Restart | Done | `POST /servers/:id/restart?skip=true` |
|
||||||
| Go node-agent | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
|
| Audit events | Done | console.command, file.write, properties.update |
|
||||||
| Web server UI | Done | Create, list, detail, start/stop |
|
| Web server tabs | Done | Console, files, properties, players |
|
||||||
| Dev seed | Done | `pnpm db:seed` — Free plan + local dev node |
|
| Unit tests | Done | Properties parser, path validation, route contracts |
|
||||||
| Migrations | Done | `20260626140000_phase2_game_servers` |
|
|
||||||
|
|
||||||
### Abnahmekriterium Phase 2
|
### Abnahmekriterium Phase 3
|
||||||
|
|
||||||
- [x] User creates server in web panel
|
- [x] Live console WebSocket (logs + commands when RUNNING)
|
||||||
- [x] Provisioning pipeline (API → Worker → Agent)
|
- [x] File manager (list/read/write, path sandbox)
|
||||||
- [x] Start/stop lifecycle
|
- [x] `server.properties` editor with restart option
|
||||||
- [x] Node enrollment + heartbeat
|
- [x] Player list + whitelist view
|
||||||
- [x] Connection address shown (`localhost:port`)
|
- [x] Server management fully via web panel (no direct host access required)
|
||||||
- [ ] Manual Minecraft join validation (requires Docker + agent running locally)
|
- [ ] Manual E2E validation (requires Docker + agent running locally)
|
||||||
|
|
||||||
### Local vertical slice
|
### Local vertical slice
|
||||||
|
|
||||||
@@ -46,17 +45,49 @@ set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
|
|||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
1. Create and start a server under `/servers/new`
|
||||||
|
2. Open **Console** tab — live logs, send commands via RCON
|
||||||
|
3. Open **Files** — browse server directory, edit files
|
||||||
|
4. Open **Properties** — change `server.properties`, optional restart
|
||||||
|
5. Open **Players** — online players and whitelist
|
||||||
|
|
||||||
### Known limitations
|
### Known limitations
|
||||||
|
|
||||||
- Single-node only (no scheduler/queue fairness yet).
|
- Console only when server status is `RUNNING`; files readable when `RUNNING` or `STOPPED`.
|
||||||
- Minecraft image pull on first provision can take several minutes.
|
- RCON via `rcon-cli` inside Minecraft container (itzg image).
|
||||||
- Node agent on Windows needs Docker Desktop; run agent on host, not in Compose.
|
- File writes limited to server data directory; no upload/download archives yet (Phase 4).
|
||||||
- mTLS for agent deferred; token-based enrollment only.
|
- Player management read-only (whitelist view); add/remove deferred.
|
||||||
- Metrics are basic heartbeat payload only.
|
- Single-node only; no scheduler fairness yet.
|
||||||
|
|
||||||
### Next phase: Phase 3 — Console, files, settings
|
---
|
||||||
|
|
||||||
- Live console WebSocket
|
## Phase 2 — Single-Node Vertical Slice ✅
|
||||||
- File manager
|
|
||||||
- server.properties editor
|
| Area | Status | Notes |
|
||||||
- Player lists
|
|------|--------|-------|
|
||||||
|
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
|
||||||
|
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
|
||||||
|
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
|
||||||
|
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
|
||||||
|
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
|
||||||
|
| Go node-agent v0.2 | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
|
||||||
|
| Web server UI | Done | Create, list, detail, start/stop |
|
||||||
|
| Migration | Done | `20260626140000_phase2_game_servers` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Authentication ✅
|
||||||
|
|
||||||
|
Session cookies, register/login/logout, email verify, password reset, TOTP 2FA, recovery codes, audit baseline. See `docs/adr/0002-session-authentication.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Monorepo foundation ✅
|
||||||
|
|
||||||
|
pnpm + Turborepo monorepo, PostgreSQL/Redis/MinIO/Mailpit compose, CI, ADRs.
|
||||||
|
|
||||||
|
### Next phase: Phase 4 — Worlds & backups
|
||||||
|
|
||||||
|
- S3-compatible storage (MinIO)
|
||||||
|
- World upload/download
|
||||||
|
- Backup jobs and restore
|
||||||
|
|||||||
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 ServerListResponse,
|
||||||
type ServerActionResponse,
|
type ServerActionResponse,
|
||||||
} from './servers';
|
} from './servers';
|
||||||
|
|
||||||
|
export {
|
||||||
|
listFilesQuerySchema,
|
||||||
|
fileEntrySchema,
|
||||||
|
fileListResponseSchema,
|
||||||
|
fileContentSchema,
|
||||||
|
readFileContentQuerySchema,
|
||||||
|
updateFileContentSchema,
|
||||||
|
type ListFilesQuery,
|
||||||
|
type FileEntry,
|
||||||
|
type FileListResponse,
|
||||||
|
type FileContent,
|
||||||
|
type ReadFileContentQuery,
|
||||||
|
type UpdateFileContent,
|
||||||
|
} from './files';
|
||||||
|
|
||||||
|
export {
|
||||||
|
consoleTokenResponseSchema,
|
||||||
|
consoleCommandSchema,
|
||||||
|
type ConsoleTokenResponse,
|
||||||
|
type ConsoleCommand,
|
||||||
|
} from './console';
|
||||||
|
|
||||||
|
export {
|
||||||
|
serverPropertiesSchema,
|
||||||
|
updateServerPropertiesSchema,
|
||||||
|
serverPropertiesResponseSchema,
|
||||||
|
type ServerProperties,
|
||||||
|
type UpdateServerProperties,
|
||||||
|
type ServerPropertiesResponse,
|
||||||
|
} from './properties';
|
||||||
|
|
||||||
|
export {
|
||||||
|
onlinePlayerSchema,
|
||||||
|
whitelistEntrySchema,
|
||||||
|
operatorEntrySchema,
|
||||||
|
playerListResponseSchema,
|
||||||
|
type OnlinePlayer,
|
||||||
|
type WhitelistEntry,
|
||||||
|
type OperatorEntry,
|
||||||
|
type PlayerListResponse,
|
||||||
|
} from './players';
|
||||||
|
|||||||
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