Files
Cs2-Mapveto/server/src/servers/rcon.js
T
mregosar f08297dd0d 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.
2026-10-25 16:50:22 +01:00

159 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}