UNPKG

rcon-ts-valve

Version:

Improved RCON client in typescript

305 lines (250 loc) 8.61 kB
import * as net from 'net'; import {Buffer} from 'buffer'; export class ExtendableError extends Error { constructor(message: string = '', public readonly innerException?: Error) { super(message); this.message = message; this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } } export class RconError extends ExtendableError { constructor(message: string, innerException?: Error) { super(message, innerException); Object.freeze(this); } } export const enum State { Disconnected = 0, Connecting = 0.5, Connected = 1, Authorized = 2, Refused = -1, Unauthorized = -2 } const enum PacketType { AUTH = 0x03, // outgoing COMMAND = 0x02, // outgoing RESPONSE_AUTH = 0x02, // incoming RESPONSE = 0x00 // incoming } type Callback = (data: string | null, error?: Error) => void; export interface RconConfig { host: string; port?: number; password: string; timeout?: number; } export namespace Defaults { export const PORT:number = 25575; export const TIMEOUT:number = 5000; } Object.freeze(Defaults); export class Rcon implements RconConfig { readonly host: string; readonly port: number; readonly password: string; readonly timeout: number; enableConsoleLogging: boolean = false; private _authPacketId: number = NaN; private _state: State = State.Disconnected; private _socket: net.Socket | undefined; private _lastRequestId: number = 0xF4240; private _callbacks: Map<number, Callback> = new Map(); private _errors: Error[] = []; private _connector: Promise<Rcon> | undefined; private _sessionCount:number = 0; private _tempResponse:string = ''; get errors(): Error[] { return this._errors.slice(); } get state(): State { return this._state; } constructor(config: RconConfig) { let host = config.host; this.host = host = host && host.trim(); if (!host) throw new TypeError('"host" argument cannot be empty'); this.port = config.port || Defaults.PORT; const password = config.password; if (!password || !password.trim()) throw new TypeError('"password" argument cannot be empty'); this.password = password; this.timeout = config.timeout || Defaults.TIMEOUT; } connect(): Promise<Rcon> { const _ = this; let p = _._connector; if (!p) _._connector = p = new Promise<Rcon>((resolve, reject) => { _._state = State.Connecting; if (_.enableConsoleLogging) console.log(this.toString(), "Connecting..."); const s = _._socket = net.createConnection(_.port, _.host); function cleanup(message?: string, error?: Error): RconError | void { if (error) _._errors.push(error); s.removeAllListeners(); if (_._socket == s) _._socket = undefined; if (_._connector == p) _._connector = undefined; if (message) { if (_.enableConsoleLogging) console.error(_.toString(), message); if (message) return new RconError(message, error); } } // Look for connection failure... s.once('error', error => { _._state = State.Refused; reject(cleanup("Connection refused.", error)); // ** First point of failure. }); // Look for successful connection... s.once('connect', () => { s.removeAllListeners('error'); _._state = State.Connected; if (_.enableConsoleLogging) console.log(_.toString(), "Connected. Authorizing ..."); s.on('data', data => _._handleResponse(data)); s.on('error', error => { _._errors.push(error); if (_.enableConsoleLogging) console.error(_.toString(), error); }); _._send(_.password, PacketType.AUTH).then(() => { _._state = State.Authorized; if (_.enableConsoleLogging) console.log(_.toString(), "Authorized."); resolve(_); }).catch(error => { _._state = State.Unauthorized; reject(cleanup("Authorization failed.", error)); // ** Second point of failure. }); }); s.once('end', () => { if (_.enableConsoleLogging) console.warn(this.toString(), "Disconnected."); _._state = State.Disconnected; cleanup(); }); }); return p; } async session<T>(context:(rcon:Rcon,sessionId:number)=>Promise<T>):Promise<T> { const sessionId = ++this._sessionCount; let rcon:Rcon|undefined; try { rcon = await this.connect(); return await context(rcon, sessionId); } finally { this._sessionCount--; if(!this._sessionCount && rcon) rcon.disconnect(); } } toString(): string { return `RCON: ${this.host}:${this.port}`; } disconnect(): void { const s = this._socket; this._callbacks.clear(); if (s) s.end(); this._socket = undefined; this._connector = undefined; } _handleResponse(data: Buffer): void { const len = data.readInt32LE(0); if (!len) throw new RconError('Received empty response package'); let id = data.readInt32LE(4); const type = data.readInt32LE(8); const callbacks = this._callbacks; const authId = this._authPacketId; let payload = data.toString('utf8', 12, len + 2); if (payload.charAt(payload.length - 1) === '\n') payload = payload.substring(0, payload.length - 1); // console.log("Received\n", "type:", type ,", response:", payload, ", current id: ", id, ", authId: ", authId); // console.log("callback: ", callbacks); if (id === -1 && !isNaN(authId) && type === PacketType.RESPONSE_AUTH) { if (callbacks.has(authId)) { id = authId; this._authPacketId = NaN; callbacks.get(authId)!(null, new RconError('Authentication failed.')); } } if (callbacks.has(id) && type === PacketType.RESPONSE_AUTH) { callbacks.get(id)!(""); } if (type === PacketType.RESPONSE) { if (callbacks.has(id+1)) { this._tempResponse = this._tempResponse + payload; }else if (!callbacks.has(id+1) && this._tempResponse.length > 0 && payload.length === 0) { callbacks.get(id-1)!(this._tempResponse); this._tempResponse = ''; callbacks.get(id)!(""); } } // if (callbacks.has(id)) { // let str = data.toString('utf8', 12, len + 2); // if (str.charAt(str.length - 1) === '\n') // str = str.substring(0, str.length - 1); // callbacks.get(id)!(str); // } // callbacks.delete(id); // Possibly superfluous but best to be sure. } async send(data: string): Promise<string> { if (!this._connector || this._state <= 0) throw new RconError('Instance is not connected.'); await this._connector; // console.log("exist callback: ", this._callbacks); const response = this._send(data, PacketType.COMMAND); this._send('', PacketType.RESPONSE) return await response } async sequentialSend(data: string[]): Promise<string[]> { if (!this._connector || this._state <= 0) throw new RconError('Instance is not connected.'); await this._connector; let responseList = []; for (let i = 0; i < data.length; i++) { const response = this._send(data[i], PacketType.COMMAND); this._send('', PacketType.RESPONSE) responseList.push(await response); } return responseList; } private async _send(data: string, cmd: number): Promise<string> { const s = this._socket; if (!s || this._state <= 0) throw new RconError('Instance was disconnected.'); const length = Buffer.byteLength(data); const id = ++this._lastRequestId; if (cmd === PacketType.AUTH) this._authPacketId = id; // console.log("Send command:", data, "current id: ", this._lastRequestId); const buf = Buffer.allocUnsafe(length + 14); buf.writeInt32LE(length + 10, 0); buf.writeInt32LE(id, 4); // Not sure how this is used or needed. buf.writeInt32LE(cmd, 8); buf.write(data, 12); buf.fill(0x00, length + 12); await s.write(buf, 'binary'); return await new Promise<string>((resolve, reject) => { const cleanup = () => { clearTimeout(timeout); s.removeListener('end', onEnded); this._callbacks.delete(id); if (cmd === PacketType.AUTH) this._authPacketId = NaN; }; const timeout = setTimeout(() => { cleanup(); reject(new RconError('Request timed out')); }, this.timeout); const onEnded = () => { cleanup(); reject(new RconError('Disconnected before response.')); }; s.once('end', onEnded); this._callbacks.set(id, (data, err) => { cleanup(); if (err) reject(err); if (data == null) reject(new RconError("No data returned.")); else resolve(data); }); }); } } export default Rcon;