├── .gitignore ├── .jshintrc ├── Makefile ├── README.md ├── dev └── auth.js ├── index.js ├── lib ├── connection.js ├── packet.js └── util.js ├── package.json └── test └── integration.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | node_modules 3 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "node": true, 4 | "unused": true, 5 | "undef": true, 6 | "newcap": false, 7 | "predef": [ 8 | "describe", 9 | "it" 10 | ] 11 | } 12 | 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # TESTS 2 | 3 | TESTER = ./node_modules/.bin/mocha 4 | OPTS = --growl --ignore-leaks --timeout 30000 5 | TESTS = test/*.test.js 6 | INTEGRATION = test/*.integration.js 7 | JSHINT = ./node_modules/.bin/jshint 8 | 9 | JS_FILES = $(shell find . -type f -name "*.js" \ 10 | -not -path "./node_modules/*" -and \ 11 | -not -path "./broker/*" -and \ 12 | -not -path "./coverage/*" -and \ 13 | -not -path "./dev/*" -and \ 14 | -not -path "./vendor/*" -and \ 15 | -not -path "./broker/*" -and \ 16 | -not -path "./public/_js/*" -and \ 17 | -not -path "./config/database.js" -and \ 18 | -not -path "./public/js/*.js" -and \ 19 | -not -path "./newrelic.js" -and \ 20 | -not -path "./db/schema.js") 21 | 22 | check: 23 | @$(JSHINT) $(JS_FILES) && echo 'Those who know do not speak. Those who speak do not know.' 24 | 25 | test: 26 | $(TESTER) $(OPTS) $(TESTS) 27 | test-verbose: 28 | $(TESTER) $(OPTS) --reporter spec $(TESTS) 29 | test-integration: 30 | $(TESTER) $(OPTS) --reporter spec $(INTEGRATION) 31 | test-full: 32 | $(TESTER) $(OPTS) --reporter spec $(TESTS) $(INTEGRATION) 33 | testing: 34 | $(TESTER) $(OPTS) --watch $(TESTS) 35 | features: 36 | NODE_ENV=test node_modules/.bin/cucumber.js 37 | 38 | .PHONY: test doc docs features 39 | 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | node-srcds-rcon 2 | =============== 3 | 4 | Node.JS high-level wrapper for SRCDS's remote console (RCON) https://developer.valvesoftware.com/wiki/RCON 5 | 6 | 7 | ## Install 8 | 9 | > npm install srcds-rcon 10 | 11 | If you think the npm version is outdated, you may install from github 12 | 13 | > npm install randunel/node-srcds-rcon 14 | 15 | ## Introduction 16 | 17 | This is a node driver for SRCDS's RCON. While it should work on all SRCDS versions, it has only been tested against the Source 2009 (orangebox) protocol. Development uses the latest CS:GO server build. 18 | 19 | The current version `2.x` requires node.js version 4.x or newer. For older node.js versions including 0.8, install srcds-rcon `1.1.7`. All development uses node.js 5.x. 20 | 21 | ## Testing 22 | 23 | Install dev dependencies (`npm install` does that by default). Set up a csgo server and bind it to `127.0.0.1:27015`, run it with `-usercon` and `rcon_password test`. Then run `make test`. 24 | 25 | Alternatively, set up a different server and edit `test/integration.test.js` `getIntegrationAuth` to return login details to the desired test server. 26 | 27 | ## Usage 28 | 29 | #### First establish connection 30 | 31 | ``` javascript 32 | let Rcon = require('srcds-rcon'); 33 | let rcon = Rcon({ 34 | address: '192.168.1.10', 35 | password: 'test' 36 | }); 37 | rcon.connect().then(() => { 38 | console.log('connected'); 39 | }).catch(console.error); 40 | ``` 41 | 42 | #### Run commands 43 | 44 | ``` javascript 45 | let rcon = require('srcds-rcon')({ 46 | address: '192.168.1.10', 47 | password: 'test' 48 | }); 49 | 50 | rcon.connect().then(() => { 51 | return rcon.command('sv_airaccelerate 10').then(() => { 52 | console.log('changed sv_airaccelerate'); 53 | }); 54 | }).then( 55 | () => rcon.command('status').then(status => console.log(`got status ${status}`)) 56 | ).then( 57 | () => rcon.command('cvarlist').then(cvarlist => console.log(`cvarlist is \n${cvarlist}`)) 58 | ).then( 59 | () => rcon.command('changelevel de_dust2').then(() => console.log('changed map')) 60 | ).then( 61 | () => rcon.disconnect() 62 | ).catch(err => { 63 | console.log('caught', err); 64 | console.log(err.stack); 65 | }); 66 | ``` 67 | 68 | #### Specify command timeout 69 | 70 | ``` javascript 71 | rcon.command('cvarlist', 1000).then(console.log, console.error); 72 | ``` 73 | 74 | #### Disconnect once finished 75 | 76 | ``` javascript 77 | rcon.disconnect(); 78 | ``` 79 | 80 | ## Errors 81 | 82 | Some errors may contain partial command output. That indicates that the command was run, but reply packets have been lost. 83 | 84 | ``` javascript 85 | rcon.command('cvarlist').then(() => {}).catch(err => { 86 | console.log(`Command error: ${err.message}`); 87 | if (err.details && err.details.partialResponse) { 88 | console.log(`Got partial response: ${err.details.partialResponse}`); 89 | } 90 | }); 91 | ``` 92 | 93 | When an error is returned, even if it doesn't contain a partial output, there is no guarantee the command was not run. The protocol uses udp and the packets sometimes get lost. The only guarantee the command did run is when the error contains a partial output. 94 | 95 | -------------------------------------------------------------------------------- /dev/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let rcon = require('..')({ 4 | address: '127.0.0.1', 5 | password: 'test' 6 | }); 7 | 8 | rcon.connect().then(() => { 9 | return rcon.command('sv_airaccelerate 10').then(() => { 10 | console.log('changed sv_airaccelerate'); 11 | }); 12 | }).then( 13 | () => rcon.command('status').then(status => console.log(`got status ${status}`)) 14 | ).then( 15 | () => rcon.command('cvarlist').then(cvarlist => console.log(`cvarlist is \n${cvarlist}`)) 16 | ).then( 17 | () => rcon.command('cvarlist', 1).then(cvarlist => console.log(`cvarlist is \n${cvarlist}`)) 18 | ).then( 19 | () => rcon.command('changelevel de_dust2').then(() => console.log('changed map')) 20 | ).catch(err => { 21 | console.log('caught', err); 22 | console.log(err.stack); 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let Connection = require('./lib/connection'); 4 | let packet = require('./lib/packet'); 5 | let util = require('./lib/util'); 6 | 7 | module.exports = params => { 8 | let address = params.address; 9 | let password = params.password; 10 | let _connection; 11 | let nextPacketId; 12 | 13 | return Object.freeze({ 14 | connect: connect, 15 | command: command, 16 | disconnect: disconnect 17 | }); 18 | 19 | function connect() { 20 | let connection = Connection(address); 21 | return connection.create().then(() => _auth(connection)); 22 | } 23 | 24 | function disconnect() { 25 | return _connection.destroy().then(() => { 26 | _connection = undefined; 27 | }); 28 | } 29 | 30 | function _auth(connection) { 31 | let buf = packet.request({ 32 | id: 1, 33 | type: packet.SERVERDATA_AUTH, 34 | body: password 35 | }); 36 | connection.send(buf); 37 | return Promise.race([ 38 | util.promiseTimeout(3000).then(() => { 39 | let err = new Error('Auth timeout'); 40 | return Promise.reject(err); 41 | }), 42 | connection.getData(dataHandler) 43 | ]).then(data => { 44 | // TODO: data as a single type, not string/object 45 | let res = packet.response(data); 46 | if (res.id === -1) { 47 | let err = new Error('Wrong rcon password'); 48 | return Promise.reject(err); 49 | } 50 | // Auth successful, but continue after receiving packet index 51 | return connection.getData(dataHandler).then(() => { 52 | _init(connection); 53 | }); 54 | }); 55 | 56 | function dataHandler() { 57 | // Auth response should only return 1 packet 58 | return false; 59 | } 60 | } 61 | 62 | function _init(connection) { 63 | _connection = connection; 64 | nextPacketId = 1; 65 | } 66 | 67 | function _getNextPacketId() { 68 | return nextPacketId += 1; 69 | } 70 | 71 | function command(text, timeout) { 72 | return Promise.race([ 73 | new Promise((resolve, reject) => { 74 | if (!_connection) { 75 | reject(new Error('not connected')); 76 | } 77 | 78 | let unexpectedPackets; 79 | 80 | let responseData = new Buffer(0); 81 | let reqId = _getNextPacketId(); 82 | let req = packet.request({ 83 | id: reqId, 84 | type: packet.SERVERDATA_EXECCOMMAND, 85 | body: text 86 | }); 87 | let ackId = _getNextPacketId(); 88 | let ack = packet.request({ 89 | id: ackId, 90 | type: packet.SERVERDATA_EXECCOMMAND, 91 | body: '' 92 | }); 93 | _connection.send(req); 94 | _connection.send(ack); 95 | _connection.getData(dataHandler).then(done); 96 | 97 | function dataHandler(data) { 98 | let res = packet.response(data); 99 | if (res.id === ackId) { 100 | return false; 101 | } else if (res.id === reqId) { 102 | // More data to come 103 | responseData = Buffer.concat([responseData, res.payload], responseData.length + res.payload.length); 104 | return true; 105 | } else { 106 | return handleUnexpectedData(res.id); 107 | } 108 | } 109 | 110 | function done() { 111 | let text = packet.convertPayload(responseData); 112 | resolve(text); 113 | } 114 | 115 | function handleUnexpectedData(id) { 116 | // Unexpected res.id, possibly from other commands 117 | if (reqId > id) { 118 | // Do nothing and keep listening, packets from older 119 | // commands are still coming in 120 | return true; 121 | } 122 | if ('undefined' === typeof unexpectedPackets) { 123 | unexpectedPackets = new Map(); 124 | } 125 | if (!unexpectedPackets.has(id)) { 126 | if (unexpectedPackets.size >= 2) { 127 | let err = new Error('Command lost'); 128 | err.details = { 129 | reqId: reqId 130 | }; 131 | if (responseData.length > 0) { 132 | err.details.partialResponse = packet.convertPayload(responseData); 133 | } 134 | reject(err); 135 | return false; 136 | } 137 | unexpectedPackets.set(id, 1); 138 | return true; 139 | } 140 | unexpectedPackets.set(id, unexpectedPackets.get(id) + 1); 141 | return true; 142 | } 143 | }), 144 | new Promise((resolve, reject) => { 145 | if ('number' === typeof timeout) { 146 | return util.promiseTimeout(timeout).then(() => { 147 | let err = new Error('Command timeout'); 148 | err.details = { 149 | timeout: timeout 150 | }; 151 | reject(err); 152 | }); 153 | } 154 | }) 155 | ]); 156 | } 157 | }; 158 | -------------------------------------------------------------------------------- /lib/connection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let net = require('net'); 4 | 5 | module.exports = address => { 6 | let connection; 7 | 8 | return Object.freeze({ 9 | create: create, 10 | send: send, 11 | getData: getData, 12 | destroy: destroy 13 | }); 14 | 15 | function create() { 16 | return _createConnection().then(newConnection => { 17 | connection = newConnection; 18 | 19 | connection.on('close', _disconnectHandler); 20 | }); 21 | } 22 | 23 | function destroy() { 24 | return _destroyConnection(); 25 | } 26 | 27 | function _createConnection() { 28 | return new Promise((resolve, reject) => { 29 | let host = address.split(':')[0]; 30 | let port = Number(address.split(':')[1]) || 27015; 31 | let connection = net.createConnection({ 32 | host: host, 33 | port: port 34 | }, () => { 35 | connection.removeListener('error', errorHandler); 36 | connection.on('error', _errorHandler); 37 | resolve(connection); 38 | }); 39 | 40 | connection.on('error', errorHandler); 41 | 42 | function errorHandler(err) { 43 | connection.removeListener('error', errorHandler); 44 | reject(err); 45 | } 46 | }); 47 | } 48 | 49 | function _destroyConnection() { 50 | return new Promise((resolve, reject) => { 51 | if (connection) { 52 | connection.end(); 53 | 54 | connection.on('close', resolve); 55 | } 56 | else { 57 | resolve(); 58 | } 59 | }); 60 | } 61 | 62 | function _errorHandler(err) { 63 | console.error(err); 64 | } 65 | 66 | function _disconnectHandler() { 67 | connection = undefined; 68 | } 69 | 70 | function getData(cbSync) { 71 | return new Promise((resolve, reject) => { 72 | connection.removeListener('error', _errorHandler); 73 | connection.on('error', errorHandler); 74 | connection.on('data', dataHandler); 75 | 76 | function dataHandler(data) { 77 | if (!cbSync(data)) { 78 | resetListeners(); 79 | resolve(data); 80 | } 81 | } 82 | 83 | function errorHandler(err) { 84 | resetListeners(); 85 | reject(err); 86 | } 87 | 88 | function resetListeners() { 89 | connection.removeListener('error', errorHandler); 90 | connection.removeListener('data', dataHandler); 91 | connection.on('error', _errorHandler); 92 | } 93 | }); 94 | } 95 | 96 | function send(buffer) { 97 | connection.write(buffer); 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /lib/packet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | request: request, 5 | response: response, 6 | convertPayload: convertPayload, 7 | SERVERDATA_AUTH: 0x03, 8 | SERVERDATA_AUTH_RESPONSE: 0x02, 9 | SERVERDATA_EXECCOMMAND: 0x02, 10 | SERVERDATA_RESPONSE_VALUE: 0x00, 11 | }; 12 | 13 | function request(options) { 14 | let id = options.id; 15 | let type = options.type; 16 | let body = options.body; 17 | 18 | let bodySize = Buffer.byteLength(body); 19 | // Add 4 to the size (body + 10) for the null char 20 | let buffer = new Buffer(bodySize + 14); 21 | // Substract 4 because the packet size field is not included when 22 | // determining the size of the packet 23 | buffer.writeInt32LE(buffer.length - 4, 0); 24 | buffer.writeInt32LE(id, 4); 25 | buffer.writeInt32LE(type, 8); 26 | buffer.write(body, 12, buffer.length - 2, 'ascii'); 27 | buffer.writeInt16LE(0x00, buffer.length - 2); 28 | 29 | return buffer; 30 | } 31 | 32 | function response(buffer) { 33 | let size = buffer.readInt32LE(0); 34 | let id = buffer.readInt32LE(4); 35 | let type = buffer.readInt32LE(8); 36 | // let body = buffer.toString('ascii', 12, buffer.length - 2); 37 | let payload = buffer.slice(12, buffer.length - 2); 38 | 39 | return { 40 | size: size, 41 | id: id, 42 | type: type, 43 | // body: body, 44 | payload: payload 45 | }; 46 | } 47 | 48 | function convertPayload(buffer) { 49 | return buffer.toString('ascii'); 50 | } 51 | 52 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = Object.freeze({ 4 | promiseTimeout: timeout => new Promise(resolve => setTimeout(resolve, timeout)) 5 | }); 6 | 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srcds-rcon", 3 | "version": "2.2.1", 4 | "description": "A node.js driver for the SRCDS RCON", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node .", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/randunel/node-srcds-rcon.git" 13 | }, 14 | "keywords": [ 15 | "valve", 16 | "srcds", 17 | "hlds", 18 | "rcon", 19 | "half", 20 | "life", 21 | "counter", 22 | "strike" 23 | ], 24 | "author": "Mihai Ene ", 25 | "license": "Apache-2.0", 26 | "bugs": { 27 | "url": "https://github.com/randunel/node-srcds-rcon/issues" 28 | }, 29 | "homepage": "https://github.com/randunel/node-srcds-rcon#readme", 30 | "devDependencies": { 31 | "should": "~7.1.1", 32 | "mocha": "~2.3.4" 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let rcon = require('../'); 4 | let should = require('should'); 5 | 6 | describe('integration', () => { 7 | it('should connect to server', () => rcon(getIntegrationAuth()).connect().catch( 8 | err => should.not.exist(err) 9 | )); 10 | 11 | it('should run single packet command', () => { 12 | let server = rcon(getIntegrationAuth()); 13 | return server.connect().then( 14 | () => server.command('status') 15 | ).then( 16 | status => status.should.containEql('hostname') 17 | ); 18 | }); 19 | 20 | it('should work with multi packet commands', () => { 21 | let server = rcon(getIntegrationAuth()); 22 | return server.connect().then( 23 | () => server.command('cvarlist') 24 | ).then( 25 | cvarlist => cvarlist.should.containEql('concommands') 26 | ); 27 | }); 28 | }); 29 | 30 | function getIntegrationAuth() { 31 | return { 32 | address: '127.0.0.1', 33 | password: 'test' 34 | }; 35 | } 36 | 37 | --------------------------------------------------------------------------------