Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
104aa1a67b
27 files changed
+6355
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { configureViaRcon, rconStatus } from './rconConfigure.js';
|
||||
import { runRcon } from './rcon.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Startet pro Match einen eigenen CS2-Container (Standard-Image: cm2network/cs2).
|
||||
* Benötigt Docker auf dem Backend-Host, ein Game Server Login Token (CS2_GSLT)
|
||||
* und eine öffentlich erreichbare Adresse (CS2_PUBLIC_HOST).
|
||||
*
|
||||
* Pro Match werden zwei aufeinanderfolgende Ports belegt: Spielport (TCP+UDP, auch RCON)
|
||||
* und CSTV-Port (UDP).
|
||||
*/
|
||||
export class DockerServerAdapter {
|
||||
constructor(options, { getUsedPorts }) {
|
||||
this.name = 'docker';
|
||||
this.simulated = false;
|
||||
this.options = options;
|
||||
this.getUsedPorts = getUsedPorts;
|
||||
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
|
||||
if (!options.gslt) throw new Error('SERVER_ADAPTER=docker benötigt CS2_GSLT (Game Server Login Token)');
|
||||
}
|
||||
|
||||
async docker(args, timeout = 60_000) {
|
||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
allocatePort() {
|
||||
const used = this.getUsedPorts();
|
||||
for (let p = this.options.portRangeStart; p + 1 <= this.options.portRangeEnd; p += 2) {
|
||||
if (!used.has(p) && !used.has(p + 1)) return p;
|
||||
}
|
||||
throw new Error('Kein freier Port im Bereich CS2_PORT_RANGE_START–CS2_PORT_RANGE_END');
|
||||
}
|
||||
|
||||
async start({ matchId, mode, secrets }) {
|
||||
const port = this.allocatePort();
|
||||
const tvPort = port + 1;
|
||||
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
||||
const env = {
|
||||
SRCDS_TOKEN: this.options.gslt,
|
||||
CS2_SERVERNAME: `CS2 Mapveto ${matchId}`,
|
||||
CS2_PORT: String(port),
|
||||
CS2_RCONPW: secrets.rconPassword,
|
||||
CS2_PW: secrets.serverPassword,
|
||||
CS2_MAXPLAYERS: mode === 'wingman' ? '4' : '10',
|
||||
CS2_GAMETYPE: '0',
|
||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||
CS2_SERVER_HIBERNATE: '0',
|
||||
TV_ENABLE: '1',
|
||||
TV_PORT: String(tvPort),
|
||||
TV_PW: secrets.tvPassword,
|
||||
TV_RELAY_PW: secrets.tvPassword,
|
||||
STEAMAPPVALIDATE: '0',
|
||||
};
|
||||
const args = ['run', '-d', '--name', container, '--label', 'cs2-mapveto=1'];
|
||||
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
|
||||
args.push(
|
||||
'-p', `${port}:${port}/tcp`,
|
||||
'-p', `${port}:${port}/udp`,
|
||||
'-p', `${tvPort}:${tvPort}/udp`,
|
||||
'-v', `${this.options.volume}:/home/steam/cs2-dedicated`,
|
||||
);
|
||||
if (this.options.extraArgs) args.push(...this.options.extraArgs.split(' ').filter(Boolean));
|
||||
args.push(this.options.image);
|
||||
|
||||
// Ein evtl. übrig gebliebener Container gleichen Namens (z. B. nach Retry) wird ersetzt.
|
||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||
// Das erste "docker run" kann das Image laden und dauert dann länger.
|
||||
await this.docker(args, 15 * 60_000);
|
||||
|
||||
return {
|
||||
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
|
||||
host: this.options.host,
|
||||
port,
|
||||
tvPort,
|
||||
};
|
||||
}
|
||||
|
||||
target(handle) {
|
||||
return { host: handle.rconHost, port: handle.port, password: handle.rconPassword };
|
||||
}
|
||||
|
||||
async status(handle) {
|
||||
let state;
|
||||
try {
|
||||
state = await this.docker(['inspect', '-f', '{{.State.Status}}', handle.container], 15_000);
|
||||
} catch {
|
||||
return { state: 'stopped', message: 'Container existiert nicht mehr' };
|
||||
}
|
||||
if (state === 'exited' || state === 'dead') {
|
||||
const logs = await this.docker(['logs', '--tail', '5', handle.container]).catch(() => '');
|
||||
return { state: 'failed', message: `Container beendet. Letzte Ausgabe: ${logs.slice(-300) || '–'}` };
|
||||
}
|
||||
if (state !== 'running') return { state: 'starting', message: `Container-Status: ${state}` };
|
||||
try {
|
||||
const status = await rconStatus(this.target(handle));
|
||||
return { state: 'running', playerCount: status.playerCount, map: status.map };
|
||||
} catch {
|
||||
const minutes = Math.round((Date.now() - handle.startedAt) / 60_000);
|
||||
if (minutes >= this.options.startTimeoutMinutes) {
|
||||
return { state: 'failed', message: `Server nach ${minutes} Minuten nicht per RCON erreichbar` };
|
||||
}
|
||||
return { state: 'starting', message: 'Container läuft, Spielserver lädt (Download/Update möglich)' };
|
||||
}
|
||||
}
|
||||
|
||||
async configure(handle, plan) {
|
||||
await configureViaRcon(this.target(handle), plan);
|
||||
}
|
||||
|
||||
async sendCommands(handle, commands) {
|
||||
return runRcon(this.target(handle), commands);
|
||||
}
|
||||
|
||||
async stop(handle) {
|
||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user