├── test ├── fixtures │ ├── response-0.bin │ ├── response-1.bin │ ├── response-3.bin │ ├── response-0.inspect │ ├── response-1.inspect │ ├── response-3.inspect │ ├── response-2.bin │ ├── response-4.bin │ ├── response-2.inspect │ └── response-4.inspect ├── tools.js ├── mock-server.js ├── packet.test.js └── query.test.js ├── index.js ├── .eslintrc.js ├── config.json.sample ├── .gitignore ├── lib ├── consts.js ├── errors.js ├── log.js ├── requestpacket.js ├── responsepacket.js └── index.js ├── examples ├── docker-compose.yml ├── intervals.js └── app.js ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── package.json └── README.md /test/fixtures/response-0.bin: -------------------------------------------------------------------------------- 1 | 3076233 -------------------------------------------------------------------------------- /test/fixtures/response-1.bin: -------------------------------------------------------------------------------- 1 | 10342033 -------------------------------------------------------------------------------- /test/fixtures/response-3.bin: -------------------------------------------------------------------------------- 1 | 11802847 -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib') 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "standard" 3 | }; -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "host":"127.0.0.1", 3 | "port": 25565 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | config.json 3 | *.log 4 | *.sublime-* 5 | 6 | .vscode/ -------------------------------------------------------------------------------- /test/fixtures/response-0.inspect: -------------------------------------------------------------------------------- 1 | { type: 9, sessionId: 1793, challengeToken: 3076233 } -------------------------------------------------------------------------------- /test/fixtures/response-1.inspect: -------------------------------------------------------------------------------- 1 | { type: 9, sessionId: 1794, challengeToken: 10342033 } -------------------------------------------------------------------------------- /test/fixtures/response-3.inspect: -------------------------------------------------------------------------------- 1 | { type: 9, sessionId: 1795, challengeToken: 11802847 } -------------------------------------------------------------------------------- /test/fixtures/response-2.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmpm/node-mcquery/HEAD/test/fixtures/response-2.bin -------------------------------------------------------------------------------- /test/fixtures/response-4.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmpm/node-mcquery/HEAD/test/fixtures/response-4.bin -------------------------------------------------------------------------------- /lib/consts.js: -------------------------------------------------------------------------------- 1 | exports.CHALLENGE_TYPE = 0x09 2 | exports.STAT_TYPE = 0x00 3 | 4 | exports.REQUEST_HANDSHAKE = 1 5 | exports.REQUEST_FULL = 2 6 | exports.REQUEST_BASIC = 3 7 | -------------------------------------------------------------------------------- /test/fixtures/response-2.inspect: -------------------------------------------------------------------------------- 1 | { type: 0, 2 | sessionId: 1794, 3 | MOTD: 'A Minecraft Server', 4 | gametype: 'SMP', 5 | map: 'world', 6 | numplayers: 1, 7 | maxplayers: 20, 8 | hostport: 25565, 9 | hostip: '172.17.0.2' } -------------------------------------------------------------------------------- /examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | mc: 4 | image: itzg/minecraft-server:latest 5 | environment: 6 | EULA: 'TRUE' 7 | ENABLE_QUERY: 'true' 8 | ports: 9 | - "25565:25565" 10 | - "25565:25565/udp" 11 | volumes: 12 | - mcdata:/data 13 | 14 | volumes: 15 | mcdata: -------------------------------------------------------------------------------- /test/fixtures/response-4.inspect: -------------------------------------------------------------------------------- 1 | { type: 0, 2 | sessionId: 1795, 3 | hostname: 'A Minecraft Server', 4 | gametype: 'SMP', 5 | game_id: 'MINECRAFT', 6 | version: '1.8', 7 | plugins: '', 8 | map: 'world', 9 | numplayers: '1', 10 | maxplayers: '20', 11 | hostport: '25565', 12 | hostip: '172.17.0.2', 13 | player_: ['crippledcanary'] } -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | class QueryError extends Error { 2 | constructor (message) { 3 | super() 4 | Error.captureStackTrace(this, this.constructor) 5 | this.message = message || 6 | 'Something went wrong. Please try again.' 7 | } 8 | } 9 | 10 | class QueryConnectionError extends QueryError { 11 | 12 | } 13 | 14 | module.exports = { 15 | QueryError, 16 | QueryConnectionError 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI Testing 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [10.x, 12.x, 14.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm run build --if-present 21 | - run: npm run lint 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | var Debug = require('debug') 6 | 7 | var LEVELS = ['ERROR', 'WARN', 'INFO', 'DEBUG'] 8 | 9 | class Log { 10 | constructor (context) { 11 | this.context = context 12 | this._addLevels() 13 | } 14 | 15 | _addLevels () { 16 | LEVELS.forEach(l => { 17 | var debug = Debug(this.context + ':' + l) 18 | this[l.toLowerCase()] = makeLog(debug) 19 | }) 20 | } 21 | 22 | addContext (addedContext) { 23 | return new Log(`${this.context}:${addedContext}`) 24 | } 25 | } 26 | 27 | module.exports = function (context) { 28 | return new Log(context) 29 | } 30 | 31 | function makeLog (debug) { 32 | return function () { 33 | debug.apply(debug, arguments) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/intervals.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Check the server every 5 seconds 3 | * 4 | * Copyright © 2011-2020 Peter Magnusson. 5 | * All rights reserved. 6 | */ 7 | var Query = require('..') 8 | 9 | var HOST = process.env.MC_SERVER || 'localhost' 10 | var PORT = process.env.MC_PORT || 25565 11 | 12 | // uses the optional settings 13 | // for a longer timeout; 14 | var query = new Query(HOST, PORT, { timeout: 10000 }) 15 | 16 | function checkMcServer () { 17 | // connect every time to get a new challengeToken 18 | query.connect(function (err) { 19 | if (err) { 20 | console.error(err) 21 | } else { 22 | query.full_stat(fullStatBack) 23 | } 24 | }) 25 | } 26 | 27 | function fullStatBack (err, stat) { 28 | if (err) { 29 | console.error(err) 30 | } 31 | console.log('%s>fullBack \n', new Date(), stat) 32 | } 33 | 34 | setInterval(function () { 35 | checkMcServer() 36 | }, 5000) 37 | -------------------------------------------------------------------------------- /examples/app.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | var Query = require('..') 6 | 7 | var HOST = process.env.MC_SERVER || 'localhost' 8 | var PORT = process.env.MC_PORT || 25565 9 | 10 | var query = new Query(HOST, PORT) 11 | 12 | function basicStatBack (err, stat) { 13 | if (err) { 14 | console.error(err) 15 | } 16 | console.log('basicBack', stat) 17 | shouldWeClose() 18 | } 19 | 20 | function fullStatBack (err, stat) { 21 | if (err) { 22 | console.error(err) 23 | } 24 | console.log('fullBack', stat) 25 | shouldWeClose() 26 | } 27 | 28 | function shouldWeClose () { 29 | // have we got all answers 30 | if (query.outstandingRequests === 0) { 31 | query.close() 32 | } 33 | } 34 | 35 | console.log('Connecting to server') 36 | query.connect() 37 | .then(() => { 38 | console.log('asking for basic_stat') 39 | query.basic_stat(basicStatBack) 40 | console.log('Asking for full_stat') 41 | query.full_stat(fullStatBack) 42 | }) 43 | .catch(err => { 44 | console.error('error connecting', err) 45 | }) 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2011-2021 Peter Magnusson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcquery", 3 | "description": "minecraft query protocol for node", 4 | "license": "MIT", 5 | "keywords": [ 6 | "mcquery", 7 | "protocol", 8 | "query", 9 | "minecraft" 10 | ], 11 | "homepage": "https://github.com/kmpm/node-mcquery", 12 | "author": "Peter Magnusson ", 13 | "version": "1.0.3", 14 | "main": "index.js", 15 | "engines": { 16 | "node": ">=v8.0.0" 17 | }, 18 | "scripts": { 19 | "test": "lab -c", 20 | "lint": "eslint lib test examples index.js", 21 | "coveralls": "lab -r lcov | ./node_modules/.bin/coveralls" 22 | }, 23 | "bugs": { 24 | "url": "http://github.com/kmpm/node-mcquery/issues" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "http://github.com/kmpm/node-mcquery.git" 29 | }, 30 | "devDependencies": { 31 | "@hapi/lab": "^22.0.5", 32 | "acorn": "^7.4.1", 33 | "coveralls": "^3.1.0", 34 | "eslint": "^5.16.0", 35 | "eslint-config-standard": "^12.0.0", 36 | "eslint-plugin-import": "^2.22.1", 37 | "eslint-plugin-node": "^8.0.1", 38 | "eslint-plugin-promise": "^4.3.1", 39 | "eslint-plugin-standard": "^4.1.0", 40 | "xtend": "^4.0.2" 41 | }, 42 | "dependencies": { 43 | "buffercursor": "0.0.12", 44 | "code": "^5.2.4", 45 | "debug": "^4.3.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/tools.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | var debug = require('debug')('mcquery:test:tools') 6 | var fs = require('fs') 7 | var vm = require('vm') 8 | var util = require('util') 9 | 10 | exports.createJs = function (obj) { 11 | return util.inspect(obj, { depth: null }) 12 | } 13 | 14 | exports.writeBin = function (filename, buf) { 15 | var ws = fs.createWriteStream(filename) 16 | ws.write(buf) 17 | ws.end() 18 | } 19 | 20 | exports.writeJs = function (filename, obj) { 21 | fs.writeFileSync(filename, exports.createJs(obj)) 22 | } 23 | 24 | exports.readBin = function (filename) { 25 | return fs.readFileSync(filename) 26 | } 27 | 28 | exports.prepareJs = function (text) { 29 | // replace with Buffer.from("aabb", "hex") 30 | var matches = text.match(/()/g) 31 | if (matches) { 32 | debug('matches', matches) 33 | matches.forEach(function (m) { 34 | var bytes = m.match(/ ([a-f0-9]{2})/g) 35 | var str = bytes.join('') 36 | str = str.replace(/ /g, '') 37 | var r = 'Buffer.from("' + str + '", "hex")' 38 | text = text.replace(m, r) 39 | }) 40 | } 41 | return text 42 | } 43 | 44 | exports.readJs = function (filename) { 45 | var js = 'foo = ' + fs.readFileSync(filename, 'utf8') 46 | var sandbox = { foo: new Error('no object created') } 47 | js = exports.prepareJs(js) 48 | vm.runInNewContext(js, sandbox, filename) 49 | return sandbox.foo 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node-MCQuery 2 | 3 | __Archival notice!!!__ This project is currently archived because of lack of time 4 | and interest. I no longer play or use minecraft in any form. But someone else might be. 5 | If you want to post a notice here about your active alternative then send me a 6 | DM here on github. 7 | 8 | __ alternatives __ 9 | - https://github.com/VityaSchel/minecraft-query - TypeScript support, promises, JSDoc and syntax sugar methods. JSR alongside NPM. 10 | 11 | 12 | ---- 13 | 14 | A library for accessing a Minecraft server using the Query protocol 15 | 16 | If you need to run under node < 8.0.0 then you have to use a version < 1.0.0 of this library 17 | 18 | ## Status 19 | [![CI Testing](https://github.com/kmpm/node-mcquery/actions/workflows/main.yml/badge.svg)](https://github.com/kmpm/node-mcquery/actions/workflows/main.yml) 20 | 21 | 22 | ## References ## 23 | * http://wiki.vg/Query 24 | 25 | 26 | ## FAQ 27 | __Q__: Response attribute `hostname` returns the server motd on full_stat. 28 | 29 | __A__: Correct. That is according to the definition in https://wiki.vg/Query#Full_stat . 30 | The response from the server uses the keyword `hostname` for the MOTD data. 31 | ```javascript 32 | { type: 0, 33 | sessionId: 1, 34 | hostname: 'A Vanilla Minecraft Server powered by Docker', 35 | gametype: 'SMP', 36 | game_id: 'MINECRAFT', 37 | version: '1.15.2', 38 | plugins: '', 39 | map: 'world', 40 | numplayers: '0', 41 | maxplayers: '20', 42 | hostport: '25565', 43 | hostip: '172.18.0.2', 44 | player_: [], 45 | from: { address: '127.0.0.1', port: 25565 } } 46 | ``` 47 | 48 | ## License 49 | (The MIT License) 50 | 51 | Copyright (c) 2011-2020 Peter Magnusson <peter@kmpm.se> 52 | 53 | Permission is hereby granted, free of charge, to any person obtaining 54 | a copy of this software and associated documentation files (the 55 | 'Software'), to deal in the Software without restriction, including 56 | without limitation the rights to use, copy, modify, merge, publish, 57 | distribute, sublicense, and/or sell copies of the Software, and to 58 | permit persons to whom the Software is furnished to do so, subject to 59 | the following conditions: 60 | 61 | The above copyright notice and this permission notice shall be 62 | included in all copies or substantial portions of the Software. 63 | 64 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 65 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 66 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 67 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 68 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 69 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 70 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 71 | -------------------------------------------------------------------------------- /test/mock-server.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | const dgram = require('dgram') 6 | const xtend = require('xtend') 7 | const ResponsePacket = require('../lib/responsepacket') 8 | const RequestPacket = require('../lib/requestpacket') 9 | const consts = require('../lib/consts') 10 | const log = new (require('../lib/log'))('mcquery:test:mock-server') 11 | 12 | var tokenCounter = 0 13 | 14 | var Server = module.exports = function (options) { 15 | options = options || {} 16 | this.settings = { 17 | port: options.port || 25565, 18 | ip: process.env.ip || '127.0.0.1' 19 | } 20 | this.socket = null 21 | 22 | this.data = { 23 | hostname: 'A Minecraft Server', 24 | gametype: 'SMP', 25 | game_id: 'MINECRAFT', 26 | version: '1.8', 27 | plugins: '', 28 | map: 'world', 29 | numplayers: '1', 30 | maxplayers: '20', 31 | hostport: this.settings.port, 32 | hostip: this.settings.ip, 33 | player_: ['crippledcanary'] 34 | } 35 | 36 | this.ignore = false 37 | this.badReply = false 38 | this.randomResponse = false 39 | this.delay = 30 40 | log.debug('MockServer created') 41 | } 42 | 43 | Server.prototype.setIgnore = function (value) { 44 | log.debug('setting ignore to', value) 45 | this.ignore = value 46 | } 47 | 48 | Server.prototype.bind = function (callback) { 49 | var self = this 50 | var socket = dgram.createSocket('udp4') 51 | this.socket = socket 52 | 53 | socket.on('error', function (err) { 54 | throw err 55 | }) 56 | 57 | socket.on('message', function (msg, rinfo) { 58 | if (self.ignore) { 59 | log.debug('ignoring 1 message') 60 | self.setIgnore(false) 61 | return 62 | } 63 | 64 | var res, req 65 | log.debug('parsing message') 66 | try { 67 | req = RequestPacket.parse(msg) 68 | } catch (ex) { 69 | log.error(ex) 70 | throw ex 71 | } 72 | log.debug('mock request', req) 73 | res = new ResponsePacket() 74 | res.sessionId = req.sessionId 75 | res.type = req.type 76 | 77 | if (self.randomResponse) { 78 | self.randomResponse = false 79 | log.debug('single random response') 80 | res.sessionId = 12345 81 | } 82 | 83 | switch (req.type) { 84 | case consts.CHALLENGE_TYPE: 85 | res.challengeToken = 3076233 + tokenCounter++ 86 | break 87 | case consts.REQUEST_BASIC: 88 | res.type = consts.STAT_TYPE 89 | res.MOTD = self.data.hostname 90 | res.gametype = self.data.gametype 91 | res.map = self.data.map 92 | res.numplayers = self.data.numplayers 93 | res.maxplayers = self.data.maxplayers 94 | res.hostport = self.data.hostport 95 | res.hostip = self.data.hostip 96 | break 97 | case consts.REQUEST_FULL: 98 | res.type = consts.STAT_TYPE 99 | res = xtend(res, self.data) 100 | break 101 | default: 102 | log.debug('request type %d is not implemented', req.type) 103 | throw new Error('request type not implemented') 104 | } 105 | 106 | var buf 107 | try { 108 | buf = ResponsePacket.write(res) 109 | } catch (ex) { 110 | log.error(ex) 111 | throw ex 112 | } 113 | if (self.badReply) { 114 | log.debug('single bad reply') 115 | self.badReply = false 116 | buf = Buffer.alloc(11) 117 | } 118 | setTimeout(function () { 119 | log.debug('mock response', res) 120 | socket.send(buf, 0, buf.length, rinfo.port, rinfo.address, (err, bytes) => { 121 | if (err) { 122 | throw err 123 | } 124 | log.debug('%d bytes sent', bytes) 125 | }) 126 | }, self.delay) 127 | }) 128 | 129 | return new Promise((resolve) => { 130 | socket.bind(this.settings.port, this.settings.ip) 131 | socket.on('listening', () => { 132 | log.debug('MockServer bound') 133 | return callback ? callback(null, socket) : resolve(this) 134 | }) 135 | socket.on('close', () => { 136 | log.debug('MockServer closed socket') 137 | }) 138 | socket.on('error', (err) => { 139 | log.debug('MockServer socket error', err) 140 | }) 141 | }) 142 | } 143 | -------------------------------------------------------------------------------- /lib/requestpacket.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | var BufferCursor = require('buffercursor') 6 | var consts = require('./consts') 7 | var log = new (require('./log'))('mcquery:lib:requestpacket') 8 | 9 | var COUNTER_MIN = 1 10 | var COUNTER_MAX = 65535 11 | var counter = 1 12 | var MAX_TOKEN = 2147483647 13 | var MIN_TOKEN = -2147483648 14 | var SESSION_BITMASK = 0x0F0F0F0F 15 | 16 | var RequestPacket = module.exports = function (requestType, options) { 17 | options = options || {} 18 | requestType = requestType || consts.REQUEST_HANDSHAKE 19 | this.sessionId = options.sessionId || RequestPacket.generateToken() 20 | switch (requestType) { 21 | case consts.REQUEST_HANDSHAKE: 22 | this.type = consts.CHALLENGE_TYPE 23 | break 24 | case consts.REQUEST_FULL: 25 | this.payload = options.payload || Buffer.from([0, 0, 0, 0]) 26 | this.type = consts.STAT_TYPE 27 | this.challengeToken = options.challengeToken 28 | break 29 | case consts.REQUEST_BASIC: 30 | this.type = consts.STAT_TYPE 31 | this.challengeToken = options.challengeToken 32 | break 33 | default: 34 | throw new Error('requestType "' + requestType + '" not implemented') 35 | } 36 | } 37 | 38 | /* 39 | * Create a request packet 40 | * @param {Number} request type 41 | * @param {Object} session information 42 | * @param {Buffer} optional payload 43 | */ 44 | RequestPacket.write = function (packet) { 45 | /* type, challengeToken, sessionId, payloadBuffer */ 46 | if (!(packet.type === consts.CHALLENGE_TYPE || 47 | packet.type === consts.STAT_TYPE)) { 48 | throw new TypeError('type did not have a correct value ' + packet.type) 49 | } 50 | if (typeof packet.sessionId !== 'number' || 51 | packet.sessionId > MAX_TOKEN || 52 | packet.sessionId < 1 || 53 | (packet.sessionId & SESSION_BITMASK) !== packet.sessionId) { 54 | throw new TypeError('sessionId is bad or missing') 55 | } 56 | 57 | if (typeof packet.payload !== 'undefined' && 58 | !(packet.payload instanceof Buffer)) { 59 | throw new TypeError('payload was not a Buffer instance') 60 | } 61 | 62 | if (packet.type === consts.STAT_TYPE) { 63 | if (typeof packet.challengeToken !== 'number' || 64 | packet.challengeToken <= MIN_TOKEN || 65 | packet.challengeToken >= MAX_TOKEN) { 66 | throw new TypeError('challengeToken is missing or wrong') 67 | } 68 | } 69 | 70 | var pLength = typeof packet.payload === 'undefined' ? 0 : packet.payload.length 71 | 72 | var sLength = typeof packet.challengeToken !== 'number' ? 0 : 4 73 | 74 | var bc = new BufferCursor(Buffer.alloc(7 + sLength + pLength)) 75 | 76 | bc.writeUInt8(0xFE) 77 | bc.writeUInt8(0xFD) 78 | bc.writeInt8(packet.type) 79 | bc.writeInt32BE(packet.sessionId) 80 | if (sLength > 0) { 81 | bc.writeInt32BE(packet.challengeToken) 82 | } 83 | if (pLength > 0) { 84 | bc.copy(packet.payload) 85 | } 86 | 87 | return bc.buffer 88 | } 89 | 90 | RequestPacket.parse = function (buf) { 91 | var packet = new RequestPacket() 92 | var bc = new BufferCursor(buf) 93 | var magic = bc.readUInt16BE() 94 | if (magic !== 0xFEFD) { 95 | throw new Error('Error in Magic Field') 96 | } 97 | 98 | packet.type = bc.readInt8() 99 | packet.sessionId = bc.readInt32BE() 100 | if (bc.length > bc.tell()) { 101 | switch (packet.type) { 102 | case consts.STAT_TYPE: 103 | // get the challengeToken 104 | packet.challengeToken = bc.readInt32BE() 105 | if (bc.eof()) { 106 | packet.type = consts.REQUEST_BASIC 107 | } else { 108 | packet.type = consts.REQUEST_FULL 109 | } 110 | break 111 | default: 112 | // TODO:get some payload 113 | log.debug(packet.type) 114 | throw new Error('payload not implemented') 115 | } 116 | } 117 | return packet 118 | } 119 | 120 | /* 121 | * Generate a idToken 122 | */ 123 | RequestPacket.generateToken = function (c) { 124 | c = c || counter++ 125 | counter = c + 1 126 | // just not let it get to big. 127 | if (counter > COUNTER_MAX) { 128 | counter = COUNTER_MIN 129 | } 130 | 131 | // the protocol only uses the first 4 bits in every byte so mask. 132 | var hex = ('00000000' + c.toString(16)).substr(-6, 6) 133 | var value = parseInt(hex.replace(/.{1}/g, '$&0').slice(0, -1), 16) 134 | value = value & SESSION_BITMASK 135 | return value 136 | } 137 | -------------------------------------------------------------------------------- /lib/responsepacket.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | var BufferCursor = require('buffercursor') 6 | var log = new (require('./log'))('mcquery:lib:responsepacket') 7 | var consts = require('./consts') 8 | 9 | var KEYVAL_START = 128 10 | var KEYVAL_END = 256 11 | 12 | var MAX_PACKET_SIZE = 512 // maximum bytes for a packet. 13 | 14 | var ResponsePacket = module.exports = function () { 15 | this.type = 0 16 | this.sessionId = 0 17 | } 18 | 19 | /* 20 | * Parse a response buffer and return an object 21 | */ 22 | ResponsePacket.parse = function parsePacket (data) { 23 | var bc = new BufferCursor(data) 24 | log.debug('parsing packet') 25 | var res = new ResponsePacket() 26 | 27 | res.type = bc.readInt8() 28 | res.sessionId = bc.readInt32BE() 29 | 30 | log.debug('response=%j', res) 31 | 32 | bc = bc.slice() 33 | if (res.type === consts.CHALLENGE_TYPE) { 34 | log.debug('challenge packet') 35 | res.challengeToken = parseInt(bc.toString(), 10) 36 | } else if (res.type === consts.STAT_TYPE) { 37 | log.debug('stat packet') 38 | var r = readString(bc) 39 | log.debug('first string', r) 40 | if (r !== 'splitnum') { 41 | // it was basic stat response 42 | res.MOTD = r 43 | res.gametype = readString(bc) 44 | res.map = readString(bc) 45 | res.numplayers = parseInt(readString(bc), 10) 46 | res.maxplayers = parseInt(readString(bc), 10) 47 | res.hostport = bc.readInt16LE() 48 | res.hostip = readString(bc) 49 | } else { 50 | // it was full_stat response 51 | 52 | // key value start byte 53 | bc.readUInt16LE() 54 | var key 55 | var value 56 | while (bc.buffer.readUInt16LE(bc.tell()) !== KEYVAL_END) { 57 | key = readString(bc) 58 | value = readString(bc) 59 | log.debug('key=%s, value=%s', key, value) 60 | res[key] = value 61 | } 62 | 63 | // key value end byte 64 | bc.readUInt16LE() 65 | 66 | // we have players on the end 67 | key = readString(bc) 68 | if (key.length > 0) { 69 | var players = [] 70 | res[key] = players 71 | 72 | // jump one extra dead byte 73 | bc.seek(bc.tell() + 1) 74 | 75 | r = readString(bc) 76 | log.debug('player %s =', players.length, r) 77 | while (r.length >= 1) { 78 | players.push(r) 79 | r = readString(bc) 80 | } 81 | } 82 | } 83 | } else { 84 | throw new Error('Unknown response type: ' + res.type) 85 | } 86 | if (bc.tell() < bc.length) { 87 | throw new Error('Bytes remaining after end of response') 88 | } 89 | return res 90 | }// end parsePacket 91 | 92 | ResponsePacket.write = function (packet) { 93 | log.debug('writing response') 94 | var bc = new BufferCursor(Buffer.alloc(MAX_PACKET_SIZE)) 95 | 96 | bc.writeInt8(packet.type) 97 | bc.writeInt32BE(packet.sessionId) 98 | switch (packet.type) { 99 | case consts.CHALLENGE_TYPE: 100 | var s = packet.challengeToken.toString() 101 | writeString(bc, s) // bc.write(s, s.length, 'utf-8') 102 | 103 | break 104 | case consts.STAT_TYPE: 105 | if (packet.version) { 106 | // full stat 107 | writeString(bc, 'splitnum') 108 | bc.writeUInt16LE(KEYVAL_START) 109 | for (var key in packet) { 110 | if (packet.hasOwnProperty(key) && 111 | ['type', 'sessionId', 'player_'].indexOf(key) === -1) { 112 | writeString(bc, key) 113 | writeString(bc, packet[key]) 114 | } 115 | } 116 | bc.writeUInt16LE(KEYVAL_END) 117 | 118 | if (packet.hasOwnProperty('player_')) { 119 | log.debug('players next') 120 | // players section 121 | writeString(bc, 'player_') 122 | bc.writeUInt8(0) // extra dead byte 123 | for (var i = 0; i < packet.player_.length; i++) { 124 | writeString(bc, packet.player_[i]) 125 | log.debug('writing', packet.player_[i]) 126 | } 127 | } 128 | writeString(bc, '') 129 | } else { 130 | // basic stat 131 | log.debug('writing basic stat') 132 | writeString(bc, packet.MOTD) 133 | writeString(bc, packet.gametype) 134 | writeString(bc, packet.map) 135 | writeString(bc, packet.numplayers) 136 | writeString(bc, packet.maxplayers) 137 | bc.writeInt16LE(packet.hostport) 138 | writeString(bc, packet.hostip) 139 | } 140 | break 141 | default: 142 | log.error('unsupported response', packet) 143 | throw new Error('packet type ' + 144 | packet.type.toString() + ' not implemented') 145 | } 146 | 147 | log.debug('writing done') 148 | return bc.buffer.slice(0, bc.tell()) 149 | }// end write 150 | 151 | function readString (bc) { 152 | var start = bc.tell() 153 | var b = bc.readUInt8() 154 | while (b !== 0x0) { 155 | b = bc.readUInt8() 156 | } 157 | 158 | return bc.buffer.toString('utf-8', start, bc.tell() - 1) 159 | } 160 | 161 | function writeString (bc, value) { 162 | if (typeof value !== 'string') { 163 | value = value.toString() 164 | } 165 | bc.write(value, value.length, 'utf-8') 166 | bc.writeUInt8(0) 167 | } 168 | -------------------------------------------------------------------------------- /test/packet.test.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | 4 | const Lab = require('@hapi/lab') 5 | const { describe, it } = exports.lab = Lab.script() 6 | const { expect } = require('code') 7 | 8 | var tools = require('./tools') 9 | var ResponsePacket = require('../lib/responsepacket') 10 | var RequestPacket = require('../lib/requestpacket') 11 | 12 | var consts = require('../lib/consts') 13 | 14 | var fixtureDir = path.join(__dirname, 'fixtures') 15 | 16 | function createParsingTests (testFolder) { 17 | var files = fs.readdirSync(testFolder).filter(function (f) { return /\.bin$/.test(f) }) 18 | files.forEach(function (file) { 19 | it('can parse ' + file, () => { 20 | var bin = tools.readBin(path.join(testFolder, file)) 21 | var ret = ResponsePacket.parse(bin) 22 | 23 | var jsFile = path.join(testFolder, file.replace(/\.bin$/, '.inspect')) 24 | var js 25 | if (fs.existsSync(jsFile)) { 26 | js = tools.readJs(jsFile) 27 | 28 | expect(JSON.stringify(ret)).to.equal(JSON.stringify(js)) 29 | // expect(ret).to.include(js) 30 | 31 | // roundtrip 32 | var bin2 = ResponsePacket.write(ret) 33 | // expect(bin2.length, 'binary length').to.equal(bin.length) 34 | expect(bin2.toString('hex')).to.equal(bin.toString('hex')) 35 | } else { 36 | tools.writeJs(jsFile, ret) 37 | } 38 | }) 39 | }) 40 | } 41 | 42 | describe('packet', () => { 43 | describe('Response', () => { 44 | it('a package with bad type', () => { 45 | var buf = Buffer.from('080000000127231f00', 'hex') 46 | function fn () { 47 | ResponsePacket.parse(buf) 48 | } 49 | expect(fn).to.throw('Unknown response type: 8') 50 | }) 51 | 52 | it('a packet without players', () => { 53 | var buf = ResponsePacket.write({ 54 | type: 0, 55 | sessionId: 1795, 56 | hostname: 'A Minecraft Server', 57 | gametype: 'SMP', 58 | game_id: 'MINECRAFT', 59 | version: '1.8', 60 | plugins: '', 61 | map: 'world', 62 | numplayers: '1', 63 | maxplayers: '20', 64 | hostport: '25565', 65 | hostip: '172.17.0.2' 66 | // player_: ['crippledcanary'] 67 | }) 68 | 69 | ResponsePacket.parse(buf) 70 | }) 71 | 72 | it('should only support STAT_TYPE and CHALLENGE_TYPE', () => { 73 | expect(fn).to.throw(Error, 'packet type 2 not implemented') 74 | 75 | function fn () { 76 | ResponsePacket.write({ 77 | type: consts.REQUEST_FULL, 78 | sessionId: 123 79 | }) 80 | } 81 | }) 82 | 83 | createParsingTests(fixtureDir) 84 | })// --Response 85 | 86 | describe('Request', () => { 87 | it('should generate valid sessinIds', () => { 88 | expect(RequestPacket.generateToken(1)).to.equal(1) 89 | expect(RequestPacket.generateToken()).to.equal(2) 90 | expect(RequestPacket.generateToken(16)).to.equal(0x100) 91 | expect(RequestPacket.generateToken(65535)).to.equal(0x0f0f0f0f) 92 | expect(RequestPacket.generateToken()).to.equal(1) 93 | }) 94 | 95 | it('should create a challenge token', () => { 96 | var p = new RequestPacket() 97 | expect(p).to.include({ 98 | type: consts.CHALLENGE_TYPE 99 | }) 100 | expect(p.sessionId).to.be.above(0) 101 | // left pad the id 102 | var id = p.sessionId.toString(16) 103 | id = ('00000000' + id).substr(-8, 8) 104 | 105 | var buf = RequestPacket.write(p) 106 | expect(buf.toString('hex')).to.equal('fefd09' + id) 107 | 108 | var p2 = RequestPacket.parse(buf) 109 | expect(p2).to.equal(p) 110 | }) 111 | 112 | it('a full stat', () => { 113 | var p = new RequestPacket(consts.REQUEST_FULL, { 114 | sessionId: 1, 115 | challengeToken: 0x0091295B 116 | }) 117 | 118 | var expected = 'fefd00000000010091295b00000000' 119 | var buf = RequestPacket.write(p) 120 | expect(buf.toString('hex')).to.equal(expected) 121 | }) 122 | 123 | it('a full stat with payload', () => { 124 | var p = new RequestPacket(consts.REQUEST_FULL, { 125 | payload: Buffer.from([1, 2, 3, 4]), 126 | sessionId: 1, 127 | challengeToken: 0x0091295B 128 | }) 129 | 130 | var expected = 'fefd00000000010091295b01020304' 131 | var buf = RequestPacket.write(p) 132 | expect(buf.toString('hex')).to.equal(expected) 133 | }) 134 | 135 | it('a basic stat', () => { 136 | var p = new RequestPacket(consts.REQUEST_BASIC, { 137 | sessionId: 1, 138 | challengeToken: 0x0091295B 139 | }) 140 | 141 | var expected = 'fefd00000000010091295b' 142 | var buf = RequestPacket.write(p) 143 | expect(buf.toString('hex')).to.equal(expected) 144 | }) 145 | 146 | it('packet with bad type', () => { 147 | expect(fn).to.throw(Error) 148 | function fn () { 149 | let p = new RequestPacket(999) 150 | expect(p.type).to.equal(0) 151 | } 152 | }) 153 | 154 | it('should not write bad type', () => { 155 | expect(fn).to.throw(Error, 'type did not have a correct value 999') 156 | 157 | function fn () { 158 | var p = new RequestPacket() 159 | p.type = 999 160 | RequestPacket.write(p) 161 | } 162 | }) 163 | 164 | it('should not write bad sessionId', () => { 165 | var p = new RequestPacket() 166 | p.sessionId = 0 167 | expect(fn).to.throw(Error, 'sessionId is bad or missing') 168 | 169 | p.sessionId = 16 170 | expect(fn).to.throw(Error, 'sessionId is bad or missing') 171 | 172 | p.sessionId = '100' 173 | expect(fn).to.throw(Error, 'sessionId is bad or missing') 174 | 175 | p.sessionId = 4147483647 176 | expect(fn).to.throw(Error, 'sessionId is bad or missing') 177 | 178 | function fn () { 179 | RequestPacket.write(p) 180 | } 181 | }) 182 | 183 | it('should not write bad payload', () => { 184 | var p = new RequestPacket() 185 | p.payload = 'asdf' 186 | expect(fn).to.throw(Error, 'payload was not a Buffer instance') 187 | 188 | function fn () { 189 | RequestPacket.write(p) 190 | } 191 | }) 192 | 193 | it('should not write STAT without challengeToken', () => { 194 | var p = new RequestPacket(consts.REQUEST_BASIC) 195 | 196 | delete p.challengeToken 197 | expect(fn).to.throw(Error, 'challengeToken is missing or wrong') 198 | 199 | p.challengeToken = -2147483648 200 | expect(fn).to.throw(Error, 'challengeToken is missing or wrong') 201 | 202 | p.challengeToken = 4147483647 203 | expect(fn).to.throw(Error, 'challengeToken is missing or wrong') 204 | 205 | function fn () { 206 | RequestPacket.write(p) 207 | } 208 | }) 209 | 210 | it('not parse bad packet', () => { 211 | // bad magic 212 | var buf = Buffer.from('000000', 'hex') 213 | expect(fn).to.throw(Error, 'Error in Magic Field') 214 | 215 | // bad length 216 | // buf = new Buffer('fefd0000000703002ef08a', 'hex') 217 | // expect(fn).to.throw(Error, 'payload not implemented') 218 | 219 | function fn () { 220 | RequestPacket.parse(buf) 221 | } 222 | }) 223 | 224 | it('should not parse anything but STAT_TYPE', () => { 225 | var buf = Buffer.from('fefd0900000703002ef08a', 'hex') 226 | expect(fn).to.throw(Error, 'payload not implemented') 227 | 228 | function fn () { 229 | RequestPacket.parse(buf) 230 | } 231 | }) 232 | 233 | it('should parse basic_stat', () => { 234 | var buf = Buffer.from('fefd0000000703002ef08a', 'hex') 235 | var p = RequestPacket.parse(buf) 236 | expect(p).to.include(['type', 'challengeToken']) 237 | expect(p.type, 'type is wrong').to.equal(consts.REQUEST_BASIC) 238 | // expect(fn).to.throw(Error, 'payload not implemented') 239 | }) 240 | })// -Request 241 | }) 242 | -------------------------------------------------------------------------------- /test/query.test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | const Lab = require('@hapi/lab') 6 | const { describe, it, before, beforeEach, afterEach } = exports.lab = Lab.script() 7 | const { expect } = require('code') 8 | const log = new (require('../lib/log'))('mcquery:test:query') 9 | 10 | var SERVER_EXISTING = { 11 | host: process.env.MC_SERVER || 'localhost', // '89.221.255.150' 12 | port: process.env.MC_PORT = 25565 13 | } 14 | 15 | var Query = require('../') 16 | 17 | const MockServer = require('./mock-server') 18 | var mockServer 19 | 20 | function hasEnv (key) { 21 | return key in Object.keys(process.env) 22 | } 23 | 24 | function setupClient () { 25 | log.debug('setupClient') 26 | if (!hasEnv('MC_SERVER') && !mockServer) { 27 | mockServer = new MockServer() 28 | return mockServer.bind() 29 | .then(() => { 30 | var address = mockServer.socket.address() 31 | SERVER_EXISTING.host = address.address 32 | SERVER_EXISTING.port = address.port 33 | return Query.createConnected(SERVER_EXISTING.host, SERVER_EXISTING.port) 34 | }) 35 | } 36 | return Query.createConnected(SERVER_EXISTING.host, SERVER_EXISTING.port) 37 | } 38 | 39 | describe('mcquery', function () { 40 | let query 41 | afterEach(() => { 42 | expect(query).to.be.instanceof(Query) 43 | query.close() 44 | if (mockServer) { 45 | mockServer.setIgnore(false) 46 | } 47 | }) 48 | 49 | beforeEach({ timeout: 5000 }, async () => { 50 | query = await setupClient() 51 | expect(query).to.be.an.object() 52 | }) 53 | 54 | it('should default to localhost:25565', () => { 55 | var q = new Query() 56 | expect(q.address()).to.include({ address: '127.0.0.1', port: 25565 }) 57 | }) 58 | 59 | it('should have a proper session', () => { 60 | expect(query).be.instanceOf(Query) 61 | expect(query).include(['challengeToken']) 62 | expect(query.challengeToken).be.within(1, 0XFFFFFFFF) 63 | }) 64 | 65 | it('should have a correct sessionId', () => { 66 | expect(query).to.include(['sessionId', 'challengeToken']) 67 | expect(query.challengeToken).to.be.within(1, 0XFFFFFFFF) 68 | // test masking 69 | expect(query.sessionId).to 70 | .equal(query.sessionId & 0x0F0F0F0F) 71 | }) 72 | 73 | it('should be able to do an .doHandshake', () => { 74 | var oldChallenge = query.challengeToken 75 | return new Promise(resolve => { 76 | query.doHandshake(function (err, session) { 77 | expect(err).not.exist() 78 | expect(session.challengeToken).not.be.equal(oldChallenge) 79 | resolve() 80 | }) 81 | }) 82 | }) 83 | 84 | it('should be able to do an .doHandshake without callback', () => { 85 | var oldChallenge = query.challengeToken 86 | return new Promise(resolve => { 87 | query.doHandshake() 88 | setTimeout(function () { 89 | expect(query.challengeToken).not.be.equal(oldChallenge) 90 | resolve() 91 | }, 300) 92 | }) 93 | }) 94 | 95 | it('should be able to connect twice', () => { 96 | var oldChallenge = query.challengeToken 97 | expect(query.online).to.equal(true) 98 | return new Promise((resolve, reject) => { 99 | query.connect(function (err, session) { 100 | if (err) { 101 | return reject(err) 102 | } 103 | expect(session.challengeToken).not.be.equal(oldChallenge) 104 | return resolve(session) 105 | }) 106 | }) 107 | }) 108 | 109 | it('should ignore bad response', { timeout: 4000, skip: hasEnv('MC_SERVER') }, () => { 110 | // var pre = query.dropped 111 | setupClient() 112 | .then(q => { 113 | mockServer.badReply = true 114 | return new Promise(resolve => { 115 | q.doHandshake((err) => { 116 | expect(err).to.exist() 117 | expect(err.message).to.equal('Request timeout') 118 | resolve() 119 | // expect(query.dropped, 'dropped packages').to.equal(pre + 1) 120 | }) 121 | }) 122 | }) 123 | }) 124 | 125 | it('should ignore response with session not in queue', { timeout: 5000, skip: true }, () => { 126 | var pre = query.dropped 127 | mockServer.randomResponse = true 128 | 129 | return new Promise(resolve => { 130 | query.doHandshake(function (err) { 131 | expect(err).to.exist() 132 | expect(err.message).to.equal('Request timeout') 133 | // console.log('asfdasdf %s "%s"', err, query.dropped) 134 | expect(query.dropped).to.equal(pre + 1) 135 | resolve() 136 | }) 137 | }) 138 | }) 139 | 140 | it('send should require RequestPacket', () => { 141 | expect(fn).to.throw(TypeError, 'packet is wrong') 142 | query.send('asdf', function (err) { 143 | expect(err).to.be.instanceOf(TypeError) 144 | }) 145 | function fn () { 146 | query.send('asdf') 147 | } 148 | }) 149 | 150 | it('should timeout', { timeout: 5000 }, () => { 151 | if (!mockServer) { 152 | return 153 | } 154 | mockServer.setIgnore(true) 155 | return new Promise(resolve => { 156 | query.doHandshake(function (err) { 157 | expect(err).to.exist() 158 | return resolve() 159 | }) 160 | }) 161 | }) 162 | 163 | it('should accept different timeout', { timeout: 1000 }, () => { 164 | if (!mockServer) { 165 | return Promise.resolve() 166 | } 167 | var q = new Query(SERVER_EXISTING.host, SERVER_EXISTING.port, 168 | { timeout: 500 } 169 | ) 170 | mockServer.setIgnore(true) 171 | 172 | return new Promise(resolve => { 173 | q.connect(function (err) { 174 | expect(err).to.exist() 175 | expect(err.message).to.equal('Request timeout') 176 | q.close() 177 | resolve() 178 | }) 179 | }) 180 | }) 181 | 182 | describe('.basic_stat(err, result)', () => { 183 | before({ timeout: 4000 }, () => { 184 | return new Promise((resolve, reject) => { 185 | query.connect(() => { 186 | expect(query.outstandingRequests, 'outstandingRequests') 187 | .to.equal(0) 188 | return resolve() 189 | }) 190 | }) 191 | }) 192 | 193 | it('result should be correct', () => { 194 | return new Promise(resolve => { 195 | query.basic_stat(function (er, result) { 196 | expect(er).to.not.exist() 197 | expect(result).to.exist() 198 | expect(result).to.be.an.object() 199 | expect(result).to.include(['MOTD', 'gametype', 'map', 'numplayers', 200 | 'maxplayers', 'hostport', 'hostip']) 201 | expect(result.numplayers).to.be.within(0, 1024) 202 | expect(result.maxplayers).to.be.within(0, 1024) 203 | expect(result.hostport).to.be.within(1, 65535) 204 | return resolve() 205 | }) 206 | }) 207 | }) 208 | 209 | it('should require callback', () => { 210 | expect(fn).to.throw(Error, 'callback is not a function') 211 | function fn () { 212 | query.basic_stat() 213 | } 214 | }) 215 | 216 | it('should require a challengeToken', () => { 217 | query.challengeToken = null 218 | query.full_stat(function (err) { 219 | expect(err).to.be.instanceOf(Error) 220 | expect(err.message).to.be.equal('bad session') 221 | }) 222 | }) 223 | })// end basic_stat 224 | 225 | describe('.full_stat(err, result)', function () { 226 | var result 227 | before(() => { 228 | log.debug('-------- full_stat ---------') 229 | return setupClient() 230 | .then((q) => { 231 | query = q 232 | return doHandshake() 233 | }) 234 | 235 | function doHandshake () { 236 | return new Promise((resolve, reject) => { 237 | query.doHandshake(function (er) { 238 | expect(er).to.not.exist() 239 | query.full_stat(function (er, stat) { 240 | if (er) { 241 | return reject(er) 242 | } 243 | result = stat 244 | return resolve(stat) 245 | }) 246 | }) 247 | }) 248 | } 249 | }) 250 | 251 | it('result should be correct', () => { 252 | expect(result).to.exist() 253 | expect(result).to.be.an.object() 254 | var props = [ 255 | 'hostname', 256 | 'gametype', 257 | 'numplayers', 258 | 'maxplayers', 259 | 'hostport', 260 | 'hostip', 261 | 'game_id', 262 | 'version', 263 | 'plugins', 264 | 'map', 265 | 'player_' 266 | ] 267 | for (var i = 0; i < props.length; i++) { 268 | expect(result).to.include(props[i]) 269 | } 270 | expect(result.player_).to.be.instanceOf(Array) 271 | }) 272 | 273 | it('should queue lots of requests', { timeout: 6000, skip: true }, () => { 274 | var i = 0 275 | var counter = 0 276 | var gotError = false 277 | if (mockServer) { 278 | mockServer.delay = 400 279 | } 280 | for (; i < 5; i++) { 281 | query.full_stat(fn) 282 | } 283 | 284 | function fn (err) { 285 | if (gotError) { 286 | return 287 | } 288 | if (err) { 289 | gotError = true 290 | } 291 | expect(err).not.exist() 292 | counter++ 293 | checkDone() 294 | } 295 | 296 | function checkDone () { 297 | if (counter === i) { 298 | 299 | } 300 | } 301 | }) 302 | 303 | it('should require callback', () => { 304 | expect(fn).to.throw(Error, 'callback is not a function') 305 | function fn () { 306 | query.full_stat() 307 | } 308 | }) 309 | 310 | it('should require a challengeToken', () => { 311 | query.challengeToken = null 312 | query.full_stat(function (err) { 313 | expect(err).to.be.instanceOf(Error) 314 | expect(err.message).to.be.equal('bad session') 315 | }) 316 | }) 317 | }) 318 | }) 319 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright © 2011-2020 Peter Magnusson. 3 | * All rights reserved. 4 | */ 5 | /* eslint camelcase: "warn" */ 6 | const dgram = require('dgram') 7 | const log = new (require('./log'))('mcquery') 8 | const ResponsePacket = require('./responsepacket') 9 | const RequestPacket = require('./requestpacket') 10 | 11 | const consts = require('./consts') 12 | const { QueryConnectionError } = require('./errors') 13 | 14 | var REQUEST_TIMEOUT = 3000 15 | var QUEUE_DELAY = 100 16 | let INSTANCE_COUNTER = 0 17 | const OPTIONAL_EVENTS = ['close', 'end', 'timeout', 'ready', 'connect'] 18 | 19 | // internal 20 | 21 | class Query { 22 | /** Create a new instance of the client 23 | * @param {String} hostname or ip 24 | * @param {Number} port 25 | * 26 | * @api public 27 | */ 28 | constructor (host, port, options) { 29 | this.name = `Query#${INSTANCE_COUNTER++}` 30 | this._host = host || '127.0.0.1' 31 | this._port = port || 25565 32 | this._clearSocket() 33 | this._requestQueue = [] 34 | this._queueTimer = null 35 | 36 | this.challengeToken = null 37 | this.online = false 38 | this.currentRequest = null 39 | 40 | this.incomming = 0 41 | this.dropped = 0 42 | this.sent = 0 43 | options = options || {} 44 | options.timeout = options.timeout || REQUEST_TIMEOUT 45 | this.options = options 46 | if (INSTANCE_COUNTER > 65535) { 47 | log.debug('reset instance_counter') 48 | INSTANCE_COUNTER = 0 49 | } 50 | log.debug('%s created', this.name) 51 | } 52 | 53 | static createConnected (host, port, options) { 54 | let query = new Query(host, port, options) 55 | let r = query.connect() 56 | return r 57 | } 58 | 59 | get outstandingRequests () { 60 | return this._requestQueue.length 61 | } 62 | 63 | address () { 64 | return { 65 | address: this._host, 66 | port: this._port 67 | } 68 | } 69 | 70 | _clearSocket () { 71 | log.debug('%s _clearSocket', this.name) 72 | this._socket = null 73 | } 74 | 75 | _createSocket () { 76 | var s = dgram.createSocket('udp4') 77 | log.debug('%s _createSocket', this.name) 78 | 79 | s.on('message', this._onMessage.bind(this)) 80 | 81 | s.on('error', (err) => { 82 | log.error('%s socket error', this.name, err) 83 | }) 84 | 85 | OPTIONAL_EVENTS.forEach(element => { 86 | s.on(element, () => { 87 | log.debug('%s socket %s', this.name, element) 88 | }) 89 | }) 90 | 91 | return s 92 | }// end _createSocket 93 | 94 | _onMessage (msg, rinfo) { 95 | log.debug('%s got a response message', this.name) 96 | var res 97 | try { 98 | res = ResponsePacket.parse(msg) 99 | this.incomming++ 100 | } catch (ex) { 101 | this.dropped = this.dropped + 1 102 | log.error(ex, this.dropped) 103 | return 104 | } 105 | // res.rinfo = rinfo 106 | res.from = { 107 | address: rinfo.address, 108 | port: rinfo.port 109 | } 110 | this._deQueue(res) 111 | } 112 | 113 | /** 114 | * Create request and put it on the queue to be sent 115 | * @param {*} packet 116 | * @param {*} callback 117 | */ 118 | send (packet, callback) { 119 | if (!this.online) { 120 | throw new QueryConnectionError('Sending offline is not supported. Connect first.') 121 | } 122 | if (!(packet instanceof RequestPacket)) { 123 | var e = new TypeError('packet is wrong') 124 | if (typeof callback === 'function') { 125 | return callback(e) 126 | } else { 127 | throw e 128 | } 129 | } 130 | 131 | var b = RequestPacket.write(packet) 132 | // var b = makePacket(type, this.challengeToken, this.sessionId, payloadBuffer) 133 | this._addQueue(packet.type, b, callback) 134 | } 135 | 136 | /** 137 | * Start a new session with given host at port 138 | * @param {function} function (err, session) 139 | * @returns {Promise} - if not callback 140 | * @api public 141 | */ 142 | connect (callback) { 143 | log.debug('%s connect', this.name) 144 | if (!this.online && this._socket === null) { 145 | return new Promise((resolve, reject) => { 146 | this._socket = this._createSocket() 147 | 148 | this._socket.on('listening', () => { 149 | // when socket is listening, do handshake. 150 | this.online = true 151 | this.doHandshake((err, result) => { 152 | if (err) { 153 | return callback ? callback(err) : reject(err) 154 | } 155 | return callback ? callback(result) : resolve(result) 156 | }) 157 | }) 158 | 159 | // bind the socket 160 | this._socket.bind() 161 | }) 162 | } else { 163 | // we are already listening 164 | // don't open a new socket. 165 | return this.doHandshake(callback) 166 | } 167 | }// end connect 168 | 169 | /** 170 | * Runs the handshake procedure 171 | * @param {*} callback - (err, this) 172 | * @returns {Promise} - Promise with current instance 173 | */ 174 | doHandshake (callback) { 175 | return new Promise((resolve, reject) => { 176 | log.debug('%s doing handshake', this.name) 177 | if (!this.online) { 178 | reject(new QueryConnectionError('doHandshake offline is not supported. Connect first.')) 179 | } 180 | this.sessionId = RequestPacket.generateToken() 181 | const p = new RequestPacket(consts.REQUEST_HANDSHAKE, { 182 | sessionId: this.sessionId 183 | }) 184 | 185 | this.send(p, (err, res) => { 186 | if (err) { 187 | log.error('%s error in doHandshake', this.name, err) 188 | return callback ? callback(err, null) : reject(err) 189 | } 190 | log.debug('%s doHandshake > challengeToken=', this.name, res.challengeToken || res) 191 | this.challengeToken = res.challengeToken 192 | return callback ? callback(null, this) : resolve(this) 193 | }) 194 | }) 195 | };// end doHandshake 196 | 197 | /** 198 | * Request basic stat information using session 199 | * @param {Object} a session object created by startSession 200 | * @param {funciton} function (err, statinfo) 201 | * 202 | * @api public 203 | */ 204 | basic_stat (callback) { 205 | if (typeof callback !== 'function') { 206 | throw new TypeError('callback is not a function') 207 | } 208 | 209 | if (!this.challengeToken) { 210 | return callback(new Error('bad session')) 211 | } 212 | this.send(new RequestPacket(consts.REQUEST_BASIC, { 213 | sessionId: this.sessionId, 214 | challengeToken: this.challengeToken 215 | }), callback) 216 | };// end basic_stat 217 | 218 | /** 219 | * Request full stat information using session 220 | * @param {Object} a session object created by startSession 221 | * @param {funciton} function (err, statinfo) 222 | * 223 | * @api public 224 | */ 225 | full_stat (callback) { 226 | if (typeof callback !== 'function') { 227 | throw new TypeError('callback is not a function') 228 | } 229 | 230 | if (!this.challengeToken) { 231 | return callback(new Error('bad session')) 232 | } 233 | 234 | var p = new RequestPacket(consts.REQUEST_FULL, { 235 | challengeToken: this.challengeToken, 236 | sessionId: this.sessionId 237 | }) 238 | 239 | this.send(p, (err, res) => { 240 | if (err) { 241 | return callback(err) 242 | } else { 243 | return callback(null, res) 244 | } 245 | }) 246 | };// end full_stat 247 | 248 | /* 249 | * Stop listening for responses 250 | * Clears the requestQueue 251 | * 252 | * @api public 253 | */ 254 | close () { 255 | log.warn('%s closing query', this.name) 256 | this._socket.close() 257 | this.online = false 258 | this._requestQueue = [] 259 | if (this._queueTimer) { 260 | clearTimeout(this._queueTimer) 261 | this._queueTimer = null 262 | } 263 | this._clearSocket() 264 | };// end close 265 | 266 | _transmit (packet, callback) { 267 | if (typeof packet !== 'object') { 268 | var e = new TypeError('packet was wrong type') 269 | if (typeof callback === 'function') { 270 | return callback(e) 271 | } else { 272 | throw e 273 | } 274 | } 275 | log.debug('%s _transmit(%o)', this.name, packet) 276 | process.nextTick(() => { 277 | if (!this._socket) { 278 | log.warn('%s no socket any more', this.name) 279 | return callback(new Error('Socket lost')) 280 | } 281 | log.debug('%s sending packet', this.name, packet) 282 | this._socket.send(packet, 0, packet.length, this._port, this._host, (err, sent) => { 283 | if (err) { 284 | log.warn('%s there was an error sending', this.name, packet) 285 | return callback(err) 286 | } 287 | this.sent++ 288 | log.debug('%s %d bytes sent to %s:%s', this.name, sent, this._host, this._port) 289 | /* callback is called when responce is received */ 290 | }) 291 | }) 292 | }// end _transmit 293 | 294 | _processQueue () { 295 | log.debug('%s _processQueue()', this.name) 296 | if (this._queueTimer) { 297 | log.debug('%s _processQueue > clearing timer', this.name) 298 | clearTimeout(this._queueTimer) 299 | this._queueTimer = null 300 | } 301 | const queueLength = this.outstandingRequests 302 | if (queueLength > 0) { 303 | log.debug('%s _processQueue > queue length %d', this.name, queueLength) 304 | } 305 | 306 | if (this.currentRequest === null && queueLength > 0) { 307 | this.currentRequest = this._requestQueue.shift() 308 | log.debug('%s _processQueue > processing something on the queue', 309 | this.name, 310 | typeof this.currentRequest) 311 | this._transmit(this.currentRequest.packet, this.currentRequest.callback) 312 | } else { 313 | log.debug('%s _processQueue > nothing to do', this.name) 314 | } 315 | if (this.currentRequest && queueLength > 0) { 316 | // if we have more requests comming up, delay next somewhat 317 | if (this._queueTimer === null) { 318 | this._queueTimer = setTimeout(this._processQueue.bind(this), QUEUE_DELAY) 319 | } 320 | } 321 | }// end _processQueue 322 | 323 | /** 324 | * Add a request to the queue 325 | */ 326 | _addQueue (type, packet, callback) { 327 | log.debug('%s _addQueue()', this.name) 328 | if (typeof callback !== 'function') { 329 | throw new Error('no callback') 330 | } 331 | 332 | var req = { type: type, callback: callback, packet: packet } 333 | log.debug('%s _addQueue > creating timeout', this.name) 334 | // create the timeout for this request 335 | var t = setTimeout(() => { 336 | log.info('%s _addQueue > timeout on req', this.name, this.sessionId) 337 | var index = this._requestQueue.indexOf(req) 338 | log.debug('%s _addQueue > queue length=%d, index=%d', this.name, this._requestQueue.length, index) 339 | if (index >= 0) { 340 | this._requestQueue.splice(index, 1) 341 | } 342 | log.debug('%s _addQueue > length after %d', this.name, this._requestQueue.length) 343 | if (req === this.currentRequest) { 344 | this.currentRequest = null 345 | log.debug('%s _addQueue > remove current request', this.name) 346 | } 347 | // this.sessionId = null 348 | // this.challengeToken = null 349 | callback(new Error('Request timeout')) 350 | }, this.options.timeout) 351 | 352 | req.timeout = t 353 | log.debug('%s _addQueue > adding type', this.name, type, 'to queue') 354 | this._requestQueue.push(req) 355 | log.debug('%s _addQueue > outstatndingRequest=', this.name, this.outstandingRequests) 356 | process.nextTick(this._processQueue.bind(this)) 357 | }// end _addQueue 358 | 359 | /** 360 | * Check for requests matching the response given 361 | */ 362 | _deQueue (res) { 363 | log.debug('%s deQueue', this.name, res) 364 | var key = res.sessionId 365 | if (this.currentRequest === null || this.sessionId !== key) { 366 | // no such session running... just ignore 367 | log.warn('%s no outstanding request. sessionId=%s, currentReqType=%s, res:', 368 | this.name, 369 | this.sessionId, 370 | typeof this.currentRequest, 371 | res 372 | ) 373 | this.dropped++ 374 | log.warn('%s, _deQueue > dropped = %s', this.name, this.dropped) 375 | 376 | return 377 | } 378 | 379 | if (this.currentRequest.type !== res.type) { 380 | // no such type in queue... just ignore 381 | log.warn('%s response of wrong type', this.name, this.currentRequest, res) 382 | return 383 | } 384 | clearTimeout(this.currentRequest.timeout) 385 | var fn = this.currentRequest.callback 386 | this.currentRequest = null 387 | if (typeof fn === 'function') { 388 | fn(null, res) 389 | } 390 | this._processQueue() 391 | }// end _deQueue 392 | }// end class Query 393 | 394 | module.exports = Query 395 | --------------------------------------------------------------------------------