mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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:
7 files changed
+16
-227
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
@@ -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")));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user