node-gamedig/protocols/core.js

402 lines
13 KiB
JavaScript
Raw Normal View History

const EventEmitter = require('events').EventEmitter,
2017-08-09 12:32:09 +02:00
dns = require('dns'),
net = require('net'),
2017-08-10 13:49:42 +02:00
Reader = require('../lib/reader'),
2019-01-07 07:52:29 +01:00
HexUtil = require('../lib/HexUtil'),
util = require('util'),
dnsLookupAsync = util.promisify(dns.lookup),
2019-01-09 12:35:11 +01:00
dnsResolveAsync = util.promisify(dns.resolve),
2019-01-12 11:43:36 +01:00
requestAsync = require('request-promise'),
Promises = require('../lib/Promises');
2014-10-29 08:02:03 +01:00
class Core extends EventEmitter {
2017-08-09 12:32:09 +02:00
constructor() {
super();
this.encoding = 'utf8';
this.byteorder = 'le';
this.delimiter = '\0';
this.srvRecord = null;
2019-01-12 11:43:36 +01:00
this.abortedPromise = null;
2017-08-09 12:32:09 +02:00
2019-01-12 11:43:36 +01:00
// Sent to us by QueryRunner
this.options = null;
this.udpSocket = null;
this.shortestRTT = 0;
this.usedTcp = false;
2017-08-09 12:32:09 +02:00
}
2019-01-12 11:43:36 +01:00
async runAllAttempts() {
2019-01-07 07:52:29 +01:00
let result = null;
let lastError = null;
for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) {
try {
result = await this.runOnceSafe();
result.query.attempts = attempt;
break;
} catch (e) {
lastError = e;
}
}
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
if (result === null) {
throw lastError;
}
return result;
2017-08-09 12:32:09 +02:00
}
2019-01-07 07:52:29 +01:00
// Runs a single attempt with a timeout and cleans up afterward
async runOnceSafe() {
2019-01-12 11:43:36 +01:00
let abortCall = null;
this.abortedPromise = new Promise((resolve,reject) => {
abortCall = () => reject("Query is finished -- cancelling outstanding promises");
});
// Make sure that if this promise isn't attached to, it doesn't throw a unhandled promise rejection
this.abortedPromise.catch(() => {});
let timeout;
2019-01-07 07:52:29 +01:00
try {
2019-01-12 11:43:36 +01:00
const promise = this.runOnce();
timeout = Promises.createTimeout(this.options.attemptTimeout, "Attempt");
return await Promise.race([promise,timeout]);
2019-01-07 07:52:29 +01:00
} finally {
2019-01-12 11:43:36 +01:00
timeout && timeout.cancel();
try {
abortCall();
} catch(e) {
this.debugLog("Error during abort cleanup: " + e.stack);
2019-01-07 07:52:29 +01:00
}
}
}
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
async runOnce() {
const options = this.options;
if (('host' in options) && !('address' in options)) {
options.address = await this.parseDns(options.host);
2017-08-09 12:32:09 +02:00
}
2019-01-12 11:43:36 +01:00
const state = {
name: '',
map: '',
password: false,
raw: {},
maxplayers: 0,
players: [],
bots: []
};
2019-01-07 07:52:29 +01:00
await this.run(state);
2017-08-09 12:32:09 +02:00
2019-01-12 11:43:36 +01:00
// because lots of servers prefix with spaces to try to appear first
2019-01-12 12:45:09 +01:00
state.name = (state.name || '').trim();
2019-01-12 11:43:36 +01:00
if (!('connect' in state)) {
state.connect = ''
+ (state.gameHost || this.options.host || this.options.address)
+ ':'
+ (state.gamePort || this.options.port)
}
state.ping = this.shortestRTT;
2019-01-12 11:43:36 +01:00
delete state.gameHost;
delete state.gamePort;
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
return state;
2017-08-09 12:32:09 +02:00
}
2019-01-07 07:52:29 +01:00
async run(state) {}
2019-01-07 07:52:29 +01:00
/**
* @param {string} host
* @returns {Promise<string>}
*/
async parseDns(host) {
const isIp = (host) => {
return !!host.match(/\d+\.\d+\.\d+\.\d+/);
};
const resolveStandard = async (host) => {
if(isIp(host)) return host;
2019-01-09 12:50:30 +01:00
this.debugLog("Standard DNS Lookup: " + host);
2019-01-07 07:52:29 +01:00
const {address,family} = await dnsLookupAsync(host);
2019-01-09 12:50:30 +01:00
this.debugLog(address);
2019-01-07 07:52:29 +01:00
return address;
2017-08-09 12:32:09 +02:00
};
2019-01-07 07:52:29 +01:00
const resolveSrv = async (srv,host) => {
if(isIp(host)) return host;
2019-01-09 12:50:30 +01:00
this.debugLog("SRV DNS Lookup: " + srv+'.'+host);
2019-01-07 07:52:29 +01:00
let records;
try {
records = await dnsResolveAsync(srv + '.' + host, 'SRV');
2019-01-09 12:50:30 +01:00
this.debugLog(records);
2019-01-07 07:52:29 +01:00
if(records.length >= 1) {
const record = records[0];
this.options.port = record.port;
const srvhost = record.name;
return await resolveStandard(srvhost);
2017-08-09 12:32:09 +02:00
}
2019-01-07 07:52:29 +01:00
} catch(e) {
2019-01-09 12:50:30 +01:00
this.debugLog(e.toString());
2019-01-07 07:52:29 +01:00
}
return await resolveStandard(host);
2017-08-09 12:32:09 +02:00
};
2019-01-07 07:52:29 +01:00
if(this.srvRecord) return await resolveSrv(this.srvRecord, host);
else return await resolveStandard(host);
}
/** Param can be a time in ms, or a promise (which will be timed) */
registerRtt(param) {
if (param.then) {
const start = Date.now();
param.then(() => {
const end = Date.now();
const rtt = end - start;
this.registerRtt(rtt);
}).catch(() => {});
} else {
this.debugLog("Registered RTT: " + param + "ms");
if (this.shortestRTT === 0 || param < this.shortestRTT) {
this.shortestRTT = param;
}
}
}
2017-08-09 12:32:09 +02:00
// utils
/** @returns {Reader} */
reader(buffer) {
return new Reader(this,buffer);
}
translate(obj,trans) {
for(const from of Object.keys(trans)) {
const to = trans[from];
2017-08-09 12:32:09 +02:00
if(from in obj) {
if(to) obj[to] = obj[from];
delete obj[from];
}
}
}
trueTest(str) {
if(typeof str === 'boolean') return str;
if(typeof str === 'number') return str !== 0;
if(typeof str === 'string') {
if(str.toLowerCase() === 'true') return true;
2019-01-07 07:52:29 +01:00
if(str.toLowerCase() === 'yes') return true;
2017-08-09 12:32:09 +02:00
if(str === '1') return true;
}
return false;
}
2014-10-29 08:02:03 +01:00
2019-01-12 11:43:36 +01:00
assertValidPort(port) {
if (!port || port < 1 || port > 65535) {
throw new Error("Invalid tcp/ip port: " + port);
}
}
2019-01-07 07:52:29 +01:00
/**
2019-01-10 13:03:07 +01:00
* @template T
* @param {function(Socket):Promise<T>} fn
* @returns {Promise<T>}
2019-01-07 07:52:29 +01:00
*/
2019-01-12 12:45:09 +01:00
async withTcp(fn, port) {
this.usedTcp = true;
2017-08-09 12:32:09 +02:00
const address = this.options.address;
2019-01-12 12:45:09 +01:00
if (!port) port = this.options.port;
2019-01-12 11:43:36 +01:00
this.assertValidPort(port);
2017-08-09 12:32:09 +02:00
2019-01-12 11:43:36 +01:00
let socket, connectionTimeout;
try {
socket = net.connect(port,address);
socket.setNoDelay(true);
this.debugLog(log => {
this.debugLog(address+':'+port+" TCP Connecting");
const writeHook = socket.write;
socket.write = (...args) => {
log(address+':'+port+" TCP-->");
log(HexUtil.debugDump(args[0]));
writeHook.apply(socket,args);
};
socket.on('error', e => log('TCP Error: ' + e));
socket.on('close', () => log('TCP Closed'));
socket.on('data', (data) => {
2019-01-09 12:50:30 +01:00
log(address+':'+port+" <--TCP");
log(data);
2019-01-12 11:43:36 +01:00
});
socket.on('ready', () => log(address+':'+port+" TCP Connected"));
2019-01-07 07:52:29 +01:00
});
2017-08-09 12:32:09 +02:00
2019-01-12 11:43:36 +01:00
const connectionPromise = new Promise((resolve,reject) => {
socket.on('ready', resolve);
socket.on('close', () => reject(new Error('TCP Connection Refused')));
});
this.registerRtt(connectionPromise);
2019-01-12 11:43:36 +01:00
connectionTimeout = Promises.createTimeout(this.options.socketTimeout, 'TCP Opening');
await Promise.race([
connectionPromise,
connectionTimeout,
this.abortedPromise
]);
2019-01-07 07:52:29 +01:00
return await fn(socket);
} finally {
2019-01-12 11:43:36 +01:00
socket && socket.destroy();
connectionTimeout && connectionTimeout.cancel();
2019-01-07 07:52:29 +01:00
}
2017-08-09 12:32:09 +02:00
}
2014-10-29 08:02:03 +01:00
2019-01-07 07:52:29 +01:00
/**
2019-01-10 13:03:07 +01:00
* @template T
2019-01-07 07:52:29 +01:00
* @param {Socket} socket
2019-01-10 13:03:07 +01:00
* @param {Buffer|string} buffer
* @param {function(Buffer):T} ondata
* @returns Promise<T>
2019-01-07 07:52:29 +01:00
*/
async tcpSend(socket,buffer,ondata) {
2019-01-12 11:43:36 +01:00
let timeout;
try {
const promise = new Promise(async (resolve, reject) => {
2019-01-07 07:52:29 +01:00
let received = Buffer.from([]);
const onData = (data) => {
received = Buffer.concat([received, data]);
const result = ondata(received);
if (result !== undefined) {
socket.off('data', onData);
resolve(result);
}
};
socket.on('data', onData);
socket.write(buffer);
2019-01-12 11:43:36 +01:00
});
timeout = Promises.createTimeout(this.options.socketTimeout, 'TCP');
return await Promise.race([promise, timeout, this.abortedPromise]);
2019-01-07 07:52:29 +01:00
} finally {
2019-01-12 11:43:36 +01:00
timeout && timeout.cancel();
2019-01-07 07:52:29 +01:00
}
}
2017-08-09 12:32:09 +02:00
2019-01-07 07:52:29 +01:00
/**
* @param {Buffer|string} buffer
* @param {function(Buffer):T} onPacket
* @param {(function():T)=} onTimeout
* @returns Promise<T>
* @template T
*/
async udpSend(buffer,onPacket,onTimeout) {
2019-01-12 11:43:36 +01:00
const address = this.options.address;
const port = this.options.port;
this.assertValidPort(port);
2019-01-07 07:52:29 +01:00
if(typeof buffer === 'string') buffer = Buffer.from(buffer,'binary');
2019-01-09 12:50:30 +01:00
this.debugLog(log => {
2019-01-12 11:43:36 +01:00
log(address+':'+port+" UDP-->");
2019-01-09 12:50:30 +01:00
log(HexUtil.debugDump(buffer));
});
2019-01-07 07:52:29 +01:00
2019-01-12 11:43:36 +01:00
const socket = this.udpSocket;
socket.send(buffer, address, port);
2019-01-07 07:52:29 +01:00
2019-01-12 11:43:36 +01:00
let socketCallback;
let timeout;
try {
const promise = new Promise((resolve, reject) => {
const start = Date.now();
let end = null;
2019-01-12 11:43:36 +01:00
socketCallback = (fromAddress, fromPort, buffer) => {
try {
if (fromAddress !== address) return;
if (fromPort !== port) return;
if (end === null) {
end = Date.now();
const rtt = end-start;
this.registerRtt(rtt);
}
2019-01-12 11:43:36 +01:00
const result = onPacket(buffer);
2019-01-07 07:52:29 +01:00
if (result !== undefined) {
2019-01-12 11:43:36 +01:00
this.debugLog("UDP send finished by callback");
2019-01-07 07:52:29 +01:00
resolve(result);
}
2019-01-12 11:43:36 +01:00
} catch(e) {
reject(e);
2019-01-07 07:52:29 +01:00
}
};
socket.addCallback(socketCallback, this.options.debug);
2019-01-07 07:52:29 +01:00
});
2019-01-12 11:43:36 +01:00
timeout = Promises.createTimeout(this.options.socketTimeout, 'UDP');
const wrappedTimeout = new Promise((resolve, reject) => {
timeout.catch((e) => {
this.debugLog("UDP timeout detected");
if (onTimeout) {
try {
const result = onTimeout();
if (result !== undefined) {
this.debugLog("UDP timeout resolved by callback");
resolve(result);
return;
}
} catch(e) {
reject(e);
}
}
reject(e);
});
});
return await Promise.race([promise, wrappedTimeout, this.abortedPromise]);
} finally {
timeout && timeout.cancel();
socketCallback && socket.removeCallback(socketCallback);
}
2017-08-09 12:32:09 +02:00
}
2019-01-09 12:35:11 +01:00
2019-01-12 11:43:36 +01:00
async request(params) {
// If we haven't opened a raw tcp socket yet during this query, just open one and then immediately close it.
// This will give us a much more accurate RTT than using the rtt of the http request.
if (!this.usedTcp) {
await this.withTcp(() => {});
}
2019-01-12 11:43:36 +01:00
let requestPromise;
try {
requestPromise = requestAsync({
...params,
timeout: this.options.socketTimeout,
resolveWithFullResponse: true
});
this.debugLog(log => {
log(() => params.uri + " HTTP-->");
requestPromise
.then((response) => log(params.uri + " <--HTTP " + response.statusCode))
2019-01-12 12:45:09 +01:00
.catch(() => {});
2019-01-12 11:43:36 +01:00
});
const wrappedPromise = requestPromise.then(response => {
if (response.statusCode !== 200) throw new Error("Bad status code: " + response.statusCode);
return response.body;
});
2019-01-12 11:43:36 +01:00
return await Promise.race([wrappedPromise, this.abortedPromise]);
} finally {
requestPromise && requestPromise.cancel();
}
2019-01-09 12:35:11 +01:00
}
2019-01-09 12:50:30 +01:00
debugLog(...args) {
2019-01-12 11:43:36 +01:00
if (!this.options.debug) return;
2019-01-09 12:50:30 +01:00
try {
if(args[0] instanceof Buffer) {
this.debugLog(HexUtil.debugDump(args[0]));
} else if (typeof args[0] == 'function') {
const result = args[0].call(undefined, this.debugLog.bind(this));
if (result !== undefined) {
this.debugLog(result);
}
} else {
console.log(...args);
}
} catch(e) {
console.log("Error while debug logging: " + e);
}
}
}
module.exports = Core;