Pair hosts when they start instead of in the installer

The installer only writes the controller URL and host name. Running the
host requests to join the cluster if it has not been approved yet, or if
the controller no longer accepts its token, and waits until an admin
approves it. Once approved it saves its id and token and starts as
normal. A rejected host exits with code 8.

clusteriohost run --user-token approves the host right away with the
token of a user allowed to approve hosts. The token is not saved.

This moves the pairing client into the host package, where it uses the
same certificate setup as the host's connection to the controller, and
removes --no-pair and the rerun handling from the installer.
This commit is contained in:
Danielv123 committed 2026-10-30 21:13:14 +01:00
1 parent 37e79d9c41
commit 4a49a198f0
25 files changed
+948 -1131

No files matched your search

+5 -6
View File
@@ -274,20 +274,19 @@ If you have copied these files over to `/etc/systemd/system/` then you can start
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:
Then start the host, the first time it starts 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
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.
The host then saves its connection details and starts up, it should show up in the Hosts list and be available for assigning and running instances on.
Only approve a request if its code matches the one shown by the host, 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.
If you have that permission you can skip the wait by starting the host with `npx clusteriohost run --user-token <token>` and your own authentication token, it is only used to approve the host and is not saved.
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.
To add the computer back to the cluster start the host again, it asks for approval again.
## Setting up clusterioctl
+4 -3
View File
@@ -327,7 +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.
The controller picks the id when the host is approved and the host 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.
@@ -367,8 +367,9 @@ Defaults to "http://localhost:8080/".
### host.controller_token
Access token used for authenticating with the controller.
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 host saves it together with host.id when it 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 it asks to be approved again the next time it starts.
Set it to `enter token here` to make the host ask to join again, for example after changing host.controller_url to another controller.
The value can only be read and changed from the host itself.
Defaults to "enter token here".
+2 -2
View File
@@ -64,7 +64,7 @@ The `container` property of `WebPluginContext` is now the plugin's ES module nam
## 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.
A host joins the cluster by having the code it shows when it first starts 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`.
@@ -72,7 +72,7 @@ A host joins the cluster by running the installer and having the code it shows a
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.
- `clusteriocontroller bootstrap generate-host-token` is removed, scripts that set up hosts can start the host with `--user-token` to approve it 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.
+7 -6
View File
@@ -34,9 +34,9 @@ The [send-buffer setting](../configuration.md#controllermax_send_buffer_size) co
## 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.
The host id and token sent in `registerHost` are requested by the host over HTTP the first time it starts, before it connects.
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.
Revoking a host deletes its record, so its token stops working and the host has to be approved again the next time it starts, 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`.
@@ -50,7 +50,7 @@ A user or host token is sent in either an `Authorization: Bearer <token>` header
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.
- `version: string` (optional) - Version of the host.
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`.
@@ -73,20 +73,20 @@ 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.
The host polls every 2 seconds, and makes a new request when the previous one has expired.
### `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.
Removes a pending request, sent by the host 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.
A host with a saved token checks it with this when it starts, and requests to join again if the token is not accepted.
## Connector flow
@@ -178,6 +178,7 @@ Host handshake for establishing a new connection session.
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 has been rejected also exits with code 8.
A host that is revoked while connected is disconnected, is rejected when it tries to connect again, and shuts down.
#### `registerControl`
+6 -6
View File
@@ -23,23 +23,23 @@ Listing, approving and rejecting installers waiting for approval needs the `core
ctl> host pending
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.
Lists hosts 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 host stops waiting, and after waiting for an hour, after which the host asks again with a new code.
### Approve host
ctl> host approve <code> [--name <host name>]
Approves the installer showing the code and prints the id of the new host.
Approves the host 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.
`--name` gives the host a different name than the one it asked for.
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.
Rejects the host showing the code, the host then exits with an error.
### Revoke host
@@ -49,7 +49,7 @@ 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.
To add the computer back to the cluster, start the host again and approve it.
### Update Clusterio
@@ -258,7 +258,7 @@ export default class HostPairingManager {
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`);
logger.verbose(`Host pairing request ${request.details.code} cancelled by the host`);
this._remove(request);
}
}
+1 -1
View File
@@ -265,7 +265,7 @@ ${err.stack}`
let host = this.controller.hosts.get(data.id);
if (!host) {
throw new Error("host is not registered, run the installer again in the host directory");
throw new Error("host is not registered, start the host again to request to join the cluster");
}
if (!tokenPayload.iat || tokenPayload.iat < host.tokenValidAfter) {
throw new Error("invalid token");
+24 -35
View File
@@ -37,11 +37,10 @@ The installer runs without prompts when all the questions for the mode are answe
| `--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. |
| `--controller-token <token>` | ctl | Authentication token saved for clusterioctl. |
| `--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. |
| `--self-signed-cert <path>` | standalone, host | Certificate to trust for a controller using a self-signed certificate. It is 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. |
@@ -57,47 +56,39 @@ 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:
2. Start the host with `run-host.cmd`, `run-host.sh` or the systemd service in the `systemd` folder.
The first time it starts 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.
Only approve a request if its code matches the one shown by the host.
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.
4. The host saves `host.id`, `host.name` and `host.controller_token` to `config-host.json` and starts up.
Later starts connect to the cluster straight away.
Press Ctrl+C while the installer waits to cancel the request.
A request that is not approved within an hour expires.
The host keeps waiting until the request is approved, also if the controller is restarted or can't be reached for a while.
Press Ctrl+C to stop waiting.
If the request is rejected the host exits with code 8, which the run scripts do not restart on.
Pass `--controller-token <token>` with the token of a user that has the `core.host.approve` permission to approve the host without waiting.
Start the host with `npx clusteriohost run --user-token <token>` and 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).
The installer adds it to the run scripts.
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.
Starting it again requests to join the cluster again, and the host gets a new id once approved.
## 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.
To move a host to another controller, change `host.controller_url` with `npx clusteriohost config set host.controller_url <url>` and set `host.controller_token` back to `enter token here`.
The host then requests to join that controller the next time it starts.
## 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.
To run a host in a container install it when building the image with all answers given as options.
The host requests to join the cluster the first time the container starts.
```dockerfile
FROM node:22
@@ -105,18 +96,16 @@ FROM node:22
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
RUN npm init "@clusterio" -- --mode host --host-name my-host --controller-url https://example.com/ \
--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
CMD exec ./node_modules/.bin/clusteriohost run ${USER_TOKEN:+--user-token "$USER_TOKEN"}
```
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 run -d --name my-host -v my-host:/clusterio -p 34100-34199:34100-34199/udp my-host-image
docker logs my-host
- The volume holds `config-host.json`, without it every new container pairs as a new host.
- The volume holds `config-host.json`, without it every new container requests to join 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.
- The code to approve is printed to the container log, or set `USER_TOKEN` to approve the host without waiting.
- Stop the container after revoking its host, otherwise a restart requests to join again.
+38 -202
View File
@@ -10,7 +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 { createLocalHost } from "./local_host.js";
import { copyPluginTemplates } from "./template.js";
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
@@ -200,21 +200,6 @@ function normaliseControllerUrl(input) {
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"]) {
@@ -222,135 +207,6 @@ async function setRemoteUpdates(exec, component, 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);
}
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() {
let entries = new Set(await fs.readdir("."));
if (entries.size) {
@@ -681,37 +537,6 @@ 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 = {};
@@ -771,13 +596,31 @@ async function inquirerMissingArgs(args) {
}
if (answers.mode === "host") {
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 (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: os.hostname(),
validate: validateHostName,
},
{
type: "input",
name: "controllerUrl",
message: "Controller URL",
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);
}
if (answers.mode === "ctl") {
@@ -947,12 +790,7 @@ async function main() {
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
})
.option("controller-token", {
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",
nargs: 1, describe: "Controller authentication token [ctl]", type: "string",
})
.option("public-address", {
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/host]", type: "string",
@@ -1005,11 +843,6 @@ 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;
@@ -1048,13 +881,18 @@ async function main() {
await execHost(["config", "set", "host.controller_token", host.token]);
}
if (answers.mode === "host") {
// The host requests to join the cluster when it is started.
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
}
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 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);
@@ -1066,16 +904,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 (answers.mode === "host") {
console.log(
`Start the host with run-host${process.platform === "win32" ? ".cmd" : ".sh"}, then approve it on the ` +
`Hosts page of ${answers.controllerUrl}`
);
}
if (adminToken) {
console.log(`Admin authentication token: ${adminToken}`);
}
+68
View File
@@ -0,0 +1,68 @@
// Adds the host of a standalone install to the controller database, used by the installer.
// Only uses Node.js builtins as it runs before Clusterio is installed.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
function signHostToken(payload, secret) {
const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url");
const unsigned = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(payload)}`;
const signature = crypto.createHmac("sha256", secret).update(unsigned).digest("base64url");
return `${unsigned}.${signature}`;
}
/**
* Add a host to the database of a controller installed in the same directory
*
* The controller must not be running, as it would overwrite the database with
* what it has in memory.
*
* @param {object} options - Host options.
* @param {string=} options.directory - Directory with config-controller.json in it.
* @param {string} options.name - Name of the host.
* @param {number=} options.id -
* Id to give the host, picks a random unused id if not given. If a host with
* this id exists, a new token is signed for it and its name is kept.
* @param {string=} options.version - Version to record for the host.
* @returns {Promise<{ hostId: number, token: string, name: string }>} id, token and name of the host.
*/
export async function createLocalHost({ directory = ".", name, id, version = "" }) {
const configPath = path.join(directory, "config-controller.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
const secret = config["controller.auth_secret"];
if (typeof secret !== "string" || !secret) {
throw new Error(`${configPath} has no controller.auth_secret`);
}
const databaseDirectory = path.resolve(directory, config["controller.database_directory"] ?? "database");
const hostsPath = path.join(databaseDirectory, "hosts.json");
let hosts = [];
try {
hosts = JSON.parse(await fs.readFile(hostsPath, "utf8"));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
const now = Math.floor(Date.now() / 1000);
const existing = id === undefined ? undefined : hosts.find(host => host.id === id);
let hostId = id;
if (existing) {
// Running the installer again signs a new token for the same host.
name = existing.name;
} else {
const ids = new Set(hosts.map(host => host.id));
while (hostId === undefined || ids.has(hostId)) {
hostId = crypto.randomInt(1, 2 ** 31);
}
hosts.push({ id: hostId, name, version, plugins: {}, token_valid_after: now });
await fs.mkdir(databaseDirectory, { recursive: true });
const temporary = path.join(databaseDirectory, "hosts.tmp.json");
await fs.writeFile(temporary, JSON.stringify(hosts, null, "\t"), { flush: true });
await fs.rename(temporary, hostsPath);
}
const token = signHostToken({ aud: "host", host: hostId, iat: now }, Buffer.from(secret, "base64"));
return { hostId, token, name };
}
-275
View File
@@ -1,275 +0,0 @@
// 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 };
}
+34 -12
View File
@@ -25,6 +25,7 @@ import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Host from "./src/Host.js";
import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js";
import { PairingError, ensurePaired } from "./src/pairing.js";
let host: Host | undefined;
@@ -134,6 +135,10 @@ async function startHost() {
type: "boolean", nargs: 0, default: false,
describe: "Start the host in recovery mode with all plugins disabled and controller disconnected",
});
yargs.option("user-token", {
type: "string", nargs: 1,
describe: "Token of a user allowed to approve hosts, approves this host without waiting. Not saved",
});
})
.demandCommand(1, "You need to specify a command to run")
.strict()
@@ -212,18 +217,6 @@ async function startHost() {
// and as the process name in ps/top on linux.
process.title = "clusteriohost";
// make sure we have the controller access token
if (hostConfig.get("host.controller_token") === "enter token here") {
logger.fatal("ERROR invalid config!");
logger.fatal(
"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;
}
// make sure url ends with /
if (!hostConfig.get("host.controller_url").endsWith("/")) {
logger.fatal("ERROR invalid config!");
@@ -232,6 +225,35 @@ async function startHost() {
return;
}
if (!args.recovery) {
const abort = new AbortController();
const onSignal = () => { abort.abort(); };
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
try {
await ensurePaired(hostConfig, {
version: packageConfig.version,
userToken: args.userToken as string | undefined,
signal: abort.signal,
});
} catch (err: any) {
if (abort.signal.aborted) {
logger.info("Stopped waiting for approval");
return;
}
if (err instanceof PairingError) {
logger.fatal(`Unable to join the cluster: ${err.message}`);
// Exit code 8 stops the run scripts from restarting the host.
process.exitCode = err.status === 403 ? 8 : 1;
return;
}
throw err;
} finally {
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
}
}
let hostConnector = new HostConnector(hostConfig, pluginInfos);
host = await Host.fromConfig(
hostConfig,
+1
View File
@@ -4,6 +4,7 @@ export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from ".
export { default as Host, HostRouter } from "./src/Host.js";
export { default as Instance } from "./src/Instance.js";
export { default as InstanceConnection } from "./src/InstanceConnection.js";
export { PairingError, checkHostToken, pairHost } from "./src/pairing.js";
if (import.meta.main) {
bootstrap();
+293
View File
@@ -0,0 +1,293 @@
import { setTimeout as sleep } from "node:timers/promises";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
/** Time to wait for a response from the controller */
const requestTimeoutMs = 30e3;
/** Placeholder token of a host that has not been paired */
const unpairedToken = "enter token here";
/** Error responded by the controller */
export class PairingError extends Error {
constructor(
message: string,
/** HTTP status the controller responded with */
public status?: number,
) {
super(message);
this.name = "PairingError";
}
}
export interface PairingResult {
hostId: number;
token: string;
name: string;
}
export interface PairHostOptions {
/** URL of the controller, ending with a slash */
controllerUrl: string;
/** Name to request for the host */
name: string;
/** Version of the host, shown with the request */
version?: string;
/** Token of a user allowed to approve hosts, approves the host without waiting */
token?: string;
/** Interval to poll for the result at */
pollIntervalMs?: number;
/** Delay before trying again when the controller can't be reached or is busy */
retryDelayMs?: number;
/** Called with the code to approve the request with each time a request is made */
onPending?: (code: string) => void;
/** Called when the controller can't be reached or is busy and the request is retried */
onRetry?: (err: Error, delayMs: number) => void;
/** Signal to cancel pairing with */
signal?: AbortSignal;
}
interface Response {
status: number;
headers: Headers;
json: any;
}
async function request(
controllerUrl: string,
route: string,
options: { body?: object, token?: string, signal?: AbortSignal, timeoutMs?: number },
): Promise<Response> {
const headers: Record<string, string> = { "Accept": "application/json" };
if (options.body) {
headers["Content-Type"] = "application/json";
}
if (options.token) {
headers["Authorization"] = `Bearer ${options.token}`;
}
const timeout = AbortSignal.timeout(options.timeoutMs ?? requestTimeoutMs);
const response = await fetch(new URL(route, controllerUrl), {
method: options.body ? "POST" : "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
});
let json;
try {
json = await response.json();
} catch {
json = undefined;
}
return { status: response.status, headers: response.headers, json };
}
function responseError(response: Response) {
const message = typeof response.json?.error === "string"
? response.json.error
: `Unexpected response from controller: ${response.status}`;
return new PairingError(message, response.status);
}
function pairingResult(response: Response): PairingResult {
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 };
}
/** Responses that are worth trying again later */
function isTemporary(status: number) {
return status === 429 || status >= 500;
}
function retryDelay(response: Response, defaultMs: number) {
const seconds = Number(response.headers.get("retry-after"));
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : defaultMs;
}
/**
* Poll a request until it is approved, rejected or no longer known by the controller
*
* @returns the result if approved, or null if the controller no longer knows the request.
*/
async function poll(requestId: string, options: Required<Pick<PairHostOptions, "pollIntervalMs">> & PairHostOptions) {
let failing = false;
while (true) {
await sleep(options.pollIntervalMs, undefined, { signal: options.signal });
let response;
try {
response = await request(options.controllerUrl, "api/host-pairing/poll", {
body: { requestId }, signal: options.signal,
});
} catch (err: any) {
if (options.signal?.aborted) {
throw err;
}
// Keep polling through network errors, the request is made again if it expired meanwhile.
if (!failing) {
options.onRetry?.(err, options.pollIntervalMs);
failing = true;
}
continue;
}
failing = false;
if (response.status === 200) {
return pairingResult(response);
}
if (response.status === 404) {
return null;
}
if (response.status !== 202 && !isTemporary(response.status)) {
throw responseError(response);
}
}
}
/**
* Request to join the cluster and wait until the request is approved
*
* Waits through network errors and controller restarts, making a new request
* if the controller no longer knows the previous one.
*
* @param options - Pairing options.
* @returns 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(options: PairHostOptions): Promise<PairingResult> {
const opts = { pollIntervalMs: 2000, retryDelayMs: 10e3, version: "", ...options };
let requestId: string | undefined;
try {
while (true) {
opts.signal?.throwIfAborted();
let response;
try {
response = await request(opts.controllerUrl, "api/host-pairing", {
body: { name: opts.name, version: opts.version }, token: opts.token, signal: opts.signal,
});
} catch (err: any) {
if (opts.signal?.aborted) {
throw err;
}
opts.onRetry?.(err, opts.retryDelayMs);
await sleep(opts.retryDelayMs, undefined, { signal: opts.signal });
continue;
}
if (response.status === 200) {
return pairingResult(response);
}
if (isTemporary(response.status)) {
const delayMs = retryDelay(response, opts.retryDelayMs);
opts.onRetry?.(responseError(response), delayMs);
await sleep(delayMs, undefined, { signal: opts.signal });
continue;
}
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 as string;
opts.onPending?.(response.json.code);
const result = await poll(requestId, opts);
requestId = undefined;
if (result) {
return result;
}
}
} catch (err) {
if (!opts.signal?.aborted) {
throw err;
}
if (requestId !== undefined) {
await request(opts.controllerUrl, "api/host-pairing/cancel", {
body: { requestId }, timeoutMs: 5e3,
}).catch(() => {});
}
throw new DOMException("Pairing was cancelled", "AbortError");
}
}
/**
* Check if the controller accepts a host token
*
* @param controllerUrl - URL of the controller, ending with a slash.
* @param token - Host token to check.
* @returns 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: string, token: string) {
const response = await request(controllerUrl, "api/host-pairing/check", { token });
if (response.status === 200) {
return true;
}
if (response.status === 401) {
return false;
}
throw responseError(response);
}
/**
* Pair the host with the controller if it has not been paired, or the controller no longer accepts it
*
* Waits until the request is approved and saves the id, name and token of the host to its config.
*
* @param config - Config of the host.
* @param options - Pairing options.
* @throws {PairingError} if the controller refuses or rejects the request.
* @throws {DOMException} AbortError if the signal is aborted.
*/
export async function ensurePaired(
config: lib.HostConfig,
options: { version: string, userToken?: string, signal?: AbortSignal },
) {
const controllerUrl = config.get("host.controller_url");
const token = config.get("host.controller_token");
if (token && token !== unpairedToken) {
let valid;
try {
valid = await checkHostToken(controllerUrl, token);
} catch (err: any) {
// Connecting reports the problem, or authenticates if the controller predates pairing.
logger.verbose(`Unable to check if the controller accepts this host: ${err.message}`);
return;
}
if (valid) {
return;
}
logger.warn("The controller no longer accepts this host, requesting to join again");
}
const name = config.get("host.name");
logger.info(`Requesting to join the cluster at ${controllerUrl} as "${name}"`);
const result = await pairHost({
controllerUrl,
name,
version: options.version,
token: options.userToken,
signal: options.signal,
onPending: code => {
logger.info(
`Waiting for approval. Approve "${name}" with code ${code} on the Hosts page of ` +
`${controllerUrl}, or run: npx clusterioctl host approve ${code}`
);
},
onRetry: (err, delayMs) => {
logger.warn(`Requesting to join failed: ${err.message}, trying again in ${Math.ceil(delayMs / 1000)}s`);
},
});
config.set("host.id", result.hostId);
config.set("host.name", result.name);
config.set("host.controller_token", result.token);
await config.save();
logger.info(`Host approved as "${result.name}" with id ${result.hostId}`);
}
@@ -114,7 +114,7 @@ function RevokeHostButton({ host, instances }: RevokeHostButtonProps) {
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.
To add it back, start the host again and approve it.
</>}
placement="bottomRight"
okText="Revoke"
+5 -5
View File
@@ -116,8 +116,8 @@ function AddHostModal(props: { open: boolean, onClose: () => void }) {
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.
Run the installer in an empty directory on the machine the host will run on, then start the host
with the run-host script. It shows a code and waits for the request to be approved here.
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
@@ -132,12 +132,12 @@ function AddHostModal(props: { open: boolean, onClose: () => void }) {
</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.
Once approved the host saves its connection details and starts up.
Later starts connect to the cluster straight away.
</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.
Only approve a request if its code matches the code shown by the host.
An approved host has full access to the cluster.
</Typography.Paragraph>
<Table
+13 -11
View File
@@ -32,8 +32,6 @@ 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);
@@ -294,16 +292,20 @@ async function pairHostWithController() {
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;
// A user allowed to approve hosts gets the host straight away, without waiting for approval.
const response = await fetch(new URL("api/host-pairing", controllerUrl), {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${ctlConfig["ctl.controller_token"]}` },
body: JSON.stringify({ name: "host" }),
});
if (response.status === 404) {
console.log("| controller does not support host pairing");
return false;
}
if (response.status !== 200) {
throw new Error(`Pairing host failed with ${response.status}: ${await response.text()}`);
}
const pairing = await response.json();
console.log(`| paired host ${pairing.name} with id ${pairing.hostId}`);
// The same fields the installer sets, which every host version reads.
+1 -1
View File
@@ -154,7 +154,7 @@ describe("controller/src/WsServer", function() {
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"
"Authentication failed: host is not registered, start the host again to request to join the cluster"
);
assert.equal(controller.hosts.has(5), false, "host record was created");
});
+10 -201
View File
@@ -1,9 +1,7 @@
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";
@@ -135,53 +133,7 @@ describe("create installer CLI", function() {
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" }];
};
}
const controllerUrl = "http://controller.example.com:8080/";
function run(args, env = {}) {
// Record the commands for npm and the clusterio CLIs instead of running them.
@@ -203,20 +155,6 @@ describe("create installer CLI", function() {
});
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)}`,
@@ -231,171 +169,43 @@ describe("create installer CLI", function() {
// 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;
}
const content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
.filter(command => !command.startsWith("getent "));
}
function hostArgs(...extra) {
function hostArgs(url = controllerUrl) {
return [
"--mode", "host", "--host-name", "Test Host", "--controller-url", controllerUrl,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm",
...extra, "--plugins",
"--mode", "host", "--host-name", "Test Host", "--controller-url", url,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm", "--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);
it("writes the host config and leaves joining the cluster to the host", async function() {
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);
assert.match(stdout, /Start the host with run-host/);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
assert.deepEqual(recorded.slice(1), [
`${hostBin} config create`,
hostSet("host.name", "Test Host"),
hostSet("host.controller_url", controllerUrl),
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);
await run(hostArgs(controllerUrl.slice(0, -1)));
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({
@@ -429,7 +239,6 @@ describe("create installer CLI", function() {
]) {
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() {
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import HostRecord from "@clusterio/controller/dist/node/src/HostRecord.js";
import { createLocalHost } from "../../packages/create/local_host.js";
describe("create/local_host createLocalHost()", function() {
let directory;
let secret;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-local-host-"));
secret = crypto.randomBytes(256).toString("base64");
});
afterEach(async function() {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
});
// Written with the controller's own config class to match its file format.
async function writeControllerConfig(fields = {}) {
const configPath = path.join(directory, "config-controller.json");
const config = new lib.ControllerConfig("controller", undefined, configPath);
config.set("controller.auth_secret", secret);
for (const [name, value] of Object.entries(fields)) {
config.set(name, value);
}
await config.save(true);
}
async function loadHosts(databaseDirectory = "database") {
const provider = new lib.JsonIdDatastoreProvider(
path.join(directory, databaseDirectory, "hosts.json"), HostRecord.fromJSON.bind(HostRecord),
);
return await provider.load();
}
it("adds a host the controller loads with a token it accepts", async function() {
await writeControllerConfig();
const before = Math.floor(Date.now() / 1000);
const result = await createLocalHost({ directory, name: "local" });
assert.equal(result.name, "local");
assert(Number.isInteger(result.hostId) && result.hostId >= 1 && result.hostId < 2 ** 31);
const hosts = await loadHosts();
assert.deepEqual([...hosts.keys()], [result.hostId]);
const record = hosts.get(result.hostId);
assert.equal(record.name, "local");
assert.equal(record.version, "");
assert.deepEqual(record.plugins, new Map());
assert(record.tokenValidAfter >= before);
const payload = jwt.verify(result.token, Buffer.from(secret, "base64"), { audience: "host" });
assert.equal(payload.host, result.hostId);
assert(payload.iat >= record.tokenValidAfter, "token must not be issued before token_valid_after");
});
it("appends to an existing database in the configured directory", async function() {
await writeControllerConfig({ "controller.database_directory": "db" });
await fs.mkdir(path.join(directory, "db"));
await fs.writeFile(path.join(directory, "db", "hosts.json"), JSON.stringify([
{ id: 1, name: "existing", version: "2.0.0", plugins: {} },
]));
const result = await createLocalHost({ directory, name: "local", id: 7, version: "2.0.3" });
assert.equal(result.hostId, 7);
const hosts = await loadHosts("db");
assert.deepEqual([...hosts.keys()], [1, 7]);
assert.equal(hosts.get(1).name, "existing");
assert.equal(hosts.get(7).version, "2.0.3");
await assert.rejects(fs.access(path.join(directory, "database")), { code: "ENOENT" });
});
it("signs a new token for an existing host with the same id", async function() {
await writeControllerConfig();
const first = await createLocalHost({ directory, name: "first", id: 7 });
const before = await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8");
const second = await createLocalHost({ directory, name: "second", id: 7 });
assert.equal(second.hostId, 7);
assert.equal(second.name, "first", "the name of the existing host should be kept");
assert.equal(await fs.readFile(path.join(directory, "database", "hosts.json"), "utf8"), before);
const record = (await loadHosts()).get(7);
for (const token of [first.token, second.token]) {
const payload = jwt.verify(token, Buffer.from(secret, "base64"), { audience: "host" });
assert.equal(payload.host, 7);
assert(payload.iat >= record.tokenValidAfter);
}
});
it("throws if the controller config has no auth secret", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
await assert.rejects(createLocalHost({ directory, name: "local" }), /controller\.auth_secret/);
});
});
-343
View File
@@ -1,343 +0,0 @@
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/);
});
});
+295
View File
@@ -0,0 +1,295 @@
import assert from "node:assert/strict";
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 * as lib from "@clusterio/lib";
import { PairingError, checkHostToken, pairHost } from "@clusterio/host";
import { ensurePaired } from "@clusterio/host/dist/node/src/pairing.js";
import "../setup_logging.js";
// Stub controller recording requests, responding with what the test's handler returns.
async function startStub() {
const stub = {
requests: [],
handler: () => [500, { error: "No handler" }],
};
stub.server = http.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, "127.0.0.1");
await events.once(stub.server, "listening");
stub.url = `http://127.0.0.1:${stub.server.address().port}/`;
stub.close = async () => {
stub.server.closeAllConnections();
stub.server.close();
await events.once(stub.server, "close");
};
return stub;
}
const approved = { hostId: 1234, token: "host-token", name: "Approved" };
// Handler for requests that are approved after the given number of pending polls.
function approveAfter(polls, result = approved) {
let pending = 0;
let requests = 0;
return ({ url }) => {
if (url === "/api/host-pairing") {
requests += 1;
return [202, { requestId: `secret-${requests}`, code: `ABC-DE${requests}` }];
}
if (url === "/api/host-pairing/poll") {
pending += 1;
return pending > polls ? [200, result] : [202, { status: "pending" }];
}
if (url === "/api/host-pairing/cancel") {
return [204, undefined];
}
return [404, { error: "Not found" }];
};
}
describe("host/src/pairing", function() {
let stub;
beforeEach(async function() {
stub = await startStub();
});
afterEach(async function() {
await stub.close();
});
function pair(options = {}) {
return pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10, retryDelayMs: 10, ...options });
}
describe("pairHost()", function() {
it("returns the host when approved with a user token", async function() {
stub.handler = ({ headers }) => (headers.authorization === "Bearer user-token"
? [200, approved]
: [401, { error: "Invalid user token" }]
);
const result = await pair({ token: "user-token", onPending: () => assert.fail("should not wait") });
assert.deepEqual(result, approved);
assert.equal(stub.requests.length, 1);
});
it("polls until the request is approved", async function() {
stub.handler = approveAfter(2);
const codes = [];
const result = await pair({ version: "2.1.0", onPending: code => { codes.push(code); } });
assert.deepEqual(result, approved);
assert.deepEqual(codes, ["ABC-DE1"]);
assert.deepEqual(stub.requests[0].body, { name: "Host", version: "2.1.0" });
assert.equal(stub.requests[0].headers.authorization, undefined);
for (const poll of stub.requests.slice(1)) {
assert.deepEqual(poll.body, { requestId: "secret-1" });
}
});
it("resolves routes under the path of the controller URL", async function() {
stub.handler = ({ url }) => (url === "/sub/api/host-pairing" ? [200, approved] : [404, {}]);
const result = await pair({ controllerUrl: `${stub.url}sub/` });
assert.equal(result.hostId, 1234);
});
it("makes a new request when the previous one expired", async function() {
const handler = approveAfter(0);
let expired = false;
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll" && !expired) {
expired = true;
return [404, { error: "Request not found" }];
}
return handler(entry);
};
const codes = [];
const result = await pair({ onPending: code => { codes.push(code); } });
assert.deepEqual(result, approved);
assert.deepEqual(codes, ["ABC-DE1", "ABC-DE2"]);
});
it("waits and tries again when the controller is busy", async function() {
const handler = approveAfter(0);
let busy = 2;
stub.handler = entry => {
if (entry.url === "/api/host-pairing" && busy > 0) {
busy -= 1;
return busy ? [429, { error: "Too many requests" }] : [503, { error: "Recovery mode" }];
}
return handler(entry);
};
const retries = [];
const result = await pair({ onRetry: err => { retries.push(err.message); } });
assert.deepEqual(result, approved);
assert.deepEqual(retries, ["Too many requests", "Recovery mode"]);
});
it("keeps trying while the controller can't be reached", async function() {
const { url } = stub;
await stub.close();
const abort = new AbortController();
const errors = [];
await assert.rejects(
pairHost({
controllerUrl: url, name: "Host", retryDelayMs: 10, signal: abort.signal,
onRetry: err => {
errors.push(err);
if (errors.length === 3) {
abort.abort();
}
},
}),
{ name: "AbortError" },
);
assert.equal(errors.length, 3);
stub = await startStub();
});
it("throws when the request is rejected", async function() {
const handler = approveAfter(Infinity);
stub.handler = entry => (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2
? [403, { error: "Request was rejected" }]
: handler(entry)
);
await assert.rejects(
pair(),
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(
pair(),
err => err instanceof PairingError && err.status === 404 && /must be updated/.test(err.message),
);
});
it("throws on an invalid user token without waiting", async function() {
stub.handler = () => [401, { error: "Invalid user token" }];
await assert.rejects(
pair({ token: "bad" }),
err => err instanceof PairingError && err.status === 401 && err.message === "Invalid user token",
);
assert.equal(stub.requests.length, 1);
});
it("cancels the request when aborted", async function() {
const abort = new AbortController();
const handler = approveAfter(Infinity);
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll") {
abort.abort();
}
return handler(entry);
};
await assert.rejects(pair({ signal: abort.signal }), { name: "AbortError" });
const cancel = stub.requests.find(r => r.url === "/api/host-pairing/cancel");
assert.deepEqual(cancel?.body, { requestId: "secret-1" });
});
it("does not send anything if already aborted", async function() {
await assert.rejects(pair({ signal: AbortSignal.abort() }), { name: "AbortError" });
assert.equal(stub.requests.length, 0);
});
});
describe("checkHostToken()", function() {
it("returns true if the controller accepts the token", async function() {
stub.handler = ({ url, headers }) => (
url === "/api/host-pairing/check" && headers.authorization === "Bearer host-token"
? [200, { valid: true }]
: [401, undefined]
);
assert.equal(await checkHostToken(stub.url, "host-token"), true);
assert.equal(await checkHostToken(stub.url, "other-token"), false);
});
it("throws on other responses", async function() {
stub.handler = () => [404, undefined];
await assert.rejects(checkHostToken(stub.url, "host-token"), PairingError);
});
});
describe("ensurePaired()", function() {
let directory;
let config;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-pairing-"));
config = new lib.HostConfig("host", undefined, path.join(directory, "config-host.json"));
config.set("host.name", "My Host");
config.set("host.controller_url", stub.url);
});
afterEach(async function() {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
});
async function savedConfig() {
return JSON.parse(await fs.readFile(path.join(directory, "config-host.json"), "utf8"));
}
it("waits for approval and saves the host when not paired", async function() {
this.timeout(5000); // Polls at the default interval of 2 seconds
stub.handler = approveAfter(0);
await ensurePaired(config, { version: "2.1.0" });
assert.deepEqual(stub.requests[0].body, { name: "My Host", version: "2.1.0" });
const saved = await savedConfig();
assert.equal(saved["host.id"], 1234);
assert.equal(saved["host.name"], "Approved");
assert.equal(saved["host.controller_token"], "host-token");
});
it("does nothing when the controller accepts the saved token", async function() {
config.set("host.controller_token", "valid-token");
stub.handler = ({ headers }) => (headers.authorization === "Bearer valid-token"
? [200, { valid: true }]
: [401, undefined]
);
await ensurePaired(config, { version: "2.1.0" });
assert.deepEqual(stub.requests.map(r => r.url), ["/api/host-pairing/check"]);
assert.equal(config.get("host.controller_token"), "valid-token");
});
it("asks to join again with the user token when the saved token is not accepted", async function() {
config.set("host.controller_token", "revoked-token");
stub.handler = ({ url, headers }) => {
if (url === "/api/host-pairing/check") {
return [401, undefined];
}
return headers.authorization === "Bearer user-token"
? [200, { hostId: 5, token: "new-token", name: "My Host" }]
: [401, { error: "Invalid user token" }];
};
await ensurePaired(config, { version: "2.1.0", userToken: "user-token" });
const saved = await savedConfig();
assert.equal(saved["host.id"], 5);
assert.equal(saved["host.controller_token"], "new-token");
assert(!JSON.stringify(saved).includes("user-token"), "user token must not be saved");
});
it("keeps the saved token when it can't be checked", async function() {
config.set("host.controller_token", "some-token");
stub.handler = () => [404, undefined];
await ensurePaired(config, { version: "2.1.0" });
assert.equal(config.get("host.controller_token"), "some-token");
assert.equal(stub.requests.length, 1);
});
});
});
+38 -17
View File
@@ -15,7 +15,7 @@ import {
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
} from "./index.js";
import { pairHost, checkHostToken, PairingError } from "../../packages/create/pair.js";
import { pairHost, checkHostToken, PairingError } from "@clusterio/host";
import { externalTest, slowTest } from "../common.js";
import { getTestRconPort } from "./rcon_port.js";
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
@@ -718,7 +718,7 @@ describe("Integration of Clusterio", function() {
describe("host pending, approve and reject", function() {
let pairings = [];
afterEach(async function() {
// Stop installers left waiting by a failed test
// Stop hosts left waiting by a failed test
for (const pairing of pairings) {
pairing.abort.abort();
}
@@ -726,7 +726,7 @@ describe("Integration of Clusterio", function() {
pairings = [];
});
// Start pairing like an installer without a token, returns once the request is pending.
// Start pairing like a host without a user token, returns once the request is pending.
async function requestPairing(name) {
const abort = new AbortController();
let onPending;
@@ -740,7 +740,7 @@ describe("Integration of Clusterio", function() {
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]));
pairing.code = await Promise.race([pending, ended]);
return pairing;
}
@@ -776,12 +776,12 @@ describe("Integration of Clusterio", function() {
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);
assert.equal(await checkHostToken(url, approved.token), true);
await execCtl(`host revoke ${approved.hostId}`);
});
it("should make the installer fail when the request is rejected", async function() {
it("should make the host 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));
@@ -792,7 +792,7 @@ describe("Integration of Clusterio", function() {
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
});
it("should remove the request when the installer stops waiting", async function() {
it("should remove the request when the host stops waiting", async function() {
const pairing = await requestPairing("cancelled-host");
pairing.abort.abort();
await assert.rejects(pairing.result, err => err.name === "AbortError");
@@ -836,7 +836,7 @@ describe("Integration of Clusterio", function() {
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);
assert.equal(await checkHostToken(url, pairing.token), true);
await execCtl(`note set host ${pairing.hostId} short-lived`);
const control = getControl();
@@ -848,7 +848,7 @@ describe("Integration of Clusterio", function() {
);
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);
assert.equal(await checkHostToken(url, 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");
});
@@ -880,7 +880,7 @@ describe("Integration of Clusterio", function() {
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() {
it("should stop a running host and have it ask to join again when started", async function() {
slowTest(this);
this.timeout(30000);
const config = "revoke-host-config.json";
@@ -905,13 +905,34 @@ describe("Integration of Clusterio", function() {
}
}
// 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;
});
// Starting it again asks to join the cluster again, and it starts up once approved
const rejoining = await spawnNode(
"revoke-host:", `../../packages/host run --config ${config}`, /Waiting for approval/
);
const exited = events.once(rejoining, "exit");
try {
const started = new Promise(resolve => {
rejoining.stdout.on("data", chunk => {
if (String(chunk).includes("Started host")) {
resolve();
}
});
});
const requests = await getControl().send(new lib.HostPairingListRequest());
const request = requests.find(entry => entry.name === "running-revokee");
assert(request, "Revoked host did not ask to join again");
const newHostId = await getControl().send(new lib.HostPairingApproveRequest(request.code));
assert.notEqual(newHostId, pairing.hostId);
assert.notEqual(
await lib.timeout(started, 10e3, "timeout"), "timeout", "Host did not start once approved"
);
await execCtl(`host revoke ${newHostId}`);
await lib.timeout(exited, 10e3, "timeout");
} finally {
if (rejoining.exitCode === null && rejoining.signalCode === null) {
rejoining.kill("SIGINT");
}
}
});
});
+2 -2
View File
@@ -12,7 +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 { pairHost } from "@clusterio/host";
import "../setup_logging.js";
@@ -276,7 +276,7 @@ export async function pairTestHost(name) {
}
/**
* Write a new host config for a paired host, with the fields the installer sets
* Write a new host config for a paired host, with the fields the installer and pairing set
*
* @param {string} configPath - Path to write the config to.
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
+1 -1
View File
@@ -162,7 +162,7 @@ describe("Integration of lib/link", function() {
await assert.rejects(
hostConnector.connect(),
new Error(
"Authentication failed: host is not registered, run the installer again in the host directory"
"Authentication failed: host is not registered, start the host again to request to join the cluster"
)
);
});