diff --git a/README.md b/README.md index d0c1d7d..ebada56 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ It was originally created to connect to Minecraft's RCON server. ## Installation npm: - - $ npm install rcon + $ npm install rcon ## Usage @@ -52,21 +51,27 @@ If your application may leave the connection idle for a long time, you can eithe new Rcon instance (and connection) each time you need it, or you can send a ping command periodically to keep the connection alive. +```javascript +setInterval(() => { + rcon.send(''); +}, 30000) +``` + ## Events The connection emits the following events: -- .emit('auth') +- .emit('authenticated') This is sent in response to an authentication request that was successful. - .emit('end') -The connection was closed from any reason +The connection was closed from any reason. - .emit('response', str) -There was a response returned to a command/message sent to the server +There was a response returned to a command/message sent to the server. - .emit('server', str) diff --git a/examples/basic.js b/examples/basic.js index c0f544a..5c7fb5d 100644 --- a/examples/basic.js +++ b/examples/basic.js @@ -1,27 +1,27 @@ -// This minimal example connects and runs the "help" command. +// This minimal example connects and runs the 'help' command. -var Rcon = require('../node-rcon'); +const RCON = require('../'); -var conn = new Rcon('localhost', 1234, 'password'); +const rcon = new RCON('localhost', 25575, 'password'); -conn.on('auth', function() { +rcon.on('authenticated', () => { // You must wait until this event is fired before sending any commands, // otherwise those commands will fail. - console.log("Authenticated"); - console.log("Sending command: help") - conn.send("help"); -}).on('response', function(str) { - console.log("Response: " + str); -}).on('error', function(err) { - console.log("Error: " + err); -}).on('end', function() { - console.log("Connection closed"); - process.exit(); + console.log('Authenticated'); + console.log('Sending command: help') + rcon.send('help'); +}).on('response', response => { + console.log('Response: ' + response); +}).on('error', error => { + console.error('Error:', error); +}).on('end', () => { + console.log('Connection closed'); + process.exit(0); }); -conn.connect(); +rcon.connect(); // connect() will return immediately. // // If you try to send a command here, it will fail since the connection isn't -// authenticated yet. Wait for the 'auth' event. +// authenticated yet. Wait for the 'authenticated' event. diff --git a/examples/stdio.js b/examples/stdio.js index 126f54d..7c48c7c 100644 --- a/examples/stdio.js +++ b/examples/stdio.js @@ -1,64 +1,31 @@ // This example reads commands from stdin and sends them on enter key press. -// You need to run `npm install keypress` for this example to work. -var Rcon = require('../node-rcon'); -var keypress = require('keypress'); +const RCON = require('../'); -var conn = new Rcon('localhost', 1234, 'password'); -var authenticated = false; -var queuedCommands = []; +const rcon = new RCON('localhost', 1234, 'password'); -conn.on('auth', function() { - console.log("Authenticated"); - authenticated = true; +rcon.on('authenticated', () => { + console.log('Authenticated'); - // You must wait until this event is fired before sending any commands, - // otherwise those commands will fail. - // - // This example buffers any commands sent before auth finishes, and sends - // them all once the connection is available. + process.stdin.on('data', inputBuffer => { + // Convert buffer to string and take out last 2 characters- return character. + const inputString = inputBuffer.toString().slice(0, -2); - for (var i = 0; i < queuedCommands.length; i++) { - conn.send(queuedCommands[i]); - } - queuedCommands = []; - -}).on('response', function(str) { - console.log("Response: " + str); -}).on('error', function(err) { - console.log("Error: " + err); -}).on('end', function() { - console.log("Connection closed"); - process.exit(); -}); - -conn.connect(); - -keypress(process.stdin); -process.stdin.setRawMode(true); -process.stdin.resume(); - -var buffer = ""; + if (inputString === 'disconnect') { + console.log('Disconnecting from the server'); + return rcon.disconnect(); + } -process.stdin.on('keypress', function(chunk, key) { - if (key && key.ctrl && (key.name == 'c' || key.name == 'd')) { - conn.disconnect(); - return; - } - process.stdout.write(chunk); + rcon.send(inputString); + }); - if (key && (key.name == 'enter' || key.name == 'return')) { - if (authenticated) { - conn.send(buffer); - } else { - queuedCommands.push(buffer); - } - buffer = ""; - process.stdout.write("\n"); - } else if (key && key.name == 'backspace') { - buffer = buffer.slice(0, -1); - process.stdout.write("\033[K"); // Clear to end of line - } else { - buffer += chunk; - } +}).on('response', response => { + console.log('Response: ' + response); +}).on('error', error => { + console.error('Error:', error); +}).on('end', () => { + console.log('Connection closed'); + process.exit(0); }); + +rcon.connect(); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..66fa216 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from 'events'; +import net from 'net'; +import dgram from 'dgram'; + +export enum PacketType { + COMMAND = 0x02, + AUTH = 0x03, + RESPONSE_VALUE = 0x00, + RESPONSE_AUTH = 0x02, +}; + +export type RCONOptions = { + id?: number; +} & ({ + useTcp?: true; +} | { + useTcp: false; + challenge?: boolean +}); + +interface RCONClientEvents { + authenticated: []; + debug: [string, Buffer?]; + response: [string]; + server: [string]; + error: [Error]; + end: [] +} + +export default class RCON extends EventEmitter { + private _outstandingData: Buffer | null; + private _challengeToken?: string; + private _tcpSocket?: net.Socket; + private _udpSocket?: dgram.Socket; + + /** + * The RCON id - you'll likely not need to change this + */ + public id: number; + /** + * Whether the RCON is authenticated + */ + public authenticated: boolean; + /** + * Whether we are using a TCP connection or not. (the other connection type is UDP) + */ + public useTcp: boolean; + /** + * Whether we are using the challenge authentication method. + * (only in UDP connections) + */ + public challenge: boolean; + + /** + * Make a new RCON connection. + * @param host The IP address of the server. + * @param port The port of the server. + * @param password The password. + * @param options Options - if you want to use a UDP connection for example. + * + * @example + * const RCON = require('node-rcon'); + * const rcon = new RCON('localhost', 25575, 'password'); + * + * rcon.on('authenticated', () => console.log('Authenticated')); + * + * rcon.connect(); + */ + public constructor(public host: string, public port: number, public password: string, options?: RCONOptions); + + private _sendData(data: Buffer): void; + private _dataReceived(data: Buffer): void; + private _onConnect(): void; + private _onClose(): void; + + /** + * Connect to the server. + * + * @example + * const rcon = new RCON('localhost', 25575, 'password'); + * rcon.on('authenticated', () => console.log('authenticated with the server.')); + * rcon.connect(); + */ + public connect(): void; + /** + * Send data to the server. + * @param data The data to send. + * @param packetType The type of packet - you'll likely not need to change this + * @param id The RCON ID - you'll likely not need to change this + * + * @example + * RCON.send('say hi'); + */ + public send(data: string, packetType?: PacketType, id?: number): void; + /** + * Disconnect the server; + * @example + * RCON.disconnect(); + * process.exit(0); + */ + public disconnect(): void; +} + +export = RCON; \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..aa1b7af --- /dev/null +++ b/index.js @@ -0,0 +1,185 @@ +import { EventEmitter } from 'events'; +import net from 'net'; +import dgram from 'dgram'; +const + { EventEmitter } = require('events'), + net = require('net'), + dgram = require('dgram'); + +const PacketType = { + COMMAND: 0x02, + AUTH: 0x03, + RESPONSE_VALUE: 0x00, + RESPONSE_AUTH: 0x02 +}; + +// Make it a valid enum type +for (const [key, value] in Object.entries(PacketType)) PacketType[value] = key; + +module.exports = class RCON extends EventEmitter { + constructor(host, port, password, options) { + super(); + this.host = host; + this.port = port; + this._outstandingData = null; + this.authenticated = false; + this.useTcp = this.challenge = true; + + // Make sure password doesn't show when logged + Object.defineProperty(this, 'password', { value: password, enumerable: false, writable: false }); + if (options) { + if ('id' in options) this.id = options.id; + if ('useTcp' in options) { + this.useTcp = options.useTcp; + if ('challenge' in options) this.challenge = options.challenge; + } + } + } + + _sendData(data) { + if (this._tcpSocket) { + this._tcpSocket.write(data.toString('binary'), 'binary'); + } else if (this._udpSocket) { + this._udpSocket.send(data, 0, data.length, this.port, this.host); + } + } + + _dataReceived(data) { + if (this.useTcp) { + if (this._outstandingData !== null) { + data = Buffer.concat([this._outstandingData, data]); + this._outstandingData = null; + } + + while (data.length >= 12) { + const length = data.readInt32LE(0); // Size of entire packet, not including the 4 byte length field + if (!length) return this.emit('debug', 'No valid packet header, discarding entire buffer.', data); + + const packetLength = length + 4; + if (data.length < packetLength) break; // Wait for full packet, TCP may have segmented it + + const bodyLength = length - 10; // Subtract size of ID, type, and two mandatory trailing null bytes + if (bodyLength < 0) { + this.emit('debug', 'Length is too short, discarding malformed packet.', data); + data = data.subarray(packetLength); + break; + } + + const id = data.readInt32LE(4); + const type = data.readInt32LE(8); + + if (id === this.id) { + if (!this.authenticated && type == PacketType.RESPONSE_AUTH) { + this.authenticated = true; + this.emit('authenticated'); + } else if (type == PacketType.RESPONSE_VALUE) { + // Read just the body of the packet (truncate the last null byte) + // See https://developer.valvesoftware.com/wiki/Source_RCON_Protocol for details + const str = data.toString('utf8', 12, 12 + bodyLength); + + this.emit('response', str.charAt(str.length - 1) === '\n' ? str.substring(0, -1) : str); + } + } else if (id === -1) { + this.emit('error', new Error('Authentication failed')); + } else { + // ping/pong likely + const str = data.toString('utf8', 12, 12 + bodyLength); + + this.emit('server', str.charAt(str.length - 1) === '\n' ? str.substring(0, -1) : str); + } + + data = data.subarray(packetLength); + } + + // Keep a reference to remaining data, since the buffer might be split within a packet + this._outstandingData = data; + } else { + if (data.readUInt32LE(0) === 0xFFFFFFFF) { + const str = data.toString('utf-8', 4); + const tokens = str.split(' '); + if (tokens.length == 3 && tokens[0] == 'challenge' && tokens[1] == 'rcon') { + this._challengeToken = tokens[2].slice(0, -1).trim(); + this.authenticated = true; + this.emit('authenticated'); + } else this.emit('response', str.slice(1, -2)); + } else this.emit('error', new Error('Received malformed packet')); + } + } + + _onConnect() { + if (this.useTcp) { + this.send(this.password, PacketType.AUTH); + } else if (this.challenge) { + const str = 'challenge rcon\n'; + const buffer = Buffer.alloc(str.length + 4); + buffer.writeInt32LE(-1, 0); + buffer.write(str, 4); + this._sendData(buffer); + } else { + const buffer = Buffer.alloc(5); + buffer.writeInt32LE(-1, 0); + buffer.writeUInt8(0, 4); + this._sendData(buffer); + + this.authenticated = true; + this.emit('authenticated'); + } + } + + _onClose() { + this.emit('end'); + this.authenticated = false; + } + + connect() { + let socket; + if (this.useTcp) + socket = this._tcpSocket = net.createConnection(this.port, this.host) + .on('data', this._dataReceived.bind(this)) + .on('connect', this._onConnect.bind(this)) + .on('end', this._onClose.bind(this)); + else + socket = this._udpSocket = dgram.createSocket('udp4') + .on('message', this._dataReceived.bind(this)) + .on('listening', this._onConnect.bind(this)) + .on('close', this._onClose.bind(this)) + .bind(0); + + socket.on('error', error => this.emit('error', error)); + } + + send(data, packetType = PacketType.COMMAND, id = this.id) { + if (this.useTcp) { + const length = Buffer.byteLength(data); + const buffer = Buffer.alloc(length + 14); + + buffer.writeInt32LE(length + 10, 0); + buffer.writeInt32LE(id, 4); + buffer.writeInt32LE(packetType, 8); + buffer.write(data, 12); + buffer.writeInt16LE(0, length + 12); + + this._sendData(buffer); + } else { + if (!this.authenticated || (this.challenge && !this._challengeToken)) + throw new Error('Not authenticated') + + let str = 'rcon '; + if (this._challengeToken) str += this._challengeToken + ' '; + if (this.password) str += this.password + ' '; + str += data + '\n'; + const buffer = Buffer.alloc(4 + Buffer.byteLength(str)); + buffer.writeInt32LE(-1, 0); + buffer.write(str, 4) + + this._sendData(buffer); + } + } + + disconnect() { + if (this._tcpSocket) this._tcpSocket.end(); + if (this._udpSocket) this._udpSocket.close(); + } +} + +RCON.default = RCON; \ No newline at end of file diff --git a/node-rcon.js b/node-rcon.js deleted file mode 100644 index b122b87..0000000 --- a/node-rcon.js +++ /dev/null @@ -1,219 +0,0 @@ -/*! - * node-rcon - * Copyright(c) 2012 Justin Li - * MIT Licensed - */ - -var util = require('util') - , events = require('events') - , net = require('net') - , dgram = require('dgram') - , Buffer = require('buffer').Buffer; - - -var PacketType = { - COMMAND: 0x02, - AUTH: 0x03, - RESPONSE_VALUE: 0x00, - RESPONSE_AUTH: 0x02 -}; - -/** - * options: - * tcp - true for TCP, false for UDP (optional, default true) - * challenge - if using UDP, whether to use the challenge protocol (optional, default true) - * id - RCON id to use (optional) - */ -function Rcon(host, port, password, options) { - if (!(this instanceof Rcon)) return new Rcon(host, port, password, options); - options = options || {}; - - this.host = host; - this.port = port; - this.password = password; - this.rconId = options.id || 0x0012D4A6; // This is arbitrary in most cases - this.hasAuthed = false; - this.outstandingData = null; - this.tcp = options.tcp == null ? true : options.tcp; - this.challenge = options.challenge == null ? true : options.challenge; - - events.EventEmitter.call(this); -}; - -util.inherits(Rcon, events.EventEmitter); - -Rcon.prototype.send = function(data, cmd, id) { - var sendBuf; - if (this.tcp) { - cmd = cmd || PacketType.COMMAND; - id = id || this.rconId; - - var length = Buffer.byteLength(data); - sendBuf = Buffer.alloc(length + 14); - sendBuf.writeInt32LE(length + 10, 0); - sendBuf.writeInt32LE(id, 4); - sendBuf.writeInt32LE(cmd, 8); - sendBuf.write(data, 12); - sendBuf.writeInt16LE(0, length + 12); - } else { - if (this.challenge && !this._challengeToken) { - this.emit('error', new Error('Not authenticated')); - return; - } - var str = "rcon "; - if (this._challengeToken) str += this._challengeToken + " "; - if (this.password) str += this.password + " "; - str += data + "\n"; - sendBuf = Buffer.alloc(4 + Buffer.byteLength(str)); - sendBuf.writeInt32LE(-1, 0); - sendBuf.write(str, 4) - } - this._sendSocket(sendBuf); -}; - -Rcon.prototype._sendSocket = function(buf) { - if (this._tcpSocket) { - this._tcpSocket.write(buf.toString('binary'), 'binary'); - } else if (this._udpSocket) { - this._udpSocket.send(buf, 0, buf.length, this.port, this.host); - } -}; - -Rcon.prototype.connect = function() { - var self = this; - - if (this.tcp) { - this._tcpSocket = net.createConnection(this.port, this.host); - this._tcpSocket.on('data', function(data) { self._tcpSocketOnData(data) }) - .on('connect', function() { self.socketOnConnect() }) - .on('error', function(err) { self.emit('error', err) }) - .on('end', function() { self.socketOnEnd() }); - } else { - this._udpSocket = dgram.createSocket("udp4"); - this._udpSocket.on('message', function(data) { self._udpSocketOnData(data) }) - .on('listening', function() { self.socketOnConnect() }) - .on('error', function(err) { self.emit('error', err) }) - .on('close', function() { self.socketOnEnd() }); - this._udpSocket.bind(0); - } -}; - -Rcon.prototype.disconnect = function() { - if (this._tcpSocket) this._tcpSocket.end(); - if (this._udpSocket) this._udpSocket.close(); -}; - -Rcon.prototype.setTimeout = function(timeout, callback) { - if (!this._tcpSocket) return; - - var self = this; - this._tcpSocket.setTimeout(timeout, function() { - self._tcpSocket.end(); - if (callback) callback(); - }); -}; - -Rcon.prototype._udpSocketOnData = function(data) { - var a = data.readUInt32LE(0); - if (a == 0xffffffff) { - var str = data.toString("utf-8", 4); - var tokens = str.split(" "); - if (tokens.length == 3 && tokens[0] == "challenge" && tokens[1] == "rcon") { - this._challengeToken = tokens[2].substr(0, tokens[2].length - 1).trim(); - this.hasAuthed = true; - this.emit('auth'); - } else { - this.emit('response', str.substr(1, str.length - 2)); - } - } else { - this.emit('error', new Error("Received malformed packet")); - } -} - -Rcon.prototype._tcpSocketOnData = function(data) { - if (this.outstandingData != null) { - data = Buffer.concat([this.outstandingData, data], this.outstandingData.length + data.length); - this.outstandingData = null; - } - - while (data.length >= 12) { - var len = data.readInt32LE(0); // Size of entire packet, not including the 4 byte length field - if (!len) return; // No valid packet header, discard entire buffer - - var packetLen = len + 4; - if (data.length < packetLen) break; // Wait for full packet, TCP may have segmented it - - var bodyLen = len - 10; // Subtract size of ID, type, and two mandatory trailing null bytes - if (bodyLen < 0) { - data = data.slice(packetLen); // Length is too short, discard malformed packet - break; - } - - var id = data.readInt32LE(4); - var type = data.readInt32LE(8); - - if (id == this.rconId) { - if (!this.hasAuthed && type == PacketType.RESPONSE_AUTH) { - this.hasAuthed = true; - this.emit('auth'); - } else if (type == PacketType.RESPONSE_VALUE) { - // Read just the body of the packet (truncate the last null byte) - // See https://developer.valvesoftware.com/wiki/Source_RCON_Protocol for details - var str = data.toString('utf8', 12, 12 + bodyLen); - - if (str.charAt(str.length - 1) === '\n') { - // Emit the response without the newline. - str = str.substring(0, str.length - 1); - } - - this.emit('response', str); - } - } else if (id == -1) { - this.emit('error', new Error("Authentication failed")); - } else { - // ping/pong likely - var str = data.toString('utf8', 12, 12 + bodyLen); - - if (str.charAt(str.length - 1) === '\n') { - // Emit the response without the newline. - str = str.substring(0, str.length - 1); - } - - this.emit('server', str); - } - - data = data.slice(packetLen); - } - - // Keep a reference to remaining data, since the buffer might be split within a packet - this.outstandingData = data; -}; - -Rcon.prototype.socketOnConnect = function() { - this.emit('connect'); - - if (this.tcp) { - this.send(this.password, PacketType.AUTH); - } else if (this.challenge) { - var str = "challenge rcon\n"; - var sendBuf = Buffer.alloc(str.length + 4); - sendBuf.writeInt32LE(-1, 0); - sendBuf.write(str, 4); - this._sendSocket(sendBuf); - } else { - var sendBuf = Buffer.alloc(5); - sendBuf.writeInt32LE(-1, 0); - sendBuf.writeUInt8(0, 4); - this._sendSocket(sendBuf); - - this.hasAuthed = true; - this.emit('auth'); - } -}; - -Rcon.prototype.socketOnEnd = function() { - this.emit('end'); - this.hasAuthed = false; -}; - -module.exports = Rcon; diff --git a/package.json b/package.json index e960513..cb43592 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,12 @@ "author": "Justin Li ", "name": "rcon", "description": "A generic RCON client for Node.js", - "version": "1.1.0", + "version": "2.0.0", "homepage": "https://github.com/pushrax/node-rcon", "repository": "git://github.com/pushrax/node-rcon", - "main": "node-rcon", - "dependencies": {} + "main": "index.js", + "dependencies": {}, + "engines": { + "node": ">=6.0.0" + } }