From 08651b591746e7e3c86db2900329ce4ca4cc2bc4 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Sun, 26 Jul 2026 18:20:22 +0700 Subject: [PATCH] fix: prevent restart downgrades (#951) Co-authored-by: Loi Nguyen <1948922+lntutor@users.noreply.github.com> --- packages/controller/package.json | 2 + packages/controller/src/ControlConnection.ts | 9 +++ packages/controller/src/Controller.ts | 26 +++++++++ packages/host/src/Host.ts | 34 +++++++++++ test/controller/ControlConnection.test.js | 42 ++++++++++++++ test/controller/Controller.js | 17 ++++++ test/host.js | 60 +++++++++++++++++++- 7 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 test/controller/ControlConnection.test.js diff --git a/packages/controller/package.json b/packages/controller/package.json index 4ef85bce..cc070c83 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -43,6 +43,7 @@ "finalhandler": "^1.3.1", "jsonwebtoken": "catalog:", "jszip": "catalog:", + "semver": "^7.7.1", "winston": "catalog:", "winston-daily-rotate-file": "catalog:", "ws": "catalog:", @@ -57,6 +58,7 @@ "@types/finalhandler": "catalog:", "@types/jsonwebtoken": "catalog:", "@types/node": "catalog:", + "@types/semver": "^7.5.0", "@types/ws": "catalog:", "@types/yargs": "^17.0.33", "react": "catalog:", diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index bb0d9812..6baf2fe4 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -189,6 +189,15 @@ export default class ControlConnection extends BaseConnection { if (!this._controller.canRestart) { throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it."); } + const downgrade = await this._controller.checkRestartDowngrade(); + if (downgrade) { + const { installedVersion, runningVersion } = downgrade; + throw new lib.RequestError( + `Cannot restart controller with installed Clusterio version ${installedVersion} because it ` + + `is older than running version ${runningVersion}. ` + + "Stop the controller before starting the older version manually." + ); + } this._controller.shouldRestart = true; this._controller.stop(); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 8051a0cc..bbf571d8 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -12,6 +12,7 @@ import http from "http"; import https from "https"; import jwt from "jsonwebtoken"; import path from "path"; +import semver from "semver"; import stream from "stream"; import * as lib from "@clusterio/lib"; @@ -654,6 +655,31 @@ export default class Controller { return false; } + async checkRestartDowngrade() { + try { + const runningVersion = this.config.get("controller.version"); + const packageJsonPath = require.resolve("@clusterio/controller/package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); + const installedVersion = packageJson.version; + + if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) { + logger.warn( + `Unable to compare running controller version ${runningVersion} ` + + `with installed version ${installedVersion}.` + ); + return null; + } + + if (semver.lt(installedVersion, runningVersion)) { + return { installedVersion, runningVersion }; + } + } catch (err: any) { + logger.warn(`Failed to check controller version before restart:\n${err.stack ?? err.message}`); + } + + return null; + } + onAutosaveIntervalChanged() { if (this.autosaveInterval) { clearInterval(this.autosaveInterval); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index b99392dc..9c9d5abe 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "path"; import events from "events"; import pidusage from "pidusage"; +import semver from "semver"; import setBlocking from "set-blocking"; import stream from "stream"; import util from "util"; @@ -496,6 +497,14 @@ export default class Host extends lib.Link { if (!this.canRestart) { throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it."); } + const downgrade = await this.checkRestartDowngrade(); + if (downgrade) { + const { installedVersion, runningVersion } = downgrade; + throw new lib.RequestError( + `Cannot restart host with installed Clusterio version ${installedVersion} because it is older than ` + + `running version ${runningVersion}. Stop the host before starting the older version manually.` + ); + } process.exitCode = 1; this.shutdown(); } @@ -875,6 +884,31 @@ export default class Host extends lib.Link { return false; } + async checkRestartDowngrade() { + try { + const runningVersion = this.config.get("host.version"); + const packageJsonPath = require.resolve("@clusterio/host/package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); + const installedVersion = packageJson.version; + + if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) { + logger.warn( + `Unable to compare running host version ${runningVersion} ` + + `with installed version ${installedVersion}.` + ); + return null; + } + + if (semver.lt(installedVersion, runningVersion)) { + return { installedVersion, runningVersion }; + } + } catch (err: any) { + logger.warn(`Failed to check host version before restart:\n${err.stack ?? err.message}`); + } + + return null; + } + async handleHostMetricsRequest() { let requests: Promise>[] = []; for (let instanceConnection of this.instanceConnections.values()) { diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js new file mode 100644 index 00000000..90483a08 --- /dev/null +++ b/test/controller/ControlConnection.test.js @@ -0,0 +1,42 @@ +"use strict"; +const assert = require("assert").strict; +const { ControlConnection } = require("@clusterio/controller"); + +describe("controller/src/ControlConnection", function() { + describe(".handleControllerRestartRequest()", function() { + let mockController; + + beforeEach(function() { + mockController = { + canRestart: true, + shouldRestart: false, + stopped: false, + async checkRestartDowngrade() { return null; }, + stop() { this.stopped = true; }, + }; + }); + + async function restart() { + await ControlConnection.prototype.handleControllerRestartRequest.call({ + _controller: mockController, + }); + } + + it("restarts when the installed version is not older", async function() { + await restart(); + assert.equal(mockController.shouldRestart, true); + assert.equal(mockController.stopped, true); + }); + + it("rejects a downgrade without stopping the controller", async function() { + mockController.checkRestartDowngrade = async () => ({ + installedVersion: "1.0.0", + runningVersion: "2.0.0", + }); + + await assert.rejects(restart, /Stop the controller before starting the older version manually/); + assert.equal(mockController.shouldRestart, false); + assert.equal(mockController.stopped, false); + }); + }); +}); diff --git a/test/controller/Controller.js b/test/controller/Controller.js index 7b0d4127..62c1c264 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -252,6 +252,23 @@ describe("controller/src/Controller", function() { assert.equal(controller.config.restartRequired, true); }); }); + describe(".checkRestartDowngrade()", function() { + it("returns null when the installed version matches", async function() { + controller.config.set("controller.version", controllerVersion); + assert.equal(await controller.checkRestartDowngrade(), null); + }); + it("returns null when the installed version is newer", async function() { + controller.config.set("controller.version", "0.0.0"); + assert.equal(await controller.checkRestartDowngrade(), null); + }); + it("returns both versions when the installed version is older", async function() { + controller.config.set("controller.version", "999.0.0"); + assert.deepEqual(await controller.checkRestartDowngrade(), { + installedVersion: controllerVersion, + runningVersion: "999.0.0", + }); + }); + }); describe(".factorioVersions", function() { const _fetch = global.fetch; diff --git a/test/host.js b/test/host.js index ad7a16aa..4308c189 100644 --- a/test/host.js +++ b/test/host.js @@ -118,9 +118,9 @@ describe("Host testing", function() { }); describe(".checkRestartRequired()", function() { let host; + const hostVersion = require("@clusterio/host/package.json").version; beforeEach(function() { // This can be used more generally, but i did not want to interfere with handleSyncUserListsEvent - const hostVersion = require("@clusterio/host/package.json").version; const pluginInfos = [{ name: "global_chat", requirePath: path.dirname(require.resolve("@clusterio/host/package.json")), @@ -174,5 +174,63 @@ describe("Host testing", function() { assert.equal(host.config.restartRequired, true); }); }); + describe(".checkRestartDowngrade()", function() { + let host; + const hostVersion = require("@clusterio/host/package.json").version; + beforeEach(function() { + const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion }); + const hostConnector = new HostConnector(hostConfig, []); + host = new Host(hostConnector, hostConfig, []); + }); + it("returns null when the installed version matches", async function() { + assert.equal(await host.checkRestartDowngrade(), null); + }); + it("returns null when the installed version is newer", async function() { + host.config.set("host.version", "0.0.0"); + assert.equal(await host.checkRestartDowngrade(), null); + }); + it("returns both versions when the installed version is older", async function() { + host.config.set("host.version", "999.0.0"); + assert.deepEqual(await host.checkRestartDowngrade(), { + installedVersion: hostVersion, + runningVersion: "999.0.0", + }); + }); + }); + describe(".handleHostRestartRequest()", function() { + let mockHost; + beforeEach(function() { + process.exitCode = undefined; + mockHost = { + canRestart: true, + shutdownCalled: false, + async checkRestartDowngrade() { return null; }, + shutdown() { this.shutdownCalled = true; }, + handleHostRestartRequest: Host.prototype.handleHostRestartRequest, + }; + }); + afterEach(function() { + process.exitCode = undefined; + }); + + it("restarts when the installed version is not older", async function() { + await mockHost.handleHostRestartRequest(); + assert.equal(process.exitCode, 1); + assert.equal(mockHost.shutdownCalled, true); + }); + it("rejects a downgrade without shutting down the host", async function() { + mockHost.checkRestartDowngrade = async () => ({ + installedVersion: "1.0.0", + runningVersion: "2.0.0", + }); + + await assert.rejects( + () => mockHost.handleHostRestartRequest(), + /Stop the host before starting the older version manually/ + ); + assert.equal(process.exitCode, undefined); + assert.equal(mockHost.shutdownCalled, false); + }); + }); }); });