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:
commit
f08297dd0d
27 files changed
+6355
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user