SquadJS/core/rcon.js

431 lines
13 KiB
JavaScript
Raw Normal View History

2020-10-25 19:11:47 -05:00
import EventEmitter from 'events';
2020-05-15 12:42:39 -05:00
import net from 'net';
2020-10-25 19:11:47 -05:00
import util from 'util';
2020-05-15 12:42:39 -05:00
2020-12-10 12:51:32 -06:00
import Logger from './logger.js';
2020-10-25 09:24:48 -05:00
2020-10-05 12:52:01 -05:00
const SERVERDATA_EXECCOMMAND = 0x02;
const SERVERDATA_RESPONSE_VALUE = 0x00;
const SERVERDATA_AUTH = 0x03;
2020-10-25 19:11:47 -05:00
const SERVERDATA_AUTH_RESPONSE = 0x02;
2020-10-05 12:52:01 -05:00
const SERVERDATA_CHAT_VALUE = 0x01;
2020-05-15 12:42:39 -05:00
2020-10-05 12:52:01 -05:00
const MID_PACKET_ID = 0x01;
const END_PACKET_ID = 0x02;
2020-05-15 12:42:39 -05:00
2020-10-25 19:11:47 -05:00
export default class Rcon extends EventEmitter {
2020-10-05 12:52:01 -05:00
constructor(options = {}) {
super();
2020-05-15 12:42:39 -05:00
2020-10-25 19:11:47 -05:00
// store config
2020-10-05 12:52:01 -05:00
for (const option of ['host', 'port', 'password'])
if (!(option in options)) throw new Error(`${option} must be specified.`);
2020-05-15 12:42:39 -05:00
2020-10-05 12:52:01 -05:00
this.host = options.host;
this.port = options.port;
this.password = options.password;
2020-10-25 19:11:47 -05:00
this.autoReconnectDelay = options.autoReconnectDelay || 5000;
2020-05-15 12:42:39 -05:00
2020-10-25 19:11:47 -05:00
// bind methods
this.connect = this.connect.bind(this); // we bind this as we call it on the auto reconnect timeout
this.onPacket = this.onPacket.bind(this);
2020-10-25 19:11:47 -05:00
this.onClose = this.onClose.bind(this);
this.onError = this.onError.bind(this);
this.decodeData = this.decodeData.bind(this);
this.encodePacket = this.encodePacket.bind(this);
2020-10-25 19:11:47 -05:00
// setup socket
this.client = new net.Socket();
this.client.on('data', this.decodeData);
2020-10-25 19:11:47 -05:00
this.client.on('close', this.onClose);
this.client.on('error', this.onError);
2020-05-15 12:42:39 -05:00
2020-10-25 19:11:47 -05:00
// constants
2020-05-15 12:42:39 -05:00
this.maximumPacketSize = 4096;
2020-10-25 19:11:47 -05:00
// internal variables
2020-05-15 12:42:39 -05:00
this.connected = false;
2020-10-25 19:11:47 -05:00
this.autoReconnect = false;
this.autoReconnectTimeout = null;
2020-05-15 12:42:39 -05:00
this.incomingData = Buffer.from([]);
this.incomingResponse = [];
this.responseCallbackQueue = [];
// Used For tracking Callbacks
this.callbackIds = [];
this.count = 1;
this.loggedin = false;
2020-10-25 19:11:47 -05:00
}
2020-05-18 10:41:32 -05:00
onPacket(decodedPacket) {
// the logic in this method simply splits data sent via the data event into packets regardless of how they're
// distributed in the event calls
Logger.verbose(
'RCON',
2,
`Processing decoded packet: ${this.decodedPacketToString(decodedPacket)}`
);
switch (decodedPacket.type) {
case SERVERDATA_RESPONSE_VALUE:
case SERVERDATA_AUTH_RESPONSE:
switch (decodedPacket.id) {
case MID_PACKET_ID:
this.incomingResponse.push(decodedPacket);
break;
case END_PACKET_ID:
this.callbackIds = this.callbackIds.filter((p) => p.id !== decodedPacket.count);
this.responseCallbackQueue.shift()(
this.incomingResponse.map((packet) => packet.body).join()
);
this.incomingResponse = [];
break;
default:
Logger.verbose(
'RCON',
1,
`Unknown packet ID ${decodedPacket.id} in: ${this.decodedPacketToString(
decodedPacket
)}`
);
this.onClose('Unknown Packet');
}
break;
2020-10-25 19:11:47 -05:00
case SERVERDATA_CHAT_VALUE:
this.processChatPacket(decodedPacket);
break;
2020-10-25 19:11:47 -05:00
default:
Logger.verbose(
'RCON',
1,
`Unknown packet type ${decodedPacket.type} in: ${this.decodedPacketToString(
decodedPacket
)}`
);
this.onClose('Unknown Packet');
}
}
2020-10-25 19:11:47 -05:00
decodeData(data) {
Logger.verbose('RCON', 4, `Got data: ${this.bufToHexString(data)}`);
2020-10-25 19:11:47 -05:00
this.incomingData = Buffer.concat([this.incomingData, data]);
2020-10-25 19:11:47 -05:00
2020-11-06 18:07:51 -06:00
while (this.incomingData.byteLength >= 4) {
const size = this.incomingData.readInt32LE(0);
const packetSize = size + 4;
if (this.incomingData.byteLength < packetSize) {
Logger.verbose(
'RCON',
4,
`Waiting for more data... Have: ${this.incomingData.byteLength} Expected: ${packetSize}`
);
break;
}
const packet = this.incomingData.slice(0, packetSize);
Logger.verbose('RCON', 4, `Processing packet: ${this.bufToHexString(packet)}`);
const decodedPacket = this.decodePacket(packet);
const matchCount = this.callbackIds.filter((d) => d.id === decodedPacket.count);
if (
matchCount.length > 0 ||
[SERVERDATA_AUTH_RESPONSE, SERVERDATA_CHAT_VALUE].includes(decodedPacket.type)
) {
this.onPacket(decodedPacket);
this.incomingData = this.incomingData.slice(packetSize);
continue;
}
// The packet following an empty packet will report to be 10 long (14 including the size header bytes), but in
// it should report 17 long (21 including the size header bytes). Therefore, if the packet is 10 in size
// and there's enough data for it to be a longer packet then we need to probe to check it's this broken packet.
const probePacketSize = 21;
if (size === 10 && this.incomingData.byteLength >= 21) {
// copy the section of the incoming data of interest
const probeBuf = this.incomingData.slice(0, probePacketSize);
// decode it
const decodedProbePacket = this.decodePacket(probeBuf);
// check whether body matches
if (decodedProbePacket.body === '\x00\x00\x00\x01\x00\x00\x00') {
// it does so it's the broken packet
// remove the broken packet from the incoming data
this.incomingData = this.incomingData.slice(probePacketSize);
Logger.verbose('RCON', 4, `Ignoring some data: ${this.bufToHexString(probeBuf)}`);
continue;
2020-10-25 19:11:47 -05:00
}
}
// We should only get this far into the loop when we are done processing packets from this onData event.
break;
2020-10-25 19:11:47 -05:00
}
}
decodePacket(packet) {
return {
size: packet.readUInt32LE(0),
id: packet.readUInt8(4),
count: packet.readUInt16LE(6),
type: packet.readUInt32LE(8),
2020-10-25 19:11:47 -05:00
body: packet.toString('utf8', 12, packet.byteLength - 2)
};
}
2020-12-10 12:51:32 -06:00
processChatPacket(decodedPacket) {}
2020-10-25 19:11:47 -05:00
onClose(hadError) {
this.connected = false;
this.loggedin = false;
Logger.verbose(
'RCON',
1,
`Socket closed ${hadError ? 'with' : 'without'} an error. ${hadError}`
);
// Cleanup all local state onClose
if (this.incomingData.length > 0) {
Logger.verbose('RCON', 2, `Clearing Buffered Data`);
this.incomingData = Buffer.from([]);
}
if (this.incomingResponse.length > 0) {
Logger.verbose('RCON', 2, `Clearing Buffered Response Data`);
this.incomingResponse = [];
}
if (this.responseCallbackQueue.length > 0) {
Logger.verbose('RCON', 2, `Clearing Pending Callbacks`);
// Cleanup Pending Callbacks; We should maybe retry these on next connection
// However, depending on the reason we got disconnected it may be a while.
// IE, Squad server crash, Squad server shutdown for multiple minutes.
while (this.responseCallbackQueue.length > 0) {
this.responseCallbackQueue.shift()(new Error('RCON DISCONNECTED'));
}
this.callbackIds = [];
}
2020-10-25 19:11:47 -05:00
2020-10-25 19:12:20 -05:00
if (this.autoReconnect) {
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Sleeping ${this.autoReconnectDelay}ms before reconnecting.`);
setTimeout(this.connect, this.autoReconnectDelay);
}
}
onError(err) {
Logger.verbose('RCON', 1, `Socket had error:`, err);
this.emit('RCON_ERROR', err);
2020-05-15 12:42:39 -05:00
}
connect() {
return new Promise((resolve, reject) => {
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Connecting to: ${this.host}:${this.port}`);
2020-05-15 12:42:39 -05:00
const onConnect = async () => {
this.client.removeListener('error', onError);
this.connected = true;
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Connected to: ${this.host}:${this.port}`);
try {
// connected successfully, now try auth...
await this.write(SERVERDATA_AUTH, this.password);
// connected and authed successfully
this.autoReconnect = true;
resolve();
} catch (err) {
reject(err);
}
2020-05-15 12:42:39 -05:00
};
const onError = (err) => {
2020-05-15 12:42:39 -05:00
this.client.removeListener('connect', onConnect);
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Failed to connect to: ${this.host}:${this.port}`, err);
2020-05-15 12:42:39 -05:00
reject(err);
};
this.client.once('connect', onConnect);
this.client.once('error', onError);
this.client.connect(this.port, this.host);
});
}
2020-10-25 19:11:47 -05:00
disconnect() {
2020-05-15 12:42:39 -05:00
return new Promise((resolve, reject) => {
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Disconnecting from: ${this.host}:${this.port}`);
2020-05-15 12:42:39 -05:00
const onClose = () => {
this.client.removeListener('error', onError);
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Disconnected from: ${this.host}:${this.port}`);
2020-05-15 12:42:39 -05:00
resolve();
};
const onError = (err) => {
2020-05-15 12:42:39 -05:00
this.client.removeListener('close', onClose);
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 1, `Failed to disconnect from: ${this.host}:${this.port}`, err);
2020-05-15 12:42:39 -05:00
reject(err);
};
this.client.once('close', onClose);
this.client.once('error', onError);
2020-10-25 19:11:47 -05:00
// prevent any auto reconnection happening
this.autoReconnect = false;
// clear the timeout just in case the socket closed and then we DCed
clearTimeout(this.autoReconnectTimeout);
this.client.end();
2020-05-15 12:42:39 -05:00
});
}
2020-10-25 19:11:47 -05:00
execute(command) {
return this.write(SERVERDATA_EXECCOMMAND, command);
2020-10-05 12:52:01 -05:00
}
2020-05-15 12:42:39 -05:00
write(type, body) {
return new Promise((resolve, reject) => {
if (!this.connected) {
reject(new Error('Not connected.'));
return;
}
2020-10-25 19:11:47 -05:00
if (!this.client.writable) {
reject(new Error('Unable to write to socket.'));
return;
}
2020-05-15 12:42:39 -05:00
if (!this.loggedin && type !== SERVERDATA_AUTH) {
reject(new Error('RCON not Logged in'));
return;
}
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 2, `Writing packet with type "${type}" and body "${body}".`);
2020-05-15 12:42:39 -05:00
2020-10-25 19:12:20 -05:00
const encodedPacket = this.encodePacket(
type,
type !== SERVERDATA_AUTH ? MID_PACKET_ID : END_PACKET_ID,
2020-10-25 19:12:20 -05:00
body
);
2020-10-25 19:11:47 -05:00
const encodedEmptyPacket = this.encodePacket(type, END_PACKET_ID, '');
2020-05-15 12:42:39 -05:00
2020-10-25 19:11:47 -05:00
if (this.maximumPacketSize < encodedPacket.length) {
reject(new Error('Packet too long.'));
return;
}
2020-05-15 12:42:39 -05:00
const onError = (err) => {
Logger.verbose('RCON', 1, 'Error occurred. Wiping response action queue.', err);
this.responseCallbackQueue = [];
reject(err);
};
// the auth packet also sends a normal response, so we add an extra empty action to ignore it
2020-10-25 19:11:47 -05:00
if (type === SERVERDATA_AUTH) {
this.callbackIds.push({ id: this.count, cmd: body });
Logger.verbose('RCON', 2, `Writing Auth Packet`);
Logger.verbose('RCON', 4, `Writing packet with type "${type}" and body "${body}".`);
this.responseCallbackQueue.push(() => {});
this.responseCallbackQueue.push((decodedPacket) => {
2020-10-25 19:11:47 -05:00
this.client.removeListener('error', onError);
if (decodedPacket.id === -1) {
Logger.verbose('RCON', 1, 'Authentication failed.');
reject(new Error('Authentication failed.'));
} else {
Logger.verbose('RCON', 1, 'Authentication succeeded.');
this.loggedin = true;
2020-10-25 19:11:47 -05:00
resolve();
}
});
2020-10-25 19:11:47 -05:00
} else {
Logger.verbose('RCON', 2, `Writing packet with type "${type}" and body "${body}".`);
this.callbackIds.push({ id: this.count, cmd: body });
this.responseCallbackQueue.push((response) => {
2020-10-25 19:11:47 -05:00
this.client.removeListener('error', onError);
2020-05-15 12:42:39 -05:00
if (response instanceof Error) {
// Called from onClose()
reject(response);
} else {
Logger.verbose(
'RCON',
2,
`Returning complete response: ${response.replace(/\r\n|\r|\n/g, '\\n')}`
);
2020-05-15 12:42:39 -05:00
resolve(response);
}
});
2020-10-25 19:11:47 -05:00
}
2020-05-15 12:42:39 -05:00
2020-10-25 19:11:47 -05:00
this.client.once('error', onError);
2020-05-15 12:42:39 -05:00
if (this.count + 1 > 65535) {
this.count = 1;
}
2020-10-25 19:11:47 -05:00
Logger.verbose('RCON', 4, `Sending packet: ${this.bufToHexString(encodedPacket)}`);
2020-05-15 12:42:39 -05:00
this.client.write(encodedPacket);
2020-10-25 19:11:47 -05:00
if (type !== SERVERDATA_AUTH) {
Logger.verbose(
'RCON',
4,
`Sending empty packet: ${this.bufToHexString(encodedEmptyPacket)}`
);
this.client.write(encodedEmptyPacket);
this.count++;
2020-10-25 19:11:47 -05:00
}
2020-05-15 12:42:39 -05:00
});
}
2020-10-25 19:11:47 -05:00
encodePacket(type, id, body, encoding = 'utf8') {
const size = Buffer.byteLength(body) + 14;
const buf = Buffer.alloc(size);
2020-05-15 12:42:39 -05:00
buf.writeUInt32LE(size - 4, 0);
buf.writeUInt8(id, 4);
buf.writeUInt8(0, 5);
buf.writeUInt16LE(this.count, 6);
buf.writeUInt32LE(type, 8);
2020-10-25 19:11:47 -05:00
buf.write(body, 12, size - 2, encoding);
buf.writeUInt16LE(0, size - 2);
2020-10-25 19:11:47 -05:00
return buf;
}
2020-10-25 19:11:47 -05:00
bufToHexString(buf) {
return buf.toString('hex').match(/../g).join(' ');
2020-05-15 12:42:39 -05:00
}
2020-10-25 19:11:47 -05:00
decodedPacketToString(decodedPacket) {
return util.inspect(decodedPacket, { breakLength: Infinity });
2020-05-15 12:42:39 -05:00
}
2020-10-25 19:39:34 -05:00
2020-10-27 18:07:26 -05:00
async warn(steamID, message) {
await this.execute(`AdminWarn "${steamID}" ${message}`);
}
2020-10-22 19:22:34 -05:00
async kick(steamID, reason) {
await this.execute(`AdminKick "${steamID}" ${reason}`);
}
2020-10-27 18:07:26 -05:00
2020-11-07 17:42:31 -06:00
async forceTeamChange(steamID) {
2020-10-21 16:49:07 -05:00
await this.execute(`AdminForceTeamChange "${steamID}"`);
}
2020-05-15 12:42:39 -05:00
}