// Copyright 2019-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (web@campbellcrowley.com)
const http = require('http');
const zlib = require('zlib');
const socketIo = require('socket.io');
const Discord = require('discord.js');
const crypto = require('crypto');
const common = require('../common.js');
const path = require('path');
const fs = require('fs');
const spawn = require('child_process').spawn;
const usersFile = path.resolve(__dirname + '/../../save/knownShards.json');
const historyFile =
path.resolve(__dirname + '/../../save/shardStatsHistory.json');
const configFile =
path.resolve(__dirname + '/../../config/shardMasterConfig.js');
const authFile = path.resolve(__dirname + '/../../auth.js');
const shardDir = path.resolve(__dirname + '/../../save/shards/');
const privKeyFile = `${shardDir}/shardMaster.priv`;
const pubKeyFile = `${shardDir}/shardMaster.pub`;
const botCWD = path.resolve(__dirname + '/../../');
const signAlgorithm = 'RSA-SHA256';
const keyType = 'rsa';
const keyOptions = {
modulusLength: 4096,
publicKeyEncoding: {type: 'spki', format: 'pem'},
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
};
// TODO: Implement update command which will pull the latest version from github
// across all shards, even if they are not currently in use.
/**
* @description The master that manages the shards below it. This is what
* specifies how many shards may start, which shard has which ID, as well as
* manages inter-shard communication. Shutdown, start, reboot, and update
* commands may be issued to individual shards with updated information on how
* they are expected to be configured. Shards are expected to communicate with
* this master via websockets once they are ready to receive their role.
* @class
*/
class ShardingMaster {
/**
* @description Start the webserver.
* @param {number} [port=8024] Port to listen for shard connections.
* @param {string} [address='127.0.0.1'] Address to listen for shard
* connections.
* @param {string} [path='/socket.io/'] Path prefix to watch for socket
* connections.
*/
constructor(port = 8024, address = '127.0.0.1', path = '/socket.io/') {
common.begin(false, true);
common.connectSQL();
this._serverError = this._serverError.bind(this);
this._socketConnection = this._socketConnection.bind(this);
/**
* @description The string representation of the private key used for
* signing initial handshake to shard.
* @see {@link ShardingMaster~_loadMasterKeys}
* @private
* @type {?string}
* @default
*/
this._privKey = null;
/**
* @description The string representation of the public key used for signing
* initial handshake to shard.
* @see {@link ShardingMaster~_loadMasterKeys}
* @private
* @type {?string}
* @default
*/
this._pubKey = null;
/**
* @description The timestamp the last time the recommended shard count was
* requested.
* @private
* @type {number}
* @default
*/
this._shardNumTimestamp = 0;
/**
* @description The number of shards recommended by Discord from our last
* request.
* @private
* @type {number}
* @default
*/
this._detectedNumShards = -1;
/**
* @description The currently known shards mapped by their name, containing
* some information about them including their public key.
* @private
* @type {?object.<ShardInfo>}
*/
this._knownUsers = null;
this._updateUsers();
fs.watchFile(usersFile, {persistent: false}, (curr, prev) => {
if (this._usersIgnoreNext) {
this._usersIgnoreNext = false;
return;
}
if (prev.mtime < curr.mtime) this._updateUsers();
});
this._readStatsHistory();
/**
* @description History for each known user of the received stats for
* showing graphs and analysis. Mapped by user ID, then array in
* chronological order.
* @private
* @type {object.<ShardStatus[]>}
* @default
*/
this._usersStatsHistory = {};
/**
* @description Toggle for when we write known users to file to not re-read
* them again.
* @private
* @type {boolean}
* @default
*/
this._usersIgnoreNext = false;
/**
* @description The current config for sharding.
* @private
* @type {?ShardMasterConfig}
*/
this._config = null;
this._updateConfig();
fs.watchFile(configFile, {persistent: false}, (curr, prev) => {
if (prev.mtime < curr.mtime) this._updateConfig();
});
/**
* @description The current authentication information.
* @private
* @type {?object}
*/
this._auth = null;
this._updateAuth();
fs.watchFile(authFile, {persistent: false}, (curr, prev) => {
if (prev.mtime < curr.mtime) this._updateAuth();
});
this._loadMasterKeys();
/**
* @description The timestamp at which the next heartbeat event is to fire.
* @private
* @type {number}
* @default
*/
this._nextHeartbeatTime = Date.now();
/**
* @description The timestamp at which the heartbeat interval loop was most
* recently started. This is used when
* {@link ShardingMaster.ShardMasterConfig.HeartbeatConfig~disperse} is
* being used in order to only update one shard for each heartbeat event.
* @private
* @type {number}
* @default
*/
this._heartbeatLoopStartTime = Date.now();
/**
* @description Timeout for heartbeat interval loop.
* @private
* @type {?number}
* @default
*/
this._hbTimeout = null;
/**
* @description Timeout used to prevent multiple calls to {@link _saveUsers}
* within a short time.
* @private
* @type {?number}
* @default
*/
this._userSaveTimeout = null;
/**
* @description Timeout used to prevent multiple calls to {@link
* _writeStatsHistory} within a short time.
* @private
* @type {?number}
* @default
*/
this._historySaveTimeout = null;
/**
* @description History of recent connections ordered by oldest to latest.
* Each index contains an array of length 2, storing the IP address and the
* timestamp of a connection attempt. This is used to start ignoring an IP
* address if too many connection attempts are made within a short time.
* @private
* @type {Array.<Array.<string, number>>}
* @default
*/
this._ipConnectHistory = [];
/**
* Map of all currently connected sockets mapped by socket.id.
*
* @private
* @type {object.<Socket>}
*/
this._sockets = {};
/**
* Map of the currently connected sockets for each shard, mapped by the
* shard names.
*
* @private
* @type {object.<Socket>}
*/
this._shardSockets = {};
/**
* The socket that the master shard is currently connected on, or null if
* disconnected.
*
* @private
* @type {?Socket}
*/
this._masterShardSocket = null;
/**
* ID of the shard we last sent a heartbeat request to that has not been
* resolved. This is only used for 'pull' style updating for debugging
* heartbeats that don't get replies.
*
* @private
* @type {?string}
* @default
*/
this._hbWaitID = null;
/**
* Webserver instance.
*
* @private
* @type {http.Server}
*/
this._app = http.createServer((...a) => this._handler(...a));
this._app.on('error', this._serverError);
/**
* Socket.io instance.
*
* @private
* @type {socketIo.Server}
*/
this._io = socketIo(this._app, {path: path});
this._app.listen(port, address);
this._io.on('connection', this._socketConnection);
}
/**
* @description Check if key-pair exists for this master. If not, they will be
* generated, and the newly generated keys will be used.
* @private
*/
_loadMasterKeys() {
fs.readFile(privKeyFile, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
common.log('No keys have been generated yet. Generating some now.');
ShardingMaster.generateKeyPair((err, pub, priv) => {
if (err) {
common.error('Failed to generate keys.');
console.error(err);
return;
}
this._pubKey = pub.toString();
this._privKey = priv.toString();
common.mkAndWrite(
pubKeyFile, path.dirname(pubKeyFile), pub, (err) => {
if (!err) {
common.logDebug('Public key created successfully.');
} else {
common.error('Failed to write public key to file!');
console.error(err);
}
});
common.mkAndWrite(
privKeyFile, path.dirname(privKeyFile), priv, (err) => {
if (err) {
common.error('Failed to write private key to file!');
console.error(err);
return;
}
fs.chmod(privKeyFile, 0o600, (err) => {
if (err) {
common.logWarning(
'Private key created, but unable to prevent others' +
' from reading the file. Please ensure only the ' +
'current user may read and write to: ' + privKeyFile);
console.error(err);
} else {
common.logDebug('Private key created.');
}
});
});
});
} else {
common.error('Failed to read private key: ' + privKeyFile);
console.error(err);
}
return;
}
if (!data || data.toString().indexOf('PRIVATE KEY') < 0) {
common.error(
'Private key appears to be invalid, or does not conform to ' +
'expected configuration.');
return;
}
this._privKey = data.toString();
});
fs.readFile(pubKeyFile, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
common.logWarning(
'Public key appears to be missing. If you wish to reset the ' +
'signing keys, remove the private key as well: ' + privKeyFile);
} else {
common.error('Failed to read public key: ' + pubKeyFile);
console.error(err);
}
return;
}
if (!data || data.toString().indexOf('PUBLIC KEY') < 0) {
common.error(
'Public key appears to be invalid, or does not conform to ' +
'expected configuration.');
return;
}
this._pubKey = data.toString();
});
}
/**
* @description Update known list of users and their associated keys.
* @private
*/
_updateUsers() {
const file = usersFile;
common.readAndParse(file, (err, users) => {
if (err) {
if (err.code === 'ENOENT') {
this._knownUsers = {};
common.logDebug(`No users to load ${file}`);
this._refreshShardStatus();
} else {
common.error(`Failed to read ${file}`);
console.error(err);
}
return;
}
this._knownUsers = {};
const ids = [];
for (const u in users) {
if (!u || !users[u]) continue;
ids.push(u);
if (!this._usersStatsHistory[u]) this._usersStatsHistory[u] = [];
this._knownUsers[u] = ShardingMaster.ShardInfo.from(users[u]);
}
common.logDebug(`Updated known users from file: ${ids.join(', ')}`);
this._refreshShardStatus();
});
}
/**
* @description Write updated known list of users to disk.
* @private
* @param {boolean} [force=false] Force saving immediately. False otherwise
* waits a moment for multiple calls, then saves once all calls have subsided.
*/
_saveUsers(force = false) {
clearTimeout(this._userSaveTimeout);
if (!force) {
this._userSaveTimeout = setTimeout(() => this._saveUsers(true), 1000);
return;
}
this._usersIgnoreNext = true;
const file = usersFile;
const dir = path.dirname(file);
const serializable = {};
for (const prop in this._knownUsers) {
if (!this._knownUsers[prop]) continue;
serializable[prop] = this._knownUsers[prop].serializable;
}
let str;
try {
str = JSON.stringify(serializable);
} catch (err) {
common.error(`Failed to stringify known users file: ${file}`);
console.error(err);
return;
}
common.mkAndWrite(file, dir, str, (err) => {
if (!err) return;
common.error(`Failed to save known users to file: ${file}`);
console.error(err);
});
}
/**
* @description Read stats history from save file on disk.
* @private
*/
_readStatsHistory() {
const file = historyFile;
common.readAndParse(file, (err, parsed) => {
if (err) {
if (err.code === 'ENOENT') return;
common.logWarning('Failed to read stats history from file: ' + file);
console.error(err);
return;
}
for (const u in parsed) {
if (!parsed[u] || !Array.isArray(parsed[u])) continue;
this._usersStatsHistory[u] = parsed[u];
}
});
}
/**
* @description Write current stats history to file.
* @private
* @param {boolean} [force=false] Force saving immediately. False otherwise
* waits a moment for multiple calls, then saves once all calls have subsided.
*/
_writeStatsHistory(force = false) {
clearTimeout(this._historySaveTimeout);
if (!force) {
this._historySaveTimeout =
setTimeout(() => this._writeStatsHistory(true), 3000);
return;
}
const file = historyFile;
common.mkAndWrite(file, null, this._usersStatsHistory, (err) => {
if (!err) return;
common.error(`Failed to save stats history to file: ${file}`);
console.error(err);
});
}
/**
* @description Update the configuration from file.
* @private
*/
_updateConfig() {
const file = configFile;
delete require.cache[require.resolve(file)];
try {
const ShardMasterConfig = require(file);
this._config = new ShardMasterConfig();
common.logDebug(`Loaded ${file}`);
this._refreshShardStatus();
} catch (err) {
common.error(`Failed to parse ${file}`);
console.error(err);
}
}
/**
* @description Update the authentication tokens from file.
* @private
*/
_updateAuth() {
const file = authFile;
delete require.cache[require.resolve(file)];
try {
this._auth = require(file);
common.logDebug(`Loaded ${file}`);
this._refreshShardStatus();
} catch (err) {
common.error(`Failed to parse ${file}`);
console.error(err);
}
}
/**
* @description The config was changed or may differ from the current setup,
* attempt to make changes to match the current setup the requested
* configuration.
* @private
*/
_refreshShardStatus() {
this._setHBTimeout();
if (!this._knownUsers) return;
const now = Date.now();
if (now < this._nextHeartbeatTime) return;
// Total running shards.
let goal = this.getGoalShardCount();
if (goal < 0) {
clearTimeout(this._hbTimeout);
this._hbTimeout = setTimeout(() => this._refreshShardStatus(), 5000);
common.logDebug('Attempting to fetch shard count again in 5 seconds.');
return;
}
// Currently available shards we can control.
const current = this.getCurrentShardCount();
// Total number of shards that should become available.
const known = this.getKnownShardCount();
// Total number of shards that have ever been connected successfully.
const registered = this.getRegisteredShardCount();
// Array of references to ShardInfo running with same goal count.
const correct = this._getCurrentConfigured(goal);
// Array of references to ShardInfo for each shard not configured correctly.
const incorrect = this._getCurrentUnconfigured(goal);
// The missing shard IDs that need to exist.
const newIds = this._getCurrentUnboundIds(goal);
const master = this._getMasterUser();
// Array of shards transitioning from "incorrect" to "correct".
const configuring = [];
if (goal > known) {
this.generateShardConfig();
}
if (goal > registered) {
goal = registered;
}
if (!master) {
this.generateShardConfig(true);
}
if (goal > current) {
common.logWarning(
`${goal} shards are queued to startup but only ${current}` +
' shards are available!');
}
// const corList =
// correct.map((el) =>
// `${el.id}(${el.goalShardId}/${el.goalShardCount})`)
// .join(', ');
// common.logDebug(`Correct: ${corList}`);
// const incorList =
// incorrect
// .map((el) => `${el.id}(${el.goalShardId}/${el.goalShardCount})`)
// .join(', ');
// common.logDebug(`Incorrect: ${incorList}`);
// const unboundList = newIds.join(', ');
// common.logDebug(`Unbound: ${unboundList}`);
const hbConf = this._config.heartbeat;
const foundIds = [];
correct.sort((a, b) => b.bootTime - a.bootTime);
for (let i = 0; i < correct.length; ++i) {
const el = correct[i];
if (now - el.lastHeartbeat > hbConf.requestRebootAfter &&
now - el.lastHeartbeat < hbConf.expectRebootAfter &&
el.currentShardId >= 0) {
common.logWarning(
'Shard has not responded for too long, enqueuing shutdown: ' +
el.id + ' ' + el.goalShardId);
const s = correct.splice(i, 1)[0];
i--;
configuring.push(s);
s.stopTime = now;
s.goalShardId = -1;
s.goalShardCount = -1;
continue;
}
const match = foundIds.find(
(f) => f.shardId === el.goalShardId && f.count === el.goalShardCount);
if (match) {
common.logWarning(
'Found multiple shards configured the same way! ' +
'Enqueuing shutdown request: ' + el.id + ' ' + el.goalShardId);
const s = correct.splice(i, 1)[0];
i--;
configuring.push(s);
s.stopTime = now;
s.goalShardId = -1;
s.goalShardCount = -1;
} else {
foundIds.push({
shardId: el.goalShardId,
count: el.goalShardCount,
});
if (el.goalShardId != el.currentShardId ||
el.goalShardCount != el.currentShardCount) {
configuring.push(el);
}
}
}
newIds.forEach((el) => {
if (incorrect.length <= 0) return;
const s = incorrect.splice(0, 1)[0];
configuring.push(s);
s.bootTime = now;
s.goalShardId = el;
s.goalShardCount = goal;
});
if (master && master.goalShardId < 0) {
configuring.push(master);
master.goalShardId = 1000; // I think this is big enough for a while.
master.goalShardCount = goal;
}
this._saveUsers();
// Send all shutdown requests asap, as they do not have their own
// timeslot.
for (let i = 0; i < configuring.length; ++i) {
if (configuring[i].goalShardId >= 0) continue;
this._sendHeartbeatRequest(configuring[i]);
configuring.splice(i, 1);
i--;
}
if (now - hbConf.interval > this._heartbeatLoopStartTime) {
this._heartbeatLoopStartTime += hbConf.interval;
}
if (hbConf.updateStyle === 'pull') {
if (hbConf.disperse) {
const updateId = Math.floor(
((now - this._heartbeatLoopStartTime) / hbConf.interval) *
(goal + 1));
const delta = Math.floor(hbConf.interval / (goal + 1));
this._nextHeartbeatTime += delta;
if (updateId >= goal) {
this._sendHeartbeatRequest(master);
} else {
this._sendHeartbeatRequest(updateId);
}
} else {
this._nextHeartbeatTime += hbConf.interval;
Object.keys(this._knownUsers).forEach((el) => {
if (el.goalShardId < 0) return;
this._sendHeartbeatRequest(el);
});
this._sendHeartbeatRequest(this._getMasterUser());
}
} else {
this._nextHeartbeatTime += hbConf.interval;
}
this._setHBTimeout();
}
/**
* @description Reset the heartbeat timeout interval for refreshing shard
* statuses.
* @private
*/
_setHBTimeout() {
if (this._hbTimeout) clearTimeout(this._hbTimeout);
let delta = this._nextHeartbeatTime - Date.now();
if (delta < -1000 || isNaN(delta)) delta = 5000;
this._hbTimeout = setTimeout(() => this._refreshShardStatus(), delta);
}
/**
* @description Fetch the current goal number of shards we are attempting to
* keep alive. Returns -1 if goal has not been set yet, or is otherwise
* unavailable.
* @public
* @returns {number} The number of shards we are attempting to create, -1 if
* the value is otherwise unknown.
*/
getGoalShardCount() {
const now = Date.now();
if (this._config.autoDetectNumShards) {
this._config.numShards = this._detectedNumShards;
if (now - this._config.autoDetectInterval > this._shardNumTimestamp) {
if (!this._auth) return this._config.numShards;
const token = this._auth[this._config.botName];
if (!token) {
if (this._auth) {
common.logWarning(
'Unable to fetch shard count due to no bot token. "' +
this._config.botName + '"');
}
return this._config.numShards;
}
this._shardNumTimestamp = now;
Discord.Util.fetchRecommendedShards(token)
.then((num) => {
const updated = this._detectedNumShards !== num;
if (updated) {
common.logDebug(
'Shard count changed from ' + this._detectedNumShards +
' to ' + num);
this._detectedNumShards = num;
this._refreshShardStatus();
}
})
.catch((err) => {
common.error(
'Unable to fetch shard count due to failed request.');
console.error(err);
});
}
}
return this._config.numShards;
}
/**
* @description Fetches the current number of shards connected to us that we
* are controlling.
* @public
* @returns {number} The current number of shards we are controlling.
*/
getCurrentShardCount() {
if (!this._shardSockets) return 0;
return Object.keys(this._shardSockets).length;
}
/**
* @description Fetches the current number of shards that we know about and
* may talk to us.
* @public
* @returns {number} The number of shards allowed to startup.
*/
getKnownShardCount() {
if (!this._knownUsers) return 0;
return Object.values(this._knownUsers).filter((el) => !el.isMaster).length;
}
/**
* @description Fetches the number of shards that have ever successfully
* connected to us.
* @public
* @returns {number} The number of shards we will probably be startable.
*/
getRegisteredShardCount() {
if (!this._knownUsers) return 0;
return Object.values(this._knownUsers)
.filter((el) => !el.isMaster && el.lastSeen)
.length;
}
/**
* @description Fetches the current number of shards that are configured for
* the goal number of shards.
* @public
* @param {number} [goal] The goal number of shards. If not specified, {@link
* getGoalShardCount} will be used.
* @returns {number} The number of shards configured.
*/
getCurrentConfiguredCount(goal) {
return this._getCurrentConfigured(goal).length;
}
/**
* @description Get references to all shards currently configured for the
* given goal number of shards, and are connected and controllable.
* @private
* @param {number} [goal] The goal number of shards. If not specified, {@link
* getGoalShardCount} will be used.
* @returns {ShardInfo[]} Array of the shards that are configured correctly
* and don't need updating.
*/
_getCurrentConfigured(goal) {
if (!this._knownUsers) return [];
if (typeof goal !== 'number' || goal < 0) {
goal = this.getGoalShardCount();
}
const now = Date.now();
return Object.values(this._knownUsers)
.filter(
(el) => !el.isMaster && el.goalShardCount === goal &&
el.lastHeartbeat >
now - this._config.heartbeat.assumeDeadAfter);
}
/**
* @description Get references to all shards currently NOT configured for the
* given goal number of shards, but ARE connected and controllable.
* @private
* @param {number} [goal] The goal number of shards. If not specified, {@link
* getGoalShardCount} will be used.
* @returns {ShardInfo[]} Array of the shards that are NOT configured
* correctly and DO need updating.
*/
_getCurrentUnconfigured(goal) {
if (!this._knownUsers) return [];
if (typeof goal !== 'number' || goal < 0) {
goal = this.getGoalShardCount();
}
const now = Date.now();
return Object.values(this._knownUsers)
.filter(
(el) => !el.isMaster && el.goalShardCount !== goal &&
el.lastSeen > now - this._config.heartbeat.assumeDeadAfter);
}
/**
* @description Get list of IDs for shards that do not exist, but need to.
* @private
* @param {number} [goal] The goal number of shards. If not specified, {@link
* getGoalShardCount} will be used.
* @returns {number[]} Array of the shard IDs that need to be configured.
*/
_getCurrentUnboundIds(goal) {
if (!this._knownUsers) return [];
if (typeof goal !== 'number' || goal < 0) {
goal = this.getGoalShardCount();
}
if (goal < 0) return [];
const ids = [...Array(goal).keys()];
Object.values(this._knownUsers).forEach((el) => {
if (el.isMaster) return;
if (el.goalShardCount === goal) {
const index = ids.indexOf(el.goalShardId);
if (index >= 0) {
ids.splice(index, 1);
} else {
common.error(
'Failed to remove shard ID from list. Are multiple shards using' +
' the same ID? (' + el.goalShardId + ')');
}
}
});
return ids;
}
/**
* @description Get a reference to a ShardInfo object using its shard ID.
* @private
* @param {number} shardId The shard ID used by Discord.
* @returns {?ShardInfo} The matched shard info, or null if it couldn't be
* found.
*/
_getShardById(shardId) {
if (!this._knownUsers) return null;
return Object.values(this._knownUsers)
.find((el) => !el.isMaster && el.goalShardId === shardId);
}
/**
* @description Get the master shard from the known users. Assumes only one
* master exists.
* @private
* @returns {?ShardInfo} The matched shard info, or null if it couldn't be
* found.
*/
_getMasterUser() {
if (!this._knownUsers) return null;
return Object.values(this._knownUsers).find((el) => el.isMaster);
}
/**
* Handler for all http requests. This may get used in the future for API
* requests. Currently unused and always replies with a "501 Not Implemented"
* response.
*
* @private
* @param {http.IncomingMessage} req The client's request.
* @param {http.ServerResponse} res Our response to the client.
*/
_handler(req, res) {
const regex = '^\\/(?<domain>www\\.spikeybot\\.com)' +
'(?<path>\\/(?:dev\\/)?[^#?]*)' +
'[^#?]*(?<query>\\?[^#]*)?' +
'(?<hash>#.*)?$';
const match = req.url.match(regex);
const path =
match && match.groups && match.groups.path.replace(/^\/dev/, '');
const queries = req.headers.queries &&
Object.fromEntries(
req.headers.queries.split('&').map((el) => el.split('='))) ||
{};
const now = Date.now();
if (req.method === 'GET' && path === '/api/public/shard-status-history') {
console.log(queries);
const obj = {ids: Object.keys(this._usersStatsHistory)};
if (queries.id) {
queries.id.split(',').forEach((el) => {
if (!this._usersStatsHistory[el]) return;
if (queries.since) {
obj[el] = this._usersStatsHistory[el].filter(
(row) => row.timestamp > queries.since);
} else {
obj[el] = this._usersStatsHistory[el].filter(
(row) => row.timestamp > now - 24 * 60 * 60 * 1000);
}
});
}
const stringified = JSON.stringify(obj);
let comp = '';
const finalSend = function(err, buffer) {
if (err) {
common.error(`Failed to prepare (${encoding}): shard-status-history`);
console.error(err);
res.statusCode = 500;
res.end('500: Internal Server Error.');
} else {
if (comp) res.setHeader('Content-Encoding', comp);
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
res.setHeader('Content-Length', buffer.byteLength);
res.setHeader('content-type', 'application/json');
res.writeHead(200);
res.end(buffer);
}
};
const encoding = req.headers['accept-encoding'] || '';
if (/\bgzip\b/.test(encoding)) {
comp = 'gzip';
zlib.gzip(stringified, finalSend);
} else if (/\bdeflate\b/.test(encoding)) {
comp = 'deflate';
zlib.deflate(stringified, finalSend);
} else {
finalSend(null, stringified);
}
} else {
res.writeHead(404);
res.end(req.method === 'GET' ? '404: Not Found' : undefined);
}
}
/**
* Returns the number of connected clients.
*
* @public
* @returns {number} Number of sockets.
*/
getNumClients() {
return Object.keys(this._sockets).length;
}
/**
* Handler for a new socket connecting.
*
* @private
* @this {ShardingMaster}
* @param {socketIo~Socket} socket The socket.io socket that connected.
*/
_socketConnection(socket) {
const now = Date.now();
// x-forwarded-for is trusted because the last process this jumps through is
// our local proxy.
const ipName = common.getIPName(
socket.handshake.headers['x-forwarded-for'] ||
socket.handshake.address);
this._ipConnectHistory.push([ipName, now]);
let remove = 0;
let ipCount = 0;
let i = 0;
while (ipCount < this._config.connCount &&
i < this._ipConnectHistory.length) {
if (now - this._ipConnectHistory[i][1] >= this._config.connTime) {
remove = i + 1;
} else if (ipName === this._ipConnectHistory[i][0]) {
ipCount++;
}
++i;
}
if (remove > 0) this._ipConnectHistory.splice(0, remove);
if (ipCount >= this._config.connCount) {
socket.disconnect(true);
return;
}
common.log(
`Socket connected (${this.getNumClients()}): ${ipName.trim()} (#${
ipCount}/${this._config.connCount})`,
socket.id);
this._sockets[socket.id] = socket;
socket.on('disconnect', (reason) => {
const num = this.getNumClients() - 1;
const id = socket.userId || userId;
common.log(
`Socket disconnected (${num})(${reason})(${id}): ${ipName.trim()}`,
socket.id);
delete this._sockets[socket.id];
if (id && this._shardSockets[id] &&
this._shardSockets[id].id == socket.id) {
delete this._shardSockets[id];
}
if (socket.isMasterShard) this._masterShardSocket = null;
});
const shardAuth = socket.handshake.headers['authorization'];
if (!shardAuth) {
common.logDebug(
'Socket attempted connection without authorization header.',
socket.id);
socket.disconnect(true);
return;
}
const [userId, userSig, timestamp] = shardAuth.split(',');
if (!userId || !userSig || !(timestamp * 1)) {
common.logDebug(
'Socket attempted connection with invalid authorization header.',
socket.id);
socket.disconnect(true);
return;
}
if (!this._knownUsers) {
common.logDebug(
'Socket attempted connection before known users were loaded.',
socket.id);
socket.disconnect(true);
return;
}
if (!this._knownUsers[userId]) {
common.logDebug(
'Socket attempted connection with invalid user ID: ' + userId,
socket.id);
socket.disconnect(true);
return;
}
if (this._shardSockets[userId]) {
common.logWarning(
'Socket attempted connection ID of shard that is already connected!',
socket.id);
// This no longer focibly disconnects because in some cases the client
// would attempt to reconnect before the server had identified that the
// connection was lost. In these cases we want the new connection to
// replace the old.
//
// socket.disconnect(true);
// return;
}
if (Math.abs(timestamp - now) > this._config.tsPrecision) {
common.logDebug(
'Socket attempted connection with invalid timestamp header.',
socket.id);
socket.disconnect(true);
return;
}
if (!this._privKey) {
common.logDebug(
'Socket attempted connection prior to us loading private key.',
socket.id);
socket.disconnect(true);
return;
}
const user = this._knownUsers[userId];
if (!user.key) {
common.logWarning('User does not have a known public key! ' + userId);
console.log(user);
socket.disconnect(true);
return;
}
const verifData = `${userId}${timestamp}`;
const verify = crypto.createVerify(signAlgorithm);
verify.update(verifData);
verify.end();
let ver = false;
try {
ver = verify.verify(user.key, userSig, 'base64');
} catch (err) {
common.error('Failed to attempt to verify signature.');
console.error(verifData, user.key, userSig);
}
if (!ver) {
common.logDebug(
'Socket attempted connection but we failed to verify signature.',
socket.id);
socket.disconnect(true);
return;
}
user.lastSeen = now;
user.lastHeartbeat = user.lastSeen;
socket.userId = userId;
if (user.isMaster && this._masterShardSocket) {
common.logWarning(
'Socket attempted connection as a master shard while another master' +
' is already connected!',
socket.id);
socket.disconnect(true);
return;
}
const sign = crypto.createSign(signAlgorithm);
const signData = `Look at me, I'm the captain now. ${now}`;
sign.update(signData);
sign.end();
const masterSig = sign.sign(this._privKey, 'base64');
socket.emit('masterVerification', masterSig, signData);
if (user.isMaster) {
socket.isMasterShard = true;
this._masterShardSocket = socket;
} else {
socket.isMasterShard = false;
this._shardSockets[userId] = socket;
}
this._refreshShardStatus();
socket.on('status', (status) => {
const now = Date.now();
user.lastSeen = now;
if (userId === this._hbWaitID) this._hbWaitID = null;
try {
// console.log(userId, ':', status);
status = ShardingMaster.ShardStatus.from(status);
} catch (err) {
common.error(
`Failed to parse shard status from shard ${userId}`, socket.id);
console.error(err);
return;
}
if (!user.stats) user.stats = new ShardingMaster.ShardStatus(userId);
if (status.id && status.id !== userId) {
common.logWarning(
'User changed ID after handshake! This will be ignored, but ' +
'should not happen! ' + userId + ' --> ' + status.id);
}
user.stats.update(status);
if (user.stats.goalShardId != user.goalShardId) {
common.logWarning(
'Shard goal state is incorrect! ' + user.id + ' Received: ' +
user.stats.goalShardId + ', expected: ' + user.goalShardId);
}
if (this._config.heartbeat.useMessageStats) {
if (user.stats.messageCountDelta > 0) {
user.lastHeartbeat = user.lastSeen;
}
} else {
user.lastHeartbeat = user.lastSeen;
}
// const d = user.lastSeen - user.lastHeartbeat;
// common.logDebug(
// `${userId} updated status. (${status.messageCountDelta}, ${d})`,
// socket.id);
// common.logDebug(`${userId} updated status. (${JSON.stringify(user)})`);
user.currentShardId = user.stats.currentShardId;
user.currentShardCount = user.stats.currentShardCount;
if (this._config.heartbeat.updateStyle === 'push') {
this._sendHeartbeatRequest(user);
}
this._usersStatsHistory[userId].push(user.stats);
while (now - this._usersStatsHistory[userId][0].timestamp >
this._config.heartbeat.historyLength) {
this._usersStatsHistory[userId].splice(0, 1);
}
this._saveUsers();
this._writeStatsHistory();
this._refreshShardStatus();
});
socket.on('broadcastEval', (...args) => {
user.lastSeen = Date.now();
this.broadcastEvalToShards(...args);
});
socket.on('respawnAll', (...args) => {
user.lastSeen = Date.now();
this.respawnAll(...args);
});
socket.on('sendSQL', (...args) => {
user.lastSeen = Date.now();
this.sendSQL(...args);
});
socket.on('reboot', (...args) => {
user.lastSeen = Date.now();
this.rebootRequest(...args);
});
socket.on('writeFile', (...args) => {
user.lastSeen = Date.now();
this.writeFile(...args);
});
socket.on('getFile', (...args) => {
user.lastSeen = Date.now();
this._sendSlaveFile(socket, ...args);
});
if (this._config.copyFiles) {
this._config.copyFiles.forEach((el) => {
fs.readFile(el, (err, data) => {
if (err) {
common.error(`Failed to read file to send to slaves: ${el}`);
console.error(err);
return;
}
socket.emit('writeFile', el, data, () => {});
});
});
}
}
/**
* @description Broadcast a message to all connected shards, except for the
* master.
* @public
* @param {string} script Text for each shard to evaluate.
* @param {Function} cb Callback once all shards have replied. First argument
* is optional error message, second will be an array of responses, indexed by
* shard ID.
*/
broadcastEvalToShards(script, cb) {
const sockets = Object.entries(this._shardSockets);
const registered = this.getRegisteredShardCount();
const goal = this.getGoalShardCount();
const num = Math.min(registered, goal);
if (num <= 0) {
cb('No Shards');
return;
}
const out = new Array(num);
let numDone = 0;
let numSent = 0;
let err = null;
const timeout = setTimeout(() => {
err = 'Master timeout exceeded';
done();
}, 15000);
/**
* @description Fired for each completed response from a shard. Fires the
* callback once all responses have been received, or an error has been
* raised.
* @private
*/
function done() {
/* if (err && numDone <= num) {
clearTimeout(timeout);
cb(err);
numDone = num + 1;
} else if (numDone == numSent) {
clearTimeout(timeout);
cb(null, out);
} */
if (numDone == numSent) {
if (err) {
common.error(`BROADCAST EVAL FAILED: ${script}`);
console.error(err, out);
}
clearTimeout(timeout);
cb(err, out);
}
}
let dbg = '';
sockets.forEach((ent) => {
const user = this._knownUsers[ent[0]];
const id = user.goalShardId;
if (id < 0 || user.isMaster || !user.lastSeen) {
dbg += `${id}${ent[0]}: NO, `;
done();
return;
}
dbg += `${id}${ent[0]}: YES, `;
numSent++;
ent[1].emit('evalRequest', script, (error, res) => {
numDone++;
if (error) {
err = this._makeSerializableError(error);
}
out[id] = res;
done();
});
});
if (numSent != num) {
err = `Not all shards connected to master. (${numSent}/${num})`;
done();
console.log(dbg);
}
if (script.startsWith('this.runBotUpdate(')) this._runBotUpdate();
}
/**
* @description Fetch the latest bot version from GitHub.
* @private
*/
_runBotUpdate() {
common.log(
`Triggered update: ${__dirname} <-- DIR | CWD -->${process.cwd()}`);
require('child_process').exec('npm run update', (err, stdout, stderr) => {
if (!err) {
if (stdout && stdout !== 'null') console.log('STDOUT:', stdout);
if (stderr && stderr !== 'null') console.error('STDERR:', stderr);
} else {
common.error('Failed to pull latest update.');
console.error(err);
if (stdout && stdout !== 'null') console.log('STDOUT:', stdout);
if (stderr && stderr !== 'null') console.error('STDERR:', stderr);
}
});
}
/**
* @description Kills all running shards and respawns them.
* @param {Function} [cb] Callback once all shards have been rebooted or an
* error has occurred.
*/
respawnAll(cb) {
const list = Object.values(this._knownUsers);
let i = 0;
list.forEach((info) => {
if (info.goalShardId < 0 || info.goalShardId !== info.currentShardId) {
return;
}
const socket = this._shardSockets[info.id];
if (socket) {
socket.emit('respawn', i * this._config.respawnDelay);
} else {
common.logWarning(
'Unable to respawn shard #' + info.goalShardId + ' ' + info.id +
' due to socket disconnect.');
// TODO: Resend this request once reconnected instead of failing.
}
i++;
});
cb();
}
/**
* @description Send an SQL query to our database.
* @public
* @param {string} query The query to send to database.
* @param {Function} cb First argument is optional error, otherwise query
* response.
*/
sendSQL(query, cb) {
if (!global.sqlCon) {
cb('No Database Connection');
return;
}
global.sqlCon.query(query, (err, ...args) => {
if (err &&
(err.fatal || err.code === 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR')) {
common.connectSQL(true);
}
cb(this._makeSerializableError(err), ...args);
});
}
/**
* @description Send the specified file to the ShardingSlave.
*
* @private
* @param {Socket|number|ShardingMaster.ShardInfo} socket Socket.io socket to
* send file to, or shard info to find socket from.
* @param {string|object.<string>} req Filename relative to project directory,
* or object with both filename and last modified time.
* @param {Function} cb Callback with optional error argument.
*/
_sendSlaveFile(socket, req, cb) {
if (typeof socket === 'number' && !isNaN(socket)) {
const shardInfo = this._getShardById(socket);
if (!shardInfo || !shardInfo.id) {
cb('Unknown Destination');
return;
}
socket = shardInfo.isMaster ? this._masterShardSocket :
this._shardSockets[shardInfo.id];
} else if (!socket.emit) {
socket = socket.isMaster ? this._masterShardSocket :
this._shardSockets[socket.id];
}
if (!socket || typeof socket.emit !== 'function') {
cb('Unknown Destination');
return;
}
const filename = req.filename || req;
const file = path.resolve(`${botCWD}/${filename}`);
if (typeof filename != 'string' || !file.startsWith(botCWD)) {
common.error(
`Attempted to send file outside of project directory: ${file}`);
cb('File path unacceptable');
return;
}
fs.stat(filename, (err, stats) => {
// Send original filename, as ShardingSlave expects the same format.
const res = {filename: filename, mtime: 1};
if (err) {
if (err.code === 'ENOENT') {
socket.emit('writeFile', res, null, (err) => {
if (err) {
common.error(`Failed to unlink file on slave: ${file}`);
console.error(err);
cb('Failed to write');
} else {
cb(null);
}
});
} else {
common.error(`Failed to stat file for slave: ${file}`);
console.error(err);
cb('Failed to stat');
}
return;
}
const mtime = stats.mtime.getTime();
res.mtime = mtime;
if (req.mtime && mtime < req.mtime) {
socket.emit('writeFile', res, null, (err) => {
if (err) {
common.error(`Failed to process read ignore on slave: ${file}`);
console.error(err);
cb('Failed to skip write on slave');
} else {
cb(null);
}
});
return;
}
fs.readFile(filename, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
socket.emit('writeFile', res, null, (err) => {
if (err) {
common.error(`Failed to unlink file on slave: ${file}`);
console.error(err);
cb('Failed to write');
} else {
cb(null);
}
});
} else {
common.error(`Failed to read file for slave: ${file}`);
console.error(err);
cb('Failed to read');
}
} else {
socket.emit('writeFile', res, data, (err) => {
if (err) {
common.error(`Failed to write file on slave: ${file}`);
console.error(err);
cb('Failed to write');
} else {
cb(null);
}
});
}
});
});
}
/**
* @description Write a given file to disk, or unlink.
* @public
* @param {string} filename The name of the file to update, relative to
* project directory.
* @param {?*} data The data to write to file, or null to unlink the file.
* @param {Function} cb First argument is optional error.
*/
writeFile(filename, data, cb) {
const file = path.resolve(`${botCWD}/${filename}`);
if (typeof filename != 'string' || !file.startsWith(botCWD)) {
this.error(
`Attempted to write file outside of project directory: ${file}`);
cb('File path unacceptable');
return;
}
if (data === null) {
common.unlink(file, (err) => {
if (err) {
common.error(`Failed to write file to disk: ${file}`);
console.error(err);
cb('IO Failed');
} else {
// common.logDebug(`Wrote file to disk: ${file}`);
cb(null);
}
});
} else {
common.mkAndWrite(file, null, data, (err) => {
if (err) {
common.error(`Failed to write file to disk: ${file}`);
console.error(err);
cb('IO Failed');
} else {
// common.logDebug(`Wrote file to disk: ${file}`);
cb(null);
}
});
}
}
/**
* @description A reboot of some form has been requested. Parse, and comply.
* @public
* @param {string} msg The reboot command.
*/
rebootRequest(msg) {
const list = Object.values(this._knownUsers);
if (msg === 'reboot master') {
common.logWarning('Rebooting ShardingMaster...');
process.exit(-1);
} else if (msg === 'reboot hard' || msg === 'reboot hard force') {
common.logWarning('TRIGGERED HARD REBOOT!');
list.forEach((s) => {
if (s.goalShardId === -2) return;
const g = s.goalShardId;
s.goalShardId = -2;
this._sendHeartbeatRequest(s);
s.goalShardId = g;
});
process.exit(-1);
} else if (typeof msg === 'string' && msg.startsWith('reboot')) {
const idList = msg.match(/\b\d+\b/g);
const force = msg.indexOf('force') > -1;
const strList = (idList || ['all']).join(',');
common.log(`Rebooting shards: ${strList}${force?' force':''}`);
list.forEach((s) => {
if (s.goalShardId < 0) return;
if (!idList || idList.includes(`${s.goalShardId}`)) {
const g = s.goalShardId;
s.goalShardId = force ? -2 : -1;
this._sendHeartbeatRequest(s);
s.goalShardId = g;
}
});
} else {
common.error('Malformed reboot request received!');
console.log(msg);
}
}
/**
* @description Generates a key pair using configured settings for either the
* master or its shards. The settings used cannot differ between any parties
* involved, otherwise all keys must be changed.
*
* {@link
* https://nodejs.org/api/crypto.html#crypto_crypto_generatekeypair_type_options_callback}.
*
* @public
* @static
* @param {Function} cb Callback with optional error, then public key, then
* private key.
*/
static generateKeyPair(cb) {
crypto.generateKeyPair(keyType, keyOptions, cb);
}
/**
* @description Generate a unique name for a new shard.
* @private
* @returns {string} New unique shard name.
*/
_generateShardName() {
const alp = 'abcdefghijklmnopqrstuvwxyz';
const len = 3;
let out;
do {
out = '';
for (let i = 0; i < len; ++i) {
out += alp[Math.floor(Math.random() * alp.length)];
}
} while (this._knownUsers[out]);
return out;
}
/**
* @description Creates a configuration for a new shard. This config contains
* the shard's key-pair, name, and the host information to find this
* master. This file will be saved directly to disk, as well as emailed to
* sysadmins.
* @public
* @param {boolean} [isMaster=false] Is this config for a master shard.
*/
generateShardConfig(isMaster = false) {
const made = new ShardingMaster.ShardInfo(this._generateShardName());
this._knownUsers[made.id] = made;
if (isMaster) made.isMaster = true;
ShardingMaster.generateKeyPair((err, pub, priv) => {
if (err) {
delete this._knownUsers[made.id];
common.error('Failed to generate key-pair!');
console.error(err);
ShardingMaster._sendShardCreateFailEmail(
this._config.mail, 'Failed to generate key-pair!', err);
return;
}
made.key = pub;
const config = {
host: (isMaster && this._config.masterHost) || this._config.remoteHost,
pubKey: pub,
privKey: priv,
masterPubKey: this._pubKey,
id: made.id,
signAlgorithm: signAlgorithm,
};
const dir = shardDir;
const file = `${shardDir}/shard_${made.id}_config.json`;
const str = JSON.stringify(config, null, 2);
common.mkAndWrite(file, dir, str, (err) => {
if (err) {
common.error('Failed to save new shard config to disk: ' + file);
console.error(err);
delete this._knownUsers[made.id];
ShardingMaster._sendShardCreateFailEmail(
this._config.mail,
'Failed to save new shard config to disk: ' + file, err);
} else {
ShardingMaster._sendShardCreateEmail(this._config.mail, file, made);
this._saveUsers();
}
});
});
}
/**
* @description Send the newly created config to sysadmins.
* @private
* @static
* @param {ShardingMaster.ShardMasterConfig.MailConfig} conf Mail config
* object.
* @param {string} filename The path to the file of the shard's configuration
* to send to sysadmins.
* @param {ShardingMaster.ShardInfo} [info] Shard info of created shard for
* additional information to sysadmins.
*/
static _sendShardCreateEmail(conf, filename, info) {
if (!conf.enabled) return;
const str = conf.shardConfigCreateMessage.replace(/{(\w+)}/g, (m, one) => {
switch (one) {
case 'date':
return new Date().toString();
case 'id':
return info && info.id || one;
case 'pubkey':
return info && info.key || one;
default:
return one;
}
});
const args = conf.args.map((el) => {
if (el === '%ATTACHMENT%') return filename;
return el;
});
ShardingMaster._sendEmail(conf.command, args, conf.spawnOpts, str);
}
/**
* @description Send an email to sysadmins alerting them that a new shard must
* be created, but we were unable to for some reason.
* @private
* @static
* @param {ShardingMaster.ShardMasterConfig.MailConfig} conf Mail config
* object.
* @param {...Error|string} [errs] The error generated with information as to
* why creation failed.
*/
static _sendShardCreateFailEmail(conf, ...errs) {
if (!conf.enabled) return;
const str = conf.shardConfigCreateFailMessage.replace(/{\w+}/g, (m) => {
switch (m) {
case 'date':
return new Date().toString();
case 'errors':
return errs.map((err) => {
if (err instanceof Error) {
return `${err.code}: ${err.message}\n${err.stack}`;
} else if (typeof err === 'string') {
return err;
} else {
return JSON.stringify(err, null, 2);
}
}).join('\n\n===== ERROR SPLIT =====\n\n');
default:
return m;
}
});
ShardingMaster._sendEmail(conf.command, conf.failArgs, conf.spawnOpts, str);
}
/**
* @description Sends an email given the raw inputs.
* @private
* @static
* @param {string} cmd The command to run to start the mail process.
* @param {string[]} args The arguments to pass.
* @param {object} opts Options to pass to spawn.
* @param {string} stdin The data to write to stdin of the process.
*/
static _sendEmail(cmd, args, opts, stdin) {
const proc = spawn(cmd, args, opts);
proc.on('error', (err) => {
common.error('Failed to spawn mail process.');
console.error(err);
});
proc.stderr.on('data', (data) => {
common.logWarning(`Mail: ${data.toString()}`);
});
proc.stdout.on('data', (data) => {
common.logDebug(`Mail: ${data.toString()}`);
});
proc.on('close', (code) => {
common.logDebug('Send email. Exited with ' + code);
});
if (stdin && stdin.length > 0) {
proc.stdin.write(stdin);
proc.stdin.end();
}
}
/**
* @description Format and send the request for a shard heartbeat.
* @private
* @param {number|ShardingMaster.ShardInfo} user Either the goal shard ID, or
* the shard user information.
*/
_sendHeartbeatRequest(user) {
let updating;
if (typeof user === 'number' && !isNaN(user)) {
updating = this._getShardById(user);
} else {
updating = user;
}
if (!updating || !updating.id) {
const num = typeof user === 'number' ? user : (user && user.goalShardId);
common.error('Unable to find user for HB with goal shard ID: ' + num);
return;
}
const socket = updating.isMaster ? this._masterShardSocket :
this._shardSockets[updating.id];
if (!socket) {
common.logWarning(
'Failed to send request for heartbeat to shard ' + updating.id +
(updating.isMaster ? ' MASTER' : ''));
} else {
const req = updating.request();
req.config = {
botName: this._config.botName,
nodeArgs: this._config.shardNodeLaunchArgs,
botArgs: this._config.shardBotLaunchArgs,
heartbeat: {
updateStyle: this._config.heartbeat.updateStyle,
interval: this._config.heartbeat.interval,
expectRebootAfter: this._config.heartbeat.expectRebootAfter,
assumeDeadAfter: this._config.heartbeat.assumeDeadAfter,
useMessageStats: this._config.heartbeat.useMessageStats,
},
};
socket.emit('update', req);
// common.logDebug('Sent heartbeat request to ' + updating.id);
if (this._hbWaitID) {
common.logWarning(
`${this._hbWaitID} did not reply to heartbeat request!`);
}
this._hbWaitID = updating.id;
}
}
/**
* @description We received a file from a shard that it intends for us to
* write to disk at the given filename relative to the project root.
*
* @private
* @param {string} filename Filename relative to project directory.
* @param {string|Buffer} data The data to write to the file.
*/
_receiveFile(filename, data) {
const file = path.resolve(`${botCWD}${filename}`);
if (!file.startsWith(botCWD)) {
this.logWarning('Shard sent file outside of project directory: ' + file);
return;
}
common.mkAndWrite(file, null, data, (err) => {
if (err) {
this.error(`Failed to write file from shard to disk: ${file}`);
console.error(err);
} else {
this.logDebug(`Wrote file from shard to disk: ${file}`);
}
});
}
/**
* @description Shard has requested one of our files.
*
* @private
* @param {string} filename Filename relative to project directory.
* @param {Function} cb Callback to send the error or file back on.
*/
_sendFile(filename, cb) {
const file = path.resolve(`${botCWD}${filename}`);
if (!file.startsWith(botCWD)) {
this.logWarning(
'Shard requested file outside of project directory: ' + file);
cb(null);
return;
}
fs.readFile(file, (err, data) => {
if (err) {
this.error('Failed to read file that shard requested disk: ' + file);
console.error(err);
cb(null);
} else {
this.logDebug('Sending file to shard from disk: ' + file);
cb(data);
}
});
}
/**
* @description Convert an Error object into a serializable object format that
* can be send easily over a socket.
* @private
* @param {?Error|string} err The error to make into a basic object. If
* falsey, returns null. If string, the string is returned unmodified.
* @returns {?object|string} A simple object or null if falsey value passed or
* string if string was passed.
*/
_makeSerializableError(err) {
if (!err) return null;
if (typeof err === 'string') return err;
return {
message: err.message,
name: err.name,
fileName: err.fileName,
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
stack: err.stack,
};
}
/**
* @description Server has encountered an error, and must close.
* @private
* @this {ShardingMaster}
* @param {Error} err The emitted error event.
*/
_serverError(err) {
if (this._io) this._io.close();
if (this._app) this._app.close();
if (err.code === 'EADDRINUSE') {
common.error(
'ShardMaster failed to bind to port because it is in use. (' +
err.port + ')');
} else {
common.error('ShardMaster failed for unknown reason.');
console.error(err);
}
process.exit(1);
}
/**
* @description Cleanup and fully shutdown gracefully.
* @public
*/
exit() {
if (this._hbTimeout) clearTimeout(this._hbTimeout);
if (this._io) this._io.close();
if (this._app) this._app.close();
fs.unwatchFile(usersFile);
fs.unwatchFile(configFile);
fs.unwatchFile(authFile);
process.exit(0);
}
}
try {
ShardingMaster.ShardMasterConfig = require(configFile);
} catch (err) {
// Meh.
}
ShardingMaster.ShardStatus = require('./ShardStatus.js');
ShardingMaster.ShardInfo = require('./ShardInfo.js');
if (require.main === module) {
console.log('Started via CLI, booting up...');
const manager =
new ShardingMaster(process.argv[2], process.argv[3], process.argv[4]);
process.on('SIGINT', manager.exit);
process.on('SIGTERM', manager.exit);
}
module.exports = ShardingMaster;