151 lines
5.1 KiB
JavaScript
151 lines
5.1 KiB
JavaScript
'use strict';
|
||
|
||
/*
|
||
* mcping.js – "Server List Ping" (SLP), das Protokoll, das auch der
|
||
* Vanilla-Mehrspieler-Bildschirm nutzt, um MOTD/Spielerzahl/Version
|
||
* abzufragen, ohne sich wirklich zu verbinden.
|
||
*
|
||
* Ablauf (siehe minecraft.wiki/w/Server_List_Ping):
|
||
* 1) Handshake-Paket senden (Protokollversion, Adresse, Port, next state=1)
|
||
* 2) Status-Request-Paket senden (leer)
|
||
* 3) Status-Response-Paket empfangen: enthält einen JSON-String
|
||
* Alle Pakete sind mit ihrer Länge als VarInt vorangestellt.
|
||
*/
|
||
|
||
const net = require('net');
|
||
|
||
function encodeVarInt(value) {
|
||
const bytes = [];
|
||
let v = value;
|
||
do {
|
||
let temp = v & 0b01111111;
|
||
v = v >>> 7;
|
||
if (v !== 0) temp |= 0b10000000;
|
||
bytes.push(temp);
|
||
} while (v !== 0);
|
||
return Buffer.from(bytes);
|
||
}
|
||
|
||
function encodeString(str) {
|
||
const strBuf = Buffer.from(str, 'utf8');
|
||
return Buffer.concat([encodeVarInt(strBuf.length), strBuf]);
|
||
}
|
||
|
||
function encodeUShort(n) {
|
||
const b = Buffer.alloc(2);
|
||
b.writeUInt16BE(n & 0xffff, 0);
|
||
return b;
|
||
}
|
||
|
||
// liest einen VarInt aus buf ab offset -> { value, next } oder null,
|
||
// wenn noch nicht genug Bytes angekommen sind
|
||
function decodeVarInt(buf, offset) {
|
||
let numRead = 0;
|
||
let result = 0;
|
||
let read;
|
||
do {
|
||
if (offset + numRead >= buf.length) return null;
|
||
read = buf[offset + numRead];
|
||
result |= (read & 0b01111111) << (7 * numRead);
|
||
numRead++;
|
||
if (numRead > 5) throw new Error('VarInt zu groß');
|
||
} while ((read & 0b10000000) !== 0);
|
||
return { value: result >>> 0, next: offset + numRead };
|
||
}
|
||
|
||
// "description" kann ein reiner String oder eine Chat-Komponente
|
||
// ({text, extra:[...]}) sein – hier auf reinen Text reduzieren
|
||
function extractMotdText(desc) {
|
||
if (!desc) return '';
|
||
if (typeof desc === 'string') return desc;
|
||
let text = String(desc.text || '');
|
||
if (Array.isArray(desc.extra)) {
|
||
for (const part of desc.extra) text += extractMotdText(part);
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// Formatierungscodes (§a, §l, …) sind fürs Spiel gedacht, nicht für unsere Liste
|
||
function stripFormatting(text) {
|
||
return String(text || '').replace(/§./g, '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
/*
|
||
* Fragt einen Server ab.
|
||
* -> { ok, motd, version, protocol, online, max, sample, favicon, latencyMs }
|
||
* oder { ok: false, message }
|
||
*/
|
||
function pingServer(host, port = 25565, timeoutMs = 4000) {
|
||
return new Promise((resolve) => {
|
||
const start = Date.now();
|
||
const socket = new net.Socket();
|
||
let buffer = Buffer.alloc(0);
|
||
let finished = false;
|
||
|
||
const done = (result) => {
|
||
if (finished) return;
|
||
finished = true;
|
||
try { socket.destroy(); } catch { /* egal */ }
|
||
resolve(result);
|
||
};
|
||
|
||
socket.setTimeout(timeoutMs);
|
||
socket.on('timeout', () => done({ ok: false, message: 'Zeitüberschreitung.' }));
|
||
socket.on('error', (err) => done({ ok: false, message: err.code === 'ENOTFOUND' ? 'Adresse nicht gefunden.' : err.message }));
|
||
|
||
socket.connect(port, host, () => {
|
||
const handshakeBody = Buffer.concat([
|
||
encodeVarInt(0x00),
|
||
encodeVarInt(-1), // Protokollversion "unbekannt" – für den Status-Abfrage-Zweck egal
|
||
encodeString(host),
|
||
encodeUShort(port),
|
||
encodeVarInt(1), // next state: 1 = status
|
||
]);
|
||
const handshakePacket = Buffer.concat([encodeVarInt(handshakeBody.length), handshakeBody]);
|
||
|
||
const requestBody = encodeVarInt(0x00);
|
||
const requestPacket = Buffer.concat([encodeVarInt(requestBody.length), requestBody]);
|
||
|
||
socket.write(Buffer.concat([handshakePacket, requestPacket]));
|
||
});
|
||
|
||
socket.on('data', (chunk) => {
|
||
if (finished) return;
|
||
buffer = Buffer.concat([buffer, chunk]);
|
||
try {
|
||
const lenInfo = decodeVarInt(buffer, 0);
|
||
if (!lenInfo) return; // Länge noch nicht vollständig angekommen
|
||
const packetEnd = lenInfo.next + lenInfo.value;
|
||
if (buffer.length < packetEnd) return; // Paket noch nicht vollständig da
|
||
|
||
const idInfo = decodeVarInt(buffer, lenInfo.next);
|
||
if (!idInfo) return;
|
||
const jsonLenInfo = decodeVarInt(buffer, idInfo.next);
|
||
if (!jsonLenInfo) return;
|
||
const jsonStart = jsonLenInfo.next;
|
||
const jsonEnd = jsonStart + jsonLenInfo.value;
|
||
if (buffer.length < jsonEnd) return;
|
||
|
||
const data = JSON.parse(buffer.toString('utf8', jsonStart, jsonEnd));
|
||
done({
|
||
ok: true,
|
||
latencyMs: Date.now() - start,
|
||
motd: stripFormatting(extractMotdText(data.description)),
|
||
version: (data.version && data.version.name) || '',
|
||
protocol: data.version ? data.version.protocol : null,
|
||
online: data.players ? data.players.online : null,
|
||
max: data.players ? data.players.max : null,
|
||
sample: (data.players && Array.isArray(data.players.sample))
|
||
? data.players.sample.map((p) => String(p.name || '')).filter(Boolean)
|
||
: [],
|
||
favicon: typeof data.favicon === 'string' ? data.favicon : '',
|
||
});
|
||
} catch (err) {
|
||
done({ ok: false, message: 'Ungültige Antwort: ' + err.message });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
module.exports = { pingServer };
|