diff --git a/.eslintrc.js b/.eslintrc.js index 8adba765..76d4a5b5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -136,7 +136,7 @@ module.exports = { "linebreak-style": ["error", "unix"], "lines-around-comment": "off", "lines-around-directive": "off", - "lines-between-class-members": ["error", "always"], + "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }], "max-classes-per-file": "off", "max-depth": "error", "max-len": ["error", { "code": 120 }], diff --git a/docs/devs/json-serialisable-classes.md b/docs/devs/json-serialisable-classes.md index 51101b43..ca152c2a 100644 --- a/docs/devs/json-serialisable-classes.md +++ b/docs/devs/json-serialisable-classes.md @@ -46,7 +46,7 @@ module.exports = Data; ``` Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object. -Deserialising is done by calling the static `fromJSON` method with the serialised JSON data, like `data = Data.fromJson(JSON.parse(serialised))`. +Deserialising is done by calling the static `fromJSON` method with the serialised JSON data, like `data = Data.fromJSON(JSON.parse(serialised))`. By structuring the classes like this they can be easily composed together. For example a class holding a data property with an instance of the Data class from the previous example can be created as follows. diff --git a/package.json b/package.json index 9e54a877..0d1e6e83 100644 --- a/package.json +++ b/package.json @@ -21,16 +21,16 @@ "node": ">=12" }, "devDependencies": { - "@clusterio/ctl": "workspace:*", - "@clusterio/lib": "workspace:*", "@clusterio/controller": "workspace:*", + "@clusterio/ctl": "workspace:*", "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "eslint": "^8.18.0", "eslint-plugin-node": "^11.1.0", "express": "^4.16.2", "form-data": "^4.0.0", "fs-extra": "^8.1.0", - "jsdoc": "^3.6.6", + "jsdoc": "^4.0.2", "jsonwebtoken": "^8.2.1", "jszip": "^3.2.2", "mocha": "^9.2.2", diff --git a/packages/controller/controller.js b/packages/controller/controller.js index 6eaa943d..86f7cedd 100755 --- a/packages/controller/controller.js +++ b/packages/controller/controller.js @@ -102,7 +102,7 @@ void new libPrometheus.Gauge( { labels: ["type"], callback: async function(gauge) { gauge.labels("host").set(controller.wsServer.hostConnections.size); - gauge.labels("control").set(controller.wsServer.controlConnections.length); + gauge.labels("control").set(controller.wsServer.controlConnections.size); }, }, ); diff --git a/packages/controller/src/BaseConnection.js b/packages/controller/src/BaseConnection.js index 3206b805..fa0e46ba 100644 --- a/packages/controller/src/BaseConnection.js +++ b/packages/controller/src/BaseConnection.js @@ -1,8 +1,10 @@ "use strict"; +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); const libPlugin = require("@clusterio/lib/plugin"); +const ControllerRouter = require("./ControllerRouter"); /** @@ -12,68 +14,28 @@ const libPlugin = require("@clusterio/lib/plugin"); * @alias module:controller/src/BaseConnection */ class BaseConnection extends libLink.Link { - constructor(target, connector, controller) { + constructor(connector, controller) { /** @member {module:controller/src/WsServerConnector} module:controller/src/BaseConnection#connector */ - super("controller", target, connector); + super(connector); + this.router = new ControllerRouter(controller); this._controller = controller; - this._disconnecting = false; - libLink.attachAllMessages(this); - for (let controllerPlugin of this._controller.plugins.values()) { - libPlugin.attachPluginMessages(this, controllerPlugin); - } - } + for (let [Request, handler] of controller._registeredRequests) { this.register(Request, handler); } + for (let [Request, handler] of controller._fallbackedRequests) { this.fallbackRequest(Request, handler); } + for (let [Event, handler] of controller._registeredEvents) { this.register(Event, handler); } + for (let [Event, handler] of controller._snoopedEvents) { this.snoopEvent(Event, handler); } - async forwardRequestToInstance(message, request) { - return await this._controller.forwardRequestToInstance(request, message.data); - } - - async forwardEventToInstance(message, event) { - let instance = this._controller.instances.get(message.data.instance_id); - if (!instance) { return; } - - let hostId = instance.config.get("instance.assigned_host"); - if (hostId === null) { return; } - - let connection = this._controller.wsServer.hostConnections.get(hostId); - if (!connection || connection.closing) { return; } - if (event.plugin && !connection.plugins.has(event.plugin)) { return; } - - event.send(connection, message.data); - } - - async broadcastEventToHosts(message, event) { - for (let hostConnection of this._controller.wsServer.hostConnections.values()) { - // Do not broadcast back to the source - if (hostConnection === this) { continue; } - if (hostConnection.connector.closing) { continue; } - if (event.plugin && !hostConnection.plugins.has(event.plugin)) { continue; } - - event.send(hostConnection, message.data); - } - } - - async broadcastEventToInstance(message, event) { - await this.broadcastEventToHosts(message, event); - } - - async prepareDisconnectRequestHandler(message, request) { - await libPlugin.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this); - this._disconnecting = true; - this.connector.setClosing(); - return await super.prepareDisconnectRequestHandler(message, request); + this.register(libData.ModPackGetRequest, this.handleModPackGetRequest.bind(this)); + this.register(libData.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this)); } async disconnect(code, reason) { - this.connector.setClosing(); try { - await libLink.messages.prepareDisconnect.send(this); + await this.connector.disconnect(); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`); } } - - await this.connector.close(code, reason); } /** @@ -82,19 +44,19 @@ class BaseConnection extends libLink.Link { * @type {boolean} */ get connected() { - return !this._disconnecting && this.connector.connected; + return this.connector.connected; } - async getModPackRequestHandler(message) { - let { id } = message.data; + async handleModPackGetRequest(request) { + let { id } = request; let modPack = this._controller.modPacks.get(id); if (!modPack) { throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`); } - return { mod_pack: modPack.toJSON() }; + return modPack; } - async getDefaultModPackRequestHandler(message) { + async handleModPackGetDefaultRequest() { let id = this._controller.config.get("controller.default_mod_pack_id"); if (id === null) { throw new libErrors.RequestError("Default mod pack not set on controller"); @@ -103,7 +65,7 @@ class BaseConnection extends libLink.Link { if (!modPack) { throw new libErrors.RequestError(`Default mod pack configured (${id}) does not exist`); } - return { mod_pack: modPack.toJSON() }; + return modPack; } } diff --git a/packages/controller/src/ControlConnection.js b/packages/controller/src/ControlConnection.js index bc8a1bde..776ae636 100644 --- a/packages/controller/src/ControlConnection.js +++ b/packages/controller/src/ControlConnection.js @@ -30,11 +30,13 @@ const queryLogTime = new libPrometheus.Summary( * @alias module:controller/src/ControlConnection */ class ControlConnection extends BaseConnection { - constructor(registerData, connector, controller, user) { - super("control", connector, controller); + constructor(registerData, connector, controller, user, id) { + super(connector, controller); this._agent = registerData.agent; this._version = registerData.version; + this.id = id; + this.dst = new libData.Address(libData.Address.instance, id); /** * The user making this connection. @@ -44,27 +46,27 @@ class ControlConnection extends BaseConnection { this.hostSubscriptions = { all: false, - host_ids: [], + hostIds: [], }; this.instanceSubscriptions = { all: false, - instance_ids: [], + instanceIds: [], }; this.saveListSubscriptions = { all: false, - instance_ids: [], + instanceIds: [], }; this.modPackSubscriptions = { all: false, - mod_pack_ids: [], + modPackIds: [], }; this.modSubscriptions = { all: false, - mod_names: [], + modNames: [], }; this.userSubscriptions = { @@ -76,8 +78,8 @@ class ControlConnection extends BaseConnection { this.logSubscriptions = { all: false, controller: false, - host_ids: [], - instance_ids: [], + hostIds: [], + instanceIds: [], }; this.ws_dumper = null; @@ -101,183 +103,245 @@ class ControlConnection extends BaseConnection { } }); } + + this.register(libData.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this)); + this.register(libData.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this)); + this.register(libData.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this)); + this.register(libData.HostListRequest, this.handleHostListRequest.bind(this)); + this.register(libData.HostSetSubscriptionsRequest, this.handleHostSetSubscriptionsRequest.bind(this)); + this.register(libData.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this)); + this.register(libData.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this)); + this.register(libData.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this)); + this.register(libData.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this)); + this.register( + libData.InstanceDetailsSetSubscriptionsRequest, this.handleInstanceDetailsSetSubscriptionsRequest.bind(this) + ); + this.register(libData.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this)); + this.register(libData.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this)); + this.register(libData.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this)); + this.register(libData.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this)); + this.register(libData.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this)); + this.register(libData.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this)); + this.register( + libData.InstanceSetSaveListSubscriptionsRequest, + this.handleInstanceSetSaveListSubscriptionsRequest.bind(this) + ); + this.register( + libData.InstanceRenameSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller) + ); + this.register(libData.InstanceCopySaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller)); + this.register( + libData.InstanceDeleteSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller) + ); + this.register(libData.InstanceDownloadSaveRequest, this.handleInstanceDownloadSaveRequest.bind(this)); + this.register(libData.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this)); + this.register(libData.ModPackListRequest, this.handleModPackListRequest.bind(this)); + this.register(libData.ModPackSetSubscriptionsRequest, this.handleModPackSetSubscriptionsRequest.bind(this)); + this.register(libData.ModPackCreateRequest, this.handleModPackCreateRequest.bind(this)); + this.register(libData.ModPackUpdateRequest, this.handleModPackUpdateRequest.bind(this)); + this.register(libData.ModPackDeleteRequest, this.handleModPackDeleteRequest.bind(this)); + this.register(libData.ModGetRequest, this.handleModGetRequest.bind(this)); + this.register(libData.ModListRequest, this.handleModListRequest.bind(this)); + this.register(libData.ModSearchRequest, this.handleModSearchRequest.bind(this)); + this.register(libData.ModSetSubscriptionsRequest, this.handleModSetSubscriptionsRequest.bind(this)); + this.register(libData.ModDownloadRequest, this.handleModDownloadRequest.bind(this)); + this.register(libData.ModDeleteRequest, this.handleModDeleteRequest.bind(this)); + this.register(libData.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this)); + this.register(libData.LogQueryRequest, this.handleLogQueryRequest.bind(this)); + this.register(libData.PermissionListRequest, this.handlePermissionListRequest.bind(this)); + this.register(libData.RoleListRequest, this.handleRoleListRequest.bind(this)); + this.register(libData.RoleCreateRequest, this.handleRoleCreateRequest.bind(this)); + this.register(libData.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this)); + this.register( + libData.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this) + ); + this.register(libData.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this)); + this.register(libData.UserGetRequest, this.handleUserGetRequest.bind(this)); + this.register(libData.UserListRequest, this.handleUserListRequest.bind(this)); + this.register(libData.UserSetSubscriptionsRequest, this.handleUserSetSubscriptionsRequest.bind(this)); + this.register(libData.UserCreateRequest, this.handleUserCreateRequest.bind(this)); + this.register(libData.UserRevokeTokenRequest, this.handleUserRevokeTokenRequest.bind(this)); + this.register(libData.UserUpdateRolesRequest, this.handleUserUpdateRolesRequest.bind(this)); + this.register(libData.UserSetAdminRequest, this.handleUserSetAdminRequest.bind(this)); + this.register(libData.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this)); + this.register(libData.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this)); + this.register(libData.UserDeleteRequest, this.handleUserDeleteRequest.bind(this)); + this.register(libData.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); } - async getControllerConfigRequestHandler() { - return { serialized_config: this._controller.config.serialize("control") }; + async handleControllerConfigGetRequest() { + return new libData.RawConfig(this._controller.config.serialize("control")); } - async setControllerConfigFieldRequestHandler(message) { - this._controller.config.set(message.data.field, message.data.value, "control"); + async handleControllerConfigSetFieldRequest(request) { + this._controller.config.set(request.field, request.value, "control"); } - async setControllerConfigPropRequestHandler(message) { - let { field, prop, value } = message.data; + async handleControllerConfigSetPropRequest(request) { + let { field, prop, value } = request; this._controller.config.setProp(field, prop, value, "control"); } - async listHostsRequestHandler(message) { + async handleHostListRequest() { let list = []; for (let host of this._controller.hosts.values()) { - list.push({ - agent: host.agent, - version: host.version, - id: host.id, - name: host.name, - public_address: host.public_address || null, - connected: this._controller.wsServer.hostConnections.has(host.id), - }); + list.push(new libData.HostDetails( + host.agent, + host.version, + host.name, + host.id, + this._controller.wsServer.hostConnections.has(host.id), + host.public_address, + )); } - return { list }; + return list; } - async setHostSubscriptionsRequestHandler(message) { - this.hostSubscriptions = message.data; + async handleHostSetSubscriptionsRequest(request) { + this.hostSubscriptions = { ...request }; } hostUpdated(host, update) { if ( this.hostSubscriptions.all - || this.hostSubscriptions.host_ids.includes(host.id) + || this.hostSubscriptions.hostIds.includes(host.id) ) { - libLink.messages.hostUpdate.send(this, update); + this.send(new libData.HostUpdateEvent(update)); } } - async generateHostTokenRequestHandler(message) { - let hostId = message.data.host_id; + async handleHostGenerateTokenRequest(message) { + let hostId = message.host_id; if (hostId === null) { hostId = Math.random() * 2**31 | 0; } - return { token: this._controller.generateHostToken(hostId) }; + return this._controller.generateHostToken(hostId); } - async createHostConfigRequestHandler(message) { + async handleHostConfigCreateRequest(request) { let hostConfig = new libConfig.HostConfig("control"); await hostConfig.init(); hostConfig.set("host.controller_url", this._controller.getControllerUrl()); - if (message.data.id !== null) { - hostConfig.set("host.id", message.data.id); + if (request.id !== null) { + hostConfig.set("host.id", request.id); } - if (message.data.name !== null) { - hostConfig.set("host.name", message.data.name); + if (request.name !== null) { + hostConfig.set("host.name", request.name); } - if (message.data.generate_token) { + if (request.generateToken) { this.user.checkPermission("core.host.generate_token"); hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id"))); } - return { serialized_config: hostConfig.serialize() }; + return new libData.RawConfig(hostConfig.serialize()); } - async getInstanceRequestHandler(message) { - let id = message.data.id; - let instance = this._controller.getRequestInstance(id); + async handleInstanceDetailsGetRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); - return { - id, - name: instance.config.get("instance.name"), - assigned_host: instance.config.get("instance.assigned_host"), - game_port: instance.game_port || null, - status: instance.status, - }; + return new libData.InstanceDetails( + instance.config.get("instance.name"), + instance.id, + instance.config.get("instance.assigned_host"), + instance.game_port || null, + instance.status, + ); } - async listInstancesRequestHandler(message) { + async handleInstanceDetailsListRequest() { let list = []; for (let instance of this._controller.instances.values()) { - list.push({ - id: instance.id, - name: instance.config.get("instance.name"), - assigned_host: instance.config.get("instance.assigned_host"), - game_port: instance.game_port || null, - status: instance.status, - }); + list.push(new libData.InstanceDetails( + instance.config.get("instance.name"), + instance.id, + instance.config.get("instance.assigned_host"), + instance.game_port || null, + instance.status, + )); } - return { list }; + return list; } - async setInstanceSubscriptionsRequestHandler(message) { - this.instanceSubscriptions = message.data; + async handleInstanceDetailsSetSubscriptionsRequest(request) { + this.instanceSubscriptions = { ...request }; } instanceUpdated(instance) { if ( this.instanceSubscriptions.all - || this.instanceSubscriptions.instance_ids.includes(instance.id) + || this.instanceSubscriptions.instanceIds.includes(instance.id) ) { - libLink.messages.instanceUpdate.send(this, { - id: instance.id, - name: instance.config.get("instance.name"), - assigned_host: instance.config.get("instance.assigned_host"), - game_port: instance.game_port || null, - status: instance.status, - }); + this.send(new libData.InstanceDetailsUpdateEvent( + new libData.InstanceDetails( + instance.config.get("instance.name"), + instance.id, + instance.config.get("instance.assigned_host"), + instance.game_port || null, + instance.status, + ) + )); } } // XXX should probably add a hook for host reuqests? - async createInstanceRequestHandler(message) { + async handleInstanceCreateRequest(request) { let instanceConfig = new libConfig.InstanceConfig("controller"); - await instanceConfig.load(message.data.serialized_config); + await instanceConfig.load(request.config); await this._controller.instanceCreate(instanceConfig); } - async deleteInstanceRequestHandler(message, request) { - await this._controller.instanceDelete(message.data.instance_id); + async handleInstanceDeleteRequest(request) { + await this._controller.instanceDelete(request.instanceId); } - async getInstanceConfigRequestHandler(message) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - return { - serialized_config: instance.config.serialize("control"), - }; + async handleInstanceConfigGetRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); + return new libData.InstanceConfigGetRequest.Response(instance.config.serialize("control")); } - async setInstanceConfigFieldRequestHandler(message) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - if (message.data.field === "instance.assigned_host") { + async handleInstanceConfigSetFieldRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); + if (request.field === "instance.assigned_host") { throw new libErrors.RequestError("instance.assigned_host must be set through the assign-host interface"); } - if (message.data.field === "instance.id") { + if (request.field === "instance.id") { // XXX is this worth implementing? It's race condition galore. throw new libErrors.RequestError("Setting instance.id is not supported"); } - instance.config.set(message.data.field, message.data.value, "control"); + instance.config.set(request.field, request.value, "control"); await this._controller.instanceConfigUpdated(instance); } - async setInstanceConfigPropRequestHandler(message) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - let { field, prop, value } = message.data; + async handleInstanceConfigSetPropRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); + let { field, prop, value } = request; instance.config.setProp(field, prop, value, "control"); await this._controller.instanceConfigUpdated(instance); } - async assignInstanceCommandRequestHandler(message, request) { - let { host_id, instance_id } = message.data; - await this._controller.instanceAssign(instance_id, host_id); + async handleInstanceAssignRequest(request) { + await this._controller.instanceAssign(request.instanceId, request.hostId); } - async setSaveListSubscriptionsRequestHandler(message) { - this.saveListSubscriptions = message.data; + async handleInstanceSetSaveListSubscriptionsRequest(request) { + this.saveListSubscriptions = { ...request }; } - saveListUpdate(data) { + saveListUpdate(instanceId, saves) { if ( this.saveListSubscriptions.all - || this.saveListSubscriptions.instance_ids.includes(data.instance_id) + || this.saveListSubscriptions.instanceIds.includes(instanceId) ) { - libLink.messages.saveListUpdate.send(this, data); + this.send(new libData.InstanceSaveListUpdateEvent(instanceId, saves)); } } - async downloadSaveRequestHandler(message) { - let { instance_id, save } = message.data; + async handleInstanceDownloadSaveRequest(request) { + let { instanceId, name } = request; let stream = await routes.createProxyStream(this._controller.app); - stream.filename = save; + stream.filename = name; let ready = new Promise((resolve, reject) => { stream.events.on("source", resolve); @@ -287,29 +351,29 @@ class ControlConnection extends BaseConnection { }); ready.catch(() => {}); - await this._controller.forwardRequestToInstance(libLink.messages.pushSave, { - instance_id, - stream_id: stream.id, - save, - }); + await this._controller.sendToHostByInstanceId(new libData.InstancePushSaveRequest( + instanceId, + stream.id, + name, + )); await ready; - return { stream_id: stream.id }; + return stream.id; } - async transferSaveRequestHandler(message, request) { - let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; - if (copy) { + async handleInstanceTransferSaveRequest(request) { + // XXX if control sends the request directly to a host these checks are bypassed + if (request.copy) { this.user.checkPermission("core.instance.save.copy"); - } else if (source_save !== target_save) { + } else if (request.sourceName !== request.targetName) { this.user.checkPermission("core.instance.save.rename"); } - if (instance_id === target_instance_id) { + if (request.sourceInstanceId === request.targetInstanceId) { throw new libErrors.RequestError("Source and target instance may not be the same"); } - let sourceInstance = this._controller.getRequestInstance(instance_id); - let targetInstance = this._controller.getRequestInstance(target_instance_id); + let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId); + let targetInstance = this._controller.getRequestInstance(request.targetInstanceId); let sourceHostId = sourceInstance.config.get("instance.assigned_host"); let targetHostId = targetInstance.config.get("instance.assigned_host"); if (sourceHostId === null) { @@ -321,7 +385,7 @@ class ControlConnection extends BaseConnection { // Let host handle request if source and target is on the same host. if (sourceHostId === targetHostId) { - return await this.forwardRequestToInstance(message, request); + return await this._controller.sendTo(request, { hostId: sourceHostId }); } // Check connectivity @@ -350,42 +414,40 @@ class ControlConnection extends BaseConnection { // Establish push from source host to stream, this is done first to // ensure the file size is known prior to the target host pull. await Promise.all([ - this._controller.forwardRequestToInstance(libLink.messages.pushSave, { - instance_id, - stream_id: stream.id, - save: source_save, - }), + this._controller.sendTo( + new libData.InstancePushSaveRequest(request.sourceInstanceId, stream.id, request.sourceName), + { hostId: sourceHostId } + ), events.once(stream.events, "source"), ]); // Establish pull from target host to stream and wait for completion. - let result = await this._controller.forwardRequestToInstance(libLink.messages.pullSave, { - instance_id: target_instance_id, - stream_id: stream.id, - filename: target_save, - }); + let storedName = await this._controller.sendTo( + new libData.InstancePullSaveRequest(request.targetInstanceId, stream.id, request.targetName), + { hostId: targetHostId }, + ); // Delete source save if this is not a copy - if (!copy) { - await this._controller.forwardRequestToInstance(libLink.messages.deleteSave, { - instance_id, - save: source_save, - }); + if (!request.copy) { + await this._controller.sendTo( + new libData.InstanceDeleteSaveRequest(request.sourceInstanceId, request.sourceName), + { hostId: sourceHostId } + ); } - return { save: result.save }; + return storedName; } - async listModPacksRequestHandler(message) { - return { list: [...this._controller.modPacks.values()].map(pack => pack.toJSON()) }; + async handleModPackListRequest() { + return [...this._controller.modPacks.values()]; } - async setModPackSubscriptionsRequestHandler(message) { - this.modPackSubscriptions = message.data; + async handleModPackSetSubscriptionsRequest(request) { + this.modPackSubscriptions = { ...request }; } - async createModPackRequestHandler(message) { - let modPack = libData.ModPack.fromJSON(message.data.mod_pack); + async handleModPackCreateRequest(request) { + let modPack = request.modPack; if (this._controller.modPacks.has(modPack.id)) { throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} already exist`); } @@ -393,8 +455,8 @@ class ControlConnection extends BaseConnection { this._controller.modPackUpdated(modPack); } - async updateModPackRequestHandler(message) { - let modPack = libData.ModPack.fromJSON(message.data.mod_pack); + async handleModPackUpdateRequest(request) { + let modPack = request.modPack; if (!this._controller.modPacks.has(modPack.id)) { throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} does not exist`); } @@ -402,8 +464,8 @@ class ControlConnection extends BaseConnection { this._controller.modPackUpdated(modPack); } - async deleteModPackRequestHandler(message) { - let { id } = message.data; + async handleModPackDeleteRequest(request) { + let { id } = request; let modPack = this._controller.modPacks.get(id); if (!modPack) { throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`); @@ -416,26 +478,24 @@ class ControlConnection extends BaseConnection { modPackUpdated(modPack) { if ( this.modPackSubscriptions.all - || this.modPackSubscriptions.mod_pack_ids.includes(modPack.id) + || this.modPackSubscriptions.modPackIds.includes(modPack.id) ) { - libLink.messages.modPackUpdate.send(this, { mod_pack: modPack.toJSON() }); + this.send(new libData.ModPackUpdateEvent(modPack)); } } - async getModRequestHandler(message) { - let { name, version } = message.data; + async handleModGetRequest(request) { + let { name, version } = request; let filename = `${name}_${version}.zip`; let mod = this._controller.mods.get(filename); if (!mod) { throw new libErrors.RequestError(`Mod ${filename} does not exist`); } - return { - mod: mod.toJSON(), - }; + return mod; } - async listModsRequestHandler(message) { - return { list: [...this._controller.mods.values()].map(mod => mod.toJSON()) }; + async handleModListRequest() { + return [...this._controller.mods.values()]; } static termsMatchesMod(terms, mod) { @@ -456,8 +516,8 @@ class ControlConnection extends BaseConnection { return true; } - async searchModsRequestHandler(message) { - let query = libHelpers.parseSearchString(message.data.query, { + async handleModSearchRequest(request) { + let query = libHelpers.parseSearchString(request.query, { name: "word", // version title: "word", @@ -471,7 +531,7 @@ class ControlConnection extends BaseConnection { // size sha1: "word", }); - let factorioVersion = message.data.factorio_version; + let factorioVersion = request.factorioVersion; let results = new Map(); for (let mod of this._controller.mods.values()) { @@ -497,7 +557,7 @@ class ControlConnection extends BaseConnection { } let resultList = [...results.values()]; - const sort = message.data.sort; + const sort = request.sort; if (sort) { const sorters = { name: (a, b) => strcmp(a.versions[0].name, b.versions[0].name), @@ -508,30 +568,30 @@ class ControlConnection extends BaseConnection { throw new libErrors.RequestError(`Invalid value for sort: ${sort}`); } resultList.sort(sorters[sort]); - let order = message.data.sort_order; + let order = request.sortOrder; if (order === "desc") { resultList.reverse(); } } - const page = message.data.page; - const pageSize = message.data.page_size || 10; + const page = request.page; + const pageSize = request.pageSize || 10; resultList = resultList.slice((page - 1) * pageSize, page * pageSize); return { - query_issues: query.issues, - page_count: Math.ceil(results.size / pageSize), - result_count: results.size, + queryIssues: query.issues, + pageCount: Math.ceil(results.size / pageSize), + resultCount: results.size, results: resultList, }; } - async setModSubscriptionsRequestHandler(message) { - this.modSubscriptions = message.data; + async handleModSetSubscriptionsRequest(request) { + this.modSubscriptions = { ...request }; } - async downloadModRequestHandler(message) { - let { name, version } = message.data; + async handleModDownloadRequest(request) { + let { name, version } = request; let filename = `${name}_${version}.zip`; let mod = this._controller.mods.get(filename); if (!mod) { @@ -545,30 +605,30 @@ class ControlConnection extends BaseConnection { stream.mime = "application/zip"; stream.size = mod.size; - return { stream_id: stream.id }; + return stream.id; } - async deleteModRequestHandler(message) { - await this._controller.deleteMod(message.data.name, message.data.version); + async handleModDeleteRequest(request) { + await this._controller.deleteMod(request.name, request.version); } - async setLogSubscriptionsRequestHandler(message) { - this.logSubscriptions = message.data; + async handleLogSetSubscriptionsRequest(request) { + this.logSubscriptions = { ...request }; this.updateLogSubscriptions(); } modUpdated(mod) { if ( this.modSubscriptions.all - || this.modSubscriptions.mod_names.includes(mod.name) + || this.modSubscriptions.modNames.includes(mod.name) ) { - libLink.messages.modUpdate.send(this, { mod: mod.toJSON() }); + this.send(new libData.ModUpdateEvent(mod)); } } updateLogSubscriptions() { - let { all, controller, host_ids, instance_ids } = this.logSubscriptions; - if (all || controller || host_ids.length || instance_ids.length) { + let { all, controller, hostIds, instanceIds } = this.logSubscriptions; + if (all || controller || hostIds.length || instanceIds.length) { if (!this.logTransport) { this.logTransport = new libLoggingUtils.LinkTransport({ link: this }); this._controller.clusterLogger.add(this.logTransport); @@ -581,57 +641,57 @@ class ControlConnection extends BaseConnection { } } - async queryLogRequestHandler(message) { + async handleLogQueryRequest(request) { let observeDuration = queryLogTime.startTimer(); - let { all, controller, host_ids, instance_ids } = message.data; + let { all, controller, hostIds, instanceIds } = request; let log; - if (!all && controller && !host_ids.length && !instance_ids.length) { - log = await this._controller.queryControllerLog(message.data); + if (!all && controller && !hostIds.length && !instanceIds.length) { + log = await this._controller.queryControllerLog(request); } else { - log = await this._controller.queryClusterLog(message.data); + log = await this._controller.queryClusterLog(request); } observeDuration(); return { log }; } - async listPermissionsRequestHandler(message) { + async handlePermissionListRequest() { let list = []; for (let permission of libUsers.permissions.values()) { - list.push({ - name: permission.name, - title: permission.title, - description: permission.description, - }); + list.push(new libData.RawPermission( + permission.name, + permission.title, + permission.description, + )); } - return { list }; + return list; } - async listRolesRequestHandler(message) { + async handleRoleListRequest() { let list = []; for (let role of this._controller.userManager.roles.values()) { - list.push({ - id: role.id, - name: role.name, - description: role.description, - permissions: [...role.permissions], - }); + list.push(new libData.RawRole( + role.id, + role.name, + role.description, + [...role.permissions], + )); } - return { list }; + return list; } - async createRoleRequestHandler(message) { + async handleRoleCreateRequest(request) { let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]); // Start at 5 to leave space for future default roles let id = Math.max(5, lastId+1); - this._controller.userManager.roles.set(id, new libUsers.Role({ id, ...message.data })); - return { id }; + this._controller.userManager.roles.set(id, new libUsers.Role({ id, ...request })); + return id; } - async updateRoleRequestHandler(message) { - let { id, name, description, permissions } = message.data; + async handleRoleUpdateRequest(request) { + let { id, name, description, permissions } = request; let role = this._controller.userManager.roles.get(id); if (!role) { throw new libErrors.RequestError(`Role with ID ${id} does not exist`); @@ -643,18 +703,18 @@ class ControlConnection extends BaseConnection { this._controller.rolePermissionsUpdated(role); } - async grantDefaultRolePermissionsRequestHandler(message) { - let role = this._controller.userManager.roles.get(message.data.id); + async handleRoleGrantDefaultPermissionsRequest(request) { + let role = this._controller.userManager.roles.get(request.id); if (!role) { - throw new libErrors.RequestError(`Role with ID ${message.data.id} does not exist`); + throw new libErrors.RequestError(`Role with ID ${request.id} does not exist`); } role.grantDefaultPermissions(); this._controller.rolePermissionsUpdated(role); } - async deleteRoleRequestHandler(message) { - let id = message.data.id; + async handleRoleDeleteRequest(request) { + let id = request.id; let role = this._controller.userManager.roles.get(id); if (!role) { throw new libErrors.RequestError(`Role with ID ${id} does not exist`); @@ -667,44 +727,48 @@ class ControlConnection extends BaseConnection { } } - async getUserRequestHandler(message) { - let name = message.data.name; + async handleUserGetRequest(request) { + let name = request.name; let user = this._controller.userManager.users.get(name); if (!user) { throw new libErrors.RequestError(`User ${name} does not exist`); } - return { - name: user.name, - roles: [...user.roles].map(role => role.id), - is_admin: user.isAdmin, - is_banned: user.isBanned, - is_whitelisted: user.isWhitelisted, - ban_reason: user.banReason, - instances: [...user.instances], - player_stats: user.playerStats, - instance_stats: [...user.instanceStats], - }; + return new libData.RawUser( + user.name, + [...user.roles].map(role => role.id), + [...user.instances], + user.isAdmin, + user.isBanned, + user.isWhitelisted, + user.banReason, + undefined, + user.playerStats, + [...user.instanceStats], + ); } - async listUsersRequestHandler(message) { + async handleUserListRequest() { let list = []; for (let user of this._controller.userManager.users.values()) { - list.push({ - name: user.name, - roles: [...user.roles].map(role => role.id), - is_admin: user.isAdmin, - is_banned: user.isBanned, - is_whitelisted: user.isWhitelisted, - instances: [...user.instances], - player_stats: user.playerStats, - }); + list.push(new libData.RawUser( + user.name, + [...user.roles].map(role => role.id), + [...user.instances], + user.isAdmin, + user.isBanned, + user.isWhitelisted, + undefined, + undefined, + user.playerStats, + undefined, + )); } - return { list }; + return list; } - async setUserSubscriptionsRequestHandler(message) { - this.userSubscriptions = message.data; + async handleUserSetSubscriptionsRequest(request) { + this.userSubscriptions = { ...request }; } userUpdated(user) { @@ -712,37 +776,39 @@ class ControlConnection extends BaseConnection { this.userSubscriptions.all || this.userSubscriptions.names.includes(user.name) ) { - libLink.messages.userUpdate.send(this, { - name: user.name, - roles: [...user.roles].map(role => role.id), - is_admin: user.isAdmin, - is_banned: user.isBanned, - is_whitelisted: user.isWhitelisted, - ban_reason: user.banReason, - instances: [...user.instances], - player_stats: user.playerStats, - instance_stats: [...user.instanceStats], - is_deleted: user.isDeleted, - }); + this.send(new libData.UserUpdateEvent( + new libData.RawUser( + user.name, + [...user.roles].map(role => role.id), + [...user.instances], + user.isAdmin, + user.isBanned, + user.isWhitelisted, + user.banReason, + user.isDeleted, + user.playerStats, + [...user.instanceStats], + ) + )); } } - async createUserRequestHandler(message) { - let user = this._controller.userManager.createUser(message.data.name); + async handleUserCreateRequest(request) { + let user = this._controller.userManager.createUser(request.name); this._controller.userUpdated(user); } - async revokeUserTokenRequestHandler(message) { - let user = this._controller.userManager.users.get(message.data.name); + async handleUserRevokeTokenRequest(request) { + let user = this._controller.userManager.users.get(request.name); if (!user) { - throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + throw new libErrors.RequestError(`User '${request.name}' does not exist`); } if (user.name !== this.user.name) { this.user.checkPermission("core.user.revoke_other_token"); } user.invalidateToken(); - for (let controlConnection of this._controller.wsServer.controlConnections) { + for (let controlConnection of this._controller.wsServer.controlConnections.values()) { if (controlConnection.user.name === user.name) { controlConnection.connector.terminate(); } @@ -750,14 +816,14 @@ class ControlConnection extends BaseConnection { this._controller.userUpdated(user); } - async updateUserRolesRequestHandler(message) { - let user = this._controller.userManager.users.get(message.data.name); + async handleUserUpdateRolesRequest(request) { + let user = this._controller.userManager.users.get(request.name); if (!user) { - throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + throw new libErrors.RequestError(`User '${request.name}' does not exist`); } let resolvedRoles = new Set(); - for (let roleId of message.data.roles) { + for (let roleId of request.roles) { let role = this._controller.userManager.roles.get(roleId); if (!role) { throw new libErrors.RequestError(`Role with ID ${roleId} does not exist`); @@ -771,8 +837,8 @@ class ControlConnection extends BaseConnection { this._controller.userUpdated(user); } - async setUserAdminRequestHandler(message) { - let { name, create, admin } = message.data; + async handleUserSetAdminRequest(request) { + let { name, create, admin } = request; let user = this._controller.userManager.users.get(name); if (!user) { if (create) { @@ -785,11 +851,11 @@ class ControlConnection extends BaseConnection { user.isAdmin = admin; this._controller.userUpdated(user); - this.broadcastEventToHosts({ data: { name, admin }}, libLink.messages.adminlistUpdate); + this._controller.sendTo(new libData.InstanceAdminlistUpdateEvent(name, admin), "allInstances"); } - async setUserBannedRequestHandler(message) { - let { name, create, banned, reason } = message.data; + async handleUserSetBannedRequest(request) { + let { name, create, banned, reason } = request; let user = this._controller.userManager.users.get(name); if (!user) { if (create) { @@ -803,11 +869,11 @@ class ControlConnection extends BaseConnection { user.isBanned = banned; user.banReason = reason; this._controller.userUpdated(user); - this.broadcastEventToHosts({ data: { name, banned, reason }}, libLink.messages.banlistUpdate); + this._controller.sendTo(new libData.InstanceBanlistUpdateEvent(name, banned, reason), "allInstances"); } - async setUserWhitelistedRequestHandler(message) { - let { name, create, whitelisted } = message.data; + async handleUserSetWhitelistedRequest(request) { + let { name, create, whitelisted } = request; let user = this._controller.userManager.users.get(name); if (!user) { if (create) { @@ -820,34 +886,35 @@ class ControlConnection extends BaseConnection { user.isWhitelisted = whitelisted; this._controller.userUpdated(user); - this.broadcastEventToHosts({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate); + this._controller.sendTo(new libData.InstanceWhitelistUpdateEvent(name, whitelisted), "allInstances"); } - async deleteUserRequestHandler(message) { - let user = this._controller.userManager.users.get(message.data.name); + async handleUserDeleteRequest(request) { + let name = request.name; + let user = this._controller.userManager.users.get(name); if (!user) { - throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + throw new libErrors.RequestError(`User '${name}' does not exist`); } user.isDeleted = true; - this._controller.userManager.users.delete(message.data.name); + this._controller.userManager.users.delete(name); this._controller.userUpdated(user); if (user.is_admin) { - this.broadcastEventToHosts({ data: { name, admin: false }}, libLink.messages.adminlistUpdate); + this._controller.sendTo(new libData.InstanceAdminlistUpdateEvent(name, false), "allInstances"); } if (user.is_whitelisted) { - this.broadcastEventToHosts({ data: { name, whitelisted: false }}, libLink.messages.whitelistUpdate); + this._controller.sendTo(new libData.InstanceWhitelistUpdateEvent(name, false), "allInstances"); } if (user.is_banned) { - this.broadcastEventToHosts({ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate); + this._controller.sendTo(new libData.InstanceBanlistUpdateEvent(name, false, ""), "allInstances"); } } - async debugDumpWsRequestHandler(message) { + async handleDebugDumpWsRequest(request) { this.ws_dumper = data => { if (this.connector.connected) { - libLink.messages.debugWsMessage.send(this, data); + this.send(new libData.DebugWsMessageEvent(data.direction, data.content)); } }; this.connector._socket.clusterio_ignore_dump = true; diff --git a/packages/controller/src/Controller.js b/packages/controller/src/Controller.js index 0e57db8b..53636cf1 100644 --- a/packages/controller/src/Controller.js +++ b/packages/controller/src/Controller.js @@ -13,7 +13,6 @@ const libConfig = require("@clusterio/lib/config"); const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const libFileOps = require("@clusterio/lib/file_ops"); -const libLink = require("@clusterio/lib/link"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); const libPlugin = require("@clusterio/lib/plugin"); const libPluginLoader = require("@clusterio/lib/plugin_loader"); @@ -114,6 +113,11 @@ class Controller { this._state = "new"; this._shouldStop = false; + this._fallbackedRequests = new Map(); + this._registeredRequests = new Map(); + this._registeredEvents = new Map(); + this._snoopedEvents = new Map(); + this.devMiddleware = null; } @@ -559,16 +563,16 @@ class Controller { } hostUpdated(host) { - let update = { - agent: host.agent, - version: host.version, - id: host.id, - name: host.name, - public_address: host.public_address || null, - connected: this.wsServer.hostConnections.has(host.id), - }; + let update = new libData.HostDetails( + host.agent, + host.version, + host.name, + host.id, + this.wsServer.hostConnections.has(host.id), + host.public_address === null ? undefined : host.public_address, + ); - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -682,17 +686,16 @@ class Controller { 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 }); + await oldHostConnection.send(new libData.InstanceUnassignInternalRequest(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"), - }); + await newHostConnection.send( + new libData.InstanceAssignInternalRequest(instanceId, instance.config.serialize("host")) + ); } else { instance.status = "unassigned"; this.instanceUpdated(instance); @@ -709,8 +712,9 @@ class Controller { */ 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 }); + let hostId = instance.config.get("instance.assigned_host"); + if (hostId !== null) { + await this.sendTo(new libData.InstanceDeleteInternalRequest(instanceId), { hostId }); } this.instances.delete(instanceId); @@ -734,10 +738,9 @@ class Controller { if (hostId !== null) { let connection = this.wsServer.hostConnections.get(hostId); if (connection) { - await libLink.messages.assignInstance.send(connection, { - instance_id: instance.id, - serialized_config: instance.config.serialize("host"), - }); + await connection.send( + new libData.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + ); } } } @@ -755,7 +758,7 @@ class Controller { } instanceUpdated(instance) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -764,18 +767,18 @@ class Controller { } } - saveListUpdate(data) { - for (let controlConnection of this.wsServer.controlConnections) { + saveListUpdate(instanceId, saves) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } - controlConnection.saveListUpdate(data); + controlConnection.saveListUpdate(instanceId, saves); } } modPackUpdated(modPack) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -786,7 +789,7 @@ class Controller { } modUpdated(mod) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -798,7 +801,7 @@ class Controller { } userUpdated(user) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -813,15 +816,15 @@ class Controller { * @param {module:lib/users.User} user - User permisions updated for. */ userPermissionsUpdated(user) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user === user) { - libLink.messages.accountUpdate.send(controlConnection, { - "roles": [...user.roles].map(r => ({ + controlConnection.send( + new libData.AccountUpdateEvent([...user.roles].map(r => ({ name: r.name, id: r.id, permissions: [...r.permissions], - })), - }); + }))) + ); } } } @@ -832,15 +835,17 @@ class Controller { * @param {module:lib/users.Role} role - Role permisions updated for. */ rolePermissionsUpdated(role) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user.roles.has(role)) { - libLink.messages.accountUpdate.send(controlConnection, { - "roles": [...controlConnection.user.roles].map(r => ({ - name: r.name, - id: r.id, - permissions: [...r.permissions], - })), - }); + controlConnection.send( + new libData.AccountUpdateEvent( + [...controlConnection.user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })) + ) + ); } } } @@ -965,35 +970,184 @@ class Controller { }; } + register(Class, handler) { + if (Class.type === "request") { + this.registerRequest(Class, handler); + } else if (Class.type === "event") { + this.registerEvent(Class, handler); + } else { + throw new Error(`Class ${Class.name} has unrecognized type ${Class.type}`); + } + } + + registerRequest(Request, handler) { + if (this._registeredRequests.has(Request)) { + throw new Error(`Request ${Request.name} is already registered`); + } + this._registeredRequests.set(Request, handler); + } + + fallbackRequest(Request, handler) { + if (this._fallbackedRequests.has(Request)) { + throw new Error(`Request ${Request.name} is already fallbacked`); + } + this._fallbackedRequests.set(Request, handler); + } + + registerEvent(Event, handler) { + if (this._registeredEvents.has(Event)) { + throw new Error(`Event ${Event.name} is already registered`); + } + this._registeredEvents.set(Event, handler); + } + + snoopEvent(Event, handler) { + if (this._snoopedEvents.has(Event)) { + throw new Error(`Event ${Event.name} is already snooped`); + } + this._snoopedEvents.set(Event, handler); + } + /** - * Forward the given request to the host contaning the instance + * Send request or event * - * Finds the host which the instance with the given instance ID is - * currently assigned to and forwards the request to that instance. - * The request data must have an instance_id parameter. + * Routes the given request or event to the given destination. The + * destination argument supports address shorthands, see {@link + * module:lib/data.Address.fromShorthand} * - * @param {module:lib/link.Request} request - The request to send. - * @param {object} data - Data to pass with the request. - * @param {number} data.instance_id - - * ID of instance to send this request towards - * @returns {Promise} data returned from the request. + * @param {*} requestOrEvent - The request or event to send. + * @param {*|module:lib/data.Address} address - Where to send it. + * @returns {Promise<*>|undefined} + * Promise that resolves to the response if a request was sent or + * undefined if it was an event. */ - async forwardRequestToInstance(request, data) { - let instance = this.getRequestInstance(data.instance_id); + sendTo(requestOrEvent, address) { + let dst = libData.Address.fromShorthand(address); + if (requestOrEvent.constructor.type === "request") { + return this.sendRequest(requestOrEvent, dst); + } + if (requestOrEvent.constructor.type === "event") { + return this.sendEvent(requestOrEvent, dst); + } + throw Error(`Unknown type ${requestOrEvent.constructor.type}.`); + } + + async sendRequest(request, dst) { + let connection; + if (dst.type === libData.Address.controller) { + throw new Error("controller as dst is not supported."); + + } else if (dst.type === libData.Address.control) { + connection = this.wsServer.controlConnections.get(dst.id); + if (!connection) { + throw new libErrors.RequestError("Target control connection does not exist."); + } + + } else if (dst.type === libData.Address.instance) { + let instance = this.getRequestInstance(dst.id); + let hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + throw new libErrors.RequestError("Instance is not assigned to a host"); + } + connection = this.wsServer.hostConnections.get(hostId); + if (!connection) { + throw new libErrors.RequestError("Host containing instance is not connected"); + } + + } else if (dst.type === libData.Address.host) { + connection = this.wsServer.hostConnections.get(dst.id); + if (!connection) { + throw new libErrors.RequestError("Host is not connected"); + } + + } else { + throw new Error(`Unknown address type ${dst.type}`); + } + + return connection.sendRequest(request, dst); + } + + sendEvent(event, dst) { + let connection; + if (dst.type === libData.Address.controller) { + throw new Error("controller as dst is not supported."); + + } else if (dst.type === libData.Address.control) { + connection = this.wsServer.controlConnections.get(dst.id); + + } else if (dst.type === libData.Address.instance) { + let instance = this.instances.get(dst.id); + if (!instance) { + return; + } + let hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + return; + } + connection = this.wsServer.hostConnections.get(hostId); + + } else if (dst.type === libData.Address.host) { + connection = this.wsServer.hostConnections.get(dst.id); + + } else if (dst.type === libData.Address.broadcast) { + if (dst.id === libData.Address.control) { + for (let controlConnection of this.wsServer.controlConnections.values()) { + controlConnection.sendEvent(event, dst); + } + + } else if (dst.id === libData.Address.instance || dst.id === libData.Address.host) { + for (let hostConnection of this.wsServer.hostConnections.values()) { + hostConnection.sendEvent(event, dst); + } + + } else { + throw new Error(`Unexpected broacdast target ${dst.id}`); + } + return; + + } else { + throw new Error(`Unknown address type ${dst.type}`); + } + + if (connection) { + connection.sendEvent(event, dst); + } + } + + sendToHostByInstanceId(requestOrEvent) { + if (requestOrEvent.constructor.type === "request") { + return this.sendRequestToHostByInstanceId(requestOrEvent); + } + if (requestOrEvent.constructor.type === "event") { + return this.sendEventToHostByInstanceId(requestOrEvent); + } + throw Error(`Unknown type ${requestOrEvent.constructor.type}.`); + } + + async sendRequestToHostByInstanceId(request) { + let instance = this.getRequestInstance(request.instanceId); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { throw new libErrors.RequestError("Instance is not assigned to a host"); } - let connection = this.wsServer.hostConnections.get(hostId); if (!connection) { throw new libErrors.RequestError("Host containing instance is not connected"); } - if (request.plugin && !connection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Host containing instance does not have ${request.plugin} plugin`); - } + return await connection.send(request); + } - return await request.send(connection, data); + sendEventToHostByInstanceId(event) { + let instance = this.getRequestInstance(event.instanceId); + let hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + return; + } + let connection = this.wsServer.hostConnections.get(hostId); + if (!connection) { + return; + } + connection.send(event); } } diff --git a/packages/controller/src/ControllerRouter.js b/packages/controller/src/ControllerRouter.js new file mode 100644 index 00000000..2b4b1f6e --- /dev/null +++ b/packages/controller/src/ControllerRouter.js @@ -0,0 +1,123 @@ +"use strict"; +const libData = require("@clusterio/lib/data"); + +class ControllerRouter { + /** @type {module:controller/src/Controller} */ + controller; + + pendingRequests = new Map(); + + constructor(controller) { + this.controller = controller; + } + + addHostConnection(connection) { + this.hostConnections.set(connection.id, connection); + } + + addControlConnection(connection) { + this.controlConnections.set(connection.id, connection); + } + + /** + * @param {module:lib/link.Link} origin - Source link of the message. + * @param {module:lib/data.Message} message + * Link the message originated from. + * @param {boolean} hasFallback - true if fallback handling is available. + * @returns {boolean} true if the message was handled + */ + forwardMessage(origin, message, hasFallback) { + if (!["request", "response", "responseError", "event"].includes(message.type)) { + throw new Error(`Message type ${message.type} can't be forwarded`); + } + + let dst = message.dst; + let nextHop; + let msg; + if (dst.type === libData.Address.broadcast) { + this.broadcastMessage(origin, message); + return true; + } else if (dst.type === libData.Address.host) { + nextHop = this.controller.wsServer.hostConnections.get(dst.id); + if (!nextHop) { + msg = `Host ${dst.id} is offline`; + } + } else if (dst.type === libData.Address.instance) { + let instance = this.controller.instances.get(dst.id); + if (!instance) { + msg = `Instance ${dst.id} does not exist`; + } else { + let assignedHost = instance.config.get("instance.assigned_host"); + if (assignedHost === null) { + msg = `Instance ${dst.id} is not assigned a host`; + } else { + nextHop = this.controller.wsServer.hostConnections.get(assignedHost); + if (!nextHop) { + msg = `Assigned host for instance ${dst.id} is offline`; + } + } + } + } else if (dst.type === libData.Address.control) { + nextHop = this.controller.wsServer.controlConnections.get(dst.id); + if (!nextHop) { + msg = "Control connection does not exist"; + } + } else if (dst.type === libData.Address.controller) { + msg = `Unexpected message forwarded to ${dst}`; + } + + if (nextHop === origin) { + msg = `Message would return back to sender ${origin.dst}.`; + nextHop = undefined; + } + + if (message.type === "request") { + if (!nextHop) { + if (hasFallback) { + return false; + } + origin.connector.sendResponseError( + new libData.ResponseError(msg || "Unroutable destination"), message.src + ); + return true; + } + } + + if (nextHop) { + nextHop.connector.send(message); + } else { + this.warnUnrouted(message, msg); + } + + return true; + } + + broadcastMessage(origin, message) { + let dst = message.dst; + if (message.type !== "event") { + this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`); + } else if (dst.id === libData.Address.host || dst.id === libData.Address.instance) { + for (let hostConnection of this.controller.wsServer.hostConnections.values()) { + if (hostConnection !== origin) { + hostConnection.connector.send(message); + } + } + } else if (dst.id === libData.Address.control) { + for (let controlConnection of this.controller.wsServer.controlConnections.values()) { + if (controlConnection !== origin) { + controlConnection.connector.send(message); + } + } + } else { + this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`); + } + } + + warnUnrouted(message, msg) { + let dst = message.dst; + let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`; + logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`); + } +} + +module.exports = ControllerRouter; diff --git a/packages/controller/src/HostConnection.js b/packages/controller/src/HostConnection.js index c37830a8..aa14e366 100644 --- a/packages/controller/src/HostConnection.js +++ b/packages/controller/src/HostConnection.js @@ -1,6 +1,6 @@ "use strict"; +const libData = require("@clusterio/lib/data"); const libConfig = require("@clusterio/lib/config"); -const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); const libPlugin = require("@clusterio/lib/plugin"); const PlayerStats = require("@clusterio/lib/PlayerStats"); @@ -17,7 +17,7 @@ const InstanceInfo = require("./InstanceInfo"); */ class HostConnection extends BaseConnection { constructor(registerData, connector, controller) { - super("host", connector, controller); + super(connector, controller); this._agent = registerData.agent; this._id = registerData.id; @@ -31,7 +31,7 @@ class HostConnection extends BaseConnection { id: this._id, name: this._name, version: this._version, - public_address: registerData.public_address, + public_address: registerData.publicAddress, plugins: registerData.plugins, }); @@ -57,6 +57,12 @@ class HostConnection extends BaseConnection { libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } }); + + this.register(libData.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this)); + this.register(libData.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this)); + this.register(libData.InstanceSaveListUpdateEvent, this.handleInstanceSaveListUpdateEvent.bind(this)); + this.register(libData.LogMessageEvent, this.handleLogMessageEvent.bind(this)); + this.register(libData.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); } _checkPluginVersions() { @@ -83,6 +89,11 @@ class HostConnection extends BaseConnection { } } + async prepareDisconnect() { + await libPlugin.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this); + return await super.prepareDisconnect(); + } + /** * ID of the host this connection is connected to * @@ -92,15 +103,15 @@ class HostConnection extends BaseConnection { return this._id; } - async instanceStatusChangedEventHandler(message, event) { - let instance = this._controller.instances.get(message.data.instance_id); + async handleInstanceStatusChangedEvent(request) { + let instance = this._controller.instances.get(request.instanceId); // It's possible to get updates from an instance that does not exist // or is not assigned to the host it originated from if it was // reassigned or deleted while the connection to the host it was // originally on was down at the time. if (!instance || instance.config.get("instance.assigned_host") !== this._id) { - logger.warn(`Got bogus update for instance id ${message.data.instance_id}`); + logger.warn(`Got bogus update for instance id ${request.instanceId}`); return; } @@ -109,41 +120,40 @@ class HostConnection extends BaseConnection { // changed. Hosts also send status updates on assignInstance which // for hacky reason is also used to push config changes and // restablish status after a connection loss. - if (instance.status === message.data.status) { + if (instance.status === request.status) { return; } let prev = instance.status; - instance.status = message.data.status; - instance.game_port = message.data.game_port; + instance.status = request.status; + instance.game_port = request.gamePort; logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); this._controller.instanceUpdated(instance); await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } - async updateInstancesRequestHandler(message) { + async handleInstancesUpdateRequest(request) { // Push updated instance configs for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") === this._id) { - await libLink.messages.assignInstance.send(this, { - instance_id: instance.id, - serialized_config: instance.config.serialize("host"), - }); + await this.send( + new libData.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + ); } } // Assign instances the host has but controller does not - for (let instanceData of message.data.instances) { + for (let instanceData of request.instances) { let instanceConfig = new libConfig.InstanceConfig("controller"); - await instanceConfig.load(instanceData.serialized_config, "host"); + await instanceConfig.load(instanceData.config, "host"); let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); if (controllerInstance) { // Check if this instance is assigned somewhere else. if (controllerInstance.config.get("instance.assigned_host") !== this._id) { - await libLink.messages.unassignInstance.send(this, { - instance_id: controllerInstance.id, - }); + await this.send( + new libData.InstanceUnassignInternalRequest(instanceConfig.get("instance.id")) + ); continue; } @@ -152,7 +162,7 @@ class HostConnection extends BaseConnection { let prev = controllerInstance.status; controllerInstance.status = instanceData.status; logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); - this._controller.instanceUpdated(instance); + this._controller.instanceUpdated(controllerInstance); await libPlugin.invokeHook( this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev ); @@ -164,10 +174,11 @@ class HostConnection extends BaseConnection { let newInstance = new InstanceInfo({ config: instanceConfig, status: instanceData.status }); this._controller.instances.set(instanceConfig.get("instance.id"), newInstance); this._controller.addInstanceHooks(newInstance); - await libLink.messages.assignInstance.send(this, { - instance_id: instanceConfig.get("instance.id"), - serialized_config: instanceConfig.serialize("host"), - }); + await this.send( + new libData.InstanceAssignInternalRequest( + instanceConfig.get("instance.id"), instanceConfig.serialize("host") + ) + ); await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance, null); } @@ -188,40 +199,43 @@ class HostConnection extends BaseConnection { } } - libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist }); + await this.send(new libData.SyncUserListsEvent(adminlist, banlist, whitelist)); } - async saveListUpdateEventHandler(message) { - this._controller.saveListUpdate(message.data); + async handleInstanceSaveListUpdateEvent(event) { + this._controller.saveListUpdate(event.instanceId, event.saves); } - async logMessageEventHandler(message) { + async handleLogMessageEvent(event) { this._controller.clusterLogger.log({ - ...message.data.info, + ...event.info, host_id: this._id, host_name: this._name, }); } - async playerEventEventHandler(message) { - let { instance_id, name, type, stats } = message.data; - let user = this._controller.userManager.users.get(name); + async handleInstancePlayerUpdateEvent(event, src) { + let instanceId = src.id; + let user = this._controller.userManager.users.get(event.name); if (!user) { - user = this._controller.userManager.createUser(name); + user = this._controller.userManager.createUser(event.name); } - if (type === "join") { - user.notifyJoin(instance_id); - } else if (type === "leave") { - user.notifyLeave(instance_id); + if (event.type === "join") { + user.notifyJoin(instanceId); + } else if (event.type === "leave") { + user.notifyLeave(instanceId); } - user.instanceStats.set(instance_id, new PlayerStats(stats)); + user.instanceStats.set(instanceId, new PlayerStats(event.stats)); user.recalculatePlayerStats(); this._controller.userUpdated(user); - delete message.data.stats; - let instance = this._controller.instances.get(instance_id); - await libPlugin.invokeHook(this._controller.plugins, "onPlayerEvent", instance, message.data); + let instance = this._controller.instances.get(instanceId); + await libPlugin.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { + type: event.type, + name: event.name, + reason: event.reason, + }); } } diff --git a/packages/controller/src/WsServer.js b/packages/controller/src/WsServer.js index bad09f17..483fca5c 100644 --- a/packages/controller/src/WsServer.js +++ b/packages/controller/src/WsServer.js @@ -2,10 +2,10 @@ const jwt = require("jsonwebtoken"); const WebSocket = require("ws"); +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const { logger } = require("@clusterio/lib/logging"); const libPrometheus = require("@clusterio/lib/prometheus"); -const libSchema = require("@clusterio/lib/schema"); const ControlConnection = require("./ControlConnection"); const packageVersion = require("../package").version; @@ -40,7 +40,7 @@ class WsServer { this.stopAcceptingNewSessions = false; /** @type {Array} */ - this.controlConnections = []; + this.controlConnections = new Map(); /** @type {Map} */ this.hostConnections = new Map(); /** @type {Map} */ @@ -51,6 +51,7 @@ class WsServer { // Unique string for the session token audience this.sessionAud = `session-${Date.now()}`; this.nextSessionId = 1; + this.nextControlId = 1; /** @type {module:ws.Server} */ this.wss = new WebSocket.Server({ @@ -64,7 +65,7 @@ class WsServer { this.stopAcceptingNewSessions = true; let disconnectTasks = []; - for (let controlConnection of this.controlConnections) { + for (let controlConnection of this.controlConnections.values()) { disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit")); } @@ -122,11 +123,7 @@ class WsServer { loadedPlugins[name] = plugin.info.version; } - socket.send(JSON.stringify({ seq: null, type: "hello", data: { - version: packageVersion, - plugins: loadedPlugins, - }})); - + socket.send(JSON.stringify(new libData.MessageHello(new libData.HelloData(packageVersion, loadedPlugins)))); this.attachHandler(socket, req); } @@ -159,49 +156,31 @@ ${err.stack}` }); } - async handleHandshake(message, socket, req) { + async handleHandshake(rawMessage, socket, req) { + let message; try { - message = JSON.parse(message); + const json = JSON.parse(rawMessage); + libData.Message.validate(json); + message = libData.Message.fromJSON(json); } catch (err) { - logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid JSON`); + logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid message`); wsRejectedConnectionsCounter.inc(); - socket.close(1002, "Invalid JSON"); + socket.close(1002, `Invalid message: ${err.message}`); return; } - if (!libSchema.clientHandshake(message)) { + if (message.type === "resume") { + this.resumeSession(message.data, socket, req); + return; + } + + if (message.type !== "registerHost" && message.type !== "registerControl") { logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid handshake`); wsRejectedConnectionsCounter.inc(); socket.close(1002, "Bad handshake"); return; } - let { type, data } = message; - - if (type === "resume") { - let connector; - try { - let payload = jwt.verify( - data.session_token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), - { audience: this.sessionAud } - ); - - connector = this.activeConnectors.get(payload.sid); - if (!connector) { - throw new Error(); - } - - } catch (err) { - socket.send(JSON.stringify({ seq: null, type: "invalidate", data: {}})); - this.attachHandler(socket, req); - return; - } - - connector.continue(socket, data.last_seq); - return; - } - if (this.stopAcceptingNewSessions) { logger.verbose(`WsServer | closing ${req.socket.remoteAddress}, server is shutting down`); wsRejectedConnectionsCounter.inc(); @@ -209,46 +188,20 @@ ${err.stack}` return; } - // Validate token - let user; - try { - if (type === "register_host") { - let tokenPayload = jwt.verify( - data.token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), - // migrate: allow pre-rename tokens issued to hosts before alpha-14 - { audience: ["host", "slave"] } - ); - - // migrate: allow pre-rename tokens issued to hosts before alpha-14 - if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) { - throw new Error("missmatched host id"); - } - - } else if (type === "register_control") { - let tokenPayload = jwt.verify( - data.token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), - { audience: "user" } - ); - - user = this.controller.userManager.users.get(tokenPayload.user); - if (!user) { - throw new Error("invalid user"); - } - if (tokenPayload.iat < user.tokenValidAfter) { - throw new Error("invalid token"); - } - user.checkPermission("core.control.connect"); - } - - } catch (err) { - logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); - wsRejectedConnectionsCounter.inc(); - socket.close(4003, `Authentication failed: ${err.message}`); + if (message.type === "registerHost") { + await this.registerHost(message.data, socket, req); return; } + if (message.type === "registerControl") { + await this.registerControl(message.data, socket, req); + return; + } + + throw new Error("This should be unreachable"); + } + + createSession(dst) { let sessionId = this.nextSessionId; this.nextSessionId += 1; let sessionToken = jwt.sign( @@ -257,66 +210,137 @@ ${err.stack}` ); let sessionTimeout = this.controller.config.get("controller.session_timeout"); let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval"); - let connector = new WsServerConnector(sessionId, sessionTimeout, heartbeatInterval); + let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval); this.activeConnectors.set(sessionId, connector); connector.on("close", () => { this.activeConnectors.delete(sessionId); }); - let additionalReadyData = {}; - if (type === "register_host") { - let connection = this.hostConnections.get(data.id); - if (connection) { - logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`); - await connection.disconnect(1008, "Registered from another connection"); - } + return [connector, sessionToken]; + } - logger.info( - `WsServer | registered host ${data.name} (${data.id}) using agent ${data.agent} ${data.version}` + async registerHost(data, socket, req) { + try { + let tokenPayload = jwt.verify( + data.token, + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + // migrate: allow pre-rename tokens issued to hosts before alpha-14 + { audience: ["host", "slave"] } ); - if (data.agent === "Clusterio Host" && data.version !== packageVersion) { - logger.warn( - `Host ${data.name} (${data.id}) connected using version ${data.version} which does not match the ` + - `version of the controller is currently running (${packageVersion}). It may not work as expected.` - ); + + // migrate: allow pre-rename tokens issued to hosts before alpha-14 + if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) { + throw new Error("missmatched host id"); } - - connection = new HostConnection(data, connector, this.controller); - connector.on("close", () => { - if (this.hostConnections.get(data.id) === connection) { - this.hostConnections.delete(data.id); - this.controller.hostUpdated(this.controller.hosts.get(data.id)); - } else { - logger.warn("Unlisted HostConnection closed"); - } - }); - this.hostConnections.set(data.id, connection); - this.controller.hostUpdated(this.controller.hosts.get(data.id)); - - - } else if (type === "register_control") { - logger.verbose(`WsServer | registered control from ${req.socket.remoteAddress}`); - let connection = new ControlConnection(data, connector, this.controller, user); - connector.on("close", () => { - let index = this.controlConnections.indexOf(connection); - if (index !== -1) { - this.controlConnections.splice(index, 1); - } else { - logger.warn("Unlisted ControlConnection closed"); - } - }); - this.controlConnections.push(connection); - additionalReadyData["account"] = { - name: user.name, - roles: [...user.roles].map(r => ({ - name: r.name, - id: r.id, - permissions: [...r.permissions], - })), - }; + } catch (err) { + logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); + wsRejectedConnectionsCounter.inc(); + socket.close(4003, `Authentication failed: ${err.message}`); + return; } - connector.ready(socket, sessionToken, additionalReadyData); + let [connector, sessionToken] = this.createSession(new libData.Address(libData.Address.host, data.id)); + let connection = this.hostConnections.get(data.id); + if (connection) { + logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`); + await connection.disconnect(1008, "Registered from another connection"); + } + + logger.info( + `WsServer | registered host ${data.name} (${data.id}) using agent ${data.agent} ${data.version}` + ); + if (data.agent === "Clusterio Host" && data.version !== packageVersion) { + logger.warn( + `Host ${data.name} (${data.id}) connected using version ${data.version} which does not match the ` + + `version of the controller is currently running (${packageVersion}). It may not work as expected.` + ); + } + + connection = new HostConnection(data, connector, this.controller); + connector.on("close", () => { + if (this.hostConnections.get(data.id) === connection) { + this.hostConnections.delete(data.id); + this.controller.hostUpdated(this.controller.hosts.get(data.id)); + } else { + logger.warn("Unlisted HostConnection closed"); + } + }); + this.hostConnections.set(data.id, connection); + this.controller.hostUpdated(this.controller.hosts.get(data.id)); + let src = new libData.Address(libData.Address.host, data.id); + connector.ready(socket, src, sessionToken); + } + + async registerControl(data, socket, req) { + let user; + try { + let tokenPayload = jwt.verify( + data.token, + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + { audience: "user" } + ); + + user = this.controller.userManager.users.get(tokenPayload.user); + if (!user) { + throw new Error("invalid user"); + } + if (tokenPayload.iat < user.tokenValidAfter) { + throw new Error("invalid token"); + } + user.checkPermission("core.control.connect"); + + } catch (err) { + logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); + wsRejectedConnectionsCounter.inc(); + socket.close(4003, `Authentication failed: ${err.message}`); + return; + } + + let id = this.nextControlId; + this.nextControlId += 1; + let [connector, sessionToken] = this.createSession(new libData.Address(libData.Address.control, id)); + + logger.verbose(`WsServer | registered control ${id} from ${req.socket.remoteAddress}`); + let connection = new ControlConnection(data, connector, this.controller, user, id); + connector.on("close", () => { + this.controlConnections.delete(id); + }); + this.controlConnections.set(id, connection); + let account = new libData.AccountDetails( + user.name, + [...user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })) + ); + + let src = new libData.Address(libData.Address.control, id); + connector.ready(socket, src, sessionToken, account); + } + + resumeSession(data, socket, req) { + let connector; + try { + let payload = jwt.verify( + data.sessionToken, + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + { audience: this.sessionAud } + ); + + connector = this.activeConnectors.get(payload.sid); + if (!connector) { + throw new Error(); + } + + } catch (err) { + socket.send(JSON.stringify(new libData.MessageInvalidate())); + this.attachHandler(socket, req); + return; + } + + connector.continue(socket, data.lastSeq); + } } diff --git a/packages/controller/src/WsServerConnector.js b/packages/controller/src/WsServerConnector.js index 004ef841..0b3fda8f 100644 --- a/packages/controller/src/WsServerConnector.js +++ b/packages/controller/src/WsServerConnector.js @@ -1,6 +1,7 @@ "use strict"; const events = require("events"); +const libData = require("@clusterio/lib/data"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); @@ -12,9 +13,10 @@ const { logger } = require("@clusterio/lib/logging"); * @alias module:controller/src/WsServerConnector */ class WsServerConnector extends libLink.WebSocketBaseConnector { - constructor(sessionId, sessionTimeout, heartbeatInterval) { - super(sessionTimeout); + constructor(dst, sessionId, sessionTimeout, heartbeatInterval) { + super(new libData.Address(libData.Address.controller, 0), dst); + this._sessionTimeout = sessionTimeout; this._socket = null; this._sessionId = sessionId; this._heartbeatInterval = heartbeatInterval; @@ -38,27 +40,27 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { * Sends the ready message over the socket to initiate the session. * * @param {Object} socket - WebSocket connection to client. + * @param {module:lib/data.Address} src - Source address for this link. * @param {string} sessionToken - * the session token to send to the client. - * @param {Object=} additionalData - - * extra properties to send along the ready message. + * @param {module:lib/data.AccountDetails=} account - + * account data to provide to control connection */ - ready(socket, sessionToken, additionalData) { + ready(socket, src, sessionToken, account) { this._socket = socket; - this._socket.send(JSON.stringify({ - seq: null, - type: "ready", - data: { - session_token: sessionToken, - session_timeout: this._sessionTimeout, - heartbeat_interval: this._heartbeatInterval, - ...additionalData, - }, - })); + this._sendInternal(new libData.MessageReady( + new libData.ReadyData( + src, + sessionToken, + this._sessionTimeout, + this._heartbeatInterval, + account, + ), + )); this._state = "connected"; this._attachSocketHandlers(); - this.emit("connect"); + this.emit("connect", { src: this.src }); } /** @@ -68,7 +70,7 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { * socket given from the message sequence given. * * @param {module:net.Socket} socket - New socket to continue on. - * @param {number} lastSeq - The last message the client received. + * @param {number=} lastSeq - The last message the client received. */ continue(socket, lastSeq) { @@ -87,15 +89,9 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { this._timeoutId = null; } - this._socket.send(JSON.stringify({ - seq: null, - type: "continue", - data: { - last_seq: this._lastReceivedSeq, - session_timeout: this._sessionTimeout, - heartbeat_interval: this._heartbeatInterval, - }, - })); + this._sendInternal(new libData.MessageContinue( + new libData.ContinueData(this._sessionTimeout, this._heartbeatInterval, this._lastReceivedSeq) + )); this._state = "connected"; this._attachSocketHandlers(); @@ -118,6 +114,7 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { this._socket.on("close", (code, reason) => { logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`); this.stopHeartbeat(); + this._socket = null; if (this._closing) { this._reset(); @@ -148,18 +145,12 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { // Handle messages this._socket.on("message", data => { - let message = JSON.parse(data); - if (["connected", "closing"].includes(this._state)) { - if (message.seq !== null) { - this._lastReceivedSeq = message.seq; - } - - if (message.type === "heartbeat") { - this._processHeartbeat(message); - - } else { - this.emit("message", message); - } + let message = this._parseMessage(data); + if (!message) { + return; + } + if (this._state === "connected") { + this._processMessage(message); } else { throw new Error(`Received message in unexpected state ${this._state}`); diff --git a/packages/controller/src/routes.js b/packages/controller/src/routes.js index 7392bebd..04a98bf1 100644 --- a/packages/controller/src/routes.js +++ b/packages/controller/src/routes.js @@ -49,12 +49,12 @@ async function getMetrics(req, res, next) { let requests = []; let timeout = req.app.locals.controller.config.get("controller.metrics_timeout") * 1000; - for (let hostConnection of req.app.locals.controller.wsServer.hostConnections.values()) { + for (let [hostId, hostConnection] of req.app.locals.controller.wsServer.hostConnections) { if (!hostConnection.connected) { continue; } requests.push(libHelpers.timeout( - libLink.messages.getMetrics.send(hostConnection).catch(err => { + hostConnection.send(new libData.HostMetricsRequest()).catch(err => { if (!(err instanceof libErrors.SessionLost)) { logger.error(`Unexpected error gathering metrics from host:\n${err.stack}`); } @@ -361,15 +361,17 @@ async function uploadSave(req, res) { }); try { - let result = await Promise.race([ - req.app.locals.controller.forwardRequestToInstance(libLink.messages.pullSave, { - instance_id: instanceId, - stream_id: proxyStream.id, - filename, - }), + let storedName = await Promise.race([ + req.app.locals.controller.sendToHostByInstanceId( + new libData.InstancePullSaveRequest( + instanceId, + proxyStream.id, + filename, + ) + ), timeout, ]); - saves.push(result.save); + saves.push(storedName); } catch (err) { proxyStream.events.emit("close"); diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 8eeb56c4..b1ee3472 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -11,6 +11,7 @@ const version = require("./package").version; const setBlocking = require("set-blocking"); const libLink = require("@clusterio/lib/link"); +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const libConfig = require("@clusterio/lib/config"); const libPlugin = require("@clusterio/lib/plugin"); @@ -35,11 +36,11 @@ class ControlConnector extends libLink.WebSocketClientConnector { register() { logger.verbose("Connector | registering control"); - this.sendHandshake("register_control", { - token: this._token, - agent: "clusterioctl", - version: version, - }); + this.sendHandshake(new libData.MessageRegisterControl(new libData.RegisterControlData( + this._token, + "clusterioctl", + version, + ))); } } @@ -50,10 +51,8 @@ class ControlConnector extends libLink.WebSocketClientConnector { * @static */ class Control extends libLink.Link { - constructor(connector, controlConfig, tlsCa, controlPlugins) { - super("control", "controller", connector); - libLink.attachAllMessages(this); + super(connector); /** * Control config used for connecting to the controller. @@ -70,62 +69,46 @@ class Control extends libLink.Link { * @type {Map} */ this.plugins = controlPlugins; - for (let controlPlugin of controlPlugins.values()) { - libPlugin.attachPluginMessages(this, controlPlugin); - } /** * Keep the control connection alive after the command completes. * @type {boolean} */ this.keepOpen = false; + + this.register(libData.LogMessageEvent, this.handleLogMessageEvent.bind(this)); + this.register(libData.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); } - async accountUpdateEventHandler() { } - - async hostUpdateEventHandler() { } - - async instanceUpdateEventHandler() { } - - async saveListUpdateEventHandler() { } - - async modPackUpdateEventHandler() { } - - async modUpdateEventHandler() { } - - async userUpdateEventHandler() { } - async setLogSubscriptions({ all = false, controller = false, - host_ids = [], - instance_ids = [], - max_level = null, + hostIds = [], + instanceIds = [], + maxLevel = null, }) { - await libLink.messages.setLogSubscriptions.send(this, { - all, controller, host_ids, instance_ids, max_level, - }); + await this.send(new libData.LogSetSubscriptionsRequest( + all, controller, hostIds, instanceIds, maxLevel, + )); } - async logMessageEventHandler(message) { - logger.log(message.data.info); + async handleLogMessageEvent(event) { + logger.log(event.info); } - async debugWsMessageEventHandler(message) { + async handleDebugWsMessageEvent(event) { // eslint-disable-next-line no-console - console.log("WS", message.data.direction, message.data.content); + console.log("WS", event.direction, event.content); } async shutdown() { try { - await libLink.messages.prepareDisconnect.send(this); + await this.connector.disconnect(); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { throw err; } } - - await this.connector.close(1000, "Control Quit"); } } @@ -300,7 +283,7 @@ async function startControl() { process.exitCode = 1; } else if (err instanceof libErrors.RequestError) { - logger.error(`Error sending request: ${err.message}`); + logger.error(`Error sending request:\n${err.stack}`); process.exitCode = 1; } else { diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js index a89e702d..fe847467 100644 --- a/packages/ctl/src/commands.js +++ b/packages/ctl/src/commands.js @@ -10,7 +10,6 @@ const child_process = require("child_process"); const stream = require("stream"); const util = require("util"); -const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); const libConfig = require("@clusterio/lib/config"); const libCommand = require("@clusterio/lib/command"); @@ -91,9 +90,9 @@ const controllerConfigCommands = new libCommand.CommandTree({ controllerConfigCommands.add(new libCommand.Command({ definition: ["list", "List controller configuration"], handler: async function(args, control) { - let response = await libLink.messages.getControllerConfig.send(control); + let response = await control.send(new libData.ControllerConfigGetRequest()); - for (let group of response.serialized_config.groups) { + for (let group of response.serializedConfig.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -115,10 +114,7 @@ controllerConfigCommands.add(new libCommand.Command({ } else if (args.value === undefined) { args.value = ""; } - await libLink.messages.setControllerConfigField.send(control, { - field: args.field, - value: args.value, - }); + await control.send(new libData.ControllerConfigSetFieldRequest(args.field, args.value)); }, })); @@ -135,22 +131,19 @@ controllerConfigCommands.add(new libCommand.Command({ if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); } - let request = { - field: args.field, - prop: args.prop, - }; + let value; try { if (args.value !== undefined) { - request.value = JSON.parse(args.value); + value = JSON.parse(args.value); } } catch (err) { // See note for the instance version of set-prop if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); } - request.value = args.value; + value = args.value; } - await libLink.messages.setControllerConfigProp.send(control, request); + await control.send(new libData.ControllerConfigSetPropRequest(args.field, args.prop, value)); }, })); controllerConfigCommands.add(new libCommand.Command({ @@ -162,7 +155,7 @@ controllerConfigCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.getControllerConfig.send(control); + let response = await control.send(new ControllerConfigGetRequest()); let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === -1) { @@ -170,7 +163,7 @@ controllerConfigCommands.add(new libCommand.Command({ Try "ctl controller config edit "`); } let allConfigElements = await serializedConfigToString( - response.serialized_config, libConfig.ControllerConfig, {} + response.serializedConfig, libConfig.ControllerConfig, {} ); await fs.writeFile(tmpFile, allConfigElements, (err) => { if (err) { @@ -191,10 +184,10 @@ controllerConfigCommands.add(new libCommand.Command({ for (let index in final) { if (index in final) { try { - await libLink.messages.setControllerConfigField.send(control, { - field: index, - value: final[index], - }); + await control.send(new ControllerConfigSetFieldRequest( + index, + final[index], + )); } catch (err) { // eslint-disable-next-line print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); @@ -241,8 +234,8 @@ const hostCommands = new libCommand.CommandTree({ name: "host", description: "Ho hostCommands.add(new libCommand.Command({ definition: [["list", "l"], "List hosts connected to the controller"], handler: async function(args, control) { - let response = await libLink.messages.listHosts.send(control); - print(asTable(response.list)); + let hosts = await control.send(new libData.HostListRequest()); + print(asTable(hosts)); }, })); @@ -252,7 +245,7 @@ hostCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let hostId = typeof args.id === "number" ? args.id : null; - let response = await libLink.messages.generateHostToken.send(control, { host_id: hostId }); + let response = await control.send(new libData.HostGenerateTokenRequest(hostId)); print(response.token); }, })); @@ -269,11 +262,11 @@ hostCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.createHostConfig.send(control, { - id: args.id, name: args.name, generate_token: args.generateToken, - }); + let rawConfig = await control.send( + new libData.HostConfigCreateRequest(args.id, args.name, args.generateToken) + ); - let content = JSON.stringify(response.serialized_config, null, 4); + let content = JSON.stringify(rawConfig.serializedConfig, null, 4); if (args.output === "-") { print(content); } else { @@ -297,8 +290,8 @@ const instanceCommands = new libCommand.CommandTree({ instanceCommands.add(new libCommand.Command({ definition: [["list", "l"], "List instances known to the controller"], handler: async function(args, control) { - let response = await libLink.messages.listInstances.send(control); - print(asTable(response.list)); + let list = await control.send(new libData.InstanceDetailsListRequest()); + print(asTable(list)); }, })); @@ -317,8 +310,8 @@ instanceCommands.add(new libCommand.Command({ instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize("controller"); - await libLink.messages.createInstance.send(control, { serialized_config }); + let serializedConfig = instanceConfig.serialize("controller"); + await control.send(new libData.InstanceCreateRequest(serializedConfig)); }, })); @@ -331,9 +324,9 @@ instanceConfigCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); + let response = await control.send(new libData.InstanceConfigGetRequest(instanceId)); - for (let group of response.serialized_config.groups) { + for (let group of response.config.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -357,11 +350,7 @@ instanceConfigCommands.add(new libCommand.Command({ } else if (args.value === undefined) { args.value = ""; } - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: instanceId, - field: args.field, - value: args.value, - }); + await control.send(new libData.InstanceConfigSetFieldRequest(instanceId, args.field, args.value)); }, })); @@ -380,14 +369,10 @@ instanceConfigCommands.add(new libCommand.Command({ if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); } - let request = { - instance_id: instanceId, - field: args.field, - prop: args.prop, - }; + let value; try { if (args.value !== undefined) { - request.value = JSON.parse(args.value); + value = JSON.parse(args.value); } } catch (err) { // If this is from stdin or looks like an array, object or string @@ -406,9 +391,9 @@ instanceConfigCommands.add(new libCommand.Command({ if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); } - request.value = args.value; + value = args.value; } - await libLink.messages.setInstanceConfigProp.send(control, request); + await control.send(new libData.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value)); }, })); @@ -423,7 +408,7 @@ instanceConfigCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); + let response = await control.send(new libData.InstanceConfigGetRequest(instanceId)); let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === -1) { @@ -432,7 +417,7 @@ instanceConfigCommands.add(new libCommand.Command({ } let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; let allConfigElements = await serializedConfigToString( - response.serialized_config, + response.serializedConfig, libConfig.InstanceConfig, disallowedList ); @@ -455,11 +440,11 @@ instanceConfigCommands.add(new libCommand.Command({ for (let index in final) { if (index in final) { try { - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: instanceId, - field: index, - value: final[index], - }); + await control.send(new libData.InstanceConfigSetField( + instanceId, + index, + final[index], + )); } catch (err) { // eslint-disable-next-line print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); @@ -494,10 +479,7 @@ instanceCommands.add(new libCommand.Command({ handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); let hostId = args.host ? await libCommand.resolveHost(control, args.host) : null; - await libLink.messages.assignInstanceCommand.send(control, { - instance_id: instanceId, - host_id: hostId, - }); + await control.send(new libData.InstanceAssignRequest(instanceId, hostId)); }, })); @@ -533,12 +515,12 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId }); - for (let entry of response.list) { - entry.mtime = new Date(entry.mtime_ms).toLocaleString(); - delete entry.mtime_ms; + let saves = await control.sendTo(new libData.InstanceListSavesRequest(), { instanceId }); + for (let entry of saves) { + entry.mtime = new Date(entry.mtimeMs).toLocaleString(); + delete entry.mtimeMs; } - print(asTable(response.list)); + print(asTable(saves)); }, })); @@ -556,14 +538,10 @@ instanceSaveCommands.add(new libCommand.Command({ handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.createSave.send(control, { - instance_id: instanceId, - name: args.name, - seed, - map_gen_settings: mapGenSettings, - map_settings: mapSettings, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo( + new libData.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings), { instanceId } + ); }, })); @@ -575,11 +553,7 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.renameSave.send(control, { - instance_id: instanceId, - old_name: args.oldName, - new_name: args.newName, - }); + await control.send(new libData.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName)); }, })); @@ -591,11 +565,7 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.copySave.send(control, { - instance_id: instanceId, - source: args.source, - destination: args.destination, - }); + await control.send(new libData.InstanceCopySaveRequest(instanceId, args.source, args.destination)); }, })); @@ -667,14 +637,16 @@ instanceSaveCommands.add(new libCommand.Command({ handler: async function(args, control) { let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance); let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance); - let result = await libLink.messages.transferSave.send(control, { - instance_id: sourceInstanceId, - source_save: args.sourceSave, - target_instance_id: targetInstanceId, - target_save: args.targetSave || args.sourceSave, - copy: args.copy, - }); - print(`Transferred as ${result.save} to ${args.targetInstance}.`); + let storedName = await control.send( + new libData.InstanceTransferSaveRequest( + sourceInstanceId, + args.sourceSave, + targetInstanceId, + args.targetSave || args.sourceSave, + args.copy + ) + ); + print(`Transferred as ${storedName} to ${args.targetInstance}.`); }, })); @@ -685,13 +657,10 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let result = await libLink.messages.downloadSave.send(control, { - instance_id: instanceId, - save: args.save, - }); + let streamId = await control.send(new libData.InstanceDownloadSaveRequest(instanceId, args.save)); let url = new URL(control.config.get("control.controller_url")); - url.pathname += `api/stream/${result.stream_id}`; + url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: control.tlsCa }, @@ -730,10 +699,7 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.deleteSave.send(control, { - instance_id: instanceId, - save: args.save, - }); + await control.send(new libData.InstanceDeleteSaveRequest(instanceId, args.save)); }, })); instanceCommands.add(instanceSaveCommands); @@ -744,10 +710,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.exportData.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo(new libData.InstanceExportDataRequest(), { instanceId }); }, })); @@ -757,10 +721,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.extractPlayers.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo(new libData.InstanceExtractPlayersRequest(), { instanceId }); }, })); @@ -774,11 +736,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.startInstance.send(control, { - instance_id: instanceId, - save: args.save || null, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo(new libData.InstanceStartRequest(args.save), { instanceId }); control.keepOpen = args.keepOpen; }, })); @@ -797,15 +756,11 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await libLink.messages.loadScenario.send(control, { - instance_id: instanceId, - scenario: args.scenario, - seed, - map_gen_settings: mapGenSettings, - map_settings: mapSettings, - }); + await control.sendTo( + new libData.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings), { instanceId } + ); control.keepOpen = args.keepOpen; }, })); @@ -816,10 +771,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.stopInstance.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo(new libData.InstanceStopRequest(args.save), { instanceId }); }, })); @@ -829,10 +782,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.killInstance.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo(new libData.InstanceKillRequest(args.save), { instanceId }); }, })); @@ -841,9 +792,8 @@ instanceCommands.add(new libCommand.Command({ yargs.positional("instance", { describe: "Instance to delete", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.deleteInstance.send(control, { - instance_id: await libCommand.resolveInstance(control, args.instance), - }); + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.send(new libData.InstanceDeleteRequest(instanceId)); }, })); @@ -853,13 +803,13 @@ instanceCommands.add(new libCommand.Command({ yargs.positional("command", { describe: "command to send", type: "string" }); }], handler: async function(args, control) { - let response = await libLink.messages.sendRcon.send(control, { - instance_id: await libCommand.resolveInstance(control, args.instance), - command: args.command, - }); + let result = await control.sendTo( + new libData.InstanceSendRconRequest(args.command), + { instanceId: await libCommand.resolveInstance(control, args.instance) }, + ); - // Factorio includes a newline in it's response output. - process.stdout.write(response.result); + // Factorio includes a newline in its response output. + process.stdout.write(result); }, })); @@ -870,36 +820,38 @@ modPackCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let mods = new Map( - (await libLink.messages.listMods.send(control)).list.map(m => [`${m.name}_${m.version}`, m]) + (await control.send(new libData.ModListRequest())).map(m => [`${m.name}_${m.version}`, m]) + ); + let modPack = await control.send( + new libData.ModPackGetRequest(await libCommand.resolveModPack(control, args.modPack)) ); - let response = await libLink.messages.getModPack.send(control, { - id: await libCommand.resolveModPack(control, args.modPack), - }); - for (let [field, value] of Object.entries(response.mod_pack)) { + for (let [field, value] of Object.entries(modPack)) { if (field === "mods") { print(`${field}:`); - for (let entry of value) { - const mod = mods.get(`${entry.name}_${entry.version}`); + for (let [name, entry] of value) { + const mod = mods.get(`${name}_${entry.version}`); const missing = !mod && "? "; const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! "; const warn = missing || badChecksum || ""; const enabled = entry.enabled ? "" : "(disabled) "; - print(` ${warn}${enabled}${entry.name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`); + print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`); } } else if (field === "settings") { print(`${field}:`); for (let [scope, settings] of Object.entries(value)) { print(` ${scope}:`); - for (let [setting, settingValue] of Object.entries(settings)) { + for (let [setting, settingValue] of settings) { print(` ${setting}: ${JSON.stringify(settingValue.value)}`); } } - } else if (field === "export_manifest") { + } else if (field === "exportManifest") { print(`${field}:`); - print(" assets:"); - for (let [name, fileName] of Object.entries(value.assets)) { - print(` ${name}: ${fileName}`); + if (value && value.assets) { + print(" assets:"); + for (let [name, fileName] of Object.entries(value.assets)) { + print(` ${name}: ${fileName}`); + } } } else { @@ -912,16 +864,16 @@ modPackCommands.add(new libCommand.Command({ modPackCommands.add(new libCommand.Command({ definition: [["list", "l"], "List mod packs in the cluster"], handler: async function(args, control) { - let response = await libLink.messages.listModPacks.send(control); - let fields = ["id", "name", "factorio_version"]; - for (let entry of response.list) { + let modPacks = await control.send(new libData.ModPackListRequest()); + let fields = ["id", "name", "factorioVersion"]; + for (let entry of modPacks) { for (let field of Object.keys(entry)) { if (!fields.includes(field)) { delete entry[field]; } } } - print(asTable(response.list)); + print(asTable(modPacks)); }, })); @@ -1014,7 +966,7 @@ modPackCommands.add(new libCommand.Command({ setModPackMods(modPack, args.mods); setModPackModsEnabled(modPack, args.disabledMods, false); setModPackSettings(modPack, args); - await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + await control.send(new libData.ModPackCreateRequest(modPack)); print(`Created mod pack ${modPack.name} (${modPack.id})`); }, })); @@ -1025,7 +977,7 @@ modPackCommands.add(new libCommand.Command({ }], handler: async function(args, control) { const modPack = libData.ModPack.fromModPackString(args.string); - await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + await control.send(new libData.ModPackCreateRequest(modPack)); print(`Created mod pack ${modPack.name} (${modPack.id})`); }, })); @@ -1035,10 +987,9 @@ modPackCommands.add(new libCommand.Command({ yargs.positional("string", { describe: "Mod pack to export", type: "string" }); }], handler: async function(args, control) { - const response = await libLink.messages.getModPack.send(control, { - id: await libCommand.resolveModPack(control, args.modPack), - }); - const modPack = libData.ModPack.fromJSON(response.mod_pack); + const modPack = await control.send( + new libData.ModPackGetRequest(await libCommand.resolveModPack(control, args.modPack)) + ); print(modPack.toModPackString()); }, })); @@ -1062,10 +1013,9 @@ modPackCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.getModPack.send(control, { - id: await libCommand.resolveModPack(control, args.modPack), - }); - let modPack = libData.ModPack.fromJSON(response.mod_pack); + const modPack = await control.send( + new libData.ModPackGetRequest(await libCommand.resolveModPack(control, args.modPack)) + ); if (args.name) { modPack.name = args.name; } if (args.description) { modPack.description = args.description; } @@ -1103,7 +1053,7 @@ modPackCommands.add(new libCommand.Command({ } } } - await libLink.messages.updateModPack.send(control, { mod_pack: modPack.toJSON() }); + await control.send(new libData.ModPackUpdateRequest(modPack)); }, })); @@ -1113,7 +1063,7 @@ modPackCommands.add(new libCommand.Command({ }], handler: async function(args, control) { const id = await libCommand.resolveModPack(control, args.modPack); - await libLink.messages.deleteModPack.send(control, { id }); + await control.send(new libData.ModPackDeleteRequest(id)); }, })); @@ -1124,8 +1074,8 @@ modCommands.add(new libCommand.Command({ yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); }], handler: async function(args, control) { - let response = await libLink.messages.getMod.send(control, { name: args.name, version: args.modVersion }); - for (let [field, value] of Object.entries(response.mod)) { + let modInfo = await control.send(new libData.ModGetRequest(args.name, args.modVersion)); + for (let [field, value] of Object.entries(modInfo)) { if (value instanceof Array) { print(`${field}:`); for (let entry of value) { @@ -1150,9 +1100,9 @@ modCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.listMods.send(control); + let mods = await control.send(new libData.ModListRequest()); if (!args.fields.includes("all")) { - for (let entry of response.list) { + for (let entry of mods) { for (let field of Object.keys(entry)) { if (!args.fields.includes(field)) { delete entry[field]; @@ -1160,7 +1110,7 @@ modCommands.add(new libCommand.Command({ } } } - print(asTable(response.list)); + print(asTable(mods)); }, })); @@ -1197,14 +1147,14 @@ modCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.searchMods.send(control, { - "query": args.query, - "factorio_version": args.factorioVersion, - "page_size": args.pageSize, - "page": args.page, - "sort": args.sort, - "sort_order": args.sortOrder, - }); + let response = await control.send(new libData.ModSearchRequest( + args.query, + args.factorioVersion, + args.page, + args.pageSize, + args.sort, + args.sortOrder + )); let results = response.results.flatMap(result => result.versions); if (!args.fields.includes("all")) { for (let entry of results) { @@ -1215,10 +1165,10 @@ modCommands.add(new libCommand.Command({ } } } - for (let issue of response.query_issues) { + for (let issue of response.queryIssues) { print(issue); } - print(`page ${args.page} of ${response.page_count} (${response.result_count} results)`); + print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`); print(asTable(results)); }, })); @@ -1275,13 +1225,10 @@ modCommands.add(new libCommand.Command({ yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); }], handler: async function(args, control) { - let result = await libLink.messages.downloadMod.send(control, { - name: args.name, - version: args.modVersion, - }); + let streamId = await control.send(new libData.ModDownloadRequest(args.name, args.modVersion)); let url = new URL(control.config.get("control.controller_url")); - url.pathname += `api/stream/${result.stream_id}`; + url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: control.tlsCa }, @@ -1318,7 +1265,7 @@ modCommands.add(new libCommand.Command({ yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.deleteMod.send(control, { name: args.name, version: args.modVersion }); + await control.send(new libData.ModDeleteRequest(args.name, args.modVersion)); }, })); @@ -1326,8 +1273,8 @@ const permissionCommands = new libCommand.CommandTree({ name: "permission", desc permissionCommands.add(new libCommand.Command({ definition: [["list", "l"], "List permissions in the cluster"], handler: async function(args, control) { - let response = await libLink.messages.listPermissions.send(control); - print(asTable(response.list)); + let permissions = await control.send(new libData.PermissionListRequest()); + print(asTable(permissions)); }, })); @@ -1336,8 +1283,8 @@ const roleCommands = new libCommand.CommandTree({ name: "role", description: "Ro roleCommands.add(new libCommand.Command({ definition: [["list", "l"], "List roles in the cluster"], handler: async function(args, control) { - let response = await libLink.messages.listRoles.send(control); - print(asTable(response.list)); + let roles = await control.send(new libData.RoleListRequest()); + print(asTable(roles)); }, })); @@ -1350,12 +1297,12 @@ roleCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.createRole.send(control, { - name: args.name, - description: args.description, - permissions: args.permissions, - }); - logger.info(`Created role ID ${response.id}`); + let id = await control.send(new libData.RoleCreateRequest( + args.name, + args.description, + args.permissions, + )); + logger.info(`Created role ID ${id}`); }, })); @@ -1395,10 +1342,10 @@ roleCommands.add(new libCommand.Command({ if (args.setPerms !== undefined) { role.permissions = args.setPerms; } - await libLink.messages.updateRole.send(control, role); + await control.send(new libData.RoleUpdateRequest(role.id, role.name, role.description, role.permissions)); if (args.grantDefault) { - await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id }); + await control.send(new libData.RoleGrantDefaultPermissionsRequest(role.id)); } }, })); @@ -1409,7 +1356,7 @@ roleCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let role = await libCommand.retrieveRole(control, args.role); - await libLink.messages.deleteRole.send(control, { id: role.id }); + await control.send(new libData.RoleDeleteRequest(role.id)); }, })); @@ -1423,16 +1370,15 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.getUser.send(control, { name: args.name }); - delete response["seq"]; - Object.assign(response, response["player_stats"]); - delete response["player_stats"]; - let instanceStats = response["instance_stats"]; - delete response["instance_stats"]; - print(asTable(Object.entries(response).map(([property, value]) => ({ property, value })))); + let user = await control.send(new libData.UserGetRequest(args.name)); + Object.assign(user, user.playerStats); + delete user.playerStats; + let instanceStats = user.instanceStats; + delete user.instanceStats; + print(asTable(Object.entries(user).map(([property, value]) => ({ property, value })))); if (args.instanceStats) { - let instances = (await libLink.messages.listInstances.send(control)).list; + let instances = await control.send(new libData.InstanceDetailsListRequest()); function instanceName(id) { let instance = instances.find(i => i.id === id); if (instance) { @@ -1457,19 +1403,22 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.listUsers.send(control); - for (let user of response.list) { + let users = await control.send(new libData.UserListRequest()); + for (let user of users) { if (args.stats) { - Object.assign(user, user["player_stats"]); + Object.assign(user, user.playerStats); } - delete user["player_stats"]; + delete user.playerStats; + delete user.isDeleted; + delete user.banReason; + delete user.instanceStats; if (!args.attributes) { - delete user["is_admin"]; - delete user["is_whitelisted"]; - delete user["is_banned"]; + delete user.isAdmin; + delete user.isWhitelisted; + delete user.isBanned; } } - print(asTable(response.list)); + print(asTable(users)); }, })); @@ -1478,7 +1427,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("name", { describe: "Name of user to create", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.createUser.send(control, { name: args.name }); + await control.send(new libData.UserCreateRequest(args.name)); }, })); @@ -1487,7 +1436,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.revokeUserToken.send(control, { name: args.name }); + await control.send(new libData.UserRevokeTokenRequest(args.name)); }, })); @@ -1500,9 +1449,7 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - await libLink.messages.setUserAdmin.send(control, { - name: args.user, create: args.create, admin: !args.revoke, - }); + await control.send(new libData.UserSetAdminRequest(args.user, args.create, !args.revoke)); }, })); @@ -1515,9 +1462,7 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - await libLink.messages.setUserWhitelisted.send(control, { - name: args.user, create: args.create, whitelisted: !args.remove, - }); + await control.send(new libData.UserSetWhitelistedRequest(args.user, args.create, !args.remove)); }, })); @@ -1531,9 +1476,7 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - await libLink.messages.setUserBanned.send(control, { - name: args.user, create: args.create, banned: !args.pardon, reason: args.reason, - }); + await control.send(new libData.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason)); }, })); @@ -1543,7 +1486,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("roles", { describe: "roles to assign", type: "string" }); }], handler: async function(args, control) { - let response = await libLink.messages.listRoles.send(control); + let roles = await control.send(new libData.RoleListRequest()); let resolvedRoles = []; for (let roleName of args.roles) { @@ -1553,7 +1496,7 @@ userCommands.add(new libCommand.Command({ } else { let found = false; - for (let role of response.list) { + for (let role of roles) { if (role.name === roleName) { resolvedRoles.push(role.id); found = true; @@ -1567,7 +1510,7 @@ userCommands.add(new libCommand.Command({ } } - await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles }); + await control.send(new libData.UserUpdateRolesRequest(args.user, resolvedRoles)); }, })); @@ -1576,7 +1519,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("user", { describe: "Name of user to delete", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.deleteUser.send(control, { name: args.user }); + await control.send(new libData.UserDeleteRequest(args.user)); }, })); @@ -1596,9 +1539,9 @@ logCommands.add(new libCommand.Command({ process.exitCode = 1; return; } - let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; - let host_ids = args.host ? [await libCommand.resolveHost(control, args.host)] : []; - await control.setLogSubscriptions({ all: args.all, controller: args.controller, host_ids, instance_ids }); + let instanceIds = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; + let hostIds = args.host ? [await libCommand.resolveHost(control, args.host)] : []; + await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds }); control.keepOpen = true; }, })); @@ -1621,17 +1564,17 @@ logCommands.add(new libCommand.Command({ process.exitCode = 1; return; } - let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; - let host_ids = args.host ? [await libCommand.resolveHost(control, args.host)] : []; - let result = await libLink.messages.queryLog.send(control, { - all: args.all, - controller: args.controller, - host_ids, - instance_ids, - max_level: args.maxLevel, - limit: args.limit, - order: args.start ? "asc" : "desc", - }); + let instanceIds = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; + let hostIds = args.host ? [await libCommand.resolveHost(control, args.host)] : []; + let result = await control.send(new libData.LogQueryRequest( + args.all, + args.controller, + hostIds, + instanceIds, + args.maxLevel, + args.limit, + args.start ? "asc" : "desc", + )); if (!args.start) { result.log.reverse(); @@ -1655,7 +1598,7 @@ const debugCommands = new libCommand.CommandTree({ name: "debug", description: " debugCommands.add(new libCommand.Command({ definition: ["dump-ws", "Dump WebSocket messages sent and received by controller", (yargs) => { }], handler: async function(args, control) { - await libLink.messages.debugDumpWs.send(control); + await control.send(new libData.DebugDumpWsRequest()); control.keepOpen = true; }, })); diff --git a/packages/host/host.js b/packages/host/host.js index a8c4ecd1..cee1f688 100755 --- a/packages/host/host.js +++ b/packages/host/host.js @@ -22,6 +22,7 @@ const winston = require("winston"); require("winston-daily-rotate-file"); // internal libraries +const libData = require("@clusterio/lib/data"); const libLink = require("@clusterio/lib/link"); const libPluginLoader = require("@clusterio/lib/plugin_loader"); const libErrors = require("@clusterio/lib/errors"); @@ -51,15 +52,15 @@ class HostConnector extends libLink.WebSocketClientConnector { plugins[pluginInfo.name] = pluginInfo.version; } - this.sendHandshake("register_host", { - token: this.hostConfig.get("host.controller_token"), - agent: "Clusterio Host", + this.sendHandshake(new libData.MessageRegisterHost(new libData.RegisterHostData( + this.hostConfig.get("host.controller_token"), + "Clusterio Host", version, - id: this.hostConfig.get("host.id"), - name: this.hostConfig.get("host.name"), - public_address: this.hostConfig.get("host.public_address"), + this.hostConfig.get("host.name"), + this.hostConfig.get("host.id"), + this.hostConfig.get("host.public_address"), plugins, - }); + ))); } } diff --git a/packages/host/src/Host.js b/packages/host/src/Host.js index 169e1e9a..da87711a 100644 --- a/packages/host/src/Host.js +++ b/packages/host/src/Host.js @@ -10,6 +10,7 @@ const stream = require("stream"); const util = require("util"); // internal libraries +const libData = require("@clusterio/lib/data"); const libFileOps = require("@clusterio/lib/file_ops"); const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); @@ -80,6 +81,153 @@ async function discoverInstances(instancesDir) { return instanceInfos; } +const instanceStartingMessages = new Set([ + libData.InstanceStartRequest.name, + libData.InstanceLoadScenarioRequest.name, + libData.InstanceCreateSaveRequest.name, + libData.InstanceExportDataRequest.name, +]); + +class HostRouter { + /** @type {module:lib/host/src/Host} */ + host; + + constructor(host) { + this.host = host; + } + + /** + * Forward a message to the next hop towards its destination. + * + * @param {module:lib/link.Link} origin - + * Link the message originated from. + * @param {module:lib/data.Message} message - Message to process. + * @param {boolean} hasFallback - is fallback available + * @returns {boolean} true if the message was handled, false if fallback + * is requested + */ + forwardMessage(origin, message, hasFallback) { + let dst = message.dst; + let nextHop; + let msg; + if (dst.type === libData.Address.broadcast) { + this.broadcastMessage(origin, message); + return true; + } else if ( + dst.type === libData.Address.controller + || dst.type === libData.Address.control + || dst.type === libData.Address.host && dst.id !== this.host.config.get("host.id") + || dst.type === libData.Address.instance && !this.host.instanceInfos.has(dst.id) + ) { + nextHop = this.host; + } else if (dst.type === libData.Address.instance && this.host.instanceInfos.has(dst.id)) { + nextHop = this.host.instanceConnections.get(dst.id); + } + + if (nextHop === origin) { + msg = `Message would return back to sender ${origin.dst}.`; + nextHop = undefined; + } + + if (message.type === "request") { + if (dst.type === libData.Address.instance && instanceStartingMessages.has(message.name)) { + this.wakeInstance(origin, message, nextHop); + return true; + + } else if (!nextHop) { + if (dst.type === libData.Address.instance) { + if (hasFallback) { + return false; + } + origin.connector.sendResponseError( + new libData.ResponseError("Instance is not running."), message.src + ); + } + return true; + } + + // XXX What if the session is invalidated and there is no + // response? Need to track pending requests here. + } + + if (nextHop) { + this.sendMessage(nextHop, message); + } else { + this.warnUnrouted(message, msg); + } + + return true; + } + + broadcastMessage(origin, message) { + let dst = message.dst; + if (message.type !== "event") { + this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`); + } else if (dst.id === libData.Address.host || dst.id === libData.Address.instance) { + for (let instanceConnection of this.host.instanceConnections.values()) { + if (instanceConnection !== origin) { + instanceConnection.connector.send(message); + } + } + if (this.host !== origin) { + this.host.connector.send(message); + } + } else if (dst.id === libData.Address.control) { + if (this.host !== origin) { + this.host.connector.send(message); + } else { + logger.warn(`Received control broadcast of ${message.name} from master.`); + } + } else { + this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`); + } + } + + wakeInstance(origin, message, nextHop) { + let dst = message.dst; + if (nextHop) { + origin.connector.sendResponseError( + new libData.ResponseError("Instance is already running."), message.src + ); + return; + } + if (this.host._shuttingDown) { + origin.connector.sendResponseError( + new libData.ResponseError("Host is shutting down."), message.src + ); + return; + } + + this.host._connectInstance(dst.id).then(instanceConnection => { + instanceConnection.connector.send(message); + }).catch(err => { + logger.error(`Error starting instance:\n${err.stack}`, this.host.instanceLogMeta(dst.id)); + origin.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); + }); + } + + sendMessage(nextHop, message) { + try { + nextHop.connector.send(message); + } catch (err) { + if (message.type === "request") { + origin.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); + } + logger.warn(`Failed to deliver ${message.name || "message"} (${message.type}): ${err.message}`); + } + } + + warnUnrouted(message, msg) { + let dst = message.dst; + let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`; + logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`); + } +} + /** * Handles running the host * @@ -87,17 +235,11 @@ async function discoverInstances(instancesDir) { * @alias module:host/src/Host */ class Host extends libLink.Link { - // I don't like God classes, but the alternative of putting all this state - // into global variables is not much better. constructor(connector, hostConfig, tlsCa, pluginInfos) { - super("host", "controller", connector); - libLink.attachAllMessages(this); + super(connector); + this.router = new HostRouter(this); this.pluginInfos = pluginInfos; - for (let pluginInfo of pluginInfos) { - libPlugin.attachPluginMessages(this, { info: pluginInfo }); - } - this.config = hostConfig; /** @@ -158,11 +300,28 @@ class Host extends libLink.Link { for (let event of ["connect", "drop", "resume", "close"]) { this.connector.on(event, () => { + let message = new libData.ControllerConnectionEvent(event); for (let instanceConnection of this.instanceConnections.values()) { - libLink.messages.controllerConnectionEvent.send(instanceConnection, { event }); + instanceConnection.send(message); } }); } + + this.register(libData.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this)); + this.snoopEvent(libData.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); + this.snoopEvent(libData.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); + this.snoopEvent(libData.InstanceWhitelistUpdateEvent, this.handleWhitelistUpdateEvent.bind(this)); + this.register(libData.InstanceAssignInternalRequest, this.handleInstanceAssignInternalRequest.bind(this)); + this.register(libData.InstanceUnassignInternalRequest, this.handleInstanceUnassignInternalRequest.bind(this)); + this.register(libData.HostMetricsRequest, this.handleHostMetricsRequest.bind(this)); + this.fallbackRequest(libData.InstanceListSavesRequest, this.fallbackInstanceListSavesRequest.bind(this)); + this.register(libData.InstanceRenameSaveRequest, this.handleInstanceRenameSaveRequest.bind(this)); + this.register(libData.InstanceCopySaveRequest, this.handleInstanceCopySaveRequest.bind(this)); + this.register(libData.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this)); + this.register(libData.InstanceDeleteSaveRequest, this.handleInstanceDeleteSaveRequest.bind(this)); + this.register(libData.InstancePullSaveRequest, this.handleInstancePullSaveRequest.bind(this)); + this.register(libData.InstancePushSaveRequest, this.handleInstancePushSaveRequest.bind(this)); + this.register(libData.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); } async _createNewInstanceDir(name) { @@ -189,45 +348,15 @@ class Host extends libLink.Link { throw Error("Unable to create instance dir, retry threshold reached"); } - async forwardRequestToInstance(message, request) { - let instanceId = message.data.instance_id; - if (!this.instanceInfos.has(instanceId)) { - throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); - } - - let instanceConnection = this.instanceConnections.get(instanceId); - if (!instanceConnection) { - throw new libErrors.RequestError(`Instance ID ${instanceId} is not running`); - } - - if (request.plugin && !instanceConnection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`); - } - - 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; } - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; } - - event.send(instanceConnection, message.data); - } - - async broadcastEventToInstance(message, event) { + async broadcastEventToInstance(event) { for (let instanceConnection of this.instanceConnections.values()) { - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; } - - event.send(instanceConnection, message.data); + if (event.constructor.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; } + instanceConnection.send(event); } } - async syncUserListsEventHandler(message) { - let updateList = (list, updatedList, prop, event) => { + async handleSyncUserListsEvent(request) { + let updateList = (list, updatedList, Event) => { let added = new Set(updatedList); let removed = new Set(list); list.forEach(el => added.delete(el)); @@ -235,23 +364,19 @@ class Host extends libLink.Link { for (let name of added) { list.add(name); - this.broadcastEventToInstance( - { data: { name, [prop]: true }}, event - ); + this.broadcastEventToInstance(new Event(name, true)); } for (let name of removed) { list.delete(name); - this.broadcastEventToInstance( - { data: { name, [prop]: false }}, event - ); + this.broadcastEventToInstance(new Event(name, false)); } }; - updateList(this.adminlist, message.data.adminlist, "admin", libLink.messages.adminlistUpdate); - updateList(this.whitelist, message.data.whitelist, "whitelisted", libLink.messages.whitelistUpdate); + updateList(this.adminlist, request.adminlist, libData.InstanceAdminlistUpdateEvent); + updateList(this.whitelist, request.whitelist, libData.InstanceWhitelistUpdateEvent); - let addedOrChanged = new Map(message.data.banlist); + let addedOrChanged = new Map(request.banlist); let removed = new Set(this.banlist.keys()); addedOrChanged.forEach((_, name) => removed.delete(name)); this.banlist.forEach((reason, name) => { @@ -262,21 +387,17 @@ class Host extends libLink.Link { for (let [name, reason] of addedOrChanged) { this.banlist.set(name, reason); - this.broadcastEventToInstance( - { data: { name, banned: true, reason }}, libLink.messages.banlistUpdate - ); + this.broadcastEventToInstance(new libData.InstanceBanlistUpdateEvent(name, true, reason)); } for (let name of removed) { this.banlist.delete(name); - this.broadcastEventToInstance( - { data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate - ); + this.broadcastEventToInstance(new libData.InstanceBanlistUpdateEvent(name, false, "")); } } - async adminlistUpdateEventHandler(message) { - let { name, admin } = message.data; + async handleAdminlistUpdateEvent(request) { + let { name, admin } = request; if (admin) { this.adminlist.add(name); } else { @@ -284,8 +405,8 @@ class Host extends libLink.Link { } } - async banlistUpdateEventHandler(message) { - let { name, banned, reason } = message.data; + async handleBanlistUpdateEvent(request) { + let { name, banned, reason } = request; if (banned) { this.banlist.set(name, reason); } else { @@ -293,8 +414,8 @@ class Host extends libLink.Link { } } - async whitelistUpdateEventHandler(message) { - let { name, whitelisted } = message.data; + async handleWhitelistUpdateEvent(request) { + let { name, whitelisted } = request; if (whitelisted) { this.whitelist.add(name); } else { @@ -302,21 +423,21 @@ class Host extends libLink.Link { } } - async assignInstanceRequestHandler(message) { - let { instance_id, serialized_config } = message.data; - let instanceInfo = this.instanceInfos.get(instance_id); + async handleInstanceAssignInternalRequest(request) { + let { instanceId, config } = request; + let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true, "controller"); - logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instance_id, instanceInfo)); + instanceInfo.config.update(config, true, "controller"); + logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); } else { - instanceInfo = this.discoveredInstanceInfos.get(instance_id); + instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true, "controller"); + instanceInfo.config.update(config, true, "controller"); } else { let instanceConfig = new libConfig.InstanceConfig("host"); - await instanceConfig.load(serialized_config, "controller"); + await instanceConfig.load(config, "controller"); let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name")); @@ -327,21 +448,22 @@ class Host extends libLink.Link { config: instanceConfig, }; - this.discoveredInstanceInfos.set(instance_id, instanceInfo); + this.discoveredInstanceInfos.set(instanceId, instanceInfo); } - this.instanceInfos.set(instance_id, instanceInfo); + this.instanceInfos.set(instanceId, instanceInfo); logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`); } // Somewhat hacky, but in the event of a lost session the status is // resent on assigment since the controller sends an assigment // request for all the instances it knows should be on this host. - let instanceConnection = this.instanceConnections.get(instance_id); - libLink.messages.instanceStatusChanged.send(this, { - instance_id, - status: instanceConnection ? instanceConnection.status : "stopped", - }); + let instanceConnection = this.instanceConnections.get(instanceId); + this.send( + new libData.InstanceStatusChangedEvent( + instanceId, instanceConnection ? instanceConnection.status : "stopped", null + ) + ); // save a copy of the instance config let warnedOutput = { @@ -354,13 +476,13 @@ class Host extends libLink.Link { ); } - async unassignInstanceRequestHandler(message) { - let instanceId = message.data.instance_id; + async handleInstanceUnassignInternalRequest(request) { + let instanceId = request.instanceId; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { let instanceConnection = this.instanceConnections.get(instanceId); if (instanceConnection && ["starting", "running"].includes(instanceConnection.status)) { - await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId }); + await instanceConnection.send(new libData.InstanceStopRequest()); } this.instanceInfos.delete(instanceId); @@ -396,7 +518,9 @@ class Host extends libLink.Link { throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`); } - let [connectionClient, connectionServer] = libLink.VirtualConnector.makePair(); + let hostAddress = new libData.Address(libData.Address.host, this.id); + let instanceAddress = new libData.Address(libData.Address.instance, instanceId); + let [connectionClient, connectionServer] = libLink.VirtualConnector.makePair(instanceAddress, hostAddress); let instanceConnection = new InstanceConnection(connectionServer, this, instanceId); let instance = new Instance( this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config @@ -408,10 +532,10 @@ class Host extends libLink.Link { return instanceConnection; } - async getMetricsRequestHandler() { + async handleHostMetricsRequest() { let requests = []; for (let instanceConnection of this.instanceConnections.values()) { - requests.push(libLink.messages.getMetrics.send(instanceConnection)); + requests.push(instanceConnection.send(new libData.InstanceMetricsRequest())); } let results = []; @@ -434,86 +558,36 @@ class Host extends libLink.Link { return { results }; } - async startInstanceRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - return await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error starting instance:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } + async fallbackInstanceListSavesRequest(request, src, dst) { + let instanceInfo = this.getRequestInstanceInfo(dst.id); + return await Instance.listSaves(path.join(instanceInfo.path, "saves"), null); } - async loadScenarioRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - return await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error starting instance:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } - } - - async listSavesRequestHandler(message, request) { - let instanceId = message.data.instance_id; - let instanceConnection = this.instanceConnections.get(instanceId); - if (instanceConnection) { - return await request.send(instanceConnection, message.data); - } - + async handleInstanceRenameSaveRequest(request) { + let { instanceId, oldName, newName } = request; + checkRequestSaveName(oldName); + checkRequestSaveName(newName); let instanceInfo = this.getRequestInstanceInfo(instanceId); - return { - list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null), - }; - } - - async createSaveRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error creating save:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } - } - - async renameSaveRequestHandler(message) { - let { instance_id, old_name, new_name } = message.data; - checkRequestSaveName(old_name); - checkRequestSaveName(new_name); - let instanceInfo = this.getRequestInstanceInfo(instance_id); try { await fs.move( - path.join(instanceInfo.path, "saves", old_name), - path.join(instanceInfo.path, "saves", new_name), + path.join(instanceInfo.path, "saves", oldName), + path.join(instanceInfo.path, "saves", newName), { overwrite: false }, ); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${old_name} does not exist`); + throw new libErrors.RequestError(`${oldName} does not exist`); } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async copySaveRequestHandler(message) { - let { instance_id, source, destination } = message.data; + async handleInstanceCopySaveRequest(request) { + let { instanceId, source, destination } = request; checkRequestSaveName(source); checkRequestSaveName(destination); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + let instanceInfo = this.getRequestInstanceInfo(instanceId); try { await fs.copy( path.join(instanceInfo.path, "saves", source), @@ -526,83 +600,83 @@ class Host extends libLink.Link { } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async transferSaveRequestHandler(message) { - let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; - checkRequestSaveName(source_save); - checkRequestSaveName(target_save); - let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id); - let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id); + async handleInstanceTransferSaveRequest(request) { + let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request; + checkRequestSaveName(sourceName); + checkRequestSaveName(targetName); + let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId); + let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId); // For consistency with remote transfer initiated through pullSave the // target is renamed if it already exists. - target_save = await libFileOps.findUnusedName( - path.join(targetInstanceInfo.path, "saves"), target_save, ".zip" + targetName = await libFileOps.findUnusedName( + path.join(targetInstanceInfo.path, "saves"), targetName, ".zip" ); try { if (copy) { await fs.copy( - path.join(sourceInstanceInfo.path, "saves", source_save), - path.join(targetInstanceInfo.path, "saves", target_save), + path.join(sourceInstanceInfo.path, "saves", sourceName), + path.join(targetInstanceInfo.path, "saves", targetName), { overwrite: true }, ); } else { await fs.move( - path.join(sourceInstanceInfo.path, "saves", source_save), - path.join(targetInstanceInfo.path, "saves", target_save), + path.join(sourceInstanceInfo.path, "saves", sourceName), + path.join(targetInstanceInfo.path, "saves", targetName), { overwrite: true }, ); } } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${source_save} does not exist`); + throw new libErrors.RequestError(`${sourceName} does not exist`); } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves")); - await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves")); - return { save: target_save }; + return targetName; } - async sendSaveListUpdate(instance_id, savesDir) { - let instanceConnection = this.instanceConnections.get(instance_id); + async sendSaveListUpdate(instanceId, savesDir) { + let instanceConnection = this.instanceConnections.get(instanceId); let saveList; if (instanceConnection) { - saveList = (await libLink.messages.listSaves.send(instanceConnection, { instance_id })).list; + saveList = await instanceConnection.send(new libData.InstanceListSavesRequest()); } else { saveList = await Instance.listSaves(savesDir, null); } - libLink.messages.saveListUpdate.send(this, { instance_id, list: saveList }); + this.send(new libData.InstanceSaveListUpdateEvent(instanceId, saveList)); } - async deleteSaveRequestHandler(message) { - let { instance_id, save } = message.data; - checkRequestSaveName(save); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + async handleInstanceDeleteSaveRequest(request) { + let { instanceId, name } = request; + checkRequestSaveName(name); + let instanceInfo = this.getRequestInstanceInfo(instanceId); try { - await fs.unlink(path.join(instanceInfo.path, "saves", save)); + await fs.unlink(path.join(instanceInfo.path, "saves", name)); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${save} does not exist`); + throw new libErrors.RequestError(`${name} does not exist`); } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async pullSaveRequestHandler(message) { - let { instance_id, stream_id, filename } = message.data; - checkRequestSaveName(filename); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + async handleInstancePullSaveRequest(request) { + let { instanceId, streamId, name } = request; + checkRequestSaveName(name); + let instanceInfo = this.getRequestInstanceInfo(instanceId); let url = new URL(this.config.get("host.controller_url")); - url.pathname += `api/stream/${stream_id}`; + url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: this.tlsCa }, @@ -615,7 +689,7 @@ class Host extends libLink.Link { } let savesDir = path.join(instanceInfo.path, "saves"); - let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip"); + let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip"); let writeStream; while (true) { try { @@ -633,61 +707,47 @@ class Host extends libLink.Link { response.pipe(writeStream); await finished(writeStream); - filename = await libFileOps.findUnusedName(savesDir, filename, ".zip"); - await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename)); + name = await libFileOps.findUnusedName(savesDir, name, ".zip"); + await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name)); - await this.sendSaveListUpdate(instance_id, savesDir); - return { save: filename }; + await this.sendSaveListUpdate(instanceId, savesDir); + return name; } - async pushSaveRequestHandler(message) { - let { instance_id, stream_id, save } = message.data; - checkRequestSaveName(save); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + async handleInstancePushSaveRequest(request) { + let { instanceId, streamId, name } = request; + checkRequestSaveName(name); + let instanceInfo = this.getRequestInstanceInfo(instanceId); let content; try { // phin doesn't support streaming requests :( - content = await fs.readFile(path.join(instanceInfo.path, "saves", save)); + content = await fs.readFile(path.join(instanceInfo.path, "saves", name)); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${save} does not exist`); + throw new libErrors.RequestError(`${name} does not exist`); } throw err; } let url = new URL(this.config.get("host.controller_url")); - url.pathname += `api/stream/${stream_id}`; + url.pathname += `api/stream/${streamId}`; phin({ url, method: "PUT", core: { ca: this.tlsCa }, data: content, }).catch(err => { - logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instance_id)); + logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId)); }); } - async exportDataRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error exporting data:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } - } - async stopInstance(instanceId) { let instanceConnection = this.instanceConnections.get(instanceId); - await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId }); + await instanceConnection.send(new libData.InstanceStopRequest()); } - async deleteInstanceRequestHandler(message) { - let instanceId = message.data.instance_id; + async handleInstanceDeleteInternalRequest(request) { + let instanceId = request.instanceId; if (this.instanceConnections.has(instanceId)) { throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`); } @@ -713,12 +773,12 @@ class Host extends libLink.Link { let list = []; for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); - list.push({ - serialized_config: instanceInfo.config.serialize("controller"), - status: instanceConnection ? instanceConnection.status : "stopped", - }); + list.push(new libData.RawInstanceInfo( + instanceInfo.config.serialize("controller"), + instanceConnection ? instanceConnection.status : "stopped", + )); } - await libLink.messages.updateInstances.send(this, { instances: list }); + await this.send(new libData.InstancesUpdateRequest(list)); // Handle configured auto startup instances if (this._startup) { @@ -728,10 +788,7 @@ class Host extends libLink.Link { if (instanceInfo.config.get("instance.auto_start")) { try { let instanceConnection = await this._connectInstance(instanceId); - await libLink.messages.startInstance.send(instanceConnection, { - instance_id: instanceId, - save: null, - }); + await instanceConnection.send(new libData.InstanceStartRequest(null)); } catch (err) { logger.error( `Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`, @@ -743,13 +800,12 @@ class Host extends libLink.Link { } } - async prepareDisconnectRequestHandler(message, request) { + async prepareDisconnect() { for (let instanceConnection of this.instanceConnections.values()) { - await libLink.messages.prepareControllerDisconnect.send(instanceConnection); + await instanceConnection.send(new libData.PrepareControllerDisconnectRequest()); } this._disconnecting = true; - this.connector.setClosing(); - return await super.prepareDisconnectRequestHandler(message, request); + return await super.prepareDisconnect(); } /** @@ -761,8 +817,16 @@ class Host extends libLink.Link { } this._shuttingDown = true; + for (let instanceId of this.instanceConnections.keys()) { + try { + await this.stopInstance(instanceId); + } catch (err) { + logger.error(`Unexpected error stopping instance:\n${err.stack}`); + } + } + try { - await libLink.messages.prepareDisconnect.send(this); + await this.connector.disconnect(); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { logger.error(`Unexpected error preparing disconnect:\n${err.stack}`); @@ -770,11 +834,6 @@ class Host extends libLink.Link { } try { - for (let instanceId of this.instanceConnections.keys()) { - await this.stopInstance(instanceId); - } - await this.connector.close(1000, "Host Shutdown"); - // Clear silly interval in pidfile library. pidusage.clear(); } catch (err) { diff --git a/packages/host/src/Instance.js b/packages/host/src/Instance.js index 3da1c9f9..f442565f 100644 --- a/packages/host/src/Instance.js +++ b/packages/host/src/Instance.js @@ -102,8 +102,7 @@ const serverSettingsActions = { */ class Instance extends libLink.Link { constructor(host, connector, dir, factorioDir, instanceConfig) { - super("instance", "host", connector); - libLink.attachAllMessages(this); + super(connector); this._host = host; this._dir = dir; @@ -211,6 +210,24 @@ class Instance extends libLink.Link { this._playerCheckInterval = null; this._hadPlayersOnline = false; this._playerAutosaveSlot = 1; + + this.register(libData.InstanceExtractPlayersRequest, this.handleInstanceExtractPlayersRequest.bind(this)); + this.register(libData.InstanceAdminlistUpdateEvent, this.handleInstanceAdminlistUpdateEvent.bind(this)); + this.register(libData.InstanceBanlistUpdateEvent, this.handleInstanceBanlistUpdateEvent.bind(this)); + this.register(libData.InstanceWhitelistUpdateEvent, this.handleInstanceWhitelistUpdateEvent.bind(this)); + this.register(libData.ControllerConnectionEvent, this.handleControllerConnectionEvent.bind(this)); + this.register( + libData.PrepareControllerDisconnectRequest, this.handlePrepareControllerDisconnectRequest.bind(this) + ); + this.register(libData.InstanceMetricsRequest, this.handleInstanceMetricsRequest.bind(this)); + this.register(libData.InstanceStartRequest, this.handleInstanceStartRequest.bind(this)); + this.register(libData.InstanceLoadScenarioRequest, this.handleInstanceLoadScenarioRequest.bind(this)); + this.register(libData.InstanceListSavesRequest, this.handleInstanceListSavesRequest.bind(this)); + this.register(libData.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this)); + this.register(libData.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this)); + this.register(libData.InstanceStopRequest, this.handleInstanceStopRequest.bind(this)); + this.register(libData.InstanceKillRequest, this.handleInstanceKillRequest.bind(this)); + this.register(libData.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this)); } _watchPlayerJoinsByChat() { @@ -284,9 +301,9 @@ class Instance extends libLink.Link { instance_id: this.id, type: "join", name, - stats: stats.toJSON(), + stats, }; - libLink.messages.playerEvent.send(this, event); + this.sendTo(new libData.InstancePlayerUpdateEvent("join", name, undefined, stats), "controller"); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); } @@ -306,13 +323,13 @@ class Instance extends libLink.Link { type: "leave", name, reason, - stats: stats.toJSON(), + stats, }; - libLink.messages.playerEvent.send(this, event); + this.sendTo(new libData.InstancePlayerUpdateEvent("leave", name, reason, stats), "controller"); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); } - async extractPlayersRequestHandler() { + async handleInstanceExtractPlayersRequest() { const exportPlayerTimes = `/sc local players = {} for _, p in pairs(game.players) do @@ -320,6 +337,7 @@ for _, p in pairs(game.players) do end rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); let playerTimes = JSON.parse(await this.sendRcon(exportPlayerTimes)); + let count = 0; for (let [name, onlineTimeTicks] of Object.entries(playerTimes)) { let stats = this.playerStats.get(name); @@ -335,9 +353,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); name, stats: stats.toJSON(), }; - libLink.messages.playerEvent.send(this, event); + this.sendTo(new libData.InstancePlayerUpdateEvent("import", name, undefined, stats), "controller"); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); + count += 1; } + this.logger.info(`Extracted data for ${count} player(s)`); } async sendRcon(message, expectEmpty, plugin = "") { @@ -371,24 +391,24 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); type = "special"; } - list.push({ - name, + list.push(new libData.SaveDetails( type, - size: stat.size, - mtime_ms: stat.mtimeMs, - loaded: name === loadedSave, - default: name === defaultSave, - }); + name, + stat.size, + stat.mtimeMs, + name === loadedSave, + name === defaultSave, + )); } return list; } async sendSaveListUpdate() { - libLink.messages.saveListUpdate.send(this, { - instance_id: this.id, - list: await Instance.listSaves(this.path("saves"), this._loadedSave), - }); + this.sendTo(new libData.InstanceSaveListUpdateEvent( + this.id, + await Instance.listSaves(this.path("saves"), this._loadedSave), + ), "controller"); } async _autosave(name) { @@ -415,11 +435,14 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); notifyStatus(status) { this._status = status; - libLink.messages.instanceStatusChanged.send(this, { - instance_id: this.id, - status, - game_port: this.server && this.server.gamePort || this.config.get("factorio.game_port") || null, - }); + this.sendTo( + new libData.InstanceStatusChangedEvent( + this.id, + status, + this.server && this.server.gamePort || this.config.get("factorio.game_port") || null, + ), + "controller" + ); } /** @@ -467,7 +490,6 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); let instancePlugin = new InstancePluginClass(pluginInfo, this, host); this.plugins.set(pluginInfo.name, instancePlugin); await instancePlugin.init(); - libPlugin.attachPluginMessages(this, instancePlugin); this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`); } @@ -528,7 +550,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); for (let [name, plugin] of this.plugins) { plugins[name] = plugin.info.version; } - libLink.messages.instanceInitialized.send(this, { instance_id: this.id, plugins }); + this.send(new libData.InstanceInitialisedEvent(plugins)); } /** @@ -598,13 +620,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); */ async syncMods() { const modPackId = this.config.get("factorio.mod_pack"); - let response; + let modPack; if (modPackId === null) { - response = await libLink.messages.getDefaultModPack.send(this); + modPack = await this.sendTo(new libData.ModPackGetDefaultRequest(), "controller"); } else { - response = await libLink.messages.getModPack.send(this, { id: modPackId }); + modPack = await this.sendTo(new libData.ModPackGetRequest(modPackId), "controller"); } - this.activeModPack = libData.ModPack.fromJSON(response.mod_pack); + this.activeModPack = modPack; // TODO validate factorioVersion @@ -698,21 +720,21 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * * Creates a new save if no save is passed and patches it with modules. * - * @param {string|null} saveName - + * @param {string|undefined} saveName - * Save to prepare from the instance saves directory. Creates a new * save if null. * @returns {Promise} Name of the save prepared. */ async prepareSave(saveName) { // Use latest save if no save was specified - if (saveName === null) { + if (saveName === undefined) { saveName = await libFileOps.getNewestFile( this.path("saves"), (name) => !name.endsWith(".tmp.zip") ); } // Create save if no save was found. - if (saveName === null) { + if (saveName === undefined) { this.logger.info("Creating new save"); await this.server.create("world.zip"); saveName = "world.zip"; @@ -907,37 +929,36 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async adminlistUpdateEventHandler(message) { + async handleInstanceAdminlistUpdateEvent(request) { if (!this.config.get("factorio.sync_adminlist")) { return; } - let { name, admin } = message.data; + let { name, admin } = request; let command = admin ? `/promote ${name}` : `/demote ${name}`; await this.sendRcon(command); } - async banlistUpdateEventHandler(message) { + async handleInstanceBanlistUpdateEvent(request) { if (!this.config.get("factorio.sync_banlist")) { return; } - let { name, banned, reason } = message.data; + let { name, banned, reason } = request; let command = banned ? `/ban ${name} ${reason}` : `/unban ${name}`; await this.sendRcon(command); } - async whitelistUpdateEventHandler(message) { + async handleInstanceWhitelistUpdateEvent(request) { if (!this.config.get("factorio.sync_whitelist")) { return; } - let { name, whitelisted } = message.data; + let { name, whitelisted } = request; let command = whitelisted ? `/whitelist add ${name}` : `/whiteliste remove ${name}`; await this.sendRcon(command); } - /** * Stop the instance */ @@ -962,15 +983,15 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.server.kill(true); } - async controllerConnectionEventEventHandler(message) { - await libPlugin.invokeHook(this.plugins, "onControllerConnectionEvent", message.data.event); + async handleControllerConnectionEvent(event) { + await libPlugin.invokeHook(this.plugins, "onControllerConnectionEvent", event.event); } - async prepareControllerDisconnectRequestHandler() { + async handlePrepareControllerDisconnectRequest() { await libPlugin.invokeHook(this.plugins, "onPrepareControllerDisconnect"); } - async getMetricsRequestHandler() { + async handleInstanceMetricsRequest() { let results = []; if (!["stopped", "stopping"].includes(this._status)) { let pluginResults = await libPlugin.invokeHook(this.plugins, "onMetrics"); @@ -988,11 +1009,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); instanceFactorioMemoryUsage.labels(String(this.id)).set(stats.memory); } - return { results }; + return new libData.InstanceMetricsRequest.Response(results); } - async startInstanceRequestHandler(message) { - let saveName = message.data.save; + async handleInstanceStartRequest(request) { + let saveName = request.save; try { await this.prepare(); saveName = await this.prepareSave(saveName); @@ -1010,7 +1031,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async loadScenarioRequestHandler(message) { + async handleInstanceLoadScenarioRequest(request) { if (this.config.get("factorio.enable_save_patching")) { this.notifyExit(); throw new libErrors.RequestError("Load scenario cannot be used with save patching enabled"); @@ -1024,22 +1045,20 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); throw err; } - let { scenario, seed, map_gen_settings, map_settings } = message.data; + let { scenario, seed, mapGenSettings, mapSettings } = request; try { - await this.startScenario(scenario, seed, map_gen_settings, map_settings); + await this.startScenario(scenario, seed, mapGenSettings, mapSettings); } catch (err) { await this.stop(); throw err; } } - async listSavesRequestHandler(message) { - return { - list: await Instance.listSaves(this.path("saves"), this._loadedSave), - }; + async handleInstanceListSavesRequest() { + return await Instance.listSaves(this.path("saves"), this._loadedSave); } - async createSaveRequestHandler(message) { + async handleInstanceCreateSaveRequest(request) { this.notifyStatus("creating_save"); try { this.logger.verbose("Writing server-settings.json"); @@ -1055,13 +1074,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } this.server.on("exit", () => this.notifyExit()); - let { name, seed, map_gen_settings, map_settings } = message.data; - await this.server.create(name, seed, map_gen_settings, map_settings); + let { name, seed, mapGenSettings, mapSettings } = request; + await this.server.create(name, seed, mapGenSettings, mapSettings); await this.sendSaveListUpdate(); this.logger.info("Successfully created save"); } - async exportDataRequestHandler() { + async handleInstanceExportDataRequest() { this.notifyStatus("exporting_data"); try { this.logger.verbose("Writing server-settings.json"); @@ -1093,17 +1112,16 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async stopInstanceRequestHandler() { + async handleInstanceStopRequest() { await this.stop(); } - async killInstanceRequestHandler() { + async handleInstanceKillRequest() { await this.kill(); } - async sendRconRequestHandler(message) { - let result = await this.sendRcon(message.data.command); - return { result }; + async handleInstanceSendRconRequest(request) { + return await this.sendRcon(request.command); } /** diff --git a/packages/host/src/InstanceConnection.js b/packages/host/src/InstanceConnection.js index b7a94eb3..51e82d72 100644 --- a/packages/host/src/InstanceConnection.js +++ b/packages/host/src/InstanceConnection.js @@ -1,4 +1,5 @@ "use strict"; +const libData = require("@clusterio/lib/data"); const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); const libErrors = require("@clusterio/lib/errors"); @@ -6,57 +7,15 @@ const libErrors = require("@clusterio/lib/errors"); class InstanceConnection extends libLink.Link { constructor(connector, host, instanceId) { - super("host", "instance", connector); + super(connector); this.host = host; + this.router = this.host.router; this.instanceId = instanceId; this.plugins = new Map(); this.status = "stopped"; - libLink.attachAllMessages(this); - for (let pluginInfo of host.pluginInfos) { - libPlugin.attachPluginMessages(this, { info: pluginInfo }); - } - } - - async forwardRequestToController(message, request) { - return await request.send(this.host, message.data); - } - - async forwardRequestToInstance(message, request) { - let instanceId = message.data.instance_id; - let instanceConnection = this.host.instanceConnections.get(instanceId); - if (!instanceConnection) { - // Instance is probably on another host - return await this.forwardRequestToController(message, request); - } - - if (request.plugin && !instanceConnection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`); - } - - return await request.send(instanceConnection, message.data); - } - - - async forwardEventToInstance(message, event) { - let instanceId = message.data.instance_id; - let instanceConnection = this.host.instanceConnections.get(instanceId); - if (!instanceConnection) { - // Instance is probably on another host - await this.forwardEventToController(message, event); - return; - } - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; } - - event.send(instanceConnection, message.data); - } - - async forwardEventToController(message, event) { - if (!this.host.connector.hasSession) { - return; - } - - event.send(this.host, message.data); + this.register(libData.InstanceInitialisedEvent, this.handleInstanceInitialisedEvent.bind(this)); + this.snoopEvent(libData.InstanceStatusChangedEvent, this.snoopInstanceStatusChangedEvent.bind(this)); } async broadcastEventToInstance(message, event) { @@ -69,16 +28,15 @@ class InstanceConnection extends libLink.Link { } } - async instanceInitializedEventHandler(message, event) { - this.plugins = new Map(Object.entries(message.data.plugins)); + async handleInstanceInitialisedEvent(event) { + this.plugins = new Map(Object.entries(event.plugins)); } - async instanceStatusChangedEventHandler(message, event) { - this.status = message.data.status; + async snoopInstanceStatusChangedEvent(event) { + this.status = event.status; if (this.status === "stopped") { this.host.instanceConnections.delete(this.instanceId); } - this.forwardEventToController(message, event); } } diff --git a/packages/lib/PlayerStats.js b/packages/lib/PlayerStats.js index 19e36818..c13fa1cb 100644 --- a/packages/lib/PlayerStats.js +++ b/packages/lib/PlayerStats.js @@ -62,6 +62,10 @@ class PlayerStats { } } + static fromJSON(json) { + return new this(json); + } + toJSON() { let json = {}; if (this.joinCount) { diff --git a/packages/lib/command.js b/packages/lib/command.js index f66e70c7..ad9b1bd0 100644 --- a/packages/lib/command.js +++ b/packages/lib/command.js @@ -7,6 +7,7 @@ * @module lib/command */ "use strict"; +const libData = require("./data"); const libLink = require("./link"); const libErrors = require("./errors"); @@ -150,8 +151,8 @@ async function resolveHost(client, hostName) { if (/^-?\d+$/.test(hostName)) { hostId = parseInt(hostName, 10); } else { - let response = await libLink.messages.listHosts.send(client); - for (let host of response.list) { + let hosts = await client.sendTo(new libData.HostListRequest(), "controller"); + for (let host of hosts) { if (host.name === hostName) { hostId = host.id; break; @@ -182,8 +183,8 @@ async function resolveInstance(client, instanceName) { if (/^-?\d+$/.test(instanceName)) { instanceId = parseInt(instanceName, 10); } else { - let response = await libLink.messages.listInstances.send(client); - for (let instance of response.list) { + let instances = await client.sendTo(new libData.InstanceDetailsListRequest(), "controller"); + for (let instance of instances) { if (instance.name === instanceName) { instanceId = instance.id; break; @@ -215,10 +216,10 @@ async function resolveModPack(client, modPackName) { if (/^-?\d+/.test(modPackName)) { modPackId = parseInt(modPackName, 10); } else { - let response = await libLink.messages.listModPacks.send(client); - for (let mod_pack of response.list) { - if (mod_pack["name"] === modPackName) { - modPackId = mod_pack["id"]; + let modPacks = await client.sendTo(new libData.ModPackListRequest(), "controller"); + for (let modPack of modPacks) { + if (modPack.name === modPackName) { + modPackId = modPack.id; break; } } @@ -243,12 +244,12 @@ async function resolveModPack(client, modPackName) { * @static */ async function retrieveRole(client, roleName) { - let response = await libLink.messages.listRoles.send(client); + let roles = await client.sendTo(new libData.RoleListRequest(), "controller"); let resolvedRole; if (/^-?\d+$/.test(roleName)) { let roleId = parseInt(roleName, 10); - for (let role of response.list) { + for (let role of roles) { if (role.id === roleId) { resolvedRole = role; break; @@ -256,7 +257,7 @@ async function retrieveRole(client, roleName) { } } else { - for (let role of response.list) { + for (let role of roles) { if (role.name === roleName) { resolvedRole = role; break; diff --git a/packages/lib/data/composites.js b/packages/lib/data/composites.js new file mode 100644 index 00000000..de62d1c6 --- /dev/null +++ b/packages/lib/data/composites.js @@ -0,0 +1,32 @@ +"use strict"; + +function jsonArray(ItemClass) { + return { + jsonSchema: { + type: "array", + items: ItemClass.jsonSchema, + }, + + fromJSON(json) { + return json.map(i => ItemClass.fromJSON(i)); + }, + }; +} + +function jsonPrimitive(type) { + return { + jsonSchema: { type }, + fromJSON(json) { + return json; + }, + }; +} + +module.exports = { + JsonBoolean: jsonPrimitive("boolean"), + JsonNumber: jsonPrimitive("number"), + JsonString: jsonPrimitive("string"), + + jsonArray, + jsonPrimitive, +}; diff --git a/packages/lib/data/index.js b/packages/lib/data/index.js index f3856763..5522a49a 100644 --- a/packages/lib/data/index.js +++ b/packages/lib/data/index.js @@ -7,11 +7,25 @@ const ExportManifest = require("./ExportManifest"); const ModInfo = require("./ModInfo"); const ModPack = require("./ModPack"); +const composites = require("./composites"); +const messagesCore = require("./messages_core"); +const messagesController = require("./messages_controller"); +const messagesHost = require("./messages_host"); +const messagesInstance = require("./messages_instance"); +const messagesMod = require("./messages_mod"); +const messagesUser = require("./messages_user"); const version = require("./version"); module.exports = { ExportManifest, ModInfo, ModPack, + ...composites, + ...messagesCore, + ...messagesController, + ...messagesHost, + ...messagesInstance, + ...messagesMod, + ...messagesUser, ...version, }; diff --git a/packages/lib/data/messages_controller.js b/packages/lib/data/messages_controller.js new file mode 100644 index 00000000..f35b6640 --- /dev/null +++ b/packages/lib/data/messages_controller.js @@ -0,0 +1,384 @@ +"use strict"; +const { JsonString } = require("./composites"); + +// TODO: remove after config refactor +/** @memberof module:lib/data */ +class RawConfig { + /** @type {object} */ + serializedConfig; + + constructor(serializedConfig) { + this.serializedConfig = serializedConfig; + } + + static jsonSchema = { + type: "object", + required: ["serializedConfig"], + properties: { + "serializedConfig": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.serializedConfig); + } +} + +/* @memberof module:lib/data */ +class ControllerConfigGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.controller.get_config"; + static Response = RawConfig; +} + +/** @memberof module:lib/data */ +class ControllerConfigSetFieldRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.controller.update_config"; + + /** @type {string} */ + field; + /** @type {string} */ + value; + + constructor(field, value) { + this.field = field; + this.value = value; + } + + static jsonSchema = { + type: "object", + required: ["field", "value"], + properties: { + "field": { type: "string" }, + "value": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.field, json.value); + } +} + +/* @memberof module:lib/data */ +class ControllerConfigSetPropRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.controller.update_config"; + + /** @type {string} */ + field; + /** @type {string} */ + prop; + /** @type {undefined} */ + value; + + constructor(field, prop, value) { + this.field = field; + this.prop = prop; + if (value) { this.value = value; } + } + + static jsonSchema = { + type: "object", + required: ["field", "prop"], + properties: { + field: { type: "string" }, + prop: { type: "string" }, + value: {}, + }, + }; + + static fromJSON(json) { + return new this(json.field, json.prop, json.value); + } +} + +/* @memberof module:lib/data */ +class HostGenerateTokenRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.generate_token"; + + /** @type {?number} */ + hostId; + + constructor(hostId) { + this.hostId = hostId; + } + + static jsonSchema = { + type: "object", + required: ["hostId"], + properties: { + hostId: { type: ["integer", "null"] }, + }, + }; + + static fromJSON(json) { + return new this(json.hostId); + } + + static Response = JsonString; +} + +/** @memberof module:lib/data */ +class HostConfigCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.create_config"; + + /** @type {?number} */ + id; + /** @type {?string} */ + name; + /** @type {boolean} */ + generateToken; + + constructor(id, name, generateToken) { + this.id = id; + this.name = name; + this.generateToken = generateToken; + } + + static jsonSchema = { + type: "object", + required: ["id", "name", "generateToken"], + properties: { + "id": { type: ["integer", "null"] }, + "name": { type: ["string", "null"] }, + "generateToken": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.id, json.name, json.generateToken); + } + + static Response = RawConfig; +} + +/* @memberof module:lib/data */ +class LogSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.log.follow"; + + /** @type {boolean} */ + all; + /** @type {boolean} */ + controller; + /** @type {array} */ + hostIds; + /** @type {array} */ + instanceIds; + /** @type {?string} */ + maxLevel; + + constructor(all, controller, hostIds, instanceIds, maxLevel) { + this.all = all; + this.controller = controller; + this.hostIds = hostIds; + this.instanceIds = instanceIds; + this.maxLevel = maxLevel; + } + + static jsonSchema = { + type: "object", + required: ["all", "controller", "hostIds", "instanceIds", "maxLevel"], + properties: { + all: { type: "boolean" }, + controller: { type: "boolean" }, + hostIds: { + type: "array", + items: { type: "integer" }, + }, + instanceIds: { + type: "array", + items: { type: "integer" }, + }, + maxLevel: { type: ["string", "null"] }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.controller, json.hostIds, json.instanceIds, json.maxLevel); + } +} + +/* @memberof module:lib/data */ +class LogQueryRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.log.query"; + + /** @type {boolean} */ + all; + /** @type {boolean} */ + controller; + /** @type {Array} */ + hostIds; + /** @type {Array} */ + instanceIds; + /** @type {?string} */ + maxLevel; + /** @type {integer} */ + limit; + /** @type {undefined} */ + order; + + constructor(all, controller, hostIds, instanceIds, maxLevel, limit, order) { + this.all = all; + this.controller = controller; + this.hostIds = hostIds; + this.instanceIds = instanceIds; + this.maxLevel = maxLevel; + this.limit = limit; + this.order = order; + } + + static jsonSchema = { + type: "object", + required: ["all", "controller", "hostIds", "instanceIds", "maxLevel", "limit", "order"], + properties: { + all: { type: "boolean" }, + controller: { type: "boolean" }, + hostIds: { + type: "array", + items: { type: "integer" }, + }, + instanceIds: { + type: "array", + items: { type: "integer" }, + }, + maxLevel: { type: ["string", "null"] }, + limit: { type: "integer" }, + order: { + enum: ["asc", "desc"], + }, + }, + }; + + static fromJSON(json) { + return new this( + json.all, json.controller, json.hostIds, json.instanceIds, json.maxLevel, json.limit, json.order + ); + } +} +LogQueryRequest.Response = class Response { + /** @type {Array} */ + log; + + constructor(log) { + this.log = log; + } + + static jsonSchema = { + type: "object", + required: ["log"], + properties: { + log: { + type: "array", + items: { type: "object" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.log); + } +}; + +/** @memberof module:lib/data */ +class LogMessageEvent { + static type = "event"; + static src = "host"; + static dst = "controller"; + + /** @type {object} */ + info; + + constructor(info) { + this.info = info; + } + + static jsonSchema = { + type: "object", + required: ["info"], + properties: { + "info": { + type: "object", + required: ["level", "message"], + properties: { + "level": { type: "string" }, + "message": { type: "string" }, + }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.info); + } +} + +/* @memberof module:lib/data */ +class DebugDumpWsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.debug.dump_ws"; +} + +/* @memberof module:lib/data */ +class DebugWsMessageEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {string} */ + direction; + /** @type {string} */ + content; + + constructor(direction, content) { + this.direction = direction; + this.content = content; + } + + static jsonSchema = { + type: "object", + required: ["direction", "content"], + properties: { + direction: { type: "string" }, + content: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.direction, json.content); + } +} + +module.exports = { + RawConfig, + ControllerConfigGetRequest, + ControllerConfigSetFieldRequest, + ControllerConfigSetPropRequest, + HostGenerateTokenRequest, + HostConfigCreateRequest, + LogSetSubscriptionsRequest, + LogQueryRequest, + LogMessageEvent, + DebugDumpWsRequest, + DebugWsMessageEvent, +}; diff --git a/packages/lib/data/messages_core.js b/packages/lib/data/messages_core.js new file mode 100644 index 00000000..3028ad43 --- /dev/null +++ b/packages/lib/data/messages_core.js @@ -0,0 +1,880 @@ +"use strict"; +const libSchema = require("../schema"); + + +/** @memberof module:lib/data */ +class Address { + /** Controller address */ + static controller = 0; + /** Instance address */ + static instance = 1; + /** Host address */ + static host = 2; + /** Control address */ + static control = 3; + /** Broadcast */ + static broadcast = 4; + + /** @type {number} */ + type; + + /** @type {string} */ + id; + + /** @type {string|undefined} */ + requestId; + + constructor(type, id, requestId) { + if (typeof type !== "number") { + throw Error(`Invalid type ${type}`); + } + this.type = type; + this.id = id; + this.requestId = requestId; + } + + /** + * Convert from convenient shorthand notation to address. + * + * Supported notations and their corresponding address: + * - `"controller"`: new Address(Address.controller, 0) + * - `{ hostId: id }`: new Address(Address.host, id) + * - `{ instanceId: id }`: new Address(Address.instance, id) + * - `{ controlId: id }`: new Address(Address.control, id) + * `"allHosts"`: new Address(Address.broadcast, Address.host) + * `"allInstances"`: new Address(Address.broadcast, Address.instance) + * `"allControls"`: new Address(Address.broadcast, Address.control) + * @param {module:lib/data.Address|string|object} shorthand - + * Shorthand to translate with. + * @returns {module:lib/data.Address} Translated address. + */ + static fromShorthand(shorthand) { + if (shorthand instanceof Address) { return shorthand; } + if (shorthand === "controller") { return new Address(Address.controller, 0); } + if (shorthand === "allHosts") { return new Address(Address.broadcast, Address.host); } + if (shorthand === "allInstances") { return new Address(Address.broadcast, Address.instance); } + if (shorthand === "allControls") { return new Address(Address.broadcast, Address.control); } + let entries = Object.entries(shorthand); + if (entries.length === 1) { + let [name, value] = entries[0]; + if (name === "hostId") { return new Address(Address.host, value); } + if (name === "instanceId") { return new Address(Address.instance, value); } + if (name === "controlId") { return new Address(Address.control, value); } + } + throw Error("Unrecognized shorthand"); + } + + static jsonSchema = { + type: "array", + minItems: 2, + maxItems: 3, + items: [ + { type: "integer", minimum: 0, maximum: 4 }, + { type: "integer" }, + { type: "integer" }, + ], + }; + + static fromJSON(json) { + return new this(json[0], json[1], json[2]); + } + + toJSON() { + if (this.requestId !== undefined) { + return [this.type, this.id, this.requestId]; + } + return [this.type, this.id]; + } + + toString() { + const typeMap = ["controller", "instance", "host", "control"]; + const type = typeMap[this.type] || this.type; + if (this.type === Address.broadcast) { + return `[Address ${type}:${typeMap[this.id] || this.id}]`; + } + if (this.requestId === undefined) { + return `[Address ${type}:${this.id}]`; + } + return `[Address ${type}:${this.id}:${this.requestId}]`; + } + + /** + * Returns true if this address targets the given destination + * @param {module:lib/Data.Address} dst - Destination to check + * @returns {boolean} true if this address is addressed to the given destination + */ + addressedTo(dst) { + if (this.type !== Address.broadcast) { + return ( + this.type === dst.type + && this.id === dst.id + ); + } + return dst.type !== Address.broadcast && this.id === dst.type; + } + + equals(other) { + return ( + this.type === other.type + && this.id === other.id + && this.requestId === other.requestId + ); + } +} + +/** @memberof module:lib/data */ +class Message { + /** @type {string} */ + type; + + constructor(type) { + this.type = type; + } + + static jsonSchema; + static validate; + + static fromJSON(json) { + /* eslint-disable no-use-before-define */ + if (json.type === "hello") { return MessageHello.fromJSON(json); } + if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); } + if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); } + if (json.type === "ready") { return MessageReady.fromJSON(json); } + if (json.type === "resume") { return MessageResume.fromJSON(json); } + if (json.type === "continue") { return MessageContinue.fromJSON(json); } + if (json.type === "invalidate") { return MessageInvalidate.fromJSON(json); } + if (json.type === "heartbeat") { return MessageHeartbeat.fromJSON(json); } + if (json.type === "request") { return MessageRequest.fromJSON(json); } + if (json.type === "response") { return MessageResponse.fromJSON(json); } + if (json.type === "responseError") { return MessageResponseError.fromJSON(json); } + if (json.type === "event") { return MessageEvent.fromJSON(json); } + if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); } + /* eslint-enable no-use-before-define */ + throw new Error(`Unrecognized message type ${json.type}`); + } +} + +/** @memberof module:lib/data */ +class HelloData { + /** @type {string} */ + version; + /** @type {object} */ + plugins; + + constructor(version, plugins) { + this.version = version; + this.plugins = plugins; + } + + static jsonSchema = { + required: ["version", "plugins"], + properties: { + "version": { type: "string" }, + "plugins": { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.version, json.plugins); + } +} + +/** @memberof module:lib/data */ +class MessageHello extends Message { + /** @type {module:lib/data.HelloData} */ + data; + + constructor(data) { + super("hello"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "data"], + properties: { + "type": { const: "hello" }, + "data": HelloData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(HelloData.fromJSON(json.data)); + } +}; + +/** @memberof module:lib/data */ +class RegisterHostData { + /** @type {string} */ + token; + /** @type {string} */ + agent; + /** @type {string} */ + version; + /** @type {string} */ + name; + /** @type {string} */ + id; + /** @type {string|undefined} */ + publicAddress; + /** @type {Object} */ + plugins; + + constructor(token, agent, version, name, id, publicAddress, plugins) { + this.token = token; + this.agent = agent; + this.version = version; + this.name = name; + this.id = id; + this.publicAddress = publicAddress; + this.plugins = plugins; + } + + static jsonSchema = { + type: "object", + required: ["token", "agent", "version", "name", "id", "plugins"], + properties: { + "token": { type: "string" }, + "agent": { type: "string" }, + "version": { type: "string" }, + "name": { type: "string" }, + "id": { type: "integer" }, + "publicAddress": { type: "string" }, + "plugins": { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.token, json.agent, json.version, json.name, json.id, json.publicAddress, json.plugins); + } +} + +/** @memberof module:lib/data */ +class MessageRegisterHost extends Message { + /** @type {module:lib/data.RegisterHostData} */ + data; + + constructor(data) { + super("registerHost"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "data"], + properties: { + "type": { const: "registerHost" }, + "data": RegisterHostData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(RegisterHostData.fromJSON(json.data)); + } +}; + +/** @memberof module:lib/data */ +class RegisterControlData { + /** @type {string} */ + token; + /** @type {string} */ + agent; + /** @type {string} */ + version; + + constructor(token, agent, version) { + this.token = token; + this.agent = agent; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["token", "agent", "version"], + properties: { + "token": { type: "string" }, + "agent": { type: "string" }, + "version": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.token, json.agent, json.version); + } +} + +/** @memberof module:lib/data */ +class MessageRegisterControl extends Message { + /** @type {module:lib/data.RegisterControlData} */ + data; + + constructor(data) { + super("registerControl"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "registerControl" }, + "data": RegisterControlData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(RegisterControlData.fromJSON(json.data)); + } +}; + +/** @memberof module:lib/data */ +class AccountDetails { + /** @type {string} */ + name; + /** @type {Array} */ + roles; + + constructor(name, roles) { + this.name = name; + this.roles = roles; + } + + static jsonSchema = { + additionalProperties: false, + required: ["name", "roles"], + properties: { + "name": { type: "string" }, + "roles": { + type: "array", + items: { + additionalProperties: false, + type: "object", + required: ["name", "id", "permissions"], + properties: { + "name": { type: "string" }, + "id": { type: "integer" }, + "permissions": { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.roles); + } +} + +/** @memberof module:lib/data */ +class ReadyData { + /** @type {module:lib/data.Address} */ + src; + /** @type {string} */ + sessionToken; + /** @type {number} */ + sessionTimeout; + /** @type {number} */ + heartbeatInterval; + /** @type {AccountDetails|undefined} */ + account; + + constructor(src, sessionToken, sessionTimeout, heartbeatInterval, account) { + this.src = src; + this.sessionToken = sessionToken; + this.sessionTimeout = sessionTimeout; + this.heartbeatInterval = heartbeatInterval; + if (account) { this.account = account; } + } + + static jsonSchema = { + additionalProperties: false, + required: ["src", "sessionToken", "sessionTimeout", "heartbeatInterval"], + properties: { + "src": Address.jsonSchema, + "sessionToken": { type: "string" }, + "sessionTimeout": { type: "number" }, + "heartbeatInterval": { type: "number" }, + "account": AccountDetails.jsonSchema, + }, + }; + + static fromJSON(json) { + let account; + if (json.account) { account = AccountDetails.fromJSON(json.account); } + return new this( + Address.fromJSON(json.src), + json.sessionToken, + json.sessionTimeout, + json.heartbeatInterval, + account + ); + } +} + +/** @memberof module:lib/data */ +class MessageReady extends Message { + /** @type {module:lib/data.ReadyData} */ + data; + + constructor(data) { + super("ready"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "ready" }, + "data": ReadyData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ReadyData.fromJSON(json.data)); + } +} + +/** @memberof module:lib/data */ +class ResumeData { + /** @type {string} */ + sessionToken; + /** @type {integer|undefined} */ + lastSeq; + + constructor(sessionToken, lastSeq) { + this.sessionToken = sessionToken; + if (lastSeq !== undefined) { this.lastSeq = lastSeq; } + } + + static jsonSchema = { + type: "object", + required: ["sessionToken"], + properties: { + "sessionToken": { type: "string" }, + "lastSeq": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.sessionToken, json.lastSeq); + } +} + + +/** @memberof module:lib/data */ +class MessageResume extends Message { + /** @type {module:lib/data.ResumeData} */ + data; + + constructor(data) { + super("resume"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "resume" }, + "data": ResumeData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ResumeData.fromJSON(json.data)); + } +} + +/** @memberof module:lib/data */ +class ContinueData { + /** @type {number} */ + sessionTimeout; + /** @type {number} */ + heartbeatInterval; + /** @type {integer|undefined} */ + lastSeq; + + constructor(sessionTimeout, heartbeatInterval, lastSeq) { + this.sessionTimeout = sessionTimeout; + this.heartbeatInterval = heartbeatInterval; + if (lastSeq !== undefined) { this.lastSeq = lastSeq; } + } + + static jsonSchema = { + required: ["sessionTimeout", "heartbeatInterval"], + properties: { + "sessionTimeout": { type: "number" }, + "heartbeatInterval": { type: "number" }, + "lastSeq": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.sessionTimeout, json.heartbeatInterval, json.lastSeq); + } +} + +/** @memberof module:lib/data */ +class MessageContinue extends Message { + /** @type {module:lib/data.ContinueData} */ + data; + + constructor(data) { + super("continue"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "continue" }, + "data": ContinueData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ContinueData.fromJSON(json.data)); + } +} + +/** @memberof module:lib/data */ +class MessageInvalidate extends Message { + constructor() { + super("invalidate"); + } + + static jsonSchema = { + type: "object", + properties: { + "type": { const: "invalidate" }, + }, + }; + + static fromJSON(json) { + return new this(); + } +} + +/** @memberof module:lib/data */ +class MessageHeartbeat extends Message { + /** @type {integer|undefined} */ + seq; + + constructor(seq) { + super("heartbeat"); + this.seq = seq; + } + + static jsonSchema = { + type: "object", + required: ["type"], + properties: { + "type": { const: "heartbeat" }, + "seq": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.seq); + } +} + +/** @memberof module:lib/data */ +class MessageRequest extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {string} */ + name; + /** @type {*} */ + data; + + constructor(seq, src, dst, name, data) { + super("request"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.name = name; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst", "name"], + properties: { + "type": { const: "request" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "name": { type: "string" }, + "data": {}, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + json.name, + json.data, + ); + } +}; + +/** @memberof module:lib/data */ +class MessageResponse extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {*} */ + data; + + constructor(seq, src, dst, data) { + super("response"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst"], + properties: { + "type": { const: "response" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "data": {}, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + json.data, + ); + } +}; + +/** @memberof module:lib/data */ +class ResponseError { + /** @type {string} */ + message; + /** @type {string|undefined} */ + code; + /** @type {string|undefined} */ + stack; + + constructor(message, code, stack) { + this.message = message; + if (code) { this.code = code; } + if (stack) { this.stack = stack; } + } + + static jsonSchema = { + required: ["message"], + properties: { + "message": { type: "string" }, + "code": { type: "string" }, + "stack": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.message, json.code, json.stack); + } +} + +/** @memberof module:lib/data */ +class MessageResponseError extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {module:lib/data.ResponseError} */ + data; + + constructor(seq, src, dst, data) { + super("responseError"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst", "data"], + properties: { + "type": { const: "responseError" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "data": ResponseError.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + ResponseError.fromJSON(json.data), + ); + } +}; + +/** @memberof module:lib/data */ +class MessageEvent extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {string} */ + name; + /** @type {*} */ + data; + + constructor(seq, src, dst, name, data) { + super("event"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.name = name; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst", "name"], + properties: { + "type": { const: "event" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "name": { type: "string" }, + "data": {}, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + json.name, + json.data, + ); + } +}; + +/** @memberof module:lib/data */ +class MessageDisconnect extends Message { + /** @type {string} */ + data; + + constructor(stage) { + super("disconnect"); + this.data = stage; + } + + static jsonSchema = { + type: "object", + required: ["type"], + properties: { + "type": { const: "disconnect" }, + "data": { enum: ["prepare", "ready"] }, + }, + }; + + static fromJSON(json) { + return new this(json.data); + } +}; + +Message.jsonSchema = { + type: "object", + required: ["type"], + properties: { + "type": { enum: [ + "hello", "registerHost", "registerControl", "ready", "resume", "continue", "invalidate", + "heartbeat", "request", "response", "responseError", "event", "disconnect", + ] }, + }, + oneOf: [ + MessageHello.jsonSchema, + MessageRegisterHost.jsonSchema, + MessageRegisterControl.jsonSchema, + MessageReady.jsonSchema, + MessageResume.jsonSchema, + MessageContinue.jsonSchema, + MessageInvalidate.jsonSchema, + MessageHeartbeat.jsonSchema, + MessageRequest.jsonSchema, + MessageResponse.jsonSchema, + MessageResponseError.jsonSchema, + MessageEvent.jsonSchema, + MessageDisconnect.jsonSchema, + ], +}; +Message.validate = libSchema.compile(Message.jsonSchema); + +/** @memberof module:lib/data */ +class PingRequest { + static type = "request"; + static src = ["controller", "host", "control"]; + static dest = ["controller", "host", "control"]; + static permission = null; +} + +module.exports = { + Message, + HelloData, + MessageHello, + RegisterHostData, + MessageRegisterHost, + RegisterControlData, + MessageRegisterControl, + AccountDetails, + ReadyData, + MessageReady, + ResumeData, + MessageResume, + ContinueData, + MessageContinue, + MessageInvalidate, + MessageHeartbeat, + Address, + MessageRequest, + MessageResponse, + ResponseError, + MessageResponseError, + MessageEvent, + MessageDisconnect, + PingRequest, +}; diff --git a/packages/lib/data/messages_host.js b/packages/lib/data/messages_host.js new file mode 100644 index 00000000..40b0b855 --- /dev/null +++ b/packages/lib/data/messages_host.js @@ -0,0 +1,240 @@ +"use strict"; +const { jsonArray } = require("./composites"); + + +/** @memberof module:lib/data */ +class HostDetails { + /** @type {string} */ + agent; + /** @type {string} */ + version; + /** @type {string} */ + name; + /** @type {number} */ + id; + /** @type {boolean} */ + connected; + /** @type {string|undefined} */ + publicAddress; + + constructor(agent, version, name, id, connected, publicAddress) { + this.agent = agent; + this.version = version; + this.name = name; + this.id = id; + this.connected = connected; + if (publicAddress !== undefined) { this.publicAddress = publicAddress; } + } + + static jsonSchema = { + required: ["agent", "version", "name", "id", "connected"], + properties: { + "agent": { type: "string" }, + "version": { type: "string" }, + "name": { type: "string" }, + "id": { type: "integer" }, + "connected": { type: "boolean" }, + "publicAddress": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.agent, json.version, json.name, json.id, json.connected, json.publicAddress); + } +} + +/** @memberof module:lib/data */ +class HostListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.list"; + static Response = jsonArray(HostDetails); +} + +/* @memberof module:lib/data */ +class HostSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + hostIds; + + constructor(all, hostIds) { + this.all = all; + this.hostIds = hostIds; + } + + static jsonSchema = { + type: "object", + required: ["all", "hostIds"], + properties: { + all: { type: "boolean" }, + hostIds: { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.hostIds); + } +} + + +/* @memberof module:lib/data */ +class HostUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.HostDetails} */ + update; + + constructor(update) { + this.update = update; + } + + static jsonSchema = { + type: "object", + required: ["update"], + properties: { + update: HostDetails.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(HostDetails.fromJSON(json.update)); + } +} + +/** @memberof module:lib/data */ +class HostMetricsRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; +} +HostMetricsRequest.Response = class Response { // TODO: Use JSON class pattern in Prometheus + /** @type {array} */ + results; + + constructor(results) { + this.results = results; + } + + static jsonSchema = { + type: "object", + required: ["results"], + properties: { + "results": { + type: "array", + }, + }, + }; + + static fromJSON(json) { + return new this(json.results); + } +}; + +/** @memberof module:lib/data */ +class ControllerConnectionEvent { + static type = "event"; + static src = "host"; + static dst = "instance"; + + /** @type {string} */ + event; + + constructor(event) { + this.event = event; + } + + static jsonSchema = { + required: ["event"], + properties: { + "event": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.event); + } +} + +/* @memberof module:lib/data */ +class PrepareControllerDisconnectRequest { + static type = "request"; + static src = "host"; + static dst = "instance"; +} + +/** @memberof module:lib/data */ +class SyncUserListsEvent { + static type = "event"; + static src = "controller"; + static dst = "host"; + + /** @type {Set} */ + adminlist; + /** @type {Map} */ + banlist; + /** @type {Set} */ + whitelist; + + constructor(adminlist, banlist, whitelist) { + this.adminlist = adminlist; + this.banlist = banlist; + this.whitelist = whitelist; + } + + static jsonSchema = { + required: ["adminlist", "banlist", "whitelist"], + properties: { + "adminlist": { + type: "array", + items: { type: "string" }, + }, + "banlist": { + type: "array", + items: { + type: "array", + additionalItems: false, + items: [{ type: "string" }, { type: "string" }], + }, + }, + "whitelist": { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(new Set(json.adminlist), new Set(json.banlist), new Set(json.whitelist)); + } + + static toJSON() { + return { + adminlist: [...this.adminlist], + banlist: [...this.banlist], + whitelist: [...this.whitelist], + }; + } +} + +module.exports = { + HostDetails, + HostListRequest, + HostSetSubscriptionsRequest, + HostUpdateEvent, + HostMetricsRequest, + ControllerConnectionEvent, + PrepareControllerDisconnectRequest, + SyncUserListsEvent, +}; diff --git a/packages/lib/data/messages_instance.js b/packages/lib/data/messages_instance.js new file mode 100644 index 00000000..f7ace486 --- /dev/null +++ b/packages/lib/data/messages_instance.js @@ -0,0 +1,1319 @@ +"use strict"; +const PlayerStats = require("../PlayerStats"); +const { JsonString, jsonArray } = require("./composites"); + +/** @memberof module:lib/data */ +class InstanceDetails { + /** @type {string} */ + name; + /** @type {number} */ + id; + /** @type {?integer} */ + assignedHost; + /** @type {?integer} */ + gamePort; + /** @type {string} */ + status; + + constructor(name, id, assignedHost, gamePort, status) { + this.name = name; + this.id = id; + this.assignedHost = assignedHost; + this.gamePort = gamePort; + this.status = status; + } + + static jsonSchema = { + type: "object", + required: ["name", "id", "assignedHost", "gamePort", "status"], + properties: { + "name": { type: "string" }, + "id": { type: "integer" }, + "assignedHost": { type: ["null", "integer"] }, + "gamePort": { type: ["null", "integer"] }, + "status": { enum: [ + "unknown", "unassigned", "stopped", "starting", "running", "stopping", + "creating_save", "exporting_data", "deleted", + ]}, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.id, json.assignedHost, json.gamePort, json.status); + } +} + +/** @memberof module:lib/data */ +class InstanceDetailsGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.get"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + "instanceId": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } + + static Response = InstanceDetails; +} + +/** @memberof module:lib/data */ +class InstanceDetailsListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.list"; + static Response = jsonArray(InstanceDetails); +}; + +/** @memberof module:lib/data */ +class InstanceDetailsSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static premission = "core.instance.subscribe"; + + /** @type {boolean} */ + all; + + /** @type {Array} */ + instanceIds; + + constructor(all, instanceIds) { + this.all = all || false; + this.instanceIds = instanceIds || []; + } + + static jsonSchema = { + type: "object", + properties: { + "all": { type: "boolean" }, + "instanceIds": { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.instanceIds); + } +}; + +/** @memberof module:lib/data */ +class InstanceDetailsUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.InstanceDetails} */ + details; + + constructor(details) { + this.details = details; + } + + static jsonSchema = InstanceDetails.jsonSchema; + + toJSON() { + return this.details; + } + + static fromJSON(json) { + return new this(InstanceDetails.fromJSON(json)); + } +}; + +/** @memberof module:lib/data */ +class InstanceCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static premission = "core.instance.create"; + + /** @type {object} */ + config; + + constructor(config) { + this.config = config; + } + + static jsonSchema = { + type: "object", + required: ["config"], + properties: { + "config": { type: "object" }, + }, + }; + + static fromJSON(json) { + // TODO deserialise config here after config refactor + return new this(json.config); + } +} + +/** @memberof module:lib/data */ +class InstanceConfigGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static premission = "core.instance.get_config"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + required: ["instanceId"], + properties: { + "instanceId": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} +// TODO replace with InstanceConfig after config refactor +InstanceConfigGetRequest.Response = class Response { + /** @type {object} */ + config; + + constructor(config) { + this.config = config; + } + + static jsonSchema = { + type: "object", + required: ["config"], + properties: { + "config": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.config); + } +}; + +/** @memberof module:lib/data */ +class InstanceConfigSetFieldRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.update_config"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + field; + /** @type {string} */ + value; + + constructor(instanceId, field, value) { + this.instanceId = instanceId; + this.field = field; + this.value = value; + } + + static jsonSchema = { + required: ["instanceId", "field", "value"], + properties: { + "instanceId": { type: "integer" }, + "field": { type: "string" }, + "value": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.field, json.value); + } +} + +/** @memberof module:lib/data */ +class InstanceConfigSetPropRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.update_config"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + field; + /** @type {string} */ + prop; + /** @type {*} */ + value; + + constructor(instanceId, field, prop, value) { + this.instanceId = instanceId; + this.field = field; + this.prop = prop; + if (value !== undefined) { + this.value = value; + } + } + + static jsonSchema = { + required: ["instanceId", "field", "prop"], + properties: { + "instanceId": { type: "integer" }, + "field": { type: "string" }, + "prop": { type: "string" }, + "value": {}, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.field, json.prop, json.value); + } +} + +/** @memberof module:lib/data */ +class InstanceAssignRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.assign"; + + /** @type {number} */ + instanceId; + /** @type {?number} */ + hostId; + + constructor(instanceId, hostId) { + this.instanceId = instanceId; + this.hostId = hostId; + } + + static jsonSchema = { + required: ["instanceId", "hostId"], + properties: { + "instanceId": { type: "number" }, + "hostId": { type: ["number", "null"] }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.hostId); + } +} + +/** @memberof module:lib/data */ +class InstanceMetricsRequest { + static type = "request"; + static src = "host"; + static dst = "instance"; +} +InstanceMetricsRequest.Response = class Response { // TODO: Use JSON class pattern in Prometheus + /** @type {array} */ + results; + + constructor(results) { + this.results = results; + } + + static jsonSchema = { + type: "object", + required: ["results"], + properties: { + "results": { + type: "array", + }, + }, + }; + + static fromJSON(json) { + return new this(json.results); + } +}; + +/** @memberof module:lib/data */ +class InstanceStartRequest { + static type = "request"; + static src = ["control", "controller"]; + static dst = "instance"; + static permission = "core.instance.start"; + + /** @type {string|undefined} */ + save; + + constructor(save) { + if (save !== undefined) { this.save = save; }; + } + + static jsonSchema = { + type: "object", + properties: { + "save": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.save); + } +} + +/** @memberof module:lib/data */ +class SaveDetails { + /** @type {string} */ + type; + /** @type {string} */ + name; + /** @type {number} */ + size; + /** @type {number} */ + mtimeMs; + /** @type {boolean} */ + loaded; + /** @type {boolean} */ + loadByDefault; + + constructor(type, name, size, mtimeMs, loaded, loadByDefault) { + this.type = type; + this.name = name; + this.size = size; + this.mtimeMs = mtimeMs; + this.loaded = loaded; + this.loadByDefault = loadByDefault; + } + + static jsonSchema = { + type: "object", + required: ["type", "name", "size", "mtimeMs", "loaded"], + properties: { + "type": { enum: ["file", "directory", "special"] }, + "name": { type: "string" }, + "size": { type: "integer" }, + "mtimeMs": { type: "number" }, + "loaded": { type: "boolean" }, + "loadByDefault": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.type, json.name, json.size, json.mtimeMs, json.loaded, json.loadByDefault); + } +} + +/** @memberof module:lib/data */ +class InstanceListSavesRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.save.list"; + static Response = jsonArray(SaveDetails); +} + +/** @memberof module:lib/data */ +class InstanceSetSaveListSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static premission = "core.instance.save.list_subscribe"; + + /** @type {boolean} */ + all; + + /** @type {Array} */ + instanceIds; + + constructor(all, instanceIds) { + this.all = all || false; + this.instanceIds = instanceIds || []; + } + + static jsonSchema = { + type: "object", + properties: { + "all": { type: "boolean" }, + "instanceIds": { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.instanceIds); + } +}; + +/** @memberof module:lib/data */ +class InstanceSaveListUpdateEvent { + static type = "event"; + static src = ["instance", "host"]; + static dst = ["controller", "control"]; + + /** @type {number} */ + instanceId; + /** @type {module:lib/data.SaveDetails} */ + saves; + + constructor(instanceId, saves) { + this.instanceId = instanceId; + this.saves = saves; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "saves"], + properties: { + "instanceId": { type: "integer" }, + "saves": { + type: "array", + items: SaveDetails.jsonSchema, + }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.saves.map(i => SaveDetails.fromJSON(i))); + } +} + +/** @memberof module:lib/data */ +class InstanceCreateSaveRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.save.create"; + + /** @type {string} */ + name; + /** @type {?number} */ + seed; + /** @type {?object} */ + mapGenSettings; + /** @type {?object} */ + mapSettings; + + constructor(name, seed, mapGenSettings, mapSettings) { + this.name = name; + if (seed) { this.seed = seed; } + if (mapGenSettings) { this.mapGenSettings = mapGenSettings; } + if (mapSettings) { this.mapSettings = mapSettings; } + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + "name": { type: "string" }, + "seed": { type: "integer" }, + "mapGenSettings": { type: "object" }, + "mapSettings": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.seed, json.mapGenSettings, json.mapSettings); + } +} + +/** @memberof module:lib/data */ +class InstanceRenameSaveRequest { + static type = "request"; + static src = "control"; + static dst = "host"; + static permission = "core.instance.save.rename"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + oldName; + /** @type {string} */ + newName; + + constructor(instanceId, oldName, newName) { + this.instanceId = instanceId; + this.oldName = oldName; + this.newName = newName; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "oldName", "newName"], + properties: { + "instanceId": { type: "integer" }, + "oldName": { type: "string" }, + "newname": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.oldName, json.newName); + } +} + +/** @memberof module:lib/data */ +class InstanceCopySaveRequest { + static type = "request"; + static src = "control"; + static dst = "host"; + static permission = "core.instance.save.copy"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + source; + /** @type {string} */ + destination; + + constructor(instanceId, source, destination) { + this.instanceId = instanceId; + this.source = source; + this.destination = destination; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "source", "destination"], + properties: { + "instanceId": { type: "integer" }, + "source": { type: "string" }, + "destination": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.source, json.destination); + } +} + +/** @memberof module:lib/data */ +class InstanceDeleteSaveRequest { + static type = "request"; + static src = "control"; + static dst = "host"; + static permission = "core.instance.save.delete"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + name; + + constructor(instanceId, name) { + this.instanceId = instanceId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "name"], + properties: { + "instanceId": { type: "integer" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.name); + } +} + +/** @memberof module:lib/data */ +class InstanceDownloadSaveRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.save.download"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + name; + + constructor(instanceId, name) { + this.instanceId = instanceId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + "instanceId": { type: "integer" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.name); + } + + static Response = JsonString; +} + +/** @memberof module:lib/data */ +class InstanceTransferSaveRequest { + static type = "request"; + static src = "control"; + static dst = ["controller", "host"]; + static permission = "core.instance.save.transfer"; + + /** @type {number} */ + sourceInstanceId; + /** @type {string} */ + sourceName; + /** @type {number} */ + targetInstanceId; + /** @type {string} */ + targetName; + /** @type {boolean} */ + copy; + + constructor(sourceInstanceId, sourceName, targetInstanceId, targetName, copy) { + this.sourceInstanceId = sourceInstanceId; + this.sourceName = sourceName; + this.targetInstanceId = targetInstanceId; + this.targetName = targetName; + this.copy = copy; + } + + static jsonSchema = { + type: "object", + required: ["sourceInstanceId", "sourceName", "targetInstanceId", "targetName", "copy"], + properties: { + "sourceInstanceId": { type: "number" }, + "sourceName": { type: "string" }, + "targetInstanceId": { type: "number" }, + "targetName": { type: "string" }, + "copy": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this( + json.sourceInstanceId, json.sourceName, json.targetInstanceId, json.targetName, json.copy + ); + } + + static Response = JsonString; +} + + +/** @memberof module:lib/data */ +class InstancePullSaveRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + streamId; + /** @type {string} */ + name; + + constructor(instanceId, streamId, name) { + this.instanceId = instanceId; + this.streamId = streamId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "streamId", "name"], + properties: { + "instanceId": { type: "integer" }, + "streamId": { type: "string" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.streamId, json.name); + } + + static Response = JsonString; +} + +/** @memberof module:lib/data */ +class InstancePushSaveRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + streamId; + /** @type {string} */ + name; + + constructor(instanceId, streamId, name) { + this.instanceId = instanceId; + this.streamId = streamId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "streamId", "name"], + properties: { + "instanceId": { type: "integer" }, + "streamId": { type: "string" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.streamId, json.name); + } +} + +/** @memberof module:lib/data */ +class InstanceLoadScenarioRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.load_scenario"; + + /** @type {string} */ + scenario; + /** @type {?number} */ + seed; + /** @type {?object} */ + mapGenSettings; + /** @type {?object} */ + mapSettings; + + constructor(scenario, seed, mapGenSettings, mapSettings) { + this.scenario = scenario; + if (seed) { this.seed = seed; } + if (mapGenSettings) { this.mapGenSettings = mapGenSettings; } + if (mapSettings) { this.mapSettings = mapSettings; } + } + + static jsonSchema = { + type: "object", + required: ["scenario"], + properties: { + "scenario": { type: "string" }, + "seed": { type: "integer" }, + "mapGenSettings": { type: "object" }, + "mapSettings": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.scenario, json.seed, json.mapGenSettings, json.mapSettings); + } +} + +/** @memberof module:lib/data */ +class InstanceExportDataRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.export_data"; +} + +/** @memberof module:lib/data */ +class InstanceExtractPlayersRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.extract_players"; +} + +/** @memberof module:lib/data */ +class InstanceStopRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.stop"; +} + +/** @memberof module:lib/data */ +class InstanceKillRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.kill"; +} + +/** @memberof module:lib/data */ +class InstanceDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.delete"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + instanceId: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} + +/** @memberof module:lib/data */ +class InstanceDeleteInternalRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + instanceId: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} + +/** @memberof module:lib/data */ +class InstanceSendRconRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.send_rcon"; + + /** @type {string} */ + command; + + constructor(command) { + this.command = command; + } + + static jsonSchema = { + type: "object", + required: ["command"], + properties: { + "command": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.command); + } + + static Response = JsonString; +} + +// TODO remove this after config refactor +/** @memberof module:lib/data */ +class RawInstanceInfo { + /** @type {object} */ + config; + + /** @type {string} */ + status; + + constructor(config, status) { + this.config = config; + this.status = status; + } + + static jsonSchema = { + type: "object", + required: ["config", "status"], + properties: { + "config": { type: "object" }, + "status": { enum: [ + "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", + ]}, + }, + }; + + static fromJSON(json) { + return new this(json.config, json.status); + } +} + +/** @memberof module:lib/data */ +class InstancesUpdateRequest { + static type = "request"; + static src = "host"; + static dst = "controller"; + + /** @type {Array} */ + instances; + + constructor(instances) { + this.instances = instances; + } + + static jsonSchema = { + type: "array", + items: RawInstanceInfo.jsonSchema, + }; + + toJSON() { + return this.instances; + } + + static fromJSON(json) { + return new this(json.map(i => RawInstanceInfo.fromJSON(i))); + } +} + +/** @memberof module:lib/data */ +class InstanceAssignInternalRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + /** @type {object} */ + config; + + constructor(instanceId, config) { + this.instanceId = instanceId; + this.config = config; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "config"], + properties: { + "instanceId": { type: "integer" }, + "config": { type: "object" }, + }, + }; + + static fromJSON(json) { + // TODO deserialise config here after config refactor + return new this(json.instanceId, json.config); + } +} + +/** @memberof module:lib/data */ +class InstanceUnassignInternalRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + "instanceId": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} + +/** @memberof module:lib/data */ +class InstanceInitialisedEvent { + static type = "event"; + static src = "instance"; + static dst = "host"; + + /** @type {Object} */ + plugins; + + constructor(plugins) { + this.plugins = plugins; + } + + static jsonSchema = { + type: "object", + required: ["plugins"], + properties: { + "plugins": { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.plugins); + } +} + +/** @memberof module:lib/data */ +class InstanceStatusChangedEvent { + static type = "event"; + static src = "instance"; + static dst = "controller"; + + /** @type {integer} */ + instanceId; + /** @type {string} */ + status; + /** @type {?number} */ + gamePort; + + constructor(instanceId, status, gamePort) { + this.instanceId = instanceId; + this.status = status; + this.gamePort = gamePort; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "status", "gamePort"], + properties: { + "instanceId": { type: "integer" }, + "status": { + type: "string", + enum: [ + "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", + ], + }, + "gamePort": { type: ["null", "integer"] }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.status, json.gamePort); + } +} + +/** @memberof module:lib/data */ +class InstanceDetailsChangedEvent { + static type = "event"; + static src = "instance"; + static dst = "controller"; + + /** @type {module:lib/data.InstanceDetails} */ + details; + + constructor(details) { + this.details = details; + } + + static jsonSchema = InstanceDetails.jsonSchema; + + static fromJSON(json) { + return new this(InstanceDetails.fromJSON(json)); + } +} + +/** @memberof module:lib/data */ +class InstanceBanlistUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "instance"; + + /** @type {string} */ + name; + + /** @type {boolean} */ + banned; + + /** @type {string} */ + reason; + + constructor(name, banned, reason) { + this.name = name; + this.banned = banned; + this.reason = reason; + } + + static jsonSchema = { + required: ["name", "banned", "reason"], + properties: { + "name": { type: "string" }, + "banned": { type: "boolean" }, + "reason": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.banned, json.reason); + } +} + +/** @memberof module:lib/data */ +class InstanceAdminlistUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "instance"; + + /** @type {string} */ + name; + + /** @type {boolean} */ + admin; + + constructor(name, admin) { + this.name = name; + this.admin = admin; + } + + static jsonSchema = { + required: ["name", "admin"], + properties: { + "name": { type: "string" }, + "admin": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.admin); + } +} + +/** @memberof module:lib/data */ +class InstanceWhitelistUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "instance"; + + /** @type {string} */ + name; + + /** @type {boolean} */ + whitelisted; + + constructor(name, whitelisted) { + this.name = name; + this.whitelisted = whitelisted; + } + + static jsonSchema = { + required: ["name", "whitelisted"], + properties: { + "name": { type: "string" }, + "whitelisted": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.whitelisted); + } +} + +/** @memberof module:lib/data */ +class InstancePlayerUpdateEvent { + static type = "event"; + static src = "instance"; + static dst = "controller"; + + /** @type {string} */ + type; + + /** @type {string} */ + name; + + /** @type {?boolean} */ + reason; + + /** @type {?string} */ + stats; + + constructor(type, name, reason, stats) { + this.type = type; + this.name = name; + if (reason !== undefined) { this.reason = reason; } + if (stats !== undefined) { this.stats = stats; } + } + + static jsonSchema = { + required: ["type", "name"], + properties: { + "type": { type: "string", enum: ["join", "leave", "import"] }, + "name": { type: "string" }, + "reason": { type: "string" }, + "stats": PlayerStats.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.whitelisted); + } +} + +module.exports = { + InstanceDetails, + InstanceDetailsGetRequest, + InstanceDetailsListRequest, + InstanceDetailsSetSubscriptionsRequest, + InstanceDetailsUpdateEvent, + InstanceCreateRequest, + InstanceConfigGetRequest, + InstanceConfigSetFieldRequest, + InstanceConfigSetPropRequest, + InstanceAssignRequest, + InstanceMetricsRequest, + InstanceStartRequest, + SaveDetails, + InstanceListSavesRequest, + InstanceSetSaveListSubscriptionsRequest, + InstanceSaveListUpdateEvent, + InstanceCreateSaveRequest, + InstanceRenameSaveRequest, + InstanceCopySaveRequest, + InstanceDeleteSaveRequest, + InstanceDownloadSaveRequest, + InstanceTransferSaveRequest, + InstancePullSaveRequest, + InstancePushSaveRequest, + InstanceLoadScenarioRequest, + InstanceExportDataRequest, + InstanceExtractPlayersRequest, + InstanceStopRequest, + InstanceKillRequest, + InstanceDeleteRequest, + InstanceDeleteInternalRequest, + InstanceSendRconRequest, + RawInstanceInfo, + InstancesUpdateRequest, + InstanceAssignInternalRequest, + InstanceUnassignInternalRequest, + InstanceInitialisedEvent, + InstanceStatusChangedEvent, + InstanceDetailsChangedEvent, + InstanceBanlistUpdateEvent, + InstanceAdminlistUpdateEvent, + InstanceWhitelistUpdateEvent, + InstancePlayerUpdateEvent, +}; diff --git a/packages/lib/data/messages_mod.js b/packages/lib/data/messages_mod.js new file mode 100644 index 00000000..da311226 --- /dev/null +++ b/packages/lib/data/messages_mod.js @@ -0,0 +1,474 @@ +"use strict"; +const ModPack = require("./ModPack"); +const ModInfo = require("./ModInfo"); +const { jsonArray, JsonString } = require("./composites"); + + +/* @memberof module:lib/data */ +class ModPackGetRequest { + static type = "request"; + static src = ["instance", "control"]; + static dst = "controller"; + static permission = "core.mod_pack.get"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } + + static Response = ModPack; +} + +/* @memberof module:lib/data */ +class ModPackGetDefaultRequest { + static type = "request"; + static src = ["instance", "control"]; + static dst = "controller"; + static permission = "core.mod_pack.get"; + static Response = ModPack; +} + +/* @memberof module:lib/data */ +class ModPackListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.list"; + static Response = jsonArray(ModPack); +} + +/** @memberof module:lib/data */ +class ModPackCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.create"; + + /** @type {module:lib/data.ModPack} */ + modPack; + + constructor(modPack) { + this.modPack = modPack; + } + + static jsonSchema = { + type: "object", + required: ["modPack"], + properties: { + "modPack": ModPack.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModPack.fromJSON(json.modPack)); + } +} + +/* @memberof module:lib/data */ +class ModPackUpdateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.update"; + + /** @type {module:lib/data.ModPack} */ + modPack; + + constructor(modPack) { + this.modPack = modPack; + } + + static jsonSchema = { + type: "object", + required: ["modPack"], + properties: { + "modPack": ModPack.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModPack.fromJSON(json.modPack)); + } +} + +/* @memberof module:lib/data */ +class ModPackDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.delete"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } +} + +/* @memberof module:lib/data */ +class ModPackSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + modPackIds; + + constructor(all, modPackIds) { + this.all = all; + this.modPackIds = modPackIds; + } + + static jsonSchema = { + type: "object", + required: ["all", "modPackIds"], + properties: { + all: { type: "boolean" }, + modPackIds: { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.modPackIds); + } +} + +/* @memberof module:lib/data */ +class ModGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.get"; + + /** @type {string} */ + name; + /** @type {string} */ + version; + + constructor(name, version) { + this.name = name; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["name", "version"], + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.version); + } + + static Response = ModInfo; +} + +/* @memberof module:lib/data */ +class ModListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.list"; + static Response = jsonArray(ModInfo); +} + +/* @memberof module:lib/data */ +class ModSearchRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.search"; + + /** @type {string} */ + query; + /** @type {string} */ + factorioVersion; + /** @type {number} */ + page; + /** @type {number|undefined} */ + pageSize; + /** @type {string|undefined} */ + sort; + /** @type {string|undefined} */ + sortOrder; + + constructor(query, factorioVersion, page, pageSize, sort, sortOrder) { + this.query = query; + this.factorioVersion = factorioVersion; + this.page = page; + if (pageSize) { this.pageSize = pageSize; } + if (sort) { this.sort = sort; } + if (sortOrder) { this.sortOrder = sortOrder; } + } + + static jsonSchema = { + type: "object", + required: ["query", "factorioVersion", "page"], + properties: { + query: { type: "string" }, + factorioVersion: { type: "string" }, + pageSize: { type: "integer" }, + page: { type: "integer" }, + sort: { type: "string" }, + sortOrder: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.query, json.factorioVersion, json.page, json.pageSize, json.sort, json.sortOrder); + } +} +ModSearchRequest.Response = class Response { + /** @type {Array} */ + queryIssues; + /** @type {number} */ + pageCount; + /** @type {number} */ + resultCount; + /** @type {Array} */ + results; + + constructor(queryIssues, pageCount, resultCount, results) { + this.queryIssues = queryIssues; + this.pageCount = pageCount; + this.resultCount = resultCount; + this.results = results; + } + + static jsonSchema = { + type: "object", + required: ["queryIssues", "pageCount", "resultCount", "results"], + properties: { + queryIssues: { + type: "array", + items: { type: "string" }, + }, + pageCount: { type: "integer" }, + resultCount: { type: "integer" }, + results: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + versions: { + type: "array", + items: ModInfo.jsonSchema, + }, + }, + }, + }, + }, + }; + + static fromJSON(json) { + let results = json.results.map( + ({ name, versions }) => ({ name, versions: versions.map(mod => ModInfo.fromJSON(mod)) }) + ); + return new this(json.queryIssues, json.pageCount, json.resultCount, results); + } +}; + +/* @memberof module:lib/data */ +class ModSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + modNames; + + constructor(all, modNames) { + this.all = all; + this.modNames = modNames; + } + + static jsonSchema = { + type: "object", + required: ["all", "modNames"], + properties: { + all: { type: "boolean" }, + modNames: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.modNames); + } +} + +/* @memberof module:lib/data */ +class ModDownloadRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.download"; + + /** @type {string} */ + name; + /** @type {string} */ + version; + + constructor(name, version) { + this.name = name; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["name", "version"], + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.version); + } + + static Response = JsonString; +} + +/* @memberof module:lib/data */ +class ModDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.delete"; + + /** @type {string} */ + name; + /** @type {string} */ + version; + + constructor(name, version) { + this.name = name; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["name", "version"], + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.version); + } +} + +/* @memberof module:lib/data */ +class ModPackUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.ModPack} */ + modPack; + + constructor(modPack) { + this.modPack = modPack; + } + + static jsonSchema = { + type: "object", + required: ["modPack"], + properties: { + modPack: ModPack.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModPack.fromJSON(json.modPack)); + } +} + +/* @memberof module:lib/data */ +class ModUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.ModInfo} */ + mod; + + constructor(mod) { + this.mod = mod; + } + + static jsonSchema = { + type: "object", + required: ["mod"], + properties: { + mod: ModInfo.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModInfo.fromJSON(json.mod)); + } +} + +module.exports = { + ModPackGetRequest, + ModPackGetDefaultRequest, + ModPackListRequest, + ModPackCreateRequest, + ModPackUpdateRequest, + ModPackDeleteRequest, + ModPackSetSubscriptionsRequest, + ModGetRequest, + ModListRequest, + ModSearchRequest, + ModSetSubscriptionsRequest, + ModDownloadRequest, + ModDeleteRequest, + ModPackUpdateEvent, + ModUpdateEvent, +}; diff --git a/packages/lib/data/messages_user.js b/packages/lib/data/messages_user.js new file mode 100644 index 00000000..c657a698 --- /dev/null +++ b/packages/lib/data/messages_user.js @@ -0,0 +1,688 @@ +"use strict"; +const PlayerStats = require("../PlayerStats"); +const { jsonArray, JsonNumber } = require("./composites"); + +/* @memberof module:lib/data */ +class RawPermission { // TODO refactor into lib/user.Permission + /** @type {string} */ + name; + /** @type {string} */ + title; + /** @type {string} */ + description; + + constructor(name, title, description) { + this.name = name; + this.title = title; + this.description = description; + } + + static jsonSchema = { + required: ["name", "title", "description"], + properties: { + name: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.title, json.description); + } +} + +/* @memberof module:lib/data */ +class PermissionListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.permission.list"; + static Response = jsonArray(RawPermission); +} + +class RawRole { // TODO refactor into lib/user.Role + /** @type {number} */ + id; + /** @type {string} */ + name; + /** @type {string} */ + description; + /** @type {Array} */ + permissions; + + constructor(id, name, description, permissions) { + this.id = id; + this.name = name; + this.description = description; + this.permissions = permissions; + } + + static jsonSchema = { + type: "object", + required: ["id", "name", "description", "permissions"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + description: { type: "string" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.id, json.name, json.description, json.permissions); + } +} + +/* @memberof module:lib/data */ +class RoleListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.list"; + static Response = jsonArray(RawRole); +} + +/* @memberof module:lib/data */ +class RoleCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.create"; + + /** @type {string} */ + name; + /** @type {string} */ + description; + /** @type {Array} */ + permissions; + + constructor(name, description, permissions) { + this.name = name; + this.description = description; + this.permissions = permissions; + } + + static jsonSchema = { + type: "object", + required: ["name", "description", "permissions"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.description, json.permissions); + } + + static Response = JsonNumber; +} + +/* @memberof module:lib/data */ +class RoleUpdateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.update"; + + /** @type {number} */ + id; + /** @type {string} */ + name; + /** @type {string} */ + description; + /** @type {Array} */ + permissions; + + constructor(id, name, description, permissions) { + this.id = id; + this.name = name; + this.description = description; + this.permissions = permissions; + } + + static jsonSchema = { + type: "object", + required: ["id", "name", "description", "permissions"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + description: { type: "string" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.id, json.name, json.description, json.permissions); + } +} + +/* @memberof module:lib/data */ +class RoleGrantDefaultPermissionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.update"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } +} + +/* @memberof module:lib/data */ +class RoleDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.delete"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } +} + +/* @memberof module:lib/data */ +class RawUser { // TODO refactor into lib/user.User + /** @type {string} */ + name; + /** @type {Array} */ + roles; + /** @type {Array} */ + instances; + /** @type {boolean|undefined} */ + isAdmin; + /** @type {boolean|undefined} */ + isBanned; + /** @type {boolean|undefined} */ + isWhitelisted; + /** @type {string|undefined} */ + banReason; + /** @type {boolean|undefined} */ + isDeleted; + /** @type {module:lib/PlayerStats} */ + playerStats; + /** @type {Map|undefined} */ + instanceStats; + + constructor( + name, roles, instances, isAdmin, isBanned, isWhitelisted, banReason, + isDeleted, playerStats, instanceStats + ) { + this.name = name; + this.roles = roles; + this.instances = instances; + if (isAdmin !== undefined) { this.isAdmin = isAdmin; } + if (isBanned !== undefined) { this.isBanned = isBanned; } + if (isWhitelisted !== undefined) { this.isWhitelisted = isWhitelisted; } + if (banReason !== undefined) { this.banReason = banReason; } + if (isDeleted !== undefined) { this.isDeleted = isDeleted; } + if (playerStats !== undefined) { this.playerStats = playerStats; } + if (instanceStats !== undefined) { this.instanceStats = instanceStats; } + } + + static jsonSchema = { + type: "object", + required: ["name", "roles", "instances"], + properties: { + name: { type: "string" }, + roles: { type: "array", items: { type: "integer" } }, + isAdmin: { type: "boolean" }, + isBanned: { type: "boolean" }, + isWhitelisted: { type: "boolean" }, + banReason: { type: "string" }, + instances: { type: "array", items: { type: "integer" } }, + isDeleted: { type: "boolean" }, + playerStats: PlayerStats.jsonSchema, + instanceStats: { + type: "array", + items: { + type: "array", + items: [{ type: "integer" }, PlayerStats.jsonSchema], + }, + }, + }, + }; + + static fromJSON(json) { + let playerStats; + if (json.playerStats) { + playerStats = PlayerStats.fromJSON(json.playerStats); + } + let instanceStats; + if (json.instanceStats) { + instanceStats = new Map( + json.instanceStats.map(([id, stats]) => [id, PlayerStats.fromJSON(stats)]) + ); + } + return new this( + json.name, json.roles, json.instances, json.isAdmin, json.isBanned, json.isWhitelisted, json.banReason, + json.isDeleted, playerStats, instanceStats + ); + } +} + +/* @memberof module:lib/data */ +class UserGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.get"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } + + static Response = RawUser; +} + +/* @memberof module:lib/data */ +class UserListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.list"; + static Response = jsonArray(RawUser); +} + +/* @memberof module:lib/data */ +class UserSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + names; + + constructor(all, names) { + this.all = all; + this.names = names; + } + + static jsonSchema = { + type: "object", + required: ["all", "names"], + properties: { + all: { type: "boolean" }, + names: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.names); + } +} + +/* @memberof module:lib/data */ +class UserCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.create"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } +} + +/* @memberof module:lib/data */ +class UserRevokeTokenRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.revoke_token"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } +} + +/* @memberof module:lib/data */ +class UserUpdateRolesRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.update_roles"; + + /** @type {string} */ + name; + /** @type {Array} */ + roles; + + constructor(name, roles) { + this.name = name; + this.roles = roles; + } + + static jsonSchema = { + type: "object", + required: ["name", "roles"], + properties: { + name: { type: "string" }, + roles: { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.roles); + } +} + +/* @memberof module:lib/data */ +class UserSetAdminRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.set_admin"; + + /** @type {string} */ + name; + /** @type {boolean} */ + create; + /** @type {boolean} */ + admin; + + constructor(name, create, admin) { + this.name = name; + this.create = create; + this.admin = admin; + } + + static jsonSchema = { + type: "object", + required: ["name", "create", "admin"], + properties: { + name: { type: "string" }, + create: { type: "boolean" }, + admin: { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.create, json.admin); + } +} + +/* @memberof module:lib/data */ +class UserSetWhitelistedRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.set_whitelisted"; + + /** @type {string} */ + name; + /** @type {boolean} */ + create; + /** @type {boolean} */ + whitelisted; + + constructor(name, create, whitelisted) { + this.name = name; + this.create = create; + this.whitelisted = whitelisted; + } + + static jsonSchema = { + type: "object", + required: ["name", "create", "whitelisted"], + properties: { + name: { type: "string" }, + create: { type: "boolean" }, + whitelisted: { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.create, json.whitelisted); + } +} + +/* @memberof module:lib/data */ +class UserSetBannedRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.set_banned"; + + /** @type {string} */ + name; + /** @type {boolean} */ + create; + /** @type {boolean} */ + banned; + /** @type {string} */ + reason; + + constructor(name, create, banned, reason) { + this.name = name; + this.create = create; + this.banned = banned; + this.reason = reason; + } + + static jsonSchema = { + type: "object", + required: ["name", "create", "banned", "reason"], + properties: { + name: { type: "string" }, + create: { type: "boolean" }, + banned: { type: "boolean" }, + reason: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.create, json.banned, json.reason); + } +} + +/* @memberof module:lib/data */ +class UserDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.delete"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } +} + + +/* @memberof module:lib/data */ +class AccountUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {Array|undefined} */ + roles; + + constructor(roles) { + if (roles) { this.roles = roles; } + } + + static jsonSchema = { + type: "object", + properties: { + roles: { + type: "array", + items: { + type: "object", + required: ["name", "id", "permissions"], + properties: { + name: { type: "string" }, + id: { type: "integer" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.roles); + } +} + +/* @memberof module:lib/data */ +class UserUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.RawUser} */ + user; + + constructor(user) { + this.user = user; + } + + static jsonSchema = { + type: "object", + required: ["user"], + properties: { + user: RawUser.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(RawUser.fromJSON(json.user)); + } +} + +module.exports = { + RawPermission, + PermissionListRequest, + RawRole, + RoleListRequest, + RoleCreateRequest, + RoleUpdateRequest, + RoleGrantDefaultPermissionsRequest, + RoleDeleteRequest, + RawUser, + UserGetRequest, + UserListRequest, + UserSetSubscriptionsRequest, + UserCreateRequest, + UserRevokeTokenRequest, + UserUpdateRolesRequest, + UserSetAdminRequest, + UserSetWhitelistedRequest, + UserSetBannedRequest, + UserDeleteRequest, + AccountUpdateEvent, + UserUpdateEvent, +}; diff --git a/packages/lib/errors.js b/packages/lib/errors.js index 9e1d2f1c..e7de55df 100644 --- a/packages/lib/errors.js +++ b/packages/lib/errors.js @@ -14,7 +14,13 @@ class CommandError extends Error { } * Thrown from requests sent when an error occured handling it * @static */ -class RequestError extends Error { } +class RequestError extends Error { + constructor(message, code, stack) { + super(message); + if (code) { this.code = code; } + if (stack) { this.stack = stack; } + } +} /** * Thrown when a permission check fails. diff --git a/packages/lib/file_ops.js b/packages/lib/file_ops.js index 2d869e41..3a19ba2f 100644 --- a/packages/lib/file_ops.js +++ b/packages/lib/file_ops.js @@ -13,13 +13,13 @@ const crypto = require("crypto"); // needed for getTempFile * @param {function(string): boolean} filter - * Optional function to filter out files to ignore. Should return true * if the file is to be considered. - * @returns {Promise} + * @returns {Promise} * Name of the file with the newest timestamp or null if the * directory contains no files. */ async function getNewestFile(directory, filter = (name) => true) { let newestTime = new Date(0); - let newestFile = null; + let newestFile; for (let entry of await fs.readdir(directory, { withFileTypes: true })) { if (entry.isFile()) { let stat = await fs.stat(path.join(directory, entry.name)); diff --git a/packages/lib/link/connectors.js b/packages/lib/link/connectors.js index 68f8653d..45159a2f 100644 --- a/packages/lib/link/connectors.js +++ b/packages/lib/link/connectors.js @@ -1,9 +1,10 @@ // Connection adapters for Links "use strict"; +const assert = require("assert").strict; const events = require("events"); const WebSocket = require("isomorphic-ws"); -const libSchema = require("../schema"); +const libData = require("../data"); const libErrors = require("../errors"); const { logger } = require("../logging"); const ExponentialBackoff = require("../ExponentialBackoff"); @@ -15,34 +16,103 @@ const ExponentialBackoff = require("../ExponentialBackoff"); * @extends events.EventEmitter * @memberof module:lib/link */ -class WebSocketBaseConnector extends events.EventEmitter { - constructor(sessionTimeout) { +class BaseConnector extends events.EventEmitter { + constructor(src, dst) { super(); + this._seq = 1; + this.src = src; + this.dst = dst; + } - this._sessionTimeout = sessionTimeout; + _reset() { + this._seq = 1; + } + + _invalidate() { + this._seq = 1; + } + + sendRequest(request, requestId, dst) { + let seq = this._seq; + this._seq += 1; + let src = new libData.Address(this.src.type, this.src.id, requestId); + let name = request.constructor.name; + let hasData = Boolean(request.constructor.jsonSchema); + const message = new libData.MessageRequest(seq, src, dst, name, hasData ? request : undefined); + this.send(message); + return seq; + } + + sendResponse(response, dst) { + let seq = this._seq; + this._seq += 1; + const message = new libData.MessageResponse(seq, this.src, dst, response); + this.send(message); + return seq; + } + + sendResponseError(error, dst) { + let seq = this._seq; + this._seq += 1; + const message = new libData.MessageResponseError(seq, this.src, dst, error); + this.send(message); + return seq; + } + + sendEvent(event, dst) { + let seq = this._seq; + this._seq += 1; + let name = event.constructor.name; + let hasData = Boolean(event.constructor.jsonSchema); + const message = new libData.MessageEvent(seq, this.src, dst, name, hasData ? event : undefined); + this.send(message); + return seq; + } +} + +/** + * Base connector for links + * + * @extends module:lib/link.BaseConnector + * @memberof module:lib/link + */ +class WebSocketBaseConnector extends BaseConnector { + constructor(src, dst) { + super(src, dst); // One of closed, connecting (client only), connected and resuming. this._state = "closed"; this._closing = false; this._socket = null; - this._seq = 1; this._lastHeartbeat = null; this._heartbeatId = null; this._heartbeatInterval = null; - this._lastReceivedSeq = null; + this._lastReceivedSeq = undefined; this._sendBuffer = []; } _reset() { this._state = "closed"; this._closing = false; - this._socket = null; - this._seq = 1; + assert(this._socket === null); this._lastHeartbeat = null; - this._heartbeatId = null; + assert(this._heartbeatId === null); this._heartbeatInterval = null; - this._lastReceivedSeq = null; + this._lastReceivedSeq = undefined; this._sendBuffer.length = 0; + super._reset(); + } + + _invalidate() { + this._state = "connecting"; + assert(this._closing === false); + assert(this._socket); + this._lastHeartbeat = null; + assert(this._heartbeatId === null); + this._heartbeatInterval = null; + this._lastReceivedSeq = undefined; + this._sendBuffer.length = 0; + super._invalidate(); } _check(...expectedStates) { @@ -54,7 +124,7 @@ class WebSocketBaseConnector extends events.EventEmitter { } _dropSendBufferSeq(seq) { - if (seq === null) { + if (seq === undefined) { return; } @@ -69,6 +139,45 @@ class WebSocketBaseConnector extends events.EventEmitter { this._sendBuffer.splice(0, dropCount); } + _parseMessage(text) { + let json; + try { + json = JSON.parse(text); + } catch (err) { + this.close(4000, "Malformed JSON"); + return undefined; + } + if (!libData.Message.validate(json)) { + logger.error(`Received malformed message: ${text}`); + // logger.error(JSON.stringify(libData.Message.validate.errors, null, 4)); + this.close(4000, "Malformed message"); + return undefined; + } + return libData.Message.fromJSON(json); + } + + _processMessage(message) { + if (message.type === "heartbeat") { + this._processHeartbeat(message); + + } else if (message.type === "disconnect") { + if (message.data === "ready") { + this.emit("disconnectReady"); + + } else if (message.data === "prepare") { + this.setClosing(); + this.emit("disconnectPrepare"); + } + + } else { + if (message.seq !== undefined) { + this._lastReceivedSeq = message.seq; + } + + this.emit("message", message); + } + } + _doHeartbeat() { this._check("connected"); if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) { @@ -81,23 +190,13 @@ class WebSocketBaseConnector extends events.EventEmitter { } } else { - this._socket.send(JSON.stringify({ - seq: null, - type: "heartbeat", - data: { seq: this._lastReceivedSeq }, - })); + this._sendInternal(new libData.MessageHeartbeat(this._lastReceivedSeq)); } } _processHeartbeat(message) { - if (!libSchema.heartbeat(message)) { - logger.warn("Connector | closing after received invalid heartbeat"); - this._socket.close(1002, "Invalid heartbeat"); - return; - } - this._lastHeartbeat = Date.now(); - this._dropSendBufferSeq(message.data.seq); + this._dropSendBufferSeq(message.seq); } /** @@ -120,29 +219,30 @@ class WebSocketBaseConnector extends events.EventEmitter { if (this._heartbeatId) { clearInterval(this._heartbeatId); this._heartbeatId = null; - this._lastHeartbeat = null; } } /** * Send a message over the socket * - * @param {string} type - Message type to send. - * @param {Object} data - Data to send with message. - * @returns {number} the sequence number of the message sent + * This is a low level method that should only be used for implementing + * links. See sendTo for sending requests and events. + * + * @param {module:lib/data.Message} message - Message to send. */ - send(type, data) { + send(message) { if (!["connected", "resuming"].includes(this._state)) { throw new libErrors.SessionLost("No session"); } - let seq = this._seq; - this._seq += 1; - let message = { seq, type, data }; + this._sendBuffer.push(message); if (this._state === "connected") { - this._socket.send(JSON.stringify(message)); + this._sendInternal(message); } - return seq; + } + + _sendInternal(message) { + this._socket.send(JSON.stringify(message)); } /** @@ -155,11 +255,40 @@ class WebSocketBaseConnector extends events.EventEmitter { throw new Error("Abstract function"); } + /** + * Gracefully disconnect + * + * Sets closing flag an initiates the disconnect sequence. + */ + async disconnect() { + if (this._state !== "connected") { + await this.close(1000, "Disconnect"); + return; + } + + this.setClosing(); + this.send(new libData.MessageDisconnect("prepare")); + + let timeout; + let waitTimeout = new Promise(resolve => { timeout = setTimeout(resolve, 10000); }); // Make Configurable? + try { + await Promise.race([ + waitTimeout, + events.once(this, "close"), + events.once(this, "disconnectReady"), + ]); + } finally { + clearTimeout(timeout); + } + + await this.close(1000, "Disconnect"); + } + /** * True if the connection is established and active */ get connected() { - return this._state === "connected"; + return !this._closing && this._state === "connected"; } /** @@ -186,7 +315,7 @@ class WebSocketBaseConnector extends events.EventEmitter { */ class WebSocketClientConnector extends WebSocketBaseConnector { constructor(url, maxReconnectDelay, tlsCa = null) { - super(null); + super(undefined, new libData.Address(libData.Address.controller, 0)); this._url = url; this._backoff = new ExponentialBackoff({ max: maxReconnectDelay }); @@ -199,32 +328,40 @@ class WebSocketClientConnector extends WebSocketBaseConnector { // connected: Connection is online this._reconnectId = null; this._sessionToken = null; + this._sessionTimeout = null; this._startedResuming = null; } _reset() { clearTimeout(this._reconnectId); this._reconnectId = null; - this._lastReceivedSeq = null; this._sessionToken = null; this._sessionTimeout = null; - this._sendBuffer.length = 0; this._startedResuming = null; super._reset(); + this.src = undefined; + } + + _invalidate() { + assert(this._reconnectId === null); + this._sessionToken = null; + this._sessionTimeout = null; + this._startedResuming = null; + super._invalidate(); + this.src = undefined; } /** * Send a handshake message over the socket * - * Used in the register function in order to send the register handshake - * message over the WebSocket. + * Used in the register function in order to send the handshake messages + * over the WebSocket. * - * @param {string} type - Message type to send. - * @param {Object} data - Data to send with message. + * @param {module:lib/data.Message} message - Message to send. */ - sendHandshake(type, data) { + sendHandshake(message) { this._check("connecting", "resuming"); - this._socket.send(JSON.stringify({ seq: null, type, data })); + this._sendInternal(message); } /** @@ -329,8 +466,13 @@ class WebSocketClientConnector extends WebSocketBaseConnector { ) { logger.error("Connector | Session timed out trying to resume"); this._reset(); - this._state = "connecting"; - this.emit("invalidate"); + if (this._disconnecting) { + this._reset(); + this.emit("close"); + } else { + this._state = "connecting"; + this.emit("invalidate"); + } } logger.verbose( `Connector | waiting ${(Math.round(delay / 10) / 100)} seconds for reconnect` @@ -416,21 +558,15 @@ class WebSocketClientConnector extends WebSocketBaseConnector { // Handle messages this._socket.onmessage = event => { - let message = JSON.parse(event.data); + let message = this._parseMessage(event.data); + if (!message) { + return; + } if (["connecting", "resuming"].includes(this._state)) { this._processHandshake(message); } else if (this._state === "connected") { - if (message.seq !== null) { - this._lastReceivedSeq = message.seq; - } - - if (message.type === "heartbeat") { - this._processHeartbeat(message); - - } else { - this.emit("message", message); - } + this._processMessage(message); } else { throw new Error(`Received message in unexpected state ${this._state}`); @@ -439,11 +575,6 @@ class WebSocketClientConnector extends WebSocketBaseConnector { } _processHandshake(message) { - if (!libSchema.serverHandshake(message)) { - logger.warn("Connector | closing after received invalid handshake"); - this._socket.close(1002, "Invalid handshake"); - return; - } let { type, data } = message; if (type === "hello") { @@ -451,10 +582,9 @@ class WebSocketClientConnector extends WebSocketBaseConnector { this.emit("hello", data); if (this._sessionToken) { logger.verbose("Connector | Attempting resume"); - this.sendHandshake("resume", { - session_token: this._sessionToken, - last_seq: this._lastReceivedSeq, - }); + this.sendHandshake(new libData.MessageResume( + new libData.ResumeData(this._sessionToken, this._lastReceivedSeq) + )); } else { this.register(); } @@ -462,39 +592,39 @@ class WebSocketClientConnector extends WebSocketBaseConnector { } else if (type === "ready") { logger.verbose("Connector | received ready from controller"); this._state = "connected"; - this._sessionToken = data.session_token; - this._sessionTimeout = data.session_timeout; - this._heartbeatInterval = data.heartbeat_interval; + this.src = data.src; + this._sessionToken = data.sessionToken; + this._sessionTimeout = data.sessionTimeout; + this._heartbeatInterval = data.heartbeatInterval; this.startHeartbeat(); for (let bufferedMessage of this._sendBuffer) { - this._socket.send(JSON.stringify(bufferedMessage)); + this._sendInternal(bufferedMessage); } this.emit("connect", data); } else if (type === "continue") { logger.info("Connector | resuming existing session"); this._state = "connected"; - this._heartbeatInterval = data.heartbeat_interval; - this._sessionTimeout = data.session_timeout; + this._heartbeatInterval = data.heartbeatInterval; + this._sessionTimeout = data.sessionTimeout; this.startHeartbeat(); - this._dropSendBufferSeq(data.last_seq); + this._dropSendBufferSeq(data.lastSeq); for (let bufferedMessage of this._sendBuffer) { - this._socket.send(JSON.stringify(bufferedMessage)); + this._sendInternal(bufferedMessage); } this._startedResuming = null; this.emit("resume"); } else if (type === "invalidate") { logger.warn("Connector | session invalidated by controller"); - this._state = "connecting"; - this._seq = 1; - this._lastReceivedSeq = null; - this._sessionToken = null; - this._sessionTimeout = null; - this._sendBuffer.length = 0; - this._startedResuming = null; - this.emit("invalidate"); - this.register(); + if (this._disconnecting) { + this._reset(); + this.emit("close"); + } else { + this._invalidate(); + this.emit("invalidate"); + this.register(); + } } } @@ -515,12 +645,11 @@ class WebSocketClientConnector extends WebSocketBaseConnector { * @extends events.EventEmitter * @memberof module:lib/link */ -class VirtualConnector extends events.EventEmitter { - constructor() { - super(); +class VirtualConnector extends BaseConnector { + constructor(src, dst) { + super(src, dst); this.other = this; - this._seq = 1; } /** @@ -529,32 +658,30 @@ class VirtualConnector extends events.EventEmitter { * Creates two virtual connectors that are liked to each othes such that * a message sent on one is received by the other. * + * @param {module:lib/data.Address} src - Source for obverse connector + * @param {module:lib/data.Address} dst - destination for obverse connector * @returns {Array} two virtuarl connectors. */ - static makePair() { - let first = new this(); - let second = new this(); - first.other = second; - second.other = first; - return [first, second]; + static makePair(src, dst) { + let obverse = new this(src, dst); + let reverse = new this(dst, src); + obverse.other = reverse; + reverse.other = obverse; + return [obverse, reverse]; } /** * Send a message to the other end of the connector * - * @param {string} type - Message type to send. - * @param {Object} data - Data to send with message. - * @returns {number} the sequence number of the message sent + * @param {module:lib/data.Message} message - Message type to send. */ - send(type, data) { - let seq = this._seq; - this._seq += 1; - this.other.emit("message", { seq, type, data }); - return seq; + send(message) { + this.other.emit("message", message); } } module.exports = { + BaseConnector, WebSocketBaseConnector, WebSocketClientConnector, VirtualConnector, diff --git a/packages/lib/link/index.js b/packages/lib/link/index.js index a156979c..dcc14b7f 100644 --- a/packages/lib/link/index.js +++ b/packages/lib/link/index.js @@ -5,11 +5,11 @@ */ "use strict"; const link = require("./link"); -const messages = require("./messages"); const connectors = require("./connectors"); module.exports = { ...link, - ...messages, ...connectors, + Event: class Event {}, + Request: class Request {}, }; diff --git a/packages/lib/link/link.js b/packages/lib/link/link.js index cb292889..f49cf253 100644 --- a/packages/lib/link/link.js +++ b/packages/lib/link/link.js @@ -1,9 +1,10 @@ // Implementation of Link class "use strict"; -const libSchema = require("../schema"); +const libData = require("../data"); const libErrors = require("../errors"); const { logger } = require("../logging"); +const libSchema = require("../schema"); // Some definitions for the terminology used here: // link: Either side of a controller - client connection @@ -20,61 +21,65 @@ const { logger } = require("../logging"); * @memberof module:lib/link */ class Link { - constructor(source, target, connector) { - this.source = source; - this.target = target; + constructor(connector) { this.connector = connector; + this.router = undefined; + this.validateSent = true; - this._waiters = new Map(); - this._handlers = new Map(); - this._validators = new Map(); + this._registeredRequests = new Map(); + this._fallbackedRequests = new Map(); + this._registeredEvents = new Map(); + this._snoopedEvents = new Map(); + this._eventHandlers = new Map(); + this._pendingRequests = new Map(); + this._nextRequestId = 1; + + this.register(libData.PingRequest, () => {}); // Process messages received by the connector connector.on("message", payload => { try { - this.processMessage(payload); + this._processMessage(payload); } catch (err) { if (err instanceof libErrors.InvalidMessage) { - logger.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`); + logger.error(err.message); if (err.errors) { logger.error(JSON.stringify(err.errors, null, 4)); } - // Send back an error response if this was a request. - if ( - typeof payload.type === "string" - && payload.type.endsWith("_request") - && Number.isInteger(payload.seq) - ) { - this.connector.send( - `${payload.type.slice(0, -8)}_response`, - { seq: payload.seq, error: err.message } - ); - } } else { + // Unexpected error, bubble back to connector this.connector.emit("error", err); } } }); - connector.on("invalidate", () => { - for (let waiterType of this._waiters.values()) { - for (let waiter of waiterType) { - waiter.reject(new libErrors.SessionLost("Session Lost")); + connector.on("disconnectPrepare", () => { + this.prepareDisconnect().catch( + err => { + logger.error(`Unexpected error preparing disconnect:\n${err.stack}`); } + ).finally( + () => { + this.connector.send(new libData.MessageDisconnect("ready")); + }, + ); + }); + + connector.on("invalidate", () => { + for (let pending of this._pendingRequests.values()) { + pending.reject(new libErrors.SessionLost("Session Lost")); } - this._waiters.clear(); + this._pendingRequests.clear(); }); connector.on("close", () => { - for (let waiterType of this._waiters.values()) { - for (let waiter of waiterType) { - waiter.reject(new libErrors.SessionLost("Session Closed")); - } + for (let pending of this._pendingRequests.values()) { + pending.reject(new libErrors.SessionLost("Session Closed")); } - this._waiters.clear(); + this._pendingRequests.clear(); }); } @@ -84,159 +89,163 @@ class Link { * Validates and invokes the handler and/or waiters for a message that has * been received. An unhandled message is considered to be an error. * - * @param {Object} message - Message to process. - * @throws {module:lib/errors.InvalidMessage} - * if validation failed, is missing, the message is invalid, or no - * handlers/waiters were invoked to handle the message. + * @param {module:lib/data.Message} message - Message to process. + * @throws {module:lib/errors.InvalidMessage} if the message is invalid or not handled. */ - processMessage(message) { - if (!libSchema.message(message)) { - throw new libErrors.InvalidMessage("Malformed", libSchema.message.errors); + _processMessage(message) { + if (!["request", "response", "responseError", "event"].includes(message.type)) { + throw new libErrors.InvalidMessage(`Unhandled message type ${message.type}`); } - let validator = this._validators.get(message.type); - if (!validator) { - throw new libErrors.InvalidMessage(`No validator for ${message.type} on ${this.source}-${this.target}`); + if (message.type === "event" && this._snoopedEvents.has(message.name)) { + let event = this._snoopedEvents.get(message.name); + event.handler(event.eventFromJSON(message.data), message.src, message.dst).catch(err => { + logger.error(`Unexpected error snooping ${message.name}:\n${err.stack}`); + }); } - if (!validator(message)) { - throw new libErrors.InvalidMessage(`Validation failed for ${message.type}`, validator.errors); + if (!message.dst.addressedTo(this.connector.src)) { + this._routeMessage(message); + return; } - let hadHandlers = this._processHandler(message); - let hadWaiters = this._processWaiters(message); + if (message.type === "request") { + let request = this._registeredRequests.get(message.name); + if (!request) { + this.connector.sendResponseError( + new libData.ResponseError(`Unrecognized request ${message.name}`), + message.src, + ); + return; + } + this._processRequest(message, request); - if (!hadWaiters && !hadHandlers) { - // XXX console.error(`Unhandled message: ${JSON.stringify(message, null, 4)}`); - throw new libErrors.InvalidMessage(`Unhandled message ${message.type}`); + } else if (message.type === "response") { + this._processResponse(message); + + } else if (message.type === "responseError") { + this._processResponseError(message); + + } else if (message.type === "event") { + this._processEvent(message); } } - /** - * Invoke the handler for this message if it exists - * - * @param {Object} message - Message to invoke handlers on. - * @returns {Boolean} true if a handler was invoked. - */ - _processHandler(message) { - let handler = this._handlers.get(message.type); - if (handler) { - handler(message); - return true; + _routeMessage(message) { + if (!this.router) { + let err = new libErrors.InvalidMessage( + `Received message addressed to ${message.dst} but this link does not route messages` + ); + if (message.type === "request") { + this.connector.sendResponseError( + new libData.ResponseError(err.message, err.code), message.src + ); + } + throw err; } - return false; + + let fallback = this._fallbackedRequests.get(message.name); + if (this.router.forwardMessage(this, message, Boolean(fallback))) { + return; + } + if (!fallback) { + throw new Error("Router requested fallback handling when fallback is unavailable"); + } + if (message.type !== "request") { + throw new Error(`Router requested fallback handling of unsupported message type ${message.type}`); + } + this._processRequest(message, fallback); } - /** - * Invokes the waiters for this message if any - * - * @param {Object} message - Message to invoke waiters on. - * @returns {Boolean} true if a waiter was triggered. - */ - _processWaiters(message) { - let { type, data } = message; - let waiters = this._waiters.get(type); - if (!waiters || !waiters.length) { - return false; + _processRequest(message, request) { + if (!request.handler) { + this.connector.sendResponseError( + new libData.ResponseError(`No handler registered for ${request.Request.name}`), message.src + ); + return; } - let matched = []; - waitersLoop: for (let index = 0; index < waiters.length; index++) { - let waiter = waiters[index]; - for (let [key, value] of Object.entries(waiter.data)) { - if (data[key] !== value) { - continue waitersLoop; + let response; + try { + response = request.handler(request.requestFromJSON(message.data), message.src, message.dst); + } catch (err) { + if (err.errors) { + logger.error(JSON.stringify(err.errors, null, 4)); + } + this.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); + return; + } + + if (!(response instanceof Promise)) { + response = Promise.resolve(response); + } + + response.then( + result => { + if (this.validateSent) { + if (request.Response) { + request.responseFromJSON(JSON.parse(JSON.stringify(result))); + } else if (result !== undefined) { + throw new Error(`Expected empty response from ${request.Request.name} handler`); + } } + this.connector.sendResponse(result, message.src); } - - waiter.resolve(message); - matched.push(index); - } - - matched.reverse(); - for (let index of matched) { - waiters.splice(index, 1); - } - - return matched.length > 0; - } - - /** - * Set handler for a message type - * - * Set function called upon receiving a message of the given type. - * - * @param {string} type - Type of message to listen to - * @param {function} handler - - * Callack to invoke on messages of the given type. - * @param {function} validator - - * Function validating the message have the correct format. - */ - setHandler(type, handler, validator) { - if (this._handlers.has(type)) { - throw new Error(`${type} already has a handler`); - } - - if (!validator) { - throw new Error("validator is required"); - } - - this.setValidator(type, validator); - this._handlers.set(type, handler); - } - - /** - * Set validator for a message type - * - * Set function called upon receiving a message of the given type in - * order to validate the message. The function should return true if - * the message is valid and false otherwise. - * - * @param {string} type - Type of message to validate - * @param {function} validator - Validation function. - */ - setValidator(type, validator) { - if (this._validators.has(type)) { - throw new Error(`${type} already has a validator`); - } - - this._validators.set(type, validator); - } - - /** - * Wait for a message matching given type and data - * - * Waits for a message over the link that matches the type given as well - * as any properties specified in data. For example specifying { foo: - * 21 } as the data will cause it to wait until a message with the right - * type and a foo property value of 21 in the data payload. - * - * @param {string} type - Message type to wait for. - * @param {Object} data - Properties to match in the message data. - */ - async waitFor(type, data) { - if (!this._validators.has(type)) { - throw new Error(`No validator for ${type} on ${this.source}-${this.target}`); - } - - let waiter = { data }; - waiter.promise = new Promise((resolve, reject) => { - waiter.resolve = resolve; - waiter.reject = reject; - if (!this._waiters.has(type)) { - this._waiters.set(type, [waiter]); - } else { - this._waiters.get(type).push(waiter); + ).catch( + err => { + if (!(err instanceof libErrors.RequestError)) { + logger.error(`Unexpected error responding to ${message.name}:\n${err.stack}`); + } + this.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); } + ); + } + + _processResponse(message) { + let pending = this._pendingRequests.get(message.dst.requestId); + if (!pending) { + throw new libErrors.InvalidMessage( + `Received response ${message.dst.requestId} without a pending request` + ); + } + + try { + pending.resolve(pending.request.responseFromJSON(message.data)); + } catch (err) { + // An invalid response object was likely received + pending.reject(err); + } + } + + _processResponseError(message) { + let pending = this._pendingRequests.get(message.dst.requestId); + if (!pending) { + throw new libErrors.InvalidMessage( + `Received error response ${message.dst.requestId} without a pending request` + ); + } + + pending.reject(new libErrors.RequestError(message.data.message, message.data.code, message.data.stack)); + } + + _processEvent(message) { + let event = this._registeredEvents.get(message.name); + if (!event) { + throw new libErrors.InvalidMessage(`Unrecognized event ${message.name}`); + } + + if (!event.handler) { + return; + } + event.handler(event.eventFromJSON(message.data), message.src, message.dst).catch(err => { + logger.error(`Unexpected error handling ${message.name}:\n${err.stack}`); }); - return waiter.promise; } - /** - * Handle ping requests - */ - async pingRequestHandler() { } - /** * Prepare connection for disconnect * @@ -245,16 +254,205 @@ class Link { * additional requests to be sent out before calling the super class * method. */ - async prepareDisconnectRequestHandler() { + async prepareDisconnect() { let promises = []; - for (let waiterType of this._waiters.values()) { - for (let waiter of waiterType) { - promises.push(waiter.promise.then(() => {}, () => {})); - } + for (let pending of this._pendingRequests.values()) { + promises.push(pending.promise.then(() => {}, () => {})); } await Promise.all(promises); } + + /** + * Send a request or event to the other side of this link + * @param {*} requestOrEvent - Request or event to send + * @returns {Promise<*>|undefined} + * Promise that resolves to the response if a request was sent or + * undefined if it was an event. + */ + send(requestOrEvent) { + return this.sendTo(requestOrEvent, this.connector.dst); + } + + /** + * Send a request or event to the given address + * + * @param {*} requestOrEvent - Request or event to send + * @param {*|module:lib/data.Address} destination - Where to send it + * @returns {Promise<*>|undefined} + * Promise that resolves to the response if a request was sent or + * undefined if it was an event. + */ + sendTo(requestOrEvent, destination) { + let dst = libData.Address.fromShorthand(destination); + + if (requestOrEvent.constructor.type === "request") { + return this.sendRequest(requestOrEvent, dst); + } + if (requestOrEvent.constructor.type === "event") { + return this.sendEvent(requestOrEvent, dst); + } + throw Error(`Expected request or event but got type ${requestOrEvent.constructor.type}`); + } + + sendRequest(request, dst) { + let name = request.constructor.name; + if (!this._registeredRequests.has(name)) { + // XXX should this be allowed? + this.registerRequest(request.constructor); + } + let registeredRequest = this._registeredRequests.get(name); + if (this.validateSent) { + registeredRequest.requestFromJSON(JSON.parse(JSON.stringify(request))); + } + + let pending = { + request: registeredRequest, + }; + pending.promise = new Promise((resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }); + let requestId = this._nextRequestId; + this._nextRequestId += 1; + this._pendingRequests.set(requestId, pending); + this.connector.sendRequest(request, requestId, dst); + return pending.promise; + } + + sendEvent(event, dst) { + if (this.validateSent) { + let name = event.constructor.name; + if (!this._registeredEvents.has(name)) { + // XXX should this be allowed? + this.registerEvent(event.constructor); + } + this._registeredEvents.get(name).eventFromJSON(JSON.parse(JSON.stringify(event))); + } + + this.connector.sendEvent(event, dst); + } + + register(Class, handler) { + if (Class.type === "request") { + this.registerRequest(Class, handler); + } else if (Class.type === "event") { + this.registerEvent(Class, handler); + } else { + throw new Error(`Class ${Class.name} has unrecognized type ${Class.type}`); + } + } + + _requestEntry(Request, handler) { + let name = Request.name; + let entry = { + Request, + handler, + }; + if (Request.fromJSON) { + if (!Request.jsonSchema) { + throw new Error(`Request ${name} has static fromJSON but is missing static jsonSchema`); + } + let validate = libSchema.compile(Request.jsonSchema); + entry.requestFromJSON = (json) => { + if (!validate(json)) { + throw new libErrors.InvalidMessage(`Request ${name} failed validation`, validate.errors); + } + return Request.fromJSON(json); + }; + } else if (Request.jsonSchema) { + throw new Error(`Request ${name} has static jsonSchema but is missing static fromJSON`); + } else { + entry.requestFromJSON = () => new Request(); + } + + let Response = Request.Response; + if (Response) { + if (!Response.jsonSchema) { + throw new Error(`Response for Request ${name} is missing static jsonSchema`); + } + if (!Response.fromJSON) { + throw new Error(`Response for Request ${name} is missing static fromJSON`); + } + + let validate = libSchema.compile(Response.jsonSchema); + entry.responseFromJSON = (json) => { + if (!validate(json)) { + throw new libErrors.InvalidMessage( + `Response for request ${name} failed validation`, validate.errors + ); + } + return Response.fromJSON(json); + }; + + entry.Response = Response; + } else { + entry.responseFromJSON = () => undefined; + } + return entry; + } + + registerRequest(Request, handler) { + let name = Request.name; + if (this._registeredRequests.has(name)) { + throw new Error(`Request ${name} is already registered`); + } + let request = this._requestEntry(Request, handler); + this._registeredRequests.set(name, request); + } + + fallbackRequest(Request, handler) { + let name = Request.name; + if (this._fallbackedRequests.has(name)) { + throw new Error(`Request ${name} is already fallbacked`); + } + let request = this._requestEntry(Request, handler); + this._fallbackedRequests.set(name, request); + } + + _createEventFromJSON(Event) { + if (Event.fromJSON) { + if (!Event.jsonSchema) { + throw new Error(`Event ${Event.name} has static fromJSON but is missing static jsonSchema`); + } + let validate = libSchema.compile(Event.jsonSchema); + return (json) => { + if (!validate(json)) { + throw new libErrors.InvalidMessage(`Event ${Event.name} failed validation`, validate.errors); + } + return Event.fromJSON(json); + }; + } + if (Event.jsonSchema) { + throw new Error(`Event ${Event.name} has static jsonSchema but is missing static fromJSON`); + } + return () => new Event(); + } + + registerEvent(Event, handler) { + if (this._registeredEvents.has(Event.name)) { + throw new Error(`Event ${Event.name} is already registered`); + } + let event = { + Event, + handler, + eventFromJSON: this._createEventFromJSON(Event), + }; + this._registeredEvents.set(Event.name, event); + } + + snoopEvent(Event, handler) { + let name = Event.name; + if (this._snoopedEvents.has(name)) { + throw new Error(`Event ${name} is already snooped`); + } + let event = { + Event, + handler, + eventFromJSON: this._createEventFromJSON(Event), + }; + this._snoopedEvents.set(Event.name, event); + } } diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js deleted file mode 100644 index d4fbc7c5..00000000 --- a/packages/lib/link/messages.js +++ /dev/null @@ -1,1583 +0,0 @@ -// Message definitions for links -"use strict"; -const libData = require("../data"); -const libErrors = require("../errors"); -const { logger } = require("../logging"); -const libSchema = require("../schema"); -const PlayerStats = require("../PlayerStats"); - -class MissingLinkHandlerError extends Error { - constructor(type, source, target) { - super(); - this.code = "MISSING_LINK_HANDLER"; - this.type = type; - this.source = source; - this.target = target; - this.handler = null; - this.plugin = null; - } - - get message() { - let handler = this.handler ? `${this.handler}` : "handler"; - let plugin = this.plugin ? ` on plugin ${this.plugin}` : ""; - - return `Missing ${handler}${plugin} for ${this.type} on ${this.source}-${this.target} link`; - } -} - -/** - * Represents a message that can be sent over the link - * - * @memberof module:lib/link - */ -class Message { - /** - * Name of the plugin this message belongs to, or null. - * @returns {?string} - plugin name or null if not from a plugin. - */ - get plugin() { - if (this._plugin === undefined) { - let index = this.type.indexOf(":"); - this._plugin = index === -1 ? null : this.type.slice(0, index); - } - - return this._plugin; - } -} - -/** - * Represents a request sent over the link - * - * A request-response message that can be innitated by either party of the - * link. - * - * @extends module:lib/link.Message - * @memberof module:lib/link - */ -class Request extends Message { - - /** - * Creates a request - * - * @param {string} type - - * message type used. Will have suffix '_request' for the request - * message and suffix '_response' for the response message. - * @param {Array} links - - * Links this request is valid on. Takes an array of strings - * containing "-" specifications. - * @param {?string} permission - - * Permission required to send this request. Only applies to - * requests sent from control to controller. - * @param {?string} forwardTo - - * Optional target to forward this request to. 'instance' add - * instance_id into the requestProperties and forward to the given - * instance. 'controller' forwards it to the controller. - * @param {?Array} requestRequired - - * List of properties that are required to be present in the data - * payload of this request. Defaults to all. - * @param {Object} requestProperties - - * Mapping of property values to JSON schema specifications for the - * properties that are valid in the data payload of this requst. - * @param {?Array} responseRequired - - * List of properties that are required to be present in the data - * payload of the repsonse to this request. Defaults to all. - * @param {Object} responseProperties - - * Mapping of property values to JSON schema specifications for the - * properties that are valid in the data payload of the response to - * this requst. - */ - constructor({ - type, permission, links, forwardTo = null, - requestRequired = null, requestProperties = {}, - responseRequired = null, responseProperties = {}, - }) { - super(); - this.type = type; - this.links = links; - this.permission = permission || null; - this.forwardTo = forwardTo; - this.handlerSuffix = "RequestHandler"; - - this.requestType = `${type}_request`; - this.responseType = `${type}_response`; - - if (permission === undefined && links.includes("control-controller")) { - throw new Error(`permission is required for ${this.type} request over control-controller links`); - } - if (permission && !links.includes("control-controller")) { - throw new Error( - `permission is not allowed on ${this.type} request as it is not over control-controller link` - ); - } - - if (!requestRequired) { - requestRequired = Object.keys(requestProperties); - } - - if (forwardTo === "instance") { - requestRequired = ["instance_id", ...requestRequired]; - requestProperties = { - "instance_id": { type: "integer" }, - ...requestProperties, - }; - - } else if (forwardTo !== "controller" && forwardTo !== null) { - throw new Error(`Invalid forwardTo value ${forwardTo}`); - } - - this._requestValidator = libSchema.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - properties: { - "type": { const: this.requestType }, - "data": { - additionalProperties: false, - required: requestRequired, - properties: requestProperties, - }, - }, - }); - - if (!responseRequired) { - responseRequired = Object.keys(responseProperties); - } - - this._responseValidator = libSchema.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - properties: { - "type": { const: this.responseType }, - "data": { - anyOf: [ - { - additionalProperties: false, - required: ["seq", ...responseRequired], - properties: { - "seq": { type: "integer" }, - ...responseProperties, - }, - }, - { - additionalProperties: false, - required: ["seq", "error"], - properties: { - "seq": { type: "integer" }, - "error": { type: "string" }, - }, - }, - ], - }, - }, - }); - } - - /** - * Attach to a link - * - * Set a the validator for the response on the given link if it's a - * source and sets the handler for the request if it is a target. - * - * If forwardTo was set to 'instance' the `forwardRequestToInstance` - * method on the link is used as the default handler. - * - * If forwardTo was set to 'controller' the `forwardRequesToController` method - * on the link is used as the default handler. - * - * Does nothing if the link is neither a source nor a target for this - * request. - * - * @param {Link} link - The link to attach to. - * @param {Function} handler - - * Async function to invoke with link set as this to handle the - * request request. Only used on the target side. - * @throws {Error} if the handler is needed and not defined. - */ - attach(link, handler) { - // Source side. - if (this.links.includes(`${link.source}-${link.target}`)) { - link.setValidator(this.responseType, this._responseValidator); - } - - // Target side. Note: Source and target is reversed here. - if (this.links.includes(`${link.target}-${link.source}`)) { - - // Use forwarder if handler is not present. - if (!handler) { - if (this.forwardTo === "instance") { - handler = link.forwardRequestToInstance; - } else if (this.forwardTo === "controller") { - handler = link.forwardRequestToController; - } - } - - if (!handler) { - throw new MissingLinkHandlerError(this.requestType, link.source, link.target); - } - - // Check permission if this is a handler for a control connection on the controller - if (this.permission !== null && `${link.source}-${link.target}` === "controller-control") { - let origHandler = handler; - handler = async function(message, request) { - // Abuse this binding to get a hold of the ControlConnection instance - // eslint-disable-next-line no-invalid-this, consistent-this - let controlConnection = this; - controlConnection.user.checkPermission(request.permission); - return await origHandler.call(controlConnection, message, request); - }; - } - - link.setHandler(this.requestType, message => { - handler.call(link, message, this).then(response => { - if (response === undefined) { - // XXX Should we allow implicit responses like this? - response = {}; - } - - let data = { ...response, seq: message.seq }; - if (!this._responseValidator({ seq: 0, type: this.responseType, data })) { - logger.error(JSON.stringify(this._responseValidator.errors, null, 4)); - throw new Error(`Validation failed responding to ${this.requestType}`); - } - - link.connector.send(this.responseType, data); - - }).catch(err => { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Unexpected error while responding to ${this.requestType}:\n${err.stack}`); - } - link.connector.send(this.responseType, { seq: message.seq, error: err.message }); - }); - }, this._requestValidator); - } - } - - /** - * Send request over the given link - * - * Sends the given data over the link as the request payload and waits - * for the response. - * - * @param {module:lib/link.Link} link - Link to send request over. - * @param {Object} data - Data to send with the request. - * @returns {Promise} response data - */ - async send(link, data = {}) { - // XXX validate link target/source? - if (!this._requestValidator({ seq: 0, type: this.requestType, data })) { - logger.error(JSON.stringify(this._requestValidator.errors, null, 4)); - throw new Error(`Validation failed sending ${this.requestType}`); - } - let seq = link.connector.send(this.requestType, data); - let responseMessage = await link.waitFor(this.responseType, { seq }); - if (responseMessage.data.error) { - throw new libErrors.RequestError(responseMessage.data.error); - } - return responseMessage.data; - } -} - - -let messages = {}; - -// Connection requests -let wsLinks = ["controller-control", "control-controller", "controller-host", "host-controller"]; -messages.prepareDisconnect = new Request({ - type: "prepare_disconnect", - links: wsLinks, - permission: null, -}); - -messages.prepareControllerDisconnect = new Request({ - type: "prepare_controller_disconnect", - links: ["host-instance"], -}); - -messages.ping = new Request({ - type: "ping", - links: wsLinks, - permission: null, -}); - -messages.debugDumpWs = new Request({ - type: "debug_dump_ws", - links: ["control-controller"], - permission: "core.debug.dump_ws", -}); - - -// Management requests -messages.getControllerConfig = new Request({ - type: "get_controller_config", - links: ["control-controller"], - permission: "core.controller.get_config", - responseProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.setControllerConfigField = new Request({ - type: "set_controller_config_field", - links: ["control-controller"], - permission: "core.controller.update_config", - requestProperties: { - "field": { type: "string" }, - "value": { type: "string" }, - }, -}); - -messages.setControllerConfigProp = new Request({ - type: "set_controller_config_prop", - links: ["control-controller"], - permission: "core.controller.update_config", - requestRequired: ["field", "prop"], - requestProperties: { - "field": { type: "string" }, - "prop": { type: "string" }, - "value": {}, - }, -}); - -let hostProperties = { - "agent": { type: "string" }, - "version": { type: "string" }, - "name": { type: "string" }, - "id": { type: "integer" }, - "connected": { type: "boolean" }, - "public_address": { type: ["null", "string"] }, -}; - -messages.listHosts = new Request({ - type: "list_hosts", - links: ["control-controller"], - permission: "core.host.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["agent", "version", "name", "id", "connected"], - properties: hostProperties, - }, - }, - }, -}); - -messages.setHostSubscriptions = new Request({ - type: "set_host_subscriptions", - links: ["control-controller"], - permission: "core.host.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "host_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -let instanceProperties = { - "name": { type: "string" }, - "id": { type: "integer" }, - "assigned_host": { type: ["null", "integer"] }, - "game_port": { type: ["null", "integer"] }, - "status": { enum: [ - "unknown", "unassigned", "stopped", "starting", "running", "stopping", - "creating_save", "exporting_data", "deleted", - ]}, -}; - -messages.getInstance = new Request({ - type: "get_instance", - links: ["control-controller"], - permission: "core.instance.get", - requestProperties: { - "id": { type: "integer" }, - }, - responseProperties: instanceProperties, -}); - -messages.listInstances = new Request({ - type: "list_instances", - links: ["control-controller"], - permission: "core.instance.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["name", "id", "assigned_host", "status"], - properties: instanceProperties, - }, - }, - }, -}); - -messages.setInstanceSubscriptions = new Request({ - type: "set_instance_subscriptions", - links: ["control-controller"], - permission: "core.instance.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -messages.generateHostToken = new Request({ - type: "generate_host_token", - links: ["control-controller"], - permission: "core.host.generate_token", - requestProperties: { - "host_id": { type: ["integer", "null"] }, - }, - responseProperties: { - "token": { type: "string" }, - }, -}); - -messages.createHostConfig = new Request({ - type: "create_host_config", - links: ["control-controller"], - permission: "core.host.create_config", - requestProperties: { - "id": { type: ["integer", "null"] }, - "name": { type: ["string", "null"] }, - "generate_token": { type: "boolean" }, - }, - responseProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.createInstance = new Request({ - type: "create_instance", - links: ["control-controller"], - permission: "core.instance.create", - requestProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.getInstanceConfig = new Request({ - type: "get_instance_config", - links: ["control-controller"], - permission: "core.instance.get_config", - requestProperties: { - "instance_id": { type: "integer" }, - }, - responseProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.setInstanceConfigField = new Request({ - type: "set_instance_config_field", - links: ["control-controller"], - permission: "core.instance.update_config", - requestProperties: { - "instance_id": { type: "integer" }, - "field": { type: "string" }, - "value": { type: "string" }, - }, -}); - -messages.setInstanceConfigProp = new Request({ - type: "set_instance_config_prop", - links: ["control-controller"], - permission: "core.instance.update_config", - requestRequired: ["instance_id", "field", "prop"], - requestProperties: { - "instance_id": { type: "integer" }, - "field": { type: "string" }, - "prop": { type: "string" }, - "value": {}, - }, -}); - -messages.assignInstanceCommand = new Request({ - type: "assign_instance_command", - links: ["control-controller"], - permission: "core.instance.assign", - requestProperties: { - "instance_id": { type: "integer" }, - "host_id": { type: ["integer", "null"] }, - }, -}); - -messages.startInstance = new Request({ - type: "start_instance", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.start", - requestProperties: { - "save": { type: ["string", "null"] }, - }, - forwardTo: "instance", -}); - -let saveList = { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["type", "name", "size", "mtime_ms", "loaded"], - properties: { - "type": { enum: ["file", "directory", "special"] }, - "name": { type: "string" }, - "size": { type: "integer" }, - "mtime_ms": { type: "number" }, - "loaded": { type: "boolean" }, - "default": { type: "boolean" }, - }, - }, -}; - -messages.listSaves = new Request({ - type: "list_saves", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.save.list", - forwardTo: "instance", - responseProperties: { - "list": saveList, - }, -}); - -messages.createSave = new Request({ - type: "create_save", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.save.create", - forwardTo: "instance", - requestProperties: { - "name": { type: "string" }, - "seed": { type: ["integer", "null"] }, - "map_gen_settings": { type: ["object", "null"] }, - "map_settings": { type: ["object", "null"] }, - }, -}); - -messages.renameSave = new Request({ - type: "rename_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.rename", - forwardTo: "instance", - requestProperties: { - "old_name": { type: "string" }, - "new_name": { type: "string" }, - }, -}); - -messages.copySave = new Request({ - type: "copy_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.copy", - forwardTo: "instance", - requestProperties: { - "source": { type: "string" }, - "destination": { type: "string" }, - }, -}); - -messages.deleteSave = new Request({ - type: "delete_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.delete", - forwardTo: "instance", - requestProperties: { - "save": { type: "string" }, - }, -}); - -messages.downloadSave = new Request({ - type: "download_save", - links: ["control-controller"], - permission: "core.instance.save.download", - requestProperties: { - "instance_id": { type: "integer" }, - "save": { type: "string" }, - }, - responseProperties: { - "stream_id": { type: "string" }, - }, -}); - -messages.transferSave = new Request({ - type: "transfer_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.transfer", - requestProperties: { - "instance_id": { type: "integer" }, - "source_save": { type: "string" }, - "target_instance_id": { type: "integer" }, - "target_save": { type: "string" }, - "copy": { type: "boolean" }, - }, - responseProperties: { - "save": { type: "string" }, - }, -}); - -messages.pullSave = new Request({ - type: "pull_save", - links: ["controller-host"], - requestProperties: { - "instance_id": { type: "integer" }, - "stream_id": { type: "string" }, - "filename": { type: "string" }, - }, - responseProperties: { - "save": { type: "string" }, - }, -}); - -messages.pushSave = new Request({ - type: "push_save", - links: ["controller-host"], - requestProperties: { - "instance_id": { type: "integer" }, - "stream_id": { type: "string" }, - "save": { type: "string" }, - }, -}); - -messages.setSaveListSubscriptions = new Request({ - type: "set_save_list_subscriptions", - links: ["control-controller"], - permission: "core.instance.save.list_subscribe", - requestProperties: { - "all": { type: "boolean" }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -messages.loadScenario = new Request({ - type: "load_scenario", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.load_scenario", - forwardTo: "instance", - requestProperties: { - "scenario": { type: "string" }, - "seed": { type: ["integer", "null"] }, - "map_gen_settings": { type: ["object", "null"] }, - "map_settings": { type: ["object", "null"] }, - }, -}); - -messages.exportData = new Request({ - type: "export_data", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.export_data", - forwardTo: "instance", -}); - -messages.extractPlayers = new Request({ - type: "extract_players", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.extract_players", - forwardTo: "instance", -}); - -messages.stopInstance = new Request({ - type: "stop_instance", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.stop", - forwardTo: "instance", -}); - -messages.killInstance = new Request({ - type: "kill_instance", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.kill", - forwardTo: "instance", -}); - -messages.deleteInstance = new Request({ - type: "delete_instance", - links: ["control-controller", "controller-host"], - permission: "core.instance.delete", - forwardTo: "instance", -}); - -messages.sendRcon = new Request({ - type: "send_rcon", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.send_rcon", - forwardTo: "instance", - requestProperties: { - "command": { type: "string" }, - }, - responseProperties: { - "result": { type: "string" }, - }, -}); - -messages.getModPack = new Request({ - type: "get_mod_pack", - links: ["control-controller", "instance-host", "host-controller"], - permission: "core.mod_pack.get", - forwardTo: "controller", - requestProperties: { - "id": { type: "integer" }, - }, - responseProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.getDefaultModPack = new Request({ - type: "get_default_mod_pack", - links: ["control-controller", "instance-host", "host-controller"], - permission: "core.mod_pack.get", - forwardTo: "controller", - responseProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.listModPacks = new Request({ - type: "list_mod_packs", - links: ["control-controller"], - permission: "core.mod_pack.list", - responseProperties: { - "list": { - type: "array", - items: libData.ModPack.jsonSchema, - }, - }, -}); - -messages.createModPack = new Request({ - type: "create_mod_pack", - links: ["control-controller"], - permission: "core.mod_pack.create", - requestProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.updateModPack = new Request({ - type: "update_mod_pack", - links: ["control-controller"], - permission: "core.mod_pack.update", - requestProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.deleteModPack = new Request({ - type: "delete_mod_pack", - links: ["control-controller"], - permission: "core.mod_pack.delete", - requestProperties: { - "id": { type: "integer" }, - }, -}); - -messages.setModPackSubscriptions = new Request({ - type: "set_mod_pack_subscriptions", - links: ["control-controller"], - permission: "core.mod_pack.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "mod_pack_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -messages.getMod = new Request({ - type: "get_mod", - links: ["control-controller"], - permission: "core.mod.get", - requestProperties: { - "name": { type: "string" }, - "version": { type: "string" }, - }, - responseProperties: { - "mod": libData.ModInfo.jsonSchema, - }, -}); - -messages.listMods = new Request({ - type: "list_mods", - links: ["control-controller"], - permission: "core.mod.list", - responseProperties: { - "list": { - type: "array", - items: libData.ModInfo.jsonSchema, - }, - }, -}); - -messages.searchMods = new Request({ - type: "search_mods", - links: ["control-controller"], - permission: "core.mod.search", - requestRequired: ["query", "factorio_version", "page"], - requestProperties: { - "query": { type: "string" }, - "factorio_version": { type: "string" }, - "page_size": { type: "integer" }, - "page": { type: "integer" }, - "sort": { type: "string" }, - "sort_order": { type: "string" }, - }, - responseProperties: { - "query_issues": { - type: "array", - items: { type: "string" }, - }, - "page_count": { type: "integer" }, - "result_count": { type: "integer" }, - "results": { - type: "array", - items: { - type: "object", - properties: { - "name": { type: "string" }, - "versions": { - type: "array", - items: libData.ModInfo.jsonSchema, - }, - }, - }, - }, - }, -}); - -messages.setModSubscriptions = new Request({ - type: "set_mod_subscriptions", - links: ["control-controller"], - permission: "core.mod.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "mod_names": { - type: "array", - items: { type: "string" }, - }, - }, -}); - -messages.downloadMod = new Request({ - type: "download_mod", - links: ["control-controller"], - permission: "core.mod.download", - requestProperties: { - "name": { type: "string" }, - "version": { type: "string" }, - }, - responseProperties: { - "stream_id": { type: "string" }, - }, -}); - -messages.deleteMod = new Request({ - type: "delete_mod", - links: ["control-controller"], - permission: "core.mod.delete", - requestProperties: { - "name": { type: "string" }, - "version": { type: "string" }, - }, -}); - -messages.listPermissions = new Request({ - type: "list_permissions", - links: ["control-controller"], - permission: "core.permission.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["name", "title", "description"], - properties: { - "name": { type: "string" }, - "title": { type: "string" }, - "description": { type: "string" }, - }, - }, - }, - }, -}); - -messages.listRoles = new Request({ - type: "list_roles", - links: ["control-controller"], - permission: "core.role.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["id", "name", "description", "permissions"], - properties: { - "id": { type: "integer" }, - "name": { type: "string" }, - "description": { type: "string" }, - "permissions": { type: "array", items: { type: "string" }}, - }, - }, - }, - }, -}); - -messages.createRole = new Request({ - type: "create_role", - links: ["control-controller"], - permission: "core.role.create", - requestProperties: { - "name": { type: "string" }, - "description": { type: "string" }, - "permissions": { type: "array", items: { type: "string" }}, - }, - responseProperties: { - "id": { type: "integer" }, - }, -}); - -messages.updateRole = new Request({ - type: "update_role", - links: ["control-controller"], - permission: "core.role.update", - requestProperties: { - "id": { type: "integer" }, - "name": { type: "string" }, - "description": { type: "string" }, - "permissions": { type: "array", items: { type: "string" }}, - }, -}); - -messages.grantDefaultRolePermissions = new Request({ - type: "grant_default_role_permissions", - links: ["control-controller"], - permission: "core.role.update", - requestProperties: { - "id": { type: "integer" }, - }, -}); - -messages.deleteRole = new Request({ - type: "delete_role", - links: ["control-controller"], - permission: "core.role.delete", - requestProperties: { - "id": { type: "integer" }, - }, -}); - -const userRequired = ["name", "roles", "instances"]; -const userProperties = { - "name": { type: "string" }, - "roles": { type: "array", items: { type: "integer" }}, - "is_admin": { type: "boolean" }, - "is_banned": { type: "boolean" }, - "is_whitelisted": { type: "boolean" }, - "ban_reason": { type: "string" }, - "instances": { type: "array", items: { type: "integer" }}, - "is_deleted": { type: "boolean" }, - "player_stats": PlayerStats.jsonSchema, - "instance_stats": { - type: "array", - items: { - type: "array", - items: [{ type: "integer" }, PlayerStats.jsonSchema], - }, - }, -}; - -messages.getUser = new Request({ - type: "get_user", - links: ["control-controller"], - permission: "core.user.get", - requestProperties: { - "name": { type: "string" }, - }, - responseRequired: userRequired, - responseProperties: userProperties, -}); - -messages.listUsers = new Request({ - type: "list_users", - links: ["control-controller"], - permission: "core.user.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: userRequired, - properties: userProperties, - }, - }, - }, -}); - -messages.setUserSubscriptions = new Request({ - type: "set_user_subscriptions", - links: ["control-controller"], - permission: "core.user.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "names": { - type: "array", - items: { type: "string" }, - }, - }, -}); - -messages.createUser = new Request({ - type: "create_user", - links: ["control-controller"], - permission: "core.user.create", - requestProperties: { - "name": { type: "string" }, - }, -}); - -messages.revokeUserToken = new Request({ - type: "revoke_user_token", - links: ["control-controller"], - permission: "core.user.revoke_token", - requestProperties: { - "name": { type: "string" }, - }, -}); - -messages.updateUserRoles = new Request({ - type: "update_user_roles", - links: ["control-controller"], - permission: "core.user.update_roles", - requestProperties: { - "name": { type: "string" }, - "roles": { type: "array", items: { type: "integer" }}, - }, -}); - -messages.setUserAdmin = new Request({ - type: "set_user_admin", - links: ["control-controller"], - permission: "core.user.set_admin", - requestProperties: { - "name": { type: "string" }, - "create": { type: "boolean" }, - "admin": { type: "boolean" }, - }, -}); - -messages.setUserWhitelisted = new Request({ - type: "set_user_whitelisted", - links: ["control-controller"], - permission: "core.user.set_whitelisted", - requestProperties: { - "name": { type: "string" }, - "create": { type: "boolean" }, - "whitelisted": { type: "boolean" }, - }, -}); - -messages.setUserBanned = new Request({ - type: "set_user_banned", - links: ["control-controller"], - permission: "core.user.set_banned", - requestProperties: { - "name": { type: "string" }, - "create": { type: "boolean" }, - "banned": { type: "boolean" }, - "reason": { type: "string" }, - }, -}); - -messages.deleteUser = new Request({ - type: "delete_user", - links: ["control-controller"], - permission: "core.user.delete", - requestProperties: { - "name": { type: "string" }, - }, -}); - -messages.setLogSubscriptions = new Request({ - type: "set_log_subscriptions", - links: ["control-controller"], - permission: "core.log.follow", - requestProperties: { - "all": { type: "boolean" }, - "controller": { type: "boolean" }, - "host_ids": { - type: "array", - items: { type: "integer" }, - }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - "max_level": { type: ["string", "null"] }, - }, -}); - -messages.queryLog = new Request({ - type: "query_log", - links: ["control-controller"], - permission: "core.log.query", - requestProperties: { - "all": { type: "boolean" }, - "controller": { type: "boolean" }, - "host_ids": { - type: "array", - items: { type: "integer" }, - }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - "max_level": { type: ["string", "null"] }, - "limit": { type: "integer" }, - "order": { enum: ["asc", "desc"] }, - }, - responseProperties: { - "log": { - type: "array", - items: { type: "object" }, - }, - }, -}); - - -// Internal requests -messages.updateInstances = new Request({ - type: "update_instances", - links: ["host-controller"], - requestProperties: { - "instances": { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["serialized_config", "status"], - properties: { - "serialized_config": { type: "object" }, - "status": { enum: [ - "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", - ]}, - }, - }, - }, - }, -}); - -messages.assignInstance = new Request({ - type: "assign_instance", - links: ["controller-host"], - requestProperties: { - "serialized_config": { type: "object" }, - }, - forwardTo: "instance", -}); - -messages.unassignInstance = new Request({ - type: "unassigne_instance", - links: ["controller-host"], - forwardTo: "instance", -}); - -messages.getMetrics = new Request({ - type: "get_metrics", - links: ["controller-host", "host-instance"], - responseProperties: { - "results": { type: "array" }, - }, -}); - - -/** - * Represents an event sent over the link - * - * A one way message without any response or recipt confirmation that - * can be innitiated by either party of the link. - * - * @extends module:lib/link.Message - * @memberof module:lib/link - */ -class Event extends Message { - - /** - * Creates an event - * - * @param {string} type - - * message type used. Will have suffix '_event' appended to it for - * the messages sent through this event. - * @param {Array} links - - * Links this event is valid on. Takes an array of strings - * containing "-" specifications. - * @param {?string} forwardTo - - * Optional target to forward this request to. Supported values are - * 'controller' and 'instance'. If set to 'instance' an instance_id - * property is implicity added to eventProperties. - * @param {?string} broadcastTo - - * Optional target to broadcast this event to. Only 'instance' is - * currently supported. An event will only be broadcast towards - * links that are downstream, so sending an event set to instance - * broadcast that's sent to a host will only be broadcast to that - * host's instances. - * @param {?Array} eventRequired - - * List of properties required to be present in the event payload. - * Defaults to all. - * @param {Object} eventProperties - - * Mapping of property values to JSON schema specifications for the - * properties that are valid in the data payload of this event. - */ - constructor({ type, links, forwardTo = null, broadcastTo = null, eventRequired = null, eventProperties = {} }) { - super(); - this.type = type; - this.links = links; - this.forwardTo = forwardTo; - this.broadcastTo = broadcastTo; - this.handlerSuffix = "EventHandler"; - - if (!eventRequired) { - eventRequired = Object.keys(eventProperties); - } - - if (forwardTo === "instance") { - eventRequired = ["instance_id", ...eventRequired]; - eventProperties = { - "instance_id": { type: "integer" }, - ...eventProperties, - }; - - } else if (forwardTo !== "controller" && forwardTo !== null) { - throw new Error(`Invalid forwardTo value ${forwardTo}`); - } - - if (broadcastTo !== "instance" && broadcastTo !== null) { - throw new Error(`Invalid broadcastTo value ${broadcastTo}`); - } - - this.eventType = `${type}_event`; - this._eventValidator = libSchema.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - properties: { - "type": { const: this.eventType }, - "data": { - additionalProperties: false, - required: eventRequired, - properties: eventProperties, - }, - }, - }); - } - - /** - * Attach to a link - * - * Set a the handler for the event on the given link if it's a target - * for the event. - * - * If forwardTo was set to 'controller' the `forwardEventToController` method on - * the link is used as the default handler. If it was set to 'instance' - * the `forwardEventToInstance` method on the link is used by default. - * - * If broadcastTo was set to 'instance' the `broadcastEventToInstance` - * method on the link is invoked before the handler. - * - * Does nothing if the link is not a target for this event. - * - * @param {Link} link - The link to attach to. - * @param {Function} handler - - * Async function to invoke with link set as this to handle the - * event. Only used on the target side. - * @throws {Error} if the handler is needed and not defined. - */ - attach(link, handler) { - // Target side. Note: Source and target is reversed here. - if (this.links.includes(`${link.target}-${link.source}`)) { - // Use forwarder if handler is not present. - if (!handler) { - if (this.forwardTo === "instance") { - handler = link.forwardEventToInstance; - } else if (this.forwardTo === "controller") { - handler = link.forwardEventToController; - } - } - - if ( - this.broadcastTo === "instance" - && [ - "instance-host", "host-controller", "control-controller", "controller-host", - ].includes(`${link.target}-${link.source}`) - ) { - if (!handler) { - handler = link.broadcastEventToInstance; - } else { - let originalHandler = handler; - handler = async (message, event) => { - await link.broadcastEventToInstance(message, event); - await originalHandler.call(link, message, event); - }; - } - } - - if (!handler) { - throw new MissingLinkHandlerError(this.eventType, link.source, link.target); - } - - link.setHandler(this.eventType, message => { - // XXX Should event handlers even be allowed to be async? - handler.call(link, message, this).catch(err => { - logger.error(`Unexpected error while handling ${this.eventType}:\n${err.stack}`); - }); - }, this._eventValidator); - } - } - - /** - * Send event over the given link - * - * Sends the given event data over the link. - * - * @param {module:lib/link.Link} link - Link to send event over. - * @param {Object} data - Data to send with the event. - */ - send(link, data = {}) { - if (!this._eventValidator({ seq: 0, type: this.eventType, data })) { - logger.error(JSON.stringify(this._eventValidator.errors, null, 4)); - throw new Error(`Validation failed sending ${this.eventType}`); - } - - link.connector.send(this.eventType, data); - } -} - -messages.debugWsMessage = new Event({ - type: "debug_ws_message", - links: ["controller-control"], - eventProperties: { - "direction": { type: "string" }, - "content": { type: "string" }, - }, -}); - -messages.accountUpdate = new Event({ - type: "account_update", - links: ["controller-control"], - eventRequired: [], - eventProperties: { - "roles": { - type: "array", - items: { - additionalProperties: false, - type: "object", - required: ["name", "id", "permissions"], - properties: { - "name": { type: "string" }, - "id": { type: "integer" }, - "permissions": { - type: "array", - items: { type: "string" }, - }, - }, - }, - }, - }, -}); - -messages.logMessage = new Event({ - type: "log_message", - links: ["host-controller", "controller-control"], - eventProperties: { - "info": { - type: "object", - required: ["level", "message"], - properties: { - "level": { type: "string" }, - "message": { type: "string" }, - }, - }, - }, -}); - -messages.hostUpdate = new Event({ - type: "host_update", - links: ["controller-control"], - eventProperties: hostProperties, -}); - - -messages.instanceInitialized = new Event({ - type: "instance_initialized", - links: ["instance-host"], - eventProperties: { - "instance_id": { type: "integer" }, - "plugins": { - type: "object", - additionalProperties: { type: "string" }, - }, - }, -}); -messages.instanceStatusChanged = new Event({ - type: "instance_status_changed", - links: ["instance-host", "host-controller"], - eventRequired: ["instance_id", "status"], - eventProperties: { - "instance_id": { type: "integer" }, - "status": { enum: [ - "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", - ]}, - "game_port": { type: ["null", "integer"] }, - }, -}); - -messages.instanceUpdate = new Event({ - type: "instance_update", - links: ["controller-control"], - eventProperties: instanceProperties, -}); - -messages.saveListUpdate = new Event({ - type: "save_list_update", - links: ["instance-host", "host-controller", "controller-control"], - forwardTo: "controller", - eventProperties: { - "instance_id": { type: "integer" }, - "list": saveList, - }, -}); - -messages.modPackUpdate = new Event({ - type: "mod_pack_update", - links: ["controller-control"], - eventProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.modUpdate = new Event({ - type: "mod_update", - links: ["controller-control"], - eventProperties: { - "mod": libData.ModInfo.jsonSchema, - }, -}); - -messages.userUpdate = new Event({ - type: "user_update", - links: ["controller-control"], - eventRequired: userRequired, - eventProperties: userProperties, -}); - -messages.controllerConnectionEvent = new Event({ - type: "controller_connection_event", - links: ["host-instance"], - eventProperties: { - "event": { type: "string" }, - }, -}); - -messages.syncUserLists = new Event({ - type: "sync_user_lists", - links: ["controller-host"], - eventProperties: { - "adminlist": { - type: "array", - items: { type: "string" }, - }, - "banlist": { - type: "array", - items: { - type: "array", - additionalItems: false, - items: [{ type: "string" }, { type: "string" }], - }, - }, - "whitelist": { - type: "array", - items: { type: "string" }, - }, - }, -}); - -messages.banlistUpdate = new Event({ - type: "banlist_update", - links: ["controller-host", "host-instance"], - broadcastTo: "instance", - eventProperties: { - "name": { type: "string" }, - "banned": { type: "boolean" }, - "reason": { type: "string" }, - }, -}); - -messages.adminlistUpdate = new Event({ - type: "adminlist_update", - links: ["controller-host", "host-instance"], - eventProperties: { - "name": { type: "string" }, - "admin": { type: "boolean" }, - }, - broadcastTo: "instance", -}); - -messages.whitelistUpdate = new Event({ - type: "whitelist_update", - links: ["controller-host", "host-instance"], - eventProperties: { - "name": { type: "string" }, - "whitelisted": { type: "boolean" }, - }, - broadcastTo: "instance", -}); - -messages.playerEvent = new Event({ - type: "player_event", - links: ["instance-host", "host-controller"], - forwardTo: "controller", - eventRequired: ["instance_id", "type", "name"], - eventProperties: { - "instance_id": { type: "integer" }, - "type": { type: "string", enum: ["join", "leave", "import"] }, - "name": { type: "string" }, - "reason": { type: "string" }, - "stats": PlayerStats.jsonSchema, - }, -}); - -/** - * Attaches all requests and events to the given link - * - * Loops through all builtin messages defined and attaches all of them - * to the link. The handler used for the messageis looked up on the - * link instance as the concatenation of the name of the message, the - * name of message class, and 'Handler'. - * - * @param {module:lib/link.Link} link - Link to attach messages to. - * @memberof module:lib/link - */ -function attachAllMessages(link) { - for (let [name, message] of Object.entries(messages)) { - let handler = `${name}${message.handlerSuffix}`; - try { - message.attach(link, link[handler]); - } catch (err) { - if (err.code === "MISSING_LINK_HANDLER") { - err.handler = handler; - } - throw err; - } - } -} - - -module.exports = { - Message, - Request, - Event, - - attachAllMessages, - - messages, -}; diff --git a/packages/lib/logging.js b/packages/lib/logging.js index 94135133..56c9a729 100644 --- a/packages/lib/logging.js +++ b/packages/lib/logging.js @@ -85,16 +85,16 @@ class WebConsoleFormat { /** * Filter object for logs * @typedef {Object} module:lib/logging~LogFilter - * @property {string} [max_level] - + * @property {string} [maxLevel] - * Maximum log level to include. Higher levels are more verbose. * @property {boolean} [all] - * Include log entries from controller, all hosts and all instances. * @property {boolean} [controller] - * Include log entries from the controller. - * @property {Array} [host_ids] - + * @property {Array} [hostIds] - * Include log entries for the given hosts and instances of those * hosts by id. - * @property {Array} [instance_ids] - + * @property {Array} [instanceIds] - * Include log entries for the given instances by id. */ @@ -107,10 +107,10 @@ class WebConsoleFormat { * filter returning true for log entries that match it. * @static */ -function logFilter({ all, controller, host_ids, instance_ids, max_level }) { +function logFilter({ all, controller, hostIds, instanceIds, maxLevel }) { return info => { // Note: reversed to filter out undefined levels - if (max_level && !(levels[info.level] <= levels[max_level])) { + if (maxLevel && !(levels[info.level] <= levels[maxLevel])) { return false; } @@ -121,14 +121,14 @@ function logFilter({ all, controller, host_ids, instance_ids, max_level }) { return true; } if ( - host_ids + hostIds && info.host_id !== undefined && info.instance_id === undefined - && host_ids.includes(info.host_id) + && hostIds.includes(info.host_id) ) { return true; } - if (instance_ids && info.instance_id !== undefined && instance_ids.includes(info.instance_id)) { + if (instanceIds && info.instance_id !== undefined && instanceIds.includes(info.instance_id)) { return true; } return false; diff --git a/packages/lib/logging_utils.js b/packages/lib/logging_utils.js index be604fd6..dea8baee 100644 --- a/packages/lib/logging_utils.js +++ b/packages/lib/logging_utils.js @@ -15,7 +15,7 @@ const Transport = require("winston-transport"); const { LEVEL, MESSAGE } = require("triple-beam"); const chalk = require("chalk"); -const libLink = require("./link"); +const libData = require("./data"); const libErrors = require("./errors"); const libFileOps = require("./file_ops"); const { levels, logFilter, logger } = require("./logging"); @@ -128,7 +128,7 @@ class LinkTransport extends Transport { } try { - libLink.messages.logMessage.send(this.link, { info }); + this.link.send(new libData.LogMessageEvent(info)); } catch (err) { // Ignore session lost errors. if (!(err instanceof libErrors.SessionLost)) { @@ -140,7 +140,7 @@ class LinkTransport extends Transport { } const logFileGlob = /^[a-z]+-(\d{4}-\d{2}-\d{2})\.log$/; -const logIndexVersion = 1; +const logIndexVersion = 2; /** * Keeps an index over a log directory to speed up queries to it @@ -159,8 +159,8 @@ class LogIndex { this.index.set(file, { levels: new Set(serializedEntry.levels), controller: serializedEntry.controller, - host_ids: new Set(serializedEntry.host_ids), - instance_ids: new Set(serializedEntry.instance_ids), + hostIds: new Set(serializedEntry.hostIds), + instanceIds: new Set(serializedEntry.instanceIds), }); } } @@ -175,8 +175,8 @@ class LogIndex { serialized.files[file] = { levels: [...entry.levels], controller: entry.controller, - host_ids: [...entry.host_ids], - instance_ids: [...entry.instance_ids], + hostIds: [...entry.hostIds], + instanceIds: [...entry.instanceIds], }; } @@ -226,8 +226,8 @@ class LogIndex { let entry = { levels: new Set(), controller: false, - host_ids: new Set(), - instance_ids: new Set(), + hostIds: new Set(), + instanceIds: new Set(), }; for await (let line of lineStream) { let info; @@ -244,10 +244,10 @@ class LogIndex { if (info.host_id === undefined) { entry.controller = true; } else if (info.instance_id === undefined) { - entry.host_ids.add(info.host_id); + entry.hostIds.add(info.host_id); } if (info.instance_id !== undefined) { - entry.instance_ids.add(info.instance_id); + entry.instanceIds.add(info.instance_id); } } return entry; @@ -280,18 +280,18 @@ class LogIndex { * @returns {boolean} * true if the file may contain entries included by the filter. */ - filterIncludesFile(file, { max_level, all, controller, instance_ids, host_ids }) { + filterIncludesFile(file, { maxLevel, all, controller, instanceIds, hostIds }) { let entry = this.index.get(file); if (!entry) { return true; } const hasOwnProperty = Object.prototype.hasOwnProperty; - if (max_level && hasOwnProperty.call(levels, max_level)) { + if (maxLevel && hasOwnProperty.call(levels, maxLevel)) { let fileLevels = [...entry.levels].map( fileLevel => (hasOwnProperty.call(levels, fileLevel) ? levels[fileLevel] : levels.verbose) ); - if (fileLevels.every(level => level > levels[max_level])) { + if (fileLevels.every(level => level > levels[maxLevel])) { return false; } } @@ -302,10 +302,10 @@ class LogIndex { if (controller && entry.controller) { return true; } - if (host_ids && host_ids.some(id => entry.host_ids.has(id))) { + if (hostIds && hostIds.some(id => entry.hostIds.has(id))) { return true; } - if (instance_ids && instance_ids.some(id => entry.instance_ids.has(id))) { + if (instanceIds && instanceIds.some(id => entry.instanceIds.has(id))) { return true; } return false; @@ -319,16 +319,16 @@ class LogIndex { * @property {string} [order="asc"] - * Return entries in ascending ("asc") date order or desceding ("desc") * date order. - * @property {string} [max_level] - + * @property {string} [maxLevel] - * Maximum log level to include. Higher levels are more verbose. * @property {boolean} [all] - * Include log entries from controller, all hosts and all instances. * @property {boolean} [controller] - * Include log entries from the controller. - * @property {Array} [host_ids] - + * @property {Array} [hostIds] - * Include log entries for the given hosts and instances of those * hosts by id. - * @property {Array} [instance_ids] - + * @property {Array} [instanceIds] - * Include log entries for the given instances by id. */ diff --git a/packages/lib/plugin.js b/packages/lib/plugin.js index fe627a86..eebfd072 100644 --- a/packages/lib/plugin.js +++ b/packages/lib/plugin.js @@ -545,10 +545,14 @@ class BaseControllerPlugin { * This does not include hosts that are in the process of closing the * connection, which typically happens when they are shutting down. * - * @param {module:lib/link.Event} event - Event to send + * @param {*} event - Event to send * @param {Object} data - Data ta pass with the event. */ broadcastEventToHosts(event, data={}) { + if (event.type !== "event") { + this.logger.warn(`Ignoring broadcast of ${event.type}`); + return; + } for (let hostConnection of this.controller.wsServer.hostConnections.values()) { if ( !hostConnection.connector.closing @@ -771,43 +775,6 @@ class BaseWebPlugin { onControllerConnectionEvent(event) { } } -/** - * Attach plugin messages - * - * Attaches all messages defined in the `.info.message` property of the - * `plugin` passed with handlers taken from the `pluin`. - * - * @param {module:lib/link.Link} link - Link to attach handlers to. - * @param {module:lib/plugin.BasePlugin} plugin - - * The instance of the plugin attach messages and handlers from. - */ -function attachPluginMessages(link, plugin) { - let messageDefinitions = plugin.info.messages || []; - for (let [name, messageFormat] of Object.entries(messageDefinitions)) { - if (messageFormat.plugin !== plugin.info.name) { - throw new Error(`Type of ${name} message must start with "${plugin.info.name}:"`); - } - - let handler = name + messageFormat.handlerSuffix; - try { - if (plugin === null || !plugin[handler]) { - messageFormat.attach(link); - - } else { - messageFormat.attach(link, async (message, format) => await plugin[handler](message, format, link)); - } - - } catch (err) { - if (err.code === "MISSING_LINK_HANDLER") { - err.plugin = plugin.info.name; - err.handler = handler; - } - - throw err; - } - } -} - /** * Invokes the given hook on all plugins * @@ -847,6 +814,5 @@ module.exports = { BaseControlPlugin, BaseWebPlugin, - attachPluginMessages, invokeHook, }; diff --git a/packages/lib/schema.js b/packages/lib/schema.js index 572b7846..a9e362c7 100644 --- a/packages/lib/schema.js +++ b/packages/lib/schema.js @@ -8,216 +8,10 @@ const ajv = new Ajv({ verbose: true, format: "full", extendRefs: "fail", - strictDefaluts: true, + strictDefaults: true, strictKeywords: true, }); - -/** - * The base format for any message sent over the link - */ -const messageSchema = { - $schema: "http://json-schema.org/draft-07/schema#", - title: "Message", - description: "Format for messages on a link", - type: "object", - additionalProperties: false, - required: ["seq", "type", "data"], - properties: { - "seq": { type: ["integer", "null"] }, - "type": { type: "string" }, - "data": { type: "object" }, - }, -}; - -/** - * Validator for message schemas - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const message = ajv.compile(messageSchema); - -/** - * Validator for heartbeat messages - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const heartbeat = ajv.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - title: "Heartbeat", - description: "Heartbeat sent over link", - type: "object", - additionalProperties: false, - required: ["seq", "type", "data"], - properties: { - "seq": { const: null }, - "type": { const: "heartbeat" }, - "data": { - type: "object", - additionalProperties: false, - required: ["seq"], - properties: { - "seq": { type: ["integer", "null"] }, - }, - }, - }, -}); - -/** - * Validator for server sent part of handshake - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const serverHandshake = ajv.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - title: "Server Handshake", - description: "Message sent from the server to a client during the handshake", - allOf: [messageSchema], - anyOf: [ - { - properties: { - "type": { const: "hello" }, - "data": { - additionalProperties: false, - required: ["version", "plugins"], - properties: { - "version": { type: "string" }, - "plugins": { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - }, - }, - { - properties: { - "type": { const: "ready" }, - "data": { - additionalProperties: false, - required: ["session_token", "session_timeout", "heartbeat_interval"], - properties: { - "session_token": { type: "string" }, - "session_timeout": { type: "number" }, - "heartbeat_interval": { type: "number" }, - "account": { - type: "object", - additionalProperties: false, - required: ["name", "roles"], - properties: { - "name": { type: "string" }, - "roles": { - type: "array", - items: { - additionalProperties: false, - type: "object", - required: ["name", "id", "permissions"], - properties: { - "name": { type: "string" }, - "id": { type: "integer" }, - "permissions": { - type: "array", - items: { type: "string" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - properties: { - "type": { const: "continue" }, - "data": { - additionalProperties: false, - required: ["last_seq", "session_timeout", "heartbeat_interval"], - properties: { - "last_seq": { type: ["integer", "null"] }, - "session_timeout": { type: "number" }, - "heartbeat_interval": { type: "number" }, - }, - }, - }, - }, - { - properties: { - "type": { const: "invalidate" }, - "data": { const: {} }, - }, - }, - ], -}); - -/** - * Validator for client sent part of handshake - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const clientHandshake = ajv.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - title: "Client Message", - description: "Message sent from a client to the server", - allOf: [messageSchema], - anyOf: [ - { - properties: { - "type": { const: "register_host" }, - "data": { - additionalProperties: false, - required: ["token", "agent", "version", "name", "id", "plugins"], - properties: { - "token": { type: "string" }, - "agent": { type: "string" }, - "version": { type: "string" }, - "name": { type: "string" }, - "id": { type: "integer" }, - "public_address": { type: "string" }, - "plugins": { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - }, - }, - { - properties: { - "type": { const: "register_control" }, - "data": { - additionalProperties: false, - required: ["token", "agent", "version"], - properties: { - "token": { type: "string" }, - "agent": { type: "string" }, - "version": { type: "string" }, - }, - }, - }, - }, - { - properties: { - "type": { const: "resume" }, - "data": { - additionalProperties: false, - required: ["session_token", "last_seq"], - properties: { - "session_token": { type: "string" }, - "last_seq": { type: ["integer", "null"] }, - }, - }, - }, - }, - ], -}); - - module.exports = { /** * Compile JSON schema into validator @@ -226,10 +20,4 @@ module.exports = { * @returns {Function} Validator for the schema. */ compile: ajv.compile.bind(ajv), - - message, - heartbeat, - - serverHandshake, - clientHandshake, }; diff --git a/packages/web_ui/src/components/AssignInstanceModal.jsx b/packages/web_ui/src/components/AssignInstanceModal.jsx index 97f49576..f64edfbc 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.jsx +++ b/packages/web_ui/src/components/AssignInstanceModal.jsx @@ -1,7 +1,7 @@ import React, { useContext, useState } from "react"; import { Button, Form, Modal, Select, Typography } from "antd"; -import { libLink } from "@clusterio/lib"; +import { libData } from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; @@ -33,10 +33,9 @@ export default function AssignInstanceModal(props) { } setApplying(true); - libLink.messages.assignInstanceCommand.send(control, { - instance_id: props.id, - host_id: hostId, - }).then(() => { + control.send( + new libData.InstanceAssignRequest(props.id, hostId) + ).then(() => { setVisible(false); if (props.onFinish) { props.onFinish(); diff --git a/packages/web_ui/src/components/ControllerConfigTree.jsx b/packages/web_ui/src/components/ControllerConfigTree.jsx index 5197f8c6..40dd7947 100644 --- a/packages/web_ui/src/components/ControllerConfigTree.jsx +++ b/packages/web_ui/src/components/ControllerConfigTree.jsx @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { libConfig, libLink } from "@clusterio/lib"; +import { libConfig, libData } from "@clusterio/lib"; import BaseConfigTree from "./BaseConfigTree"; import ControlContext from "./ControlContext"; @@ -10,27 +10,26 @@ export default function ControllerConfigTree(props) { let control = useContext(ControlContext); async function retrieveConfig() { - let result = await libLink.messages.getControllerConfig.send(control); - return result.serialized_config; + let result = await control.send(new libData.ControllerConfigGetRequest()); + return result.serializedConfig; } async function setField(field, value) { - await libLink.messages.setControllerConfigField.send(control, { + await control.send(new libData.ControllerConfigSetFieldRequest( field, - value: String(value), - }); + String(value), + )); } async function setProp(field, prop, value) { - let data = { field, prop }; if (value) { try { - data.value = JSON.parse(value); + value = JSON.parse(value); } catch (err) { return; } } - await libLink.messages.setControllerConfigProp.send(control, data); + await control.send(new libData.ControllerConfigSetPropRequest(field, prop, value)); } return { + control.sendTo( + new libData.InstanceCreateSaveRequest(name, seed, mapGenSettings, mapSettings), + { instanceId: props.instance.id } + ).then(() => { form.resetFields(); setVisible(false); }).catch( diff --git a/packages/web_ui/src/components/HostViewPage.jsx b/packages/web_ui/src/components/HostViewPage.jsx index 23589ef0..51f1db69 100644 --- a/packages/web_ui/src/components/HostViewPage.jsx +++ b/packages/web_ui/src/components/HostViewPage.jsx @@ -20,7 +20,7 @@ export default function HostViewPage(props) { let [instanceList] = useInstanceList(); let [host] = useHost(hostId); - instanceList = instanceList.filter(instance => instance["assigned_host"] === hostId); + instanceList = instanceList.filter(instance => instance.assignedHost === hostId); let nav = [{ name: "Hosts", path: "/hosts" }, { name: host.name || hostId }]; if (host.loading) { diff --git a/packages/web_ui/src/components/HostsPage.jsx b/packages/web_ui/src/components/HostsPage.jsx index f941602a..afb8c2dd 100644 --- a/packages/web_ui/src/components/HostsPage.jsx +++ b/packages/web_ui/src/components/HostsPage.jsx @@ -3,7 +3,7 @@ import { useHistory } from "react-router-dom"; import { Button, Form, InputNumber, Modal, PageHeader, Table, Tag, Typography } from "antd"; import CopyOutlined from "@ant-design/icons/lib/icons/CopyOutlined"; -import { libLink } from "@clusterio/lib"; +import { libData } from "@clusterio/lib"; import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; @@ -35,7 +35,7 @@ function GenerateHostTokenButton(props) { }, []); async function generateToken() { - let id; + let id = null; let values = form.getFieldsValue(); if (values.hostId) { id = Number.parseInt(values.hostId, 10); @@ -46,8 +46,8 @@ function GenerateHostTokenButton(props) { form.setFields([{ name: "hostId", errors: [] }]); } - let result = await libLink.messages.generateHostToken.send(control, { host_id: id || null }); - setToken(result.token); + let newToken = await control.send(new libData.HostGenerateTokenRequest(id)); + setToken(newToken); setHostId(id); } @@ -192,37 +192,37 @@ export default function HostsPage() { title: "Name", dataIndex: "name", defaultSortOrder: "ascend", - sorter: (a, b) => strcmp(a["name"], b["name"]), + sorter: (a, b) => strcmp(a.name, b.name), }, { title: "Agent", dataIndex: "agent", - sorter: (a, b) => strcmp(a["agent"], b["agent"]), + sorter: (a, b) => strcmp(a.agent, b.agent), responsive: ["lg"], }, { title: "Version", dataIndex: "version", - sorter: (a, b) => strcmp(a["version"], b["version"]), + sorter: (a, b) => strcmp(a.version, b.version), }, { title: "Public address", - dataIndex: "public_address", - sorter: (a, b) => strcmp(a["public_address"], b["public_address"]), + dataIndex: "publicAddress", + sorter: (a, b) => strcmp(a.publicAddress, b.publicAddress), }, { title: "Connected", key: "connected", render: host => - {host["connected"] ? "Connected" : "Disconnected"} + {host.connected ? "Connected" : "Disconnected"} , - sorter: (a, b) => a["connected"] - b["connected"], + sorter: (a, b) => a.connected - b.connected, }, ]} dataSource={hostList} - rowKey={host => host["id"]} + rowKey={host => host.id} pagination={false} onRow={(record, rowIndex) => ({ onClick: event => { diff --git a/packages/web_ui/src/components/InstanceConfigTree.jsx b/packages/web_ui/src/components/InstanceConfigTree.jsx index a2d05a71..74afaa31 100644 --- a/packages/web_ui/src/components/InstanceConfigTree.jsx +++ b/packages/web_ui/src/components/InstanceConfigTree.jsx @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { libConfig, libLink } from "@clusterio/lib"; +import { libConfig, libData } from "@clusterio/lib"; import BaseConfigTree from "./BaseConfigTree"; import ControlContext from "./ControlContext"; @@ -10,34 +10,23 @@ export default function InstanceConfigTree(props) { let control = useContext(ControlContext); async function retrieveConfig() { - let result = await libLink.messages.getInstanceConfig.send(control, { - instance_id: props.id, - }); - return result.serialized_config; + let result = await control.send(new libData.InstanceConfigGetRequest(props.id)); + return result.config; } async function setField(field, value) { - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: props.id, - field, - value, - }); + await control.send(new libData.InstanceConfigSetFieldRequest(props.id, field, value)); } async function setProp(field, prop, value) { - let data = { - instance_id: props.id, - field, - prop, - }; if (value) { try { - data.value = JSON.parse(value); + value = JSON.parse(value); } catch (err) { return; } } - await libLink.messages.setInstanceConfigProp.send(control, data); + await control.send(new libData.InstanceConfigSetPropRequest(props.id, field, prop, value)); } return s.id === instance.assigned_host); - if (host && host.public_address) { - if (instance.game_port) { - return `${host.public_address}:${instance.game_port}`; + let host = hostList.find(s => s.id === instance.assignedHost); + if (host && host.publicAddress) { + if (instance.gamePort) { + return `${host.publicAddress}:${instance.gamePort}`; } - return host.public_address; + return host.publicAddress; } return null; } @@ -46,30 +46,30 @@ export default function InstanceList(props) { }, { title: "Assigned Host", - key: "assigned_host", - render: instance => hostName(instance["assigned_host"]), - sorter: (a, b) => strcmp(hostName(a["assigned_host"]), hostName(b["assigned_host"])), + key: "assignedHost", + render: instance => hostName(instance.assignedHost), + sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)), responsive: ["sm"], }, { title: "Public address", - key: "public_address", + key: "publicAddress", render: instance => { - let public_address = instancePublicAddress(instance); - return public_address ? <> - {public_address} + let publicAddress = instancePublicAddress(instance); + return publicAddress ? <> + {publicAddress} :