diff --git a/packages/controller/src/ControlConnection.js b/packages/controller/src/ControlConnection.js index d1835205..29db241b 100644 --- a/packages/controller/src/ControlConnection.js +++ b/packages/controller/src/ControlConnection.js @@ -1,7 +1,6 @@ "use strict"; const events = require("events"); const fs = require("fs-extra"); -const jwt = require("jsonwebtoken"); const path = require("path"); const libConfig = require("@clusterio/lib/config"); @@ -11,12 +10,10 @@ const libHelpers = require("@clusterio/lib/helpers"); const libLink = require("@clusterio/lib/link"); const { logFilter, logger } = require("@clusterio/lib/logging"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); -const libPlugin = require("@clusterio/lib/plugin"); const libPrometheus = require("@clusterio/lib/prometheus"); const libUsers = require("@clusterio/lib/users"); const BaseConnection = require("./BaseConnection"); -const InstanceInfo = require("./InstanceInfo"); const routes = require("./routes"); const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; @@ -147,19 +144,12 @@ class ControlConnection extends BaseConnection { } } - generateHostToken(hostId) { - return jwt.sign( - { aud: "host", host: hostId }, - Buffer.from(this._controller.config.get("controller.auth_secret"), "base64") - ); - } - async generateHostTokenRequestHandler(message) { let hostId = message.data.host_id; if (hostId === null) { hostId = Math.random() * 2**31 | 0; } - return { token: this.generateHostToken(hostId) }; + return { token: this._controller.generateHostToken(hostId) }; } async createHostConfigRequestHandler(message) { @@ -175,7 +165,7 @@ class ControlConnection extends BaseConnection { } if (message.data.generate_token) { this.user.checkPermission("core.host.generate_token"); - hostConfig.set("host.controller_token", this.generateHostToken(hostConfig.get("host.id"))); + hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id"))); } return { serialized_config: hostConfig.serialize() }; } @@ -230,55 +220,11 @@ class ControlConnection extends BaseConnection { async createInstanceRequestHandler(message) { let instanceConfig = new libConfig.InstanceConfig("controller"); await instanceConfig.load(message.data.serialized_config); - - let instanceId = instanceConfig.get("instance.id"); - if (this._controller.instances.has(instanceId)) { - throw new libErrors.RequestError(`Instance with ID ${instanceId} already exists`); - } - - // Add common settings for the Factorio server - let settings = { - "name": `${this._controller.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`, - "description": `Clusterio instance for ${this._controller.config.get("controller.name")}`, - "tags": ["clusterio"], - "max_players": 0, - "visibility": { "public": true, "lan": true }, - "username": "", - "token": "", - "game_password": "", - "require_user_verification": true, - "max_upload_in_kilobytes_per_second": 0, - "max_upload_slots": 5, - "ignore_player_limit_for_returning_players": false, - "allow_commands": "admins-only", - "autosave_interval": 10, - "autosave_slots": 5, - "afk_autokick_interval": 0, - "auto_pause": false, - "only_admins_can_pause_the_game": true, - "autosave_only_on_server": true, - - ...instanceConfig.get("factorio.settings"), - }; - instanceConfig.set("factorio.settings", settings); - - let instance = new InstanceInfo({ config: instanceConfig, status: "unassigned" }); - this._controller.instances.set(instanceId, instance); - await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, null); - this._controller.addInstanceHooks(instance); + await this._controller.instanceCreate(instanceConfig); } async deleteInstanceRequestHandler(message, request) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - if (instance.config.get("instance.assigned_host") !== null) { - await this.forwardRequestToInstance(message, request); - } - this._controller.instances.delete(message.data.instance_id); - - let prev = instance.status; - instance.status = "deleted"; - this._controller.instanceUpdated(instance); - await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); + await this._controller.instanceDelete(message.data.instance_id); } async getInstanceConfigRequestHandler(message) { @@ -288,19 +234,6 @@ class ControlConnection extends BaseConnection { }; } - async updateInstanceConfig(instance) { - let hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null) { - let connection = this._controller.wsServer.hostConnections.get(hostId); - if (connection) { - await libLink.messages.assignInstance.send(connection, { - instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize("host"), - }); - } - } - } - async setInstanceConfigFieldRequestHandler(message) { let instance = this._controller.getRequestInstance(message.data.instance_id); if (message.data.field === "instance.assigned_host") { @@ -313,54 +246,19 @@ class ControlConnection extends BaseConnection { } instance.config.set(message.data.field, message.data.value, "control"); - await this.updateInstanceConfig(instance); + await this._controller.instanceConfigUpdated(instance); } async setInstanceConfigPropRequestHandler(message) { let instance = this._controller.getRequestInstance(message.data.instance_id); let { field, prop, value } = message.data; instance.config.setProp(field, prop, value, "control"); - await this.updateInstanceConfig(instance); + await this._controller.instanceConfigUpdated(instance); } async assignInstanceCommandRequestHandler(message, request) { let { host_id, instance_id } = message.data; - let instance = this._controller.getRequestInstance(instance_id); - - // Check if target host is connected - let newHostConnection; - if (host_id !== null) { - newHostConnection = this._controller.wsServer.hostConnections.get(host_id); - if (!newHostConnection) { - // The case of the host not getting the assign instance message - // still have to be handled, so it's not a requirement that the - // target host be connected to the controller while doing the - // assignment, but it is IMHO a better user experience if this - // is the case. - throw new libErrors.RequestError("Target host is not connected to the controller"); - } - } - - // Unassign from currently assigned host if it is connected. - let currentAssignedHost = instance.config.get("instance.assigned_host"); - if (currentAssignedHost !== null && host_id !== currentAssignedHost) { - let oldHostConnection = this._controller.wsServer.hostConnections.get(currentAssignedHost); - if (oldHostConnection && !oldHostConnection.connector.closing) { - await libLink.messages.unassignInstance.send(oldHostConnection, { instance_id }); - } - } - - // Assign to target - instance.config.set("instance.assigned_host", host_id); - if (host_id !== null) { - await libLink.messages.assignInstance.send(newHostConnection, { - instance_id, - serialized_config: instance.config.serialize("host"), - }); - } else { - instance.status = "unassigned"; - this._controller.instanceUpdated(instance); - } + await this._controller.instanceAssign(instance_id, host_id); } async setSaveListSubscriptionsRequestHandler(message) { diff --git a/packages/controller/src/Controller.js b/packages/controller/src/Controller.js index bc748b38..556d0715 100644 --- a/packages/controller/src/Controller.js +++ b/packages/controller/src/Controller.js @@ -5,6 +5,7 @@ const express = require("express"); const fs = require("fs-extra"); const http = require("http"); const https = require("https"); +const jwt = require("jsonwebtoken"); const path = require("path"); const stream = require("stream"); @@ -544,6 +545,19 @@ class Controller { } } + /** + * Generate access token for host + * + * @param {number} hostId - ID of host to generate a token for. + * @returns {string} access token for host + */ + generateHostToken(hostId) { + return jwt.sign( + { aud: "host", host: hostId }, + Buffer.from(this.config.get("controller.auth_secret"), "base64") + ); + } + hostUpdated(host) { let update = { agent: host.agent, @@ -578,6 +592,155 @@ class Controller { return instance; } + /** + * Create a new instance + * + * Adds common Factorio settings to the provided instance config then + * creates an instance using that config in the cluster. + * + * @example + * let instanceConfig = new libConfig.InstanceConfig("controller"); + * await instanceConfig.init(); + * instanceConfig.set("instance.name", "My instance"); + * await controller.createInstance(instanceConfig); + * + * @param {module:lib/config.InstanceConfig} instanceConfig - + * Config to base newly created instance on. + * @returns {module:controller/src/InstanceInfo} The created instance + */ + async instanceCreate(instanceConfig) { + let instanceId = instanceConfig.get("instance.id"); + if (this.instances.has(instanceId)) { + throw new libErrors.RequestError(`Instance with ID ${instanceId} already exists`); + } + + // Add common settings for the Factorio server + let settings = { + "name": `${this.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`, + "description": `Clusterio instance for ${this.config.get("controller.name")}`, + "tags": ["clusterio"], + "max_players": 0, + "visibility": { "public": true, "lan": true }, + "username": "", + "token": "", + "game_password": "", + "require_user_verification": true, + "max_upload_in_kilobytes_per_second": 0, + "max_upload_slots": 5, + "ignore_player_limit_for_returning_players": false, + "allow_commands": "admins-only", + "autosave_interval": 10, + "autosave_slots": 5, + "afk_autokick_interval": 0, + "auto_pause": false, + "only_admins_can_pause_the_game": true, + "autosave_only_on_server": true, + + ...instanceConfig.get("factorio.settings"), + }; + instanceConfig.set("factorio.settings", settings); + + let instance = new InstanceInfo({ config: instanceConfig, status: "unassigned" }); + this.instances.set(instanceId, instance); + await libPlugin.invokeHook(this.plugins, "onInstanceStatusChanged", instance, null); + this.addInstanceHooks(instance); + } + + /** + * Change assigned host of an instance + * + * Unassigns instance from existing host if already assigned to one and + * then assigns it to the given host. This is the only supported way of + * changing the instance.assigned_host config entry. + * + * Note: this will not transfer any files or saves stored on the host + * the instance was previously assgined to the new one. + * + * @param {number} instanceId - ID of Instance to assign. + * @param {number} hostId - ID of host to assign instance to. + */ + async instanceAssign(instanceId, hostId) { + let instance = this.getRequestInstance(instanceId); + + // Check if target host is connected + let newHostConnection; + if (hostId !== null) { + newHostConnection = this.wsServer.hostConnections.get(hostId); + if (!newHostConnection) { + // The case of the host not getting the assign instance message + // still have to be handled, so it's not a requirement that the + // target host be connected to the controller while doing the + // assignment, but it is IMHO a better user experience if this + // is the case. + throw new libErrors.RequestError("Target host is not connected to the controller"); + } + } + + // Unassign from currently assigned host if it is connected. + let currentAssignedHost = instance.config.get("instance.assigned_host"); + if (currentAssignedHost !== null && hostId !== currentAssignedHost) { + let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost); + if (oldHostConnection && !oldHostConnection.connector.closing) { + await libLink.messages.unassignInstance.send(oldHostConnection, { instance_id: instanceId }); + } + } + + // Assign to target + instance.config.set("instance.assigned_host", hostId); + if (hostId !== null) { + await libLink.messages.assignInstance.send(newHostConnection, { + instance_id: instanceId, + serialized_config: instance.config.serialize("host"), + }); + } else { + instance.status = "unassigned"; + this.instanceUpdated(instance); + } + } + + /** + * Delete an instance + * + * Permanently deletes the instance from its assigned host (if assigned) + * and the controller. This action cannot be undone. + * + * @param {number} instanceId - ID of instance to delete. + */ + async instanceDelete(instanceId) { + let instance = this.getRequestInstance(instanceId); + if (instance.config.get("instance.assigned_host") !== null) { + await this.forwardRequestToInstance(libLink.messages.deleteInstance, { instance_id: instanceId }); + } + this.instances.delete(instanceId); + + let prev = instance.status; + instance.status = "deleted"; + this.instanceUpdated(instance); + await libPlugin.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev); + } + + /** + * Notify the config of an instance updated + * + * Used to push config changes to the assigned host when the config of + * an instance has changed. + * + * @param {module:controller/src/InstanceInfo} instance - + * Instance to nodify the config updated. + */ + async instanceConfigUpdated(instance) { + let hostId = instance.config.get("instance.assigned_host"); + if (hostId !== null) { + let connection = this.wsServer.hostConnections.get(hostId); + if (connection) { + await libLink.messages.assignInstance.send(connection, { + instance_id: instance.config.get("instance.id"), + serialized_config: instance.config.serialize("host"), + }); + } + } + } + addInstanceHooks(instance) { instance.config.on("fieldChanged", (group, field, prev) => { if (group.name === "instance" && field === "name") {