Approve standalone hosts like any other host

A standalone install no longer adds its host to the controller database
by signing a token and writing hosts.json itself. The host asks to join
when it first starts and is approved once with the link it prints.
This commit is contained in:
Danielv123 committed 2026-10-30 23:15:22 +01:00
1 parent a4894aae9d
commit e5701a8dae
7 files changed
+16 -227

No files matched your search

+1
View File
@@ -251,6 +251,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
After completing the installation and creating your admin user, start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
The first time a host starts it prints a link for approving it, open the link and click Approve.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start. You can also use the "Connect via Steam" button to launch Factorio directly and connect to the server using Steam's protocol handler.
+3 -1
View File
@@ -87,10 +87,12 @@ Install dependencies with:
This installs dependencies and builds the packages.
`pnpm-lock.yaml` is intentionally ignored; do not force-add it or assume a frozen-lockfile install is supported.
For a fresh development cluster, run the installer and select standalone mode to create `config-controller.json`, `config-host.json`, and the credentials needed to connect them:
For a fresh development cluster, run the installer and select standalone mode to create `config-controller.json` and `config-host.json`:
node packages/create --dev
The first time the host starts it prints a link for approving it in the web interface.
You can skip this step if you already have a working controller and host configuration.
To add plugins to use in the development environment use
+1 -1
View File
@@ -19,7 +19,7 @@ The installer runs without prompts when all the questions for the mode are answe
## Modes
- `standalone`: controller and host on this computer.
- `standalone`: controller and host on this computer. The host is approved like any other host the first time it starts.
The host is added to the controller directly and does not need to be approved.
- `controller`: controller only.
- `host`: host only, joins the cluster of an existing controller, see [Setting up a host](#setting-up-a-host).
+6 -17
View File
@@ -10,7 +10,6 @@ import util from "node:util";
import yargs from "yargs";
import { levels, logger, setLogLevel } from "./logging.js";
import { createLocalHost } from "./local_host.js";
import { copyPluginTemplates } from "./template.js";
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
@@ -869,25 +868,15 @@ async function main() {
}
if (answers.mode === "standalone") {
// The controller is not running, so the host is added to its database directly. The id
// from the host config is kept so running the installer again reuses the same host.
let result = await execHost(["config", "show", "host.id"]);
let hostId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
let host = await createLocalHost({ name: "local", id: Number.isInteger(hostId) ? hostId : undefined });
await execHost(["config", "set", "host.id", host.hostId]);
await execHost(["config", "set", "host.name", host.name]);
// Default to localhost on correct port for host in standalone mode
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
await execHost(["config", "set", "host.controller_token", host.token]);
}
if (answers.mode === "host") {
// The host requests to join the cluster when it is started.
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
answers.hostName = "local";
answers.controllerUrl = `http://localhost:${answers.httpPort}/`;
}
if (["standalone", "host"].includes(answers.mode)) {
// The host requests to join the cluster when it is started.
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await setRemoteUpdates(execHost, "host", answers.remoteNpm);
@@ -906,7 +895,7 @@ async function main() {
/* eslint-disable no-console */
console.log(`Successfully installed ${answers.mode}`);
if (answers.mode === "host") {
if (["standalone", "host"].includes(answers.mode)) {
console.log(
`Start the host with run-host${process.platform === "win32" ? ".cmd" : ".sh"}, then approve it on the ` +
`Hosts page of ${answers.controllerUrl}`
-68
View File
@@ -1,68 +0,0 @@
// Adds the host of a standalone install to the controller database, used by the installer.
// Only uses Node.js builtins as it runs before Clusterio is installed.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
function signHostToken(payload, secret) {
const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url");
const unsigned = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(payload)}`;
const signature = crypto.createHmac("sha256", secret).update(unsigned).digest("base64url");
return `${unsigned}.${signature}`;
}
/**
* Add a host to the database of a controller installed in the same directory
*
* The controller must not be running, as it would overwrite the database with
* what it has in memory.
*
* @param {object} options - Host options.
* @param {string=} options.directory - Directory with config-controller.json in it.
* @param {string} options.name - Name of the host.
* @param {number=} options.id -
* Id to give the host, picks a random unused id if not given. If a host with
* this id exists, a new token is signed for it and its name is kept.
* @param {string=} options.version - Version to record for the host.
* @returns {Promise<{ hostId: number, token: string, name: string }>} id, token and name of the host.
*/
export async function createLocalHost({ directory = ".", name, id, version = "" }) {
const configPath = path.join(directory, "config-controller.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
const secret = config["controller.auth_secret"];
if (typeof secret !== "string" || !secret) {
throw new Error(`${configPath} has no controller.auth_secret`);
}
const databaseDirectory = path.resolve(directory, config["controller.database_directory"] ?? "database");
const hostsPath = path.join(databaseDirectory, "hosts.json");
let hosts = [];
try {
hosts = JSON.parse(await fs.readFile(hostsPath, "utf8"));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
const now = Math.floor(Date.now() / 1000);
const existing = id === undefined ? undefined : hosts.find(host => host.id === id);
let hostId = id;
if (existing) {
// Running the installer again signs a new token for the same host.
name = existing.name;
} else {
const ids = new Set(hosts.map(host => host.id));
while (hostId === undefined || ids.has(hostId)) {
hostId = crypto.randomInt(1, 2 ** 31);
}
hosts.push({ id: hostId, name, version, plugins: {}, token_valid_after: now });
await fs.mkdir(databaseDirectory, { recursive: true });
const temporary = path.join(databaseDirectory, "hosts.tmp.json");
await fs.writeFile(temporary, JSON.stringify(hosts, null, "\t"), { flush: true });
await fs.rename(temporary, hostsPath);
}
const token = signHostToken({ aud: "host", host: hostId, iat: now }, Buffer.from(secret, "base64"));
return { hostId, token, name };
}
+5 -42
View File
@@ -1,11 +1,9 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import jwt from "jsonwebtoken";
import { escapeArg } from "../packages/create/escape_arg.js";
@@ -145,13 +143,10 @@ describe("create installer CLI", function() {
fs.appendFileSync(process.env.CREATE_TEST_COMMANDS, JSON.stringify(command) + "\\n");
const stdout = new PassThrough();
const stderr = new PassThrough();
// Lets tests give the host id a config show prints.
const output = process.env.CREATE_TEST_HOST_ID && command.includes("config show host.id")
? process.env.CREATE_TEST_HOST_ID + "\\n" : "";
queueMicrotask(() => {
stdout.end();
stderr.end();
callback(null, output, "");
callback(null, "", "");
});
return { stdout, stderr };
};
@@ -206,28 +201,17 @@ describe("create installer CLI", function() {
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
});
it("adds the host of a standalone install to the controller database", async function() {
const secret = crypto.randomBytes(256).toString("base64");
await fs.writeFile(path.join(directory, "config-controller.json"), JSON.stringify({
"controller.auth_secret": secret,
}));
it("leaves joining the cluster to the host of a standalone install", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
const { stdout } = await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed standalone/);
const hosts = JSON.parse(await fs.readFile(path.join(directory, "database", "hosts.json")));
assert.equal(hosts.length, 1);
assert.equal(hosts[0].name, "local");
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
const tokenSet = `${hostBin} config set host.controller_token `;
const token = recorded.find(command => command.startsWith(tokenSet)).slice(tokenSet.length);
const payload = jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" });
assert.equal(payload.host, hosts[0].id);
for (const expected of [
hostSet("host.id", hosts[0].id),
hostSet("host.name", "local"),
hostSet("host.controller_url", "http://localhost:8123/"),
`${controllerBin} config set controller.allow_remote_updates true`,
@@ -239,28 +223,7 @@ describe("create installer CLI", function() {
]) {
assert(recorded.includes(expected), `missing ${expected}`);
}
});
it("keeps the host of a standalone install when run again", async function() {
const secret = crypto.randomBytes(256).toString("base64");
await fs.writeFile(path.join(directory, "config-controller.json"), JSON.stringify({
"controller.auth_secret": secret,
}));
await fs.mkdir(path.join(directory, "database"));
const existing = [{ id: 1234, name: "local", version: "2.0.3", plugins: {}, token_valid_after: 1 }];
await fs.writeFile(path.join(directory, "database", "hosts.json"), JSON.stringify(existing));
const { stdout } = await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
], { CREATE_TEST_HOST_ID: "1234" });
assert.match(stdout, /Successfully installed standalone/);
assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "database", "hosts.json"))), existing);
const recorded = await commands();
assert(recorded.includes(hostSet("host.id", 1234)), "host id was changed");
const tokenSet = `${hostBin} config set host.controller_token `;
const token = recorded.find(command => command.startsWith(tokenSet)).slice(tokenSet.length);
assert.equal(jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" }).host, 1234);
assert(!recorded.some(command => command.includes("host.controller_token")));
});
});
});
-98
View File
@@ -1,98 +0,0 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import HostRecord from "@clusterio/controller/dist/node/src/HostRecord.js";
import { createLocalHost } from "../../packages/create/local_host.js";
describe("create/local_host createLocalHost()", function() {
let directory;
let secret;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-local-host-"));
secret = crypto.randomBytes(256).toString("base64");
});
afterEach(async function() {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
});
// Written with the controller's own config class to match its file format.
async function writeControllerConfig(fields = {}) {
const configPath = path.join(directory, "config-controller.json");
const config = new lib.ControllerConfig("controller", undefined, configPath);
config.set("controller.auth_secret", secret);
for (const [name, value] of Object.entries(fields)) {
config.set(name, value);
}
await config.save(true);
}
async function loadHosts(databaseDirectory = "database") {
const provider = new lib.JsonIdDatastoreProvider(
path.join(directory, databaseDirectory, "hosts.json"), HostRecord.fromJSON.bind(HostRecord),
);
return await provider.load();
}
it("adds a host the controller loads with a token it accepts", async function() {
await writeControllerConfig();
const before = Math.floor(Date.now() / 1000);
const result = await createLocalHost({ directory, name: "local" });
assert.equal(result.name, "local");
assert(Number.isInteger(result.hostId) && result.hostId >= 1 && result.hostId < 2 ** 31);
const hosts = await loadHosts();
assert.deepEqual([...hosts.keys()], [result.hostId]);
const record = hosts.get(result.hostId);
assert.equal(record.name, "local");
assert.equal(record.version, "");
assert.deepEqual(record.plugins, new Map());
assert(record.tokenValidAfter >= before);
const payload = jwt.verify(result.token, Buffer.from(secret, "base64"), { audience: "host" });
assert.equal(payload.host, result.hostId);
assert(payload.iat >= record.tokenValidAfter, "token must not be issued before token_valid_after");
});
it("appends to an existing database in the configured directory", async function() {
await writeControllerConfig({ "controller.database_directory": "db" });
await fs.mkdir(path.join(directory, "db"));
await fs.writeFile(path.join(directory, "db", "hosts.json"), JSON.stringify([
{ id: 1, name: "existing", version: "2.0.0", plugins: {} },
]));
const result = await createLocalHost({ directory, name: "local", id: 7, version: "2.0.3" });
assert.equal(result.hostId, 7);
const hosts = await loadHosts("db");
assert.deepEqual([...hosts.keys()], [1, 7]);
assert.equal(hosts.get(1).name, "existing");
assert.equal(hosts.get(7).version, "2.0.3");
await assert.rejects(fs.access(path.join(directory, "database")), { code: "ENOENT" });
});
it("signs a new token for an existing host with the same id", async function() {
await writeControllerConfig();
const first = await createLocalHost({ directory, name: "first", id: 7 });
const before = await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8");
const second = await createLocalHost({ directory, name: "second", id: 7 });
assert.equal(second.hostId, 7);
assert.equal(second.name, "first", "the name of the existing host should be kept");
assert.equal(await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8"), before);
const record = (await loadHosts()).get(7);
for (const token of [first.token, second.token]) {
const payload = jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" });
assert.equal(payload.host, 7);
assert(payload.iat >= record.tokenValidAfter);
}
});
it("throws if the controller config has no auth secret", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
await assert.rejects(createLocalHost({ directory, name: "local" }), /controller\.auth_secret/);
});
});