mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Implement a new plugin system based subclassing pre-defined base clases and communicating with pre-defined link messages.
868 lines
26 KiB
JavaScript
868 lines
26 KiB
JavaScript
const fs = require('fs-extra');
|
|
const path = require('path');
|
|
const yargs = require("yargs");
|
|
const version = require("./package").version;
|
|
|
|
// internal libraries
|
|
const fileOps = require("lib/fileOps");
|
|
const hashFile = require('lib/hash').hashFile;
|
|
const factorio = require("lib/factorio");
|
|
const link = require("lib/link");
|
|
const plugin = require("lib/plugin");
|
|
const errors = require("lib/errors");
|
|
|
|
|
|
/**
|
|
* Keeps track of the runtime parameters of an instance
|
|
*/
|
|
class Instance extends link.Link{
|
|
constructor(connector, dir, factorioDir, instanceConfig) {
|
|
super('instance', 'slave', connector);
|
|
link.attachAllMessages(this);
|
|
this._dir = dir;
|
|
|
|
this.plugins = new Map();
|
|
|
|
// This is expected to change with the config system rewrite
|
|
this.config = {
|
|
id: instanceConfig.id,
|
|
name: instanceConfig.name,
|
|
gamePort: instanceConfig.factorioPort,
|
|
rconPort: instanceConfig.clientPort,
|
|
rconPassword: instanceConfig.clientPassword,
|
|
}
|
|
|
|
let serverOptions = {
|
|
gamePort: this.config.gamePort,
|
|
rconPort: this.config.rconPort,
|
|
rconPassword: this.config.rconPassword,
|
|
};
|
|
|
|
this.server = new factorio.FactorioServer(
|
|
path.join(factorioDir, "data"), this._dir, serverOptions
|
|
);
|
|
|
|
this.server.on('output', (output) => {
|
|
link.messages.instanceOutput.send(this, { instance_id: this.config.id, output })
|
|
|
|
for (let [name, plugin] of this.plugins) {
|
|
plugin.onOutput(output).catch(err => {
|
|
console.error(`Plugin ${name} raised error in onOutput:`, err);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async init(pluginInfos) {
|
|
await this.server.init();
|
|
|
|
// load plugins
|
|
for (let pluginInfo of pluginInfos) {
|
|
if (!pluginInfo.enabled || !pluginInfo.instanceEntrypoint) {
|
|
continue;
|
|
}
|
|
|
|
// require plugin class and initialize it
|
|
let pluginLoadStarted = Date.now();
|
|
let { InstancePlugin } = require(`./plugins/${pluginInfo.name}/${pluginInfo.instanceEntrypoint}`);
|
|
let instancePlugin = new InstancePlugin(pluginInfo, this);
|
|
await instancePlugin.init();
|
|
this.plugins.set(pluginInfo.name, instancePlugin);
|
|
plugin.attachPluginMessages(this, pluginInfo, instancePlugin);
|
|
|
|
console.log(`Clusterio | Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new empty instance directory
|
|
*
|
|
* Creates the neccessary files for starting up a new instance into the
|
|
* provided instance directory.
|
|
*
|
|
* @param {Number} id -
|
|
* ID of the new instance. Must be unique to the cluster.
|
|
* @param {String} instanceDir -
|
|
* Directory to create the new instance into.
|
|
* @param {String} factorioDir - Path to factorio installation.
|
|
* @param {Object} options - Options for new instance.
|
|
*/
|
|
static async create(id, instanceDir, factorioDir, options) {
|
|
let instanceConfig = {
|
|
id,
|
|
name: options.name,
|
|
factorioPort: null,
|
|
clientPort: null,
|
|
clientPassword: null,
|
|
}
|
|
|
|
console.log(`Clusterio | Creating ${instanceDir}`);
|
|
await fs.ensureDir(instanceDir);
|
|
await fs.ensureDir(path.join(instanceDir, "script-output"));
|
|
await fs.ensureDir(path.join(instanceDir, "saves"));
|
|
|
|
// save instance config
|
|
await fs.outputFile(path.join(instanceDir, "config.json"), JSON.stringify(instanceConfig, null, 4));
|
|
|
|
let serverSettings = await factorio.FactorioServer.exampleSettings(path.join(factorioDir, "data"));
|
|
let gameName = "Clusterio instance: " + options.name;
|
|
if (options.username) {
|
|
gameName = options.username + "'s clusterio " + options.name;
|
|
}
|
|
|
|
let overrides = {
|
|
"name": gameName,
|
|
"description": options.description,
|
|
"tags": ["clusterio"],
|
|
"visibility": options.visibility,
|
|
"username": options.username,
|
|
"token": options.token,
|
|
"game_password": options.game_password,
|
|
"require_user_verification": options.verify_user_identity,
|
|
"allow_commands": options.allow_commands,
|
|
"auto_pause": options.auto_pause,
|
|
};
|
|
|
|
for (let [key, value] of Object.entries(overrides)) {
|
|
if (!Object.hasOwnProperty.call(serverSettings, key)) {
|
|
throw Error(`Expected server settings to have a ${key} property`);
|
|
}
|
|
serverSettings[key] = value;
|
|
}
|
|
|
|
await fs.writeFile(
|
|
path.join(instanceDir, "server-settings.json"),
|
|
JSON.stringify(serverSettings, null, 4)
|
|
);
|
|
}
|
|
|
|
async start(saveName, slaveConfig, socket) {
|
|
console.log("Clusterio | Rotating old logs...");
|
|
// clean old log file to avoid crash
|
|
try{
|
|
let logPath = this.path("factorio-current.log");
|
|
let stat = await fs.stat(logPath);
|
|
if(stat.isFile()){
|
|
let logFilename = `factorio-${Math.floor(Date.parse(stat.mtime)/1000)}.log`;
|
|
await fs.rename(logPath, this.path(logFilename));
|
|
console.log(`Log rotated as ${logFilename}`);
|
|
}
|
|
}catch(e){}
|
|
|
|
await symlinkMods(this, "sharedMods", console);
|
|
|
|
// Patch save with lua modules from plugins
|
|
console.log("Clusterio | Patching save");
|
|
|
|
// For now it's assumed that all files in the lua folder of a plugin is
|
|
// to be patched in under the name of the plugin and loaded for all
|
|
// plugins that are not disabled. This will most likely change in the
|
|
// future when the plugin refactor is done.
|
|
let modules = [];
|
|
for (let pluginName of await fs.readdir("plugins")) {
|
|
let pluginDir = path.join("plugins", pluginName);
|
|
if (await fs.pathExists(path.join(pluginDir, "DISABLED"))) {
|
|
continue;
|
|
}
|
|
|
|
if (!await fs.pathExists(path.join(pluginDir, "lua"))) {
|
|
continue;
|
|
}
|
|
|
|
let module = {
|
|
"name": pluginName,
|
|
"files": [],
|
|
};
|
|
|
|
for (let fileName of await fs.readdir(path.join(pluginDir, "lua"))) {
|
|
module["files"].push({
|
|
path: pluginName+"/"+fileName,
|
|
content: await fs.readFile(path.join(pluginDir, "lua", fileName)),
|
|
load: true,
|
|
});
|
|
}
|
|
|
|
modules.push(module);
|
|
}
|
|
await factorio.patch(this.path("saves", saveName), modules);
|
|
|
|
this.server.on('rcon-ready', () => {
|
|
console.log("Clusterio | RCON connection established");
|
|
// Temporary measure for backwards compatibility
|
|
let compatConfig = {
|
|
id: this.config.id,
|
|
unique: this.config.id,
|
|
name: this.config.name,
|
|
|
|
// FactorioServer.init may have generated a random port or password
|
|
// if they were null.
|
|
factorioPort: this.server.gamePort,
|
|
clientPort: this.server.rconPort,
|
|
clientPassword: this.server.rconPassword,
|
|
}
|
|
});
|
|
|
|
await this.server.start(saveName);
|
|
|
|
for (let pluginInstance of this.plugins.values()) {
|
|
await pluginInstance.onStart();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the instance
|
|
*/
|
|
async stop() {
|
|
// XXX this needs more thought to it
|
|
if (this.server._state === "running") {
|
|
for (let pluginInstance of this.plugins.values()) {
|
|
await pluginInstance.onStop();
|
|
}
|
|
|
|
await this.server.stop();
|
|
}
|
|
}
|
|
|
|
async startInstanceRequestHandler() {
|
|
// Find save to start
|
|
let latestSave = await fileOps.getNewestFile(
|
|
this.path("saves"), (name) => !name.endsWith('.tmp.zip')
|
|
);
|
|
if (latestSave === null) {
|
|
throw new errors.RequestError(
|
|
"No savefile was found to start the instance with."
|
|
);
|
|
}
|
|
|
|
await this.start(latestSave, this.config, this.socket);
|
|
}
|
|
|
|
async createSaveRequestHandler() {
|
|
console.log("Creating save .....");
|
|
|
|
await this.server.create("world");
|
|
console.log("Clusterio | Successfully created save");
|
|
}
|
|
|
|
async stopInstanceRequestHandler() {
|
|
await this.stop();
|
|
}
|
|
|
|
async sendRconRequestHandler(message) {
|
|
let result = await this.server.sendRcon(message.data.command);
|
|
return { result };
|
|
}
|
|
|
|
/**
|
|
* Name of the instance
|
|
*
|
|
* This should not be used for filesystem paths. See .path() for that.
|
|
*/
|
|
get name() {
|
|
return this.config.name;
|
|
}
|
|
|
|
/**
|
|
* Return path in instance
|
|
*
|
|
* Creates a path using path.join with the given parts that's relative to
|
|
* the directory of the instance. For example instance.path("mods")
|
|
* returns a path to the mods directory of the instance. If no parts are
|
|
* given it returns a path to the directory of the instance.
|
|
*/
|
|
path(...parts) {
|
|
return path.join(this._dir, ...parts);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Searches for instances in the provided directory
|
|
*
|
|
* Looks through all sub-dirs of the provided directory for valid
|
|
* instance definitions and returns a mapping of instance id to
|
|
* instance info objects.
|
|
*
|
|
* @returns {Map<integer, Object>} mapping between instance
|
|
*/
|
|
async function discoverInstances(instancesDir, logger) {
|
|
let instanceInfos = new Map();
|
|
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
let instanceConfig;
|
|
let configPath = path.join(instancesDir, entry.name, "config.json");
|
|
try {
|
|
instanceConfig = JSON.parse(await fs.readFile(configPath));
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") {
|
|
continue; // Ignore folders without config.json
|
|
}
|
|
|
|
logger.error(`Error occured while parsing ${configPath}: ${err}`);
|
|
continue;
|
|
}
|
|
|
|
// XXX should probably validate the entire config with a JSON Schema.
|
|
if (typeof instanceConfig.id !== "number" || isNaN(instanceConfig.id)) {
|
|
logger.error(`${configPath} is missing id`);
|
|
continue;
|
|
}
|
|
|
|
if (typeof instanceConfig.name !== "string") {
|
|
logger.error(`${configPath} is missing name`);
|
|
continue;
|
|
}
|
|
|
|
let instancePath = path.join(instancesDir, entry.name);
|
|
logger.log(`found instance ${instanceConfig.name} in ${instancePath}`);
|
|
instanceInfos.set(instanceConfig.id, {
|
|
path: instancePath,
|
|
config: instanceConfig,
|
|
});
|
|
}
|
|
}
|
|
|
|
return instanceInfos;
|
|
}
|
|
|
|
class InstanceConnection extends link.Link {
|
|
constructor(connector, slave) {
|
|
super('slave', 'instance', connector);
|
|
this.slave = slave;
|
|
link.attachAllMessages(this);
|
|
|
|
for (let pluginInfo of slave.pluginInfos) {
|
|
plugin.attachPluginMessages(this, pluginInfo, null);
|
|
}
|
|
}
|
|
|
|
async forwardEventToInstance(message, event) {
|
|
let instanceId = message.data.instance_id;
|
|
if (!this.slave.instanceInfos.has(instanceId)) {
|
|
// Instance is probably on another slave
|
|
await this.slave.forwardEventToMaster(message, event);
|
|
return;
|
|
}
|
|
|
|
let instanceConnection = this.slave.instanceConnections.get(instanceId);
|
|
if (!instanceConnection) { return; }
|
|
|
|
event.send(instanceConnection, message.data);
|
|
}
|
|
|
|
async forwardEventToMaster(message, event) {
|
|
event.send(this.slave, message.data);
|
|
}
|
|
|
|
async broadcastEventToInstance(message, event) {
|
|
for (let instanceConnection of this.slave.instanceConnections.values()) {
|
|
if (instanceConnection === this) {
|
|
continue; // Do not broadcast back to the source
|
|
}
|
|
|
|
console.log("Broadcasting event");
|
|
event.send(instanceConnection, message.data);
|
|
}
|
|
}
|
|
}
|
|
|
|
class SlaveConnector extends link.SocketIOClientConnector {
|
|
constructor(slaveConfig) {
|
|
super(slaveConfig.masterURL, slaveConfig.masterAuthToken);
|
|
|
|
this.id = slaveConfig.id;
|
|
this.name = slaveConfig.name;
|
|
}
|
|
|
|
register() {
|
|
console.log("SOCKET | registering slave");
|
|
this.send('register_slave', {
|
|
agent: 'Clusterio Slave',
|
|
version,
|
|
id: this.id,
|
|
name: this.name,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles running the slave
|
|
*
|
|
* Connects to the master server over the socket.io connection and manages
|
|
* intsances.
|
|
*/
|
|
class Slave extends link.Link {
|
|
// I don't like God classes, but the alternative of putting all this state
|
|
// into global variables is not much better.
|
|
constructor(connector, slaveConfig, pluginInfos) {
|
|
super('slave', 'master', connector);
|
|
link.attachAllMessages(this);
|
|
|
|
this.pluginInfos = pluginInfos;
|
|
for (let pluginInfo of pluginInfos) {
|
|
plugin.attachPluginMessages(this, pluginInfo, null);
|
|
}
|
|
|
|
this.config = {
|
|
id: slaveConfig.id,
|
|
name: slaveConfig.name,
|
|
instancesDir: slaveConfig.instanceDirectory,
|
|
factorioDir: slaveConfig.factorioDirectory,
|
|
masterUrl: slaveConfig.masterURL,
|
|
masterToken: slaveConfig.masterAuthToken,
|
|
publicAddress: slaveConfig.publicIP,
|
|
}
|
|
|
|
this.instanceConnections = new Map();
|
|
this.instanceInfos = new Map();
|
|
}
|
|
|
|
async _findNewInstanceDir(name) {
|
|
try {
|
|
checkFilename(name)
|
|
} catch (err) {
|
|
throw new Error(`Instance name ${err.message}`);
|
|
}
|
|
|
|
// For now add dashes until an unused directory name is found
|
|
let dir = path.join(this.config.instancesDir, name);
|
|
while (await fs.pathExists(dir)) {
|
|
dir += '-';
|
|
}
|
|
|
|
return dir;
|
|
}
|
|
|
|
async forwardRequestToInstance(message, request) {
|
|
let instanceId = message.data.instance_id;
|
|
if (!this.instanceInfos.has(instanceId)) {
|
|
throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
|
}
|
|
|
|
let instanceConnection = this.instanceConnections.get(instanceId);
|
|
if (!instanceConnection) {
|
|
throw new errors.RequestError(`Instance ID ${instanceId} is not active`);
|
|
}
|
|
|
|
return await request.send(instanceConnection, message.data);
|
|
}
|
|
|
|
async forwardEventToInstance(message, event) {
|
|
let instanceId = message.data.instance_id;
|
|
if (!this.instanceInfos.has(instanceId)) { return; }
|
|
|
|
let instanceConnection = this.instanceConnections.get(instanceId);
|
|
if (!instanceConnection) { return; }
|
|
|
|
event.send(instanceConnection, message.data);
|
|
}
|
|
|
|
async broadcastEventToInstance(message, event) {
|
|
for (let instanceConnection of this.instanceConnections.values()) {
|
|
event.send(instanceConnection, message.data);
|
|
}
|
|
}
|
|
|
|
async createInstanceRequestHandler(message) {
|
|
let { id, options } = message.data;
|
|
if (this.instanceInfos.has(id)) {
|
|
throw new Error(`Instance with ID ${id} already exists`);
|
|
}
|
|
|
|
// XXX: race condition on multiple simultanious calls
|
|
let instanceDir = await this._findNewInstanceDir(options.name);
|
|
|
|
await Instance.create(id, instanceDir, this.config.factorioDir, options);
|
|
await this.updateInstances();
|
|
}
|
|
|
|
/**
|
|
* Initialize and connect an unloaded instance
|
|
*/
|
|
async _connectInstance(instanceId) {
|
|
let instanceInfo = this.instanceInfos.get(instanceId);
|
|
if (!instanceInfo) {
|
|
throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
|
}
|
|
|
|
if (this.instanceConnections.has(instanceId)) {
|
|
throw new errors.RequestError(`Instance with ID ${instanceId} is active`);
|
|
}
|
|
|
|
let [connectionClient, connectionServer] = link.VirtualConnector.makePair();
|
|
let instanceConnection = new InstanceConnection(connectionServer, this);
|
|
let instance = new Instance(
|
|
connectionClient, instanceInfo.path, this.config.factorioDir, instanceInfo.config
|
|
);
|
|
await instance.init(this.pluginInfos);
|
|
|
|
// XXX: race condition on multiple simultanious calls
|
|
this.instanceConnections.set(instanceId, instanceConnection);
|
|
return instanceConnection;
|
|
}
|
|
|
|
async startInstanceRequestHandler(message, request) {
|
|
let instanceConnection = await this._connectInstance(message.data.instance_id);
|
|
return await request.send(instanceConnection, message.data);
|
|
}
|
|
|
|
async createSaveRequestHandler(message, request) {
|
|
let instanceId = message.data.instance_id;
|
|
let instanceConnection = await this._connectInstance(instanceId);
|
|
await request.send(instanceConnection, message.data);
|
|
this.instanceConnections.delete(instanceId);
|
|
}
|
|
|
|
async stopInstanceRequestHandler(message, request) {
|
|
await this.forwardRequestToInstance(message, request);
|
|
this.instanceConnections.delete(message.data.instance_id);
|
|
}
|
|
|
|
async deleteInstanceRequestHandler(message) {
|
|
let instanceId = message.data.instance_id;
|
|
if (this.instanceConnections.has(instanceId)) {
|
|
throw new errors.RequestError(`Instance with ID ${instanceId} is active`);
|
|
}
|
|
|
|
let instanceInfo = this.instanceInfos.get(instanceId);
|
|
if (!instanceInfo) {
|
|
throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
|
}
|
|
|
|
await fs.remove(instanceInfo.path);
|
|
await this.updateInstances();
|
|
}
|
|
|
|
/**
|
|
* Discover available instances
|
|
*
|
|
* Looks through the instances directory for instances and updates
|
|
* the slave and master server with the new list of instances.
|
|
*/
|
|
async updateInstances() {
|
|
this.instanceInfos = await discoverInstances(this.config.instancesDir, console);
|
|
let list = [];
|
|
for (let instanceInfo of this.instanceInfos.values()) {
|
|
list.push({
|
|
id: instanceInfo.config.id,
|
|
name: instanceInfo.config.name,
|
|
});
|
|
}
|
|
link.messages.updateInstances.send(this, { instances: list });
|
|
}
|
|
|
|
async start() {
|
|
await this.updateInstances();
|
|
}
|
|
|
|
async stop() {
|
|
for (let instanceConnection of this.instanceConnections.values()) {
|
|
// XXX this neeeds more thought to it
|
|
// await instance.stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkFilename(name) {
|
|
// All of these are bad in Windows only, except for /, . and ..
|
|
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
|
|
const badEnd = /[. ]$/;
|
|
|
|
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
const badNames = [
|
|
// Relative path components
|
|
'.', '..',
|
|
|
|
// Reserved filenames in Windows
|
|
'CON', 'PRN', 'AUX', 'NUL',
|
|
...oneToNine.map(n => `COM${n}`),
|
|
...oneToNine.map(n => `LPT${n}`),
|
|
];
|
|
|
|
if (typeof name !== "string") {
|
|
throw new Error("must be a string");
|
|
}
|
|
|
|
if (name === "") {
|
|
throw new Error("cannot be empty");
|
|
}
|
|
|
|
if (badChars.test(name)) {
|
|
throw new Error('cannot contain <>:"\\/|=* or control characters');
|
|
}
|
|
|
|
if (badNames.includes(name.toUpperCase())) {
|
|
throw new Error(
|
|
"cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9"
|
|
);
|
|
}
|
|
|
|
if (badEnd.test(name)) {
|
|
throw new Error("cannot end with . or space");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create and update symlinks for shared mods in an instance
|
|
*
|
|
* Creates symlinks for .zip and .dat files that are not present in the
|
|
* instance mods directory but is present in the sharedMods directory,
|
|
* and removes any symlinks that don't point to a file in the instance
|
|
* mods directory. If the instance mods directory doesn't exist it will
|
|
* be created.
|
|
*
|
|
* Note that on Windows this creates hard links instead of symbolic
|
|
* links as the latter requires elevated privileges. This unfortunately
|
|
* means the removal of mods from the shared mods dir can't be detected.
|
|
*
|
|
* @param {Instance} instance - Instance to link mods for
|
|
* @param {string} sharedMods - Path to folder to link mods from.
|
|
* @param {object} logger - console like logging interface.
|
|
*/
|
|
async function symlinkMods(instance, sharedMods, logger) {
|
|
await fs.ensureDir(instance.path("mods"));
|
|
|
|
// Remove broken symlinks in instance mods.
|
|
for (let entry of await fs.readdir(instance.path("mods"), { withFileTypes: true })) {
|
|
if (entry.isSymbolicLink()) {
|
|
if (!await fs.pathExists(instance.path("mods", entry.name))) {
|
|
logger.log(`Removing broken symlink ${entry.name}`);
|
|
await fs.unlink(instance.path("mods", entry.name));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Link entries that are in sharedMods but not in instance mods.
|
|
let instanceModsEntries = new Set(await fs.readdir(instance.path("mods")));
|
|
for (let entry of await fs.readdir(sharedMods, { withFileTypes: true })) {
|
|
if (entry.isFile()) {
|
|
if (['.zip', '.dat'].includes(path.extname(entry.name))) {
|
|
if (!instanceModsEntries.has(entry.name)) {
|
|
logger.log(`linking ${entry.name} from ${sharedMods}`);
|
|
let target = path.join(sharedMods, entry.name);
|
|
let link = instance.path("mods", entry.name);
|
|
|
|
if (process.platform !== "win32") {
|
|
await fs.symlink(path.relative(path.dirname(link), target), link);
|
|
|
|
// On Windows symlinks require elevated privileges, which is
|
|
// not something we want to have. For this reason the mods
|
|
// are hard linked instead. This has the drawback of not
|
|
// being able to identify when mods are removed from the
|
|
// sharedMods directory, or which mods are linked.
|
|
} else {
|
|
await fs.link(target, link);
|
|
}
|
|
}
|
|
|
|
} else {
|
|
logger.warning(`Warning: ignoring file '${entry.name}' in sharedMods`);
|
|
}
|
|
|
|
} else {
|
|
logger.warning(`Warning: ignoring non-file '${entry.name}' in sharedMods`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startClient() {
|
|
// add better stack traces on promise rejection
|
|
process.on('unhandledRejection', r => console.log(r));
|
|
|
|
// argument parsing
|
|
const args = yargs
|
|
.scriptName("client")
|
|
.usage("$0 <command> [options]")
|
|
.option('config', {
|
|
nargs: 1,
|
|
describe: "slave config file to use",
|
|
default: 'config-slave.json',
|
|
type: 'string',
|
|
})
|
|
.command('create-config', "Create slave config", (yargs) => {
|
|
yargs.options({
|
|
'name': { describe: "Name of the slave", nargs: 1, type: 'string', demandOption: true },
|
|
'url': { describe: "Master URL", nargs: 1, type: 'string', default: "http://localhost:8080/" },
|
|
'token': { describe: "Master token", nargs: 1, type: 'string', demandOption: true },
|
|
'ip': { describe: "Public facing IP", nargs: 1, type: 'string', default: "localhost" },
|
|
'instances-dir': { describe: "Instances directory", nargs: 1, type: 'string', default: "instances" },
|
|
'factorio-dir': { describe: "Factorio directory", nargs: 1, type: 'string', default: "factorio" },
|
|
'id': {
|
|
describe: "Numeric id of the slave",
|
|
nargs: 1,
|
|
type: 'number',
|
|
default: Math.random() * 2**31 | 0,
|
|
defaultDescription: "random id",
|
|
},
|
|
});
|
|
})
|
|
.command('edit-config', "Edit slave config", (yargs) => {
|
|
yargs.options({
|
|
'name': { describe: "Set name of the slave", nargs: 1, type: 'string' },
|
|
'url': { describe: "Set master URL", nargs: 1, type: 'string' },
|
|
'token': { describe: "Set master token", nargs: 1, type: 'string' },
|
|
'ip': { describe: "Set public facing IP", nargs: 1, type: 'string' },
|
|
'instances-dir': { describe: "Set instances directory", nargs: 1, type: 'string' },
|
|
'factorio-dir': { describe: "Set Factorio directory", nargs: 1, type: 'string' },
|
|
'id': { describe: "Set id of the slave", nargs: 1, type: 'number' },
|
|
});
|
|
})
|
|
.command('show-config', "Show slave config")
|
|
.command('start', "Start slave")
|
|
.demandCommand(1, "You need to specify a command to run")
|
|
.strict()
|
|
.argv
|
|
;
|
|
|
|
let command = args._[0];
|
|
|
|
if (command === "create-config") {
|
|
await fs.outputFile(args.config, JSON.stringify({
|
|
name: args.name,
|
|
masterURL: args.url,
|
|
masterAuthToken: args.token,
|
|
publicIP: args.ip,
|
|
instanceDirectory: args.instancesDir,
|
|
factorioDirectory: args.factorioDir,
|
|
id: args.id,
|
|
}, null, 4), { flag: 'wx' });
|
|
return;
|
|
|
|
} else if (command == "edit-config") {
|
|
let slaveConfig = JSON.parse(await fs.readFile(args.config));
|
|
if ('name' in args) slaveConfig.name = args.name;
|
|
if ('url' in args) slaveConfig.masterURL = args.url;
|
|
if ('token' in args) slaveConfig.masterAuthToken = args.token;
|
|
if ('ip' in args) slaveConfig.publicIP = args.ip;
|
|
if ('instancesDir' in args) slaveConfig.instanceDirectory = args.instancesDir;
|
|
if ('factorioDir' in args) slaveConfig.factorioDirectory = args.factorioDir;
|
|
if ('id' in args) slaveConfig.id = args.id;
|
|
await fs.outputFile(args.config, JSON.stringify(slaveConfig, null, 4));
|
|
return;
|
|
|
|
} else if (command == "show-config") {
|
|
let slaveConfig = JSON.parse(await fs.readFile(args.config));
|
|
console.log(slaveConfig);
|
|
return;
|
|
}
|
|
|
|
// If we get here the command was start
|
|
|
|
// handle commandline parameters
|
|
console.log(`Loading ${args.config}`);
|
|
const slaveConfig = JSON.parse(await fs.readFile(args.config));
|
|
|
|
await fs.ensureDir(slaveConfig.instanceDirectory);
|
|
await fs.ensureDir("sharedMods");
|
|
|
|
// Set the process title, shows up as the title of the CMD window on windows
|
|
// and as the process name in ps/top on linux.
|
|
process.title = "clusterioClient";
|
|
|
|
// make sure we have the master access token
|
|
if(!slaveConfig.masterAuthToken || typeof slaveConfig.masterAuthToken !== "string"){
|
|
console.error("ERROR invalid config!");
|
|
console.error(
|
|
"Master server now needs an access token for write operations. As clusterio\n"+
|
|
"slaves depends upon this, please add your token to config.json in the field\n"+
|
|
"named masterAuthToken. You can retrieve your auth token from the master in\n"+
|
|
"secret-api-token.txt after running it once."
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// make sure url ends with /
|
|
if (!slaveConfig.masterURL.endsWith("/")) {
|
|
console.error("ERROR invalid config!");
|
|
console.error("masterURL (set with --url) must end with '/'");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let pluginInfos = await plugin.getPluginInfos("plugins");
|
|
let slaveConnector = new SlaveConnector(slaveConfig);
|
|
let slave = new Slave(slaveConnector, slaveConfig, pluginInfos);
|
|
|
|
// Handle interrupts
|
|
let secondSigint = false
|
|
process.on('SIGINT', () => {
|
|
if (secondSigint) {
|
|
console.log("Caught second interrupt, terminating immediately");
|
|
process.exit(1);
|
|
}
|
|
|
|
secondSigint = true;
|
|
console.log("Caught interrupt signal, shutting down");
|
|
slave.stop().then(() => {
|
|
// There's currently no shutdown mechanism for instance plugins so
|
|
// they keep the event loop alive.
|
|
process.exit();
|
|
});
|
|
});
|
|
|
|
await slaveConnector.connect();
|
|
await slave.start();
|
|
|
|
/*
|
|
} else if (command == "manage"){
|
|
await manage(config, instance);
|
|
// process.exit(0);
|
|
*/
|
|
}
|
|
|
|
// string, function
|
|
// returns [{modName:string,hash:string}, ... ]
|
|
function hashMods(instance, callback) {
|
|
if(!callback) {
|
|
throw new Error("ERROR in function hashMods NO CALLBACK");
|
|
}
|
|
|
|
function hashMod(name) {
|
|
if (path.extname(name) != ".zip") {
|
|
// Can't hash unzipped mods, return null that's filtered out later
|
|
return null;
|
|
} else {
|
|
return hashFile(instance.path("mods", name)).then(hash => (
|
|
{modName: name, hash: hash}
|
|
));
|
|
}
|
|
}
|
|
|
|
let promises = fs.readdirSync(instance.path("mods")).map(hashMod);
|
|
Promise.all(promises).then(hashes => {
|
|
// Remove null entries from hashMod
|
|
callback(hashes.filter(entry => entry !== null));
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
// For testing only
|
|
_Instance: Instance,
|
|
_checkFilename: checkFilename,
|
|
_symlinkMods: symlinkMods,
|
|
_discoverInstances: discoverInstances,
|
|
_Slave: Slave,
|
|
};
|
|
|
|
if (module === require.main) {
|
|
console.warn(`
|
|
+==========================================================+
|
|
I WARNING: This is the development branch for the 2.0 I
|
|
I version of clusterio. Expect things to break. I
|
|
+==========================================================+
|
|
`
|
|
);
|
|
startClient().catch(err => {
|
|
console.error(`
|
|
+---------------------------------------------------------------+
|
|
| Unexpected error occured while starting client, please report |
|
|
| it to https://github.com/clusterio/factorioClusterio/issues |
|
|
+---------------------------------------------------------------+`
|
|
);
|
|
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
}
|