mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Exit client.js with a non-zero exit code if it's detected that the
masterAuthToken is missing in the config.
This is a port of 8b4ec58.
948 lines
34 KiB
JavaScript
948 lines
34 KiB
JavaScript
const fs = require('fs-extra');
|
|
const Tail = require('tail').Tail;
|
|
const https = require('follow-redirects').https;
|
|
const needle = require("needle");
|
|
const child_process = require('child_process');
|
|
const path = require('path');
|
|
const syncRequest = require('sync-request');
|
|
const request = require("request");
|
|
const ncp = require('ncp').ncp;
|
|
const Rcon = require('rcon-client-fork').Rcon;
|
|
const deepmerge = require("deepmerge");
|
|
const getMac = require('getmac').getMac;
|
|
const rmdirSync = require('rmdir-sync');
|
|
const ioClient = require("socket.io-client");
|
|
const asTable = require("as-table").configure({delimiter: ' | '});
|
|
const util = require("util");
|
|
const crypto = require("crypto");
|
|
|
|
// internal libraries
|
|
const objectOps = require("lib/objectOps.js");
|
|
const fileOps = require("lib/fileOps");
|
|
const stringUtils = require("lib/stringUtils.js");
|
|
const pluginManager = require("lib/manager/pluginManager");
|
|
const modManager = require("lib/manager/modManager");
|
|
const hashFile = require('lib/hash').hashFile;
|
|
|
|
// Uhm...
|
|
var global = {};
|
|
|
|
/**
|
|
* Keeps track of the runtime parameters of an instance
|
|
*/
|
|
class Instance {
|
|
constructor(dir, name) {
|
|
this._name = name;
|
|
this._dir = dir;
|
|
}
|
|
|
|
/**
|
|
* Name of the instance
|
|
*
|
|
* This should not be used for filesystem paths. See .path() for that.
|
|
*/
|
|
get name() {
|
|
return this._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);
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
function needleOptionsWithTokenAuthHeader(config) {
|
|
return {
|
|
compressed: true,
|
|
headers: {
|
|
'x-access-token': config.masterAuthToken
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
var instanceInfo = {};
|
|
var commandBuffer=[];
|
|
|
|
// function to handle sending commands into the game
|
|
async function messageInterfaceInternal(command, callback, resolve, reject) {
|
|
// try to save us if you send a buffer instead of string
|
|
if(typeof command == "object") {
|
|
command = command.toString('utf8');
|
|
}
|
|
|
|
if(false && process.platform == "linux" && typeof command == "string" && serverprocess) {
|
|
/*
|
|
to send to stdin, use:
|
|
serverprocess.stdin.write("/c command;\n")
|
|
*/
|
|
serverprocess.stdin.write(command+"\n");
|
|
if(typeof callback == "function"){
|
|
callback();
|
|
}
|
|
resolve();
|
|
} else if(typeof command == "string" && client && client.send && typeof client.send == "function") {
|
|
try {
|
|
let str = await client.send(command+"\n");
|
|
if(typeof callback == "function") callback(str)
|
|
resolve(str)
|
|
} catch (err) {
|
|
console.log("RCON failed, server might be paused or shutting down");
|
|
// console.log(err);
|
|
// reject(err);
|
|
if(typeof callback == "function"){
|
|
callback();
|
|
}
|
|
reject(err)
|
|
}
|
|
}
|
|
}
|
|
function messageInterface(command, callback) {
|
|
return new Promise((resolve,reject) => {
|
|
commandBuffer.push([command,callback, resolve, reject]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Give a random dynamic port
|
|
*
|
|
* Returns a random port number in the Dynamic Ports range as defined by
|
|
* RFC 6335.
|
|
*
|
|
* @return {number} a number in the range 49152 to 65535.
|
|
*/
|
|
function randomDynamicPort() {
|
|
const start = 49152;
|
|
const end = 65535 + 1;
|
|
|
|
return Math.floor(Math.random() * (end - start) + start)
|
|
}
|
|
|
|
/**
|
|
* Generate a secure random password of the given length
|
|
*
|
|
* Uses crypto.randomBytes to generate a secure alphanumeric password of
|
|
* the given length.
|
|
*
|
|
* @param {number} length - the length of the password to generate.
|
|
* @return {string} password of the given length
|
|
*/
|
|
async function generatePassword(length) {
|
|
function validChar(byte) {
|
|
const ranges = ['az', 'AZ', '09'];
|
|
return ranges.some(range =>
|
|
range.codePointAt(0) <= byte && byte <= range.codePointAt(1)
|
|
);
|
|
}
|
|
let randomBytesAsync = util.promisify(crypto.randomBytes);
|
|
|
|
let password = "";
|
|
while (true) {
|
|
let bytes = await randomBytesAsync((length - password.length) * 3);
|
|
for (let byte of bytes) {
|
|
|
|
// Crop to ASCII values only
|
|
byte = byte & 0x7f;
|
|
|
|
if (validChar(byte)) {
|
|
password += String.fromCharCode(byte);
|
|
if (password.length == length) {
|
|
return password;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function printUsage() {
|
|
console.error("Usage: ");
|
|
console.error("node client.js start [instance name]");
|
|
console.error("node client.js list");
|
|
console.error("node client.js delete [instance]");
|
|
console.error("To download the latest version of the Clusterio lua mod, do");
|
|
console.error("node client.js manage shared mods download clusterio");
|
|
console.error("To download a clusterio plugin, do");
|
|
console.error("node client.js manage shared plugins install https://github.com/Danielv123/playerManager");
|
|
console.error("For more management options, do");
|
|
console.error("node client.js manage");
|
|
}
|
|
|
|
async function listInstances(config) {
|
|
let instanceNames = fileOps.getDirectoriesSync(config.instanceDirectory);
|
|
let instances = [];
|
|
for (instance of instanceNames) {
|
|
let cfg = path.resolve(config.instanceDirectory, instance, 'config');
|
|
let port = require(cfg).factorioPort;
|
|
instances.push({
|
|
"Name": instance,
|
|
"Port": port.toString(),
|
|
});
|
|
}
|
|
|
|
console.log(asTable(instances));
|
|
}
|
|
|
|
async function manage(config, instance) {
|
|
// console.log("Launching mod manager");
|
|
//const fullUsage = 'node client.js manage [instance, "shared"] ["mods", "config"] ...';
|
|
function usage(instance, tool, action){
|
|
if(tool && tool == "mods"){
|
|
console.log('node client.js manage '+instance.name+' '+tool+' ["list", "search", "add", "remove"]');
|
|
} else if(tool && tool == "plugins") {
|
|
console.log(`node client.js manage ${instance.name} ${tool} ["list", "add", "remove"]`);
|
|
} else {
|
|
console.log('node client.js manage '+(instance && instance.name || '[instance, "shared"]') +' '+ (tool || '["mods", "plugins"]') + ' ...');
|
|
}
|
|
}
|
|
const tool = process.argv[4] || "";
|
|
const action = process.argv[5] || "";
|
|
if (instance !== undefined) {
|
|
if(tool == "mods"){
|
|
(async function(){try{
|
|
// allow managing mods
|
|
if(action == "list"){
|
|
console.log(await modManager.listMods(config, instance));
|
|
} else if(action == "search"){
|
|
console.log(await modManager.findMods(config, process.argv[6]));
|
|
} else if(action == "add" || action == "download"){
|
|
await modManager.addMod(config, process.argv[6], instance);
|
|
} else if(action == "remove" || action == "rm" || action == "delete"){
|
|
await modManager.removeMod(config, process.argv[6], instance);
|
|
} else {
|
|
usage(instance, tool);
|
|
}
|
|
process.exit(0);
|
|
}catch(e){
|
|
console.log("Got error from modManager:")
|
|
console.log(e);
|
|
}})();
|
|
} else if(tool == "plugins"){
|
|
(async function(){try{
|
|
if(action == "list"){
|
|
await pluginManager.listPlugins();
|
|
} else if(action == "add" || action == "install" || action == "download"){
|
|
let status = await pluginManager.addPlugin(process.argv[6]);
|
|
} else if(action == "remove" || action == "uninstall" || action == "delete"){
|
|
let status = await pluginManager.removePlugin(process.argv[6]);
|
|
if(status && status.msg) console.log(status.msg);
|
|
} else if(action == "enable"){
|
|
let status = await pluginManager.enablePlugin(process.argv[6]);
|
|
if(status && status.msg) console.log(status.msg);
|
|
} else if(action == "disable"){
|
|
let status = await pluginManager.disablePlugin(process.argv[6]);
|
|
if(status && status.msg) console.log(status.msg);
|
|
}
|
|
process.exit(0);
|
|
}catch(e){
|
|
console.error(e);
|
|
process.exit(1);
|
|
}})();
|
|
} else {
|
|
usage(instance);
|
|
}
|
|
} else {
|
|
console.log('Usage:');
|
|
usage(instance);
|
|
}
|
|
}
|
|
|
|
async function deleteInstance(instance) {
|
|
if (instance === undefined) {
|
|
console.error("Usage: node client.js delete [instance]");
|
|
process.exit(1);
|
|
} else if (fs.existsSync(instance.path())) {
|
|
fileOps.deleteFolderRecursiveSync(instance.path()); // TODO: Check if this can cause i-craft users to format their server by using wrong paths
|
|
console.log("Deleted instance " + instance.name);
|
|
process.exit(0);
|
|
} else {
|
|
console.error("Instance not found: " + instance.name);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
async function downloadMod() {
|
|
console.log("Downloading mods...");
|
|
// get JSON data about releases
|
|
let res = syncRequest('GET', 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases', {"headers":{"User-Agent":"factorioClusterio"}});
|
|
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
|
|
let name = JSON.parse(res.getBody())[0].assets[0].name;
|
|
if(url) {
|
|
console.log(url);
|
|
let file = fs.createWriteStream("sharedMods/"+name);
|
|
https.get(url, function(response) {
|
|
response.on('end', function () {
|
|
console.log("Downloaded "+name);
|
|
process.exit(0);
|
|
});
|
|
response.pipe(file);
|
|
}).end();
|
|
}
|
|
}
|
|
|
|
async function createInstance(config, args, instance) {
|
|
// if instance does not exist, create it
|
|
console.log("Creating instance...");
|
|
fs.mkdirSync(instance.path());
|
|
fs.mkdirSync(instance.path("script-output"));
|
|
fs.writeFileSync(instance.path("script-output", "output.txt"), "");
|
|
fs.writeFileSync(instance.path("script-output", "orders.txt"), "");
|
|
fs.writeFileSync(instance.path("script-output", "txbuffer.txt"), "");
|
|
fs.mkdirSync(instance.path("mods"));
|
|
fs.mkdirSync(instance.path("instanceMods"));
|
|
fs.mkdirSync(instance.path("scenarios"));
|
|
ncp("lib/scenarios", instance.path("scenarios"), err => {
|
|
if (err) console.error(err)
|
|
});
|
|
|
|
// fs.symlinkSync('../../../sharedMods', instance.path("mods"), 'junction') // This is broken because it can only take a file as first argument, not a folder
|
|
fs.writeFileSync(instance.path("config.ini"), `[path]\r\n
|
|
read-data=${ path.resolve(config.factorioDirectory, "data") }\r\n
|
|
write-data=${ instance.path() }\r\n
|
|
`);
|
|
|
|
// this line is probably not needed anymore but Im not gonna remove it
|
|
fs.copySync('sharedMods', instance.path("mods"));
|
|
let instconf = {
|
|
"factorioPort": args.port || process.env.FACTORIOPORT || randomDynamicPort(),
|
|
"clientPort": args["rcon-port"] || process.env.RCONPORT || randomDynamicPort(),
|
|
"__comment_clientPassword": "This is the rcon password. Its also used for making an instanceID. Make sure its unique and not blank.",
|
|
"clientPassword": args["rcon-password"] || await generatePassword(10),
|
|
"info": {}
|
|
}
|
|
console.log("Clusterio | Created instance with settings:")
|
|
console.log(instconf);
|
|
|
|
// create instance config
|
|
fs.writeFileSync(instance.path("config.json"), JSON.stringify(instconf, null, 4));
|
|
|
|
let name = "Clusterio instance: " + instance.name;
|
|
if (config.username) {
|
|
name = config.username + "'s clusterio " + instance.name;
|
|
}
|
|
let serversettings = {
|
|
"name": name,
|
|
"description": config.description,
|
|
"tags": ["clusterio"],
|
|
"max_players": "20",
|
|
"visibility": config.visibility,
|
|
"username": config.username,
|
|
"token": config.token,
|
|
"game_password": config.game_password,
|
|
"verify_user_identity": config.verify_user_identity,
|
|
"admins": [config.username],
|
|
"allow_commands": config.allow_commands,
|
|
"autosave_interval": 10,
|
|
"autosave_slots": 5,
|
|
"afk_autokick_interval": 0,
|
|
"auto_pause": config.auto_pause,
|
|
};
|
|
fs.writeFileSync(instance.path("server-settings.json"), JSON.stringify(serversettings, null, 4));
|
|
console.log("Server settings: "+JSON.stringify(serversettings, null, 4));
|
|
console.log("Creating save .....");
|
|
let factorio = child_process.spawn(
|
|
'./' + config.factorioDirectory + '/bin/x64/factorio', [
|
|
'-c', instance.path("config.ini"),
|
|
// '--create', instance.path("saves", "save.zip"),
|
|
'--start-server-load-scenario', 'Hotpatch',
|
|
'--server-settings', instance.path("server-settings.json"),
|
|
'--rcon-port', Number(process.env.RCONPORT) || instconf.clientPort,
|
|
'--rcon-password', instconf.clientPassword,
|
|
], {
|
|
'stdio': ['pipe', 'pipe', 'pipe'],
|
|
'detached': process.platform === "linux",
|
|
}
|
|
);
|
|
factorio.stdout.on("data", data => {
|
|
data = data.toString("utf8").replace(/(\r\n\t|\n|\r\t)/gm,"");
|
|
console.log(data);
|
|
if(data.includes("Starting RCON interface")){
|
|
let client = new Rcon({
|
|
packetResponseTimeout: 200000,
|
|
maxPending: 5
|
|
});
|
|
client.connect({
|
|
host: 'localhost',
|
|
port: Number(process.env.RCONPORT) || instconf.clientPort,
|
|
password: instconf.clientPassword
|
|
});
|
|
client.onDidAuthenticate(() => {
|
|
console.log('Clusterio | RCON Authenticated!');
|
|
});
|
|
client.onDidConnect(() => {
|
|
console.log('Clusterio | RCON Connected, starting save');
|
|
client.send("/c game.server_save('hotpachSave')");
|
|
});
|
|
}
|
|
if(data.includes("Saving finished")){
|
|
console.log("Map saved as hotpachSave.zip, exiting...");
|
|
console.log("Instance created!")
|
|
process.exit(0);
|
|
}
|
|
if(data.includes("Downloading from auth server failed")){
|
|
console.error("Instance creation failed, unable to establish auth server connection.");
|
|
console.error("Deleting broken instance...");
|
|
|
|
fileOps.deleteFolderRecursiveSync(path.resolve(config.instanceDirectory, process.argv[3]));
|
|
|
|
process.exit(0);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function startInstance(config, args, instance) {
|
|
var instanceconfig = JSON.parse(await fs.readFile(instance.path("config.json")));
|
|
instanceconfig.unique = stringUtils.hashCode(instanceconfig.clientPassword);
|
|
if (process.env.FACTORIOPORT) {
|
|
instanceconfig.factorioPort = process.env.FACTORIOPORT;
|
|
}
|
|
if (process.env.RCONPORT) {
|
|
instanceconfig.rconPort = process.env.RCONPORT;
|
|
}
|
|
console.log("Deleting .tmp.zip files");
|
|
let savefiles = fs.readdirSync(instance.path("saves"));
|
|
for(i = 0; i < savefiles.length; i++){
|
|
if(savefiles[i].substr(savefiles[i].length - 8, 8) == ".tmp.zip") {
|
|
fs.unlinkSync(instance.path("saves", savefiles[i]));
|
|
}
|
|
}
|
|
console.log("Clusterio | Rotating old logs...");
|
|
// clean old log file to avoid crash
|
|
try{
|
|
let logPath = instance.path("factorio-current.log");
|
|
let stat = await fs.stat(logPath);
|
|
console.log(stat)
|
|
console.log(stat.isFile())
|
|
if(stat.isFile()){
|
|
let logFilename = `factorio-${Math.floor(Date.parse(stat.mtime)/1000)}.log`;
|
|
await fs.rename(logPath, instance.path(logFilename));
|
|
console.log(`Log rotated as ${logFilename}`);
|
|
}
|
|
}catch(e){}
|
|
// Math.floor(Date.now()/1000)
|
|
// (new Date).toGMTString()
|
|
|
|
// move mods from ./sharedMods to the instances mod directory
|
|
try{fs.mkdirSync(instance.path("instanceMods"));}catch(e){}
|
|
try{rmdirSync(instance.path("mods"));}catch(e){}
|
|
try {
|
|
// mods directory that will be emptied (deleted) when closing the server to facilitate seperation of instanceMods and sharedMods
|
|
fs.mkdirSync(instance.path("mods"));
|
|
} catch(e){}
|
|
console.log("Clusterio | Moving shared mods from sharedMods/ to instance/mods...");
|
|
fs.copySync('sharedMods', instance.path("mods"));
|
|
console.log("Clusterio | Moving instance specific mods from instance/instanceMods to instance/mods...");
|
|
fs.copySync(instance.path("instanceMods"), instance.path("mods"));
|
|
|
|
// Set paths for factorio so it reads and writes from the correct place even if the instance is imported from somewhere else
|
|
fs.writeFileSync(instance.path("config.ini"), `[path]\r\n
|
|
read-data=${ path.resolve(config.factorioDirectory, "data") }\r\n
|
|
write-data=${ instance.path() }\r\n
|
|
`);
|
|
|
|
// Spawn factorio server
|
|
//var serverprocess = child_process.exec(commandline);
|
|
fileOps.getNewestFile(instance.path("saves"), fs.readdirSync(instance.path("saves")),function(err, latestSave) {
|
|
if(err) {
|
|
console.error("ERROR!");
|
|
console.error("Your savefile seems to be missing. This might because you created an instance without having factorio\
|
|
installed and configured properly. Try installing factorio and adding your savefile to instances/[instancename]/saves/\n");
|
|
throw err;
|
|
}
|
|
// implicit global
|
|
serverprocess = child_process.spawn(
|
|
'./' + config.factorioDirectory + '/bin/x64/factorio', [
|
|
'-c', instance.path("config.ini"),
|
|
'--start-server', latestSave.file,
|
|
'--rcon-port', args["rcon-port"] || Number(process.env.RCONPORT) || instanceconfig.clientPort,
|
|
'--rcon-password', args["rcon-password"] || instanceconfig.clientPassword,
|
|
'--server-settings', instance.path("server-settings.json"),
|
|
'--port', args.port || Number(process.env.FACTORIOPORT) || instanceconfig.factorioPort
|
|
], {
|
|
'stdio': ['pipe', 'pipe', 'pipe'],
|
|
'detached': process.platform === "linux",
|
|
}
|
|
);
|
|
|
|
serverprocess.on('close', code => {
|
|
console.log(`child process exited with code ${code}`);
|
|
process.exit();
|
|
});
|
|
serverprocess.stdout.on("data", data => {
|
|
// log("Stdout: " + data);
|
|
if(data.toString('utf8').includes("Couldn't parse RCON data: Maximum payload size exceeded")){
|
|
console.error("ERROR: RCON CONNECTION BROKE DUE TO TOO LARGE PACKET!");
|
|
console.error("Attempting reconnect...");
|
|
client.disconnect();
|
|
client.connect();
|
|
}
|
|
// we have to do this to make logs visible on linux and in powershell. Causes log duplication for people with CMD.
|
|
console.log('Fact: ' + data.toString("utf8").replace("\n", ""));
|
|
});
|
|
serverprocess.stderr.on('data', (chunk) => {
|
|
console.log('ERR: ' + chunk);
|
|
});
|
|
|
|
// connect to the server with rcon
|
|
if(true || process.platform != "linux"){
|
|
// IP, port, password
|
|
client = new Rcon({
|
|
packetResponseTimeout: 200000, // 200s, should allow for commands up to 1250kB in length
|
|
maxPending: 5
|
|
});
|
|
// check the logfile to see if the RCON interface is running as there is no way to continue without it
|
|
// we read the log every 2 seconds and stop looping when we start connecting to factorio
|
|
function checkRcon() {
|
|
fs.readFile(instance.path("factorio-current.log"), function (err, data) {
|
|
// if (err) console.error(err);
|
|
if(data && data.indexOf('Starting RCON interface') > 0){
|
|
client.connect({
|
|
host: 'localhost',
|
|
port: args["rcon-port"] || Number(process.env.RCONPORT) || instanceconfig.clientPort,
|
|
password: args["rcon-password"] || instanceconfig.clientPassword,
|
|
});
|
|
} else {
|
|
setTimeout(function(){
|
|
checkRcon();
|
|
},5000);
|
|
}
|
|
});
|
|
}
|
|
setTimeout(checkRcon, 5000);
|
|
|
|
client.onDidAuthenticate(() => {
|
|
console.log('Clusterio | RCON Authenticated!');
|
|
instanceManagement(config, instance, instanceconfig); // start using rcons
|
|
});
|
|
client.onDidConnect(() => {
|
|
console.log('Clusterio | RCON Connected!');
|
|
// getID();
|
|
});
|
|
client.onDidDisconnect(() => {
|
|
console.log('Clusterio | RCON Disconnected!');
|
|
// process.exit(0); // exit because RCON disconnecting is undefined behaviour and we rather just wanna restart now
|
|
});
|
|
process.on('SIGINT', function () {
|
|
console.log("Caught interrupt signal, disconnecting RCON");
|
|
client.disconnect().then(()=>{
|
|
console.log("Rcon disconnected, Sending ^C");
|
|
// We don't actually do this on windows, because ctrl+c in windows CMD sends it to all subprocesses as well. Doing it twice will abort factorios save.
|
|
if(process.platform == "linux"){
|
|
serverprocess.kill("SIGINT");
|
|
}
|
|
});
|
|
});
|
|
} else if(process.platform == "linux"){
|
|
// don't open an RCON connection and just use stdio instead, does not work on windows.
|
|
instanceManagement(config, instance, instanceconfig);
|
|
process.on('SIGINT', function () {
|
|
console.log("Caught interrupt signal, sending ^C");
|
|
serverprocess.kill("SIGINT");
|
|
});
|
|
}
|
|
|
|
// set some globals
|
|
confirmedOrders = [];
|
|
lastSignalCheck = Date.now();
|
|
});
|
|
}
|
|
|
|
async function startClient() {
|
|
// argument parsing
|
|
const args = require('minimist')(process.argv.slice(2));
|
|
|
|
const command = process.argv[2];
|
|
|
|
// add better stack traces on promise rejection
|
|
process.on('unhandledRejection', r => console.log(r));
|
|
|
|
console.log(`Requiring config from ${args.config || './config'}`);
|
|
const config = require(args.config || './config');
|
|
|
|
await fs.ensureDir(config.instanceDirectory);
|
|
await fs.ensureDir("sharedPlugins");
|
|
await fs.ensureDir("sharedMods");
|
|
|
|
let instance;
|
|
if (process.argv[3] !== undefined) {
|
|
let name = process.argv[3];
|
|
try {
|
|
checkFilename(name);
|
|
} catch (err) {
|
|
throw new Error(`Instance name ${err.message}`);
|
|
}
|
|
let dir = path.join(config.instanceDirectory, name);
|
|
instance = new Instance(dir, name);
|
|
}
|
|
|
|
// 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.
|
|
if (instance) {
|
|
process.title = "clusterioClient "+instance.name;
|
|
}
|
|
|
|
|
|
// make sure we have the master access token (can't write to master without it since clusterio 2.0)
|
|
if(!config.masterAuthToken || typeof config.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;
|
|
}
|
|
|
|
// messageInterface Management
|
|
setInterval(function(){
|
|
let command=commandBuffer.shift();
|
|
if(command){
|
|
messageInterfaceInternal(command[0], command[1], command[2], command[3]);
|
|
}
|
|
},config.msBetweenCommands || 50);
|
|
|
|
// handle commandline parameters
|
|
if (!command || command == "help" || command == "--help") {
|
|
printUsage();
|
|
process.exit(1);
|
|
} else if (command == "list") {
|
|
await listInstances(config);
|
|
process.exit(0);
|
|
} else if (command == "manage"){
|
|
await manage(config, instance);
|
|
// process.exit(0);
|
|
} else if (command == "delete") {
|
|
await deleteInstance(instance);
|
|
} else if (command == "download") {
|
|
await downloadMod();
|
|
} else if (command == "start" && instance === undefined) {
|
|
console.error("ERROR: No instanceName provided!");
|
|
console.error("Usage: node client.js start [instanceName]");
|
|
process.exit(0);
|
|
} else if (command == "start" && !fs.existsSync(instance.path())) {
|
|
await createInstance(config, args, instance);
|
|
} else if (command == "start" && fs.existsSync(instance.path())) {
|
|
await startInstance(config, args, instance);
|
|
} else {
|
|
console.error("Invalid arguments, quitting.");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ensure instancemanagement only ever runs once
|
|
var _instanceInitialized;
|
|
async function instanceManagement(config, instance, instanceconfig) {
|
|
if (_instanceInitialized) return;
|
|
_instanceInitialized = true;
|
|
|
|
console.log("Started instanceManagement();");
|
|
|
|
/* Open websocket connection to master */
|
|
var socket = ioClient(config.masterURL+"?token="+config.masterAuthToken);
|
|
socket.on("error", err => {
|
|
console.log("SOCKET | Error: ", err);
|
|
});
|
|
socket.on("hello", data => {
|
|
console.log("SOCKET | registering slave!");
|
|
socket.emit("registerSlave", {
|
|
instanceID: instanceconfig.unique,
|
|
});
|
|
});
|
|
setInterval(B=> socket.emit("heartbeat"), 10000);
|
|
|
|
// load plugins and execute onLoad event
|
|
let pluginsToLoad = await pluginManager.getPlugins();
|
|
let plugins = [];
|
|
|
|
for(let i = 0; i < pluginsToLoad.length; i++){
|
|
let pluginLoadStarted = Date.now();
|
|
let combinedConfig = deepmerge(instanceconfig,config,{clone:true});
|
|
combinedConfig.instanceName = instance.name;
|
|
let pluginConfig = pluginsToLoad[i];
|
|
|
|
if(!global.subscribedFiles) {
|
|
global.subscribedFiles = {};
|
|
}
|
|
if(pluginConfig.binary == "nodePackage" && pluginConfig.enabled){
|
|
// require plugin class and execute it
|
|
let pluginClass = require(path.resolve(pluginConfig.pluginPath, "index.js"));
|
|
plugins[i] = new pluginClass(combinedConfig, async function(data, callback){
|
|
if(data && data.toString('utf8')[0] != "/") {
|
|
console.log("Clusterio | "+ pluginsToLoad[i].name + " | " + data.toString('utf8'));
|
|
return true;
|
|
} else if (data && data.toString('utf8')[0] == "/"){
|
|
return messageInterface(data.toString('utf8'), callback);
|
|
}
|
|
}, { // extra functions to pass in object. Should have done it like this from the start, but won't break backwards compat.
|
|
socket, // socket.io connection to master (and ES6 destructuring, yay)
|
|
});
|
|
if(plugins[i].factorioOutput && typeof plugins[i].factorioOutput === "function"){
|
|
// when factorio logs a line, send it to the plugin. This includes things like autosaves, chat, errors etc
|
|
serverprocess.stdout.on("data", data => plugins[i].factorioOutput(data.toString()));
|
|
}
|
|
if(pluginConfig.scriptOutputFileSubscription && typeof pluginConfig.scriptOutputFileSubscription == "string"){
|
|
if(global.subscribedFiles[pluginConfig.scriptOutputFileSubscription]) {
|
|
// please choose a unique file to subscribe to. If you need plugins to share this interface, set up a direct communication
|
|
// between those plugins instead.
|
|
throw "FATAL ERROR IN " + pluginConfig.name + " FILE ALREADY SUBSCRIBED " + pluginConfig.scriptOutputFileSubscription;
|
|
}
|
|
|
|
let outputPath = instance.path(
|
|
"script-output",
|
|
pluginConfig.scriptOutputFileSubscription
|
|
);
|
|
if (!fs.existsSync(outputPath)) {
|
|
// Do something
|
|
fs.writeFileSync(outputPath, "");
|
|
}
|
|
global.subscribedFiles[pluginConfig.scriptOutputFileSubscription] = true;
|
|
console.log("Clusterio | Registered file subscription on "+outputPath);
|
|
|
|
|
|
if(!pluginConfig.fileReadDelay || pluginConfig.fileReadDelay == 0) {
|
|
// only wipe the file on restart for now, should most likely be rotated during runtime too
|
|
fs.writeFileSync(outputPath, "");
|
|
let tail = new Tail(outputPath);
|
|
tail.on("line", function (data) {
|
|
plugins[i].scriptOutput(data);
|
|
});
|
|
} else {
|
|
fs.watch(outputPath, fileChangeHandler);
|
|
// run once in case a plugin wrote out information before the plugin loaded fully
|
|
// delay, so the socket got enough time to connect
|
|
setTimeout(() => {
|
|
fileChangeHandler(false, pluginConfig.scriptOutputFileSubscription);
|
|
}, 500);
|
|
|
|
// send file contents to plugin for processing
|
|
function fileChangeHandler(eventType, filename) {
|
|
if (filename != null) {
|
|
setTimeout(
|
|
() => {
|
|
// get array of lines in file
|
|
let stuff = fs.readFileSync(instance.path("script-output", filename), "utf8").split("\n");
|
|
|
|
// if you found anything, reset the file
|
|
if (stuff[0]) {
|
|
fs.writeFileSync(instance.path("script-output", filename), "");
|
|
}
|
|
for (let o = 0; o < stuff.length; o++) {
|
|
if (stuff[o] && !stuff[o].includes('\u0000\u0000')) {
|
|
try {
|
|
plugins[i].scriptOutput(stuff[o]);
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
pluginConfig.fileReadDelay || 0
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
console.log(`Clusterio | Loaded plugin ${pluginsToLoad[i].name} in ${Date.now() - pluginLoadStarted}ms`);
|
|
} else {
|
|
// this plugin doesn't have a client portion. Maybe it runs on the master only?
|
|
}
|
|
}
|
|
|
|
// world IDs ------------------------------------------------------------------
|
|
hashMods(instance, function(modHashes){
|
|
setInterval(getID, 10000);
|
|
getID();
|
|
function getID() {
|
|
messageInterface("/silent-command rcon.print(#game.connected_players)", function(playerCount) {
|
|
var payload = {
|
|
time: Date.now(),
|
|
rconPort: instanceconfig.clientPort,
|
|
rconPassword: instanceconfig.clientPassword,
|
|
serverPort: instanceconfig.factorioPort,
|
|
unique: instanceconfig.unique,
|
|
publicIP: config.publicIP, // IP of the server should be global for all instances, so we pull that straight from the config
|
|
mods:modHashes,
|
|
instanceName: instance.name,
|
|
info: instanceconfig.info,
|
|
};
|
|
if(playerCount){
|
|
payload.playerCount = playerCount.replace(/(\r\n\t|\n|\r\t)/gm, "");
|
|
} else {
|
|
payload.playerCount = 0;
|
|
}
|
|
|
|
function callback(err, mac) {
|
|
if (err) {
|
|
mac = "unknown";
|
|
console.log("##### getMac crashed, but we don't really give a shit because we are probably closing down #####");
|
|
}
|
|
global.mac = mac;
|
|
payload.mac = mac;
|
|
// console.log("Registered our presence with master "+config.masterURL+" at " + payload.time);
|
|
needle.post(config.masterURL + '/api/getID', payload, needleOptionsWithTokenAuthHeader(config), function (err, response, body) {
|
|
if (err && err.code != "ECONNRESET"){
|
|
console.error("We got problems, something went wrong when contacting master "+config.masterURL+" at " + payload.time);
|
|
console.error(err);
|
|
} else if (response && response.body) {
|
|
// In the future we might be interested in whether or not we actually manage to send it, but honestly I don't care.
|
|
if(response.body !== "ok") {
|
|
console.log("Got no \"ok\" while registering our precense with master "+config.masterURL+" at " + payload.time);
|
|
console.log(response.body);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
if(global.mac){
|
|
callback(undefined, global.mac);
|
|
} else {
|
|
getMac(callback);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
// Mod uploading and management -----------------------------------------------
|
|
// get mod names and hashes
|
|
// string: instance, function: callback
|
|
|
|
setTimeout(function(){hashMods(instance, uploadMods)}, 5000);
|
|
function uploadMods(modHashes) {
|
|
// [{modName:string,hash:string}, ... ]
|
|
for(let i=0;i<modHashes.length;i++){
|
|
let payload = {
|
|
modName: modHashes[i].modName,
|
|
hash: modHashes[i].hash,
|
|
};
|
|
needle.post(config.masterURL + '/api/checkMod', payload, needleOptionsWithTokenAuthHeader(config), function (err, response, body) {
|
|
if(err) console.error("Unable to contact master server /api/checkMod! Please check your config.json.");
|
|
if(response && body && body == "found") {
|
|
console.log("master has mod "+modHashes[i].modName);
|
|
} else if (response && body && typeof body == "string") {
|
|
let mod = response.body;
|
|
if(config.uploadModsToMaster){
|
|
console.log("Sending mod: " + mod);
|
|
// Send mods master says it wants
|
|
// response.body is a string which is a modName.zip
|
|
var req = request.post({url: config.masterURL + '/api/uploadMod',
|
|
headers: {
|
|
"x-access-token": config.masterAuthToken,
|
|
},
|
|
}, function (err, resp, body) {
|
|
if (err) {
|
|
console.error(new Error("Unable to contact master server /api/uploadMod! Please check your config.json."));
|
|
} else {
|
|
console.log('URL: ' + body);
|
|
}
|
|
});
|
|
var form = req.form();
|
|
form.append('file', fs.createReadStream(instance.path("mods", mod)));
|
|
} else {
|
|
console.log("Not sending mod: " + mod + " to master because config.uploadModsToMaster is not enabled")
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} // END OF INSTANCE START ---------------------------------------------------------------------
|
|
|
|
// 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,
|
|
_randomDynamicPort: randomDynamicPort,
|
|
_generatePassword: generatePassword,
|
|
};
|
|
|
|
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);
|
|
});
|
|
}
|