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 -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 };
}