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.
159 lines
5.5 KiB
JavaScript
159 lines
5.5 KiB
JavaScript
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,
|
||
};
|
||
}
|