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, }; }