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.
This commit is contained in:
mregosar committed 2026-10-25 16:50:22 +01:00
commit f08297dd0d
27 files changed
+6355

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
import net from 'node:net';
// Source-RCON-Protokoll (TCP), wie es auch der CS2-Dedicated-Server spricht.
// https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
const SERVERDATA_AUTH = 3;
const SERVERDATA_AUTH_RESPONSE = 2;
const SERVERDATA_EXECCOMMAND = 2;
export function encodePacket(id, type, body) {
const bodyBuf = Buffer.from(body, 'utf8');
const size = 4 + 4 + bodyBuf.length + 2;
const buf = Buffer.alloc(4 + size);
buf.writeInt32LE(size, 0);
buf.writeInt32LE(id, 4);
buf.writeInt32LE(type, 8);
bodyBuf.copy(buf, 12);
// zwei abschließende Null-Bytes (Body-Terminator + leerer String) sind bereits durch alloc gesetzt
return buf;
}
/** Zerlegt einen Puffer in vollständige Pakete; gibt Pakete und den Rest zurück. */
export function decodePackets(buffer) {
const packets = [];
let offset = 0;
while (buffer.length - offset >= 4) {
const size = buffer.readInt32LE(offset);
if (size < 10 || size > 1024 * 1024) throw new Error(`Ungültige RCON-Paketgröße ${size}`);
if (buffer.length - offset < 4 + size) break;
const id = buffer.readInt32LE(offset + 4);
const type = buffer.readInt32LE(offset + 8);
const body = buffer.toString('utf8', offset + 12, offset + 4 + size - 2);
packets.push({ id, type, body });
offset += 4 + size;
}
return { packets, rest: buffer.subarray(offset) };
}
export class RconError extends Error {}
export class RconClient {
constructor({ host, port, password, timeoutMs = 5000 }) {
Object.assign(this, { host, port, password, timeoutMs });
this.socket = null;
this.nextId = 1;
this.buffer = Buffer.alloc(0);
this.listeners = new Set();
}
connect() {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: this.host, port: this.port });
const timer = setTimeout(() => {
socket.destroy();
reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} – Zeitüberschreitung`));
}, this.timeoutMs);
socket.once('error', (err) => {
clearTimeout(timer);
reject(new RconError(`RCON-Verbindung zu ${this.host}:${this.port} fehlgeschlagen: ${err.code ?? err.message}`));
});
socket.once('connect', async () => {
clearTimeout(timer);
this.socket = socket;
socket.on('data', (chunk) => this.onData(chunk));
socket.on('error', () => {});
try {
await this.authenticate();
resolve(this);
} catch (err) {
this.close();
reject(err);
}
});
});
}
onData(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
const { packets, rest } = decodePackets(this.buffer);
this.buffer = rest;
for (const packet of packets) for (const listener of this.listeners) listener(packet);
}
authenticate() {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => done(new RconError('RCON-Anmeldung – keine Antwort')), this.timeoutMs);
const listener = (packet) => {
if (packet.type !== SERVERDATA_AUTH_RESPONSE) return;
if (packet.id === -1) done(new RconError('RCON-Passwort wurde abgelehnt'));
else if (packet.id === id) done();
};
const done = (err) => {
clearTimeout(timer);
this.listeners.delete(listener);
err ? reject(err) : resolve();
};
this.listeners.add(listener);
this.socket.write(encodePacket(id, SERVERDATA_AUTH, this.password));
});
}
/** Führt einen Befehl aus und sammelt die (ggf. mehrteilige) Antwort. */
command(cmd, { quietMs = 250 } = {}) {
if (!this.socket) return Promise.reject(new RconError('RCON nicht verbunden'));
const id = this.nextId++;
return new Promise((resolve, reject) => {
let body = '';
let quietTimer = null;
const timer = setTimeout(() => finish(), this.timeoutMs);
const listener = (packet) => {
if (packet.id !== id) return;
body += packet.body;
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs);
};
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
const finish = (err) => {
clearTimeout(timer);
clearTimeout(quietTimer);
this.listeners.delete(listener);
this.socket?.off('close', onClose);
err ? reject(err) : resolve(body);
};
this.listeners.add(listener);
this.socket.once('close', onClose);
this.socket.write(encodePacket(id, SERVERDATA_EXECCOMMAND, cmd));
});
}
close() {
this.socket?.destroy();
this.socket = null;
}
}
/** Baut eine Verbindung auf, führt Befehle nacheinander aus und schließt wieder. */
export async function runRcon({ host, port, password }, commands, options = {}) {
const client = new RconClient({ host, port, password, timeoutMs: options.timeoutMs });
await client.connect();
try {
const outputs = [];
for (const cmd of commands) outputs.push(await client.command(cmd, options));
return outputs;
} finally {
client.close();
}
}
/** Liest Map und Spielerzahl aus der Ausgabe von "status". Gibt null-Werte zurück, wenn unbekannt. */
export function parseStatus(output) {
const text = String(output ?? '');
const map = /(?:loaded spawngroup\(\s*1\)\s*:\s*SV:\s*\[1:\s*(\w+)|^map\s*:\s*(\w+))/im.exec(text);
const players = /players\s*:\s*(\d+)\s*humans/i.exec(text);
return {
map: map ? map[1] ?? map[2] : null,
playerCount: players ? Number(players[1]) : null,
};
}