Replace host tokens with host pairing

Hosts join the cluster by running the installer, which shows a code and
waits until the request is approved under Hosts > Add host in the web
interface or with clusterioctl host approve. An admin can pass their
user token to the installer to approve the host right away. The token
is only used for that and is not saved.

The controller only accepts hosts that have a record, and only
approving a host creates one. Revoking a host deletes its record, so
its token can no longer be used to reconnect. Hosts that have connected
before keep working.

Removes host generate-token, revoke-token and create-config from
clusterioctl, bootstrap generate-host-token, the host token messages and
the core.host.generate_token, revoke_token and create_config
permissions, which are also stripped from stored roles. Adds
clusterioctl host pending, approve, reject and revoke, and the
core.host.approve and core.host.revoke permissions.

Standalone installs add the host to the controller database directly,
since the controller is not running during the install.
This commit is contained in:
Danielv123 committed 2026-10-30 06:11:32 +01:00
1 parent fa2883d74e
commit 87d4f5c64e
49 files changed
+4855 -473

No files matched your search

+15 -2
View File
@@ -272,10 +272,23 @@ If you have copied these files over to `/etc/systemd/system/` then you can start
## Setting up remote hosts
Run the installer as described in the installation section and choose "Host only" as the operating mode to install, it'll ask for a controller URL and an authentication token.
The URL is the same as what is needed to connect to the web interface, and the authentication token can be generated on the Hosts page in the web interface.
Run the installer as described in the installation section and choose "Host only" as the operating mode to install, it'll ask for a name for the host and a controller URL.
The URL is the same as what is needed to connect to the web interface.
Once it has installed everything the installer prints a code and waits for the host to be approved:
Waiting for approval. Approve "my-host" with code ABC-DEF on the Hosts page of https://example.com/, or run: npx clusterioctl host approve ABC-DEF
Approve it in the web interface by clicking Add host on the Hosts page and approving the request with the same code, or with `npx clusterioctl host approve <code>`.
The installer then saves the connection details for the host and exits.
Only approve a request if its code matches the one shown by the installer, approving gives the host full access to the cluster.
Approving requires the `core.host.approve` permission, which the Cluster Admin role has.
If you have that permission you can skip the wait by passing your own authentication token to the installer with `--controller-token <token>`, it is only used to approve the host and is not saved.
Once you start up the host it should show up in the Hosts list and be available for assigning and running instances on.
To remove a host from the cluster, unassign its instances and click Revoke host access on the page for the host in the web interface, or run `npx clusterioctl host revoke <host>`.
This disconnects the host and its saved connection details stop working.
To add the computer back to the cluster run the installer again in the same directory, it reuses the existing installation and asks for approval again.
## Setting up clusterioctl
+6 -2
View File
@@ -93,7 +93,7 @@ Defaults to null meaning the header is never trusted.
### controller.public_url
Public address the controller is accessible on.
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
@@ -277,6 +277,7 @@ Defaults to 1000.
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
The installer sets this, controller.allow_plugin_updates and controller.allow_plugin_install from one question about remote updates and plugin installs.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
@@ -326,6 +327,7 @@ Defaults to "New Host".
### host.id
Immutable numeric id of the host which uniquely identifies this host in the cluster.
The controller picks the id when the host is approved and the installer writes it here, the controller does not accept hosts with an id it has not approved.
Hidden in the web interface.
Defaults to a random 31 bit number.
@@ -365,7 +367,8 @@ Defaults to "http://localhost:8080/".
### host.controller_token
Access token used for authenticating with the controller.
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
The installer writes it together with host.id when the host is approved, see [Setting up remote hosts](/README.md#setting-up-remote-hosts).
The token only works while the host exists on the controller, if the host is revoked run the installer again in the host directory to get it approved again.
The value can only be read and changed from the host itself.
Defaults to "enter token here".
@@ -418,6 +421,7 @@ Defaults to 60.
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
The installer sets this, host.allow_plugin_updates and host.allow_plugin_install from one question about remote updates and plugin installs.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
+20
View File
@@ -59,3 +59,23 @@ Imports of `assert`, `buffer`, `events`, `os`, `path`, `process`, `stream`, `uti
The `container` property of `WebPluginContext` is now the plugin's ES module namespace, `await container.get("./web")` returns the module instead of a factory function.
`lib.PluginWebpackEnvInfo` is renamed to `lib.PluginWebEnvInfo`.
## Host pairing
Host tokens are no longer generated by users.
A host joins the cluster by running the installer and having the code it shows approved, see [Host pairing](protocol.md#host-pairing) for the HTTP endpoints it uses.
- `HostGenerateTokenRequest` and `HostConfigCreateRequest` are removed.
Pending requests are listed with `HostPairingListRequest`, approved with `HostPairingApproveRequest` and rejected with `HostPairingRejectRequest`, and changes to them are sent to subscribers of `HostPairingUpdatesEvent`.
- `HostRevokeTokensRequest` is replaced by `HostRevokeRequest`, which deletes the host instead of only invalidating its tokens.
It throws `ConflictError` while instances are assigned to the host.
- The permissions `core.host.generate_token`, `core.host.revoke_token` and `core.host.create_config` are removed.
Check `core.host.approve` and `core.host.revoke` instead.
- `clusteriocontroller bootstrap generate-host-token` is removed, scripts that set up hosts should run the installer with `--controller-token` to approve the host with a user token.
Host records are no longer created when a host connects, only by approving it, and the controller rejects hosts it has no record of.
Revoking a host deletes its record and note.
Plugins that keep data per host should remove it when the host is deleted.
On the controller the `update` event of `controller.hosts` gives the record with `isDeleted` set to true, and subscribers of `HostUpdatesEvent` receive the host with `isDeleted` set to true.
A host that is revoked and installed again gets a new id.
+61
View File
@@ -32,6 +32,63 @@ The [send-buffer setting](../configuration.md#controllermax_send_buffer_size) co
![Connection flow](../assets/connection_flow.svg)
## Host pairing
The host id and token sent in `registerHost` come from the installer, which gets them from the controller over HTTP before the host is started.
Hosts are only added to the controller by approving a pairing request, and `registerHost` is rejected with close code 3000 (Unauthorized) for a host id the controller has no record of.
Revoking a host deletes its record, so its token stops working and the installer has to pair it again, which gives it a new id.
See [Setting up remote hosts](/README.md#setting-up-remote-hosts) for how this looks to the user.
The endpoints below take and return JSON bodies and are sent with `Cache-Control: no-store`.
The `POST` endpoints return errors as `{"error": "<message>"}` and respond with 503 while the controller is in recovery mode.
A request body that is not valid JSON or is larger than 4 kB is rejected with 400 or 413 before it reaches the endpoint, without a JSON body.
`GET /api/host-pairing/check` also works in recovery mode, and responds to a token that is not valid with a plain 401 without a JSON body.
A user or host token is sent in either an `Authorization: Bearer <token>` header or an `X-Access-Token` header.
### `POST /api/host-pairing`
Creates a request for a host to join the cluster.
- `name: string` - Name of the host, at most 100 characters after trimming whitespace, without control characters.
- `version: string` (optional) - Version of the installer.
Responds with 202 and `{"requestId": string, "code": string}`.
The `code` is shown to the user to approve the request with, formatted as six characters like `ABC-DEF`.
Codes are matched ignoring case and anything that is not a letter or digit, so `abcdef` matches `ABC-DEF`.
The `requestId` is a secret used to poll for the result, it is never shown to users.
If a user token is sent and the user has the `core.host.approve` permission the host is created right away and the response is 200 with the same body as an approved poll.
An invalid token gives 401 and a user without the permission gives 403, a token is never ignored to create a pending request instead.
A missing or too long name gives 400.
Too many requests from the same address or in total gives 429 with a `Retry-After` header in seconds.
### `POST /api/host-pairing/poll`
- `requestId: string` - Id returned when the request was created.
Responds with 202 and `{"status": "pending"}` while the request is waiting for approval.
Once approved it responds with 200 and `{"hostId": integer, "token": string, "name": string}`, where `name` is the name the host was approved with, which can differ from the one asked for.
Rejected requests give 403, and unknown or expired requests give 404.
The result of an approved or rejected request is only returned once, after which the request is gone.
A pending request expires 30 seconds after it was last polled, and after an hour of waiting.
The installer polls every 2 seconds.
### `POST /api/host-pairing/cancel`
- `requestId: string` - Id returned when the request was created.
Removes a pending request, sent by the installer when it stops waiting.
Responds with 204 whether or not the request existed.
### `GET /api/host-pairing/check`
Checks a host token.
Responds with 200 and `{"valid": true}` if the token is valid and the host it was issued to still exists, and with 401 otherwise.
The installer uses this to tell whether an existing host config still works when it is run again.
## Connector flow
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
@@ -119,6 +176,10 @@ Host handshake for establishing a new connection session.
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the host.
The controller closes the connection with code 3000 (Unauthorized) if the token is invalid, was issued for another id, or if there is no host with this id, see [Host pairing](#host-pairing).
If this happens when the host starts it exits with code 8, and the run scripts created by the installer do not restart it.
A host that is revoked while connected is disconnected, is rejected when it tries to connect again, and shuts down.
#### `registerControl`
Control handshake for enstablishing a new connection session.
+34 -9
View File
@@ -16,17 +16,40 @@ The easiest way to do this is to run `npx clusteriocontroller bootstrap create-c
Lists all hosts.
### Generate token
Hosts are added to the cluster by running the installer on the computer the host runs on and approving the code it shows, see [Setting up remote hosts](/README.md#setting-up-remote-hosts).
Listing, approving and rejecting installers waiting for approval needs the `core.host.approve` permission.
ctl> host generate-token [--id <host id>]
### List pending hosts
Generate a token for a host. `--id` is the host id.
ctl> host pending
### Create config
Lists installers waiting for approval with the code they show, the name they asked for, the address they connected from and how long they have been waiting.
A request goes away when its installer stops waiting, and after waiting for an hour.
ctl> host create-config [--id <host id>] [--name <host name>] [--generate-token <bool>] [--output <path to output>]
### Approve host
Create a host config. `--id` id the host id. `--name` is the host name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-host.json", "-" for stdout.
ctl> host approve <code> [--name <host name>]
Approves the installer showing the code and prints the id of the new host.
The code is not case sensitive and the dash can be left out.
`--name` gives the host a different name than the one entered in the installer.
Approving gives the host full access to the cluster, check with `host pending` that the name and address match the computer you are setting up.
### Reject host
ctl> host reject <code>
Rejects the installer showing the code, the installer then exits with an error.
### Revoke host
ctl> host revoke <host>
Specify the host by name or ID.
Removes the host from the cluster, disconnects it and makes its token stop working.
Instances assigned to the host have to be unassigned first.
Requires the `core.host.revoke` permission.
To add the computer back to the cluster, run the installer again in the directory the host is installed in.
### Update Clusterio
@@ -270,9 +293,11 @@ To be written.
## Plugins (Remote)
By default, remote updates are enabled unless you selected "no" during installation.
Remote updates allow ctl and the web UI to update plugins on machines within the cluster.
However, remote installations are disabled by default to prevent the risk of arbitrary code execution through the installation of malicious npm packages onto the machine. See below if you wish to enable this feature.
The installer asks "Allow remote updates and plugin installs from the web interface?" and the answer sets `allow_remote_updates`, `allow_plugin_updates` and `allow_plugin_install` together on that machine, pass `--remote-npm` or `--no-remote-npm` to answer it from the command line.
Remote updates allow ctl and the web UI to update Clusterio and plugins on machines within the cluster.
Remote installs allow them to install any package from npm onto the machine, which is a risk of arbitrary code execution through malicious npm packages, so only allow them if you trust everyone with access to install plugins.
In a config created without the installer remote updates are enabled and remote installs are disabled by default.
See below for changing these settings after installing.
### List
+1 -1
View File
@@ -63,7 +63,7 @@ This creates a private key file named `key.pem` and a public certificate file na
npx clusteriocontroller config set controller.tls_certificate cert.pem
npx clusteriocontroller config set controller.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, which also makes the installer trust the certificate when it asks the controller to approve the host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
**For run-host.cmd**
-12
View File
@@ -23,7 +23,6 @@ import util from "util";
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import jwt from "jsonwebtoken";
// homebrew modules
import * as lib from "@clusterio/lib";
@@ -186,14 +185,6 @@ async function handleBootstrapCommand(
// eslint-disable-next-line no-console
console.log(userManager.signUserToken(user));
} else if (subCommand === "generate-host-token") {
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
// Auth secret is never null after startup
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
));
} else if (subCommand === "create-ctl-config") {
let admin = userManager.getByName(args.name);
if (!admin) {
@@ -322,9 +313,6 @@ async function initialize(): Promise<InitializeParameters> {
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
yargs.positional("name", { describe: "Name of the user", type: "string" });
})
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
yargs.positional("id", { describe: "ID of the host", type: "number" });
})
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
yargs.option("output", {
describe: "Path to output config (- for stdout)", type: "string",
+1
View File
@@ -6,6 +6,7 @@ export { default as HostConnection } from "./src/HostConnection.js";
export { default as InstanceRecord } from "./src/InstanceRecord.js";
export { default as InstanceManager } from "./src/InstanceManager.js";
export { default as HostRecord } from "./src/HostRecord.js";
export { default as HostPairingManager, HostPairingError } from "./src/HostPairingManager.js";
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
export { default as UserManager } from "./src/UserManager.js";
export { default as UserRecord } from "./src/UserRecord.js";
+29 -28
View File
@@ -81,10 +81,11 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
this.handle(lib.HostRevokeRequest, this.handleHostRevokeRequest.bind(this));
this.handle(lib.HostListRequest, this.handleHostListRequest.bind(this));
this.handle(lib.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this));
this.handle(lib.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this));
this.handle(lib.HostPairingListRequest, this.handleHostPairingListRequest.bind(this));
this.handle(lib.HostPairingApproveRequest, this.handleHostPairingApproveRequest.bind(this));
this.handle(lib.HostPairingRejectRequest, this.handleHostPairingRejectRequest.bind(this));
this.handle(lib.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this));
this.handle(lib.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this));
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
@@ -248,49 +249,49 @@ export default class ControlConnection extends BaseConnection {
this._controller.config.setProp(field as keyof lib.ControllerConfigFields, prop, value, "control");
}
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
async handleHostRevokeRequest(request: lib.HostRevokeRequest) {
const host = this._controller.hosts.getMutable(request.hostId);
if (!host) {
throw new Error(`Unknown host id (${request.hostId})`);
throw new lib.NotFoundError(`Unknown host id (${request.hostId})`);
}
host.tokenValidAfter = Math.floor(Date.now() / 1000);
const assigned = [...this._controller.instances.values()].filter(
instance => instance.config.get("instance.assigned_host") === request.hostId
);
if (assigned.length) {
const names = assigned.map(instance => instance.config.get("instance.name")).join(", ");
throw new lib.ConflictError(
`Cannot revoke host ${host.name} while instances are assigned to it: ${names}`
);
}
this._controller.hosts.delete(host);
this._controller.deleteNote("host", request.hostId, this.user.name);
// Disconnect before saving so the host is cut off even if the save fails.
const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId);
if (hostConnection) {
hostConnection.connector.terminate();
}
this._controller.hosts.set(host);
logger.audit(`User ${this.user.name} revoked host ${host.name} (${host.id})`);
await this._controller.hosts.save();
}
async handleHostListRequest(): Promise<lib.HostDetails[]> {
return [...this._controller.hosts.values()].map(host => host.toHostDetails());
}
async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) {
let hostId = message.hostId;
if (hostId === undefined) {
hostId = Math.random() * 2**31 | 0;
}
return this._controller.generateHostToken(hostId);
async handleHostPairingListRequest(): Promise<lib.HostPairingDetails[]> {
this._controller.hostPairing.prune();
return [...this._controller.hostPairing.requests.values()];
}
async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) {
const hostConfig = new lib.HostConfig("host");
async handleHostPairingApproveRequest(request: lib.HostPairingApproveRequest) {
return await this._controller.hostPairing.approve(request.code, request.name, this.user.name);
}
hostConfig.set("host.controller_url", this._controller.getControllerUrl());
if (request.id !== undefined) {
hostConfig.set("host.id", request.id);
}
if (request.name !== undefined) {
hostConfig.set("host.name", request.name);
}
if (request.generateToken) {
this.user.checkPermission("core.host.generate_token");
hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id")));
}
return hostConfig.toJSON();
async handleHostPairingRejectRequest(request: lib.HostPairingRejectRequest) {
this._controller.hostPairing.reject(request.code, this.user.name);
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
+28 -1
View File
@@ -29,6 +29,7 @@ import UserRecord from "./UserRecord.js";
import UserManager from "./UserManager.js";
import WsServer from "./WsServer.js";
import HostRecord from "./HostRecord.js";
import HostPairingManager from "./HostPairingManager.js";
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
import ControllerRouter from "./ControllerRouter.js";
import InstanceManager from "./InstanceManager.js";
@@ -91,6 +92,8 @@ export default class Controller {
users: UserManager;
/** Instance manager for the cluster, also contains access to the user records */
instances: InstanceManager;
/** Pending requests from host installers to join the cluster */
hostPairing: HostPairingManager;
// Possible states are new, starting, running, stopping, stopped
private _state: string = "new";
@@ -296,6 +299,7 @@ export default class Controller {
this.users = new UserManager(users, roles, config);
this.instances = new InstanceManager(instances, this);
this.hostPairing = new HostPairingManager(this);
this.modStore.on("change", mod => {
this.modsUpdated([mod]);
@@ -305,6 +309,7 @@ export default class Controller {
this.subscriptions = new lib.SubscriptionController(config.get("controller.max_subscription_filters"));
this.subscriptions.handle(lib.SystemInfoUpdateEvent, this.handleSystemInfoSubscription.bind(this));
this.subscriptions.handle(lib.HostUpdatesEvent, this.handleHostSubscription.bind(this));
this.subscriptions.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingSubscription.bind(this));
this.subscriptions.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsSubscription.bind(this));
this.subscriptions.handle(
lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsSubscription.bind(this)
@@ -318,6 +323,7 @@ export default class Controller {
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
this.hosts.on("update", this.hostsUpdated.bind(this));
this.hostPairing.requests.on("update", this.hostPairingRequestsUpdated.bind(this));
this.instances.records.on("update", this.instanceUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
@@ -367,6 +373,7 @@ export default class Controller {
async _startInternal(args: ControllerArgs) {
this.logDirectory = args.logDirectory;
this.hostPairing.start();
this.clusterLogIndex = await lib.LogIndex.load(path.join(this.logDirectory, "cluster"));
this.clusterLogBuildInterval = setInterval(() => {
@@ -582,6 +589,8 @@ export default class Controller {
this.autosaveInterval = undefined;
}
this.hostPairing.stop();
if (this.clusterLogIndex) {
await this.clusterLogIndex.save();
}
@@ -869,6 +878,10 @@ export default class Controller {
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
// migrate: host token permissions were removed with host pairing in 2.1
const removedPermissions = new Set([
"core.host.generate_token", "core.host.revoke_token", "core.host.create_config",
]);
return serialized.map(json => {
json.permissions = json.permissions.map(permission => {
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
@@ -876,7 +889,7 @@ export default class Controller {
return "core.instance.save.subscribe";
}
return permission;
});
}).filter(permission => !removedPermissions.has(permission));
return lib.Role.pluginMigrations.reduce(json);
});
}
@@ -1017,6 +1030,20 @@ export default class Controller {
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
}
hostPairingRequestsUpdated(requests: lib.HostPairingDetails[]) {
this.subscriptions.broadcast(new lib.HostPairingUpdatesEvent(requests));
}
async handleHostPairingSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const requests = [...this.hostPairing.requests.values()].filter(
pairing => pairing.updatedAtMs > request.lastRequestTimeMs,
);
return requests.length ? new lib.HostPairingUpdatesEvent(requests) : null;
}
async handleHostSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
+19 -15
View File
@@ -34,19 +34,16 @@ export default class HostConnection extends BaseConnection {
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostRecord(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
this.plugins,
true,
remoteAddress,
previousHostInfo?.publicAddress,
previousHostInfo?.tokenValidAfter,
0,
false,
);
// Hosts are only created by approving them, WsServer rejects hosts without a record.
const record = this._controller.hosts.getMutable(registerData.id);
if (!record) {
throw new Error(`Host ${registerData.id} is not registered`);
}
record.version = registerData.version;
record.plugins = this.plugins;
record.connected = true;
record.remoteAddress = remoteAddress;
this.info = record;
this._controller.hosts.set(this.info);
this._checkPluginVersions();
@@ -71,7 +68,7 @@ export default class HostConnection extends BaseConnection {
this._controller.instances.records.setMany(instances);
this.info.connected = false;
this._controller.hosts.set(this.info);
this._saveInfo();
});
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
@@ -175,10 +172,17 @@ export default class HostConnection extends BaseConnection {
return {};
}
/** Store changes to the host record, unless the host was revoked */
private _saveInfo() {
if (this._controller.hosts.getMutable(this.id) === this.info) {
this._controller.hosts.set(this.info);
}
}
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
this.info.name = event.update.name;
this.info.publicAddress = event.update.publicAddress;
this._controller.hosts.set(this.info);
this._saveInfo();
}
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
@@ -0,0 +1,361 @@
import type Controller from "./Controller.js";
import crypto from "crypto";
import net from "net";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import HostRecord from "./HostRecord.js";
/** Symbols used in pairing codes, avoids 0/O and 1/I/L */
const codeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const codeLength = 6;
/** Time a request is kept after the last poll from the installer */
export const pollTimeoutMs = 30e3;
/** Maximum time a request can stay pending */
export const maxPendingMs = 60 * 60e3;
/** Maximum number of pending requests in total */
export const maxPending = 50;
/** Maximum number of pending requests from one address */
export const maxPendingPerAddress = 3;
/** Maximum number of requests created from one address per minute */
export const maxCreatesPerMinute = 10;
/** Maximum length of a host name */
export const maxNameLength = 100;
/** Control and bidirectional override characters refused in host names */
const nameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
/** Result of an approved request, sent to the installer */
export interface HostPairingResult {
hostId: number,
token: string,
name: string,
}
/** Error with an HTTP status code to respond to the installer with */
export class HostPairingError extends Error {
constructor(
message: string,
public status: number,
/** Seconds to wait before trying again, for status 429 */
public retryAfter?: number,
) {
super(message);
}
}
interface PairingRequest {
/** Secret the installer polls with, never shown to users */
secret: string,
details: lib.HostPairingDetails,
/** Address with IPv6 addresses grouped by /64, used for limits */
addressKey: string,
lastPollMs: number,
state: "pending" | "approving" | "approved" | "rejected",
result?: HostPairingResult,
}
/**
* Strips the IPv4 mapped prefix and groups IPv6 addresses by their /64
*
* @param address - Address to make a key for
* @returns key used to count requests from an address.
*/
export function addressKey(address: string) {
if (address.startsWith("::ffff:") && net.isIPv4(address.slice(7))) {
return address.slice(7);
}
if (!net.isIPv6(address)) {
return address;
}
const [head, tail = ""] = address.split("::");
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
const missing = 8 - headParts.length - tailParts.length;
const parts = address.includes("::")
? [...headParts, ...new Array(missing).fill("0"), ...tailParts]
: headParts;
return `${parts.slice(0, 4).map(part => part.toLowerCase().replace(/^0+(?=.)/, "")).join(":")}::/64`;
}
/**
* Normalise a pairing code typed by a user
*
* @param code - Code as typed, may contain dashes and lower case letters.
* @returns code formatted as shown by the installer.
*/
export function normaliseCode(code: string) {
const plain = code.toUpperCase().replace(/[^0-9A-Z]/g, "");
return `${plain.slice(0, 3)}-${plain.slice(3)}`;
}
function generateCode() {
let code = "";
for (let i = 0; i < codeLength; i++) {
code += codeAlphabet[crypto.randomInt(codeAlphabet.length)];
}
return normaliseCode(code);
}
/**
* Keeps track of requests from host installers to join the cluster
*
* Requests only live in memory, and only while the installer keeps polling
* for the result.
*/
export default class HostPairingManager {
/** Pending requests by code, broadcast to subscribed control links */
requests = new lib.SubscribableDatastore<lib.HostPairingDetails>();
private _bySecret = new Map<string, PairingRequest>();
private _byCode = new Map<string, PairingRequest>();
/** Creation times of recent requests by address key */
private _recentCreates = new Map<string, number[]>();
private _pruneInterval?: ReturnType<typeof setInterval>;
constructor(
private _controller: Controller,
) { }
start() {
this._pruneInterval = setInterval(() => this.prune(), 5e3);
this._pruneInterval.unref();
}
stop() {
clearInterval(this._pruneInterval);
this._pruneInterval = undefined;
}
/**
* Check and trim a host name given by an installer or a user
*
* @param name - Name to check.
* @returns the trimmed name.
*/
static validateName(name: unknown) {
if (typeof name !== "string" || !name.trim()) {
throw new HostPairingError("Host name is required", 400);
}
const trimmed = name.trim();
if (trimmed.length > maxNameLength) {
throw new HostPairingError(`Host name must be at most ${maxNameLength} characters`, 400);
}
// Names come from unauthenticated requests and are printed to terminals and logs.
if (nameControlCharacters.test(trimmed)) {
throw new HostPairingError("Host name may not contain control characters", 400);
}
return trimmed;
}
/**
* Create an approved host with a new id and token
*
* The host record is saved to disk before this returns.
*
* @param name - Name of the host.
* @returns the id, token and name of the new host.
*/
async createHost(name: string): Promise<HostPairingResult> {
const hosts = this._controller.hosts;
let hostId;
do {
hostId = crypto.randomInt(1, 2 ** 31);
} while (hosts.has(hostId));
const record = new HostRecord(
hostId, name, "", new Map(), false, "", "", Math.floor(Date.now() / 1000),
);
hosts.set(record);
try {
await hosts.save();
} catch (err) {
hosts.delete(record);
throw err;
}
return { hostId, token: this._controller.generateHostToken(hostId), name };
}
/**
* Create a pending request
*
* @param name - Name the installer asked for.
* @param version - Version of the installer.
* @param remoteAddress - Address the request came from.
* @returns the secret to poll with and the code to show the user.
*/
create(name: string, version: string, remoteAddress: string) {
this.prune();
const key = addressKey(remoteAddress);
const now = Date.now();
const recent = (this._recentCreates.get(key) ?? []).filter(ms => ms > now - 60e3);
if (recent.length >= maxCreatesPerMinute) {
throw new HostPairingError(
"Too many requests from this address", 429, Math.ceil((recent[0] + 60e3 - now) / 1000),
);
}
const pendingFromAddress = [...this._bySecret.values()].filter(
request => request.addressKey === key && request.state === "pending"
).length;
if (pendingFromAddress >= maxPendingPerAddress) {
throw new HostPairingError("Too many pending requests from this address", 429, 30);
}
if (this.requests.size >= maxPending) {
throw new HostPairingError("Too many pending requests", 429, 30);
}
recent.push(now);
this._recentCreates.set(key, recent);
let code;
do {
code = generateCode();
} while (this._byCode.has(code));
const request: PairingRequest = {
secret: crypto.randomBytes(32).toString("base64url"),
details: new lib.HostPairingDetails(code, name, remoteAddress, version, now),
addressKey: key,
lastPollMs: now,
state: "pending",
};
this._bySecret.set(request.secret, request);
this._byCode.set(code, request);
this.requests.set(request.details);
logger.verbose(`Host pairing request ${code} for "${name}" from ${remoteAddress}`);
return { requestId: request.secret, code };
}
/**
* Poll for the result of a request
*
* @param secret - Request id returned when the request was created.
* @returns the result if the request was approved, or null if still pending.
*/
poll(secret: string): HostPairingResult | null {
this.prune();
const request = this._bySecret.get(secret);
if (!request) {
throw new HostPairingError("Request not found", 404);
}
request.lastPollMs = Date.now();
if (request.state === "approved") {
this._remove(request);
return request.result!;
}
if (request.state === "rejected") {
this._remove(request);
throw new HostPairingError("Request was rejected", 403);
}
return null;
}
/**
* Cancel a pending request
*
* @param secret - Request id returned when the request was created.
*/
cancel(secret: string) {
const request = this._bySecret.get(secret);
if (request && request.state === "pending") {
logger.verbose(`Host pairing request ${request.details.code} cancelled by the installer`);
this._remove(request);
}
}
private _pending(code: string) {
this.prune();
const request = this._byCode.get(normaliseCode(code));
if (!request || request.state !== "pending") {
throw new lib.NotFoundError(`No pending host request with code ${code}`);
}
return request;
}
/**
* Approve a pending request, creating the host
*
* @param code - Code of the request.
* @param name - Name to give the host, defaults to the requested name.
* @param approvedBy - Name of the user approving it, for logging.
* @returns the id of the new host.
*/
async approve(code: string, name: string | undefined, approvedBy: string) {
const request = this._pending(code);
let hostName;
try {
hostName = name === undefined ? request.details.name : HostPairingManager.validateName(name);
} catch (err: any) {
throw new lib.ValidationError(err.message);
}
request.state = "approving";
let result;
try {
result = await this.createHost(hostName);
} catch (err) {
request.state = "pending";
throw err;
}
request.state = "approved";
request.result = result;
this.requests.delete(request.details);
logger.audit(
`User ${approvedBy} approved host ${result.name} (${result.hostId}) with code ` +
`${request.details.code} from ${request.details.remoteAddress}`
);
return result.hostId;
}
/**
* Reject a pending request
*
* @param code - Code of the request.
* @param rejectedBy - Name of the user rejecting it, for logging.
*/
reject(code: string, rejectedBy: string) {
const request = this._pending(code);
request.state = "rejected";
this.requests.delete(request.details);
logger.audit(
`User ${rejectedBy} rejected host ${request.details.name} with code ` +
`${request.details.code} from ${request.details.remoteAddress}`
);
}
private _remove(request: PairingRequest) {
this._bySecret.delete(request.secret);
this._byCode.delete(request.details.code);
if (this.requests.has(request.details.code)) {
this.requests.delete(request.details);
}
}
/** Remove requests the installer stopped polling for or that waited too long */
prune() {
const now = Date.now();
for (const request of this._bySecret.values()) {
if (request.state === "approving") {
continue;
}
const expired = now - request.lastPollMs > pollTimeoutMs
|| (request.state === "pending" && now - request.details.createdAtMs > maxPendingMs);
if (!expired) {
continue;
}
if (request.state === "approved") {
logger.warn(
`Approved host ${request.result!.name} (${request.result!.hostId}) was never picked up ` +
"by its installer, revoke it on the hosts page if it is not needed"
);
}
this._remove(request);
}
for (const [key, times] of this._recentCreates) {
if (!times.some(ms => ms > now - 60e3)) {
this._recentCreates.delete(key);
}
}
}
}
+11 -1
View File
@@ -264,7 +264,10 @@ ${err.stack}`
}
let host = this.controller.hosts.get(data.id);
if (!tokenPayload.iat || tokenPayload.iat < (host?.tokenValidAfter ?? 0)) {
if (!host) {
throw new Error("host is not registered, run the installer again in the host directory");
}
if (!tokenPayload.iat || tokenPayload.iat < host.tokenValidAfter) {
throw new Error("invalid token");
}
} catch (err: any) {
@@ -303,6 +306,13 @@ ${err.stack}`
);
}
// The host may have been revoked while waiting for the old connection to close.
if (!this.controller.hosts.has(data.id)) {
wsRejectedConnectionsCounter.inc();
socket.close(lib.ConnectionClosed.Unauthorized, "Authentication failed: host was revoked");
return;
}
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
// Handle close before HostConnection does. This avoids a host
+130 -14
View File
@@ -1,5 +1,6 @@
import type { Application, Request, Response } from "express";
import express, { type Application, type Request, type Response } from "express";
import Controller from "./Controller.js";
import HostPairingManager, { HostPairingError } from "./HostPairingManager.js";
import busboy from "busboy";
import crypto from "crypto";
@@ -215,19 +216,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
res.locals.user = user;
res.locals.user = userFromToken(req.app.locals.controller, token);
} catch (err) {
res.sendStatus(401);
return;
@@ -236,6 +225,118 @@ function validateUserToken(req: Request, res: Response, next: any) {
next();
}
function userFromToken(controller: Controller, token: string) {
const tokenPayload = jwt.verify(token, controller.authSecret, { audience: "user" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = controller.users.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
return user;
}
// Responds with the status of a pairing error, returns false for other errors.
function sendHostPairingError(res: Response, err: unknown) {
if (!(err instanceof HostPairingError)) {
return false;
}
if (err.retryAfter !== undefined) {
res.set("Retry-After", String(err.retryAfter));
}
res.status(err.status).json({ error: err.message });
return true;
}
// Checks recovery mode and the request id for the pairing endpoints, returns false if responded to.
function checkHostPairingRequest(req: Request, res: Response, needsRequestId: boolean) {
if (req.app.locals.controller.recoveryMode) {
res.status(503).json({ error: "Controller is in recovery mode" });
return false;
}
if (needsRequestId && typeof req.body?.requestId !== "string") {
res.status(400).json({ error: "requestId is required" });
return false;
}
return true;
}
// Request from an installer for a host to join the cluster.
async function createHostPairing(req: Request, res: Response) {
if (!checkHostPairingRequest(req, res, false)) {
return;
}
const controller = req.app.locals.controller;
const remoteAddress = controller.wsServer.remoteAddr(req);
try {
const name = HostPairingManager.validateName(req.body?.name);
const version = typeof req.body?.version === "string" ? req.body.version.slice(0, 64) : "";
// A user allowed to approve hosts can skip the wait by passing their token.
const token = getAccessToken(req);
if (token !== undefined) {
let user;
try {
user = userFromToken(controller, token);
} catch (err) {
res.status(401).json({ error: "Invalid user token" });
return;
}
try {
user.checkPermission("core.host.approve");
} catch (err) {
res.status(403).json({ error: `User ${user.name} is not allowed to approve hosts` });
return;
}
const result = await controller.hostPairing.createHost(name);
logger.audit(
`User ${user.name} approved host ${result.name} (${result.hostId}) from ${remoteAddress} ` +
"with their token"
);
res.status(200).json(result);
return;
}
res.status(202).json(controller.hostPairing.create(name, version, remoteAddress));
} catch (err) {
if (!sendHostPairingError(res, err)) {
throw err;
}
}
}
// Poll from an installer for the result of its request.
function pollHostPairing(req: Request, res: Response) {
if (!checkHostPairingRequest(req, res, true)) {
return;
}
try {
const result = req.app.locals.controller.hostPairing.poll(req.body.requestId);
if (result) {
res.status(200).json(result);
} else {
res.status(202).json({ status: "pending" });
}
} catch (err) {
if (!sendHostPairingError(res, err)) {
throw err;
}
}
}
// Cancel from an installer that stopped waiting.
function cancelHostPairing(req: Request, res: Response) {
if (!checkHostPairingRequest(req, res, true)) {
return;
}
req.app.locals.controller.hostPairing.cancel(req.body.requestId);
res.sendStatus(204);
}
// Handle an uploaded export package.
async function uploadExport(req: Request, res: Response) {
if (req.get("Content-Type") !== "application/zip") {
@@ -686,6 +787,21 @@ export function addRouteHandlers(app: Application) {
);
app.get("/api/plugins", getPlugins);
app.get("/api/cluster-name", getClusterName);
// Set before parsing so errors from the body parser and token check are not cached either.
const noStore = (req: Request, res: Response, next: any) => {
res.set("Cache-Control", "no-store");
next();
};
const pairingJson = express.json({ limit: "4kb" });
app.post("/api/host-pairing",
noStore, pairingJson,
(req:Request, res:Response, next:any) => createHostPairing(req, res).catch(next)
);
app.post("/api/host-pairing/poll", noStore, pairingJson, pollHostPairing);
app.post("/api/host-pairing/cancel", noStore, pairingJson, cancelHostPairing);
app.get("/api/host-pairing/check", noStore, validateHostToken, (req: Request, res: Response) => {
res.json({ valid: true });
});
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
+116
View File
@@ -4,3 +4,119 @@ Installer for Clusterio.
Install the latest [Node.js LTS release](https://nodejs.org/en/download) before running the installer.
See [the Clusterio installation guide](https://github.com/clusterio/clusterio#installation) for platform-specific instructions.
Run the installer in a new empty directory:
npm init "@clusterio"
It asks which components to install and how to set them up.
Every question can be answered with an option instead, pass options to the installer after `--`:
npm init "@clusterio" -- --mode host --host-name my-host --controller-url https://example.com/
The installer runs without prompts when all the questions for the mode are answered with options.
## Modes
- `standalone`: controller and host on this computer.
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).
- `ctl`: the clusterioctl command line interface only.
- `plugins`: plugins only, for adding plugins to an existing installation.
- `plugin-template`: files for writing a new plugin.
## Options
| Option | Modes | Description |
| --- | --- | --- |
| `--mode <mode>` | all | Operating mode to install. |
| `--admin <name>` | standalone, controller | Name of the admin account to create. |
| `--http-port <port>` | standalone, controller | Port the controller web interface listens on, defaults to 8080. |
| `--host-name <name>` | host | Name to request for the host, defaults to the name of the computer. |
| `--controller-url <url>` | host, ctl | URL of the controller, the same as for its web interface. |
| `--controller-token <token>` | host, ctl | ctl: authentication token saved for clusterioctl. host: authentication token of a user that approves the host without waiting, it is not saved. |
| `--no-pair` | host | Install the host without pairing it with the controller. |
| `--public-address <address>` | standalone, host | DNS name or IP address players connect to this computer with. |
| `--factorio-dir <path>` | standalone, host | Path to the Factorio installation. |
| `--self-signed-cert <path>` | standalone, host | Certificate to trust for a controller using a self-signed certificate. It is used for pairing and added to the run scripts. |
| `--remote-npm`, `--no-remote-npm` | standalone, controller, host | Allow or deny remote updates, plugin updates and plugin installs from the web interface. |
| `--plugins <package...>` | all | Plugins to install, pass `--plugins` without packages to install none. |
| `--plugin-template <template...>`, `--plugin-name <name>` | plugin-template | Templates to use and name of the new plugin. |
| `--log-level <level>` | all | Log level to print, defaults to info. |
| `--allow-install-as-root` | all | Linux only, allow installing as root (not recommended). |
| `--migrate-rename` | | Migrate an installation from before the slave/master rename. |
## Setting up a host
A new host has to be approved before it can join the cluster:
1. Run the installer with the host mode on the computer for the host.
It asks for a name for the host, the controller URL, the public address, the Factorio installation, whether to allow remote updates and which plugins to install.
2. Once it has installed everything it prints a code and waits for the host to be approved:
Waiting for approval. Approve "my-host" with code ABC-DEF on the Hosts page of https://example.com/, or run: npx clusterioctl host approve ABC-DEF
3. Approve the request by clicking Add host on the Hosts page of the web interface, or run `npx clusterioctl host approve ABC-DEF`.
Only approve a request if its code matches the one shown by the installer.
Approving requires the `core.host.approve` permission.
4. The installer saves `host.id`, `host.name`, `host.controller_url` and `host.controller_token` to `config-host.json` and exits.
Start the host with `run-host.cmd`, `run-host.sh` or the systemd service in the `systemd` folder.
Press Ctrl+C while the installer waits to cancel the request.
A request that is not approved within an hour expires.
Pass `--controller-token <token>` with the token of a user that has the `core.host.approve` permission to approve the host without waiting.
The token is only used for approving the host and is not saved.
If the controller uses a self-signed certificate pass it with `--self-signed-cert <path>`, see [setting up TLS](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate).
When a host is revoked in the web interface or with `npx clusterioctl host revoke <host>` its token stops working and a running host shuts down.
Starting it again fails with exit code 8, which the run scripts do not restart on.
## Running the installer again
Running the installer with the host mode in a directory with `config-host.json` in it does not install anything, it only pairs the host again if needed:
- If the controller accepts the saved token it prints "Host is already set up" and exits with code 0.
- If it does not, for example because the host was revoked or never approved, it asks for the host name and controller URL, defaulting to the saved ones, and pairs the host again.
The host gets a new id, other settings in `config-host.json` are kept.
- If the controller cannot be reached it prints a warning, keeps the saved config and exits with code 0.
The token is only checked with the controller in `config-host.json`.
Passing a different `--controller-url` pairs the host with that controller instead, without sending it the saved token, and the host gets a new id.
If the same controller only moved to a new address, change the address with `npx clusteriohost config set host.controller_url <url>` instead, which keeps the host.
## Docker
To run a host in a container install it when building the image with all answers given as options and `--no-pair`.
Then run the installer again with the host mode when the container starts, it pairs the host on the first start and exits straight away on later starts.
```dockerfile
FROM node:22
# Install the Factorio headless server to /opt/factorio here
RUN mkdir /clusterio && chown node:node /clusterio
USER node
WORKDIR /clusterio
RUN npm init "@clusterio" -- --mode host --no-pair --public-address factorio.example.com \
--factorio-dir /opt/factorio --no-remote-npm --plugins
VOLUME /clusterio
CMD npm init "@clusterio" -- --mode host --controller-url "$CONTROLLER_URL" --host-name "$HOST_NAME" \
${CONTROLLER_TOKEN:+--controller-token "$CONTROLLER_TOKEN"} && exec ./run-host.sh
```
docker run -d --name my-host -v my-host:/clusterio -p 34100-34199:34100-34199/udp \
-e CONTROLLER_URL=https://example.com/ -e HOST_NAME=my-host my-host-image
docker logs my-host
- The volume holds `config-host.json`, without it every new container pairs as a new host.
Docker copies the installation from the image into a new empty volume, later image updates do not change the installation in an existing volume.
- The code to approve is printed to the container log, or set `CONTROLLER_TOKEN` to approve the host without waiting.
- Stop the container after revoking its host, otherwise a restart runs the installer again and the host asks to join again.
+255 -48
View File
@@ -10,6 +10,7 @@ import util from "node:util";
import yargs from "yargs";
import { levels, logger, setLogLevel } from "./logging.js";
import { checkHostToken, createLocalHost, pairHost, PairingError } from "./pair.js";
import { copyPluginTemplates } from "./template.js";
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
@@ -161,22 +162,193 @@ async function execCtl(args) {
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
function validateHostToken(token) {
let parts = token.split(".");
if (parts.length !== 3) {
throw new InstallError("Invalid token");
function validateHostName(input) {
if (!input || !input.trim()) {
return "Host name may not be empty";
}
if (input.trim().length > 100) {
return "Host name may be at most 100 characters";
}
if (hostNameControlCharacters.test(input)) {
return "Host name may not contain control characters";
}
return true;
}
let parsed;
// Same characters as the controller refuses in host names.
const hostNameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
function validateControllerUrl(input) {
let url;
try {
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
url = new URL(input);
} catch (err) {
throw new InstallError("Invalid token");
return "Controller URL must be a URL like https://example.com/";
}
if (!["http:", "https:"].includes(url.protocol)) {
return "Controller URL must start with http:// or https://";
}
return true;
}
// The host requires the controller URL to end with a slash.
function normaliseControllerUrl(input) {
const url = new URL(input);
if (!url.pathname.endsWith("/")) {
url.pathname += "/";
}
return url.href;
}
async function readSelfSignedCert(args) {
if (!args.selfSignedCert) {
return undefined;
}
try {
return await fs.readFile(args.selfSignedCert);
} catch (err) {
throw new InstallError(`Failed to read --self-signed-cert ${args.selfSignedCert}: ${err.message}`);
}
}
async function installerVersion() {
return JSON.parse(await fs.readFile(path.join(import.meta.dirname, "package.json"))).version;
}
// Remote updates, plugin updates and plugin installs are allowed or denied together.
async function setRemoteUpdates(exec, component, allow) {
for (let field of ["allow_remote_updates", "allow_plugin_updates", "allow_plugin_install"]) {
await exec(["config", "set", `${component}.${field}`, allow]);
}
}
// Turn an error from pairing into a message for the user.
function pairingFailed(err) {
const retry = "Run the installer again with --mode host in this directory to retry.";
if (err.name === "AbortError") {
return new InstallError(`Pairing cancelled. ${retry}`);
}
if (err instanceof PairingError) {
return new InstallError(`Pairing failed: ${err.message}. ${retry}`);
}
if (typeof err.code === "string") {
const hint = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(err.code)
? " Pass the certificate with --self-signed-cert if the controller uses a self-signed one."
: "";
return new InstallError(`Failed to connect to the controller: ${err.message}.${hint} ${retry}`);
}
return err;
}
// Request to join the cluster and save the id and token of the host once approved.
async function pairWithController(args, { hostName, controllerUrl }) {
const ca = await readSelfSignedCert(args);
await execHost(["config", "set", "host.name", hostName]);
await execHost(["config", "set", "host.controller_url", controllerUrl]);
// Ctrl-C may arrive twice under npm init, as npm forwards it to the installer, so keep
// listening until the request has been cancelled.
const abort = new AbortController();
const onSigint = () => { abort.abort(); };
process.on("SIGINT", onSigint);
// The close patch for inquirer on Windows leaves the console in raw mode, where Ctrl-C is
// read as input instead of raising SIGINT. No prompts follow, so it can be turned off.
if (process.platform === "win32" && process.stdin.isTTY && process.stdin.isRaw) {
process.stdin.setRawMode(false);
}
let result;
try {
logger.info(`Requesting to join the cluster at ${controllerUrl}`);
result = await pairHost({
controllerUrl,
name: hostName,
version: await installerVersion(),
token: args.controllerToken,
ca,
signal: abort.signal,
onPending: ({ code }) => {
// eslint-disable-next-line no-console
console.log(
`Waiting for approval. Approve "${hostName}" with code ${code} on the Hosts page of ` +
`${controllerUrl}, or run: npx clusterioctl host approve ${code}`
);
},
});
} catch (err) {
throw pairingFailed(err);
} finally {
process.off("SIGINT", onSigint);
}
if (parsed.aud !== "host" || !Number.isInteger(parsed.host)) {
throw new InstallError("Invalid token");
logger.info(`Host approved as "${result.name}" with id ${result.hostId}`);
await execHost(["config", "set", "host.id", result.hostId]);
if (result.name !== hostName) {
await execHost(["config", "set", "host.name", result.name]);
}
// Saved last so that an interrupted install is paired again when the installer is run again.
await execHost(["config", "set", "host.controller_token", result.token]);
}
// Pair a host that is already installed again, unless the controller still accepts it.
async function rerunHost(args) {
let config;
try {
config = JSON.parse(await fs.readFile("config-host.json"));
} catch (err) {
throw new InstallError(`Failed to read config-host.json: ${err.message}`);
}
// Let a rerun fix run scripts written before the certificate was passed.
if (!dev && args.selfSignedCert) {
logger.info("Writing run scripts");
await writeScripts("host", args.selfSignedCert);
}
if (!args.pair) {
// eslint-disable-next-line no-console
console.log("Host is already installed");
return;
}
const token = config["host.controller_token"];
const controllerUrl = config["host.controller_url"];
let otherController = false;
if (args.controllerUrl !== undefined) {
const result = validateControllerUrl(args.controllerUrl);
if (result !== true) {
throw new InstallError(result);
}
const savedUrl = validateControllerUrl(controllerUrl) === true ? normaliseControllerUrl(controllerUrl) : "";
otherController = normaliseControllerUrl(args.controllerUrl) !== savedUrl;
}
// The token is never sent to another controller, it would give that controller access to this cluster.
if (otherController) {
logger.info(`Pairing with ${args.controllerUrl} instead of the controller in config-host.json`);
} else if (token && token !== "enter token here") {
let valid;
try {
valid = await checkHostToken({ controllerUrl, token, ca: await readSelfSignedCert(args) });
} catch (err) {
if (err instanceof InstallError) {
throw err;
}
logger.warn(`Could not check if ${controllerUrl} accepts this host: ${err.message}`);
logger.warn("Keeping the existing host config, pass --controller-url to pair with another controller");
return;
}
if (valid) {
// eslint-disable-next-line no-console
console.log("Host is already set up");
return;
}
logger.info("The controller no longer accepts this host, pairing it again");
}
const answers = await inquirerPairingArgs(args, {
hostName: config["host.name"],
controllerUrl: config["host.controller_url"],
});
await pairWithController(args, answers);
// eslint-disable-next-line no-console
console.log("Successfully set up host");
}
async function validateInstallDir() {
@@ -509,6 +681,37 @@ WantedBy=multi-user.target
}
}
async function inquirerPairingArgs(args, defaults = {}) {
let answers = {};
if (args.hostName) { answers.hostName = args.hostName; }
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
answers = await inquirer.prompt([
{
type: "input",
name: "hostName",
message: "Name of host",
default: defaults.hostName ?? os.hostname(),
validate: validateHostName,
},
{
type: "input",
name: "controllerUrl",
message: "Controller URL",
default: defaults.controllerUrl,
validate: validateControllerUrl,
},
], answers);
// Inquirer does not validate answers given as arguments
for (let result of [validateHostName(answers.hostName), validateControllerUrl(answers.controllerUrl)]) {
if (result !== true) {
throw new InstallError(result);
}
}
answers.controllerUrl = normaliseControllerUrl(answers.controllerUrl);
return answers;
}
// eslint-disable-next-line complexity
async function inquirerMissingArgs(args) {
let answers = {};
@@ -568,17 +771,16 @@ async function inquirerMissingArgs(args) {
}
if (answers.mode === "host") {
if (args.hostName) { answers.hostName = args.hostName; }
answers = await inquirer.prompt([
{
type: "input",
name: "hostName",
message: "Name of host",
},
], answers);
if (await pathExists("config-host.json")) {
// An installed host is only paired again, see rerunHost
return { mode: "host", rerun: true };
}
if (args.pair) {
answers = { ...answers, ...await inquirerPairingArgs(args) };
}
}
if (["host", "ctl"].includes(answers.mode)) {
if (answers.mode === "ctl") {
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
answers = await inquirer.prompt([
{
@@ -598,10 +800,6 @@ async function inquirerMissingArgs(args) {
], answers);
}
if (answers.mode === "host") {
validateHostToken(answers.controllerToken);
}
if (["standalone", "host"].includes(answers.mode)) {
let myIp = "localhost";
if (args.publicAddress) {
@@ -663,7 +861,7 @@ async function inquirerMissingArgs(args) {
{
type: "confirm",
name: "remoteNpm",
message: "Allow remote updates via npm?",
message: "Allow remote updates and plugin installs from the web interface?",
default: true,
},
], answers);
@@ -749,7 +947,12 @@ async function main() {
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
})
.option("controller-token", {
nargs: 1, describe: "Controller authentication token [host/ctl]", type: "string",
nargs: 1, type: "string", describe:
"User token for ctl, or of a user that approves the host without waiting (not saved) [host/ctl]",
})
.option("pair", {
describe: "Pair the host with the controller, use --no-pair to only install it [host]",
default: true, type: "boolean",
})
.option("public-address", {
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/host]", type: "string",
@@ -761,7 +964,8 @@ async function main() {
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
nargs: 0, type: "boolean", description:
"Allow remote updates and plugin installs from the web interface [standalone/controller/host]",
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
@@ -801,6 +1005,11 @@ async function main() {
let answers = await inquirerMissingArgs(args);
logger.verbose(JSON.stringify(answers));
if (answers.rerun) {
await rerunHost(args);
return;
}
if (answers.pluginTemplate) {
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
return;
@@ -816,46 +1025,36 @@ async function main() {
await execController(["config", "create"]);
await execController(["bootstrap", "create-admin", answers.admin]);
await execController(["config", "set", "controller.http_port", answers.httpPort]);
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
await setRemoteUpdates(execController, "controller", answers.remoteNpm);
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
adminToken = result.stdout.split("\n").slice(-2)[0];
}
if (answers.mode === "standalone") {
if (["standalone", "host"].includes(answers.mode)) {
logger.info("Setting up host");
await execHost(["config", "create"]);
await execHost(["config", "set", "host.name", "local"]);
}
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);
result = await execController(["bootstrap", "generate-host-token", hostId]);
let hostToken = result.stdout.split("\n").slice(-2)[0];
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", hostToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.controller_token", host.token]);
}
if (answers.mode === "host") {
logger.info("Setting up host");
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
await execHost(["config", "create"]);
await execHost(["config", "set", "host.id", hostId]);
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
if (["standalone", "host"].includes(answers.mode)) {
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
await setRemoteUpdates(execHost, "host", answers.remoteNpm);
}
// Written before pairing as running the installer again on an installed host only pairs it.
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode, args.selfSignedCert);
@@ -867,6 +1066,14 @@ async function main() {
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
}
if (answers.mode === "host") {
if (args.pair) {
await pairWithController(args, answers);
} else {
logger.info("Run the installer again with --mode host in this directory to pair the host");
}
}
/* eslint-disable no-console */
console.log(`Successfully installed ${answers.mode}`);
if (adminToken) {
+275
View File
@@ -0,0 +1,275 @@
// Pairing of hosts with the controller, used by the installer.
// Only uses Node.js builtins as it runs before or without Clusterio being installed.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import http from "node:http";
import https from "node:https";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
/** Time to keep polling through network errors, the controller drops requests not polled for 30 seconds */
const pollRetryMs = 30e3;
/** Time to wait for a response from the controller */
const requestTimeoutMs = 30e3;
/** Largest response accepted from the controller */
const maxResponseSize = 64 * 1024;
/** Error responded by the controller, or for an invalid controller URL */
export class PairingError extends Error {
/**
* @param {string} message - Description of the error.
* @param {number=} status - HTTP status the controller responded with.
*/
constructor(message, status) {
super(message);
this.name = "PairingError";
/** @type {number | undefined} */
this.status = status;
}
}
function apiUrl(controllerUrl, route) {
let url;
try {
url = new URL(route, controllerUrl.endsWith("/") ? controllerUrl : `${controllerUrl}/`);
} catch (err) {
throw new PairingError(`Invalid controller URL ${controllerUrl}`);
}
if (!["http:", "https:"].includes(url.protocol)) {
throw new PairingError(`Controller URL ${controllerUrl} must start with http:// or https://`);
}
return url;
}
function request(url, { method = "POST", body, token, ca, signal, timeoutMs = requestTimeoutMs }) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : JSON.stringify(body);
const headers = { "Accept": "application/json" };
if (data !== undefined) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(data);
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const options = { method, headers, signal, timeout: timeoutMs };
if (url.protocol === "https:" && ca) {
options.ca = ca;
}
const req = (url.protocol === "https:" ? https : http).request(url, options, res => {
const chunks = [];
let size = 0;
res.on("data", chunk => {
size += chunk.length;
if (size > maxResponseSize) {
req.destroy(new PairingError("Response from controller is too large", res.statusCode));
return;
}
chunks.push(chunk);
});
res.on("error", reject);
res.on("end", () => {
let json;
try {
json = JSON.parse(Buffer.concat(chunks).toString());
} catch (err) {
json = undefined;
}
resolve({ status: res.statusCode, headers: res.headers, json });
});
});
req.on("timeout", () => {
const err = new Error(`Request to ${url} timed out`);
err.code = "ETIMEDOUT";
req.destroy(err);
});
req.on("error", reject);
req.end(data);
});
}
function responseError(response) {
let message = typeof response.json?.error === "string"
? response.json.error
: `Unexpected response from controller: ${response.status} ${http.STATUS_CODES[response.status] ?? ""}`.trim();
const retryAfter = response.headers["retry-after"];
if (response.status === 429 && retryAfter) {
message += `, try again in ${retryAfter} seconds`;
}
return new PairingError(message, response.status);
}
function pairingResult(response) {
const { hostId, token, name } = response.json ?? {};
if (!Number.isInteger(hostId) || typeof token !== "string" || typeof name !== "string") {
throw new PairingError("Invalid response from controller", response.status);
}
return { hostId, token, name };
}
async function pollUntilDone(controllerUrl, requestId, ca, pollIntervalMs, signal) {
const url = apiUrl(controllerUrl, "api/host-pairing/poll");
let lastResponseMs = Date.now();
while (true) {
await sleep(pollIntervalMs, undefined, { signal });
let response;
try {
response = await request(url, { body: { requestId }, ca, signal });
} catch (err) {
if (signal?.aborted || Date.now() - lastResponseMs > pollRetryMs) {
throw err;
}
continue;
}
lastResponseMs = Date.now();
if (response.status === 200) {
return pairingResult(response);
}
if (response.status !== 202) {
throw responseError(response);
}
}
}
/**
* Request to join the cluster and wait until the request is approved
*
* @param {object} options - Pairing options.
* @param {string} options.controllerUrl - URL of the controller, with or without a trailing slash.
* @param {string} options.name - Name to request for the host.
* @param {string=} options.version - Version of the installer, shown with the request.
* @param {string=} options.token -
* Token of a user allowed to approve hosts, approves the host without waiting.
* @param {(string|Buffer)=} options.ca - Certificate to trust for a controller using a self-signed certificate.
* @param {number=} options.pollIntervalMs - Interval to poll for the result at.
* @param {function({ code: string }): void=} options.onPending -
* Called once with the code to approve the request with if it has to wait for approval.
* @param {AbortSignal=} options.signal - Signal to cancel the request with.
* @returns {Promise<{ hostId: number, token: string, name: string }>} id, token and approved name of the host.
* @throws {PairingError} if the controller refuses or rejects the request.
* @throws {DOMException} AbortError if the signal is aborted.
*/
export async function pairHost({
controllerUrl, name, version = "", token, ca, pollIntervalMs = 2000, onPending, signal,
}) {
let requestId;
try {
signal?.throwIfAborted();
const response = await request(apiUrl(controllerUrl, "api/host-pairing"), {
body: { name, version }, token, ca, signal,
});
if (response.status === 200) {
return pairingResult(response);
}
if (response.status === 404) {
throw new PairingError("The controller must be updated to a version that supports host pairing", 404);
}
if (response.status !== 202) {
throw responseError(response);
}
if (typeof response.json?.requestId !== "string" || typeof response.json.code !== "string") {
throw new PairingError("Invalid response from controller", response.status);
}
requestId = response.json.requestId;
onPending?.({ code: response.json.code });
return await pollUntilDone(controllerUrl, requestId, ca, pollIntervalMs, signal);
} catch (err) {
if (!signal?.aborted) {
throw err;
}
if (requestId !== undefined) {
await request(apiUrl(controllerUrl, "api/host-pairing/cancel"), {
body: { requestId }, ca, timeoutMs: 5e3,
}).catch(() => {});
}
throw new DOMException("Pairing was cancelled", "AbortError");
}
}
/**
* Check if the controller accepts a host token
*
* @param {object} options - Check options.
* @param {string} options.controllerUrl - URL of the controller, with or without a trailing slash.
* @param {string} options.token - Host token to check.
* @param {(string|Buffer)=} options.ca - Certificate to trust for a controller using a self-signed certificate.
* @returns {Promise<boolean>} true if the token is valid and its host is registered.
* @throws {PairingError} if the controller gives an unexpected response.
*/
export async function checkHostToken({ controllerUrl, token, ca }) {
const response = await request(apiUrl(controllerUrl, "api/host-pairing/check"), {
method: "GET", token, ca,
});
if (response.status === 200) {
return true;
}
if (response.status === 401) {
return false;
}
throw responseError(response);
}
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 };
}
+61 -46
View File
@@ -5,7 +5,6 @@ import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
@@ -51,62 +50,78 @@ hostCommands.add(new lib.Command({
},
}));
/** Format a duration in milliseconds as a short age like "1m 5s" */
function formatAge(ms: number) {
const seconds = Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) {
return `${seconds}s`;
}
if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m`;
}
/** Strip case and separators the same way the controller does when matching pairing codes */
function plainCode(code: string) {
return code.toUpperCase().replace(/[^0-9A-Z]/g, "");
}
hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, ctl: Ctl) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await ctl.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
definition: ["pending", "List host installers waiting for approval"],
handler: async function(args: object, ctl: Ctl) {
let requests = await ctl.send(new lib.HostPairingListRequest());
if (!requests.length) {
print("No pending host requests");
return;
}
let now = Date.now();
print(asTable(requests.map(request => ({
code: request.code,
name: request.name,
address: request.remoteAddress,
age: formatAge(now - request.createdAtMs),
}))));
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
definition: ["approve <code>", "Approve a host installer waiting for approval", (yargs) => {
yargs.positional("code", { describe: "Code shown by the installer", type: "string" });
yargs.option("name", {
type: "string", nargs: 1, describe: "Name to give the host instead of the one it asked for",
});
}],
handler: async function(args: { code: string, name?: string }, ctl: Ctl) {
let name = args.name?.trim();
if (name === undefined) {
let requests = await ctl.send(new lib.HostPairingListRequest());
name = requests.find(request => plainCode(request.code) === plainCode(args.code))?.name;
}
let hostId = await ctl.send(new lib.HostPairingApproveRequest(args.code, args.name));
print(name ? `Approved host ${name} with id ${hostId}` : `Approved host with id ${hostId}`);
},
}));
hostCommands.add(new lib.Command({
definition: ["reject <code>", "Reject a host installer waiting for approval", (yargs) => {
yargs.positional("code", { describe: "Code shown by the installer", type: "string" });
}],
handler: async function(args: { code: string }, ctl: Ctl) {
await ctl.send(new lib.HostPairingRejectRequest(args.code));
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke <host>", "Remove a host from the cluster and disconnect it", (yargs) => {
yargs.positional("host", { describe: "Host to revoke", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.send(new lib.HostRevokeTokensRequest(hostId));
await ctl.send(new lib.HostRevokeRequest(hostId));
},
}));
hostCommands.add(new lib.Command({
definition: ["create-config", "Create host config", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
yargs.option("generate-token", {
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
});
yargs.option("output", {
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
});
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
ctl: Ctl
) {
let config = await ctl.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
let content = JSON.stringify(config, null, "\t");
if (args.output === "-") {
print(content);
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
}
throw err;
}
}
},
}));
hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
+3
View File
@@ -132,6 +132,9 @@ Lists up all configuration entries with their currently configured values.
### `run`
Runs the host.
The host has to be approved on the controller before it can connect, which is done by running the installer in the host directory, see Setting up remote hosts in the readme of the main repository.
If the controller rejects the token of the host when it starts, for example because the host was revoked, it exits with code 8 and the run scripts created by the installer do not restart it.
A host that is revoked while it runs shuts down.
## See Also
+3 -4
View File
@@ -216,10 +216,9 @@ async function startHost() {
if (hostConfig.get("host.controller_token") === "enter token here") {
logger.fatal("ERROR invalid config!");
logger.fatal(
"Controller requires an access token for socket operations. As clusterio\n"+
"hosts depends upon this, please set your token using the command npx\n"+
"clusteriohost config set host.controller_token <token>. You can generate an\n"+
"auth token using npx clusterioctl generate-host-token."
"This host has not been set up to connect to a controller. Run the installer\n"+
"again in this directory with npm init @clusterio and approve the host in the\n"+
"web interface."
);
process.exitCode = 1;
return;
@@ -0,0 +1,73 @@
import { Type, Static } from "@sinclair/typebox";
/**
* A pending request from a host installer to join the cluster
*
* Requests are kept in memory on the controller only while the installer
* waits for them to be approved or rejected.
*/
export default class HostPairingDetails {
constructor(
/** Code shown by the installer, used to identify the request. */
public code: string,
/** Name the installer asked for the host to have. */
public name: string,
/** Address the request was made from. */
public remoteAddress: string,
/** Version of the installer that made the request. */
public version = "",
/** Millisecond Unix timestamp the request was made at. */
public createdAtMs = 0,
/** Millisecond Unix timestamp this entry was last updated at. */
public updatedAtMs = 0,
/** True if this request is no longer pending. */
public isDeleted = false,
) { }
get id() {
return this.code;
}
static jsonSchema = Type.Object({
"code": Type.String(),
"name": Type.String(),
"remoteAddress": Type.String(),
"version": Type.Optional(Type.String()),
"createdAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.code,
json.name,
json.remoteAddress,
json.version,
json.createdAtMs,
json.updatedAtMs,
json.isDeleted,
);
}
toJSON() {
const json: Static<typeof HostPairingDetails.jsonSchema> = {
code: this.code,
name: this.name,
remoteAddress: this.remoteAddress,
};
if (this.version !== "") {
json.version = this.version;
}
if (this.createdAtMs !== 0) {
json.createdAtMs = this.createdAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.isDeleted) {
json.isDeleted = this.isDeleted;
}
return json;
}
}
+1
View File
@@ -15,6 +15,7 @@ export { default as Role } from "./Role.js";
export { default as Note } from "./Note.js";
export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails.js";
export { default as HostDetails } from "./HostDetails.js";
export { default as HostPairingDetails } from "./HostPairingDetails.js";
export { default as UserDetails } from "./UserDetails.js";
export type { IUser } from "./UserDetails.js";
export * from "./composites.js";
+63 -33
View File
@@ -1,9 +1,10 @@
import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js";
import { JsonNumber, StringEnum, StringKey, jsonArray, plainJson } from "./composites.js";
import { type LogEntry, levels } from "../logging.js";
import { ControllerConfig, HostConfig } from "../config/index.js";
import { ControllerConfig } from "../config/index.js";
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
import { PluginDetails } from "./messages_plugin.js";
import HostPairingDetails from "./HostPairingDetails.js";
const LogEntrySchema = Type.Object({
"level": StringKey(levels),
@@ -120,52 +121,81 @@ export class ControllerConfigSetPropRequest {
}
}
export class HostGenerateTokenRequest {
declare ["constructor"]: typeof HostGenerateTokenRequest;
export class HostPairingListRequest {
declare ["constructor"]: typeof HostPairingListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.host.generate_token" as const;
constructor(
public hostId?: number,
) { }
static jsonSchema = Type.Object({
"hostId": Type.Optional(Type.Integer()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.hostId);
}
static Response = JsonString;
static permission = "core.host.approve" as const;
static Response = jsonArray(HostPairingDetails);
}
export class HostConfigCreateRequest {
declare ["constructor"]: typeof HostConfigCreateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.host.create_config" as const;
export class HostPairingUpdatesEvent {
declare ["constructor"]: typeof HostPairingUpdatesEvent;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static permission = "core.host.approve" as const;
constructor(
public id: number | undefined,
public name: string | undefined,
public generateToken: boolean,
public updates: HostPairingDetails[],
) { }
static jsonSchema = Type.Object({
"id": Type.Optional(Type.Integer()),
"name": Type.Optional(Type.String()),
"generateToken": Type.Boolean(),
"updates": Type.Array(HostPairingDetails.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.name, json.generateToken);
return new this(json.updates.map(update => HostPairingDetails.fromJSON(update)));
}
}
export class HostPairingApproveRequest {
declare ["constructor"]: typeof HostPairingApproveRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.host.approve" as const;
constructor(
/** Code of the pending request, case and dashes are ignored. */
public code: string,
/** Name to give the host instead of the one it asked for. */
public name?: string,
) { }
static jsonSchema = Type.Object({
"code": Type.String(),
"name": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.code, json.name);
}
static Response = plainJson(HostConfig.jsonSchema);
/** Id of the host created. */
static Response = JsonNumber;
}
export class HostPairingRejectRequest {
declare ["constructor"]: typeof HostPairingRejectRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.host.approve" as const;
constructor(
/** Code of the pending request, case and dashes are ignored. */
public code: string,
) { }
static jsonSchema = Type.Object({
"code": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.code);
}
}
export class GetFactorioCredentialsRequest {
+3 -3
View File
@@ -259,12 +259,12 @@ export class SyncUserListsEvent {
}
}
export class HostRevokeTokensRequest {
declare["constructor"]: typeof HostRevokeTokensRequest;
export class HostRevokeRequest {
declare ["constructor"]: typeof HostRevokeRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.host.revoke_token" as const;
static permission = "core.host.revoke" as const;
constructor(
public hostId: number,
+5 -3
View File
@@ -29,8 +29,10 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.ControllerConfigSetRequest,
controller.ControllerConfigSetFieldRequest,
controller.ControllerConfigSetPropRequest,
controller.HostGenerateTokenRequest,
controller.HostConfigCreateRequest,
controller.HostPairingListRequest,
controller.HostPairingUpdatesEvent,
controller.HostPairingApproveRequest,
controller.HostPairingRejectRequest,
controller.GetFactorioCredentialsRequest,
controller.LogSetSubscriptionsRequest,
controller.LogQueryRequest,
@@ -59,7 +61,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
host.ControllerConnectionEvent,
host.PrepareControllerDisconnectRequest,
host.SyncUserListsEvent,
host.HostRevokeTokensRequest,
host.HostRevokeRequest,
instance.InstanceDetailsGetRequest,
instance.InstanceDetailsListRequest,
+8 -11
View File
@@ -196,19 +196,16 @@ export const corePermissions = [
grantByDefault: true,
},
{
name: "core.host.generate_token",
title: "Generate host token",
description: "Generate tokens for hosts to connect to the cluster with.",
name: "core.host.approve",
title: "Approve hosts",
description:
"List, approve and reject requests from host installers to join the cluster, and approve hosts " +
"directly by passing a token to the installer. Approving gives the new host full access to the cluster.",
},
{
name: "core.host.revoke_token",
title: "Revoke host token",
description: "Revoke access token and terminate existing connection for hosts.",
},
{
name: "core.host.create_config",
title: "Create host config",
description: "Create host configs via the controller.",
name: "core.host.revoke",
title: "Revoke hosts",
description: "Remove hosts from the cluster, disconnecting them and making their access tokens invalid.",
},
{
+35 -28
View File
@@ -1,7 +1,7 @@
import React, { useContext, useState } from "react";
import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import * as lib from "@clusterio/lib";
import notify, { notifyErrorHandler } from "../util/notify";
@@ -21,7 +21,6 @@ import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
} from "./system_metrics";
import { formatTimestamp } from "../util/time_format";
import { useSystems } from "../model/system";
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
@@ -98,10 +97,42 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB
return <VariableDropdownButton actions={actions} />;
}
type RevokeHostButtonProps = { host: lib.HostDetails, instances: ReadonlyMap<number, lib.InstanceDetails> };
function RevokeHostButton({ host, instances }: RevokeHostButtonProps) {
const control = useContext(ControlContext);
const navigate = useNavigate();
const assigned = [...instances.values()].map(instance => instance.name);
if (assigned.length) {
return <Tooltip title={`Unassign or delete the instances on this host first: ${assigned.join(", ")}`}>
<Button danger disabled>Revoke host access</Button>
</Tooltip>;
}
return <Popconfirm
title={`Revoke access for ${host.name}?`}
description={<>
The host is removed from the cluster and disconnected.<br />
To add it back, run the installer again in its directory.
</>}
placement="bottomRight"
okText="Revoke"
okButtonProps={{ danger: true }}
onConfirm={() => control.send(
new lib.HostRevokeRequest(host.id)
).then(() => {
notify(`Revoked access for host ${host.name}`, "success");
navigate("/hosts");
}).catch(notifyErrorHandler(`Error revoking access for host ${host.name}`))}
>
<Button danger>Revoke host access</Button>
</Popconfirm>;
}
export default function HostViewPage() {
let params = useParams();
let hostId = Number(params.id);
let control = useContext(ControlContext);
let account = useAccount();
let [instances] = useInstances();
const [systems] = useSystems();
@@ -125,24 +156,7 @@ export default function HostViewPage() {
let hostButtons = <Space wrap>
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
{
account.hasPermission("core.host.revoke_token")
&& <Popconfirm
title={`Revoke tokens of ${host.name}?`}
placement="bottomRight"
okText="Revoke Tokens"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(new lib.HostRevokeTokensRequest(host.id!))
.then(() => notify("Host tokens revoked"))
.catch(notifyErrorHandler(`Error revoking tokens for host id:${host.id}`));
}}
>
<Button danger>
Revoke tokens
</Button>
</Popconfirm>
}
{account.hasPermission("core.host.revoke") && <RevokeHostButton host={host} instances={hostInstances} />}
{canShowNpm && (
<NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
)}
@@ -175,13 +189,6 @@ export default function HostViewPage() {
<Descriptions.Item label="Disk Usage"><MetricDiskRatio system={system} /></Descriptions.Item>
<Descriptions.Item label="Disk"><MetricDiskUsed system={system} /></Descriptions.Item>
<Descriptions.Item label="Public Address">{host.publicAddress}</Descriptions.Item>
{
host.tokenValidAfter
? <Descriptions.Item label="Tokens valid after:">
{formatTimestamp(host.tokenValidAfter*1000)}
</Descriptions.Item>
: null
}
</Descriptions>
<ResourceNotes resourceType="host" resourceId={hostId} />
{account.hasPermission("core.instance.list") && <>
+179 -125
View File
@@ -1,6 +1,6 @@
import React, { useContext, useRef, useState, useEffect } from "react";
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
import React, { useContext, useState, useEffect } from "react";
import { Badge, Button, Flex, Input, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined, PlusOutlined } from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -16,9 +16,10 @@ import {
MetricDiskUsed, MetricDiskRatio,
RestartRequired,
} from "./system_metrics";
import { useHosts } from "../model/host";
import { useHostPairings, useHosts } from "../model/host";
import { useSystems } from "../model/system";
import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
@@ -27,133 +28,186 @@ import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function GenerateHostTokenButton() {
let control = useContext(ControlContext);
let [open, setOpen] = useState(false);
let [token, setToken] = useState<string|null>(null);
let [hostId, setHostId] = useState<number|null>(null);
let [form] = Form.useForm();
let [pluginList, setPluginList] = useState<lib.PluginWebApi[]>([]);
/** Current time in milliseconds, updated every second while active */
function useNow(active: boolean) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
const plugins = await response.json();
setPluginList(plugins);
} else {
notify("Failed to load plugin list");
}
})();
}, []);
async function generateToken() {
let id;
let values = form.getFieldsValue();
if (values.hostId) {
id = Number.parseInt(values.hostId, 10);
if (Number.isNaN(id)) {
form.setFields([{ name: "hostId", errors: ["Must be an integer"] }]);
return;
}
form.setFields([{ name: "hostId", errors: [] }]);
if (!active) {
return undefined;
}
setNow(Date.now());
const interval = setInterval(() => { setNow(Date.now()); }, 1000);
return () => { clearInterval(interval); };
}, [active]);
return now;
}
let newToken = await control.send(new lib.HostGenerateTokenRequest(id));
setToken(newToken);
setHostId(id ?? null);
function AddHostModal(props: { open: boolean, onClose: () => void }) {
const control = useContext(ControlContext);
const [requests, synced] = useHostPairings();
const [names, setNames] = useState<ReadonlyMap<string, string>>(new Map());
const [busyCodes, setBusyCodes] = useState<ReadonlySet<string>>(new Set());
const now = useNow(props.open);
const controllerUrl = new URL(webRoot, document.location.href).href;
const installCommand = `npm init "@clusterio" -- --mode host --controller-url ${controllerUrl}`;
function setName(code: string, name?: string) {
setNames(current => {
const next = new Map(current);
if (name === undefined) {
next.delete(code);
} else {
next.set(code, name);
}
return next;
});
}
// Generate a new random
useEffect(() => {
if (open) {
generateToken().catch(notifyErrorHandler("Error generating token"));
}
}, [open]);
function setBusy(code: string, busy: boolean) {
setBusyCodes(current => {
const next = new Set(current);
if (busy) {
next.add(code);
} else {
next.delete(code);
}
return next;
});
}
const pluginString = pluginList.filter(p => p.npmPackage).map(p => `"${p.npmPackage}"`).join(" ");
function nameOf(request: Readonly<lib.HostPairingDetails>) {
return (names.get(request.code) ?? request.name).trim();
}
function approve(request: Readonly<lib.HostPairingDetails>) {
const name = nameOf(request);
setBusy(request.code, true);
control.send(
new lib.HostPairingApproveRequest(request.code, name !== request.name ? name : undefined)
).then(() => {
setName(request.code);
notify(`Approved host ${name}`, "success");
}).catch(
notifyErrorHandler(`Error approving host ${request.name}`)
).finally(() => {
setBusy(request.code, false);
});
}
function reject(request: Readonly<lib.HostPairingDetails>) {
setBusy(request.code, true);
control.send(
new lib.HostPairingRejectRequest(request.code)
).then(() => {
setName(request.code);
}).catch(
notifyErrorHandler(`Error rejecting host ${request.name}`)
).finally(() => {
setBusy(request.code, false);
});
}
return <Modal
title="Add Host"
open={props.open}
footer={null}
onCancel={props.onClose}
width="800px"
>
<Typography.Paragraph>
Run the installer in an empty directory on the machine the host will run on.
It asks for a name for the host, then shows a code and waits for the request to be approved here.
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied host setup commands to clipboard"
text={["mkdir clusterio", "cd clusterio", installCommand].join("\n")}
/>
<p>&gt; mkdir clusterio</p>
<p>&gt; cd clusterio</p>
<p>
&gt; npm init "@clusterio" -- --mode host
--controller-url <span className="highlight">{controllerUrl}</span>
</p>
</div>
<Typography.Paragraph>
Once approved the installer saves the connection details of the host and exits.
The host can then be started with the run-host script in that directory.
</Typography.Paragraph>
<Typography.Title level={5}>Waiting for approval</Typography.Title>
<Typography.Paragraph type="secondary">
Only approve a request if its code matches the code shown by the installer.
An approved host has full access to the cluster.
</Typography.Paragraph>
<Table
size="small"
pagination={false}
loading={!synced}
rowKey="code"
dataSource={[...requests.values()].sort((a, b) => a.createdAtMs - b.createdAtMs)}
locale={{ emptyText: "No hosts are waiting for approval" }}
columns={[
{
title: "Name",
key: "name",
render: (_, request) => <Input
size="small"
aria-label={`Name for host with code ${request.code}`}
value={names.get(request.code) ?? request.name}
onChange={event => { setName(request.code, event.target.value); }}
/>,
},
{
title: "Code",
dataIndex: "code",
render: (_, request) => <Typography.Text code strong>{request.code}</Typography.Text>,
},
{
title: "Address",
dataIndex: "remoteAddress",
},
{
title: "Age",
key: "age",
render: (_, request) => <Tooltip title={new Date(request.createdAtMs).toLocaleString()}>
<span>{formatDuration(Math.max(0, now - request.createdAtMs))}</span>
</Tooltip>,
},
{
key: "action",
align: "right",
render: (_, request) => {
const busy = busyCodes.has(request.code);
return <Space>
<Button
type="primary"
size="small"
disabled={busy || !nameOf(request)}
onClick={() => { approve(request); }}
>Approve</Button>
<Button
danger
size="small"
disabled={busy}
onClick={() => { reject(request); }}
>Reject</Button>
</Space>;
},
},
]}
/>
</Modal>;
}
function AddHostButton() {
const [open, setOpen] = useState(false);
const [requests] = useHostPairings();
return <>
<Button
onClick={() => { setOpen(true); }}
>Generate Token</Button>
<Modal
title="Generate Host Token"
open={open}
footer={null}
onCancel={() => {
setOpen(false);
setToken(null);
form.resetFields();
}}
width="700px"
>
<Form form={form} layout="vertical" requiredMark="optional">
<Form.Item name="hostId" label="Host ID">
<InputNumber onChange={() => {
generateToken().catch(notifyErrorHandler("Error generating token"));
}} />
</Form.Item>
</Form>
<Typography.Paragraph>
Host IDs are randomly generated and unique, leave blank to use a new ID,
otherwise the ID of an existing host can be found using:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied get host id to clipboard"
text="npx clusteriohost config get host.id"
/>
npx clusteriohost config get host.id
</div>
{token !== null && <>
<Typography.Paragraph>
Host auth token:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied host token to clipboard"
text={token}
/>
{token}
</div>
<Typography.Paragraph>
You can set the token on an existing host with the following command:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied set host id to clipboard"
text={`npx clusteriohost config set host.id ${token}`}
/>
npx clusteriohost config set host.id {token}
</div>
<Typography.Paragraph>
A new host can be setup using the following commands:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message={"Copied host setup commands to clipboard"}
text={`\
mkdir clusterio
cd clusterio
npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-headless \
--controller-url ${document.location.origin}/ --host-name "${hostId ? `Host ${hostId}` : "Host"}" \
--public-address localhost ${pluginString.length ? "--plugins" : ""} ${pluginString}`
}/>
<p>&gt; mkdir clusterio</p>
<p>&gt; cd clusterio</p>
<p>
&gt; npm init "@clusterio" --
--controller-token <span className="highlight">{token} </span>
--mode "host"
--download-headless
--controller-url <span className="highlight">{document.location.origin}/ </span>
--host-name <span className="highlight">{hostId ? `Host ${hostId}` : "Host"} </span>
--public-address <span className="highlight">localhost </span>
{pluginString.length ? "--plugins" : ""} <span className="highlight">{pluginString}</span>
</p>
<p>&gt; ./run-host</p>
</div>
</>}
</Modal>
<Badge count={requests.size} title={`${requests.size} waiting for approval`}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setOpen(true); }}>Add host</Button>
</Badge>
<AddHostModal open={open} onClose={() => { setOpen(false); }} />
</>;
}
@@ -216,7 +270,7 @@ export default function HostsPage() {
return <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
title="Hosts"
extra={account.hasPermission("core.host.generate_token") ? <GenerateHostTokenButton /> : undefined}
extra={account.hasPermission("core.host.approve") ? <AddHostButton /> : undefined}
/>
<Table
style={{ overflowX: "auto" }}
+13
View File
@@ -1,5 +1,6 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import ControlContext from "../components/ControlContext";
import { useAccount } from "./account";
export function useHost(id?: number) {
const [hosts, synced] = useHosts();
@@ -11,3 +12,15 @@ export function useHosts() {
const subscribe = useCallback((callback: () => void) => control.hosts.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => control.hosts.getSnapshot());
}
/** Requests from host installers waiting to be approved, empty if the account may not approve hosts */
export function useHostPairings() {
const control = useContext(ControlContext);
const account = useAccount();
const permitted = Boolean(account.hasPermission("core.host.approve"));
const subscribe = useCallback(
(callback: () => void) => (permitted ? control.hostPairings.subscribe(callback) : () => {}),
[control, permitted],
);
return useSyncExternalStore(subscribe, () => control.hostPairings.getSnapshot());
}
+72
View File
@@ -0,0 +1,72 @@
import * as lib from "@clusterio/lib";
/**
* Subscriber for the requests from host installers waiting to be approved
*
* Only accounts that may approve hosts can subscribe to the requests, so
* this does not resubscribe on connect or account changes for other
* accounts, which would fail with a permission error. The requests only
* live in the controller's memory and it does not tell a new session about
* requests removed before it started, so the requests are cleared when a
* new session starts and fetched again. The same applies to requests removed
* while nothing was subscribed, so they are also cleared when subscribing
* again after the last handler unsubscribed.
*/
export class HostPairingSubscriber extends lib.MapSubscriber<lib.HostPairingUpdatesEvent> {
private _permitted: () => boolean;
private _handlers = 0;
/**
* @param control - Link to the controller.
* @param permitted - Returns true if the account may approve hosts.
*/
constructor(control: lib.Link, permitted: () => boolean) {
super(lib.HostPairingUpdatesEvent, control);
this._permitted = permitted;
}
override handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect") {
this.values.clear();
this.lastUpdatedMs = -1;
}
if (event === "connect" || event === "resume") {
// The account of a new session is reported after the connect event.
queueMicrotask(() => {
if (this._permitted()) {
super.handleConnectionEvent(event);
}
});
return;
}
super.handleConnectionEvent(event);
}
override handleAccountEvent() {
if (this._permitted()) {
super.handleAccountEvent();
}
}
override subscribe(handler: lib.EventSubscriberCallback<lib.HostPairingUpdatesEvent>) {
if (this._handlers === 0) {
this.values.clear();
this.lastUpdatedMs = -1;
}
this._handlers += 1;
const unsubscribe = super.subscribe(handler);
let unsubscribed = false;
return () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
unsubscribe();
// Deferred like the unsubscribe in EventSubscriber, so moving between pages
// that both subscribe keeps the requests.
setTimeout(() => {
this._handlers -= 1;
});
};
}
}
+8
View File
@@ -1,9 +1,16 @@
import * as lib from "@clusterio/lib";
import packageJson from "../../package.json";
import * as WebPlugin from "../BaseWebPlugin";
import { HostPairingSubscriber } from "./host_pairing";
const { logFilter, logger } = lib;
function rolesHavePermission(roles: lib.AccountRole[] | null, permission: lib.PermissionName) {
return (roles ?? []).some(
role => role.permissions.includes("core.admin") || role.permissions.includes(permission)
);
}
type accountHandler = (account: lib.AccountDetails) => void;
type hostHandler = (hostDetails: lib.HostDetails) => void;
type instanceHandler = (instanceDetails: lib.InstanceDetails) => void;
@@ -67,6 +74,7 @@ export class Control extends lib.Link {
/** Updates handled by the subscription service */
systems = new lib.MapSubscriber(lib.SystemInfoUpdateEvent, this);
hosts = new lib.MapSubscriber(lib.HostUpdatesEvent, this);
hostPairings = new HostPairingSubscriber(this, () => rolesHavePermission(this.accountRoles, "core.host.approve"));
instances = new lib.MapSubscriber(lib.InstanceDetailsUpdatesEvent, this);
saves = new lib.MapSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this);
modPacks = new lib.MapSubscriber(lib.ModPackUpdatesEvent, this);
+46 -4
View File
@@ -32,6 +32,8 @@ import readline from "node:readline";
import util from "node:util";
import { loadBreaks, matchBreaks, selectBreaks } from "./version-compat-breaks.js";
// Only uses Node builtins, so it does not load a second copy of the lib.
import { pairHost, PairingError } from "../packages/create/pair.js";
const execFile = util.promisify(child_process.execFile);
@@ -279,6 +281,44 @@ async function pollCtl(ctl, cliArgs, expectRegex, description, timeoutMs = 30e3)
}
}
/**
* Pair the host with the running controller and write its config
*
* The pairing API is probed instead of checking which side is local, as releases that support
* pairing no longer have ctl host create-config to fall back on.
*
* @returns true if the host was paired, false if the controller predates host pairing.
*/
async function pairHostWithController() {
// Written by bootstrap create-ctl-config, holds the admin token which approves the host directly.
const ctlConfig = JSON.parse(await fs.readFile(path.join(controllerDir, "config-ctl.json"), "utf8"));
const controllerUrl = ctlConfig["ctl.controller_url"];
console.log(`| pairing host with ${controllerUrl}`);
let pairing;
try {
pairing = await pairHost({ controllerUrl, name: "host", token: ctlConfig["ctl.controller_token"] });
} catch (err) {
if (err instanceof PairingError && err.status === 404) {
console.log("| controller does not support host pairing");
return false;
}
throw err;
}
console.log(`| paired host ${pairing.name} with id ${pairing.hostId}`);
// The same fields the installer sets, which every host version reads.
const hostConfig = {
"host.id": pairing.hostId,
"host.name": pairing.name,
"host.controller_url": controllerUrl,
"host.controller_token": pairing.token,
};
await fs.writeFile(
path.join(hostDir, "config-host.json"), JSON.stringify(hostConfig, null, "\t"), { flag: "wx", mode: 0o600 }
);
return true;
}
async function main() {
const options = parseArgs();
const breaks = await loadBreaks();
@@ -337,10 +377,12 @@ async function main() {
);
try {
await run("ctl", ctl, [
"host", "create-config", "--id", "1", "--name", "host", "--generate-token",
"--output", path.join(hostDir, "config-host.json"),
], controllerDir);
if (!await pairHostWithController()) {
await run("ctl", ctl, [
"host", "create-config", "--id", "1", "--name", "host", "--generate-token",
"--output", path.join(hostDir, "config-host.json"),
], controllerDir);
}
// Started host is only logged after the connection to the controller is established.
const hostProc = await spawnDaemon("host", host, ["run"], hostDir, /Started host/);
+213 -1
View File
@@ -1,8 +1,11 @@
import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { Controller, ControlConnection, ControllerHooks, InstanceManager } from "@clusterio/controller";
import {
Controller, ControlConnection, ControllerHooks, HostConnection, HostRecord, InstanceManager, InstanceRecord,
} from "@clusterio/controller";
import { MockConnector, MockController, MockLogger } from "../mock.js";
import "../setup_logging.js";
const addr = lib.Address.fromShorthand;
@@ -272,4 +275,213 @@ describe("controller/src/ControlConnection", function() {
assert.equal(player.connector.terminated, true);
});
});
describe("host management", function() {
/** @type {Controller} */
let controller;
/** @type {ControlConnection} */
let connection;
let audits;
let originalAudit;
beforeEach(function() {
const config = new lib.ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
controller = new Controller(lib.logger, [], config);
const connector = new lib.VirtualConnector(addr("controller"), addr({ controlId: 1 }));
const user = controller.users.getOrCreateUser("admin");
connection = new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1);
audits = [];
originalAudit = lib.logger.audit;
lib.logger.audit = msg => { audits.push(msg); };
});
afterEach(function() {
lib.logger.audit = originalAudit;
});
it("registers the host pairing and revoke handlers", function() {
for (const Request of [
lib.HostRevokeRequest,
lib.HostPairingListRequest,
lib.HostPairingApproveRequest,
lib.HostPairingRejectRequest,
]) {
assert(connection._requestHandlers.has(Request), `${Request.name} is not handled`);
}
});
describe(".handleHostRevokeRequest()", function() {
let record;
let broadcasts;
beforeEach(function() {
record = new HostRecord(10, "host-10", "", new Map());
controller.hosts.set(record);
broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof lib.HostUpdatesEvent) {
broadcasts.push(...event.updates.map(host => ({ id: host.id, isDeleted: host.isDeleted })));
}
};
});
function assignInstance(id, name, hostId) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.set("instance.id", id);
instanceConfig.set("instance.name", name);
instanceConfig.set("instance.assigned_host", hostId);
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
}
async function revoke(hostId = 10) {
await connection.handleHostRevokeRequest(new lib.HostRevokeRequest(hostId));
}
it("deletes and saves the host record", async function() {
let savedWithoutHost;
const originalSave = controller.hosts.save;
controller.hosts.save = async function() {
savedWithoutHost = !this.has(10);
return await originalSave.call(this);
};
await revoke();
assert.equal(controller.hosts.has(10), false);
assert.equal(savedWithoutHost, true, "hosts were not saved after deleting the record");
assert.deepEqual(broadcasts, [{ id: 10, isDeleted: true }]);
assert.equal(audits.length, 1);
assert.match(audits[0], /User admin revoked host host-10 \(10\)/);
});
it("deletes the note of the host", async function() {
controller.setNote("host", "10", "rack 3", "someone");
controller.setNote("host", "11", "rack 4", "someone");
await revoke();
const note = controller.notes.get("host/10");
assert.equal(note.isDeleted, true);
assert.equal(note.content, "");
assert.equal(note.updatedBy, "admin");
assert.equal(controller.notes.get("host/11").isDeleted, false);
});
it("terminates the connection of the host", async function() {
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
await revoke();
assert.equal(terminated, true);
});
it("terminates the connection of the host when saving fails", async function() {
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
controller.hosts.save = async () => { throw new Error("EPERM: operation not permitted"); };
await assert.rejects(revoke(), /EPERM/);
assert.equal(terminated, true, "revoked host kept its connection");
assert.equal(controller.hosts.has(10), false);
assert.equal(controller.notes.get("host/10"), undefined);
});
it("does not leave a host record behind when the connection closes", async function() {
const connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
connector.terminate = () => { connector.emit("close"); };
const hostConnection = new HostConnection(
new lib.RegisterHostData("", "2.0.0", 10, {}), connector, controller, "10.0.0.1"
);
controller.wsServer.hostConnections.set(10, hostConnection);
assert.equal(controller.hosts.get(10).connected, true);
await revoke();
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(broadcasts.at(-1), { id: 10, isDeleted: true });
});
it("refuses to revoke a host with assigned instances", async function() {
controller.setNote("host", "10", "rack 3", "someone");
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
assignInstance(101, "alpha", 10);
assignInstance(102, "beta", 10);
assignInstance(201, "gamma", 20);
await assert.rejects(
revoke(),
new lib.ConflictError("Cannot revoke host host-10 while instances are assigned to it: alpha, beta"),
);
assert.equal(controller.hosts.get(10), record);
assert.equal(controller.notes.get("host/10").isDeleted, false);
assert.equal(terminated, false);
assert.deepEqual(broadcasts, []);
});
it("ignores instances assigned to other hosts", async function() {
assignInstance(201, "gamma", 20);
await revoke();
assert.equal(controller.hosts.has(10), false);
});
it("throws NotFoundError for an unknown host", async function() {
await assert.rejects(revoke(99), new lib.NotFoundError("Unknown host id (99)"));
assert.equal(controller.hosts.has(10), true);
});
});
describe("host pairing requests", function() {
it("lists pending requests", async function() {
const first = controller.hostPairing.create("Host A", "2.0.0", "10.0.0.1");
const second = controller.hostPairing.create("Host B", "2.0.0", "10.0.0.2");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert(list.every(details => details instanceof lib.HostPairingDetails));
assert.deepEqual(list.map(details => details.code).sort(), [first.code, second.code].sort());
assert.equal(list.find(details => details.code === first.code).name, "Host A");
});
it("leaves out expired, approved and rejected requests when listing", async function() {
const expired = controller.hostPairing.create("Host A", "", "10.0.0.1");
const approved = controller.hostPairing.create("Host B", "", "10.0.0.2");
const rejected = controller.hostPairing.create("Host C", "", "10.0.0.3");
const pending = controller.hostPairing.create("Host D", "", "10.0.0.4");
controller.hostPairing._bySecret.get(expired.requestId).lastPollMs -= 60e3;
await controller.hostPairing.approve(approved.code, undefined, "admin");
controller.hostPairing.reject(rejected.code, "admin");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert.deepEqual(list.map(details => details.code), [pending.code]);
});
it("approves a request and returns the new host id", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
const hostId = await connection.handleHostPairingApproveRequest(
new lib.HostPairingApproveRequest(code.toLowerCase().replace("-", ""))
);
assert.equal(typeof hostId, "number");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(controller.hostPairing.poll(requestId).hostId, hostId);
assert(audits.some(msg => msg.startsWith(`User admin approved host Host A (${hostId})`)));
});
it("approves a request with a different name", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
const hostId = await connection.handleHostPairingApproveRequest(
new lib.HostPairingApproveRequest(code, "Renamed")
);
assert.equal(controller.hosts.get(hostId).name, "Renamed");
assert.equal(controller.hostPairing.poll(requestId).name, "Renamed");
});
it("rejects approving an unknown code or with an invalid name", async function() {
await assert.rejects(
connection.handleHostPairingApproveRequest(new lib.HostPairingApproveRequest("ZZZ-ZZZ")),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
const { code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await assert.rejects(
connection.handleHostPairingApproveRequest(new lib.HostPairingApproveRequest(code, " ")),
new lib.ValidationError("Host name is required"),
);
assert.equal(controller.hosts.size, 0);
});
it("rejects a request", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code.toLowerCase()));
assert.equal(controller.hostPairing.requests.has(code), false);
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
assert.equal(controller.hosts.size, 0);
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
});
it("throws NotFoundError when rejecting an unknown code", async function() {
await assert.rejects(
connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest("ZZZ-ZZZ")),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
});
});
});
+97 -1
View File
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { HostConnection } from "@clusterio/controller";
import { Controller, HostConnection, HostRecord } from "@clusterio/controller";
import { MockConnector } from "../mock.js";
const addr = lib.Address.fromShorthand;
function makeSave(instanceId, name) {
return new lib.SaveDetails(instanceId, "file", name, 0, 0, false, false, 0, false);
@@ -40,6 +43,99 @@ function makeInstances(assignments, names = new Map()) {
}
describe("controller/src/HostConnection", function() {
describe("host record", function() {
/** @type {Controller} */
let controller;
let record;
let connector;
let updates;
beforeEach(function() {
controller = new Controller(lib.logger, [], new lib.ControllerConfig("controller"));
record = new HostRecord(10, "host-10", "1.0.0", new Map(), false, "", "203.0.113.1", 123);
controller.hosts.set(record);
connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
updates = [];
controller.hosts.on("update", changed => {
updates.push(...changed.map(host => ({ id: host.id, name: host.name, isDeleted: host.isDeleted })));
});
});
function connect(id = 10) {
return new HostConnection(
new lib.RegisterHostData("", "2.0.0", id, {}), connector, controller, "10.0.0.1"
);
}
function revoke() {
controller.hosts.delete(record);
updates.length = 0;
}
it("should refuse a host without a record", function() {
assert.throws(() => connect(11), /Host 11 is not registered/);
assert.equal(controller.hosts.has(11), false);
assert.deepEqual(updates, []);
});
it("should update the existing record on connect", function() {
const connection = connect();
assert.equal(controller.hosts.get(10), record, "host record was replaced");
assert.equal(connection.id, 10);
assert.equal(record.connected, true);
assert.equal(record.version, "2.0.0");
assert.equal(record.remoteAddress, "10.0.0.1");
assert.equal(record.name, "host-10");
assert.equal(record.publicAddress, "203.0.113.1");
assert.equal(record.tokenValidAfter, 123);
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
});
it("should mark the host disconnected when the connection closes", function() {
connect();
updates.length = 0;
connector.emit("close");
assert.equal(controller.hosts.get(10), record);
assert.equal(record.connected, false);
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
});
it("should save info updates from the host", async function() {
const connection = connect();
updates.length = 0;
await connection.handleHostInfoUpdateEvent(
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
);
assert.equal(controller.hosts.get(10).name, "renamed");
assert.equal(controller.hosts.get(10).publicAddress, "198.51.100.1");
assert.deepEqual(updates, [{ id: 10, name: "renamed", isDeleted: false }]);
});
it("should not recreate the record of a revoked host when the connection closes", function() {
connect();
revoke();
connector.emit("close");
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(updates, []);
});
it("should not recreate the record of a revoked host on info updates", async function() {
const connection = connect();
revoke();
await connection.handleHostInfoUpdateEvent(
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
);
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(updates, []);
});
it("should not overwrite a newer record with the same id", function() {
connect();
revoke();
const newer = new HostRecord(10, "newer", "", new Map());
controller.hosts.set(newer);
updates.length = 0;
connector.emit("close");
assert.equal(controller.hosts.get(10), newer);
assert.equal(newer.name, "newer");
assert.deepEqual(updates, []);
});
});
describe(".handleInstanceSaveDetailsUpdatesEvent()", function() {
// Instance 1 is on host 10, instance 2 is on host 20.
const assignments = new Map([[1, 10], [2, 20]]);
+648
View File
@@ -0,0 +1,648 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostPairingManager, HostPairingError, HostRecord } from "@clusterio/controller";
import {
addressKey, normaliseCode, pollTimeoutMs, maxPendingMs, maxPending, maxPendingPerAddress,
maxCreatesPerMinute, maxNameLength,
} from "@clusterio/controller/dist/node/src/HostPairingManager.js";
import "../setup_logging.js";
const codePattern = /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/;
function makeController() {
const config = new lib.ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
return new Controller(lib.logger, [], config);
}
/**
* Make crypto.randomInt return the given values before falling back to random ones
*
* @param {number[]} values - Values to return, in order.
* @returns {() => void} function restoring crypto.randomInt.
*/
function stubRandomInt(values) {
const original = crypto.randomInt;
crypto.randomInt = (...args) => (values.length ? values.shift() : original(...args));
return () => { crypto.randomInt = original; };
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function assertPairingError(fn, status, message) {
assert.throws(fn, err => {
assert(err instanceof HostPairingError, `expected HostPairingError, got ${err}`);
assert.equal(err.status, status);
if (message) {
assert.equal(err.message, message);
}
return true;
});
}
describe("controller/src/HostPairingManager", function() {
describe("addressKey()", function() {
it("should return IPv4 addresses unchanged", function() {
assert.equal(addressKey("10.0.0.1"), "10.0.0.1");
});
it("should strip the IPv4 mapped prefix", function() {
assert.equal(addressKey("::ffff:10.0.0.1"), "10.0.0.1");
});
it("should group IPv6 addresses by their /64", function() {
assert.equal(addressKey("2001:db8:0:1::5"), "2001:db8:0:1::/64");
assert.equal(addressKey("2001:db8::1:ffff:0:0:3"), "2001:db8:0:1::/64");
assert.equal(addressKey("2001:0DB8:0000:0001:aaaa:bbbb:cccc:dddd"), "2001:db8:0:1::/64");
assert.equal(addressKey("2001:db8:0:2::5"), "2001:db8:0:2::/64");
});
it("should expand compressed groups at the start and end", function() {
assert.equal(addressKey("::1"), "0:0:0:0::/64");
assert.equal(addressKey("::"), "0:0:0:0::/64");
assert.equal(addressKey("fe80::1"), "fe80:0:0:0::/64");
assert.equal(addressKey("2001:db8::"), "2001:db8:0:0::/64");
});
it("should return other values unchanged", function() {
assert.equal(addressKey(""), "");
assert.equal(addressKey("not an address"), "not an address");
assert.equal(addressKey("::ffff:not-ipv4"), "::ffff:not-ipv4");
});
});
describe("normaliseCode()", function() {
it("should format codes as shown by the installer", function() {
assert.equal(normaliseCode("ABC-DEF"), "ABC-DEF");
assert.equal(normaliseCode("abcdef"), "ABC-DEF");
assert.equal(normaliseCode("abc-def"), "ABC-DEF");
assert.equal(normaliseCode(" abc def "), "ABC-DEF");
assert.equal(normaliseCode("a.b_c-d e/f"), "ABC-DEF");
});
});
describe("HostPairingManager.validateName()", function() {
it("should require a non-empty string", function() {
for (const name of [undefined, null, 5, {}, "", " "]) {
assertPairingError(() => HostPairingManager.validateName(name), 400, "Host name is required");
}
});
it("should trim the name", function() {
assert.equal(HostPairingManager.validateName(" Host A \t"), "Host A");
});
it("should limit the length of the name", function() {
const longest = "x".repeat(maxNameLength);
assert.equal(maxNameLength, 100);
assert.equal(HostPairingManager.validateName(longest), longest);
assert.equal(HostPairingManager.validateName(` ${longest} `), longest);
assertPairingError(
() => HostPairingManager.validateName(`${longest}x`),
400, "Host name must be at most 100 characters",
);
});
it("should refuse control and bidirectional override characters", function() {
for (const name of ["x\u001b]0;title\u0007", "line\nfaked", "a\rb", "del\u007f", "c1\u009b", "x\u202ey"]) {
assertPairingError(
() => HostPairingManager.validateName(name), 400, "Host name may not contain control characters",
);
}
});
it("should allow other unicode characters", function() {
for (const name of ["Fabrikk p\u00e5 \u00d8stlandet", "\u5de5\u5382 1", "box \u{1F44D}\u200d"]) {
assert.equal(HostPairingManager.validateName(name), name);
}
});
});
describe("class HostPairingManager", function() {
/** @type {Controller} */
let controller;
/** @type {HostPairingManager} */
let manager;
let updates;
let restoreRandomInt;
beforeEach(function() {
controller = makeController();
manager = controller.hostPairing;
updates = [];
manager.requests.on("update", changed => {
updates.push(...changed.map(details => ({ code: details.code, isDeleted: details.isDeleted })));
});
restoreRandomInt = undefined;
});
afterEach(function() {
manager.stop();
if (restoreRandomInt) {
restoreRandomInt();
}
});
function internal(requestId) {
return manager._bySecret.get(requestId);
}
// Pretend the installer last polled the request ms milliseconds earlier.
function idle(requestId, ms) {
internal(requestId).lastPollMs -= ms;
}
describe(".createHost()", function() {
it("should create a host record with a valid token", async function() {
const before = Math.floor(Date.now() / 1000);
const result = await manager.createHost("Host A");
assert.equal(result.name, "Host A");
assert(Number.isInteger(result.hostId), "hostId is not an integer");
assert(result.hostId >= 1 && result.hostId < 2 ** 31, `hostId ${result.hostId} out of range`);
const record = controller.hosts.get(result.hostId);
assert(record instanceof HostRecord, "host record was not created");
assert.equal(record.name, "Host A");
assert.equal(record.connected, false);
assert(record.tokenValidAfter >= before, "tokenValidAfter predates the call");
assert(record.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, result.hostId);
assert(payload.iat >= record.tokenValidAfter, "token is not valid for the new record");
});
it("should save the hosts before returning", async function() {
const save = deferred();
let saveCalls = 0;
controller.hosts.save = () => { saveCalls += 1; return save.promise; };
let done = false;
const promise = manager.createHost("Host A").then(result => { done = true; return result; });
await new Promise(resolve => setImmediate(resolve));
assert.equal(saveCalls, 1);
assert.equal(done, false, "createHost returned before the hosts were saved");
save.resolve();
const result = await promise;
assert(controller.hosts.has(result.hostId));
});
it("should remove the record if saving fails", async function() {
restoreRandomInt = stubRandomInt([1234]);
controller.hosts.save = async () => { throw new Error("disk full"); };
await assert.rejects(manager.createHost("Host A"), /disk full/);
assert.equal(controller.hosts.has(1234), false);
assert.equal(controller.hosts.size, 0);
});
it("should not reuse the id of an existing host", async function() {
controller.hosts.set(new HostRecord(5, "existing", "", new Map()));
restoreRandomInt = stubRandomInt([5, 5, 6]);
const result = await manager.createHost("Host A");
assert.equal(result.hostId, 6);
assert.equal(controller.hosts.get(5).name, "existing");
assert.equal(controller.hosts.get(6).name, "Host A");
});
});
describe(".create()", function() {
it("should create a pending request", function() {
const before = Date.now();
const { requestId, code } = manager.create("Host A", "2.0.0", "10.0.0.1");
assert.equal(typeof requestId, "string");
assert(requestId.length >= 32, "requestId is too short to be a secret");
assert.match(code, codePattern);
assert.notEqual(requestId, code);
const details = manager.requests.get(code);
assert(details instanceof lib.HostPairingDetails);
assert.equal(details.code, code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "2.0.0");
assert.equal(details.remoteAddress, "10.0.0.1");
assert(details.createdAtMs >= before && details.createdAtMs <= Date.now());
assert(details.updatedAtMs > 0);
assert.equal(details.isDeleted, false);
assert(!JSON.stringify(details).includes(requestId), "requestId is exposed to users");
assert.deepEqual(updates, [{ code, isDeleted: false }]);
});
it("should create unique codes", function() {
const codes = new Set();
for (let i = 0; i < maxPending; i++) {
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
assert.match(code, codePattern);
codes.add(code);
}
assert.equal(codes.size, maxPending);
});
it("should not reuse the code of a pending request", function() {
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
assert.equal(manager.create("Host A", "", "10.0.0.1").code, "222-222");
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
it("should not reuse the code of an approved request not yet picked up", async function() {
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
const { code } = manager.create("Host A", "", "10.0.0.1");
assert.equal(code, "222-222");
assert.equal(await manager.approve(code, undefined, "admin"), 1234);
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
it("should use distinct request ids", function() {
const first = manager.create("Host A", "", "10.0.0.1");
const second = manager.create("Host A", "", "10.0.0.1");
assert.notEqual(first.requestId, second.requestId);
assert.notEqual(first.code, second.code);
});
});
describe("limits", function() {
it("should limit pending requests per address", function() {
assert.equal(maxPendingPerAddress, 3);
for (let i = 0; i < maxPendingPerAddress; i++) {
manager.create("Host", "", "10.0.0.1");
}
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
assert(err instanceof HostPairingError);
assert.equal(err.status, 429);
assert.equal(err.retryAfter, 30);
assert.equal(err.message, "Too many pending requests from this address");
return true;
});
// Other addresses are not affected
manager.create("Host", "", "10.0.0.2");
assert.equal(manager.requests.size, maxPendingPerAddress + 1);
});
it("should count IPv4 mapped addresses as the IPv4 address", function() {
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "::ffff:10.0.0.1");
manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "::ffff:10.0.0.1"), 429);
});
it("should group IPv6 addresses by /64", function() {
manager.create("Host", "", "2001:db8:0:1::1");
manager.create("Host", "", "2001:db8:0:1::2");
manager.create("Host", "", "2001:db8::1:ffff:0:0:3");
assertPairingError(
() => manager.create("Host", "", "2001:0db8:0000:0001:0000:0000:0000:0004"),
429, "Too many pending requests from this address",
);
manager.create("Host", "", "2001:db8:0:2::1");
});
it("should free the slot when a request is no longer pending", async function() {
const first = manager.create("Host", "", "10.0.0.1");
const second = manager.create("Host", "", "10.0.0.1");
const third = manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
manager.cancel(first.requestId);
const fourth = manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
// Approved and rejected results waiting to be picked up do not count
await manager.approve(second.code, undefined, "admin");
manager.reject(third.code, "admin");
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
assert(manager.requests.has(fourth.code));
});
it("should limit the total number of pending requests", function() {
assert.equal(maxPending, 50);
for (let i = 0; i < maxPending; i++) {
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
}
assert.throws(() => manager.create("Host", "", "10.2.0.1"), err => {
assert(err instanceof HostPairingError);
assert.equal(err.status, 429);
assert.equal(err.retryAfter, 30);
assert.equal(err.message, "Too many pending requests");
return true;
});
assert.equal(manager.requests.size, maxPending);
});
it("should limit the number of requests created per minute from an address", function() {
assert.equal(maxCreatesPerMinute, 10);
for (let i = 0; i < maxCreatesPerMinute; i++) {
manager.cancel(manager.create("Host", "", "10.0.0.1").requestId);
}
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
assert(err instanceof HostPairingError);
assert.equal(err.status, 429);
assert.equal(err.message, "Too many requests from this address");
assert(err.retryAfter >= 1 && err.retryAfter <= 60, `bad retryAfter ${err.retryAfter}`);
return true;
});
// IPv6 addresses in the same /64 share the limit
for (let i = 0; i < maxCreatesPerMinute; i++) {
manager.cancel(manager.create("Host", "", `2001:db8:0:1::${i + 1}`).requestId);
}
assertPairingError(
() => manager.create("Host", "", "2001:db8:0:1:ffff::1"), 429, "Too many requests from this address"
);
manager.create("Host", "", "10.0.0.2");
});
it("should allow creating requests again after a minute", function() {
for (let i = 0; i < maxCreatesPerMinute; i++) {
manager.cancel(manager.create("Host", "", "10.0.0.1").requestId);
}
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
for (const times of manager._recentCreates.values()) {
times.forEach((ms, index) => { times[index] = ms - 60e3 - 1; });
}
manager.create("Host", "", "10.0.0.1");
});
it("should not count refused requests towards the rate limit", function() {
const first = manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
const createRefused = () => manager.create("Host", "", "10.0.0.1");
for (let i = 0; i < maxCreatesPerMinute; i++) {
assertPairingError(createRefused, 429);
}
manager.cancel(first.requestId);
manager.create("Host", "", "10.0.0.1");
});
});
describe(".poll()", function() {
it("should return null while pending", function() {
const { requestId } = manager.create("Host A", "", "10.0.0.1");
assert.equal(manager.poll(requestId), null);
assert.equal(manager.poll(requestId), null);
});
it("should throw 404 for an unknown request", function() {
assertPairingError(() => manager.poll("invalid"), 404, "Request not found");
});
it("should not accept the code in place of the request id", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
assertPairingError(() => manager.poll(code), 404);
});
it("should hand over an approved result once", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
const result = manager.poll(requestId);
assert.deepEqual(Object.keys(result).sort(), ["hostId", "name", "token"]);
assert.equal(result.hostId, hostId);
assert.equal(result.name, "Host A");
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, hostId);
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
assert.equal(manager._byCode.has(code), false);
});
it("should report a rejection once", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
assertPairingError(() => manager.poll(requestId), 403, "Request was rejected");
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
});
});
describe(".cancel()", function() {
it("should remove a pending request", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.cancel(requestId);
assert.equal(manager.requests.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
});
it("should ignore unknown requests", function() {
manager.cancel("invalid");
});
it("should not throw away an approved result", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
manager.cancel(requestId);
assert.equal(manager.poll(requestId).hostId, hostId);
});
it("should not throw away a rejection", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
manager.cancel(requestId);
assertPairingError(() => manager.poll(requestId), 403);
});
});
describe(".approve()", function() {
it("should create the host with the requested name", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(manager.requests.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
});
it("should accept codes in any case and without the dash", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
assert(controller.hosts.has(hostId));
});
it("should use and trim the name given", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, " Renamed ", "admin");
assert.equal(controller.hosts.get(hostId).name, "Renamed");
assert.equal(manager.poll(requestId).name, "Renamed");
});
it("should reject an invalid name and keep the request pending", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
await assert.rejects(
manager.approve(code, "", "admin"),
new lib.ValidationError("Host name is required"),
);
await assert.rejects(
manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
new lib.ValidationError("Host name must be at most 100 characters"),
);
assert.equal(controller.hosts.size, 0);
assert.equal(manager.poll(requestId), null);
await manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.size, 1);
});
it("should throw NotFoundError for an unknown code", async function() {
await assert.rejects(
manager.approve("ZZZ-ZZZ", undefined, "admin"),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should only approve a request once", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
await manager.approve(code, undefined, "admin");
await assert.rejects(manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.equal(controller.hosts.size, 1);
});
it("should not create two hosts when approved twice at the same time", async function() {
const save = deferred();
controller.hosts.save = () => save.promise;
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const first = manager.approve(code, undefined, "admin");
await assert.rejects(manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
// The request stays while approving, the installer sees it as pending
manager.cancel(requestId);
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.poll(requestId), null);
assert(manager.requests.has(code), "request removed while approving");
save.resolve();
const hostId = await first;
assert.equal(controller.hosts.size, 1);
assert.equal(manager.poll(requestId).hostId, hostId);
});
it("should keep the request pending if creating the host fails", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const originalSave = controller.hosts.save;
controller.hosts.save = async () => { throw new Error("disk full"); };
await assert.rejects(manager.approve(code, undefined, "admin"), /disk full/);
assert.equal(controller.hosts.size, 0);
assert(manager.requests.has(code));
assert.equal(manager.poll(requestId), null);
controller.hosts.save = originalSave;
const hostId = await manager.approve(code, undefined, "admin");
assert.equal(manager.poll(requestId).hostId, hostId);
});
});
describe(".reject()", function() {
it("should remove the request from the pending list", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code.toLowerCase(), "admin");
assert.equal(manager.requests.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.equal(controller.hosts.size, 0);
});
it("should throw NotFoundError for an unknown code", function() {
assert.throws(
() => manager.reject("ZZZ-ZZZ", "admin"),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should not reject an approved request", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
assert.equal(manager.poll(requestId).hostId, hostId);
});
});
describe(".prune()", function() {
let warnings;
let originalWarn;
beforeEach(function() {
warnings = [];
originalWarn = lib.logger.warn;
lib.logger.warn = msg => { warnings.push(msg); };
});
afterEach(function() {
lib.logger.warn = originalWarn;
});
it("should expire a request the installer stopped polling", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.requests.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assertPairingError(() => manager.poll(requestId), 404);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
});
it("should keep a request that is still polled", function() {
assert.equal(pollTimeoutMs, 30e3);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
idle(requestId, pollTimeoutMs - 1000);
manager.prune();
assert.equal(manager.poll(requestId), null);
idle(requestId, pollTimeoutMs - 1000);
manager.prune();
assert(manager.requests.has(code));
});
it("should expire a request that has been pending for too long", function() {
assert.equal(maxPendingMs, 60 * 60e3);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
internal(requestId).details.createdAtMs -= maxPendingMs - 1000;
manager.prune();
assert.equal(manager.poll(requestId), null);
internal(requestId).details.createdAtMs -= 1001;
manager.prune();
assert.equal(manager.requests.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
});
it("should expire requests when creating and polling", function() {
const first = manager.create("Host A", "", "10.0.0.1");
idle(first.requestId, pollTimeoutMs + 1);
const second = manager.create("Host B", "", "10.0.0.1");
assert.equal(manager.requests.has(first.code), false);
idle(second.requestId, pollTimeoutMs + 1);
assertPairingError(() => manager.poll(second.requestId), 404);
});
it("should drop and warn about an approved host never picked up", async function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = await manager.approve(code, undefined, "admin");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assertPairingError(() => manager.poll(requestId), 404);
assert.equal(warnings.length, 1);
assert.match(warnings[0], new RegExp(`Approved host Host A \\(${hostId}\\) was never picked up`));
// The host stays until revoked
assert(controller.hosts.has(hostId));
});
it("should drop a rejection never picked up", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assertPairingError(() => manager.poll(requestId), 404);
assert.equal(warnings.length, 0);
});
it("should forget creation times older than a minute", function() {
manager.create("Host A", "", "10.0.0.1");
assert.equal(manager._recentCreates.size, 1);
manager.prune();
assert.equal(manager._recentCreates.size, 1);
for (const times of manager._recentCreates.values()) {
times.forEach((ms, index) => { times[index] = ms - 60e3 - 1; });
}
manager.prune();
assert.equal(manager._recentCreates.size, 0);
});
});
describe(".start()/.stop()", function() {
it("should run an unreferenced prune interval while started", function() {
manager.start();
assert(manager._pruneInterval, "prune interval not started");
assert.equal(manager._pruneInterval.hasRef(), false);
manager.stop();
assert.equal(manager._pruneInterval, undefined);
});
});
describe("Controller integration", function() {
it("should broadcast changes to pending requests", function() {
const broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof lib.HostPairingUpdatesEvent) {
broadcasts.push(event.updates.map(details => [details.code, details.isDeleted]));
}
};
const { code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
assert.deepEqual(broadcasts, [[[code, false]], [[code, true]]]);
});
it("should send pending requests to new subscribers", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const event = await controller.handleHostPairingSubscription(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, 0)
);
assert(event instanceof lib.HostPairingUpdatesEvent);
assert.deepEqual(event.updates.map(details => details.code), [code]);
const lastUpdate = manager.requests.get(code).updatedAtMs;
assert.equal(await controller.handleHostPairingSubscription(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
), null);
});
});
});
});
+142 -1
View File
@@ -1,7 +1,32 @@
import assert from "node:assert/strict";
import events from "node:events";
import { Controller, WsServer } from "@clusterio/controller";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostRecord, WsServer } from "@clusterio/controller";
import { ControllerConfig } from "@clusterio/lib";
import packageJson from "@clusterio/controller/package.json" with { type: "json" };
import "../setup_logging.js";
class MockWebSocket extends events.EventEmitter {
constructor() {
super();
this.sent = [];
this.closed = null;
}
send(message) {
this.sent.push(JSON.parse(message));
}
close(code, reason) {
this.closed = { code, reason };
}
terminate() {
this.emit("close", 1006, "");
}
}
describe("controller/src/WsServer", function() {
describe("class WsServer", function() {
@@ -88,4 +113,120 @@ describe("controller/src/WsServer", function() {
});
});
});
describe(".registerHost()", function() {
const secret = Buffer.from("TestSecretDoNotUse");
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
/** @type {Controller} */
let controller;
/** @type {WsServer} */
let wsServer;
let socket;
beforeEach(function() {
const config = new ControllerConfig("controller");
config.set("controller.auth_secret", secret.toString("base64"));
controller = new Controller(lib.logger, [], config);
wsServer = controller.wsServer;
socket = new MockWebSocket();
});
afterEach(function() {
for (const connection of wsServer.hostConnections.values()) {
connection.connector.terminate();
}
});
function addHost(id, tokenValidAfter = 0) {
const record = new HostRecord(id, `host-${id}`, "", new Map(), false, "", "203.0.113.1", tokenValidAfter);
controller.hosts.set(record);
return record;
}
async function register(token, id = 5) {
await wsServer.registerHost(new lib.RegisterHostData(token, packageJson.version, id, {}), socket, req);
}
function assertRejected(reason) {
assert.deepEqual(socket.closed, { code: lib.ConnectionClosed.Unauthorized, reason });
assert.equal(socket.closed.code, 3000);
assert.equal(wsServer.hostConnections.size, 0);
assert(!socket.sent.some(message => message.type === "ready"), "ready was sent");
}
it("should reject a validly signed token for a host that is not registered", async function() {
await register(controller.generateHostToken(5));
assertRejected(
"Authentication failed: host is not registered, run the installer again in the host directory"
);
assert.equal(controller.hosts.has(5), false, "host record was created");
});
it("should accept a registered host", async function() {
const record = addHost(5);
await register(controller.generateHostToken(5));
assert.equal(socket.closed, null);
assert(socket.sent.some(message => message.type === "ready"), "ready was not sent");
assert(wsServer.hostConnections.has(5));
assert.equal(controller.hosts.size, 1);
assert.equal(controller.hosts.get(5), record, "host record was replaced");
assert.equal(record.connected, true);
assert.equal(record.version, packageJson.version);
assert.equal(record.remoteAddress, "10.0.0.1");
assert.equal(record.name, "host-5");
assert.equal(record.publicAddress, "203.0.113.1");
wsServer.hostConnections.get(5).connector.terminate();
assert.equal(wsServer.hostConnections.has(5), false);
assert.equal(controller.hosts.get(5).connected, false);
});
it("should reject a token issued before tokenValidAfter", async function() {
const token = controller.generateHostToken(5);
addHost(5, Math.floor(Date.now() / 1000) + 60);
await register(token);
assertRejected("Authentication failed: invalid token");
});
it("should accept a token issued at tokenValidAfter", async function() {
const now = Math.floor(Date.now() / 1000);
addHost(5, now);
await register(jwt.sign({ aud: "host", host: 5, iat: now }, secret));
assert.equal(socket.closed, null);
assert(wsServer.hostConnections.has(5));
});
it("should reject a token without an issue time", async function() {
addHost(5);
await register(jwt.sign({ aud: "host", host: 5 }, secret, { noTimestamp: true }));
assertRejected("Authentication failed: invalid token");
});
it("should reject a token for another host", async function() {
addHost(5);
addHost(6);
await register(controller.generateHostToken(6), 5);
assertRejected("Authentication failed: missmatched host id");
});
it("should reject a token signed with another secret", async function() {
addHost(5);
await register(jwt.sign({ aud: "host", host: 5 }, Buffer.from("OtherSecret")));
assertRejected("Authentication failed: invalid signature");
});
it("should reject a host revoked while its previous connection closes", async function() {
const record = addHost(5);
wsServer.hostConnections.set(5, {
remoteAddress: "10.0.0.1",
async disconnect() {
controller.hosts.delete(record);
wsServer.hostConnections.delete(5);
},
});
await register(controller.generateHostToken(5));
assertRejected("Authentication failed: host was revoked");
assert.equal(controller.hosts.has(5), false, "host record was recreated");
});
it("should reject hosts in recovery mode", async function() {
addHost(5);
controller.recoveryMode = true;
await register(controller.generateHostToken(5));
assert.deepEqual(socket.closed, {
code: lib.ConnectionClosed.RecoveryMode,
reason: "Connection Refused: Controller in recovery mode",
});
});
});
});
+324 -1
View File
@@ -3,7 +3,10 @@ import events from "node:events";
import FormData from "form-data";
import http from "node:http";
import { wait, expositionContentType } from "@clusterio/lib";
import jwt from "jsonwebtoken";
import { SubscribableDatastore, wait, expositionContentType } from "@clusterio/lib";
import { Controller, HostPairingManager, HostRecord } from "@clusterio/controller";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.js";
@@ -211,6 +214,326 @@ describe("controller/src/routes", function() {
assert.deepEqual(data, { name: "Test Cluster" });
});
});
describe("/api/host-pairing", function() {
let endpoint, adminToken, playerToken;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/host-pairing`;
// The mock controller lacks what pairing needs, use the real implementations.
controller.hosts = new SubscribableDatastore();
controller.hostPairing = new HostPairingManager(controller);
controller.generateHostToken = Controller.prototype.generateHostToken;
controller.wsServer.remoteAddr = req => req.socket.remoteAddress;
adminToken = controller.users.signUserToken(controller.users.getByName("test"));
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
});
function post(path, body, headers = {}) {
return fetch(`${endpoint}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: typeof body === "string" || body === undefined ? body : JSON.stringify(body),
});
}
async function assertResponse(response, status, body) {
assert.equal(response.status, status);
assert.equal(response.headers.get("cache-control"), "no-store");
if (body !== undefined) {
assert.deepEqual(await response.json(), body);
}
}
async function createRequest(name = "Host A") {
const response = await post("", { name });
assert.equal(response.status, 202);
return await response.json();
}
function check(headers) {
return fetch(`${endpoint}/check`, { headers });
}
function assertNothingCreated() {
assert.equal(controller.hostPairing.requests.size, 0, "a pending request was created");
assert.equal(controller.hosts.size, 0, "a host was created");
}
describe("POST /api/host-pairing", function() {
it("should create a pending request", async function() {
const response = await post("", { name: "Host A", version: "2.0.0" });
assert.equal(response.status, 202);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["code", "requestId"]);
assert.match(body.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
const details = controller.hostPairing.requests.get(body.code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "2.0.0");
assert.match(details.remoteAddress, /127\.0\.0\.1|::1/);
assert.equal(controller.hosts.size, 0);
});
it("should trim the name and limit the version", async function() {
let { code } = await (await post("", { name: " Host A ", version: "x".repeat(100) })).json();
let details = controller.hostPairing.requests.get(code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "x".repeat(64));
({ code } = await (await post("", { name: "Host B", version: 5 })).json());
details = controller.hostPairing.requests.get(code);
assert.equal(details.version, "");
});
it("should respond with 400 for a missing or invalid name", async function() {
const required = { error: "Host name is required" };
await assertResponse(await post("", {}), 400, required);
await assertResponse(await post("", { name: "" }), 400, required);
await assertResponse(await post("", { name: " " }), 400, required);
await assertResponse(await post("", { name: 5 }), 400, required);
await assertResponse(await post("", undefined), 400, required);
await assertResponse(await post("", "name=Host", { "Content-Type": "text/plain" }), 400, required);
await assertResponse(
await post("", { name: "x".repeat(101) }),
400, { error: "Host name must be at most 100 characters" },
);
assertNothingCreated();
});
it("should respond with 400 for malformed JSON", async function() {
controller.app.set("env", "test"); // Silence the default error handler
const response = await post("", "{\"name\":");
assert.equal(response.status, 400);
assertNothingCreated();
});
it("should respond with 413 for a body over 4kb", async function() {
controller.app.set("env", "test"); // Silence the default error handler
const response = await post("", { name: "x".repeat(5000) });
assert.equal(response.status, 413);
assertNothingCreated();
});
it("should respond with 429 and Retry-After when limited", async function() {
for (let i = 0; i < 3; i++) {
await createRequest();
}
const response = await post("", { name: "Host A" });
assert.equal(response.headers.get("retry-after"), "30");
await assertResponse(response, 429, { error: "Too many pending requests from this address" });
assert.equal(controller.hostPairing.requests.size, 3);
});
it("should respond with 503 in recovery mode", async function() {
controller.recoveryMode = true;
const recovery = { error: "Controller is in recovery mode" };
await assertResponse(await post("", { name: "Host A" }), 503, recovery);
await assertResponse(
await post("", { name: "Host A" }, { "Authorization": `Bearer ${adminToken}` }), 503, recovery
);
assertNothingCreated();
});
it("should approve directly with the token of a user allowed to approve hosts", async function() {
for (const headers of [
{ "Authorization": `Bearer ${adminToken}` },
{ "Authorization": `bearer ${adminToken}` },
{ "X-Access-Token": adminToken },
]) {
const response = await post("", { name: "Host A" }, headers);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
assert.equal(body.name, "Host A");
assert.equal(controller.hosts.get(body.hostId).name, "Host A");
const payload = jwt.verify(body.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, body.hostId);
}
assert.equal(controller.hosts.size, 3);
assert.equal(controller.hostPairing.requests.size, 0, "a pending request was created");
});
it("should validate the name before approving directly", async function() {
await assertResponse(
await post("", {}, { "Authorization": `Bearer ${adminToken}` }),
400, { error: "Host name is required" },
);
assertNothingCreated();
});
it("should respond with 403 for a user not allowed to approve hosts", async function() {
await assertResponse(
await post("", { name: "Host A" }, { "Authorization": `Bearer ${playerToken}` }),
403, { error: "User player is not allowed to approve hosts" },
);
assertNothingCreated();
});
it("should respond with 401 for an invalid token without creating a request", async function() {
// The admin user's tokens are revoked after signing this one
const revokedToken = adminToken;
const user = controller.users.getOrCreateUser("test");
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
user.saveRecord();
controller.hosts.set(new HostRecord(5, "five", "", new Map()));
const hostToken = controller.generateHostToken(5);
controller.hosts.delete(controller.hosts.get(5));
for (const headers of [
{ "Authorization": "Bearer invalid" },
{ "X-Access-Token": "invalid" },
{ "Authorization": `Bearer ${controller.users.signUserToken({ id: "nobody" })}` },
{ "Authorization": `Bearer ${revokedToken}` },
{ "Authorization": `Bearer ${hostToken}` },
]) {
await assertResponse(
await post("", { name: "Host A" }, headers), 401, { error: "Invalid user token" }
);
}
assertNothingCreated();
});
});
describe("POST /api/host-pairing/poll", function() {
it("should respond with 202 while pending", async function() {
const { requestId } = await createRequest();
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});
it("should hand over the result of an approved request once", async function() {
const { requestId, code } = await createRequest();
const hostId = await controller.hostPairing.approve(code, "Renamed", "test");
const response = await post("/poll", { requestId });
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
assert.equal(body.hostId, hostId);
assert.equal(body.name, "Renamed");
assert.equal(jwt.verify(body.token, controller.authSecret, { audience: "host" }).host, hostId);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 403 once for a rejected request", async function() {
const { requestId, code } = await createRequest();
controller.hostPairing.reject(code, "test");
await assertResponse(await post("/poll", { requestId }), 403, { error: "Request was rejected" });
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 404 for an unknown request", async function() {
const { code } = await createRequest();
const notFound = { error: "Request not found" };
await assertResponse(await post("/poll", { requestId: "invalid" }), 404, notFound);
await assertResponse(await post("/poll", { requestId: code }), 404, notFound);
});
it("should respond with 400 without a requestId in the body", async function() {
const { requestId } = await createRequest();
const required = { error: "requestId is required" };
await assertResponse(await post("/poll", {}), 400, required);
await assertResponse(await post("/poll", { requestId: 5 }), 400, required);
await assertResponse(await post(`/poll?requestId=${encodeURIComponent(requestId)}`, {}), 400, required);
});
it("should respond with 503 in recovery mode", async function() {
const { requestId } = await createRequest();
controller.recoveryMode = true;
await assertResponse(
await post("/poll", { requestId }), 503, { error: "Controller is in recovery mode" }
);
});
});
describe("POST /api/host-pairing/cancel", function() {
it("should cancel a pending request", async function() {
const { requestId, code } = await createRequest();
await assertResponse(await post("/cancel", { requestId }), 204);
assert.equal(controller.hostPairing.requests.has(code), false);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 204 for an unknown request", async function() {
const { code } = await createRequest();
await assertResponse(await post("/cancel", { requestId: "invalid" }), 204);
await assertResponse(await post("/cancel", { requestId: code }), 204);
assert(controller.hostPairing.requests.has(code), "cancelled by the code");
});
it("should respond with 400 without a requestId in the body", async function() {
const { requestId, code } = await createRequest();
await assertResponse(await post("/cancel", {}), 400, { error: "requestId is required" });
await assertResponse(
await post(`/cancel?requestId=${encodeURIComponent(requestId)}`, {}),
400, { error: "requestId is required" },
);
assert(controller.hostPairing.requests.has(code));
});
it("should respond with 503 in recovery mode", async function() {
const { requestId, code } = await createRequest();
controller.recoveryMode = true;
await assertResponse(
await post("/cancel", { requestId }), 503, { error: "Controller is in recovery mode" }
);
assert(controller.hostPairing.requests.has(code));
});
});
describe("request ids", function() {
it("should not be accepted in the URL", async function() {
const { requestId } = await createRequest();
const id = encodeURIComponent(requestId);
for (const [method, path] of [
["GET", `/poll?requestId=${id}`],
["GET", `/poll/${id}`],
["POST", `/poll/${id}`],
["POST", `/${id}`],
["GET", `/${id}`],
["POST", `/cancel/${id}`],
]) {
const response = await fetch(`${endpoint}${path}`, { method });
assert.equal(response.status, 404, `${method} ${path}`);
}
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});
});
describe("GET /api/host-pairing/check", function() {
it("should respond with 200 for a valid host token", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
for (const headers of [
{ "Authorization": `Bearer ${token}` },
{ "X-Access-Token": token },
]) {
await assertResponse(await check(headers), 200, { valid: true });
}
assert(controller.hosts.has(hostId));
});
it("should respond with 401 without a valid host token", async function() {
await controller.hostPairing.createHost("Host A");
assert.equal((await check({})).status, 401);
assert.equal((await check({ "Authorization": "Bearer invalid" })).status, 401);
assert.equal((await check({ "Authorization": `Bearer ${adminToken}` })).status, 401);
});
it("should respond with 401 for a host that is not registered", async function() {
const token = controller.generateHostToken(99);
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
it("should respond with 401 once the host is revoked", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
controller.hosts.delete(controller.hosts.get(hostId));
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
it("should respond with 401 for a token issued before tokenValidAfter", async function() {
const { hostId, token } = await controller.hostPairing.createHost("Host A");
const record = controller.hosts.getMutable(hostId);
record.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
controller.hosts.set(record);
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
});
describe("pairing flow", function() {
it("should pair a host approved while the installer polls", async function() {
const { requestId, code } = await createRequest("Host A");
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
const hostId = await controller.hostPairing.approve(code.toLowerCase(), undefined, "test");
const result = await (await post("/poll", { requestId })).json();
assert.deepEqual(result, { hostId, token: result.token, name: "Host A" });
await assertResponse(
await check({ "Authorization": `Bearer ${result.token}` }), 200, { valid: true }
);
});
});
});
describe("/api/upload-save", function() {
let endpoint, testToken;
beforeEach(function() {
+331
View File
@@ -1,9 +1,15 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import crypto from "node:crypto";
import events from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
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";
const execFileAsync = promisify(execFile);
const installer = path.resolve(import.meta.dirname, "../packages/create/create.js");
@@ -123,4 +129,329 @@ describe("create installer CLI", function() {
return true;
});
});
describe("host setup", function() {
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const hostBin = path.join("node_modules", ".bin", `clusteriohost${scriptExt}`);
const controllerBin = path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`);
const hostSet = (field, value) => `${hostBin} config set ${field} ${escapeArg(String(value))}`;
const approved = { hostId: 1234, token: "host-token", name: "Approved" };
let server;
let controllerUrl;
let requests;
let handler;
beforeEach(async function() {
requests = [];
handler = () => [500, { error: "No handler" }];
server = http.createServer(async (req, res) => {
let body = "";
for await (const chunk of req) {
body += chunk;
}
const entry = { url: req.url, headers: req.headers, body: body ? JSON.parse(body) : undefined };
requests.push(entry);
const [status, json] = handler(entry);
res.writeHead(status, { "Content-Type": "application/json" });
res.end(json === undefined ? undefined : JSON.stringify(json));
});
server.listen(0, "localhost");
await events.once(server, "listening");
controllerUrl = `http://localhost:${server.address().port}/`;
});
afterEach(function() {
server.closeAllConnections();
server.close();
});
// Stub of the controller pairing endpoints, approving after the given number of pending polls.
function pairingHandler(pendingPolls = 0, result = approved) {
let polls = 0;
return ({ url }) => {
if (url === "/api/host-pairing") {
return [202, { requestId: "secret", code: "ABC-DEF" }];
}
if (url === "/api/host-pairing/poll") {
polls += 1;
return polls > pendingPolls ? [200, result] : [202, { status: "pending" }];
}
if (url === "/api/host-pairing/cancel") {
return [204, undefined];
}
return [404, { error: "Not found" }];
};
}
function run(args, env = {}) {
// Record the commands for npm and the clusterio CLIs instead of running them.
const mockExec = `
import childProcess from "node:child_process";
import fs from "node:fs";
import { PassThrough } from "node:stream";
childProcess.execFile = (command, options, callback) => {
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, "");
});
return { stdout, stderr };
};
if (process.env.CREATE_TEST_SIGINT) {
// Stands in for Ctrl-C, which cannot be sent to a child process on Windows.
// Sent twice like when npm init forwards the one it gets to the installer.
const write = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, ...rest) => {
if (String(chunk).includes("Waiting for approval")) {
setTimeout(() => {
process.emit("SIGINT", "SIGINT");
process.emit("SIGINT", "SIGINT");
}, 50);
}
return write(chunk, ...rest);
};
}
`;
return execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockExec)}`,
installer,
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
...args,
], {
cwd: directory,
env: { ...process.env, CREATE_TEST_COMMANDS: path.join(directory, "commands.jsonl"), ...env },
});
}
// Commands run by the installer, without the getent lookups made when writing systemd units.
async function commands() {
let content;
try {
content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
} catch (err) {
if (err.code === "ENOENT") {
return [];
}
throw err;
}
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
.filter(command => !command.startsWith("getent "));
}
function hostArgs(...extra) {
return [
"--mode", "host", "--host-name", "Test Host", "--controller-url", controllerUrl,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm",
...extra, "--plugins",
];
}
async function writeHostConfig(token) {
await fs.writeFile(path.join(directory, "config-host.json"), JSON.stringify({
"host.name": "Test Host",
"host.controller_url": controllerUrl,
"host.controller_token": token,
}));
}
it("pairs a new host once approved and saves its id and token", async function() {
handler = pairingHandler(0);
const { stdout } = await run(hostArgs());
assert(stdout.includes(
`Waiting for approval. Approve "Test Host" with code ABC-DEF on the Hosts page of ${controllerUrl}, ` +
"or run: npx clusterioctl host approve ABC-DEF"
), stdout);
assert.match(stdout, /Successfully installed host/);
const { version } = JSON.parse(await fs.readFile(path.join(path.dirname(installer), "package.json")));
assert.deepEqual(requests[0].body, { name: "Test Host", version });
assert.equal(requests[0].headers.authorization, undefined);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
assert.deepEqual(recorded.slice(1), [
`${hostBin} config create`,
hostSet("host.public_address", "host.example.com"),
hostSet("host.factorio_directory", "factorio"),
hostSet("host.allow_remote_updates", false),
hostSet("host.allow_plugin_updates", false),
hostSet("host.allow_plugin_install", false),
hostSet("host.name", "Test Host"),
hostSet("host.controller_url", controllerUrl),
hostSet("host.id", 1234),
hostSet("host.name", "Approved"),
hostSet("host.controller_token", "host-token"),
]);
await fs.access(path.join(directory, `run-host${process.platform === "win32" ? ".cmd" : ".sh"}`));
});
it("uses --controller-token to approve the host without saving it", async function() {
handler = ({ headers }) => (headers.authorization === "Bearer user-token"
? [200, { hostId: 99, token: "host-token", name: "Test Host" }]
: [401, { error: "Invalid user token" }]
);
const { stdout } = await run(hostArgs("--controller-token", "user-token"));
assert.doesNotMatch(stdout, /Waiting for approval/);
const recorded = await commands();
assert(!recorded.some(command => command.includes("user-token")), "user token must not be saved");
assert.deepEqual(recorded.slice(-2), [
hostSet("host.id", 99),
hostSet("host.controller_token", "host-token"),
]);
});
it("cancels the request on Ctrl-C", async function() {
handler = pairingHandler(Infinity);
await assert.rejects(run(hostArgs(), { CREATE_TEST_SIGINT: "1" }), err => {
assert.equal(err.code, 1);
assert.match(err.stdout + err.stderr, /Pairing cancelled/);
return true;
});
assert.deepEqual(requests.find(r => r.url === "/api/host-pairing/cancel")?.body, { requestId: "secret" });
assert(!(await commands()).some(command => command.includes("host.controller_token")));
});
it("installs without contacting the controller with --no-pair", async function() {
const { stdout } = await run([
"--mode", "host", "--no-pair", "--public-address", "localhost", "--factorio-dir", "factorio",
"--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed host/);
assert.equal(requests.length, 0);
const recorded = await commands();
assert(recorded.includes(hostSet("host.allow_plugin_install", true)));
assert(!recorded.some(command => command.includes("host.controller_token")));
});
it("exits when run again on a host the controller accepts", async function() {
await writeHostConfig("valid-token");
handler = ({ url, headers }) => {
const valid = url === "/api/host-pairing/check" && headers.authorization === "Bearer valid-token";
return valid ? [200, { valid: true }] : [401, undefined];
};
const { stdout } = await run(["--mode", "host"]);
assert.match(stdout, /Host is already set up/);
assert.deepEqual(requests.map(r => r.url), ["/api/host-pairing/check"]);
assert.deepEqual(await commands(), []);
});
it("pairs again when run again on a revoked host", async function() {
await writeHostConfig("revoked-token");
const pairing = pairingHandler(0, { hostId: 5, token: "new-token", name: "Renamed" });
handler = entry => (entry.url === "/api/host-pairing/check" ? [401, undefined] : pairing(entry));
const { stdout } = await run([
"--mode", "host", "--host-name", "Renamed", "--controller-url", controllerUrl,
]);
assert.match(stdout, /Successfully set up host/);
assert.equal(requests[0].headers.authorization, "Bearer revoked-token");
assert.equal(requests[1].body.name, "Renamed");
assert.deepEqual(await commands(), [
hostSet("host.name", "Renamed"),
hostSet("host.controller_url", controllerUrl),
hostSet("host.id", 5),
hostSet("host.controller_token", "new-token"),
]);
});
it("does not send the saved token to another controller", async function() {
await writeHostConfig("old-token");
// Same stub server under its IP address, which the installer sees as another controller.
const { address, port } = server.address();
const otherUrl = `http://${address.includes(":") ? `[${address}]` : address}:${port}/`;
handler = pairingHandler(0, { hostId: 6, token: "other-token", name: "Moved" });
const { stdout } = await run(["--mode", "host", "--host-name", "Moved", "--controller-url", otherUrl]);
assert.match(stdout, /Successfully set up host/);
assert(!requests.some(r => r.url === "/api/host-pairing/check"), "token checked with other controller");
assert(!requests.some(r => String(r.headers.authorization).includes("old-token")));
assert.deepEqual(await commands(), [
hostSet("host.name", "Moved"),
hostSet("host.controller_url", otherUrl),
hostSet("host.id", 6),
hostSet("host.controller_token", "other-token"),
]);
});
it("adds the trailing slash the host requires to the controller URL", async function() {
handler = pairingHandler(0);
const args = hostArgs();
args[args.indexOf(controllerUrl)] = controllerUrl.slice(0, -1);
await run(args);
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
});
it("writes the certificate into the run scripts when run again with one", async function() {
await writeHostConfig("valid-token");
handler = ({ url }) => (url === "/api/host-pairing/check" ? [200, { valid: true }] : [404, undefined]);
const cert = path.resolve(import.meta.dirname, "file", "tls", "cert.pem");
const { stdout } = await run(["--mode", "host", "--self-signed-cert", cert]);
assert.match(stdout, /Host is already set up/);
const script = process.platform === "win32" ? "run-host.cmd" : "run-host.sh";
const content = await fs.readFile(path.join(directory, script), "utf8");
assert(content.includes(`NODE_EXTRA_CA_CERTS=${cert}`), content);
});
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,
}));
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");
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`,
`${controllerBin} config set controller.allow_plugin_updates true`,
`${controllerBin} config set controller.allow_plugin_install true`,
hostSet("host.allow_remote_updates", true),
hostSet("host.allow_plugin_updates", true),
hostSet("host.allow_plugin_install", true),
]) {
assert(recorded.includes(expected), `missing ${expected}`);
}
assert(!recorded.some(command => command.includes("generate-host-token")));
});
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);
});
});
});
+343
View File
@@ -0,0 +1,343 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import events from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import tls from "node:tls";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import HostRecord from "@clusterio/controller/dist/node/src/HostRecord.js";
import {
PairingError, checkHostToken, createLocalHost, pairHost,
} from "../../packages/create/pair.js";
import "../setup_logging.js";
const tlsDir = path.join(import.meta.dirname, "..", "file", "tls");
// Stub controller recording requests, responding with what the test's handler returns.
async function startStub(createServer = http.createServer) {
const stub = {
requests: [],
handler: () => [500, { error: "No handler" }],
};
stub.server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const entry = {
method: req.method,
url: req.url,
headers: req.headers,
body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : undefined,
};
stub.requests.push(entry);
const [status, json, headers = {}] = await stub.handler(entry);
res.writeHead(status, { "Content-Type": "application/json", ...headers });
res.end(json === undefined ? undefined : JSON.stringify(json));
});
stub.server.listen(0, "localhost");
await events.once(stub.server, "listening");
stub.url = `${stub.server instanceof https.Server ? "https" : "http"}://localhost:${stub.server.address().port}/`;
stub.close = async () => {
stub.server.closeAllConnections();
stub.server.close();
await events.once(stub.server, "close");
};
return stub;
}
// Handler for a request that is approved after the given number of pending polls.
function approveAfter(polls, result = { hostId: 1234, token: "host-token", name: "Approved" }) {
let pending = 0;
return ({ url }) => {
if (url === "/api/host-pairing") {
return [202, { requestId: "secret", code: "ABC-DEF" }];
}
if (url === "/api/host-pairing/poll") {
pending += 1;
return pending > polls ? [200, result] : [202, { status: "pending" }];
}
return [404, { error: "Not found" }];
};
}
describe("create/pair", function() {
let stub;
beforeEach(async function() {
stub = await startStub();
});
afterEach(async function() {
await stub.close();
});
describe("pairHost()", function() {
it("returns the host when approved with a user token", async function() {
stub.handler = () => [200, { hostId: 5, token: "host-token", name: "Host" }];
let pendingCalls = 0;
const result = await pairHost({
controllerUrl: stub.url, name: "Host", version: "2.0.3", token: "user-token",
onPending: () => { pendingCalls += 1; },
});
assert.deepEqual(result, { hostId: 5, token: "host-token", name: "Host" });
assert.equal(pendingCalls, 0);
assert.equal(stub.requests.length, 1);
const [created] = stub.requests;
assert.equal(created.method, "POST");
assert.equal(created.url, "/api/host-pairing");
assert.equal(created.headers.authorization, "Bearer user-token");
assert.deepEqual(created.body, { name: "Host", version: "2.0.3" });
});
it("polls until the request is approved", async function() {
stub.handler = approveAfter(2);
const codes = [];
const result = await pairHost({
controllerUrl: stub.url, name: "Host", pollIntervalMs: 10,
onPending: ({ code }) => { codes.push(code); },
});
assert.deepEqual(result, { hostId: 1234, token: "host-token", name: "Approved" });
assert.deepEqual(codes, ["ABC-DEF"]);
assert.deepEqual(stub.requests.map(r => r.url), [
"/api/host-pairing", "/api/host-pairing/poll", "/api/host-pairing/poll", "/api/host-pairing/poll",
]);
assert.equal(stub.requests[0].headers.authorization, undefined);
assert.deepEqual(stub.requests[0].body, { name: "Host", version: "" });
for (const poll of stub.requests.slice(1)) {
assert.deepEqual(poll.body, { requestId: "secret" });
}
});
["sub", "sub/"].forEach(controllerPath => {
it(`resolves routes under a controller URL ending in ${controllerPath}`, async function() {
stub.handler = ({ url }) => (url === "/sub/api/host-pairing"
? [200, { hostId: 5, token: "host-token", name: "Host" }]
: [404, {}]
);
const result = await pairHost({ controllerUrl: `${stub.url}${controllerPath}`, name: "Host" });
assert.equal(result.hostId, 5);
});
});
it("throws the error from the controller when the request is rejected", async function() {
const handler = approveAfter(1);
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2) {
return [403, { error: "Request was rejected" }];
}
return handler(entry);
};
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10 }),
err => err instanceof PairingError && err.status === 403 && err.message === "Request was rejected",
);
});
it("throws if the controller does not support pairing", async function() {
stub.handler = () => [404, undefined];
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host" }),
err => err instanceof PairingError && err.status === 404 && /must be updated/.test(err.message),
);
});
it("throws on an invalid user token", async function() {
stub.handler = () => [401, { error: "Invalid user token" }];
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host", token: "bad", onPending: () => assert.fail() }),
err => err instanceof PairingError && err.status === 401 && err.message === "Invalid user token",
);
assert.equal(stub.requests.length, 1);
});
it("includes the retry time when rate limited", async function() {
stub.handler = () => [429, { error: "Too many requests from this address" }, { "Retry-After": "42" }];
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host" }),
err => err instanceof PairingError && err.status === 429 && /try again in 42 seconds/.test(err.message),
);
});
it("throws if the request expired", async function() {
const handler = approveAfter(0);
stub.handler = entry => (entry.url === "/api/host-pairing/poll"
? [404, { error: "Request not found" }]
: handler(entry)
);
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10 }),
err => err instanceof PairingError && err.status === 404 && err.message === "Request not found",
);
});
it("cancels the request when aborted", async function() {
const controller = new AbortController();
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll") {
controller.abort();
}
if (entry.url === "/api/host-pairing/cancel") {
return [204, undefined];
}
return approveAfter(Infinity)(entry);
};
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10, signal: controller.signal }),
{ name: "AbortError" },
);
const cancel = stub.requests.find(r => r.url === "/api/host-pairing/cancel");
assert.deepEqual(cancel?.body, { requestId: "secret" });
});
it("does not send anything if already aborted", async function() {
await assert.rejects(
pairHost({ controllerUrl: stub.url, name: "Host", signal: AbortSignal.abort() }),
{ name: "AbortError" },
);
assert.equal(stub.requests.length, 0);
});
it("throws on invalid controller URLs", async function() {
for (const controllerUrl of ["localhost:8080", "ftp://localhost/", "not a url"]) {
await assert.rejects(pairHost({ controllerUrl, name: "Host" }), PairingError);
}
});
it("trusts the certificate passed as ca", async function() {
const [key, cert] = await Promise.all([
fs.readFile(path.join(tlsDir, "key.pem")),
fs.readFile(path.join(tlsDir, "cert.pem")),
]);
const httpsStub = await startStub(handler => https.createServer({ key, cert }, handler));
try {
httpsStub.handler = () => [200, { hostId: 5, token: "host-token", name: "Host" }];
const result = await pairHost({ controllerUrl: httpsStub.url, name: "Host", ca: cert });
assert.equal(result.hostId, 5);
// Passing a different certificate replaces the trusted ones, proving ca is used.
await assert.rejects(
pairHost({ controllerUrl: httpsStub.url, name: "Host", ca: tls.rootCertificates[0] }),
err => typeof err.code === "string" && !(err instanceof PairingError),
);
} finally {
await httpsStub.close();
}
});
});
describe("checkHostToken()", function() {
it("returns true if the controller accepts the token", async function() {
stub.handler = () => [200, { valid: true }];
assert.equal(await checkHostToken({ controllerUrl: stub.url, token: "host-token" }), true);
assert.equal(stub.requests[0].method, "GET");
assert.equal(stub.requests[0].url, "/api/host-pairing/check");
assert.equal(stub.requests[0].headers.authorization, "Bearer host-token");
});
it("returns false if the controller does not accept the token", async function() {
stub.handler = () => [401, undefined];
assert.equal(await checkHostToken({ controllerUrl: stub.url, token: "host-token" }), false);
});
it("throws on other responses", async function() {
stub.handler = () => [503, { error: "Controller is in recovery mode" }];
await assert.rejects(
checkHostToken({ controllerUrl: stub.url, token: "host-token" }),
err => err instanceof PairingError && err.status === 503,
);
});
});
});
describe("create/pair createLocalHost()", function() {
let directory;
let secret;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-pair-"));
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/);
});
});
+145
View File
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { hostCommands } from "@clusterio/ctl/dist/node/src/commands_host.js";
/** Stand-in for Ctl that answers requests from a map of handlers and records what was sent */
class FakeCtl {
constructor(handlers) {
this.handlers = new Map(handlers);
this.sent = [];
}
async send(request) {
return await this.sendTo("controller", request);
}
async sendTo(dst, request) {
this.sent.push(request);
const handler = this.handlers.get(request.constructor);
if (!handler) {
throw new Error(`Unexpected ${request.constructor.name}`);
}
return handler(request);
}
}
async function run(command, args, ctl) {
const lines = [];
const originalLog = console.log; // eslint-disable-line no-console
console.log = (...content) => lines.push(content.join(" ")); // eslint-disable-line no-console
try {
await hostCommands.get(command).run(args, ctl);
} finally {
console.log = originalLog; // eslint-disable-line no-console
}
return lines;
}
describe("ctl/commands_host", function() {
// Created when the request is handled, the ages printed depend on the current time.
const pending = () => [
new lib.HostPairingDetails("ABC-DEF", "new-box", "192.0.2.10", "2.0.3", Date.now() - 65500),
new lib.HostPairingDetails("XYZ-234", "other-box", "2001:db8::1", "", Date.now() - 3720500),
];
const hosts = [{ id: 7, name: "basement-box" }, { id: 12, name: "attic-box" }];
it("no longer has the token based commands", function() {
for (const name of ["generate-token", "revoke-token", "create-config"]) {
assert.equal(hostCommands.get(name), undefined, `host ${name} still exists`);
}
});
describe("host pending", function() {
it("should list pending requests with their age", async function() {
const ctl = new FakeCtl([[lib.HostPairingListRequest, pending]]);
const lines = await run("pending", {}, ctl);
const output = lines.join("\n");
assert.match(output, /code\s*\|\s*name\s*\|\s*address\s*\|\s*age/);
assert.match(output, /ABC-DEF\s*\|\s*new-box\s*\|\s*192\.0\.2\.10\s*\|\s*1m 5s/);
assert.match(output, /XYZ-234\s*\|\s*other-box\s*\|\s*2001:db8::1\s*\|\s*1h 2m/);
});
it("should say so when nothing is pending", async function() {
const ctl = new FakeCtl([[lib.HostPairingListRequest, () => []]]);
assert.deepEqual(await run("pending", {}, ctl), ["No pending host requests"]);
});
});
describe("host approve", function() {
it("should approve with the requested name and print it", async function() {
const ctl = new FakeCtl([
[lib.HostPairingListRequest, pending],
[lib.HostPairingApproveRequest, () => 42],
]);
const lines = await run("approve", { code: "abcdef" }, ctl);
const approve = ctl.sent.find(request => request instanceof lib.HostPairingApproveRequest);
assert.equal(approve.code, "abcdef");
assert.equal(approve.name, undefined);
assert.deepEqual(lines, ["Approved host new-box with id 42"]);
});
it("should pass --name and print it without listing requests", async function() {
const ctl = new FakeCtl([[lib.HostPairingApproveRequest, () => 43]]);
const lines = await run("approve", { code: "ABC-DEF", name: " renamed " }, ctl);
assert.equal(ctl.sent.length, 1);
assert.equal(ctl.sent[0].code, "ABC-DEF");
assert.equal(ctl.sent[0].name, " renamed ");
assert.deepEqual(lines, ["Approved host renamed with id 43"]);
});
it("should pass on errors from the controller", async function() {
const ctl = new FakeCtl([
[lib.HostPairingListRequest, () => []],
[lib.HostPairingApproveRequest, () => {
throw new lib.NotFoundError("No pending host request with code AAA-AAA");
}],
]);
await assert.rejects(
run("approve", { code: "AAA-AAA" }, ctl),
new lib.NotFoundError("No pending host request with code AAA-AAA"),
);
});
});
describe("host reject", function() {
it("should send the code to the controller", async function() {
const ctl = new FakeCtl([[lib.HostPairingRejectRequest, () => undefined]]);
assert.deepEqual(await run("reject", { code: "abc-def" }, ctl), []);
assert.equal(ctl.sent.length, 1);
assert(ctl.sent[0] instanceof lib.HostPairingRejectRequest);
assert.equal(ctl.sent[0].code, "abc-def");
});
});
describe("host revoke", function() {
function revokeCtl() {
return new FakeCtl([
[lib.HostListRequest, () => hosts],
[lib.HostRevokeRequest, () => undefined],
]);
}
it("should revoke a host given by name", async function() {
const ctl = revokeCtl();
await run("revoke", { host: "basement-box" }, ctl);
const revoke = ctl.sent.at(-1);
assert(revoke instanceof lib.HostRevokeRequest);
assert.equal(revoke.hostId, 7);
});
it("should revoke a host given by id", async function() {
const ctl = revokeCtl();
await run("revoke", { host: "12" }, ctl);
assert.equal(ctl.sent.at(-1).hostId, 12);
});
it("should fail for an unknown host", async function() {
const ctl = revokeCtl();
await assert.rejects(
run("revoke", { host: "missing" }, ctl),
new lib.CommandError("No host named missing"),
);
assert(!ctl.sent.some(request => request instanceof lib.HostRevokeRequest));
});
});
});
+278 -64
View File
@@ -10,11 +10,12 @@ import * as libBuildMod from "@clusterio/lib/build_mod.js";
import * as testStrings from "../lib/factorio/test_strings.js";
import {
TestControl, TestControlConnector, url, controlToken,
TestControl, TestControlConnector, TestHostConnector, url, controlToken, hostId,
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
requiresFactorio, hasFactorio,
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
} from "./index.js";
import { pairHost, checkHostToken, PairingError } from "../../packages/create/pair.js";
import { externalTest, slowTest } from "../common.js";
import { getTestRconPort } from "./rcon_port.js";
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
@@ -31,6 +32,18 @@ async function checkInstanceStatus(id, status) {
assert.equal(instances.get(44).status, status, "incorrect instance status");
}
// Factorio directory relative to temp/test where the hosts are run from
const hostFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
/** Id, token and name of the alt-host, paired the first time it starts */
const altHost = { hostId: undefined, token: undefined, name: undefined };
async function pairAltHost() {
if (altHost.hostId === undefined) {
Object.assign(altHost, await pairTestHost("alt-host"));
}
}
async function spawnAltHost(config = "alt-host-config.json") {
return await spawnNode("alt-host:", `../../packages/host run --config ${config}`, /Started host/);
}
@@ -41,10 +54,10 @@ async function startAltHost() {
await fs.rm(configPath, { force: true });
await fs.rm(path.join("temp", "test", "alt-instances"), { force: true, recursive: true, maxRetries: 10 });
await fs.rm(path.join("temp", "test", "alt-mods"), { force: true, recursive: true, maxRetries: 10 });
await execCtl(`host create-config --id 5 --name alt-host --generate-token --output ${config}`);
await pairAltHost();
await writeHostConfig(configPath, altHost);
await execHost(`--config ${config} config set host.instances_directory alt-instances`);
const dir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await execHost(`--config ${config} config set host.factorio_directory ${dir}`);
await execHost(`--config ${config} config set host.factorio_directory ${hostFactorioDir}`);
await execHost(`--config ${config} config set host.mods_directory alt-mods`);
return await spawnAltHost(config);
}
@@ -57,7 +70,7 @@ async function stopAltHost(hostProcess) {
"timeout"
).catch(() => {});
try {
await execCtl("host stop 5");
await execCtl(`host stop ${altHost.hostId}`);
} catch (err) {
// Some tests cause the host to stop
}
@@ -105,6 +118,28 @@ function jsonArg(value) {
return `"${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}"`;
}
/** Run a ctl command in this process and return what it printed */
async function execCtlOutput(command) {
const lines = [];
const originalLog = console.log; // eslint-disable-line no-console
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
try {
await execCtl(command);
} finally {
console.log = originalLog; // eslint-disable-line no-console
}
return lines.join("\n");
}
/** Validator for assert.rejects expecting a PairingError with the given HTTP status */
function pairingError(status) {
return err => {
assert(err instanceof PairingError, `Expected a PairingError, got ${err.stack}`);
assert.equal(err.status, status, err.message);
return true;
};
}
describe("Integration of Clusterio", function() {
describe("clusteriocontroller", function() {
describe("bootstrap generate-user-token", function() {
@@ -281,11 +316,15 @@ describe("Integration of Clusterio", function() {
describe("hostUpdateEventHandler()", function() {
it("should trigger when a new host is added", async function() {
slowTest(this);
// Pairing sends an update for the new host before it connects, which is not what is tested here.
// The round trip makes sure that update has been received before clearing the updates.
await pairAltHost();
await getControl().send(new lib.HostListRequest());
getControl().hostUpdates = [];
await runWithAltHost(async () => {
await execCtl("instance create alt-test --id 99");
await execCtl("instance assign alt-test 5");
await execCtl(`instance assign alt-test ${altHost.hostId}`);
});
let sawUpdate = false;
@@ -311,7 +350,7 @@ describe("Integration of Clusterio", function() {
let result = await getControl().send(new lib.HostListRequest());
let hosts = new Map(result.map(instance => [instance.id, instance]));
assert(hosts.has(5), "Host list was not updated");
assert(hosts.has(altHost.hostId), "Host list was not updated");
});
});
it("should download mods from controller", async function() {
@@ -325,7 +364,7 @@ describe("Integration of Clusterio", function() {
}
assert(!await checkModDownloaded(), "mod was downloaded before the test");
await execCtl("instance create alt-mod --id 98");
await execCtl("instance assign alt-mod 5");
await execCtl(`instance assign alt-mod ${altHost.hostId}`);
await execCtl("instance save create alt-mod");
assert(await checkModDownloaded(), "mod was not downloaded by the test");
});
@@ -340,7 +379,7 @@ describe("Integration of Clusterio", function() {
hostProcess = await startAltHost();
await execCtl("instance create alt-start --id 97");
await execCtl(`instance config set alt-start factorio.rcon_port ${await getTestRconPort()}`);
await execCtl("instance assign alt-start 5");
await execCtl(`instance assign alt-start ${altHost.hostId}`);
await execCtl("instance config set alt-start instance.auto_start true");
const visibility = jsonArg({ lan: false, public: false });
await execCtlProcess(`instance config set-prop alt-start factorio.settings visibility ${visibility}`);
@@ -448,7 +487,7 @@ describe("Integration of Clusterio", function() {
});
it("should load the plugin on the host", async function() {
const details = await pluginDetails({ hostId: 4 });
const details = await pluginDetails({ hostId });
assert(details.enabled, "test_plugin is not enabled on the host");
assert(details.loaded, "test_plugin is not loaded on the host");
});
@@ -470,7 +509,7 @@ describe("Integration of Clusterio", function() {
});
it("should route a plugin request from control to a host", async function() {
const received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("never-broadcast"));
const received = await getControl().sendTo({ hostId }, new HostEchoReceived("never-broadcast"));
assert.equal(received, false);
});
@@ -480,7 +519,7 @@ describe("Integration of Clusterio", function() {
// The broadcast is sent without waiting for the hosts to receive it.
let received = false;
for (let attempt = 0; attempt < 100 && !received; attempt++) {
received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("to-hosts"));
received = await getControl().sendTo({ hostId }, new HostEchoReceived("to-hosts"));
if (!received) {
await lib.wait(20);
}
@@ -593,31 +632,31 @@ describe("Integration of Clusterio", function() {
describe("host config", function() {
it("changes host config", async function() {
await execCtl("host config set 4 host.name My-Host");
const result = await execCtlProcess("host config list 4");
await execCtl(`host config set ${hostId} host.name My-Host`);
const result = await execCtlProcess(`host config list ${hostId}`);
assert(/My-Host/.test(result.stdout), "New host name not in config output");
});
it("should not allow setting host.controller_token", async function() {
await assert.rejects(
execCtl("host config set 4 host.controller_token xyz"),
execCtl(`host config set ${hostId} host.controller_token xyz`),
/Field 'host\.controller_token' is not accessible/,
);
});
it("should not allow setting host.id", async function() {
await assert.rejects(
execCtl("host config set 4 host.id 200"),
execCtl(`host config set ${hostId} host.id 200`),
/Setting 'host\.id' while host is running is not supported/,
);
});
it("should not leak host.controller_token", async function() {
let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest());
let result = await getControl().sendTo({ hostId }, new lib.HostConfigGetRequest());
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
});
});
describe("host plugin list", function() {
it("runs", async function() {
await execCtl("host plugin list 4");
await execCtl(`host plugin list ${hostId}`);
});
});
@@ -625,7 +664,7 @@ describe("Integration of Clusterio", function() {
it("runs", async function() {
// In dev plugins have no npm package, so best we can do is get an error from the host
await assert.rejects(
execCtl("host plugin update 4 foo"),
execCtl(`host plugin update ${hostId} foo`),
/Plugin foo is not installed on this machine/
);
});
@@ -637,7 +676,7 @@ describe("Integration of Clusterio", function() {
// Default is to disallow updates, changing this value would require a restart
// Additionally, it can not be changed via ctl, so best we can do is get an error from the host
await assert.rejects(
execCtl("host plugin install 4 foo"),
execCtl(`host plugin install ${hostId} foo`),
/Plugin installs are disabled on this machine/
);
});
@@ -646,15 +685,15 @@ describe("Integration of Clusterio", function() {
describe("host update", function() {
it("runs", async function() {
await execCtl("host update 4");
await execCtl(`host update ${hostId}`);
});
it("accepts --all", async function() {
await execCtl("host update 4 --all");
await execCtl(`host update ${hostId} --all`);
});
it("accepts --restart", async function() {
// We cannot restart the host, so we check for host error instead
await assert.rejects(
execCtl("host update 4 --restart"),
execCtl(`host update ${hostId} --restart`),
/Cannot restart, host does not have a process monitor to restart it./
);
});
@@ -664,7 +703,7 @@ describe("Integration of Clusterio", function() {
it("runs", async function() {
// We cannot restart the host, so we check for host error instead
await assert.rejects(
execCtl("host restart 4"),
execCtl(`host restart ${hostId}`),
/Cannot restart, host does not have a process monitor to restart it./
);
});
@@ -672,41 +711,216 @@ describe("Integration of Clusterio", function() {
describe("host mark-restart-required", function() {
it("runs", async function() {
await execCtl("host mark-restart-required 4");
await execCtl(`host mark-restart-required ${hostId}`);
});
});
describe("host generate-token", function() {
it("runs", async function() {
await execCtl("host generate-token --id 42");
describe("host pending, approve and reject", function() {
let pairings = [];
afterEach(async function() {
// Stop installers left waiting by a failed test
for (const pairing of pairings) {
pairing.abort.abort();
}
await Promise.allSettled(pairings.map(pairing => pairing.result));
pairings = [];
});
it("runs without an id", async function() {
await execCtl("host generate-token");
});
});
describe("host revoke-token", async function() {
it("should disconnect existing host", async function() {
slowTest(this);
let sawDisconnected;
await runWithAltHost(async () => {
const control = getControl();
control.hostUpdates = [];
await wait(50); // Allow for host to update instances
await execCtl("host revoke-token 5");
await wait(50); // Allow controller to notify control
for (let update of control.hostUpdates) {
if (update.name !== "alt-host") {
continue;
}
if (!update.connected) {
sawDisconnected = true;
}
}
// Start pairing like an installer without a token, returns once the request is pending.
async function requestPairing(name) {
const abort = new AbortController();
let onPending;
const pending = new Promise(resolve => { onPending = resolve; });
const result = pairHost({
controllerUrl: url, name, pollIntervalMs: 100, signal: abort.signal, onPending,
});
const pairing = { abort, result, code: undefined };
pairings.push(pairing);
const ended = result.then(() => {
throw new Error("Pairing finished before the request was pending");
});
ended.catch(() => {}); // Only of interest while racing the pending request
({ code: pairing.code } = await Promise.race([pending, ended]));
return pairing;
}
assert(sawDisconnected, "No host update with status disconnected was sent after revoking token");
it("should list a waiting request and hand the host over when approved", async function() {
const control = getControl();
control.hostPairingUpdates = [];
const pairing = await requestPairing("pending-host");
assert.match(pairing.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
const pendingOutput = await execCtlOutput("host pending");
const pendingLines = pendingOutput.split("\n");
assert(
pendingLines.some(line => line.includes(pairing.code) && line.includes("pending-host")),
`Request missing from host pending output:\n${pendingOutput}`,
);
assert(
control.hostPairingUpdates.some(update => update.code === pairing.code && !update.isDeleted),
"No pairing update was sent for the new request",
);
// Codes are matched ignoring case and separators
const typedCode = pairing.code.replace("-", "").toLowerCase();
const approveOutput = await execCtlOutput(`host approve ${typedCode} --name approved-host`);
const approved = await pairing.result;
assert.equal(approved.name, "approved-host");
assert(approveOutput.includes(String(approved.hostId)), `Host id missing from: ${approveOutput}`);
assert(
control.hostPairingUpdates.some(update => update.code === pairing.code && update.isDeleted),
"No pairing update was sent for the approved request",
);
const requests = await control.send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.code === pairing.code), "Approved request is still pending");
const hosts = await control.send(new lib.HostListRequest());
assert.equal(hosts.find(host => host.id === approved.hostId)?.name, "approved-host");
assert.equal(await checkHostToken({ controllerUrl: url, token: approved.token }), true);
await execCtl(`host revoke ${approved.hostId}`);
});
it("should make the installer fail when the request is rejected", async function() {
const pairing = await requestPairing("rejected-host");
await execCtl(`host reject ${pairing.code}`);
await assert.rejects(pairing.result, pairingError(403));
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.code === pairing.code), "Rejected request is still pending");
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
});
it("should remove the request when the installer stops waiting", async function() {
const pairing = await requestPairing("cancelled-host");
pairing.abort.abort();
await assert.rejects(pairing.result, err => err.name === "AbortError");
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.code === pairing.code), "Cancelled request is still pending");
});
it("should fail for a code that is not pending", async function() {
await assert.rejects(execCtl("host approve 222-222"), /No pending host request with code 222-222/);
await assert.rejects(execCtl("host reject 222-222"), /No pending host request with code 222-222/);
});
it("should refuse an invalid user token instead of waiting for approval", async function() {
await assert.rejects(
pairHost({ controllerUrl: url, name: "invalid-token-host", token: "invalid" }), pairingError(401)
);
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.name === "invalid-token-host"), "Request was created");
});
it("should refuse a user token without permission to approve hosts", async function() {
await getControl().send(new lib.UserCreateRequest("pairing-player"));
try {
const token = jwt.sign(
{ aud: "user", user: "pairing-player" }, Buffer.from("TestSecretDoNotUse", "base64")
);
await assert.rejects(
pairHost({ controllerUrl: url, name: "unapproved-host", token }), pairingError(403)
);
} finally {
await getControl().send(new lib.UserDeleteRequest("pairing-player"));
}
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.name === "unapproved-host"), "Request was created");
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.name === "unapproved-host"), "Host was added");
});
});
describe("host revoke", function() {
it("should remove the host and invalidate its token", async function() {
const pairing = await pairTestHost("revokee-host");
assert.equal(await checkHostToken({ controllerUrl: url, token: pairing.token }), true);
await execCtl(`note set host ${pairing.hostId} short-lived`);
const control = getControl();
control.hostUpdates = [];
await execCtl(`host revoke ${pairing.hostId}`);
assert(
control.hostUpdates.some(update => update.id === pairing.hostId && update.isDeleted),
"No host update with isDeleted was sent",
);
const hosts = await control.send(new lib.HostListRequest());
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
assert.equal(await checkHostToken({ controllerUrl: url, token: pairing.token }), false);
const notes = await control.send(new lib.NoteListRequest());
assert(!notes.some(note => note.id === `host/${pairing.hostId}`), "Note of the host was not removed");
});
it("should refuse while instances are assigned to the host", async function() {
const pairing = await pairTestHost("assigned-host");
// Instances can only be assigned to connected hosts, a link that accepts the assignment is enough.
const connector = new TestHostConnector(url, 0.2);
connector.token = pairing.token;
connector.hostId = pairing.hostId;
const link = new lib.Link(connector);
link.handle(lib.InstanceAssignInternalRequest, async () => {});
await connector.connect();
await execCtl("instance create revoke-assigned --id 45");
try {
await execCtl(`instance assign revoke-assigned ${pairing.hostId}`);
await assert.rejects(
execCtl(`host revoke ${pairing.hostId}`),
/Cannot revoke host assigned-host while instances are assigned to it: revoke-assigned/,
);
} finally {
await connector.disconnect();
await execCtl("instance assign revoke-assigned");
await execCtl("instance delete revoke-assigned");
}
await execCtl(`host revoke ${pairing.hostId}`);
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
});
it("should stop a running host and prevent it from starting again", async function() {
slowTest(this);
this.timeout(30000);
const config = "revoke-host-config.json";
const pairing = await pairTestHost("running-revokee");
await writeHostConfig(path.join("temp", "test", config), pairing, {
"host.factorio_directory": hostFactorioDir,
"host.instances_directory": "revoke-instances",
"host.mods_directory": "revoke-mods",
});
const hostProcess = await spawnNode(
"revoke-host:", `../../packages/host run --config ${config}`, /Started host/
);
try {
const exited = events.once(hostProcess, "exit");
await execCtl(`host revoke ${pairing.hostId}`);
assert.notEqual(
await lib.timeout(exited, 10e3, "timeout"), "timeout", "Host did not stop after being revoked"
);
} finally {
if (hostProcess.exitCode === null && hostProcess.signalCode === null) {
hostProcess.kill("SIGINT");
}
}
// Exit code 8 tells the run scripts not to restart the host
await assert.rejects(execHost(`--config ${config} run`), err => {
const output = `${err.stdout}${err.stderr}`;
assert.equal(err.code, 8, `Unexpected exit code ${err.code}:\n${output}`);
assert.match(output, /host is not registered/);
return true;
});
});
});
describe("removed host token commands", function() {
it("should no longer exist", async function() {
slowTest(this);
for (const command of ["generate-token", "revoke-token", "create-config"]) {
await assert.rejects(execCtlProcess(`host ${command}`), new RegExp(`Unknown argument: ${command}`));
}
});
});
@@ -751,7 +965,7 @@ describe("Integration of Clusterio", function() {
describe("instance assign", function() {
it("creates the instance files", async function() {
await execCtl("instance assign test 4");
await execCtl(`instance assign test ${hostId}`);
await assert.doesNotReject(
fs.access(path.join(instancesDir, "test")),
"Instance directory was not created"
@@ -1046,14 +1260,14 @@ describe("Integration of Clusterio", function() {
assert.equal(controllerEntry.host_id, undefined);
assert.equal(controllerEntry.instance_id, undefined);
const hostEntry = await findLog({ hostIds: [4] }, "test_plugin host echo log-tagging");
const hostEntry = await findLog({ hostIds: [hostId] }, "test_plugin host echo log-tagging");
assert.equal(hostEntry.plugin, "test_plugin");
assert.equal(hostEntry.host_id, 4);
assert.equal(hostEntry.host_id, hostId);
assert.equal(hostEntry.instance_id, undefined);
const instanceEntry = await findLog({ instanceIds: [44] }, "test_plugin instance echo log-tagging");
assert.equal(instanceEntry.plugin, "test_plugin");
assert.equal(instanceEntry.host_id, 4);
assert.equal(instanceEntry.host_id, hostId);
assert.equal(instanceEntry.instance_id, 44);
});
});
@@ -1523,7 +1737,7 @@ describe("Integration of Clusterio", function() {
before(async function() {
slowTest(this);
await execCtlProcess("instance create spam --id 66");
await execCtlProcess("instance assign spam 4");
await execCtlProcess(`instance assign spam ${hostId}`);
await execCtlProcess("instance create unassign --id 77");
});
after(async function() {
@@ -1550,7 +1764,7 @@ describe("Integration of Clusterio", function() {
hostProcess = await startAltHost();
await execCtlProcess("instance create save-test --id 88");
await execCtlProcess("instance assign save-test 5");
await execCtlProcess(`instance assign save-test ${altHost.hostId}`);
});
after(async function() {
await stopAltHost(hostProcess);
@@ -1978,9 +2192,9 @@ describe("Integration of Clusterio", function() {
describe("note set", function() {
it("should set the note of the given resource", async function() {
await execCtl("note set host 4 basement-box");
await execCtl(`note set host ${hostId} basement-box`);
const notes = await getControl().send(new lib.NoteListRequest());
const note = notes.find(n => n.id === "host/4");
const note = notes.find(n => n.id === `host/${hostId}`);
assert.equal(note.content, "basement-box");
assert.equal(note.updatedBy, "test");
});
@@ -2001,7 +2215,7 @@ describe("Integration of Clusterio", function() {
const originalLog = console.log; // eslint-disable-line no-console
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
try {
await execCtl("note get host 4");
await execCtl(`note get host ${hostId}`);
} finally {
console.log = originalLog; // eslint-disable-line no-console
}
@@ -2017,9 +2231,9 @@ describe("Integration of Clusterio", function() {
describe("note delete", function() {
it("should remove the note of the given resource", async function() {
await execCtl("note delete host 4");
await execCtl(`note delete host ${hostId}`);
const notes = await getControl().send(new lib.NoteListRequest());
assert(!notes.find(n => n.id === "host/4"), "Note was not deleted");
assert(!notes.find(n => n.id === `host/${hostId}`), "Note was not deleted");
});
});
+44 -1
View File
@@ -12,6 +12,7 @@ import { LineSplitter, logger } from "@clusterio/lib";
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
import { pairHost } from "../../packages/create/pair.js";
import "../setup_logging.js";
@@ -26,6 +27,7 @@ export class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
super(connector);
this.hostUpdates = [];
this.hostPairingUpdates = [];
this.instanceUpdates = [];
this.saveUpdates = [];
this.modUpdates = [];
@@ -39,6 +41,9 @@ export class TestControl extends lib.Link {
this.send(
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host pairing subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
@@ -58,6 +63,7 @@ export class TestControl extends lib.Link {
this.handle(lib.AccountUpdateEvent);
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
this.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.bind(this));
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
@@ -69,6 +75,10 @@ export class TestControl extends lib.Link {
this.hostUpdates.push(...event.updates);
}
async handleHostPairingUpdatesEvent(event) {
this.hostPairingUpdates.push(...event.updates);
}
async handleInstanceDetailsUpdatesEvent(event) {
this.instanceUpdates.push(...event.updates);
}
@@ -181,6 +191,8 @@ const baseHostConfig = loadJSON("config-host.json");
export let url = "https://localhost:4443/";
// Signed in the root before hook, as the user has to exist before the token is issued
export let controlToken;
// Id of the test host, assigned by the controller when it is paired in the root before hook
export let hostId;
export let instancesDir = path.join("temp", "test", "instances");
export let modsDir = path.join("temp", "test", "mods");
export let databaseDir = path.join("temp", "test", "database");
@@ -253,6 +265,35 @@ export function getControl() {
return control;
}
/**
* Pair a new host with the test controller, approved right away with the admin token
*
* @param {string} name - Name to give the host.
* @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host.
*/
export async function pairTestHost(name) {
return await pairHost({ controllerUrl: url, name, token: controlToken });
}
/**
* Write a new host config for a paired host, with the fields the installer sets
*
* @param {string} configPath - Path to write the config to.
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
* @param {Record<string, unknown>} fields - Other fields to set in the config.
*/
export async function writeHostConfig(configPath, pairing, fields = {}) {
const hostConfig = new lib.HostConfig("host");
hostConfig.set("host.id", pairing.hostId);
hostConfig.set("host.name", pairing.name);
hostConfig.set("host.controller_url", url);
hostConfig.set("host.controller_token", pairing.token);
for (const [name, value] of Object.entries(fields)) {
hostConfig.set(name, value);
}
await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 });
}
/** @returns {Promise<child_process.ChildProcess>} */
export function spawn(name, cmd, waitFor, options = {}) {
@@ -366,7 +407,9 @@ before(async function() {
haveFactorioInstall = factorioVersions.size > 0;
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await execCtlProcess("host create-config --id 4 --name host --generate-token");
const pairing = await pairTestHost("host");
hostId = pairing.hostId;
await writeHostConfig(hostConfigPath, pairing);
await execHost("config create");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
+6 -6
View File
@@ -8,7 +8,7 @@ import fs from "node:fs/promises";
import { slowTest, testMatrix } from "../common.js";
import {
execCtl, execCtlProcess, sendRcon, getControl,
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir,
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir, hostId,
} from "./index.js";
import { getTestRconPort } from "./rcon_port.js";
@@ -96,7 +96,7 @@ describe("Clusterio Instance", function() {
await execCtl(`instance config set ${instName} factorio.enable_whitelist true`);
await execCtlProcess(`instance config set-prop ${instName} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${instName} factorio.settings require_user_verification false`);
await execCtl(`instance assign ${instName} 4`);
await execCtl(`instance assign ${instName} ${hostId}`);
// Exclude main test instance from start-all to prevent interference
await execCtl(`instance config set ${instName} instance.exclude_from_start_all true`);
@@ -112,7 +112,7 @@ describe("Clusterio Instance", function() {
await execCtl(`instance config set ${instAltName} factorio.enable_whitelist true`);
await execCtlProcess(`instance config set-prop ${instAltName} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${instAltName} factorio.settings require_user_verification false`);
await execCtl(`instance assign ${instAltName} 4`);
await execCtl(`instance assign ${instAltName} ${hostId}`);
// Exclude alt test instance from start-all to prevent interference
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
await execCtl(`instance start ${instAltName}`);
@@ -340,9 +340,9 @@ describe("start-all and stop-all commands", function() {
await execCtl(`instance create ${testInstName3} --id ${testInstId3}`);
// Configure instances
await execCtl(`instance assign ${testInstName1} 4`);
await execCtl(`instance assign ${testInstName2} 4`);
await execCtl(`instance assign ${testInstName3} 4`);
await execCtl(`instance assign ${testInstName1} ${hostId}`);
await execCtl(`instance assign ${testInstName2} ${hostId}`);
await execCtl(`instance assign ${testInstName3} ${hostId}`);
const visibility = JSON.stringify({ lan: false, public: false }).replace(
/"/g, process.platform === "win32" ? '""' : '\\"'
);
+29 -7
View File
@@ -3,25 +3,36 @@ import events from "node:events";
import jwt from "jsonwebtoken";
import {
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl,
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl, hostId, pairTestHost,
} from "./index.js";
import * as lib from "@clusterio/lib";
import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib";
import { slowTest } from "../common.js";
let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
// Valid token for a host id that was never approved
let tokenUnregistered = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
describe("Integration of lib/link", function() {
let control;
let controlConnector;
let hostConnector;
// Hosts can only connect once approved, so the host connections use a paired host
let linkHost;
before(async function() {
linkHost = await pairTestHost("link-test");
});
after(async function() {
if (linkHost) {
await getControl().send(new lib.HostRevokeRequest(linkHost.hostId));
}
});
beforeEach(async function() {
controlConnector = new TestControlConnector(url, 0.2);
controlConnector.token = controlToken;
control = new TestControl(controlConnector, false);
hostConnector = new TestHostConnector(url, 0.2);
hostConnector.token = tokenHost;
hostConnector.hostId = 0;
hostConnector.token = linkHost.token;
hostConnector.hostId = linkHost.hostId;
});
afterEach(async function() {
await controlConnector.disconnect();
@@ -138,13 +149,24 @@ describe("Integration of lib/link", function() {
});
it("should refuse connection with mismatched ids", async function() {
hostConnector.hostId = 1;
hostConnector.hostId = linkHost.hostId + 1;
await assert.rejects(
hostConnector.connect(),
new Error("Authentication failed: missmatched host id")
);
});
it("should refuse connection for a host that is not registered", async function() {
hostConnector.token = tokenUnregistered;
hostConnector.hostId = 0;
await assert.rejects(
hostConnector.connect(),
new Error(
"Authentication failed: host is not registered, run the installer again in the host directory"
)
);
});
it("should close old connections from the same address", async function() {
slowTest(this);
const hostConnectorFirst = new TestHostConnector(url, 0.2);
@@ -163,8 +185,8 @@ describe("Integration of lib/link", function() {
it("should refuse duplicate host connections", async function() {
this.skip(); // Can't be checked because the remote address for both connections is the same
hostConnector.token = jwt.sign({ aud: "host", host: 4 }, Buffer.from("TestSecretDoNotUse", "base64"));
hostConnector.hostId = 4; // Same as already running host
hostConnector.token = jwt.sign({ aud: "host", host: hostId }, Buffer.from("TestSecretDoNotUse", "base64"));
hostConnector.hostId = hostId; // Same as already running host
await assert.rejects(
hostConnector.connect(),
new Error("Authentication failed: missmatched host id")
+3 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { Host, Instance, InstanceConnection } from "@clusterio/host";
import { ControlConnection, Controller, HostConnection, InstanceRecord } from "@clusterio/controller";
import { ControlConnection, Controller, HostConnection, HostRecord, InstanceRecord } from "@clusterio/controller";
import { MockServer } from "../mock.js";
// Although there are no imports, the file has side-effects such as "before" which can effect tests
@@ -19,6 +19,8 @@ class Control extends lib.Link { }
function connectHost(controller, hostId, plugins) {
const [controllerSide, hostSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ hostId }));
const registerData = new lib.RegisterHostData("", "0.0.0", hostId, plugins);
// Hosts can only connect once approved, which creates their record.
controller.hosts.set(new HostRecord(hostId, `host-${hostId}`, "", new Map()));
const hostConnection = new HostConnection(registerData, controllerSide, controller, "host-a.test");
controller.wsServer.hostConnections.set(hostId, hostConnection);
const hostConfig = new lib.HostConfig("host");
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { HostPairingDetails } from "@clusterio/lib";
import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js";
describe("lib/data/HostPairingDetails", function () {
it("should be round trip serialisable", function () {
testRoundTripJsonSerialisable(HostPairingDetails, testMatrix(
["ABC-DEF"], // code (required)
["New Host", ""], // name (required)
["10.0.0.1", "2001:db8::1"], // remoteAddress (required)
[undefined, "2.0.0"], // version
[undefined, 123456], // createdAtMs
[undefined, 999999], // updatedAtMs
[undefined, true], // isDeleted
));
});
describe("constructor", function () {
it("should set defaults correctly", function () {
const details = new HostPairingDetails("ABC-DEF", "New Host", "10.0.0.1");
assert.equal(details.code, "ABC-DEF");
assert.equal(details.name, "New Host");
assert.equal(details.remoteAddress, "10.0.0.1");
assert.equal(details.version, "");
assert.equal(details.createdAtMs, 0);
assert.equal(details.updatedAtMs, 0);
assert.equal(details.isDeleted, false);
});
it("should use the code as the id", function () {
const details = new HostPairingDetails("ABC-DEF", "New Host", "10.0.0.1");
assert.equal(details.id, "ABC-DEF");
});
});
describe(".toJSON()", function () {
it("should only include required fields when optional fields are defaults", function () {
const details = new HostPairingDetails("ABC-DEF", "New Host", "10.0.0.1", "", 0, 0, false);
assert.deepEqual(details.toJSON(), {
code: "ABC-DEF",
name: "New Host",
remoteAddress: "10.0.0.1",
});
});
it("should include all optional fields when set", function () {
const details = new HostPairingDetails("ABC-DEF", "New Host", "10.0.0.1", "2.0.0", 100, 200, true);
assert.deepEqual(details.toJSON(), {
code: "ABC-DEF",
name: "New Host",
remoteAddress: "10.0.0.1",
version: "2.0.0",
createdAtMs: 100,
updatedAtMs: 200,
isDeleted: true,
});
});
it("should not include the id getter", function () {
const details = new HostPairingDetails("ABC-DEF", "New Host", "10.0.0.1");
assert.equal(Object.hasOwn(JSON.parse(JSON.stringify(details)), "id"), false);
});
});
describe("static fromJSON()", function () {
it("should construct minimal object", function () {
const details = HostPairingDetails.fromJSON({
code: "ABC-DEF",
name: "New Host",
remoteAddress: "10.0.0.1",
});
assert.equal(details.code, "ABC-DEF");
assert.equal(details.version, "");
assert.equal(details.createdAtMs, 0);
assert.equal(details.updatedAtMs, 0);
assert.equal(details.isDeleted, false);
});
});
});
+77
View File
@@ -186,6 +186,83 @@ describe("messages/controller", function() {
});
});
describe("Host pairing", function() {
const details = [
new lib.HostPairingDetails("ABC-DEF", "Host A", "10.0.0.1", "2.0.0", 100, 200),
new lib.HostPairingDetails("GHJ-KMN", "Host B", "2001:db8::1", "", 0, 300, true),
];
it("defines the pairing messages for control links approving hosts", function() {
for (const Message of [
lib.HostPairingListRequest,
lib.HostPairingApproveRequest,
lib.HostPairingRejectRequest,
]) {
assert.equal(Message.type, "request", Message.name);
assert.equal(Message.src, "control", Message.name);
assert.equal(Message.dst, "controller", Message.name);
assert.equal(Message.permission, "core.host.approve", Message.name);
assert(lib.Link._requestsByClass.has(Message), `${Message.name} is not registered`);
}
assert.equal(lib.HostPairingUpdatesEvent.type, "event");
assert.equal(lib.HostPairingUpdatesEvent.src, "controller");
assert.equal(lib.HostPairingUpdatesEvent.dst, "control");
assert.equal(lib.HostPairingUpdatesEvent.permission, "core.host.approve");
assert(lib.Link._eventsByClass.has(lib.HostPairingUpdatesEvent));
});
it("removes the host token messages", function() {
for (const name of ["HostGenerateTokenRequest", "HostConfigCreateRequest", "HostRevokeTokensRequest"]) {
assert.equal(lib[name], undefined, `${name} is still exported`);
assert(!lib.Link._requestsByName.has(name), `${name} is still registered`);
}
});
it("defines the host permissions", function() {
for (const name of ["core.host.approve", "core.host.revoke"]) {
assert(lib.permissions.has(name), `${name} is not defined`);
assert.equal(lib.permissions.get(name).grantByDefault, false, `${name} is granted by default`);
}
for (const name of ["core.host.generate_token", "core.host.revoke_token", "core.host.create_config"]) {
assert.equal(lib.permissions.has(name), false, `${name} is still defined`);
}
});
it("round trips HostPairingListRequest responses", function() {
const validate = lib.compile(lib.HostPairingListRequest.Response.jsonSchema);
const json = JSON.parse(JSON.stringify(details));
assert(validate(json), JSON.stringify(validate.errors));
assert.deepEqual(lib.HostPairingListRequest.Response.fromJSON(json), details);
});
it("round trips HostPairingUpdatesEvent", function() {
testRoundTripJsonSerialisable(lib.HostPairingUpdatesEvent, [[[]], [details]]);
});
it("round trips HostPairingApproveRequest", function() {
testRoundTripJsonSerialisable(lib.HostPairingApproveRequest, testMatrix(
["ABC-DEF", "abcdef"], // code
[undefined, "Renamed"], // name
));
const validate = lib.compile(lib.HostPairingApproveRequest.Response.jsonSchema);
assert(validate(1234));
});
it("round trips HostPairingRejectRequest", function() {
testRoundTripJsonSerialisable(lib.HostPairingRejectRequest, [["ABC-DEF"], ["abcdef"]]);
});
it("approves a pending request", async function() {
controller.config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
const list = await controlConnection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert.deepEqual(list.map(entry => entry.code), [code]);
const hostId = await controlConnection.handleHostPairingApproveRequest(
new lib.HostPairingApproveRequest(code)
);
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(controller.hostPairing.poll(requestId).hostId, hostId);
});
it("rejects a pending request", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await controlConnection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code));
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
});
});
describe("InstanceConfigGetRequest", function() {
it("runs", async function() {
const config = await controlConnection.handleInstanceConfigGetRequest(
+13
View File
@@ -18,6 +18,19 @@ describe("messages/host", function() {
host = new Host(hostConnector, hostConfig, []);
});
describe("HostRevokeRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.HostRevokeRequest, [[1], [2 ** 31 - 1]]);
});
it("is a control request requiring core.host.revoke", function() {
assert.equal(lib.HostRevokeRequest.type, "request");
assert.equal(lib.HostRevokeRequest.src, "control");
assert.equal(lib.HostRevokeRequest.dst, "controller");
assert.equal(lib.HostRevokeRequest.permission, "core.host.revoke");
assert(lib.Link._requestsByClass.has(lib.HostRevokeRequest));
});
});
describe("HostUpdateRequest", function() {
it("runs", async function() {
host.config.set("host.allow_remote_updates", true);
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { MockConnector, MockControl } from "../mock.js";
import "../setup_logging.js";
import { HostPairingSubscriber } from "../../packages/web_ui/src/util/host_pairing.ts";
const addr = lib.Address.fromShorthand;
describe("web_ui/util/host_pairing", function() {
describe("class HostPairingSubscriber", function() {
let mockControl;
let permitted;
let subscriber;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
permitted = true;
subscriber = new HostPairingSubscriber(mockControl, () => permitted);
});
function pairing(code, updatedAtMs) {
return new lib.HostPairingDetails(code, `Host ${code}`, "192.0.2.1", "", updatedAtMs, updatedAtMs);
}
async function settle() {
await new Promise(resolve => setImmediate(resolve));
}
function sentRequests() {
return mockControl.connector.sentMessages.filter(message => message.name === "SubscriptionRequest");
}
it("should not send requests on connect or account updates without permission", async function() {
permitted = false;
mockControl.connector.emit("connect");
subscriber.handleConnectionEvent("resume");
subscriber.handleAccountEvent();
await settle();
assert.deepEqual(sentRequests(), []);
});
it("should resubscribe on connect with permission", async function() {
subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.length = 0;
mockControl.connector.emit("connect");
await settle();
assert.equal(sentRequests().length, 1);
assert.deepEqual(sentRequests()[0].data, new lib.SubscriptionRequest(
lib.HostPairingUpdatesEvent.name, "replace", -1, lib.SubscriptionFilters.all()
));
});
it("should check the permission after the connect handlers have run", async function() {
subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.length = 0;
permitted = false;
mockControl.connector.emit("connect");
permitted = true;
await settle();
assert.equal(sentRequests().length, 1);
});
it("should resubscribe on account updates with permission", async function() {
subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.length = 0;
permitted = false;
subscriber.handleAccountEvent();
await settle();
assert.deepEqual(sentRequests(), []);
permitted = true;
subscriber.handleAccountEvent();
await settle();
assert.equal(sentRequests().length, 1);
});
it("should clear requests when a new session starts", async function() {
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
assert.equal(subscriber.values.size, 1);
const [before] = subscriber.getSnapshot();
mockControl.connector.emit("connect");
assert.equal(subscriber.values.size, 0);
assert.equal(subscriber.lastUpdatedMs, -1);
const [after] = subscriber.getSnapshot();
assert.notEqual(after, before);
assert.equal(after.size, 0);
});
it("should keep requests when a session is resumed", async function() {
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
subscriber.handleConnectionEvent("drop");
subscriber.handleConnectionEvent("resume");
assert.equal(subscriber.values.size, 1);
assert.equal(subscriber.lastUpdatedMs, 10);
});
it("should clear requests when subscribing again after the last unsubscribe", async function() {
const unsubscribe = subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
unsubscribe();
await new Promise(resolve => setTimeout(resolve));
mockControl.connector.sentMessages.length = 0;
subscriber.subscribe(() => {});
assert.equal(subscriber.values.size, 0);
await settle();
assert.equal(sentRequests().length, 1);
assert.equal(sentRequests()[0].data.lastRequestTimeMs, -1);
});
it("should keep requests when a page that subscribes replaces another", async function() {
const unsubscribe = subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
unsubscribe();
subscriber.subscribe(() => {});
assert.equal(subscriber.values.size, 1);
assert.equal(subscriber.lastUpdatedMs, 10);
});
it("should set synced false on drop and close", async function() {
subscriber.synced = true;
subscriber.handleConnectionEvent("drop");
assert.equal(subscriber.synced, false);
subscriber.synced = true;
subscriber.handleConnectionEvent("close");
assert.equal(subscriber.synced, false);
});
});
});