commit e9cd3489354bb3e376b82ce330cefaf07f2d922f Author: xzeldon Date: Mon Jan 10 23:21:47 2022 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a34f56a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Timofey Gelazoniya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf58e60 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# mineping +Collect information about Minecraft (both Java and Bedrock) using **[Node.js](http://nodejs.org)**. + +## Description + +`mineping` is a Javasript library thar provides Minecraft server ping protocol implementation. It can be used to collect information about the server, such as MODT, current online, server icon (java edition only) and etc. + +## Requirements +> **[Node.js](https://nodejs.org/) 14 or newer is required** + +## Example + +Ping a Java server with default options: + +```js +import { pingJava } from 'mineping' + +const data = await pingJava('mc.hypixel.net'); +console.log(data); +``` + +Ping a Bedrock server with custom options: + +```js +import { pingBedrock } from 'mineping' + +const data = await pingBedrock('mco.mineplex.com', { + port: 19132, + timeout: 500 +}) +``` +> More complex example can be found in the `examples` folder! + +## Acknowledgements +- [mcping](https://github.com/Scetch/mcping) crate for Rust +- [mcping-js](https://github.com/Cryptkeeper/mcping-js) library for quering Minecraft Java Edition servers \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2afec94 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting a Vulnerability + +If you believe you have found a security vulnerability, let us know by sending email to contact@zeldon.ru We will investigate that and do our best to quickly fix the problem. + +Please don't open an issue to or discuss this security vulnerability in a public place. Thanks for understanding! \ No newline at end of file diff --git a/example/cli.js b/example/cli.js new file mode 100644 index 0000000..7b9afaf --- /dev/null +++ b/example/cli.js @@ -0,0 +1,75 @@ +import { pingBedrock, pingJava } from '../index.js'; + +const args = getArgs(); + +if (args.help || args.h || Object.keys(args).length === 0) { + console.log(`node cli.js [..] + A simple to use, efficient, and full-featured Minecraft server info parser! + + USAGE: + node cli.js [OPTIONS] --host --port --timeout + + OPTIONS: + -j Use for Minecraft Java Edition + -b Use for Minecraft Bedrock Edition + P.S. Don't use them at the same time!`); + + process.exit(1); +} + +if (!args.host) { + console.error('ERROR: The host argument not found! Use -h or --help.'); + process.exit(1); +} + +// easter egg <3 +if (args.j && args.b) { + console.log(`Some interesting facts about MOTDs on bedrock: + - so far they seem to exclusively use legacy color codes + - the random style has a special impl for periods, they turn into animated + colons that warp up and down rapidly + - motd_2 is ignored? client displays "motd_1 - v{version}", where the + appended version text is considered part of motd_1 for color code processing + - motd_2 seems to mainly be used to return the server software in use (e.g. PocketMine-MP)`); + process.exit(0); +} + +if (args.j) { + const data = await pingJava(args.host, { + port: args.port || 25565, + timeout: args.timeout || 500 + }); + + console.log(`host: ${args.host}\nprotocol: ${data.version.protocol}\nonline: ${data.players.online}`); +} else if (args.b) { + const data = await pingBedrock(args.host, { + port: args.port || 19132, + timeout: args.timeout || 500 + }); + + console.log(`host: ${args.host}\nprotocol: ${data.version.protocol}\nonline: ${data.players.online}`); +} else { + console.error('ERROR: Unsupported flag passed. Use -h or --help.'); +} + +// parsing command line arguments +function getArgs() { + const args = {}; + process.argv.slice(2).forEach(arg => { + // long arg + if (arg.slice(0, 2) === '--') { + const longArg = arg.split('='); + const longArgFlag = longArg[0].slice(2, longArg[0].length); + const longArgValue = longArg.length > 1 ? longArg[1] : true; + args[longArgFlag] = longArgValue; + // flags + } else if (arg[0] === '-') { + const flags = arg.slice(1, arg.length).split(''); + flags.forEach(flag => { + args[flag] = true; + }); + } + }); + + return args; +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..2dda95f --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +export { pingJava } from './lib/java.js'; +export { pingBedrock } from './lib/bedrock.js'; \ No newline at end of file diff --git a/lib/bedrock.js b/lib/bedrock.js new file mode 100644 index 0000000..df6744a --- /dev/null +++ b/lib/bedrock.js @@ -0,0 +1,168 @@ +/** + * Implementation of the RakNet ping/pong protocol. + * @see https://wiki.vg/Raknet_Protocol#Unconnected_Ping + * + * Data types: + * @see https://wiki.vg/Raknet_Protocol#Data_types + */ + +'use strict'; + +import dgram from 'dgram'; +import ByteBuffer from 'bytebuffer'; + +const START_TIME = new Date().getTime(); + +/** + * Decode Unconnected Ping + * @param {number} pingId + * @returns {import('bytebuffer')} + * @see https://wiki.vg/Raknet_Protocol#Unconnected_Ping + */ +const UNCONNECTED_PING = (pingId) => { + // 0x01 + const bb = new ByteBuffer(); + bb.buffer[0] = 0x01; + bb.offset = 1; + return bb.writeLong(pingId).append('00ffff00fefefefefdfdfdfd12345678', 'hex').writeLong(0).flip().compact(); +}; + +/** + * Decode Unconnected Pong + * @param {import('bytebuffer')} buffer + * @see https://wiki.vg/Raknet_Protocol#Unconnected_Pong + */ +const UNCONNECTED_PONG = (buffer) => { + // 0x1c + buffer.offset = 1; + const pingId = buffer.readLong(); + const serverId = buffer.readLong(); + const offset = buffer.offset += 16; + const nameLength = buffer.readShort(); + let advertiseStr; + + try { + advertiseStr = buffer.readUTF8String(nameLength); + } catch (err) { + advertiseStr = buffer.readUTF8String(parseInt(err.message.substr(err.message.indexOf(',') + 2, 3))); + } + + advertiseStr = advertiseStr.split(/;/g); + const gameId = advertiseStr[0]; + const description = advertiseStr[1]; + const protocolVersion = advertiseStr[2]; + const gameVersion = advertiseStr[3]; + const currentPlayers = advertiseStr[4]; + const maxPlayers = advertiseStr[5]; + const name = advertiseStr[7]; + const mode = advertiseStr[8]; + + return { + pingId, + advertiseStr, + serverId, + offset, + gameId, + description, + protocolVersion, + gameVersion, + currentPlayers, + maxPlayers, + name, + mode + }; +}; + +function ping(host, port = 19132, cb, timeout = 5000) { + const socket = dgram.createSocket('udp4'); + + // Set manual timeout interval. + // This ensures the connection will NEVER hang regardless of internal state + const timeoutTask = setTimeout(() => { + socket.emit('error', new Error('Socket timeout')); + }, timeout); + + const closeSocket = () => { + socket.close(); + clearTimeout(timeoutTask); + }; + + // Generic error handler + // This protects multiple error callbacks given the complex socket state + // This is mostly dangerous since it can swallow errors + let didFireError = false; + + const handleError = (err) => { + closeSocket(); + + if (!didFireError) { + didFireError = true; + cb(null, err); + } + }; + + try { + const ping = UNCONNECTED_PING(new Date().getTime() - START_TIME); + socket.send(ping.buffer, 0, ping.buffer.length, port, host); + } catch (err) { + handleError(err); + } + + socket.on('message', (msg) => { + const buffer = new ByteBuffer().append(msg, 'hex').flip(); + const id = buffer.buffer[0]; + + switch (id) { + // https://wiki.vg/Raknet_Protocol#Unconnected_Ping + case 0x1c: { + const pong = UNCONNECTED_PONG(buffer); + const clientData = { + version: { + name: pong.name, + protocol: pong.protocolVersion + }, + players: { + max: pong.maxPlayers, + online: pong.currentPlayers + }, + description: pong.description.replace(/\xA7[0-9A-FK-OR]/ig, ''), + gamemode: pong.mode + }; + + // Close the socket and clear the timeout task + // This is a general cleanup for success conditions + closeSocket(); + cb(null, clientData); + break; + } + + default: { + handleError(new Error('Received unexpected packet')); + break; + } + } + }); + + socket.on('error', (err) => handleError(err)); +} + +/** + * Asynchronously ping Minecraft Bedrock server. + * + * The optional `options` argument can be an object with a `ping` (default is `19132`) or/and `timeout` (default is `5000`) property. + * + * @param {string} host The Bedrock server address. + * @param {import('../types/index.js').PingOptions} options The configuration for pinging Minecraft Bedrock server. + * @returns {Promise} + */ +export function pingBedrock(host, options = {}) { + if (!host) throw new Error('Host argument is not provided'); + + const { port = 19132, timeout = 5000 } = options; + + return new Promise((resilve, reject) => { + ping(host, port, (err, res) => { + err ? reject(err) : resilve(res); + }, timeout); + }); +} diff --git a/lib/java.js b/lib/java.js new file mode 100644 index 0000000..fe925e0 --- /dev/null +++ b/lib/java.js @@ -0,0 +1,127 @@ +/** + * Implementation of the Java Minecraft ping protocol. + * @see https://wiki.vg/Server_List_Ping + */ + +'use strict'; + +import net from 'net'; +import varint from './varint.js'; + +const PROTOCOL_VERSION = 0; + +function ping(host, port = 25565, cb, timeout = 5000) { + const socket = net.createConnection(({ host, port })); + + // Set manual timeout interval. + // This ensures the connection will NEVER hang regardless of internal state + const timeoutTask = setTimeout(() => { + socket.emit('error', new Error('Socket timeout')); + }, timeout); + + const closeSocket = () => { + socket.destroy(); + clearTimeout(timeoutTask); + }; + + // Generic error handler + // This protects multiple error callbacks given the complex socket state + // This is mostly dangerous since it can swallow errors + let didFireError = false; + + const handleError = (err) => { + closeSocket(); + + if (!didFireError) { + didFireError = true; + cb(null, err); + } + }; + + // #setNoDelay instantly flushes data during read/writes + // This prevents the runtime from delaying the write at all + socket.setNoDelay(true); + + socket.on('connect', () => { + const handshake = varint.concat([ + varint.encodeInt(0), + varint.encodeInt(PROTOCOL_VERSION), + varint.encodeInt(host.length), + varint.encodeString(host), + varint.encodeUShort(port), + varint.encodeInt(1) + ]); + + socket.write(handshake); + + const request = varint.concat([ + varint.encodeInt(0) + ]); + + socket.write(request); + }); + + let incomingBuffer = Buffer.alloc(0); + + socket.on('data', (data) => { + incomingBuffer = Buffer.concat([incomingBuffer, data]); + // Wait until incomingBuffer is at least 5 bytes long to ensure it has captured the first VarInt value + // This value is used to determine the full read length of the response + // "VarInts are never longer than 5 bytes" + // https://wiki.vg/Data_types#VarInt_and_VarLong + if (incomingBuffer.length < 5) { + return; + } + + let offset = 0; + const packetLength = varint.decodeInt(incomingBuffer, offset); + + // Ensure incomingBuffer contains the full response + if (incomingBuffer.length - offset < packetLength) { + return; + } + + const packetId = varint.decodeInt(incomingBuffer, varint.decodeLength(packetLength)); + + if (packetId === 0) { + const data = incomingBuffer.slice(varint.decodeLength(packetLength) + varint.decodeLength(packetId)); + const responseLength = varint.decodeInt(data, 0); + const response = data.slice(varint.decodeLength(responseLength), varint.decodeLength(responseLength) + responseLength); + + try { + const message = JSON.parse(response); + cb(null, message); + + // Close the socket and clear the timeout task + closeSocket(); + } catch (err) { + handleError(err); + } + } else { + handleError(new Error('Received unexpected packet')); + } + }); + + socket.on('error', handleError); +} + +/** + * Asynchronously ping Minecraft Java server. + * + * The optional `options` argument can be an object with a `ping` (default is `25565`) or/and `timeout` (default is `5000`) property. + * + * @param {string} host The Java server address. + * @param {import('../types/index.js').PingOptions} options The configuration for pinging Minecraft Java server. + * @returns {Promise} + */ +export function pingJava(host, options = {}) { + if (!host) throw new Error('Host argument is not provided'); + + const { port = 25565, timeout = 5000 } = options; + + return new Promise((resolve, reject) => { + ping(host, port, (err, res) => { + err ? reject(err) : resolve(res); + }, timeout); + }); +} \ No newline at end of file diff --git a/lib/varint.js b/lib/varint.js new file mode 100644 index 0000000..d0b3377 --- /dev/null +++ b/lib/varint.js @@ -0,0 +1,90 @@ +// https://wiki.vg/Data_types + +const varint = { + encodeInt: (val) => { + // "constInts are never longer than 5 bytes" + // https://wiki.vg/Data_types#constInt_and_constLong + const buf = Buffer.alloc(5); + let written = 0; + + while (true) { + if ((val & 0xFFFFFF80) === 0) { + buf.writeUInt8(val, written++); + break; + } else { + buf.writeUInt8(val & 0x7F | 0x80, written++); + val >>>= 7; + } + } + + return buf.slice(0, written); + }, + + encodeString: (val) => { + return Buffer.from(val, 'utf-8'); + }, + + encodeUShort: (val) => { + return Buffer.from([val >> 8, val & 0xFF]); + }, + + concat: (chunks) => { + let length = 0; + + for (const chunk of chunks) { + length += chunk.length; + } + + const buffer = [ + varint.encodeInt(length), + ...chunks + ]; + + return Buffer.concat(buffer); + }, + + decodeInt: (buffer, offset) => { + let val = 0; + let count = 0; + + while (true) { + const b = buffer.readUInt8(offset++); + + val |= (b & 0x7F) << count++ * 7; + + if ((b & 0x80) != 128) { + break; + } + } + + return val; + }, + + // The number of bytes that the last .decodeInt() call had to use to decode. + decodeLength: (val) => { + const N1 = Math.pow(2, 7); + const N2 = Math.pow(2, 14); + const N3 = Math.pow(2, 21); + const N4 = Math.pow(2, 28); + const N5 = Math.pow(2, 35); + const N6 = Math.pow(2, 42); + const N7 = Math.pow(2, 49); + const N8 = Math.pow(2, 56); + const N9 = Math.pow(2, 63); + + return ( + val < N1 ? 1 + : val < N2 ? 2 + : val < N3 ? 3 + : val < N4 ? 4 + : val < N5 ? 5 + : val < N6 ? 6 + : val < N7 ? 7 + : val < N8 ? 8 + : val < N9 ? 9 + : 10 + ); + } +}; + +export default varint; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b2fbc9 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "mineping", + "version": "1.0.0", + "description": "Ping both Minecraft Bedrock and Java servers.", + "main": "index.js", + "types": "types/index.d.ts", + "keywords": [], + "author": { + "name": "Timofey (xzeldon)", + "email": "contact@zeldon.ru", + "url": "https://zeldon.ru" + }, + "type": "module", + "engines": { + "node": ">=14" + }, + "license": "MIT", + "dependencies": { + "bytebuffer": "^5.0.1" + } +} \ No newline at end of file diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..eb72a5f --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,11 @@ +export { pingJava } from "./lib/java.js"; +export { pingBedrock } from "./lib/bedrock.js"; + +/** + * @param port The server port. + * @param timeout The read/write socket timeout. + */ +export type PingOptions = { + port: number, + timeout: number; +}; \ No newline at end of file diff --git a/types/lib/bedrock.d.ts b/types/lib/bedrock.d.ts new file mode 100644 index 0000000..ed2ac70 --- /dev/null +++ b/types/lib/bedrock.d.ts @@ -0,0 +1,41 @@ +import { PingOptions } from ".."; + +export type BedrockPingResponse = { + version: { + name: string; + protocol: string; + }; + players: { + max: string; + online: string; + }; + description: string; + gamemode: string; +}; + +/** + * Asynchronously ping Minecraft Bedrock server. + * + * The optional `options` argument can be an object with a `ping` (default is `19132`) or/and `timeout` (default is `5000`) property. + * + * @param host The Bedrock server address. + * + * ```js + * import { pingBedrock } from 'mineping'; + * + * const data = await pingBedrock('mco.mineplex.com'); + * console.log(data); + * ``` + * + * The resulting output will resemble: + * ```console + * { + * version: { name: 'Mineplex', protocol: '475' }, + * players: { max: '5207', online: '5206' }, + * description: ' New Costumes', + * gamemode: 'Survival' + * } + * ``` + */ +export function pingBedrock(host: string, options?: PingOptions): Promise; + diff --git a/types/lib/java.d.ts b/types/lib/java.d.ts new file mode 100644 index 0000000..73db119 --- /dev/null +++ b/types/lib/java.d.ts @@ -0,0 +1,53 @@ +import { PingOptions } from ".."; + +export type SampleProp = { + name: string, + id: string; +}; + +/** + * `JSON Response` field of Response packet. + * @see https://wiki.vg/Server_List_Ping#Response + */ +export type JavaPingResponse = { + version: { + name: string; + protocol: number; + }; + players: { + max: number; + online: number; + sample: SampleProp[]; + }; + description: string; + favicon: string; +}; + +/** + * Asynchronously ping Minecraft Java server. + * + * The optional `options` argument can be an object with a `ping` (default is `25565`) or/and `timeout` (default is `5000`) property. + * + * @param host The Java server address. + * @param options The configuration for pinging Minecraft Java server. + * + * ```js + * import { pingJava } from 'mineping'; + * + * const data = await pingJava('mc.hypixel.net'); + * console.log(data); + * ``` + * + * The resulting output will resemble: + * ```console + * { + * version: { name: 'Requires MC 1.8 / 1.18', protocol: 47 }, + * players: { max: 200000, online: 67336, sample: [] }, + * description: ' §f☃ §aHypixel Network §eTRIPLE COINS & EXP §f☃\n' + + * ' §6✰ §f§lHOLIDAY SALE §c§lUP TO 85% OFF §6✰', + * favicon: 'data:image/png;base64,iVBORw0KGg... + } + * ``` + */ +export function pingJava(host: string, options?: PingOptions): Promise; + diff --git a/types/lib/varint.d.ts b/types/lib/varint.d.ts new file mode 100644 index 0000000..f14b5f5 --- /dev/null +++ b/types/lib/varint.d.ts @@ -0,0 +1,10 @@ +export default varint; +declare namespace varint { + function encodeInt(val: number): Buffer; + function encodeString(val: string): Buffer; + function encodeUShort(val: number): Buffer; + function concat(chunks: Buffer[]): Buffer; + function decodeInt(buffer: Buffer, offset: number): number; + function decodeString(val: Buffer, offset?: number): string; + function decodeLength(val: number): 5 | 7 | 8 | 1 | 2 | 3 | 4 | 6 | 9 | 10; +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..a345df6 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,15 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +bytebuffer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/bytebuffer/-/bytebuffer-5.0.1.tgz#582eea4b1a873b6d020a48d58df85f0bba6cfddd" + integrity sha1-WC7qSxqHO20CCkjVjfhfC7ps/d0= + dependencies: + long "~3" + +long@~3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/long/-/long-3.2.0.tgz#d821b7138ca1cb581c172990ef14db200b5c474b" + integrity sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s=