From e011625a2d801fae2ac5187df42e16ea65dc8ecf Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Sun, 13 Aug 2023 16:53:03 +0200 Subject: [PATCH 01/16] expose and/or modify signatures from lib package --- packages/lib/src/config/definitions.ts | 65 +++++++++++++++++++- packages/lib/src/data/messages_controller.ts | 4 +- packages/lib/src/data/messages_core.ts | 4 +- packages/lib/src/link/link.ts | 8 +-- packages/lib/src/logging_utils.ts | 2 +- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 13e18599..7d475ae3 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -140,7 +140,27 @@ ControllerGroup.finalize(); * Controller Config class * @extends classes.Config */ -export class ControllerConfig extends classes.Config { } +export class ControllerConfig extends classes.Config { + get(name: "controller.name"): string; + get(name: "controller.mods_directory"): string; + get(name: "controller.database_directory"): string; + get(name: "controller.http_port"): number | null; + get(name: "controller.https_port"): number | null; + get(name: "controller.bind_address"): string | null; + get(name: "controller.external_address"): string | null; + get(name: "controller.tls_certificate"): string | null; + get(name: "controller.tls_private_key"): string | null; + get(name: "controller.auth_secret"): string | null; + get(name: "controller.heartbeat_interval"): number; + get(name: "controller.session_timeout"): number; + get(name: "controller.metrics_timeout"): number; + get(name: "controller.proxy_stream_timeout"): number; + get(name: "controller.default_mod_pack_id"): number | null; + get(name: "controller.default_role_id"): number | null; + get(name: string) { + return super.get(name); + } +} ControllerConfig.registerGroup(ControllerGroup); @@ -230,7 +250,22 @@ HostGroup.finalize(); * Host Config class * @extends classes.Config */ -export class HostConfig extends classes.Config { } +export class HostConfig extends classes.Config { + get(name: "host.name"): string; + get(name: "host.id"): number; + get(name: "host.factorio_directory"): string; + get(name: "host.mods_directory"): string; + get(name: "host.mods_directory_is_shared"): boolean; + get(name: "host.instances_directory"): string; + get(name: "host.controller_url"): string; + get(name: "host.controller_token"): string; + get(name: "host.tls_ca"): string | null; + get(name: "host.public_address"): string; + get(name: "host.max_reconnect_delay"): number; + get(name: string) { + return super.get(name); + } +} HostConfig.registerGroup(HostGroup); @@ -393,7 +428,31 @@ FactorioGroup.finalize(); * Instance config class * @extends classes.Config */ -export class InstanceConfig extends classes.Config { } +export class InstanceConfig extends classes.Config { + get(name: "instance.name"): string; + get(name: "instance.id"): number; + get(name: "instance.assigned_host"): number | null; + get(name: "instance.auto_start"): boolean; + + get(name: "factorio.version"): string; + get(name: "factorio.game_port"): number | null; + get(name: "factorio.rcon_port"): number | null; + get(name: "factorio.rcon_password"): string | null; + get(name: "factorio.player_online_autosave_slots"): number; + get(name: "factorio.mod_pack"): string | null; + get(name: "factorio.enable_save_patching"): boolean; + get(name: "factorio.enable_whitelist"): boolean; + get(name: "factorio.enable_authserver_bans"): boolean; + get(name: "factorio.settings"): Object; + get(name: "factorio.verbose_logging"): boolean; + get(name: "factorio.strip_paths"): boolean; + get(name: "factorio.sync_adminlist"): boolean; + get(name: "factorio.sync_whitelist"): boolean; + get(name: "factorio.max_concurrent_commands"): number; + get(name: string) { + return super.get(name); + } +} InstanceConfig.registerGroup(InstanceGroup); InstanceConfig.registerGroup(FactorioGroup); diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index ba885145..6534fad3 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -80,7 +80,7 @@ export class HostGenerateTokenRequest { static permission = "core.host.generate_token" as const; constructor( - public hostId?: number, + public hostId: number | null = null, ) { } static jsonSchema = Type.Object({ @@ -202,7 +202,7 @@ export class LogMessageEvent { static dst = ["controller", "control"] as const; constructor( - public info: object, + public info: { level: string, message: string }, ) { } static jsonSchema = Type.Object({ diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index 207fc01a..e27c2bd1 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -48,7 +48,7 @@ export class Address { * Shorthand to translate with. * @returns Translated address. */ - static fromShorthand(shorthand: AddressShorthand) { + static fromShorthand(shorthand: AddressShorthand | Address) { 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); } @@ -172,6 +172,8 @@ export class Message { } } +export type MessageSrcDst = MessageRequest | MessageResponse | MessageResponseError | MessageEvent; + export class HelloData { constructor( public version: string, diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index 2736e79c..99c55037 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -37,7 +37,7 @@ export interface Event { } export type EventClass = Event["constructor"]; -interface RequestEntry { +export interface RequestEntry { Request: RequestClass; name: string, allowedSrcTypes: Set; @@ -60,15 +60,15 @@ interface ForwardedRequest { dst: libData.Address; } -interface EventEntry { +export interface EventEntry { Event: EventClass; name: string, allowedSrcTypes: Set; allowedDstTypes: Set; eventFromJSON: (json: any) => Event; } -type RequestHandler = (request: Req, src: libData.Address, dst: libData.Address) => Promise; -type EventHandler = (event: T, src: libData.Address, dst: libData.Address) => Promise; +export type RequestHandler = (request: Req, src: libData.Address, dst: libData.Address) => Promise; +export type EventHandler = (event: T, src: libData.Address, dst: libData.Address) => Promise; interface Router { forwardMessage(origin: Link, message: Message, fallback: boolean): boolean, diff --git a/packages/lib/src/logging_utils.ts b/packages/lib/src/logging_utils.ts index f55b0ddd..e3cc6c6c 100644 --- a/packages/lib/src/logging_utils.ts +++ b/packages/lib/src/logging_utils.ts @@ -451,7 +451,7 @@ export interface QueryLogFilter { * Index to speed up query with. * @returns log entries matching the filter */ -export async function queryLog(logDirectory: string, filter: QueryLogFilter, index: LogIndex) { +export async function queryLog(logDirectory: string, filter: QueryLogFilter, index: LogIndex | null) { let files = (await fs.readdir(logDirectory)).filter(entry => logFileGlob.test(entry)); filter = { From 6104843858b2a4852593caf50144429a45c1bd18 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Sun, 13 Aug 2023 18:12:12 +0200 Subject: [PATCH 02/16] transition of controller package to typescript --- .../{controller.js => controller.ts} | 181 ++++--- packages/controller/package.json | 16 +- .../{BaseConnection.js => BaseConnection.ts} | 42 +- ...trolConnection.js => ControlConnection.ts} | 319 +++++++------ .../src/{Controller.js => Controller.ts} | 446 +++++++++--------- ...ontrollerRouter.js => ControllerRouter.ts} | 67 +-- .../{HostConnection.js => HostConnection.ts} | 82 ++-- .../src/{HttpCloser.js => HttpCloser.ts} | 34 +- packages/controller/src/InstanceInfo.js | 47 -- packages/controller/src/InstanceInfo.ts | 49 ++ .../src/{UserManager.js => UserManager.ts} | 65 ++- .../src/{WsServer.js => WsServer.ts} | 131 ++--- ...erverConnector.js => WsServerConnector.ts} | 79 ++-- packages/controller/src/metrics.js | 14 - packages/controller/src/metrics.ts | 15 + .../controller/src/{routes.js => routes.ts} | 194 ++++---- packages/controller/tsconfig.json | 18 + packages/controller/typedoc.json | 4 + packages/lib/src/config/definitions.ts | 2 +- 19 files changed, 1001 insertions(+), 804 deletions(-) rename packages/controller/{controller.js => controller.ts} (71%) mode change 100755 => 100644 rename packages/controller/src/{BaseConnection.js => BaseConnection.ts} (52%) rename packages/controller/src/{ControlConnection.js => ControlConnection.ts} (74%) rename packages/controller/src/{Controller.js => Controller.ts} (74%) rename packages/controller/src/{ControllerRouter.js => ControllerRouter.ts} (65%) rename packages/controller/src/{HostConnection.js => HostConnection.ts} (79%) rename packages/controller/src/{HttpCloser.js => HttpCloser.ts} (80%) delete mode 100644 packages/controller/src/InstanceInfo.js create mode 100644 packages/controller/src/InstanceInfo.ts rename packages/controller/src/{UserManager.js => UserManager.ts} (55%) rename packages/controller/src/{WsServer.js => WsServer.ts} (75%) rename packages/controller/src/{WsServerConnector.js => WsServerConnector.ts} (74%) delete mode 100644 packages/controller/src/metrics.js create mode 100644 packages/controller/src/metrics.ts rename packages/controller/src/{routes.js => routes.ts} (72%) create mode 100644 packages/controller/tsconfig.json create mode 100644 packages/controller/typedoc.json diff --git a/packages/controller/controller.js b/packages/controller/controller.ts old mode 100755 new mode 100644 similarity index 71% rename from packages/controller/controller.js rename to packages/controller/controller.ts index c42892a3..a6344fb1 --- a/packages/controller/controller.js +++ b/packages/controller/controller.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -/** +/** * Clusterio controller * * Facilitates communication between hosts and control of the cluster @@ -14,27 +14,28 @@ * npx clusteriocontroller run */ -"use strict"; -const path = require("path"); -const fs = require("fs-extra"); -const crypto = require("crypto"); -const setBlocking = require("set-blocking"); -const yargs = require("yargs"); -const util = require("util"); -const winston = require("winston"); -require("winston-daily-rotate-file"); -const jwt = require("jsonwebtoken"); +import path from "path"; +import fs from "fs-extra"; + +import crypto from "crypto"; +import setBlocking from "set-blocking"; +import yargs from "yargs"; +import util from "util"; +import winston from "winston"; +import DailyRotateFile from "winston-daily-rotate-file"; + +import jwt from "jsonwebtoken"; // homebrew modules -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; const { ConsoleTransport, levels, logger } = lib; -const Controller = require("./src/Controller"); -const UserManager = require("./src/UserManager"); -const version = require("./package").version; +import Controller from "./src/Controller"; +import UserManager from "./src/UserManager"; +const version = process.env.npm_package_version; // globals -let controller; +let controller: Controller; void new lib.Gauge( @@ -42,7 +43,7 @@ void new lib.Gauge( "Mapping of Host ID to name", { labels: ["host_id", "host_name"], - callback: function(gauge) { + callback: function(gauge:lib.Gauge) { gauge.clear(); if (!controller || !controller.hosts) { return; @@ -62,7 +63,7 @@ void new lib.Gauge( "Mapping of Instance ID to name and host", { labels: ["instance_id", "instance_name", "host_id"], - callback: function(gauge) { + callback: function(gauge:lib.Gauge) { gauge.clear(); if (!controller || !controller.instances) { return; @@ -81,19 +82,26 @@ void new lib.Gauge( void new lib.Gauge( "clusterio_controller_websocket_active_connections", "How many WebSocket connections are currently open to the controller", - { callback: function(gauge) { gauge.set(controller.wsServer.activeConnectors.size); }} + { callback: function(gauge:lib.Gauge) { + gauge.set(controller.wsServer.activeConnectors.size); + } + } ); void new lib.Gauge( "clusterio_controller_active_hosts", "How many hosts are currently connected to the controller", - { callback: function(gauge) { gauge.set(controller.wsServer.hostConnections.size); }} + { callback: function(gauge:lib.Gauge) { + gauge.set(controller.wsServer.hostConnections.size); + } + } ); void new lib.Gauge( - "clusterio_controller_connected_clients_count", "How many clients are currently connected to this controller", + "clusterio_controller_connected_clients_count", + "How many clients are currently connected to this controller", { - labels: ["type"], callback: async function(gauge) { + labels: ["type"], callback: async function(gauge:lib.Gauge) { gauge.labels("host").set(controller.wsServer.hostConnections.size); gauge.labels("control").set(controller.wsServer.controlConnections.size); }, @@ -101,7 +109,10 @@ void new lib.Gauge( ); -async function handleBootstrapCommand(args, controllerConfig) { +async function handleBootstrapCommand( + args: any, + controllerConfig: lib.ControllerConfig +): Promise { let subCommand = args._[1]; let userManager = new UserManager(controllerConfig); await userManager.load(path.join(controllerConfig.get("controller.database_directory"), "users.json")); @@ -166,18 +177,31 @@ async function handleBootstrapCommand(args, controllerConfig) { } } -async function initialize() { - let parameters = { - args: null, - shouldRun: false, - clusterLogger: null, - pluginInfos: null, - controllerConfigPath: null, - controllerConfig: null, - }; +export interface ControllerArgs { + [x: string]: unknown; + logLevel: string; + logDirectory: string; + pluginList: string; + devPlugin?: (string | number)[] | undefined; + dev?: boolean; + config: string; + _: (string | number)[]; + $0: string; +} + +interface InitializeParameters { + args: ControllerArgs; + shouldRun: boolean; + clusterLogger: winston.Logger; + pluginInfos: any[] | null; + controllerConfigPath: string | null; + controllerConfig: lib.ControllerConfig | null; +} + +async function initialize(): Promise { // argument parsing - parameters.args = yargs + const args = await yargs .scriptName("controller") .usage("$0 [options]") .option("log-level", { @@ -232,45 +256,46 @@ async function initialize() { ; // Combined log stream of the whole cluster. - parameters.clusterLogger = winston.createLogger({ + const clusterLogger = winston.createLogger({ format: winston.format.json(), level: "verbose", levels, }); - parameters.clusterLogger.add(new winston.transports.DailyRotateFile({ + clusterLogger.add(new DailyRotateFile({ filename: "cluster-%DATE%.log", utc: true, - dirname: path.join(parameters.args.logDirectory, "cluster"), + dirname: path.join(args.logDirectory, "cluster"), })); // Log stream for the controller. - logger.add(new winston.transports.DailyRotateFile({ + logger.add(new DailyRotateFile({ format: winston.format.json(), filename: "controller-%DATE%.log", - dirname: path.join(parameters.args.logDirectory, "controller"), + dirname: path.join(args.logDirectory, "controller"), })); logger.add(new winston.transports.Stream({ - stream: parameters.clusterLogger, + stream: clusterLogger, })); - if (parameters.args.logLevel !== "none") { + if (args.logLevel !== "none") { logger.add(new ConsoleTransport({ - level: parameters.args.logLevel, + level: args.logLevel, format: new lib.TerminalFormat(), })); } - lib.handleUnhandledErrors(logger); + lib.handleUnhandledErrors(); - let command = parameters.args._[0]; + let command = args._[0]; + let shouldRun = false; if (command === "run") { logger.info(`Starting Clusterio controller ${version}`); - parameters.shouldRun = true; + shouldRun = true; } - logger.info(`Loading available plugins from ${parameters.args.pluginList}`); + logger.info(`Loading available plugins from ${args.pluginList}`); let pluginList = new Map(); try { - pluginList = new Map(JSON.parse(await fs.readFile(parameters.args.pluginList))); - } catch (err) { + pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, { encoding: 'utf8' }))); + } catch (err: any) { if (err.code !== "ENOENT") { throw err; } @@ -278,52 +303,67 @@ async function initialize() { // If the command is plugin management we don't try to load plugins if (command === "plugin") { - await lib.handlePluginCommand(parameters.args, pluginList, parameters.args.pluginList); - return parameters; + await lib.handlePluginCommand(args, pluginList, args.pluginList); + return { + args, + shouldRun, + clusterLogger, + pluginInfos: null, + controllerConfigPath: null, + controllerConfig: null, + }; } logger.info("Loading Plugin info"); - parameters.pluginInfos = await lib.loadPluginInfos(pluginList); - lib.registerPluginMessages(parameters.pluginInfos); - lib.registerPluginConfigGroups(parameters.pluginInfos); + const pluginInfos = await lib.loadPluginInfos(pluginList); + lib.registerPluginMessages(pluginInfos); + lib.registerPluginConfigGroups(pluginInfos); lib.finalizeConfigs(); - parameters.controllerConfigPath = parameters.args.config; - logger.info(`Loading config from ${parameters.controllerConfigPath}`); - parameters.controllerConfig = new lib.ControllerConfig("controller"); + const controllerConfigPath = args.config; + logger.info(`Loading config from ${controllerConfigPath}`); + const controllerConfig = new lib.ControllerConfig("controller"); try { - await parameters.controllerConfig.load(JSON.parse(await fs.readFile(parameters.controllerConfigPath))); + let fileData = await fs.readFile(controllerConfigPath, { encoding: 'utf8' }) + await controllerConfig.load(JSON.parse(fileData)); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { logger.info("Config not found, initializing new config"); - await parameters.controllerConfig.init(); + await controllerConfig.init(); } else { - throw new lib.StartupError(`Failed to load ${parameters.controllerConfigPath}: ${err.message}`); + throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`); } } - if (!parameters.controllerConfig.get("controller.auth_secret")) { + if (!controllerConfig.get("controller.auth_secret")) { logger.info("Generating new controller authentication secret"); let asyncRandomBytes = util.promisify(crypto.randomBytes); let bytes = await asyncRandomBytes(256); - parameters.controllerConfig.set("controller.auth_secret", bytes.toString("base64")); + controllerConfig.set("controller.auth_secret", bytes.toString("base64")); await lib.safeOutputFile( - parameters.controllerConfigPath, JSON.stringify(parameters.controllerConfig.serialize(), null, 4) + controllerConfigPath, JSON.stringify(controllerConfig.serialize(), null, 4) ); } if (command === "config") { await lib.handleConfigCommand( - parameters.args, parameters.controllerConfig, parameters.controllerConfigPath + args, controllerConfig, controllerConfigPath ); } else if (command === "bootstrap") { - await handleBootstrapCommand(parameters.args, parameters.controllerConfig); + await handleBootstrapCommand(args, controllerConfig); } - return parameters; + return { + args, + clusterLogger, + pluginInfos, + controllerConfigPath, + controllerConfig, + shouldRun, + } } async function startup() { @@ -331,8 +371,15 @@ async function startup() { // and as the process name in ps/top on linux. process.title = "clusteriocontroller"; - let { args, shouldRun, clusterLogger, pluginInfos, controllerConfigPath, controllerConfig } = await initialize(); - if (!shouldRun) { + let { + args, + shouldRun, + clusterLogger, + pluginInfos, + controllerConfigPath, + controllerConfig + } = await initialize(); + if (!shouldRun || !pluginInfos || !controllerConfigPath || !controllerConfig) { return; } diff --git a/packages/controller/package.json b/packages/controller/package.json index c2ebd05c..7789ffc4 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -4,13 +4,14 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "main": "controller.js", + "main": "controller.ts", "scripts": { - "prepare": "webpack-cli --env production", + "prepare": "webpack-cli --env production && pnpm build", + "build": "tsc --build", "test": "echo \"Error: no test specified\" && exit 1" }, "bin": { - "clusteriocontroller": "./controller.js" + "clusteriocontroller": "./dist/controller/controller.js" }, "keywords": [ "factorio" @@ -35,6 +36,15 @@ }, "devDependencies": { "@clusterio/web_ui": "^2.0.0-alpha.13", + "@types/busboy": "^1.5.0", + "@types/compression": "^1.7.2", + "@types/express": "^4.17.17", + "@types/fs-extra": "^11.0.1", + "@types/jsonwebtoken": "^9.0.2", + "@types/node": "^20.4.5", + "@types/set-blocking": "^2.0.0", + "@types/ws": "^8.5.5", + "@types/yargs": "^17.0.24", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.4.14", diff --git a/packages/controller/src/BaseConnection.js b/packages/controller/src/BaseConnection.ts similarity index 52% rename from packages/controller/src/BaseConnection.js rename to packages/controller/src/BaseConnection.ts index 73964d22..b12c7e3c 100644 --- a/packages/controller/src/BaseConnection.js +++ b/packages/controller/src/BaseConnection.ts @@ -1,7 +1,9 @@ -"use strict"; -const lib = require("@clusterio/lib"); +import type Controller from "./Controller"; +import type WsServerConnector from "./WsServerConnector"; + +import * as lib from "@clusterio/lib"; const { logger } = lib; -const ControllerRouter = require("./ControllerRouter"); +import ControllerRouter from "./ControllerRouter"; /** @@ -10,25 +12,28 @@ const ControllerRouter = require("./ControllerRouter"); * @extends module:lib.Link * @alias module:controller/src/BaseConnection */ -class BaseConnection extends lib.Link { - constructor(connector, controller) { - /** @member {module:controller/src/WsServerConnector} module:controller/src/BaseConnection#connector */ +export default class BaseConnection extends lib.Link { + declare connector: lib.WebSocketBaseConnector; + + constructor( + connector: lib.WebSocketBaseConnector, + public _controller: Controller + ) { super(connector); - this.router = new ControllerRouter(controller); - this._controller = controller; - for (let [Request, handler] of controller._registeredRequests) { this.handle(Request, handler); } - for (let [Request, handler] of controller._fallbackedRequests) { this.fallbackRequest(Request, handler); } - for (let [Event, handler] of controller._registeredEvents) { this.handle(Event, handler); } - for (let [Event, handler] of controller._snoopedEvents) { this.snoopEvent(Event, handler); } + this.router = new ControllerRouter(this._controller); + for (let [Request, handler] of this._controller._registeredRequests) { this.handle(Request, handler); } + for (let [Request, handler] of this._controller._fallbackedRequests) { this.fallbackRequest(Request, handler); } + for (let [Event, handler] of this._controller._registeredEvents) { this.handle(Event, handler); } + for (let [Event, handler] of this._controller._snoopedEvents) { this.snoopEvent(Event, handler); } this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this)); this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this)); } - async disconnect(code, reason) { + async disconnect(code: number, reason: string) { try { await this.connector.disconnect(); - } catch (err) { + } catch (err: any) { if (!(err instanceof lib.SessionLost)) { logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`); } @@ -38,13 +43,12 @@ class BaseConnection extends lib.Link { /** * True if the link is connected, not in the dropped state and not in * the process of disconnecting. - * @type {boolean} */ - get connected() { + get connected(): boolean { return this.connector.connected; } - async handleModPackGetRequest(request) { + async handleModPackGetRequest(request: lib.ModPackGetRequest): Promise { let { id } = request; let modPack = this._controller.modPacks.get(id); if (!modPack) { @@ -53,7 +57,7 @@ class BaseConnection extends lib.Link { return modPack; } - async handleModPackGetDefaultRequest() { + async handleModPackGetDefaultRequest(): Promise { let id = this._controller.config.get("controller.default_mod_pack_id"); if (id === null) { throw new lib.RequestError("Default mod pack not set on controller"); @@ -65,5 +69,3 @@ class BaseConnection extends lib.Link { return modPack; } } - -module.exports = BaseConnection; diff --git a/packages/controller/src/ControlConnection.js b/packages/controller/src/ControlConnection.ts similarity index 74% rename from packages/controller/src/ControlConnection.js rename to packages/controller/src/ControlConnection.ts index ee446c8f..e67e04b7 100644 --- a/packages/controller/src/ControlConnection.js +++ b/packages/controller/src/ControlConnection.ts @@ -1,15 +1,20 @@ -"use strict"; -const events = require("events"); -const fs = require("fs-extra"); -const path = require("path"); +import type WsServerConnector from "./WsServerConnector"; +import type { HostInfo } from "./HostConnection"; -const lib = require("@clusterio/lib"); +import events from "events"; +import fs from "fs-extra"; +import path from "path"; + +import * as lib from "@clusterio/lib"; const { logFilter, logger } = lib; -const BaseConnection = require("./BaseConnection"); -const routes = require("./routes"); +import BaseConnection from "./BaseConnection"; +import routes from "./routes"; +import Controller from "./Controller"; +import { promises } from "dns"; +import InstanceInfo from "./InstanceInfo"; -const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; const queryLogTime = new lib.Summary( "clusterio_controller_query_log_duration_seconds", @@ -22,66 +27,49 @@ const queryLogTime = new lib.Summary( * @extends module:controller/src/BaseConnection * @alias module:controller/src/ControlConnection */ -class ControlConnection extends BaseConnection { - constructor(registerData, connector, controller, user, id) { +export default class ControlConnection extends BaseConnection { + private _agent: string; + private _version: string; + hostSubscriptions: { all:boolean, hostIds:number[] } = { all:false, hostIds:[] }; + instanceSubscriptions: { all:boolean, instanceIds:number[] } = { all:false, instanceIds:[] }; + saveListSubscriptions: { all:boolean, instanceIds:number[] } = { all:false, instanceIds:[] }; + modPackSubscriptions: { all:boolean, modPackIds:number[] } = { all:false, modPackIds:[] }; + modSubscriptions: { all:boolean, modNames:string[] } = { all:false, modNames:[] }; + userSubscriptions: { all:boolean, names:string[]} = { all:false, names:[] }; + logTransport: lib.LinkTransport | null = null; + logSubscriptions: { + all: boolean, + controller: boolean, + hostIds: number[], + instanceIds: number[], + } = { + all: false, + controller: false, + hostIds: [], + instanceIds: [], + }; + ws_dumper: ((...args: any[]) => void) | null = null; + declare connector: WsServerConnector; + + constructor( + registerData: { agent:string, version:string }, + connector: WsServerConnector, + controller: Controller, + public user: lib.User, // The user making this connection. + public id: number + ) { super(connector, controller); this._agent = registerData.agent; this._version = registerData.version; - this.id = id; - /** - * The user making this connection. - * @type {module:lib.User} - */ - this.user = user; - - this.hostSubscriptions = { - all: false, - hostIds: [], - }; - - this.instanceSubscriptions = { - all: false, - instanceIds: [], - }; - - this.saveListSubscriptions = { - all: false, - instanceIds: [], - }; - - this.modPackSubscriptions = { - all: false, - modPackIds: [], - }; - - this.modSubscriptions = { - all: false, - modNames: [], - }; - - this.userSubscriptions = { - all: false, - names: [], - }; - - this.logTransport = null; - this.logSubscriptions = { - all: false, - controller: false, - hostIds: [], - instanceIds: [], - }; - - this.ws_dumper = null; this.connector.on("connect", () => { this.connector._socket.clusterio_ignore_dump = Boolean(this.ws_dumper); }); this.connector.on("close", () => { if (this.logTransport) { - this.logTransport = null; logger.remove(this.logTransport); + this.logTransport = null; } if (this.ws_dumper) { this._controller.debugEvents.off("message", this.ws_dumper); @@ -91,7 +79,10 @@ class ControlConnection extends BaseConnection { for (let event of ["connect", "drop", "resume", "close"]) { this.connector.on(event, () => { for (let controllerPlugin of this._controller.plugins.values()) { - controllerPlugin.onControlConnectionEvent(this, event); + controllerPlugin.onControlConnectionEvent( + this, + event as "connect" | "drop" | "resume" | "close" + ); } }); } @@ -105,26 +96,17 @@ class ControlConnection extends BaseConnection { this.handle(lib.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this)); this.handle(lib.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this)); this.handle(lib.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this)); - this.handle( - lib.InstanceDetailsSetSubscriptionsRequest, this.handleInstanceDetailsSetSubscriptionsRequest.bind(this) - ); + this.handle(lib.InstanceDetailsSetSubscriptionsRequest, this.handleInstanceDetailsSetSubscriptionsRequest.bind(this)); this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this)); this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this)); this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this)); this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this)); this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this)); this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this)); - this.handle( - lib.InstanceSetSaveListSubscriptionsRequest, - this.handleInstanceSetSaveListSubscriptionsRequest.bind(this) - ); - this.handle( - lib.InstanceRenameSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller) - ); + this.handle(lib.InstanceSetSaveListSubscriptionsRequest, this.handleInstanceSetSaveListSubscriptionsRequest.bind(this)); + this.handle(lib.InstanceRenameSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller)); this.handle(lib.InstanceCopySaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller)); - this.handle( - lib.InstanceDeleteSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller) - ); + this.handle(lib.InstanceDeleteSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller)); this.handle(lib.InstanceDownloadSaveRequest, this.handleInstanceDownloadSaveRequest.bind(this)); this.handle(lib.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this)); this.handle(lib.ModPackListRequest, this.handleModPackListRequest.bind(this)); @@ -144,9 +126,7 @@ class ControlConnection extends BaseConnection { this.handle(lib.RoleListRequest, this.handleRoleListRequest.bind(this)); this.handle(lib.RoleCreateRequest, this.handleRoleCreateRequest.bind(this)); this.handle(lib.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this)); - this.handle( - lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this) - ); + this.handle(lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this)); this.handle(lib.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this)); this.handle(lib.UserGetRequest, this.handleUserGetRequest.bind(this)); this.handle(lib.UserListRequest, this.handleUserListRequest.bind(this)); @@ -161,29 +141,35 @@ class ControlConnection extends BaseConnection { this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); } - validateIngress(message) { + validateIngress(message: lib.MessageRequest | lib.MessageEvent) { let origin = this.connector.dst; if (origin.type !== message.src.type || origin.id !== message.src.id) { throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`); } } - validatePermission(message, entry) { + validatePermission( + message: lib.MessageRequest | lib.MessageEvent, + entry: lib.RequestEntry | lib.EventEntry + ) { try { this.checkPermission(message, entry); - } catch (err) { + } catch (err: any) { this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src); logger.audit(`Permission denied for ${message.name} by ${this.user.name} from ${this.connector.dst}`); throw err; } } - checkPermission(message, entry) { + checkPermission( + message: lib.MessageRequest | lib.MessageEvent, + entry: lib.RequestEntry | lib.EventEntry + ) { let permission; if (message.type === "request") { - permission = entry.Request.permission; + permission = (entry as lib.RequestEntry).Request.permission; } else if (message.type === "event") { - permission = entry.Event.permission; + permission = (entry as lib.EventEntry).Event.permission; } else { return; } @@ -205,20 +191,20 @@ class ControlConnection extends BaseConnection { throw new Error("Should be unreachable"); } - async handleControllerConfigGetRequest() { + async handleControllerConfigGetRequest(): Promise { return new lib.RawConfig(this._controller.config.serialize("control")); } - async handleControllerConfigSetFieldRequest(request) { + async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) { this._controller.config.set(request.field, request.value, "control"); } - async handleControllerConfigSetPropRequest(request) { + async handleControllerConfigSetPropRequest(request: lib.ControllerConfigSetPropRequest) { let { field, prop, value } = request; this._controller.config.setProp(field, prop, value, "control"); } - async handleHostListRequest() { + async handleHostListRequest(): Promise { let list = []; for (let host of this._controller.hosts.values()) { list.push(new lib.HostDetails( @@ -233,11 +219,14 @@ class ControlConnection extends BaseConnection { return list; } - async handleHostSetSubscriptionsRequest(request) { + async handleHostSetSubscriptionsRequest(request: lib.HostSetSubscriptionsRequest) { this.hostSubscriptions = { ...request }; } - hostUpdated(host, update) { + hostUpdated( + host: HostInfo, + update: lib.HostDetails + ) { if ( this.hostSubscriptions.all || this.hostSubscriptions.hostIds.includes(host.id) @@ -246,15 +235,15 @@ class ControlConnection extends BaseConnection { } } - async handleHostGenerateTokenRequest(message) { - let hostId = message.host_id; - if (hostId === undefined) { + async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) { + let hostId = message.hostId; + if (hostId === null) { hostId = Math.random() * 2**31 | 0; } return this._controller.generateHostToken(hostId); } - async handleHostConfigCreateRequest(request) { + async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) { let hostConfig = new lib.HostConfig("control"); await hostConfig.init(); @@ -272,14 +261,14 @@ class ControlConnection extends BaseConnection { return new lib.RawConfig(hostConfig.serialize()); } - async handleInstanceDetailsGetRequest(request) { + async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) { let instance = this._controller.getRequestInstance(request.instanceId); return new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, instance.config.get("instance.assigned_host"), - instance.game_port || undefined, + instance.config.get("factorio.game_port"), instance.status, ); } @@ -290,19 +279,19 @@ class ControlConnection extends BaseConnection { list.push(new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, - instance.config.get("instance.assigned_host") || undefined, - instance.game_port || undefined, + instance.config.get("instance.assigned_host"), + instance.config.get("factorio.game_port"), instance.status, )); } return list; } - async handleInstanceDetailsSetSubscriptionsRequest(request) { + async handleInstanceDetailsSetSubscriptionsRequest(request: lib.InstanceDetailsSetSubscriptionsRequest) { this.instanceSubscriptions = { ...request }; } - instanceUpdated(instance) { + instanceUpdated(instance: InstanceInfo) { if ( this.instanceSubscriptions.all || this.instanceSubscriptions.instanceIds.includes(instance.id) @@ -311,8 +300,8 @@ class ControlConnection extends BaseConnection { new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, - instance.config.get("instance.assigned_host") || undefined, - instance.game_port || undefined, + instance.config.get("instance.assigned_host"), + instance.config.get("factorio.game_port"), instance.status, ) )); @@ -320,22 +309,22 @@ class ControlConnection extends BaseConnection { } // XXX should probably add a hook for host reuqests? - async handleInstanceCreateRequest(request) { + async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) { let instanceConfig = new lib.InstanceConfig("controller"); - await instanceConfig.load(request.config); + await instanceConfig.load(request.config as lib.SerializedConfig); await this._controller.instanceCreate(instanceConfig); } - async handleInstanceDeleteRequest(request) { + async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) { await this._controller.instanceDelete(request.instanceId); } - async handleInstanceConfigGetRequest(request) { + async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) { let instance = this._controller.getRequestInstance(request.instanceId); return new lib.InstanceConfigGetRequest.Response(instance.config.serialize("control")); } - async handleInstanceConfigSetFieldRequest(request) { + async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) { let instance = this._controller.getRequestInstance(request.instanceId); if (request.field === "instance.assigned_host") { throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface"); @@ -350,22 +339,22 @@ class ControlConnection extends BaseConnection { await this._controller.instanceConfigUpdated(instance); } - async handleInstanceConfigSetPropRequest(request) { + async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) { 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 handleInstanceAssignRequest(request) { - await this._controller.instanceAssign(request.instanceId, request.hostId); + async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) { + await this._controller.instanceAssign(request.instanceId, request.hostId || null); } - async handleInstanceSetSaveListSubscriptionsRequest(request) { + async handleInstanceSetSaveListSubscriptionsRequest(request: lib.InstanceSetSaveListSubscriptionsRequest) { this.saveListSubscriptions = { ...request }; } - saveListUpdate(instanceId, saves) { + saveListUpdate(instanceId: number, saves: lib.SaveDetails[]) { if ( this.saveListSubscriptions.all || this.saveListSubscriptions.instanceIds.includes(instanceId) @@ -374,7 +363,7 @@ class ControlConnection extends BaseConnection { } } - async handleInstanceDownloadSaveRequest(request) { + async handleInstanceDownloadSaveRequest(request: lib.InstanceDownloadSaveRequest) { let { instanceId, name } = request; let stream = await routes.createProxyStream(this._controller.app); stream.filename = name; @@ -397,7 +386,7 @@ class ControlConnection extends BaseConnection { return stream.id; } - async handleInstanceTransferSaveRequest(request) { + async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) { if (request.sourceInstanceId === request.targetInstanceId) { throw new lib.RequestError("Source and target instance may not be the same"); } @@ -471,29 +460,29 @@ class ControlConnection extends BaseConnection { return [...this._controller.modPacks.values()]; } - async handleModPackSetSubscriptionsRequest(request) { + async handleModPackSetSubscriptionsRequest(request: lib.ModPackSetSubscriptionsRequest) { this.modPackSubscriptions = { ...request }; } - async handleModPackCreateRequest(request) { + async handleModPackCreateRequest(request: lib.ModPackCreateRequest) { let modPack = request.modPack; - if (this._controller.modPacks.has(modPack.id)) { + if (!modPack.id || this._controller.modPacks.has(modPack.id)) { throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`); } this._controller.modPacks.set(modPack.id, modPack); this._controller.modPackUpdated(modPack); } - async handleModPackUpdateRequest(request) { + async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) { let modPack = request.modPack; - if (!this._controller.modPacks.has(modPack.id)) { + if (!modPack.id || !this._controller.modPacks.has(modPack.id)) { throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`); } this._controller.modPacks.set(modPack.id, modPack); this._controller.modPackUpdated(modPack); } - async handleModPackDeleteRequest(request) { + async handleModPackDeleteRequest(request: lib.ModPackDeleteRequest) { let { id } = request; let modPack = this._controller.modPacks.get(id); if (!modPack) { @@ -504,16 +493,16 @@ class ControlConnection extends BaseConnection { this._controller.modPackUpdated(modPack); } - modPackUpdated(modPack) { + modPackUpdated(modPack: lib.ModPack) { if ( this.modPackSubscriptions.all - || this.modPackSubscriptions.modPackIds.includes(modPack.id) + || this.modPackSubscriptions.modPackIds.includes(modPack.id||-1) ) { this.send(new lib.ModPackUpdateEvent(modPack)); } } - async handleModGetRequest(request) { + async handleModGetRequest(request: lib.ModGetRequest) { let { name, version } = request; let filename = `${name}_${version}.zip`; let mod = this._controller.mods.get(filename); @@ -527,7 +516,7 @@ class ControlConnection extends BaseConnection { return [...this._controller.mods.values()]; } - static termsMatchesMod(terms, mod) { + static termsMatchesMod(terms: lib.ParsedTerm[], mod: lib.ModInfo) { for (let term of terms) { if (term.type === "word") { if (!lib.wordMatches(term, @@ -537,7 +526,7 @@ class ControlConnection extends BaseConnection { return false; } } else if (term.type === "attribute") { - if (!lib.wordMatches(term.value, mod[term.name])) { + if (!lib.wordMatches(term.value, mod[term.name as keyof lib.ModInfo] as string)) { return false; } } @@ -545,7 +534,7 @@ class ControlConnection extends BaseConnection { return true; } - async handleModSearchRequest(request) { + async handleModSearchRequest(request: lib.ModSearchRequest) { let query = lib.parseSearchString(request.query, { name: "word", // version @@ -562,7 +551,8 @@ class ControlConnection extends BaseConnection { }); let factorioVersion = request.factorioVersion; - let results = new Map(); + type ModVersions = { name: string, versions: lib.ModInfo[] }; + let results: Map = new Map(); for (let mod of this._controller.mods.values()) { if ( mod.factorioVersion !== factorioVersion @@ -589,14 +579,14 @@ class ControlConnection extends BaseConnection { const sort = request.sort; if (sort) { const sorters = { - name: (a, b) => strcmp(a.versions[0].name, b.versions[0].name), - title: (a, b) => strcmp(a.versions[0].title, b.versions[0].title), - author: (a, b) => strcmp(a.versions[0].author, b.versions[0].author), + name: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].name, b.versions[0].name), + title: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].title, b.versions[0].title), + author: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].author, b.versions[0].author), }; if (!Object.prototype.hasOwnProperty.call(sorters, sort)) { throw new lib.RequestError(`Invalid value for sort: ${sort}`); } - resultList.sort(sorters[sort]); + resultList.sort(sorters[sort as keyof typeof sorters]); let order = request.sortOrder; if (order === "desc") { resultList.reverse(); @@ -615,11 +605,11 @@ class ControlConnection extends BaseConnection { }; } - async handleModSetSubscriptionsRequest(request) { + async handleModSetSubscriptionsRequest(request: lib.ModSetSubscriptionsRequest) { this.modSubscriptions = { ...request }; } - async handleModDownloadRequest(request) { + async handleModDownloadRequest(request: lib.ModDownloadRequest) { let { name, version } = request; let filename = `${name}_${version}.zip`; let mod = this._controller.mods.get(filename); @@ -637,16 +627,21 @@ class ControlConnection extends BaseConnection { return stream.id; } - async handleModDeleteRequest(request) { + async handleModDeleteRequest(request: lib.ModDeleteRequest) { await this._controller.deleteMod(request.name, request.version); } - async handleLogSetSubscriptionsRequest(request) { - this.logSubscriptions = { ...request }; + async handleLogSetSubscriptionsRequest(request: lib.LogSetSubscriptionsRequest) { + this.logSubscriptions = { + all: request.all || false, + controller: request.controller || false, + hostIds: request.hostIds || [], + instanceIds: request.instanceIds || [], + }; this.updateLogSubscriptions(); } - modUpdated(mod) { + modUpdated(mod: lib.ModInfo) { if ( this.modSubscriptions.all || this.modSubscriptions.modNames.includes(mod.name) @@ -670,15 +665,25 @@ class ControlConnection extends BaseConnection { } } - async handleLogQueryRequest(request) { + async handleLogQueryRequest(request: lib.LogQueryRequest) { + let _request = { + limit: request.limit, + order: request.order, + maxLevel: request.maxLevel || "info", + all: request.all, + controller: request.controller, + hostIds: request.hostIds, + instanceIds: request.instanceIds, + } + let observeDuration = queryLogTime.startTimer(); let { all, controller, hostIds, instanceIds } = request; let log; if (!all && controller && !hostIds.length && !instanceIds.length) { - log = await this._controller.queryControllerLog(request); + log = await this._controller.queryControllerLog(_request); } else { - log = await this._controller.queryClusterLog(request); + log = await this._controller.queryClusterLog(_request); } observeDuration(); @@ -710,7 +715,7 @@ class ControlConnection extends BaseConnection { return list; } - async handleRoleCreateRequest(request) { + async handleRoleCreateRequest(request: lib.RoleCreateRequest) { let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]); // Start at 5 to leave space for future default roles @@ -719,7 +724,7 @@ class ControlConnection extends BaseConnection { return id; } - async handleRoleUpdateRequest(request) { + async handleRoleUpdateRequest(request: lib.RoleUpdateRequest) { let { id, name, description, permissions } = request; let role = this._controller.userManager.roles.get(id); if (!role) { @@ -732,7 +737,7 @@ class ControlConnection extends BaseConnection { this._controller.rolePermissionsUpdated(role); } - async handleRoleGrantDefaultPermissionsRequest(request) { + async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) { let role = this._controller.userManager.roles.get(request.id); if (!role) { throw new lib.RequestError(`Role with ID ${request.id} does not exist`); @@ -742,7 +747,7 @@ class ControlConnection extends BaseConnection { this._controller.rolePermissionsUpdated(role); } - async handleRoleDeleteRequest(request) { + async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) { let id = request.id; let role = this._controller.userManager.roles.get(id); if (!role) { @@ -756,7 +761,7 @@ class ControlConnection extends BaseConnection { } } - async handleUserGetRequest(request) { + async handleUserGetRequest(request: lib.UserGetRequest) { let name = request.name; let user = this._controller.userManager.users.get(name); if (!user) { @@ -773,7 +778,7 @@ class ControlConnection extends BaseConnection { user.banReason, undefined, user.playerStats, - [...user.instanceStats], + user.instanceStats, ); } @@ -796,11 +801,11 @@ class ControlConnection extends BaseConnection { return list; } - async handleUserSetSubscriptionsRequest(request) { + async handleUserSetSubscriptionsRequest(request: lib.UserSetSubscriptionsRequest) { this.userSubscriptions = { ...request }; } - userUpdated(user) { + userUpdated(user: any) { if ( this.userSubscriptions.all || this.userSubscriptions.names.includes(user.name) @@ -816,18 +821,18 @@ class ControlConnection extends BaseConnection { user.banReason, user.isDeleted, user.playerStats, - [...user.instanceStats], + user.instanceStats, ) )); } } - async handleUserCreateRequest(request) { + async handleUserCreateRequest(request: lib.UserCreateRequest) { let user = this._controller.userManager.createUser(request.name); this._controller.userUpdated(user); } - async handleUserRevokeTokenRequest(request) { + async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) { let user = this._controller.userManager.users.get(request.name); if (!user) { throw new lib.RequestError(`User '${request.name}' does not exist`); @@ -845,13 +850,13 @@ class ControlConnection extends BaseConnection { this._controller.userUpdated(user); } - async handleUserUpdateRolesRequest(request) { + async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) { let user = this._controller.userManager.users.get(request.name); if (!user) { throw new lib.RequestError(`User '${request.name}' does not exist`); } - let resolvedRoles = new Set(); + let resolvedRoles: Set = new Set(); for (let roleId of request.roles) { let role = this._controller.userManager.roles.get(roleId); if (!role) { @@ -866,7 +871,7 @@ class ControlConnection extends BaseConnection { this._controller.userUpdated(user); } - async handleUserSetAdminRequest(request) { + async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) { let { name, create, admin } = request; let user = this._controller.userManager.users.get(name); if (!user) { @@ -883,7 +888,7 @@ class ControlConnection extends BaseConnection { this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin)); } - async handleUserSetBannedRequest(request) { + async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) { let { name, create, banned, reason } = request; let user = this._controller.userManager.users.get(name); if (!user) { @@ -901,7 +906,7 @@ class ControlConnection extends BaseConnection { this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason)); } - async handleUserSetWhitelistedRequest(request) { + async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) { let { name, create, whitelisted } = request; let user = this._controller.userManager.users.get(name); if (!user) { @@ -918,7 +923,7 @@ class ControlConnection extends BaseConnection { this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted)); } - async handleUserDeleteRequest(request) { + async handleUserDeleteRequest(request: lib.UserDeleteRequest) { let name = request.name; let user = this._controller.userManager.users.get(name); if (!user) { @@ -929,18 +934,18 @@ class ControlConnection extends BaseConnection { this._controller.userManager.users.delete(name); this._controller.userUpdated(user); - if (user.is_admin) { + if (user.isAdmin) { this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false)); } - if (user.is_whitelisted) { + if (user.isWhitelisted) { this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, false)); } - if (user.is_banned) { + if (user.isBanned) { this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, false, "")); } } - async handleDebugDumpWsRequest(request) { + async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) { this.ws_dumper = data => { if (this.connector.connected) { this.send(new lib.DebugWsMessageEvent(data.direction, data.content)); diff --git a/packages/controller/src/Controller.js b/packages/controller/src/Controller.ts similarity index 74% rename from packages/controller/src/Controller.js rename to packages/controller/src/Controller.ts index 6c84c135..8592ba57 100644 --- a/packages/controller/src/Controller.js +++ b/packages/controller/src/Controller.ts @@ -1,32 +1,39 @@ -"use strict"; -const compression = require("compression"); -const events = require("events"); -const express = require("express"); -const fs = require("fs-extra"); -const http = require("http"); -const https = require("https"); -const jwt = require("jsonwebtoken"); -const path = require("path"); -const stream = require("stream"); - -const lib = require("@clusterio/lib"); -const { logger } = lib; - -const HttpCloser = require("./HttpCloser"); -const InstanceInfo = require("./InstanceInfo"); -const metrics = require("./metrics"); -const routes = require("./routes"); -const UserManager = require("./UserManager"); -const WsServer = require("./WsServer"); +import type winston from "winston"; +import type { AddressInfo } from "net"; +import type { ControllerArgs } from "../controller"; +import type { HostInfo } from "./HostConnection"; +import type { Request, Response, Application } from "express"; -const endpointDurationSummary = new lib.Summary( +import compression from "compression"; +import events, { EventEmitter } from "events"; +import express from "express"; +import fs from "fs-extra"; +import http from "http"; +import https from "https"; +import jwt from "jsonwebtoken"; +import path from "path"; +import stream from "stream"; + +import * as lib from "@clusterio/lib"; +const { logger, Summary, Gauge } = lib; + +import HttpCloser from "./HttpCloser"; +import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; +import metrics from "./metrics"; +import routes from "./routes"; +import UserManager from "./UserManager"; +import WsServer from "./WsServer"; +import yargs from "yargs"; +import HostConnection from "./HostConnection"; + +const endpointDurationSummary = new Summary( "clusterio_controller_http_endpoint_duration_seconds", "Time it took to respond to a an HTTP request", { labels: ["route"] } ); -const logSizeGauge = new lib.Gauge( +const logSizeGauge = new Gauge( "clusterio_controller_log_bytes", "Size of all log files currently stored on the controller." ); @@ -35,86 +42,73 @@ const logSizeGauge = new lib.Gauge( * Manages all controller related operations * @alias module:controller/src/Controller */ -class Controller { - constructor(clusterLogger, pluginInfos, configPath, config) { +export default class Controller { + clusterLogger: winston.Logger; + /** Array of plugin info objects for known plugins */ + pluginInfos: lib.PluginInfo[]; + configPath: string; + /** Controller config. */ + config: lib.ControllerConfig; + app: Application; + + /** Mapping of host id to host info */ + hosts: Map = new Map(); + /** Mapping of instance id to instance info */ + instances: Map = new Map(); + /** Mapping of mod pack id to mod pack */ + modPacks: Map = new Map(); + /** Mapping of mod names to mod infos */ + mods: Map = new Map(); + /** User and roles manager for the cluster */ + userManager: UserManager; + + httpServer: http.Server | null = null; + httpServerCloser: HttpCloser | null = null; + httpsServer: https.Server | null = null; + httpsServerCloser: HttpCloser | null = null; + + /** Mapping of plugin name to loaded plugin */ + plugins: Map = new Map(); + + /** WebSocket server */ + wsServer: WsServer; + debugEvents: events.EventEmitter = new events.EventEmitter(); + private _events: events.EventEmitter = new events.EventEmitter(); + + // Possible states are new, starting, running, stopping, stopped + private _state: string = "new"; + private _shouldStop: boolean = false; + _fallbackedRequests: Map, lib.RequestHandler> = new Map(); + _registeredRequests: Map, lib.RequestHandler> = new Map(); + _registeredEvents = new Map(); + _snoopedEvents = new Map(); + + devMiddleware: any | null = null; + + logDirectory: string = ""; + clusterLogIndex: lib.LogIndex | null = null; + clusterLogBuildInterval: NodeJS.Timer | null = null; + + constructor( + clusterLogger: winston.Logger, + pluginInfos: any[], + configPath: string, + config: lib.ControllerConfig + ) { this.clusterLogger = clusterLogger; - /** - * Array of plugin info objects for known plugins - * @type {Array} - */ this.pluginInfos = pluginInfos; this.configPath = configPath; - /** - * Controller config. - * @type {module:lib.ControllerConfig} - */ this.config = config; this.app = express(); this.app.locals.controller = this; this.app.locals.streams = new Map(); - /** - * Mapping of host id to host info - * @type {Map} - */ - this.hosts = null; - - /** - * Mapping of instance id to instance info - * @type {Map} - */ - this.instances = null; - - /** - * Mapping of mod pack id to mod pack - * @type {Map} - */ - this.modPacks = null; - - /** - * Mapping of mod names to mod infos - * @type {Map} - */ - this.mods = null; - - /** - * User and roles manager for the cluster - * @type {module:controller/src/UserManager} - */ - this.userManager = null; - this.httpServer = null; - this.httpServerCloser = null; - this.httpsServer = null; - this.httpsServerCloser = null; - - /** - * Mapping of plugin name to loaded plugin - * @type {Map} - */ - this.plugins = new Map(); - - /** - * WebSocket server - * @type {module:controller/src/WsServer} - */ this.wsServer = new WsServer(this); - - this.debugEvents = new events.EventEmitter(); - this._events = new events.EventEmitter(); - // Possible states are new, starting, running, stopping, stopped - 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; + this.userManager = new UserManager(this.config); } - async start(args) { + async start(args: ControllerArgs) { if (this._state !== "new") { throw new Error(`Cannot start in state ${this._state}`); } @@ -148,13 +142,16 @@ class Controller { ])).reduce((a, v) => a + v, 0); } - async _startInternal(args) { + async _startInternal(args: ControllerArgs) { this.logDirectory = args.logDirectory; this.clusterLogIndex = await lib.LogIndex.load(path.join(this.logDirectory, "cluster")); + this.clusterLogBuildInterval = setInterval(() => { - this.clusterLogIndex.buildIndex().catch( - err => logger.error(`Error building cluster log index:\n${err.stack}`) - ); + if (this.clusterLogIndex) { + this.clusterLogIndex.buildIndex().catch( + err => logger.error(`Error building cluster log index:\n${err.stack}`) + ); + } }, 600e3); logSizeGauge.callback = async () => { logSizeGauge.set(await this.logSize()); }; @@ -165,6 +162,7 @@ class Controller { const webpack = require("webpack"); const webpackDevMiddleware = require("webpack-dev-middleware"); const webpackConfigs = []; + if (args.dev) { webpackConfigs.push(require("../webpack.config")({})); } @@ -191,10 +189,9 @@ class Controller { let databaseDirectory = this.config.get("controller.database_directory"); await fs.ensureDir(databaseDirectory); - this.hosts = await Controller.loadHosts(path.join(databaseDirectory, "hosts.json")); - this.instances = await Controller.loadInstances(path.join(databaseDirectory, "instances.json")); - this.modPacks = await Controller.loadModPacks(path.join(databaseDirectory, "mod-packs.json")); - this.userManager = new UserManager(this.config); + await Controller.loadHosts(path.join(databaseDirectory, "hosts.json"), this.hosts); + await Controller.loadInstances(path.join(databaseDirectory, "instances.json"), this.instances); + await Controller.loadModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks); await this.userManager.load(path.join(databaseDirectory, "users.json")); let modsDirectory = this.config.get("controller.mods_directory"); @@ -230,7 +227,9 @@ class Controller { Controller.addAppRoutes(this.app, this.pluginInfos); if (!args.dev) { - let manifest = await Controller.loadJsonObject(path.join(__dirname, "..", "dist", "web", "manifest.json")); + let manifestPath = path.join(__dirname, "..", "..", "web", "manifest.json") + + let manifest = await Controller.loadJsonObject(manifestPath); if (!manifest["main.js"]) { logger.error("Missing main.js entry in dist/web/manifest.json"); } @@ -247,16 +246,16 @@ class Controller { this.httpServer = http.createServer(this.app); this.httpServerCloser = new HttpCloser(this.httpServer); await this.listen(this.httpServer, httpPort, bindAddress); - logger.info(`Listening for HTTP on port ${this.httpServer.address().port}`); + logger.info(`Listening for HTTP on port ${(this.httpServer.address() as AddressInfo).port}`); } - if (httpsPort) { + if (httpsPort && tls_cert && tls_key) { let certificate, privateKey; try { certificate = await fs.readFile(tls_cert); privateKey = await fs.readFile(tls_key); - } catch (err) { + } catch (err: any) { throw new lib.StartupError( `Error loading ssl certificate: ${err.message}` ); @@ -268,7 +267,7 @@ class Controller { }, this.app); this.httpsServerCloser = new HttpCloser(this.httpsServer); await this.listen(this.httpsServer, httpsPort, bindAddress); - logger.info(`Listening for HTTPS on port ${this.httpsServer.address().port}`); + logger.info(`Listening for HTTPS on port ${(this.httpsServer.address() as AddressInfo).port}`); } logger.info("Started controller"); @@ -299,7 +298,11 @@ class Controller { // This function should never throw. this._state = "stopping"; logger.info("Stopping controller"); - clearInterval(this.clusterLogBuildInterval); + + if (this.clusterLogBuildInterval) { + clearInterval(this.clusterLogBuildInterval); + } + if (this.clusterLogIndex) { await this.clusterLogIndex.save(); } @@ -312,15 +315,15 @@ class Controller { } let databaseDirectory = this.config.get("controller.database_directory"); - if (this.hosts) { + if (this.hosts.size > 0) { await Controller.saveHosts(path.join(databaseDirectory, "hosts.json"), this.hosts); } - if (this.instances) { + if (this.instances.size > 0) { await Controller.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances); } - if (this.modPacks) { + if (this.modPacks.size > 0) { await Controller.saveModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks); } @@ -336,88 +339,99 @@ class Controller { let stopTasks = []; logger.info("Stopping HTTP(S) server"); - if (this.httpServer && this.httpServer.listening) { stopTasks.push(this.httpServerCloser.close()); } - if (this.httpsServer && this.httpsServer.listening) { stopTasks.push(this.httpsServerCloser.close()); } + if (this.httpServer && this.httpServer.listening && this.httpServerCloser) { + stopTasks.push(this.httpServerCloser.close()); + } + if (this.httpsServer && this.httpsServer.listening && this.httpsServerCloser) { + stopTasks.push(this.httpsServerCloser.close()); + } await Promise.all(stopTasks); logger.info("Goodbye"); } - static async loadHosts(filePath) { - let serialized; - try { - serialized = JSON.parse(await fs.readFile(filePath)); + static async loadHosts(filePath: string, hosts:Map) { + hosts.clear(); - } catch (err) { + try { + const serialized = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); + // TODO: Remove after release. + if (serialized.length && !(serialized[0] instanceof Array)) { + for (let [id, host] of serialized) { + hosts.set(id ,host) + } + } + + } catch (err: any) { if (err.code !== "ENOENT") { throw err; } - - return new Map(); + hosts.clear(); } - - // TODO: Remove after release. - if (serialized.length && !(serialized[0] instanceof Array)) { - return new Map(); // Discard old format. - } - - return new Map(serialized); } - static async saveHosts(filePath, hosts) { + static async saveHosts(filePath: string, hosts: Map) { await lib.safeOutputFile(filePath, JSON.stringify([...hosts.entries()], null, 4)); } - static async loadInstances(filePath) { + static async loadInstances(filePath: string, instances: Map) { logger.info(`Loading ${filePath}`); + instances.clear(); - let instances = new Map(); try { - let serialized = JSON.parse(await fs.readFile(filePath)); + const serialized = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); for (let serializedConfig of serialized) { let instanceConfig = new lib.InstanceConfig("controller"); await instanceConfig.load(serializedConfig); let status = instanceConfig.get("instance.assigned_host") === null ? "unassigned" : "unknown"; - let instance = new InstanceInfo({ config: instanceConfig, status }); + let instance = new InstanceInfo({ + config: instanceConfig, + status: status as InstanceStatus, + }); instances.set(instanceConfig.get("instance.id"), instance); } - } catch (err) { + } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } - - return instances; } - static async saveInstances(filePath, instances) { + static async saveInstances(filePath: string, instances: Map) { let serialized = []; - for (let instance of instances.values()) { + for (const instance of instances.values()) { serialized.push(instance.config.serialize()); } await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, 4)); } - static async loadModPacks(filePath) { - let json; + static async loadModPacks(filePath: string, modPacks: Map) { + modPacks.clear(); + try { - json = JSON.parse(await fs.readFile(filePath)); - } catch (err) { + const json = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); + + for (const modPackJson of json) { + modPacks.set( + modPackJson.id, + lib.ModPack.fromJSON(modPackJson) + ) + } + + } catch (err: any) { if (err.code !== "ENOENT") { throw err; } - return new Map(); } - return new Map(json.map(e => [e.id, lib.ModPack.fromJSON(e)])); } - static async saveModPacks(filePath, modPacks) { + static async saveModPacks(filePath: string, modPacks: Map) { await lib.safeOutputFile(filePath, JSON.stringify([...modPacks.values()], null, 4)); } - static async loadModInfos(modsDirectory) { + static async loadModInfos(modsDirectory: string) { let mods = new Map(); for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) { if (entry.isDirectory()) { @@ -433,7 +447,7 @@ class Controller { let modInfo; try { modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, entry.name)); - } catch (err) { + } catch (err: any) { logger.error(`Error loading mod ${entry.name}: ${err.message}`); continue; } @@ -451,11 +465,11 @@ class Controller { return mods; } - static async loadJsonObject(filePath, throwOnMissing = false) { + static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise { let manifest = {}; try { - manifest = JSON.parse(await fs.readFile(filePath)); - } catch (err) { + manifest = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); + } catch (err: any) { if (!throwOnMissing && err.code !== "ENOENT") { throw err; } @@ -463,7 +477,7 @@ class Controller { return manifest; } - async deleteMod(name, version) { + async deleteMod(name: string, version: string): Promise { let filename = `${name}_${version}.zip`; let mod = this.mods.get(filename); if (!mod) { @@ -480,32 +494,32 @@ class Controller { /** * Query controller log * - * @param {module:lib~QueryLogFilter} filter - + * @param filter - * Filter to limit entries with. Note that only the controller log can * be queried from this function. - * @returns {Promise>} log entries matching the filter + * @returns log entries matching the filter */ - async queryControllerLog(filter) { - return await lib.queryLog( - path.join(this.logDirectory, "controller"), filter, + async queryControllerLog(filter: lib.QueryLogFilter): Promise { + return lib.queryLog( + path.join(this.logDirectory, "controller"), filter, null, ); } /** * Query cluster log * - * @param {module:lib~QueryLogFilter} filter - + * @param filter - * Filter to limit entries with. - * @returns {Promise>} log entries matching the filter + * @returns log entries matching the filter */ - async queryClusterLog(filter) { + async queryClusterLog(filter: lib.QueryLogFilter): Promise { return await lib.queryLog( path.join(this.logDirectory, "cluster"), filter, this.clusterLogIndex, ); } - static addAppRoutes(app, pluginInfos) { - app.use((req, res, next) => { + static addAppRoutes(app: Application, pluginInfos: any[]) { + app.use((req: Request, res: Response, next) => { let start = process.hrtime.bigint(); stream.finished(res, () => { let routePath = "static"; @@ -521,7 +535,7 @@ class Controller { // Set folder to serve static content from (the website) const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 }; - app.use("/static", express.static(path.join(__dirname, "..", "dist", "web", "static"), staticOptions)); + app.use("/static", express.static(path.join(__dirname, "..", "..", "..", "dist", "web", "static"), staticOptions)); app.use("/static", express.static("static", staticOptions)); // Used for data export files // Add API routes @@ -545,17 +559,17 @@ class Controller { /** * Generate access token for host * - * @param {number} hostId - ID of host to generate a token for. - * @returns {string} access token for host + * @returns access token for host + * @param hostId - ID of host to generate a token for. */ - generateHostToken(hostId) { + generateHostToken(hostId: number): string { return jwt.sign( { aud: "host", host: hostId }, Buffer.from(this.config.get("controller.auth_secret"), "base64") ); } - hostUpdated(host) { + hostUpdated(host: HostInfo) { let update = new lib.HostDetails( host.agent, host.version, @@ -577,11 +591,10 @@ class Controller { /** * Get instance by ID for a request * - * @param {number} instanceId - ID of instance to get. - * @returns {object} instance + * @param instanceId - ID of instance to get. * @throws {module:lib.RequestError} if the instance does not exist. */ - getRequestInstance(instanceId) { + getRequestInstance(instanceId:number):InstanceInfo { let instance = this.instances.get(instanceId); if (!instance) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); @@ -602,11 +615,11 @@ class Controller { * let instance = await controller.instanceAssign(instanceConfig); * await controller.instanceAssign(instance.id, hostId); * - * @param {module:lib.InstanceConfig} instanceConfig - + * @param instanceConfig - * Config to base newly created instance on. - * @returns {module:controller/src/InstanceInfo} The created instance + * @returns The created instance */ - async instanceCreate(instanceConfig) { + async instanceCreate(instanceConfig: lib.InstanceConfig): Promise { let instanceId = instanceConfig.get("instance.id"); if (this.instances.has(instanceId)) { throw new lib.RequestError(`Instance with ID ${instanceId} already exists`); @@ -642,6 +655,7 @@ class Controller { this.instances.set(instanceId, instance); await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, null); this.addInstanceHooks(instance); + return instance; } /** @@ -654,14 +668,14 @@ class Controller { * Note: this will not transfer any files or saves stored on the host * the instance was previously assgined to the new one. * - * @param {number} instanceId - ID of Instance to assign. - * @param {number} hostId - ID of host to assign instance to. + * @param instanceId - ID of Instance to assign. + * @param hostId - ID of host to assign instance to. */ - async instanceAssign(instanceId, hostId) { + async instanceAssign(instanceId: number, hostId: number | null) { let instance = this.getRequestInstance(instanceId); // Check if target host is connected - let newHostConnection; + let newHostConnection: HostConnection | undefined; if (hostId !== null) { newHostConnection = this.wsServer.hostConnections.get(hostId); if (!newHostConnection) { @@ -676,7 +690,7 @@ class Controller { // Unassign from currently assigned host if it is connected. let currentAssignedHost = instance.config.get("instance.assigned_host"); - if (currentAssignedHost !== null && hostId !== currentAssignedHost) { + if (currentAssignedHost !== null && currentAssignedHost !== undefined && hostId !== currentAssignedHost) { let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost); if (oldHostConnection && !oldHostConnection.connector.closing) { await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId)); @@ -685,12 +699,12 @@ class Controller { // Assign to target instance.config.set("instance.assigned_host", hostId); - if (hostId !== null) { + if (hostId !== null && newHostConnection) { await newHostConnection.send( new lib.InstanceAssignInternalRequest(instanceId, instance.config.serialize("host")) ); } else { - instance.status = "unassigned"; + instance.status = "unassigned" this.instanceUpdated(instance); } } @@ -701,12 +715,12 @@ class Controller { * Permanently deletes the instance from its assigned host (if assigned) * and the controller. This action cannot be undone. * - * @param {number} instanceId - ID of instance to delete. + * @param instanceId - ID of instance to delete. */ - async instanceDelete(instanceId) { + async instanceDelete(instanceId: number) { let instance = this.getRequestInstance(instanceId); let hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null) { + if (hostId !== null && hostId !== undefined) { await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId)); } this.instances.delete(instanceId); @@ -723,12 +737,12 @@ class Controller { * Used to push config changes to the assigned host when the config of * an instance has changed. * - * @param {module:controller/src/InstanceInfo} instance - + * @param instance - * Instance to nodify the config updated. */ - async instanceConfigUpdated(instance) { + async instanceConfigUpdated(instance: InstanceInfo) { let hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null) { + if (hostId !== null && hostId !== undefined) { let connection = this.wsServer.hostConnections.get(hostId); if (connection) { await connection.send( @@ -738,8 +752,8 @@ class Controller { } } - addInstanceHooks(instance) { - instance.config.on("fieldChanged", (group, field, prev) => { + addInstanceHooks(instance: InstanceInfo) { + instance.config.on("fieldChanged", (group: lib.ConfigGroup, field: string, prev: any) => { if (group.name === "instance" && field === "name") { this.instanceUpdated(instance); } @@ -750,7 +764,7 @@ class Controller { this.instanceUpdated(instance); } - instanceUpdated(instance) { + instanceUpdated(instance: InstanceInfo) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; @@ -760,7 +774,7 @@ class Controller { } } - saveListUpdate(instanceId, saves) { + saveListUpdate(instanceId: number, saves: lib.SaveDetails[]) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; @@ -770,7 +784,7 @@ class Controller { } } - modPackUpdated(modPack) { + modPackUpdated(modPack: lib.ModPack) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; @@ -781,7 +795,7 @@ class Controller { lib.invokeHook(this.plugins, "onModPackUpdated", modPack); } - modUpdated(mod) { + modUpdated(mod: lib.ModInfo) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; @@ -793,7 +807,7 @@ class Controller { lib.invokeHook(this.plugins, "onModUpdated", mod); } - userUpdated(user) { + userUpdated(user: lib.User) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; @@ -806,9 +820,9 @@ class Controller { /** * Notify connected control clients under the given user that the * permissions for this user may have changed. - * @param {module:lib.User} user - User permisions updated for. + * @param user - User permisions updated for. */ - userPermissionsUpdated(user) { + userPermissionsUpdated(user: lib.User) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user === user) { controlConnection.send( @@ -825,9 +839,9 @@ class Controller { /** * Notify connected control clients with the given role that the * permissions may have changed. - * @param {module:lib.Role} role - Role permisions updated for. + * @param role - Role permisions updated for. */ - rolePermissionsUpdated(role) { + rolePermissionsUpdated(role: lib.Role) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user.roles.has(role)) { controlConnection.send( @@ -862,11 +876,12 @@ class Controller { ControllerPluginClass = await lib.loadControllerPluginClass(pluginInfo); } + //@ts-ignore //TODO::: metrics is lib.Counter but ControllerPluginClass expect any[], can't figure out how the metrics is used. let controllerPlugin = new ControllerPluginClass(pluginInfo, this, metrics, logger); await controllerPlugin.init(); this.plugins.set(pluginInfo.name, controllerPlugin); - } catch (err) { + } catch (err: any) { throw new lib.PluginError(pluginInfo.name, err); } @@ -883,14 +898,14 @@ class Controller { * @param {*} args - Arguments to the .listen() call on the server. * @returns {Promise} promise that resolves the server is listening. */ - listen(server, ...args) { + listen(server: http.Server|https.Server, ...args: any[]): Promise { return new Promise((resolve, reject) => { server.on("upgrade", (req, socket, head) => { logger.verbose("handling WebSocket upgrade"); this.wsServer.handleUpgrade(req, socket, head); }); - function wrapError(err) { + function wrapError(err: any) { reject(new lib.StartupError( `Server listening failed: ${err.message}` )); @@ -908,13 +923,13 @@ class Controller { /** * Returns the URL needed to connect to the controller. * - * @returns {string} controller URL. + * @returns controller URL. */ - getControllerUrl() { + getControllerUrl(): string { return Controller.calculateControllerUrl(this.config); } - static calculateControllerUrl(config) { + static calculateControllerUrl(config: lib.ControllerConfig) { let url = config.get("controller.external_address"); if (!url) { if (config.get("controller.https_port")) { @@ -932,24 +947,25 @@ class Controller { * @param {string} route - route the interface is served under. * @returns {function} Experess.js route handler. */ - static serveWeb(route) { + static serveWeb(route: string) { // The depth is is the number of slashes in the route minus one, but due // to lenient matching on trailing slashes in the route we need to // compensate if the request path contains a slash but not the route, // and vice versa. - let routeDepth = (route.match(/\//g) || []).length - 1 - (route.slice(-1) === "/"); - return function(req, res, next) { - let depth = routeDepth + (req.path.slice(-1) === "/"); + let routeDepth = (route.match(/\//g) || []).length - 1 - Number(route.slice(-1) === "/"); + return function(req: Request, res: Response, next: Function) { + let depth = routeDepth + Number(req.path.slice(-1) === "/"); let webRoot = "../".repeat(depth) || "./"; let staticRoot = webRoot; - let mainBundle; + let mainBundle: string = ""; if (res.app.locals.mainBundle) { mainBundle = res.app.locals.mainBundle; } else { let stats = res.locals.webpack.devMiddleware.stats.stats[0]; mainBundle = stats.toJson().assetsByChunkName["main"]; } - fs.readFile(path.join(__dirname, "..", "web", "index.html"), "utf8").then((content) => { + + fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => { res.type("text/html"); res.send(content .replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name")) @@ -963,7 +979,7 @@ class Controller { }; } - handle(Class, handler) { + handle(Class: any, handler: any) { if (Class.type === "request") { this.handleRequest(Class, handler); } else if (Class.type === "event") { @@ -973,7 +989,7 @@ class Controller { } } - handleRequest(Request, handler) { + handleRequest(Request: any, handler: any) { if (!lib.Link._requestsByClass.has(Request)) { throw new Error(`Unregistered Request class ${Request.name}`); } @@ -983,7 +999,7 @@ class Controller { this._registeredRequests.set(Request, handler); } - fallbackRequest(Request, handler) { + fallbackRequest(Request: any, handler: any) { if (!lib.Link._requestsByClass.has(Request)) { throw new Error(`Unregistered Request class ${Request.name}`); } @@ -993,7 +1009,7 @@ class Controller { this._fallbackedRequests.set(Request, handler); } - handleEvent(Event, handler) { + handleEvent(Event: any, handler: any) { if (!lib.Link._eventsByClass.has(Event)) { throw new Error(`Unregistered Event class ${Event.name}`); } @@ -1003,7 +1019,7 @@ class Controller { this._registeredEvents.set(Event, handler); } - snoopEvent(Event, handler) { + snoopEvent(Event: any, handler: any) { if (!lib.Link._eventsByClass.has(Event)) { throw new Error(`Unregistered Event class ${Event.name}`); } @@ -1026,7 +1042,7 @@ class Controller { * Promise that resolves to the response if a request was sent or * undefined if it was an event. */ - sendTo(address, requestOrEvent) { + sendTo(address: lib.AddressShorthand, requestOrEvent: any) { let dst = lib.Address.fromShorthand(address); if (requestOrEvent.constructor.type === "request") { return this.sendRequest(requestOrEvent, dst); @@ -1037,7 +1053,7 @@ class Controller { throw Error(`Unknown type ${requestOrEvent.constructor.type}.`); } - async sendRequest(request, dst) { + async sendRequest(request: any, dst: lib.Address) { let connection; if (dst.type === lib.Address.controller) { throw new Error("controller as dst is not supported."); @@ -1072,7 +1088,7 @@ class Controller { return connection.sendRequest(request, dst); } - sendEvent(event, dst) { + sendEvent(event: lib.Event, dst: lib.Address) { let connection; if (dst.type === lib.Address.controller) { throw new Error("controller as dst is not supported."); @@ -1119,7 +1135,7 @@ class Controller { } } - sendToHostByInstanceId(requestOrEvent) { + async sendToHostByInstanceId(requestOrEvent: any) { if (requestOrEvent.constructor.type === "request") { return this.sendRequestToHostByInstanceId(requestOrEvent); } @@ -1129,7 +1145,7 @@ class Controller { throw Error(`Unknown type ${requestOrEvent.constructor.type}.`); } - async sendRequestToHostByInstanceId(request) { + async sendRequestToHostByInstanceId(request: any) { let instance = this.getRequestInstance(request.instanceId); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { @@ -1142,7 +1158,7 @@ class Controller { return await connection.send(request); } - sendEventToHostByInstanceId(event) { + sendEventToHostByInstanceId(event: any) { let instance = this.getRequestInstance(event.instanceId); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { @@ -1155,5 +1171,3 @@ class Controller { connection.send(event); } } - -module.exports = Controller; diff --git a/packages/controller/src/ControllerRouter.js b/packages/controller/src/ControllerRouter.ts similarity index 65% rename from packages/controller/src/ControllerRouter.js rename to packages/controller/src/ControllerRouter.ts index 1534bb2f..b94da0e6 100644 --- a/packages/controller/src/ControllerRouter.js +++ b/packages/controller/src/ControllerRouter.ts @@ -1,39 +1,44 @@ -"use strict"; -const lib = require("@clusterio/lib"); +import type Controller from "./Controller"; +import type BaseConnection from "./BaseConnection"; -class ControllerRouter { - /** @type {module:controller/src/Controller} */ - controller; +import * as lib from "@clusterio/lib"; +import HostConnection from "./HostConnection"; +import ControlConnection from "./ControlConnection"; +const { logger } = lib; - pendingRequests = new Map(); +export default class ControllerRouter { + pendingRequests: Map = new Map(); - constructor(controller) { - this.controller = controller; + constructor( + public controller: Controller + ) {} + + addHostConnection(connection: HostConnection) { + this.controller.wsServer.hostConnections.set(connection.id, connection); } - addHostConnection(connection) { - this.hostConnections.set(connection.id, connection); - } - - addControlConnection(connection) { - this.controlConnections.set(connection.id, connection); + addControlConnection(connection: ControlConnection) { + this.controller.wsServer.controlConnections.set(connection.id, connection); } /** - * @param {module:lib.Link} origin - Source link of the message. - * @param {module:lib.Message} message - * Link the message originated from. - * @param {boolean} hasFallback - true if fallback handling is available. - * @returns {boolean} true if the message was handled + * @param origin - Source link of the message. + * @param message - Link the message originated from. + * @param hasFallback - true if fallback handling is available. + * @returns true if the message was handled */ - forwardMessage(origin, message, hasFallback) { + forwardMessage( + origin: ControlConnection | HostConnection, + message: lib.MessageSrcDst, + hasFallback: boolean + ): boolean { 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; + let dst: lib.Address = message.dst; + let nextHop: ControlConnection | HostConnection | undefined; + let msg: string = ""; if (dst.type === lib.Address.broadcast) { this.broadcastMessage(origin, message); return true; @@ -67,7 +72,7 @@ class ControllerRouter { } if (nextHop === origin) { - msg = `Message would return back to sender ${origin.dst}.`; + msg = `Message would return back to sender ${origin.id}.`; nextHop = undefined; } @@ -85,9 +90,9 @@ class ControllerRouter { if (nextHop) { if (message.type === "request") { - nextHop.forwardRequest(message, origin); + nextHop.forwardRequest(message as lib.MessageRequest, origin); } else { - nextHop.connector.send(message); + nextHop.connector._sendInternal(message); } } else { this.warnUnrouted(message, msg); @@ -96,7 +101,10 @@ class ControllerRouter { return true; } - broadcastMessage(origin, message) { + broadcastMessage( + origin: lib.Link, + message: lib.MessageSrcDst, + ) { let dst = message.dst; if (message.type !== "event") { this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`); @@ -117,7 +125,10 @@ class ControllerRouter { } } - warnUnrouted(message, msg) { + warnUnrouted( + message: lib.MessageSrcDst, + msg: string + ) { let dst = message.dst; let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`; logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`); diff --git a/packages/controller/src/HostConnection.js b/packages/controller/src/HostConnection.ts similarity index 79% rename from packages/controller/src/HostConnection.js rename to packages/controller/src/HostConnection.ts index fdb88eed..5dd757ec 100644 --- a/packages/controller/src/HostConnection.js +++ b/packages/controller/src/HostConnection.ts @@ -1,19 +1,42 @@ -"use strict"; -const lib = require("@clusterio/lib"); +import type Controller from "./Controller"; +import type WsServerConnector from "./WsServerConnector"; + +import * as lib from "@clusterio/lib"; const { logger, PlayerStats } = lib; -const BaseConnection = require("./BaseConnection"); -const InstanceInfo = require("./InstanceInfo"); +import BaseConnection from "./BaseConnection"; +import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; +export interface HostInfo { + agent: string; + id: number; + name: string; + version: string; + plugins: [number, any][]; + public_address: string | undefined; +} + /** * Represents the connection to a host * * @extends module:controller/src/BaseConnection * @alias module:controller/src/HostConnection */ -class HostConnection extends BaseConnection { - constructor(registerData, connector, controller) { +export default class HostConnection extends BaseConnection { + declare connector: WsServerConnector; + private _agent: any; + private _id: any; + private _name: any; + private _version: any; + plugins: Map; + closing: boolean = false; + + constructor( + registerData: any, + connector: WsServerConnector, + controller: Controller, + ) { super(connector, controller); this._agent = registerData.agent; @@ -36,12 +59,14 @@ class HostConnection extends BaseConnection { // eslint-disable-next-line no-loop-func this.connector.on(event, () => { for (let plugin of this._controller.plugins.values()) { - plugin.onHostConnectionEvent(this, event); + plugin.onHostConnectionEvent(this, event as "connect"|"drop"|"resume"|"close"); } }); } this.connector.on("close", () => { + this.closing = true; + // Update status to unknown for instances on this host. for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") !== this._id) { @@ -62,7 +87,7 @@ class HostConnection extends BaseConnection { this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); } - validateIngress(message) { + validateIngress(message: lib.MessageSrcDst) { let origin = this.connector.dst; switch (message.src.type) { case lib.Address.control: @@ -129,7 +154,7 @@ class HostConnection extends BaseConnection { return this._id; } - async handleInstanceStatusChangedEvent(request) { + async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) { let instance = this._controller.instances.get(request.instanceId); // It's possible to get updates from an instance that does not exist @@ -151,14 +176,14 @@ class HostConnection extends BaseConnection { } let prev = instance.status; - instance.status = request.status; - instance.game_port = request.gamePort; + instance.status = request.status as lib.InstanceStatus; + instance.config.set("factorio.game_port", request.gamePort); logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); this._controller.instanceUpdated(instance); await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } - async handleInstancesUpdateRequest(request) { + async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) { // Push updated instance configs for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") === this._id) { @@ -171,7 +196,7 @@ class HostConnection extends BaseConnection { // Assign instances the host has but controller does not for (let instanceData of request.instances) { let instanceConfig = new lib.InstanceConfig("controller"); - await instanceConfig.load(instanceData.config, "host"); + await instanceConfig.load(instanceData.config as lib.SerializedConfig, "host"); let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); if (controllerInstance) { @@ -186,7 +211,7 @@ class HostConnection extends BaseConnection { // Already have this instance, update state instead if (controllerInstance.status !== instanceData.status) { let prev = controllerInstance.status; - controllerInstance.status = instanceData.status; + controllerInstance.status = instanceData.status as InstanceStatus; logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); this._controller.instanceUpdated(controllerInstance); await lib.invokeHook( @@ -197,7 +222,7 @@ class HostConnection extends BaseConnection { } instanceConfig.set("instance.assigned_host", this._id); - let newInstance = new InstanceInfo({ config: instanceConfig, status: instanceData.status }); + let newInstance = new InstanceInfo({ config: instanceConfig, status: instanceData.status as InstanceStatus }); this._controller.instances.set(instanceConfig.get("instance.id"), newInstance); this._controller.addInstanceHooks(newInstance); await this.send( @@ -209,30 +234,30 @@ class HostConnection extends BaseConnection { } // Push lists to make sure they are in sync. - let adminlist = []; - let banlist = []; - let whitelist = []; + let adminlist: Set = new Set(); + let banlist: Map = new Map(); + let whitelist: Set = new Set(); for (let user of this._controller.userManager.users.values()) { if (user.isAdmin) { - adminlist.push(user.name); + adminlist.add(user.name); } if (user.isBanned) { - banlist.push([user.name, user.banReason]); + banlist.set(user.name, user.banReason); } if (user.isWhitelisted) { - whitelist.push(user.name); + whitelist.add(user.name); } } await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist)); } - async handleInstanceSaveListUpdateEvent(event) { + async handleInstanceSaveListUpdateEvent(event: lib.InstanceSaveListUpdateEvent) { this._controller.saveListUpdate(event.instanceId, event.saves); } - async handleLogMessageEvent(event) { + async handleLogMessageEvent(event: lib.LogMessageEvent) { this._controller.clusterLogger.log({ ...event.info, host_id: this._id, @@ -240,7 +265,8 @@ class HostConnection extends BaseConnection { }); } - async handleInstancePlayerUpdateEvent(event, src) { + async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) { + let instanceId = src.id; let user = this._controller.userManager.users.get(event.name); if (!user) { @@ -252,7 +278,11 @@ class HostConnection extends BaseConnection { } else if (event.type === "leave") { user.notifyLeave(instanceId); } - user.instanceStats.set(instanceId, new PlayerStats(event.stats)); + + if (event.stats) { + user.instanceStats.set(instanceId, event.stats); + } + user.recalculatePlayerStats(); this._controller.userUpdated(user); @@ -264,5 +294,3 @@ class HostConnection extends BaseConnection { }); } } - -module.exports = HostConnection; diff --git a/packages/controller/src/HttpCloser.js b/packages/controller/src/HttpCloser.ts similarity index 80% rename from packages/controller/src/HttpCloser.js rename to packages/controller/src/HttpCloser.ts index ef31e7d6..5359f684 100644 --- a/packages/controller/src/HttpCloser.js +++ b/packages/controller/src/HttpCloser.ts @@ -1,6 +1,7 @@ -"use strict"; -const http = require("http"); -const https = require("https"); +import type { Socket } from "net"; + +import http from "http"; +import https from "https"; /** * Gracefully close a Node.js HTTP(S) server @@ -19,34 +20,37 @@ const https = require("https"); * * @alias module:controller/src/HttpCloser */ -class HttpCloser { +export default class HttpCloser { + private _server: http.Server | https.Server; + private _sockets: Set; + private _responses: Set; + /** * Server to attach this closer to. - * @param {https.Server | http.Server} server - HTTP(S) to attach to */ - constructor(server) { + constructor(server: http.Server | https.Server) { this._server = server; this._sockets = new Set(); this._responses = new Set(); - if (this._server instanceof http.Server) { - this._server.on("connection", this._addSocket.bind(this)); - } else if (this._server instanceof https.Server) { + if (this._server instanceof https.Server) { this._server.on("secureConnection", this._addSocket.bind(this)); + } else if (this._server instanceof http.Server) { + this._server.on("connection", this._addSocket.bind(this)); } else { throw Error("server must be an instance of either http.Server or https.Server."); } this._server.on("request", this._addRequest.bind(this)); } - _addSocket(socket) { + _addSocket(socket:Socket) { this._sockets.add(socket); socket.once("close", () => { this._sockets.delete(socket); }); } - _addRequest(request, response) { + _addRequest(request: http.IncomingMessage, response: http.ServerResponse) { this._responses.add(response); response.once("close", () => this._responses.delete(response)); response.once("finish", () => this._responses.delete(response)); @@ -62,15 +66,15 @@ class HttpCloser { * If there's requests in progress that still haven't been completed * after the timeout value passed they will be aborted. * - * @param {number} timeout - + * @param timeout - * Time in ms to wait for connections to close. */ - async close(timeout = 5000) { + async close(timeout:number = 5000): Promise { let activeSockets = new Set(); for (let response of this._responses) { if (!response.headersSent) { response.setHeader("Connection", "close"); - } else { + } else if (response.socket) { let socket = response.socket; response.once("finish", () => { socket.end(); @@ -104,5 +108,3 @@ class HttpCloser { }); } }; - -module.exports = HttpCloser; diff --git a/packages/controller/src/InstanceInfo.js b/packages/controller/src/InstanceInfo.js deleted file mode 100644 index 27bfdb5a..00000000 --- a/packages/controller/src/InstanceInfo.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; - -/** - * Runtime status of an instance on the controller - * @alias module:controller/src/InstanceInfo - */ -class InstanceInfo { - /** @type {module:lib.InstanceConfig} */ - config; - - /** - * Current status of the instance. One of: - * - `unknown`: Instance is assigned to a host but this host is currently - * not connected to the contreller. - * - `unassigned`: Instance is not assigned to a a host and exists only on - * the controller. - * - `stopped`: Instance is stopped. - * - `starting`: Instance is in the process of starting up. - * - `running`: Instance is running normally. - * - `stopping`: Instance is in the process of stopping. - * - `creating_save`: Instance is in the process of creating a save. - * - `exporting_data`: Instance is in the process of exporting game data. - * - `deleted`: Instance has been deleted. - * - * @type {string} - */ - status; - - constructor({ config, status }) { - this.config = config; - this.status = status; - } - - toJSON() { - return { - config: this.config.serialize(), - status: this.status, - }; - } - - /** Shorthand for `instance.config.get("instance.id")` @type {number} */ - get id() { - return this.config.get("instance.id"); - } -} - -module.exports = InstanceInfo; diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts new file mode 100644 index 00000000..64111eda --- /dev/null +++ b/packages/controller/src/InstanceInfo.ts @@ -0,0 +1,49 @@ +import type * as lib from "@clusterio/lib"; + +/** + * Current status of the instance. One of: + * - `unknown`: Instance is assigned to a host but this host is currently + * not connected to the contreller. + * - `unassigned`: Instance is not assigned to a a host and exists only on + * the controller. + * - `stopped`: Instance is stopped. + * - `starting`: Instance is in the process of starting up. + * - `running`: Instance is running normally. + * - `stopping`: Instance is in the process of stopping. + * - `creating_save`: Instance is in the process of creating a save. + * - `exporting_data`: Instance is in the process of exporting game data. + * - `deleted`: Instance has been deleted. + */ +export type InstanceStatus = + "unknown" | "unassigned" | "stopped" | "starting" | "running" | + "stopping" | "creating_save" | "exporting_data" | "deleted"; + + +/** + * Runtime status of an instance on the controller + * @alias module:controller/src/InstanceInfo + */ +export default class InstanceInfo { + config: lib.InstanceConfig; + status: InstanceStatus; + + constructor( + { config, status }: + { config: lib.InstanceConfig, status: InstanceStatus } + ) { + this.config = config; + this.status = status; + } + + toJSON(): Object { + return { + config: this.config.serialize(), + status: this.status, + }; + } + + /** Shorthand for `instance.config.get("instance.id")` */ + get id():number { + return this.config.get("instance.id"); + } +} diff --git a/packages/controller/src/UserManager.js b/packages/controller/src/UserManager.ts similarity index 55% rename from packages/controller/src/UserManager.js rename to packages/controller/src/UserManager.ts index 312b291c..076baefb 100644 --- a/packages/controller/src/UserManager.js +++ b/packages/controller/src/UserManager.ts @@ -1,51 +1,47 @@ -"use strict"; -const fs = require("fs-extra"); -const jwt = require("jsonwebtoken"); +import fs from "fs-extra"; +import jwt from "jsonwebtoken"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; /** * Manages users and roles * @alias module:controller/src/UserManager */ -class UserManager { - constructor(config) { - this._config = config; - this.roles = null; - this.users = null; +export default class UserManager { + roles: Map = new Map(); + users: Map = new Map(); + + constructor( + private _config: lib.ControllerConfig + ) { } - async load(filePath) { - let loadedRoles = new Map(); - let loadedUsers = new Map(); + async load(filePath: string): Promise { try { - let content = JSON.parse(await fs.readFile(filePath)); + let content = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); for (let serializedRole of content.roles) { let role = new lib.Role(serializedRole); - loadedRoles.set(role.id, role); + this.roles.set(role.id, role); } for (let serializedUser of content.users) { - let user = new lib.User(serializedUser, loadedRoles); - loadedUsers.set(user.name, user); + let user = new lib.User(serializedUser, this.roles); + this.users.set(user.name, user); } - } catch (err) { + } catch (err: any) { if (err.code !== "ENOENT") { throw err; } // Create default roles if loading failed - lib.ensureDefaultAdminRole(loadedRoles); - lib.ensureDefaultPlayerRole(loadedRoles); + lib.ensureDefaultAdminRole(this.roles); + lib.ensureDefaultPlayerRole(this.roles); } - - this.roles = loadedRoles; - this.users = loadedUsers; } - async save(filePath) { - if (!this.roles || !this.users) { + async save(filePath:string): Promise { + if (this.roles.size === 0 || this.users.size === 0) { return; } @@ -67,19 +63,19 @@ class UserManager { } /** - * Create a new user - * * Creates a new user and add it to the user database. - * - * @param {string} name - Name of the user to create. - * @returns {module:lib.User} newly created user. + * @param name - Name of the user to create. */ - createUser(name) { + createUser(name:string): lib.User { if (this.users.has(name)) { throw new Error(`User '${name}' already exists`); } let defaultRoleId = this._config.get("controller.default_role_id"); + if (defaultRoleId === null) { + throw new Error(`controller.default_role_id is not defined.`); + } + let user = new lib.User({ name, roles: [defaultRoleId] }, this.roles); this.users.set(name, user); return user; @@ -88,16 +84,13 @@ class UserManager { /** * Sign access token for the given user name * - * @param {string} name - user name to sign token for - * @returns {string} JWT access token for the user. + * @param name - user name to sign token for + * @returns JWT access token for the user. */ - signUserToken(name) { + signUserToken(name: string): string { return jwt.sign( { aud: "user", user: name }, Buffer.from(this._config.get("controller.auth_secret"), "base64") ); } - } - -module.exports = UserManager; diff --git a/packages/controller/src/WsServer.js b/packages/controller/src/WsServer.ts similarity index 75% rename from packages/controller/src/WsServer.js rename to packages/controller/src/WsServer.ts index d14cba3d..4aa56f30 100644 --- a/packages/controller/src/WsServer.js +++ b/packages/controller/src/WsServer.ts @@ -1,15 +1,23 @@ -"use strict"; -const jwt = require("jsonwebtoken"); -const WebSocket = require("ws"); +import type { IncomingMessage } from "http"; +import type { Duplex } from "stream"; +import type Controller from "./Controller"; -const lib = require("@clusterio/lib"); +import jwt from "jsonwebtoken"; +import WebSocket, { WebSocketServer } from "ws"; + +import * as lib from "@clusterio/lib"; const { logger } = lib; -const ControlConnection = require("./ControlConnection"); -const packageVersion = require("../package").version; -const HostConnection = require("./HostConnection"); -const WsServerConnector = require("./WsServerConnector"); +import ControlConnection from "./ControlConnection"; +import HostConnection, { HostInfo } from "./HostConnection"; +import WsServerConnector from "./WsServerConnector"; +export interface WebSocketClusterio extends WebSocket { + clusterio_ignore_dump: boolean | undefined; +} + + +const packageVersion = process.env.npm_package_version || "-1"; const wsMessageCounter = new lib.Counter( "clusterio_controller_websocket_message_total", @@ -31,35 +39,30 @@ const wsRejectedConnectionsCounter = new lib.Counter( * WebSocket server * @alias module:controller/src/WsServer */ -class WsServer { - constructor(controller) { +export default class WsServer { + controller: Controller; + stopAcceptingNewSessions: boolean = false; + controlConnections: Map = new Map(); + hostConnections: Map = new Map(); + activeConnectors: Map = new Map(); + pendingSockets: Set = new Set(); + // Unique string for the session token audience + sessionAud: string = `session-${Date.now()}`; + nextSessionId: number = 1; + nextControlId: number = 1; + wss: WebSocket.Server; + + constructor(controller: Controller) { this.controller = controller; - this.stopAcceptingNewSessions = false; - - /** @type {Array} */ - this.controlConnections = new Map(); - /** @type {Map} */ - this.hostConnections = new Map(); - /** @type {Map} */ - this.activeConnectors = new Map(); - /** @type {Set} */ - this.pendingSockets = new Set(); - - // 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({ noServer: true, path: "/api/socket", }); - this.wss.on("connection", (socket, req) => this.handleConnection(socket, req)); + this.wss.on("connection", (socket: WebSocketClusterio, req: IncomingMessage) => this.handleConnection(socket, req)); } - async stop() { + async stop(): Promise { this.stopAcceptingNewSessions = true; let disconnectTasks = []; @@ -75,7 +78,7 @@ class WsServer { for (let task of disconnectTasks) { try { await task; - } catch (err) { + } catch (err: any) { if (!(err instanceof lib.SessionLost)) { logger.warn(`Unexpected error disconnecting connector:\n${err.stack}`); } @@ -87,7 +90,11 @@ class WsServer { } } - handleUpgrade(req, socket, head) { + handleUpgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer + ) { // For reasons that defy common sense, the connection event has // to be emitted explictly when using noServer. this.wss.handleUpgrade(req, socket, head, (ws) => { @@ -95,7 +102,7 @@ class WsServer { }); } - handleConnection(socket, req) { + handleConnection(socket: WebSocketClusterio, req: IncomingMessage) { logger.verbose(`WsServer | new connection from ${req.socket.remoteAddress}`); // Track statistics @@ -112,11 +119,12 @@ class WsServer { if (typeof args[0] === "string" && !socket.clusterio_ignore_dump) { this.controller.debugEvents.emit("message", { direction: "out", content: args[0] }); } + // @ts-ignore TODO::: Issue with originalSend having multiple signatures ? return originalSend.call(socket, ...args); }; // Start connection handshake. - let loadedPlugins = {}; + let loadedPlugins: any = {}; for (let [name, plugin] of this.controller.plugins) { loadedPlugins[name] = plugin.info.version; } @@ -125,7 +133,10 @@ class WsServer { this.attachHandler(socket, req); } - attachHandler(socket, req) { + attachHandler( + socket: WebSocket, + req: IncomingMessage + ) { this.pendingSockets.add(socket); let timeoutId = setTimeout(() => { @@ -135,7 +146,7 @@ class WsServer { this.pendingSockets.delete(socket); }, 30*1000); - socket.once("message", (message) => { + socket.once("message", (message: string) => { clearTimeout(timeoutId); this.pendingSockets.delete(socket); this.handleHandshake( @@ -154,13 +165,13 @@ ${err.stack}` }); } - async handleHandshake(rawMessage, socket, req) { + async handleHandshake(rawMessage: string, socket: WebSocket, req: IncomingMessage) { let message; try { const json = JSON.parse(rawMessage); lib.Message.validate(json); message = lib.Message.fromJSON(json); - } catch (err) { + } catch (err: any) { logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid message`); wsRejectedConnectionsCounter.inc(); socket.close(1002, `Invalid message: ${err.message}`); @@ -168,7 +179,7 @@ ${err.stack}` } if (message.type === "resume") { - this.resumeSession(message.data, socket, req); + this.resumeSession((message as lib.MessageResume).data, socket, req); return; } @@ -187,19 +198,19 @@ ${err.stack}` } if (message.type === "registerHost") { - await this.registerHost(message.data, socket, req); + await this.registerHost((message as lib.MessageRegisterHost).data, socket, req); return; } if (message.type === "registerControl") { - await this.registerControl(message.data, socket, req); + await this.registerControl((message as lib.MessageRegisterControl).data, socket, req); return; } throw new Error("This should be unreachable"); } - createSession(dst) { + createSession(dst: lib.Address): [ WsServerConnector, string ] { let sessionId = this.nextSessionId; this.nextSessionId += 1; let sessionToken = jwt.sign( @@ -217,7 +228,7 @@ ${err.stack}` return [connector, sessionToken]; } - async registerHost(data, socket, req) { + async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) { try { let tokenPayload = jwt.verify( data.token, @@ -226,11 +237,15 @@ ${err.stack}` { audience: ["host", "slave"] } ); + if (typeof tokenPayload === "string") { + throw new Error("unexpected JsonWebToken type"); + } + // 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"); } - } catch (err) { + } catch (err: any) { logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); wsRejectedConnectionsCounter.inc(); socket.close(4003, `Authentication failed: ${err.message}`); @@ -258,36 +273,40 @@ ${err.stack}` connector.on("close", () => { if (this.hostConnections.get(data.id) === connection) { this.hostConnections.delete(data.id); - this.controller.hostUpdated(this.controller.hosts.get(data.id)); + this.controller.hostUpdated(this.controller.hosts.get(data.id) as HostInfo); } else { logger.warn("Unlisted HostConnection closed"); } }); this.hostConnections.set(data.id, connection); - this.controller.hostUpdated(this.controller.hosts.get(data.id)); + this.controller.hostUpdated(this.controller.hosts.get(data.id) as HostInfo); let src = new lib.Address(lib.Address.host, data.id); - connector.ready(socket, src, sessionToken); + connector.ready(socket, src, sessionToken, undefined); } - async registerControl(data, socket, req) { + async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) { let user; try { let tokenPayload = jwt.verify( data.token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + this.controller.config.get("controller.auth_secret"), { audience: "user" } ); + if (typeof tokenPayload === "string") { + throw new Error("unexpected JsonWebToken type"); + } + user = this.controller.userManager.users.get(tokenPayload.user); if (!user) { throw new Error("invalid user"); } - if (tokenPayload.iat < user.tokenValidAfter) { + if ((tokenPayload.iat || 0) < user.tokenValidAfter) { throw new Error("invalid token"); } user.checkPermission("core.control.connect"); - } catch (err) { + } catch (err: any) { logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); wsRejectedConnectionsCounter.inc(); socket.close(4003, `Authentication failed: ${err.message}`); @@ -317,7 +336,7 @@ ${err.stack}` connector.ready(socket, src, sessionToken, account); } - resumeSession(data, socket, req) { + resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) { let connector; try { let payload = jwt.verify( @@ -326,6 +345,10 @@ ${err.stack}` { audience: this.sessionAud } ); + if (typeof payload === "string") { + throw new Error("unexpected JsonWebToken type"); + } + connector = this.activeConnectors.get(payload.sid); if (!connector) { throw new Error(); @@ -337,10 +360,6 @@ ${err.stack}` return; } - connector.continue(socket, data.lastSeq); - + connector.continue(socket, data.lastSeq as number); } - } - -module.exports = WsServer; diff --git a/packages/controller/src/WsServerConnector.js b/packages/controller/src/WsServerConnector.ts similarity index 74% rename from packages/controller/src/WsServerConnector.js rename to packages/controller/src/WsServerConnector.ts index c0c4d0a1..3974a9c1 100644 --- a/packages/controller/src/WsServerConnector.js +++ b/packages/controller/src/WsServerConnector.ts @@ -1,25 +1,26 @@ -"use strict"; -const events = require("events"); +import type WebSocket from "ws"; -const lib = require("@clusterio/lib"); +import events from "events"; + +import * as lib from "@clusterio/lib"; const { logger } = lib; - /** * Connector for controller connections * * @extends module:lib.WebSocketBaseConnector * @alias module:controller/src/WsServerConnector */ -class WsServerConnector extends lib.WebSocketBaseConnector { - constructor(dst, sessionId, sessionTimeout, heartbeatInterval) { - super(new lib.Address(lib.Address.controller, 0), dst); +export default class WsServerConnector extends lib.WebSocketBaseConnector { + _timeoutId: NodeJS.Timeout | null = null; - this._sessionTimeout = sessionTimeout; - this._socket = null; - this._sessionId = sessionId; - this._heartbeatInterval = heartbeatInterval; - this._timeoutId = null; + constructor( + dst: lib.Address, + private _sessionId: number, + private _sessionTimeout: number, + public _heartbeatInterval: number, + ) { + super(new lib.Address(lib.Address.controller, 0), dst); // The following states are used in the server connector // closed: Connection is closed @@ -28,8 +29,10 @@ class WsServerConnector extends lib.WebSocketBaseConnector { } _reset() { - clearTimeout(this._timeoutId); - this._timeoutId = null; + if (this._timeoutId) { + clearTimeout(this._timeoutId); + this._timeoutId = null; + } super._reset(); } @@ -38,14 +41,17 @@ class WsServerConnector extends lib.WebSocketBaseConnector { * * Sends the ready message over the socket to initiate the session. * - * @param {Object} socket - WebSocket connection to client. - * @param {module:lib.Address} src - Source address for this link. - * @param {string} sessionToken - - * the session token to send to the client. - * @param {module:lib.AccountDetails=} account - - * account data to provide to control connection + * @param socket - WebSocket connection to client. + * @param src - Source address for this link. + * @param sessionToken - the session token to send to the client. + * @param account - account data to provide to control connection */ - ready(socket, src, sessionToken, account) { + ready( + socket: WebSocket, + src: lib.Address, + sessionToken: string, + account: lib.AccountDetails | undefined + ) { this._socket = socket; this._sendInternal(new lib.MessageReady( new lib.ReadyData( @@ -68,10 +74,13 @@ class WsServerConnector extends lib.WebSocketBaseConnector { * Terminates the current socket and contiunes the session over the * 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 socket - New socket to continue on. + * @param lastSeq - The last message the client received. */ - continue(socket, lastSeq) { + continue( + socket: WebSocket.WebSocket, + lastSeq: number + ) { // It's possible the previous connection hasn't closed yet due to a // stale connection. Terminate it if so. @@ -110,7 +119,7 @@ class WsServerConnector extends lib.WebSocketBaseConnector { _attachSocketHandlers() { this.startHeartbeat(); - this._socket.on("close", (code, reason) => { + this._socket.on("close", (code: number, reason: string) => { logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`); this.stopHeartbeat(); this._socket = null; @@ -124,10 +133,9 @@ class WsServerConnector extends lib.WebSocketBaseConnector { this.emit("drop"); this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000); } - }); - this._socket.on("error", err => { + this._socket.on("error", (err: any) => { // It's assumed that close is always called by ws logger.verbose("Connector | Error:", err); }); @@ -135,15 +143,15 @@ class WsServerConnector extends lib.WebSocketBaseConnector { this._socket.on("open", () => { logger.verbose("Connector | Open"); }); - this._socket.on("ping", data => { + this._socket.on("ping", (data: string) => { logger.verbose(`Connector | Ping (data: ${data}`); }); - this._socket.on("pong", data => { + this._socket.on("pong", (data: string) => { logger.verbose(`Connector | Pong (data: ${data}`); }); // Handle messages - this._socket.on("message", data => { + this._socket.on("message", (data: string) => { let message = this._parseMessage(data); if (!message) { return; @@ -162,10 +170,13 @@ class WsServerConnector extends lib.WebSocketBaseConnector { * * Sends a close frame and disconnects the connector. * - * @param {number} code - WebSocket close code. - * @param {string} reason - WebSocket close reason. + * @param code - WebSocket close code. + * @param reason - WebSocket close reason. */ - async close(code, reason) { + async close( + code: number, + reason: string + ): Promise { if (this._state === "closed") { return; } @@ -213,5 +224,3 @@ class WsServerConnector extends lib.WebSocketBaseConnector { this._closing = true; } } - -module.exports = WsServerConnector; diff --git a/packages/controller/src/metrics.js b/packages/controller/src/metrics.js deleted file mode 100644 index 3107e294..00000000 --- a/packages/controller/src/metrics.js +++ /dev/null @@ -1,14 +0,0 @@ -// Shared metrics -"use strict"; -const lib = require("@clusterio/lib"); -const util = require("util"); - -exports.endpointHitCounter = new lib.Counter( - "clusterio_controller_http_endpoint_hits_total", - "How many requests a particular HTTP endpoint has gotten", - { labels: ["route"], register: false } -); - -exports.endpointHitCounter.labels = util.deprecate( - exports.endpointHitCounter.labels, "incrementing endpointHitCounter is no longer needed and has no effect" -); diff --git a/packages/controller/src/metrics.ts b/packages/controller/src/metrics.ts new file mode 100644 index 00000000..d25d5690 --- /dev/null +++ b/packages/controller/src/metrics.ts @@ -0,0 +1,15 @@ +import * as lib from "@clusterio/lib"; +import util from "util"; + +const endpointHitCounter = new lib.Counter( + "clusterio_controller_http_endpoint_hits_total", + "How many requests a particular HTTP endpoint has gotten", + { labels: ["route"], register: false } +); + +endpointHitCounter.labels = util.deprecate( + endpointHitCounter.labels, + "incrementing endpointHitCounter is no longer needed and has no effect" +); + +export default endpointHitCounter diff --git a/packages/controller/src/routes.js b/packages/controller/src/routes.ts similarity index 72% rename from packages/controller/src/routes.js rename to packages/controller/src/routes.ts index 21c9aac7..efbff12d 100644 --- a/packages/controller/src/routes.js +++ b/packages/controller/src/routes.ts @@ -1,26 +1,29 @@ -"use strict"; -const busboy = require("busboy"); -const crypto = require("crypto"); -const events = require("events"); -const fs = require("fs-extra"); -const JSZip = require("jszip"); -const jwt = require("jsonwebtoken"); -const path = require("path"); -const nodeStream = require("stream"); -const util = require("util"); +import type { IncomingMessage, ServerResponse } from "http"; "http"; +import type { Application, Express, Request, Response } from "express"; +import type Controller from "./Controller"; -const lib = require("@clusterio/lib"); +import busboy from "busboy"; +import crypto from "crypto"; +import events from "events"; +import fs from "fs-extra"; +import JSZip from "jszip"; +import jwt from "jsonwebtoken"; +import path from "path"; +import nodeStream from "stream"; +import util from "util"; + +import * as lib from "@clusterio/lib"; const { logger } = lib; const finished = util.promisify(nodeStream.finished); // Merges samples from sourceResult to destinationResult -function mergeSamples(destinationResult, sourceResult) { +function mergeSamples(destinationResult: any, sourceResult: any) { let receivedSamples = new Map(sourceResult.samples); for (let [suffix, suffixSamples] of destinationResult.samples) { if (receivedSamples.has(suffix)) { - suffixSamples.push(...receivedSamples.get(suffix)); + suffixSamples.push(...receivedSamples.get(suffix) as any[]); receivedSamples.delete(suffix); } } @@ -31,23 +34,25 @@ function mergeSamples(destinationResult, sourceResult) { } // Prometheus polling endpoint -async function getMetrics(req, res, next) { - let results = []; - let pluginResults = await lib.invokeHook(req.app.locals.controller.plugins, "onMetrics"); +async function getMetrics(req: Request, res: Response, next: any) { + const controller: Controller = req.app.locals.controller + + let results: any[] = []; + let pluginResults: any[] = await lib.invokeHook(controller.plugins, "onMetrics"); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(metric); } } - let requests = []; - let timeout = req.app.locals.controller.config.get("controller.metrics_timeout") * 1000; - for (let [hostId, hostConnection] of req.app.locals.controller.wsServer.hostConnections) { + let requests: Promise[] = []; + let timeout = controller.config.get("controller.metrics_timeout") * 1000; + for (let [hostId, hostConnection] of controller.wsServer.hostConnections) { if (!hostConnection.connected) { continue; } requests.push(lib.timeout( - hostConnection.send(new lib.HostMetricsRequest()).catch(err => { + hostConnection.send(new lib.HostMetricsRequest()).catch((err: any) => { if (!(err instanceof lib.SessionLost)) { logger.error(`Unexpected error gathering metrics from host:\n${err.stack}`); } @@ -61,7 +66,7 @@ async function getMetrics(req, res, next) { results.push(result); } - let resultMap = new Map(); + let resultMap: Map = new Map(); for (let response of await Promise.all(requests)) { if (!response) { // TODO: Log timeout occured? @@ -74,28 +79,34 @@ async function getMetrics(req, res, next) { } else { // Merge metrics received by multiple hosts - mergeSamples(resultMap.get(result.metric.name), result); + mergeSamples(resultMap.get(result.metric.name)!, result); } } } - + + // TODO: results seem to mix deserialized and serialized lib.CollectorResult ? + // mergeSamples imply samples be [string, [string, number][]] + // but lib.defaultRegistry.collect() imply Map> for (let result of resultMap.values()) { results.push(lib.deserializeResult(result)); } + // lib.exposition expect AsyncIterable as parameter. + // Should wrap results into a Generator functions ? + // @ts-ignore let text = await lib.exposition(results); res.set("Content-Type", lib.exposition.contentType); res.send(text); } -function getPlugins(req, res) { +function getPlugins(req: Request, res: Response) { let plugins = []; for (let pluginInfo of req.app.locals.controller.pluginInfos) { let name = pluginInfo.name; let loaded = req.app.locals.controller.plugins.has(name); let enabled = loaded && req.app.locals.controller.config.group(pluginInfo.name).get("load_plugin"); - let web = {}; + let web: { main:any, error:string|undefined } = { main: undefined, error: undefined}; let devPlugins = req.app.locals.devPlugins; if (devPlugins && devPlugins.has(name)) { let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)]; @@ -116,7 +127,7 @@ function getPlugins(req, res) { res.send(plugins); } -function validateHostToken(req, res, next) { +function validateHostToken(req: Request, res: Response, next: any) { let token = req.header("x-access-token"); if (!token) { res.sendStatus(401); @@ -138,7 +149,7 @@ function validateHostToken(req, res, next) { next(); } -function validateUserToken(req, res, next) { +function validateUserToken(req: Request, res: Response, next: any) { let token = req.header("x-access-token"); if (!token) { res.sendStatus(401); @@ -151,11 +162,15 @@ function validateUserToken(req, res, next) { Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"), { audience: "user" } ); + + if (typeof tokenPayload === "string") { + throw new Error("unexpected JsonWebToken type"); + } let user = req.app.locals.controller.userManager.users.get(tokenPayload.user); if (!user) { throw new Error("invalid user"); } - if (tokenPayload.iat < user.tokenValidAfter) { + if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) { throw new Error("invalid token"); } res.locals.user = user; @@ -169,7 +184,7 @@ function validateUserToken(req, res, next) { } // Handle an uploaded export package. -async function uploadExport(req, res) { +async function uploadExport(req: Request, res: Response) { if (req.get("Content-Type") !== "application/zip") { res.sendStatus(415); return; @@ -194,9 +209,9 @@ async function uploadExport(req, res) { for await (let chunk of req) { data.push(chunk); } - data = Buffer.concat(data); - let zip = await JSZip.loadAsync(data); - data = null; + let buffer: Buffer | null = Buffer.concat(data); + let zip = await JSZip.loadAsync(buffer); + buffer = null; // This is hardcoded to prevent path expansion attacks let exportFiles = [ @@ -207,7 +222,7 @@ async function uploadExport(req, res) { "export/locale.json", ]; - let assets = {}; + let assets: any = {}; let settingPrototypes = {}; for (let filePath of exportFiles) { let file = zip.file(filePath); @@ -232,11 +247,24 @@ async function uploadExport(req, res) { res.sendStatus(200); } -async function createProxyStream(app) { + +interface ProxyStream { + id: string; + source: nodeStream.Readable | null; + flowing: boolean; + size: number | null; + mime: string | null; + filename: string | null; + events: events.EventEmitter; + timeout: NodeJS.Timeout; +} + +async function createProxyStream(app: Application): Promise { let asyncRandomBytes = util.promisify(crypto.randomBytes); let id = (await asyncRandomBytes(8)).toString("hex"); - let stream = { + let stream: ProxyStream = { id, + source: null, flowing: false, size: null, mime: null, @@ -257,7 +285,7 @@ async function createProxyStream(app) { return stream; } -async function putStream(req, res) { +async function putStream(req: Request, res: Response) { let stream = req.app.locals.streams.get(req.params.id); if (!stream || stream.source) { res.sendStatus(404); @@ -278,7 +306,7 @@ async function putStream(req, res) { }); } -async function getStream(req, res) { +async function getStream(req: Request, res: Response) { let stream = req.app.locals.streams.get(req.params.id); if (!stream || stream.flowing) { res.sendStatus(404); @@ -321,15 +349,15 @@ const zipMimes = [ const contentTypeRegExp = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/; -async function uploadSave(req, res) { +async function uploadSave(req: Request, res: Response) { try { res.locals.user.checkPermission("core.instance.save.upload"); - } catch (err) { + } catch (err: any) { res.status(403).json({ request_errors: [err.message] }); return; } - let contentType = req.get("Content-Type"); + let contentType = req.get("Content-Type") || ""; let match = contentTypeRegExp.exec(contentType); if (!match) { res.status(415).json({ request_errors: ["invalid Content-Type"] }); @@ -338,11 +366,11 @@ async function uploadSave(req, res) { let contentMime = match[1].toLowerCase(); let tasks = []; - let errors = []; + let errors: string[] = []; let requestErrors = []; - let saves = []; + let saves: string[] = []; - async function handleFile(instanceId, stream, filename, streamMime) { + async function handleFile(instanceId:number, stream: nodeStream.Readable, filename: string, streamMime: string) { let proxyStream = await createProxyStream(req.app); proxyStream.source = stream; proxyStream.mime = streamMime; @@ -354,7 +382,7 @@ async function uploadSave(req, res) { }); try { - let storedName = await Promise.race([ + let storedName: string = await Promise.race([ req.app.locals.controller.sendToHostByInstanceId( new lib.InstancePullSaveRequest( instanceId, @@ -366,7 +394,7 @@ async function uploadSave(req, res) { ]); saves.push(storedName); - } catch (err) { + } catch (err: any) { proxyStream.events.emit("close"); logger.error(`Error uploading save: ${err.message}`); errors.push(err.message); @@ -376,10 +404,10 @@ async function uploadSave(req, res) { if (contentMime === "multipart/form-data") { await new Promise(resolve => { - let fields = {}; + let instanceId:number | undefined; let parser = busboy({ headers: req.headers }); - parser.on("file", (name, stream, { filename, mimeType }) => { - if (fields.instanceId === undefined) { + parser.on("file", (name: string, stream: nodeStream.Readable, { filename, mimeType }: { filename: string, mimeType: string } ) => { + if (instanceId === undefined) { requestErrors.push("instance_id must come before files uploaded"); } @@ -396,18 +424,18 @@ async function uploadSave(req, res) { return; } - tasks.push(handleFile(fields.instanceId, stream, filename, mimeType)); + tasks.push(handleFile(instanceId as number, stream, filename, mimeType)); }); parser.on("field", (name, value, info) => { if (name === "instance_id") { - fields.instanceId = Number.parseInt(value, 10); - if (Number.isNaN(fields.instanceId)) { + instanceId = Number.parseInt(value, 10); + if (Number.isNaN(instanceId)) { requestErrors.push("invalid instance_id"); } } }); parser.on("close", resolve); - parser.on("error", (err) => { + parser.on("error", (err: any) => { logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`); errors.push(err.message); }); @@ -415,13 +443,13 @@ async function uploadSave(req, res) { }); } else if (zipMimes.includes(contentMime)) { - let filename = req.query.filename; + let filename = String(req.query.filename); if (typeof filename !== "string") { requestErrors.push("Missing or invalid filename parameter"); } else if (!filename.endsWith(".zip")) { requestErrors.push("filename must end with .zip"); } - let instanceId = Number.parseInt(req.query.instance_id, 10); + let instanceId = Number.parseInt(String(req.query.instance_id), 10); if (Number.isNaN(instanceId)) { requestErrors.push("Missing or invalid instance_id parameter"); } @@ -453,23 +481,23 @@ async function uploadSave(req, res) { res.json({ saves }); } -function checkModName(name) { +function checkModName(name: string) { try { lib.checkFilename(name); - } catch (err) { + } catch (err: any) { throw new lib.RequestError(`Mod name ${err.message}`); } } -async function uploadMod(req, res) { +async function uploadMod(req: Request, res: Response) { try { res.locals.user.checkPermission("core.mod.upload"); - } catch (err) { + } catch (err: any) { res.status(403).json({ request_errors: [err.message] }); return; } - let contentType = req.get("Content-Type"); + let contentType = req.get("Content-Type")!; let match = contentTypeRegExp.exec(contentType); if (!match) { res.status(415).json({ request_errors: ["invalid Content-Type"] }); @@ -478,15 +506,15 @@ async function uploadMod(req, res) { let contentMime = match[1].toLowerCase(); let tasks = []; - let errors = []; + let errors: string[] = []; let requestErrors = []; - let mods = []; + let mods: any[] = []; - async function handleFile(stream, filename) { + async function handleFile(stream: nodeStream.Readable, filename: string) { try { checkModName(filename); - } catch (err) { + } catch (err: any) { logger.error(`Error uploading mod: ${err.message}`); errors.push(err.message); stream.resume(); @@ -495,15 +523,16 @@ async function uploadMod(req, res) { const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory"); let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip"); + + let writeStream; try { - let writeStream; while (true) { try { writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" }); await events.once(writeStream, "open"); break; - } catch (err) { + } catch (err: any) { if (err.code === "EEXIST") { tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip"); } else { @@ -520,18 +549,21 @@ async function uploadMod(req, res) { req.app.locals.controller.modUpdated(modInfo); mods.push(modInfo.toJSON()); - } catch (err) { + } catch (err: any) { logger.error(`Error uploading mod: ${err.message}`); errors.push(err.message); stream.resume(); // Attempt to clean up. - writeStream.destroy(); + if (writeStream) { + writeStream.destroy(); + } + try { await fs.unlink(path.join(modsDirectory, tempFilename)); - } catch (unlinkErr) { + } catch (unlinkErr: any) { if (unlinkErr.code !== "ENOENT") { - logger.error(`Error removing ${tempFilename}: ${err.message}`); + logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`); } } } @@ -540,7 +572,7 @@ async function uploadMod(req, res) { if (contentMime === "multipart/form-data") { await new Promise(resolve => { let parser = busboy({ headers: req.headers }); - parser.on("file", (name, stream, { filename, mimeType }) => { + parser.on("file", (name:string, stream: nodeStream.Readable, { filename, mimeType }: { filename: string, mimeType: string }) => { if (!zipMimes.includes(mimeType)) { requestErrors.push("invalid file Content-Type"); } @@ -557,7 +589,7 @@ async function uploadMod(req, res) { tasks.push(handleFile(stream, filename)); }); parser.on("close", resolve); - parser.on("error", (err) => { + parser.on("error", (err: any) => { logger.error(`Error parsing multipart request in upload-mod:\n${err.stack}`); errors.push(err.message); }); @@ -575,7 +607,7 @@ async function uploadMod(req, res) { if (errors.length || requestErrors.length) { req.resume(); } else { - tasks.push(handleFile(req, filename)); + tasks.push(handleFile(req, filename as string)); } } else { @@ -600,22 +632,22 @@ async function uploadMod(req, res) { } -function addRouteHandlers(app) { - app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next)); +function addRouteHandlers(app: Application) { + app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next)); app.get("/api/plugins", getPlugins); app.put("/api/upload-export", validateHostToken, - (req, res, next) => uploadExport(req, res).catch(next) + (req:Request, res:Response, next:any) => uploadExport(req, res).catch(next) ); - app.put("/api/stream/:id", (req, res, next) => putStream(req, res).catch(next)); - app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next)); + app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next)); + app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next)); app.post("/api/upload-save", validateUserToken, - (req, res, next) => uploadSave(req, res).catch(next) + (req:Request, res:Response, next:any) => uploadSave(req, res).catch(next) ); app.post("/api/upload-mod", validateUserToken, - (req, res, next) => uploadMod(req, res).catch(next) + (req:Request, res:Response, next:any) => uploadMod(req, res).catch(next) ); } @@ -637,8 +669,8 @@ const webRoutes = [ "/plugins/:name/view", ]; -module.exports = { +export default { addRouteHandlers, webRoutes, createProxyStream, -}; +} diff --git a/packages/controller/tsconfig.json b/packages/controller/tsconfig.json new file mode 100644 index 00000000..aefb3e4b --- /dev/null +++ b/packages/controller/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["controller.ts", "src/**/*.ts"], + "compilerOptions": { + "lib": ["esnext"], + "module": "commonjs", + "target": "es2022", + "rootDir": ".", + "outDir": "dist/controller", + "composite": true, + "strict": true, + "alwaysStrict": true, + "allowJs": true, + "checkJs": false, + "noEmitOnError": false, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/packages/controller/typedoc.json b/packages/controller/typedoc.json new file mode 100644 index 00000000..859f97b8 --- /dev/null +++ b/packages/controller/typedoc.json @@ -0,0 +1,4 @@ +{ + "entryPoints": ["controller.ts"], + "includeVersion": true +} diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 7d475ae3..34b9876b 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -150,7 +150,7 @@ export class ControllerConfig extends classes.Config { get(name: "controller.external_address"): string | null; get(name: "controller.tls_certificate"): string | null; get(name: "controller.tls_private_key"): string | null; - get(name: "controller.auth_secret"): string | null; + get(name: "controller.auth_secret"): string; get(name: "controller.heartbeat_interval"): number; get(name: "controller.session_timeout"): number; get(name: "controller.metrics_timeout"): number; From 799cfe7ff2c899c6355edebbccbd8e51a3460c0f Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Sun, 13 Aug 2023 19:21:51 +0200 Subject: [PATCH 03/16] test: import controller/* from dist folder --- test/controller/HttpCloser.js | 2 +- test/controller/routes.js | 2 +- test/mock.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/controller/HttpCloser.js b/test/controller/HttpCloser.js index 3dca01c5..13201b62 100644 --- a/test/controller/HttpCloser.js +++ b/test/controller/HttpCloser.js @@ -7,7 +7,7 @@ const util = require("util"); const phin = require("phin"); -const HttpCloser = require("@clusterio/controller/src/HttpCloser"); +const HttpCloser = require("@clusterio/controller/dist/controller/src/HttpCloser"); // Time to wait during async operations to ensure they happened in order. const tick = 20; diff --git a/test/controller/routes.js b/test/controller/routes.js index 0a5ec99a..a9724ea2 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -7,7 +7,7 @@ const http = require("http"); const phin = require("phin"); const { wait } = require("@clusterio/lib"); -const routes = require("@clusterio/controller/src/routes"); +const routes = require("@clusterio/controller/dist/controller/src/routes"); const mock = require("../mock"); diff --git a/test/mock.js b/test/mock.js index 21be1360..13d3b220 100644 --- a/test/mock.js +++ b/test/mock.js @@ -5,7 +5,7 @@ const express = require("express"); const lib = require("@clusterio/lib"); -const UserManager = require("@clusterio/controller/src/UserManager"); +const UserManager = require("@clusterio/controller/dist/controller/src/UserManager"); const addr = lib.Address.fromShorthand; From 3e4acaaf4e0dace7006b0f77b45eb8aeb9cb6da1 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 09:47:50 +0200 Subject: [PATCH 04/16] use instance.game_port --- packages/controller/src/ControlConnection.ts | 6 +++--- packages/controller/src/HostConnection.ts | 2 +- packages/controller/src/InstanceInfo.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index e67e04b7..c30512ed 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -268,7 +268,7 @@ export default class ControlConnection extends BaseConnection { instance.config.get("instance.name"), instance.id, instance.config.get("instance.assigned_host"), - instance.config.get("factorio.game_port"), + instance.game_port, instance.status, ); } @@ -280,7 +280,7 @@ export default class ControlConnection extends BaseConnection { instance.config.get("instance.name"), instance.id, instance.config.get("instance.assigned_host"), - instance.config.get("factorio.game_port"), + instance.game_port, instance.status, )); } @@ -301,7 +301,7 @@ export default class ControlConnection extends BaseConnection { instance.config.get("instance.name"), instance.id, instance.config.get("instance.assigned_host"), - instance.config.get("factorio.game_port"), + instance.game_port, instance.status, ) )); diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 5dd757ec..ad630579 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -177,7 +177,7 @@ export default class HostConnection extends BaseConnection { let prev = instance.status; instance.status = request.status as lib.InstanceStatus; - instance.config.set("factorio.game_port", request.gamePort); + instance.game_port = request.gamePort || null; logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); this._controller.instanceUpdated(instance); await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index 64111eda..7af224cf 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -26,6 +26,7 @@ export type InstanceStatus = export default class InstanceInfo { config: lib.InstanceConfig; status: InstanceStatus; + game_port: number | null = null; constructor( { config, status }: From 3fac5b00d3da43b08a2fd2f23fad416f2df4c4ec Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 09:48:38 +0200 Subject: [PATCH 05/16] non-null assertion on response.socket --- packages/controller/src/HttpCloser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/controller/src/HttpCloser.ts b/packages/controller/src/HttpCloser.ts index 5359f684..4c52364e 100644 --- a/packages/controller/src/HttpCloser.ts +++ b/packages/controller/src/HttpCloser.ts @@ -74,8 +74,8 @@ export default class HttpCloser { for (let response of this._responses) { if (!response.headersSent) { response.setHeader("Connection", "close"); - } else if (response.socket) { - let socket = response.socket; + } else { + let socket = response.socket!; response.once("finish", () => { socket.end(); }); From c96a13ef30f210507f4deb2cba49d61ad79e2049 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:03:31 +0200 Subject: [PATCH 06/16] fix removed Buffer from base64 auth_secret --- packages/controller/src/WsServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index 4aa56f30..510ac852 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -289,7 +289,7 @@ ${err.stack}` try { let tokenPayload = jwt.verify( data.token, - this.controller.config.get("controller.auth_secret"), + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), { audience: "user" } ); From 3036507baa6001c87cf604c7ade6926399841632 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 10:54:30 +0200 Subject: [PATCH 07/16] clarify null / undefined / 0 remove useless type import and signature export directly cleanup --- packages/controller/controller.ts | 2 +- packages/controller/src/ControlConnection.ts | 17 ++++++++------- packages/controller/src/Controller.ts | 22 ++++++++++---------- packages/controller/src/ControllerRouter.ts | 12 ++--------- packages/controller/src/routes.ts | 15 ++++--------- packages/lib/src/data/messages_controller.ts | 2 +- packages/lib/src/data/messages_core.ts | 2 +- 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index a6344fb1..19068d38 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -/** +/** * Clusterio controller * * Facilitates communication between hosts and control of the cluster diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index c30512ed..de7d303b 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -8,8 +8,8 @@ import path from "path"; import * as lib from "@clusterio/lib"; const { logFilter, logger } = lib; -import BaseConnection from "./BaseConnection"; -import routes from "./routes"; +import BaseConnection from "./BaseConnection"; +import * as routes from "./routes"; import Controller from "./Controller"; import { promises } from "dns"; import InstanceInfo from "./InstanceInfo"; @@ -237,7 +237,7 @@ export default class ControlConnection extends BaseConnection { async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) { let hostId = message.hostId; - if (hostId === null) { + if (hostId === undefined) { hostId = Math.random() * 2**31 | 0; } return this._controller.generateHostToken(hostId); @@ -347,7 +347,7 @@ export default class ControlConnection extends BaseConnection { } async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) { - await this._controller.instanceAssign(request.instanceId, request.hostId || null); + await this._controller.instanceAssign(request.instanceId, request.hostId); } async handleInstanceSetSaveListSubscriptionsRequest(request: lib.InstanceSetSaveListSubscriptionsRequest) { @@ -466,7 +466,10 @@ export default class ControlConnection extends BaseConnection { async handleModPackCreateRequest(request: lib.ModPackCreateRequest) { let modPack = request.modPack; - if (!modPack.id || this._controller.modPacks.has(modPack.id)) { + if (modPack.id === undefined) { + throw new lib.RequestError(`Mod pack need an ID to be created`); + } + if (this._controller.modPacks.has(modPack.id)) { throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`); } this._controller.modPacks.set(modPack.id, modPack); @@ -475,7 +478,7 @@ export default class ControlConnection extends BaseConnection { async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) { let modPack = request.modPack; - if (!modPack.id || !this._controller.modPacks.has(modPack.id)) { + if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) { throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`); } this._controller.modPacks.set(modPack.id, modPack); @@ -496,7 +499,7 @@ export default class ControlConnection extends BaseConnection { modPackUpdated(modPack: lib.ModPack) { if ( this.modPackSubscriptions.all - || this.modPackSubscriptions.modPackIds.includes(modPack.id||-1) + || this.modPackSubscriptions.modPackIds.includes(modPack.id!) ) { this.send(new lib.ModPackUpdateEvent(modPack)); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 8592ba57..6e35efab 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -21,7 +21,7 @@ const { logger, Summary, Gauge } = lib; import HttpCloser from "./HttpCloser"; import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; import metrics from "./metrics"; -import routes from "./routes"; +import * as routes from "./routes"; import UserManager from "./UserManager"; import WsServer from "./WsServer"; import yargs from "yargs"; @@ -671,12 +671,12 @@ export default class Controller { * @param instanceId - ID of Instance to assign. * @param hostId - ID of host to assign instance to. */ - async instanceAssign(instanceId: number, hostId: number | null) { + async instanceAssign(instanceId: number, hostId?: number) { let instance = this.getRequestInstance(instanceId); // Check if target host is connected let newHostConnection: HostConnection | undefined; - if (hostId !== null) { + if (hostId !== undefined) { newHostConnection = this.wsServer.hostConnections.get(hostId); if (!newHostConnection) { // The case of the host not getting the assign instance message @@ -690,7 +690,7 @@ export default class Controller { // Unassign from currently assigned host if it is connected. let currentAssignedHost = instance.config.get("instance.assigned_host"); - if (currentAssignedHost !== null && currentAssignedHost !== undefined && hostId !== currentAssignedHost) { + if (currentAssignedHost !== null && hostId !== currentAssignedHost) { let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost); if (oldHostConnection && !oldHostConnection.connector.closing) { await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId)); @@ -704,7 +704,7 @@ export default class Controller { new lib.InstanceAssignInternalRequest(instanceId, instance.config.serialize("host")) ); } else { - instance.status = "unassigned" + instance.status = "unassigned"; this.instanceUpdated(instance); } } @@ -720,7 +720,7 @@ export default class Controller { async instanceDelete(instanceId: number) { let instance = this.getRequestInstance(instanceId); let hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null && hostId !== undefined) { + if (hostId !== null) { await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId)); } this.instances.delete(instanceId); @@ -742,7 +742,7 @@ export default class Controller { */ async instanceConfigUpdated(instance: InstanceInfo) { let hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null && hostId !== undefined) { + if (hostId !== null) { let connection = this.wsServer.hostConnections.get(hostId); if (connection) { await connection.send( @@ -1036,13 +1036,13 @@ export default class Controller { * destination argument supports address shorthands, see {@link * module:lib.Address.fromShorthand} * - * @param {*|module:lib.Address} address - Where to send it. - * @param {*} requestOrEvent - The request or event to send. - * @returns {Promise<*>|undefined} + * @param address - Where to send it. + * @param requestOrEvent - The request or event to send. + * @returns * Promise that resolves to the response if a request was sent or * undefined if it was an event. */ - sendTo(address: lib.AddressShorthand, requestOrEvent: any) { + sendTo(address: lib.AddressShorthand, requestOrEvent: any): Promise | void { let dst = lib.Address.fromShorthand(address); if (requestOrEvent.constructor.type === "request") { return this.sendRequest(requestOrEvent, dst); diff --git a/packages/controller/src/ControllerRouter.ts b/packages/controller/src/ControllerRouter.ts index b94da0e6..744a98c9 100644 --- a/packages/controller/src/ControllerRouter.ts +++ b/packages/controller/src/ControllerRouter.ts @@ -13,14 +13,6 @@ export default class ControllerRouter { public controller: Controller ) {} - addHostConnection(connection: HostConnection) { - this.controller.wsServer.hostConnections.set(connection.id, connection); - } - - addControlConnection(connection: ControlConnection) { - this.controller.wsServer.controlConnections.set(connection.id, connection); - } - /** * @param origin - Source link of the message. * @param message - Link the message originated from. @@ -72,7 +64,7 @@ export default class ControllerRouter { } if (nextHop === origin) { - msg = `Message would return back to sender ${origin.id}.`; + msg = `Message would return back to sender ${origin.connector.dst}.`; nextHop = undefined; } @@ -92,7 +84,7 @@ export default class ControllerRouter { if (message.type === "request") { nextHop.forwardRequest(message as lib.MessageRequest, origin); } else { - nextHop.connector._sendInternal(message); + nextHop.connector.send(message); } } else { this.warnUnrouted(message, msg); diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index efbff12d..b401bd2c 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -1,5 +1,4 @@ -import type { IncomingMessage, ServerResponse } from "http"; "http"; -import type { Application, Express, Request, Response } from "express"; +import type { Application, Request, Response } from "express"; import type Controller from "./Controller"; import busboy from "busboy"; @@ -259,7 +258,7 @@ interface ProxyStream { timeout: NodeJS.Timeout; } -async function createProxyStream(app: Application): Promise { +export async function createProxyStream(app: Application): Promise { let asyncRandomBytes = util.promisify(crypto.randomBytes); let id = (await asyncRandomBytes(8)).toString("hex"); let stream: ProxyStream = { @@ -632,7 +631,7 @@ async function uploadMod(req: Request, res: Response) { } -function addRouteHandlers(app: Application) { +export function addRouteHandlers(app: Application) { app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next)); app.get("/api/plugins", getPlugins); app.put("/api/upload-export", @@ -652,7 +651,7 @@ function addRouteHandlers(app: Application) { } // Routes used in the web interface and served by the controller -const webRoutes = [ +export const webRoutes = [ "/", "/controller", "/hosts", @@ -668,9 +667,3 @@ const webRoutes = [ "/plugins", "/plugins/:name/view", ]; - -export default { - addRouteHandlers, - webRoutes, - createProxyStream, -} diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 6534fad3..4e2807f7 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -80,7 +80,7 @@ export class HostGenerateTokenRequest { static permission = "core.host.generate_token" as const; constructor( - public hostId: number | null = null, + public hostId?: number, ) { } static jsonSchema = Type.Object({ diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index e27c2bd1..62df2711 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -48,7 +48,7 @@ export class Address { * Shorthand to translate with. * @returns Translated address. */ - static fromShorthand(shorthand: AddressShorthand | Address) { + static fromShorthand(shorthand: AddressShorthand) { 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); } From 23089aa1afa2a610fa6774c1755768779f5ad32c Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 11:00:11 +0200 Subject: [PATCH 08/16] createUser: if no default_role_id, assign no role --- packages/controller/src/UserManager.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/controller/src/UserManager.ts b/packages/controller/src/UserManager.ts index 076baefb..d964aa69 100644 --- a/packages/controller/src/UserManager.ts +++ b/packages/controller/src/UserManager.ts @@ -72,11 +72,9 @@ export default class UserManager { } let defaultRoleId = this._config.get("controller.default_role_id"); - if (defaultRoleId === null) { - throw new Error(`controller.default_role_id is not defined.`); - } + let roles = defaultRoleId ? [defaultRoleId] : []; - let user = new lib.User({ name, roles: [defaultRoleId] }, this.roles); + let user = new lib.User({ name, roles }, this.roles); this.users.set(name, user); return user; } From ef32f6f46688c3264723c89a3344a14801a28eaa Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 11:03:44 +0200 Subject: [PATCH 09/16] .closing > .connector.closing --- packages/controller/src/ControlConnection.ts | 4 ++-- packages/controller/src/HostConnection.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index de7d303b..a0a8eae5 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -408,12 +408,12 @@ export default class ControlConnection extends BaseConnection { // Check connectivity let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId); - if (!sourceHostConnection || sourceHostConnection.closing) { + if (!sourceHostConnection || sourceHostConnection.connector.closing) { throw new lib.RequestError("Source host is not connected to the controller"); } let targetHostConnection = this._controller.wsServer.hostConnections.get(targetHostId); - if (!targetHostConnection || targetHostConnection.closing) { + if (!targetHostConnection || targetHostConnection.connector.closing) { throw new lib.RequestError("Target host is not connected to the controller"); } diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index ad630579..6c21855f 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -30,7 +30,6 @@ export default class HostConnection extends BaseConnection { private _name: any; private _version: any; plugins: Map; - closing: boolean = false; constructor( registerData: any, @@ -65,8 +64,6 @@ export default class HostConnection extends BaseConnection { } this.connector.on("close", () => { - this.closing = true; - // Update status to unknown for instances on this host. for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") !== this._id) { From 25e65910dd4f0ef458032a7d6729699d48e92862 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 11:26:48 +0200 Subject: [PATCH 10/16] Controller: back to hosts, instances, modPacks, mods init to null --- packages/controller/src/BaseConnection.ts | 4 +- packages/controller/src/ControlConnection.ts | 26 +++--- packages/controller/src/Controller.ts | 86 ++++++++++---------- packages/controller/src/ControllerRouter.ts | 2 +- packages/controller/src/HostConnection.ts | 16 ++-- packages/controller/src/WsServer.ts | 4 +- 6 files changed, 67 insertions(+), 71 deletions(-) diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index b12c7e3c..4c55eb9a 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -50,7 +50,7 @@ export default class BaseConnection extends lib.Link { async handleModPackGetRequest(request: lib.ModPackGetRequest): Promise { let { id } = request; - let modPack = this._controller.modPacks.get(id); + let modPack = this._controller.modPacks!.get(id); if (!modPack) { throw new lib.RequestError(`Mod pack with ID ${id} does not exist`); } @@ -62,7 +62,7 @@ export default class BaseConnection extends lib.Link { if (id === null) { throw new lib.RequestError("Default mod pack not set on controller"); } - let modPack = this._controller.modPacks.get(id); + let modPack = this._controller.modPacks!.get(id); if (!modPack) { throw new lib.RequestError(`Default mod pack configured (${id}) does not exist`); } diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index a0a8eae5..557cd092 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -206,7 +206,7 @@ export default class ControlConnection extends BaseConnection { async handleHostListRequest(): Promise { let list = []; - for (let host of this._controller.hosts.values()) { + for (let host of this._controller.hosts!.values()) { list.push(new lib.HostDetails( host.agent, host.version, @@ -275,7 +275,7 @@ export default class ControlConnection extends BaseConnection { async handleInstanceDetailsListRequest() { let list = []; - for (let instance of this._controller.instances.values()) { + for (let instance of this._controller.instances!.values()) { list.push(new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, @@ -457,7 +457,7 @@ export default class ControlConnection extends BaseConnection { } async handleModPackListRequest() { - return [...this._controller.modPacks.values()]; + return [...this._controller.modPacks!.values()]; } async handleModPackSetSubscriptionsRequest(request: lib.ModPackSetSubscriptionsRequest) { @@ -469,30 +469,30 @@ export default class ControlConnection extends BaseConnection { if (modPack.id === undefined) { throw new lib.RequestError(`Mod pack need an ID to be created`); } - if (this._controller.modPacks.has(modPack.id)) { + if (this._controller.modPacks!.has(modPack.id)) { throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`); } - this._controller.modPacks.set(modPack.id, modPack); + this._controller.modPacks!.set(modPack.id, modPack); this._controller.modPackUpdated(modPack); } async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) { let modPack = request.modPack; - if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) { + if (modPack.id === undefined || !this._controller.modPacks!.has(modPack.id)) { throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`); } - this._controller.modPacks.set(modPack.id, modPack); + this._controller.modPacks!.set(modPack.id, modPack); this._controller.modPackUpdated(modPack); } async handleModPackDeleteRequest(request: lib.ModPackDeleteRequest) { let { id } = request; - let modPack = this._controller.modPacks.get(id); + let modPack = this._controller.modPacks!.get(id); if (!modPack) { throw new lib.RequestError(`Mod pack with ID ${id} does not exist`); } modPack.isDeleted = true; - this._controller.modPacks.delete(id); + this._controller.modPacks!.delete(id); this._controller.modPackUpdated(modPack); } @@ -508,7 +508,7 @@ export default class ControlConnection extends BaseConnection { async handleModGetRequest(request: lib.ModGetRequest) { let { name, version } = request; let filename = `${name}_${version}.zip`; - let mod = this._controller.mods.get(filename); + let mod = this._controller.mods!.get(filename); if (!mod) { throw new lib.RequestError(`Mod ${filename} does not exist`); } @@ -516,7 +516,7 @@ export default class ControlConnection extends BaseConnection { } async handleModListRequest() { - return [...this._controller.mods.values()]; + return [...this._controller.mods!.values()]; } static termsMatchesMod(terms: lib.ParsedTerm[], mod: lib.ModInfo) { @@ -556,7 +556,7 @@ export default class ControlConnection extends BaseConnection { type ModVersions = { name: string, versions: lib.ModInfo[] }; let results: Map = new Map(); - for (let mod of this._controller.mods.values()) { + for (let mod of this._controller.mods!.values()) { if ( mod.factorioVersion !== factorioVersion || !ControlConnection.termsMatchesMod(query.terms, mod) @@ -615,7 +615,7 @@ export default class ControlConnection extends BaseConnection { async handleModDownloadRequest(request: lib.ModDownloadRequest) { let { name, version } = request; let filename = `${name}_${version}.zip`; - let mod = this._controller.mods.get(filename); + let mod = this._controller.mods!.get(filename); if (!mod) { throw new lib.RequestError(`Mod ${filename} does not exist`); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 6e35efab..09f79563 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -52,13 +52,13 @@ export default class Controller { app: Application; /** Mapping of host id to host info */ - hosts: Map = new Map(); + hosts: Map | null = null; /** Mapping of instance id to instance info */ - instances: Map = new Map(); + instances: Map | null = null; /** Mapping of mod pack id to mod pack */ - modPacks: Map = new Map(); + modPacks: Map | null = null; /** Mapping of mod names to mod infos */ - mods: Map = new Map(); + mods: Map | null = null; /** User and roles manager for the cluster */ userManager: UserManager; @@ -189,9 +189,9 @@ export default class Controller { let databaseDirectory = this.config.get("controller.database_directory"); await fs.ensureDir(databaseDirectory); - await Controller.loadHosts(path.join(databaseDirectory, "hosts.json"), this.hosts); - await Controller.loadInstances(path.join(databaseDirectory, "instances.json"), this.instances); - await Controller.loadModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks); + this.hosts = await Controller.loadHosts(path.join(databaseDirectory, "hosts.json")); + this.instances = await Controller.loadInstances(path.join(databaseDirectory, "instances.json")); + this.modPacks = await Controller.loadModPacks(path.join(databaseDirectory, "mod-packs.json")); await this.userManager.load(path.join(databaseDirectory, "users.json")); let modsDirectory = this.config.get("controller.mods_directory"); @@ -201,7 +201,7 @@ export default class Controller { this.config.on("fieldChanged", (group, field, prev) => { lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", group, field, prev); }); - for (let instance of this.instances.values()) { + for (let instance of this.instances!.values()) { this.addInstanceHooks(instance); } @@ -315,15 +315,15 @@ export default class Controller { } let databaseDirectory = this.config.get("controller.database_directory"); - if (this.hosts.size > 0) { + if (this.hosts) { await Controller.saveHosts(path.join(databaseDirectory, "hosts.json"), this.hosts); } - if (this.instances.size > 0) { + if (this.instances) { await Controller.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances); } - if (this.modPacks.size > 0) { + if (this.modPacks) { await Controller.saveModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks); } @@ -350,36 +350,36 @@ export default class Controller { logger.info("Goodbye"); } - static async loadHosts(filePath: string, hosts:Map) { - hosts.clear(); - + static async loadHosts(filePath: string): Promise> { + let serialized: [number, HostInfo][]; try { - const serialized = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); - // TODO: Remove after release. - if (serialized.length && !(serialized[0] instanceof Array)) { - for (let [id, host] of serialized) { - hosts.set(id ,host) - } - } + serialized = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); } catch (err: any) { if (err.code !== "ENOENT") { throw err; } - hosts.clear(); + return new Map(); } + + // TODO: Remove after release. + if (serialized.length && !(serialized[0] instanceof Array)) { + return new Map(); // Discard old format. + } + + return new Map(serialized); } static async saveHosts(filePath: string, hosts: Map) { await lib.safeOutputFile(filePath, JSON.stringify([...hosts.entries()], null, 4)); } - static async loadInstances(filePath: string, instances: Map) { + static async loadInstances(filePath: string): Promise> { logger.info(`Loading ${filePath}`); - instances.clear(); + let instances = new Map(); try { - const serialized = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); + let serialized = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" })); for (let serializedConfig of serialized) { let instanceConfig = new lib.InstanceConfig("controller"); await instanceConfig.load(serializedConfig); @@ -395,7 +395,10 @@ export default class Controller { if (err.code !== "ENOENT") { throw err; } + return new Map(); } + + return instances; } static async saveInstances(filePath: string, instances: Map) { @@ -407,31 +410,24 @@ export default class Controller { await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, 4)); } - static async loadModPacks(filePath: string, modPacks: Map) { - modPacks.clear(); - + static async loadModPacks(filePath: string): Promise> { + let json; try { - const json = JSON.parse(await fs.readFile(filePath, { encoding: 'utf8' })); - - for (const modPackJson of json) { - modPacks.set( - modPackJson.id, - lib.ModPack.fromJSON(modPackJson) - ) - } - + json = JSON.parse(await fs.readFile(filePath, { encoding : "utf8" })); } catch (err: any) { if (err.code !== "ENOENT") { throw err; } + return new Map(); } + return new Map(json.map((e: any) => [e.id, lib.ModPack.fromJSON(e)])); } static async saveModPacks(filePath: string, modPacks: Map) { await lib.safeOutputFile(filePath, JSON.stringify([...modPacks.values()], null, 4)); } - static async loadModInfos(modsDirectory: string) { + static async loadModInfos(modsDirectory: string): Promise> { let mods = new Map(); for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) { if (entry.isDirectory()) { @@ -479,14 +475,14 @@ export default class Controller { async deleteMod(name: string, version: string): Promise { let filename = `${name}_${version}.zip`; - let mod = this.mods.get(filename); + let mod = this.mods!.get(filename); if (!mod) { throw new Error(`Mod ${filename} does not exist`); } let modsDirectory = this.config.get("controller.mods_directory"); await fs.unlink(path.join(modsDirectory, filename)); - this.mods.delete(filename); + this.mods!.delete(filename); mod.isDeleted = true; this.modUpdated(mod); } @@ -595,7 +591,7 @@ export default class Controller { * @throws {module:lib.RequestError} if the instance does not exist. */ getRequestInstance(instanceId:number):InstanceInfo { - let instance = this.instances.get(instanceId); + let instance = this.instances!.get(instanceId); if (!instance) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); } @@ -621,7 +617,7 @@ export default class Controller { */ async instanceCreate(instanceConfig: lib.InstanceConfig): Promise { let instanceId = instanceConfig.get("instance.id"); - if (this.instances.has(instanceId)) { + if (this.instances!.has(instanceId)) { throw new lib.RequestError(`Instance with ID ${instanceId} already exists`); } @@ -652,7 +648,7 @@ export default class Controller { instanceConfig.set("factorio.settings", settings); let instance = new InstanceInfo({ config: instanceConfig, status: "unassigned" }); - this.instances.set(instanceId, instance); + this.instances!.set(instanceId, instance); await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, null); this.addInstanceHooks(instance); return instance; @@ -723,7 +719,7 @@ export default class Controller { if (hostId !== null) { await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId)); } - this.instances.delete(instanceId); + this.instances!.delete(instanceId); let prev = instance.status; instance.status = "deleted"; @@ -1097,7 +1093,7 @@ export default class Controller { connection = this.wsServer.controlConnections.get(dst.id); } else if (dst.type === lib.Address.instance) { - let instance = this.instances.get(dst.id); + let instance = this.instances!.get(dst.id); if (!instance) { return; } diff --git a/packages/controller/src/ControllerRouter.ts b/packages/controller/src/ControllerRouter.ts index 744a98c9..c7337611 100644 --- a/packages/controller/src/ControllerRouter.ts +++ b/packages/controller/src/ControllerRouter.ts @@ -40,7 +40,7 @@ export default class ControllerRouter { msg = `Host ${dst.id} is offline`; } } else if (dst.type === lib.Address.instance) { - let instance = this.controller.instances.get(dst.id); + let instance = this.controller.instances!.get(dst.id); if (!instance) { msg = `Instance ${dst.id} does not exist`; } else { diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 6c21855f..9f820595 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -45,7 +45,7 @@ export default class HostConnection extends BaseConnection { this.plugins = new Map(Object.entries(registerData.plugins)); this._checkPluginVersions(); - this._controller.hosts.set(this._id, { + this._controller.hosts!.set(this._id, { agent: this._agent, id: this._id, name: this._name, @@ -65,7 +65,7 @@ export default class HostConnection extends BaseConnection { this.connector.on("close", () => { // Update status to unknown for instances on this host. - for (let instance of this._controller.instances.values()) { + for (let instance of this._controller.instances!.values()) { if (instance.config.get("instance.assigned_host") !== this._id) { continue; } @@ -100,7 +100,7 @@ export default class HostConnection extends BaseConnection { break; case lib.Address.instance: - let instance = this._controller.instances.get(message.src.id); + let instance = this._controller.instances!.get(message.src.id); if (!instance || instance.config.get("instance.assigned_host") !== this._id) { throw new lib.InvalidMessage( `Received message with invalid src ${message.src} from ${origin}` @@ -152,7 +152,7 @@ export default class HostConnection extends BaseConnection { } async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) { - let instance = this._controller.instances.get(request.instanceId); + 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 @@ -182,7 +182,7 @@ export default class HostConnection extends BaseConnection { async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) { // Push updated instance configs - for (let instance of this._controller.instances.values()) { + for (let instance of this._controller.instances!.values()) { if (instance.config.get("instance.assigned_host") === this._id) { await this.send( new lib.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) @@ -195,7 +195,7 @@ export default class HostConnection extends BaseConnection { let instanceConfig = new lib.InstanceConfig("controller"); await instanceConfig.load(instanceData.config as lib.SerializedConfig, "host"); - let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); + 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) { @@ -220,7 +220,7 @@ export default class HostConnection extends BaseConnection { instanceConfig.set("instance.assigned_host", this._id); let newInstance = new InstanceInfo({ config: instanceConfig, status: instanceData.status as InstanceStatus }); - this._controller.instances.set(instanceConfig.get("instance.id"), newInstance); + this._controller.instances!.set(instanceConfig.get("instance.id"), newInstance); this._controller.addInstanceHooks(newInstance); await this.send( new lib.InstanceAssignInternalRequest( @@ -283,7 +283,7 @@ export default class HostConnection extends BaseConnection { user.recalculatePlayerStats(); this._controller.userUpdated(user); - let instance = this._controller.instances.get(instanceId); + let instance = this._controller.instances!.get(instanceId); await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { type: event.type, name: event.name, diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index 510ac852..e1651dd0 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -273,13 +273,13 @@ ${err.stack}` connector.on("close", () => { if (this.hostConnections.get(data.id) === connection) { this.hostConnections.delete(data.id); - this.controller.hostUpdated(this.controller.hosts.get(data.id) as HostInfo); + this.controller.hostUpdated(this.controller.hosts!.get(data.id) as HostInfo); } else { logger.warn("Unlisted HostConnection closed"); } }); this.hostConnections.set(data.id, connection); - this.controller.hostUpdated(this.controller.hosts.get(data.id) as HostInfo); + this.controller.hostUpdated(this.controller.hosts!.get(data.id) as HostInfo); let src = new lib.Address(lib.Address.host, data.id); connector.ready(socket, src, sessionToken, undefined); } From 8256f06a6ad84cd9cc25f936dcdcb44c32befa3a Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 11:29:24 +0200 Subject: [PATCH 11/16] update QueryLogFilter: make maxLevel optional --- packages/controller/src/ControlConnection.ts | 2 +- packages/lib/src/logging_utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 557cd092..76f9b3d7 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -672,7 +672,7 @@ export default class ControlConnection extends BaseConnection { let _request = { limit: request.limit, order: request.order, - maxLevel: request.maxLevel || "info", + maxLevel: request.maxLevel, all: request.all, controller: request.controller, hostIds: request.hostIds, diff --git a/packages/lib/src/logging_utils.ts b/packages/lib/src/logging_utils.ts index e3cc6c6c..42f50045 100644 --- a/packages/lib/src/logging_utils.ts +++ b/packages/lib/src/logging_utils.ts @@ -421,7 +421,7 @@ export interface QueryLogFilter { /** * Maximum log level to include. Higher levels are more verbose. */ - maxLevel: keyof typeof levels; + maxLevel?: keyof typeof levels; /** * Include log entries from controller, all hosts and all instances. */ From 8108fcac816f07dba2f8741c8c51b4b2b4f1e0dc Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 11:56:41 +0200 Subject: [PATCH 12/16] update package.json to fix test running and import version --- packages/controller/controller.ts | 2 +- packages/controller/package.json | 5 +++-- packages/controller/src/WsServer.ts | 2 +- packages/controller/tsconfig.json | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 19068d38..c9c9a8fb 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -32,7 +32,7 @@ const { ConsoleTransport, levels, logger } = lib; import Controller from "./src/Controller"; import UserManager from "./src/UserManager"; -const version = process.env.npm_package_version; +const version = require("./package.json").version; // globals let controller: Controller; diff --git a/packages/controller/package.json b/packages/controller/package.json index 7789ffc4..b61d3d6c 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -4,7 +4,7 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "main": "controller.ts", + "main": "dist/controller/controller.js", "scripts": { "prepare": "webpack-cli --env production && pnpm build", "build": "tsc --build", @@ -36,7 +36,7 @@ }, "devDependencies": { "@clusterio/web_ui": "^2.0.0-alpha.13", - "@types/busboy": "^1.5.0", + "@types/busboy": "^1.5.0", "@types/compression": "^1.7.2", "@types/express": "^4.17.17", "@types/fs-extra": "^11.0.1", @@ -48,6 +48,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.4.14", + "typescript": "^5.1.6", "webpack": "^5.88.2", "webpack-dev-middleware": "^6.1.1", "webpack-merge": "^5.9.0" diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index e1651dd0..e120c8eb 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -17,7 +17,7 @@ export interface WebSocketClusterio extends WebSocket { } -const packageVersion = process.env.npm_package_version || "-1"; +const packageVersion = require("../package.json").version; const wsMessageCounter = new lib.Counter( "clusterio_controller_websocket_message_total", diff --git a/packages/controller/tsconfig.json b/packages/controller/tsconfig.json index aefb3e4b..87c2e4ab 100644 --- a/packages/controller/tsconfig.json +++ b/packages/controller/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["controller.ts", "src/**/*.ts"], + "include": ["controller.ts", "package.json", "src/**/*.ts"], "compilerOptions": { "lib": ["esnext"], "module": "commonjs", @@ -13,6 +13,7 @@ "checkJs": false, "noEmitOnError": false, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "resolveJsonModule": true } } From a26dcf507536588ce62d41252da84fb436c145cb Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 13:31:55 +0200 Subject: [PATCH 13/16] defaultRoleId: explicitly check for null --- packages/controller/src/UserManager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/controller/src/UserManager.ts b/packages/controller/src/UserManager.ts index d964aa69..bd2789c3 100644 --- a/packages/controller/src/UserManager.ts +++ b/packages/controller/src/UserManager.ts @@ -71,8 +71,11 @@ export default class UserManager { throw new Error(`User '${name}' already exists`); } + let roles = []; let defaultRoleId = this._config.get("controller.default_role_id"); - let roles = defaultRoleId ? [defaultRoleId] : []; + if (defaultRoleId !== null) { + roles.push(defaultRoleId) + } let user = new lib.User({ name, roles }, this.roles); this.users.set(name, user); From f2736bae405aed3105fb51fab5b08f9edc2d4dd8 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 14:34:56 +0200 Subject: [PATCH 14/16] tsconfig update; fix function signature; test: add .default --- packages/controller/src/ControlConnection.ts | 47 ++++++++++++++++---- packages/controller/src/WsServer.ts | 6 +-- packages/controller/src/WsServerConnector.ts | 18 ++++++-- packages/controller/tsconfig.json | 6 +-- packages/lib/src/config/classes.ts | 2 +- test/controller/HttpCloser.js | 2 +- test/controller/routes.js | 2 +- test/mock.js | 2 +- 8 files changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 76f9b3d7..b43c274c 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -64,7 +64,7 @@ export default class ControlConnection extends BaseConnection { this._version = registerData.version; this.connector.on("connect", () => { - this.connector._socket.clusterio_ignore_dump = Boolean(this.ws_dumper); + this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper); }); this.connector.on("close", () => { if (this.logTransport) { @@ -264,11 +264,21 @@ export default class ControlConnection extends BaseConnection { async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) { let instance = this._controller.getRequestInstance(request.instanceId); + let assigned_host: number|null|undefined = instance.config.get("instance.assigned_host"); + if (assigned_host === null) { + assigned_host = undefined; + } + + let game_port: number|null|undefined = instance.game_port + if (game_port === null) { + game_port = undefined; + } + return new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, - instance.config.get("instance.assigned_host"), - instance.game_port, + assigned_host, + game_port, instance.status, ); } @@ -276,11 +286,21 @@ export default class ControlConnection extends BaseConnection { async handleInstanceDetailsListRequest() { let list = []; for (let instance of this._controller.instances!.values()) { + let assigned_host: number|null|undefined = instance.config.get("instance.assigned_host"); + if (assigned_host === null) { + assigned_host = undefined; + } + + let game_port: number|null|undefined = instance.game_port + if (game_port === null) { + game_port = undefined; + } + list.push(new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, - instance.config.get("instance.assigned_host"), - instance.game_port, + assigned_host, + game_port, instance.status, )); } @@ -296,12 +316,23 @@ export default class ControlConnection extends BaseConnection { this.instanceSubscriptions.all || this.instanceSubscriptions.instanceIds.includes(instance.id) ) { + let assigned_host: number|null|undefined = instance.config.get("instance.assigned_host"); + if (assigned_host === null) { + assigned_host = undefined; + } + + let game_port: number|null|undefined = instance.game_port + if (game_port === null) { + game_port = undefined; + } + + this.send(new lib.InstanceDetailsUpdateEvent( new lib.InstanceDetails( instance.config.get("instance.name"), instance.id, - instance.config.get("instance.assigned_host"), - instance.game_port, + assigned_host, + game_port, instance.status, ) )); @@ -954,7 +985,7 @@ export default class ControlConnection extends BaseConnection { this.send(new lib.DebugWsMessageEvent(data.direction, data.content)); } }; - this.connector._socket.clusterio_ignore_dump = true; + this.connector._socket!.clusterio_ignore_dump = true; this._controller.debugEvents.on("message", this.ws_dumper); } } diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index e120c8eb..bf57cb79 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -1,6 +1,7 @@ import type { IncomingMessage } from "http"; import type { Duplex } from "stream"; import type Controller from "./Controller"; +import type { WebSocketClusterio } from "./WsServerConnector"; import jwt from "jsonwebtoken"; import WebSocket, { WebSocketServer } from "ws"; @@ -12,11 +13,6 @@ import ControlConnection from "./ControlConnection"; import HostConnection, { HostInfo } from "./HostConnection"; import WsServerConnector from "./WsServerConnector"; -export interface WebSocketClusterio extends WebSocket { - clusterio_ignore_dump: boolean | undefined; -} - - const packageVersion = require("../package.json").version; const wsMessageCounter = new lib.Counter( diff --git a/packages/controller/src/WsServerConnector.ts b/packages/controller/src/WsServerConnector.ts index 3974a9c1..5776f93a 100644 --- a/packages/controller/src/WsServerConnector.ts +++ b/packages/controller/src/WsServerConnector.ts @@ -5,6 +5,11 @@ import events from "events"; import * as lib from "@clusterio/lib"; const { logger } = lib; +export interface WebSocketClusterio extends WebSocket { + clusterio_ignore_dump?: boolean; +} + + /** * Connector for controller connections * @@ -13,6 +18,7 @@ const { logger } = lib; */ export default class WsServerConnector extends lib.WebSocketBaseConnector { _timeoutId: NodeJS.Timeout | null = null; + declare _socket: WebSocketClusterio | null; constructor( dst: lib.Address, @@ -47,7 +53,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector { * @param account - account data to provide to control connection */ ready( - socket: WebSocket, + socket: WebSocket.WebSocket, src: lib.Address, sessionToken: string, account: lib.AccountDetails | undefined @@ -84,7 +90,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector { // It's possible the previous connection hasn't closed yet due to a // stale connection. Terminate it if so. - if (this._state === "connected") { + if (this._state === "connected" && this._socket !== null) { this._socket.terminate(); this._socket.once("close", () => this.continue(socket, lastSeq)); return; @@ -117,6 +123,10 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector { } _attachSocketHandlers() { + if (this._socket === null) { + throw new Error("this._socket is null"); + } + this.startHeartbeat(); this._socket.on("close", (code: number, reason: string) => { @@ -188,7 +198,9 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector { } this._closing = true; - this._socket.close(code, reason); + if (this._socket !== null) { + this._socket.close(code, reason); + } await events.once(this, "close"); } diff --git a/packages/controller/tsconfig.json b/packages/controller/tsconfig.json index 87c2e4ab..a0c4cf46 100644 --- a/packages/controller/tsconfig.json +++ b/packages/controller/tsconfig.json @@ -1,17 +1,15 @@ { "include": ["controller.ts", "package.json", "src/**/*.ts"], "compilerOptions": { - "lib": ["esnext"], + "lib": ["es2023"], "module": "commonjs", "target": "es2022", "rootDir": ".", "outDir": "dist/controller", "composite": true, + "sourceMap": true, "strict": true, "alwaysStrict": true, - "allowJs": true, - "checkJs": false, - "noEmitOnError": false, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index b1e956d1..6eb33fcb 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -58,7 +58,7 @@ function splitOn(separator: string, string: string) { return [string.slice(0, index), string.slice(index + separator.length)]; } -type SerializedConfig = ReturnType; +export type SerializedConfig = ReturnType; type SerializedGroup = ReturnType; /** diff --git a/test/controller/HttpCloser.js b/test/controller/HttpCloser.js index 13201b62..b44dc561 100644 --- a/test/controller/HttpCloser.js +++ b/test/controller/HttpCloser.js @@ -7,7 +7,7 @@ const util = require("util"); const phin = require("phin"); -const HttpCloser = require("@clusterio/controller/dist/controller/src/HttpCloser"); +const HttpCloser = require("@clusterio/controller/dist/controller/src/HttpCloser").default; // Time to wait during async operations to ensure they happened in order. const tick = 20; diff --git a/test/controller/routes.js b/test/controller/routes.js index a9724ea2..ef3f591e 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -7,7 +7,7 @@ const http = require("http"); const phin = require("phin"); const { wait } = require("@clusterio/lib"); -const routes = require("@clusterio/controller/dist/controller/src/routes"); +const routes = require("@clusterio/controller/dist/controller/src/routes").default; const mock = require("../mock"); diff --git a/test/mock.js b/test/mock.js index 13d3b220..8ad5d751 100644 --- a/test/mock.js +++ b/test/mock.js @@ -5,7 +5,7 @@ const express = require("express"); const lib = require("@clusterio/lib"); -const UserManager = require("@clusterio/controller/dist/controller/src/UserManager"); +const UserManager = require("@clusterio/controller/dist/controller/src/UserManager").default; const addr = lib.Address.fromShorthand; From 4024969be86354baae2bb41be3f2cf280a328d5c Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 18:54:49 +0200 Subject: [PATCH 15/16] no default for routes --- test/controller/routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controller/routes.js b/test/controller/routes.js index ef3f591e..a9724ea2 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -7,7 +7,7 @@ const http = require("http"); const phin = require("phin"); const { wait } = require("@clusterio/lib"); -const routes = require("@clusterio/controller/dist/controller/src/routes").default; +const routes = require("@clusterio/controller/dist/controller/src/routes"); const mock = require("../mock"); From e59027f557e54d4e5bf8b1841c8bef8cb2de4db2 Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Mon, 14 Aug 2023 22:27:35 +0200 Subject: [PATCH 16/16] don't cast filename to string --- packages/controller/src/routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index b401bd2c..5a1de286 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -442,7 +442,7 @@ async function uploadSave(req: Request, res: Response) { }); } else if (zipMimes.includes(contentMime)) { - let filename = String(req.query.filename); + let filename = req.query.filename; if (typeof filename !== "string") { requestErrors.push("Missing or invalid filename parameter"); } else if (!filename.endsWith(".zip")) { @@ -456,7 +456,7 @@ async function uploadSave(req: Request, res: Response) { if (errors.length || requestErrors.length) { req.resume(); } else { - tasks.push(handleFile(instanceId, req, filename, contentMime)); + tasks.push(handleFile(instanceId, req, filename as string, contentMime)); } } else {