mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Sending ^C via the terminal on Linux may cause SIGINT to be sent twice
to the factorio process, causing it to exit without completing the
saving. This happens because the shell sends the interupt to all
members of the process group for the job and this includes both the
node client process and the factorio server process.
Avoid this situation by passing detached to spawn when the platform is
Linux, which causes the process to be created with its own process group
and therefore not targeted by a SIGINT from the shell.
This unfortunately does not work on Windows as we can't send SIGINT or
^C to a process there. Doing subprocces.kill on Windows terminates the
process similarly to how SIGKILL works on Linux.
This is a port of 6b090cd.
943 lines
34 KiB
JavaScript
943 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 slaves depends \
|
|
upon this, please add your token to config.json in the field named masterAuthToken. \
|
|
You can retrieve your auth token from the master in secret-api-token.txt after running it once.");
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|