mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge remote-tracking branch 'origin/master' into fix/headless-export-icons
This commit is contained in:
114 files changed
+5338
-1165
No files matched your search
@@ -1,5 +1,5 @@
|
||||
name: Install dependencies
|
||||
description: Set up pnpm and Node.js, restore caches and run pnpm install
|
||||
description: Set up pnpm and Node.js and run pnpm install
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
@@ -12,40 +12,15 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
# The lockfile is not committed, so the usual lockfile hash key does not
|
||||
# apply. Instead a new store snapshot is saved whenever a package.json
|
||||
# changes or a week passes, and every run restores the most recent one.
|
||||
- id: store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-
|
||||
|
||||
# The prepare scripts run tsc --build, which is incremental when its
|
||||
# previous output is present. Every run saves its output and the next run
|
||||
# starts from the most recent one.
|
||||
- if: inputs.ignore-scripts != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
packages/*/dist
|
||||
plugins/*/dist
|
||||
key: build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: build-${{ runner.os }}-
|
||||
# pnpm/action-setup installs a bootstrap pnpm and then switches to the
|
||||
# packageManager version, which takes about 10 seconds.
|
||||
- shell: bash
|
||||
run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager.split("+")[0]')"
|
||||
|
||||
- shell: bash
|
||||
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
|
||||
# Drop packages the restored store had but this install did not use, so
|
||||
# the snapshot does not grow with every version bump.
|
||||
- shell: bash
|
||||
run: pnpm store prune
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.sha || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
EMMYLUA_CHECK_VERSION: "0.25.1"
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
# Superseded runs of a branch or pull request are cancelled. Runs on master
|
||||
# each get their own group so every commit there is tested.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.sha || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.x
|
||||
|
||||
@@ -22,17 +28,33 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
|
||||
# To stay within the concurrent job limit, every Factorio version is
|
||||
# tested on the latest Node.js, and every other supported Node.js
|
||||
# version only with the latest Factorio. Coverage is performed on the
|
||||
# oldest supported Node.js.
|
||||
# Node.js release schedule: https://nodejs.org/en/about/releases/
|
||||
node-version: [24.x, 26.x]
|
||||
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
node-version: [26.x]
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||
|
||||
# Coverage is performed on the older of the supported node versions
|
||||
|
||||
# The tests run in two halves of similar length on separate runners,
|
||||
# the clusterioctl, plugin system and instance integration tests and the rest.
|
||||
tests: [ctl, other]
|
||||
|
||||
include:
|
||||
- node-version: 24.x
|
||||
factorio-version: 2.1.17
|
||||
tests: ctl
|
||||
- node-version: 24.x
|
||||
factorio-version: 2.1.17
|
||||
tests: other
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
tests: ctl
|
||||
coverage: true
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
tests: other
|
||||
coverage: true
|
||||
|
||||
steps:
|
||||
@@ -45,21 +67,23 @@ jobs:
|
||||
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: pnpm test
|
||||
run: pnpm test --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.coverage }}
|
||||
run: pnpm run ci-cover
|
||||
run: pnpm run ci-cover --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ matrix.coverage }}
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
# Otherwise the raw V8 coverage in coverage/tmp is uploaded as well
|
||||
disable_search: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload test logs for debugging
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}-${{ matrix.tests }}
|
||||
path: temp/test/logs
|
||||
|
||||
version-compat:
|
||||
@@ -87,8 +111,6 @@ jobs:
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
|
||||
run: >
|
||||
node scripts/version-compat-test.js
|
||||
|
||||
@@ -251,6 +251,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
|
||||
After completing the installation and creating your admin user, start up the controller and at least one host separately.
|
||||
The installer provides the `run-controller` and `run-host` scripts to make this simple.
|
||||
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
|
||||
The first time a host starts it prints a link for approving it, open the link and click Approve.
|
||||
|
||||
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start. You can also use the "Connect via Steam" button to launch Factorio directly and connect to the server using Steam's protocol handler.
|
||||
|
||||
@@ -272,9 +273,23 @@ If you have copied these files over to `/etc/systemd/system/` then you can start
|
||||
|
||||
## Setting up remote hosts
|
||||
|
||||
Run the installer as described in the installation section and choose "Host only" as the operating mode to install, it'll ask for a controller URL and an authentication token.
|
||||
The URL is the same as what is needed to connect to the web interface, and the authentication token can be generated on the Hosts page in the web interface.
|
||||
Once you start up the host it should show up in the Hosts list and be available for assigning and running instances on.
|
||||
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.
|
||||
Then start the host, the first time it starts it prints a code and waits for the host to be approved:
|
||||
|
||||
Waiting for approval of "my-host" with code ABC-DEF, open this link to approve it:
|
||||
https://example.com/hosts?approve=ABC-DEF
|
||||
Or run: npx clusterioctl host approve ABC-DEF
|
||||
|
||||
Open the link to approve it in the web interface, where you can also change its name, or click Add host on the Hosts page and approve the request with the same code, or run `npx clusterioctl host approve <code>`.
|
||||
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 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, 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, its instances become unassigned and its saved connection details stop working.
|
||||
To add the computer back to the cluster start the host again, it asks for approval again.
|
||||
|
||||
|
||||
## Setting up clusterioctl
|
||||
|
||||
+24
-2
@@ -48,6 +48,23 @@ Must be greater than 0.
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.host_pairing_timeout
|
||||
|
||||
Time in seconds a host can wait for approval before its request expires, after which the host requests again with a new code.
|
||||
Set to 0 for no limit.
|
||||
|
||||
Defaults to 3600.
|
||||
|
||||
|
||||
### controller.host_pairing_max_pending
|
||||
|
||||
Maximum number of hosts that can wait for approval at the same time.
|
||||
Hosts over the limit keep trying until there is room.
|
||||
Set to 0 for no limit.
|
||||
|
||||
Defaults to 50.
|
||||
|
||||
|
||||
### controller.http_port
|
||||
|
||||
Port to host HTTP server on.
|
||||
@@ -93,7 +110,7 @@ Defaults to null meaning the header is never trusted.
|
||||
### controller.public_url
|
||||
|
||||
Public address the controller is accessible on.
|
||||
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
|
||||
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
|
||||
This should be a full URL ending with a /.
|
||||
|
||||
Defaults to null meaning assume localhost.
|
||||
@@ -277,6 +294,7 @@ Defaults to 1000.
|
||||
|
||||
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
|
||||
The installer sets this, controller.allow_plugin_updates and controller.allow_plugin_install from one question about remote updates and plugin installs.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
@@ -326,6 +344,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 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.
|
||||
@@ -374,7 +393,9 @@ Defaults to "http://localhost:8080/".
|
||||
### host.controller_token
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
|
||||
The 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.
|
||||
The value can only be read and changed from the host itself.
|
||||
|
||||
Defaults to "enter token here".
|
||||
@@ -427,6 +448,7 @@ Defaults to 60.
|
||||
|
||||
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
|
||||
The installer sets this, host.allow_plugin_updates and host.allow_plugin_install from one question about remote updates and plugin installs.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
@@ -87,10 +87,12 @@ Install dependencies with:
|
||||
|
||||
This installs dependencies and builds the packages.
|
||||
`pnpm-lock.yaml` is intentionally ignored; do not force-add it or assume a frozen-lockfile install is supported.
|
||||
For a fresh development cluster, run the installer and select standalone mode to create `config-controller.json`, `config-host.json`, and the credentials needed to connect them:
|
||||
For a fresh development cluster, run the installer and select standalone mode to create `config-controller.json` and `config-host.json`:
|
||||
|
||||
node packages/create --dev
|
||||
|
||||
The first time the host starts it prints a link for approving it in the web interface.
|
||||
|
||||
You can skip this step if you already have a working controller and host configuration.
|
||||
|
||||
To add plugins to use in the development environment use
|
||||
@@ -123,6 +125,7 @@ For web development on the controller there are also the following flags:
|
||||
`--dev` - Serve the `web_ui` package from source through Vite's development server with hot module replacement.
|
||||
|
||||
`--dev-plugin [name]` - Serve the web module for the given plugin from source the same way.
|
||||
This also serves the `web_ui` package from source, as the plugin has to share its modules with it.
|
||||
|
||||
These options can be combined and multiple plugins can be actived for dev mode.
|
||||
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
|
||||
@@ -254,6 +257,7 @@ If there are no changelog entries to add, remove the code block and start the ch
|
||||
|
||||
The version-compat job in CI runs the local controller with the latest published host, and the published controller with the local host.
|
||||
It fails if a link message changed its src, dst, data or response schema since that release.
|
||||
Control is left out of src and dst, and messages that only go between control and the controller are not checked, as the web UI is served by the controller it talks to.
|
||||
If the break is intended, add an entry to [scripts/version-compat-breaks.json](../scripts/version-compat-breaks.json) in the same pull request.
|
||||
Breaking changes are only accepted if the next release is intended to include them, see our decision on [breaking changes](/docs/decisions.md#breaking-changes).
|
||||
The entry is keyed by the published version directly before the breaking change was introduced, which is the latest release at the time.
|
||||
@@ -338,7 +342,8 @@ The format of this log should be self explanatory, please follow it carefully. P
|
||||
|
||||
For development, use the latest Node.js LTS release, following the [installation instructions](../README.md#installation).
|
||||
Make sure the Node.js built-in APIs and npm packages you use remain compatible with the supported versions.
|
||||
The GitHub Actions tests currently run on Node.js 24 and 26 across the Factorio matrix, with Node.js 22 used for coverage; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
|
||||
The GitHub Actions tests run every Factorio version on the latest Node.js, and every other supported Node.js version with the latest Factorio only, which keeps the number of jobs within GitHub's concurrent job limit.
|
||||
Coverage is collected on the oldest supported Node.js; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
|
||||
|
||||
For Factorio, active support covers the latest stable version and the previous major version, using version lines such as 2.1, 2.0 and 1.1 as described in the [Factorio support policy](decisions.md#factorio).
|
||||
Older versions from 0.17 onwards receive passive support where it does not restrict actively supported versions.
|
||||
|
||||
@@ -59,3 +59,23 @@ Imports of `assert`, `buffer`, `events`, `os`, `path`, `process`, `stream`, `uti
|
||||
The `container` property of `WebPluginContext` is now the plugin's ES module namespace, `await container.get("./web")` returns the module instead of a factory function.
|
||||
|
||||
`lib.PluginWebpackEnvInfo` is renamed to `lib.PluginWebEnvInfo`.
|
||||
|
||||
|
||||
## Host pairing
|
||||
|
||||
Host tokens are no longer generated by users.
|
||||
A host joins the cluster by 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`.
|
||||
- `HostRevokeTokensRequest` is replaced by `HostRevokeRequest`, which deletes the host instead of only invalidating its tokens.
|
||||
Instances assigned to the host become unassigned.
|
||||
- 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 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.
|
||||
Plugins that keep data per host should remove it when the host is deleted.
|
||||
On the controller the `update` event of `controller.hosts` gives the record with `isDeleted` set to true, and subscribers of `HostUpdatesEvent` receive the host with `isDeleted` set to true.
|
||||
A host that is revoked and approved again gets a new id.
|
||||
@@ -32,6 +32,63 @@ The [send-buffer setting](../configuration.md#controllermax_send_buffer_size) co
|
||||

|
||||
|
||||
|
||||
## Host pairing
|
||||
|
||||
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 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`.
|
||||
The `POST` endpoints return errors as `{"error": "<message>"}` and respond with 503 while the controller is in recovery mode.
|
||||
A request body that is not valid JSON or is larger than 4 kB is rejected with 400 or 413 before it reaches the endpoint, without a JSON body.
|
||||
`GET /api/host-pairing/check` also works in recovery mode, and responds to a token that is not valid with a plain 401 without a JSON body.
|
||||
A user or host token is sent in either an `Authorization: Bearer <token>` header or an `X-Access-Token` header.
|
||||
|
||||
### `POST /api/host-pairing`
|
||||
|
||||
Creates a request for a host to join the cluster.
|
||||
|
||||
- `name: string` - Name of the host, at most 100 characters after trimming whitespace, without control characters.
|
||||
- `version: string` (optional) - Version of the 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`.
|
||||
Codes are matched ignoring case and anything that is not a letter or digit, so `abcdef` matches `ABC-DEF`.
|
||||
The `requestId` is a secret used to poll for the result, it is never shown to users.
|
||||
|
||||
If a user token is sent and the user has the `core.host.approve` permission the host is created right away and the response is 200 with the same body as an approved poll.
|
||||
An invalid token gives 401 and a user without the permission gives 403, a token is never ignored to create a pending request instead.
|
||||
|
||||
A missing or too long name gives 400.
|
||||
Too many pending requests from the same address, or in total as set by [controller.host_pairing_max_pending](/docs/configuration.md#controllerhost_pairing_max_pending), gives 429 with a `Retry-After` header in seconds.
|
||||
|
||||
### `POST /api/host-pairing/poll`
|
||||
|
||||
- `requestId: string` - Id returned when the request was created.
|
||||
|
||||
Responds with 202 and `{"status": "pending"}` while the request is waiting for approval.
|
||||
Once approved it responds with 200 and `{"hostId": integer, "token": string, "name": string}`, where `name` is the name the host was approved with, which can differ from the one asked for.
|
||||
Rejected requests give 403, and unknown or expired requests give 404.
|
||||
The result of an approved or rejected request is only returned once, after which the request is gone.
|
||||
|
||||
A pending request expires 30 seconds after it was last polled, and after waiting for the time set by [controller.host_pairing_timeout](/docs/configuration.md#controllerhost_pairing_timeout).
|
||||
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 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.
|
||||
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
|
||||
|
||||
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
|
||||
@@ -119,6 +176,11 @@ Host handshake for establishing a new connection session.
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are available on the host.
|
||||
|
||||
The controller closes the connection with code 3000 (Unauthorized) if the token is invalid, was issued for another id, or if there is no host with this id, see [Host pairing](#host-pairing).
|
||||
The host checks its token before connecting and requests to join again if it is not accepted, so this normally only happens to a host that is already running.
|
||||
A host that has been rejected exits with code 8, and the run scripts created by the installer do not restart it.
|
||||
A host that is revoked while connected is disconnected, is rejected when it tries to connect again, and shuts down.
|
||||
|
||||
#### `registerControl`
|
||||
|
||||
Control handshake for enstablishing a new connection session.
|
||||
|
||||
@@ -16,17 +16,40 @@ The easiest way to do this is to run `npx clusteriocontroller bootstrap create-c
|
||||
|
||||
Lists all hosts.
|
||||
|
||||
### Generate token
|
||||
Hosts are added to the cluster by running the installer on the computer the host runs on and approving the code it shows, see [Setting up remote hosts](/README.md#setting-up-remote-hosts).
|
||||
Listing, approving and rejecting hosts waiting for approval needs the `core.host.approve` permission.
|
||||
|
||||
ctl> host generate-token [--id <host id>]
|
||||
### List pending hosts
|
||||
|
||||
Generate a token for a host. `--id` is the host id.
|
||||
ctl> host pending
|
||||
|
||||
### Create config
|
||||
Lists 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 the time set by `controller.host_pairing_timeout`, an hour by default, after which the host asks again with a new code.
|
||||
|
||||
ctl> host create-config [--id <host id>] [--name <host name>] [--generate-token <bool>] [--output <path to output>]
|
||||
### Approve host
|
||||
|
||||
Create a host config. `--id` id the host id. `--name` is the host name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-host.json", "-" for stdout.
|
||||
ctl> host approve <code> [--name <host name>]
|
||||
|
||||
Approves the 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 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 host showing the code, the host then exits with an error.
|
||||
|
||||
### Revoke host
|
||||
|
||||
ctl> host revoke <host>
|
||||
|
||||
Specify the host by name or ID.
|
||||
Removes the host from the cluster, disconnects it and makes its token stop working.
|
||||
Instances assigned to the host become unassigned, their files stay on the computer.
|
||||
Requires the `core.host.revoke` permission.
|
||||
To add the computer back to the cluster, start the host again and approve it.
|
||||
|
||||
### Update Clusterio
|
||||
|
||||
@@ -270,9 +293,11 @@ To be written.
|
||||
|
||||
## Plugins (Remote)
|
||||
|
||||
By default, remote updates are enabled unless you selected "no" during installation.
|
||||
Remote updates allow ctl and the web UI to update plugins on machines within the cluster.
|
||||
However, remote installations are disabled by default to prevent the risk of arbitrary code execution through the installation of malicious npm packages onto the machine. See below if you wish to enable this feature.
|
||||
The installer asks "Allow remote updates and plugin installs from the web interface?" and the answer sets `allow_remote_updates`, `allow_plugin_updates` and `allow_plugin_install` together on that machine, pass `--remote-npm` or `--no-remote-npm` to answer it from the command line.
|
||||
Remote updates allow ctl and the web UI to update Clusterio and plugins on machines within the cluster.
|
||||
Remote installs allow them to install any package from npm onto the machine, which is a risk of arbitrary code execution through malicious npm packages, so only allow them if you trust everyone with access to install plugins.
|
||||
In a config created without the installer remote updates are enabled and remote installs are disabled by default.
|
||||
See below for changing these settings after installing.
|
||||
|
||||
### List
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ This creates a private key file named `key.pem` and a public certificate file na
|
||||
npx clusteriocontroller config set controller.tls_certificate cert.pem
|
||||
npx clusteriocontroller config set controller.tls_private_key key.pem
|
||||
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, which also makes the installer trust the certificate when it asks the controller to approve the host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
|
||||
|
||||
**For run-host.cmd**
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Each entrypoint of a plugin exports a function that attaches handlers to hooks c
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
- [Handling connection events](#handling-connection-events)
|
||||
- [Web Pages for a Single Resource](#web-pages-for-a-single-resource)
|
||||
- [Collecting Statistics](#collecting-statistics)
|
||||
- [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl)
|
||||
|
||||
@@ -842,6 +843,34 @@ This means that if your plugin sends a lot of events or requests, they can end u
|
||||
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
|
||||
|
||||
|
||||
## Web Pages for a Single Resource
|
||||
|
||||
A web page showing one resource from a subscription has more to handle than the resource itself.
|
||||
It can still be loading, it might not exist, it can be deleted while the page is open, and the subscription is rejected if the user lacks the permission for it.
|
||||
`useResourceState` and `ResourceState` from `@clusterio/web_ui` show these the same way as the pages for hosts, instances and the other built in resources.
|
||||
|
||||
```tsx
|
||||
function FrobberPage() {
|
||||
const id = Number(useParams().id);
|
||||
const [frobbers, synced, error] = useSyncExternalStore(subscribe, () => frobberSubscriber.getSnapshot());
|
||||
const frobber = frobbers.get(id);
|
||||
const state = useResourceState(frobber, synced, error, id);
|
||||
|
||||
const nav = [{ name: "Frobbers", path: "/frobbers" }, { name: frobber?.name ?? String(id) }];
|
||||
if (!frobber) {
|
||||
return <ResourceState state={state} type="Frobber" id={id} nav={nav} listPath="/frobbers" error={error} />;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>{frobber.name}</PageLayout>;
|
||||
}
|
||||
```
|
||||
|
||||
`useResourceState` returns `"ok"` when there is a value and otherwise `"loading"`, `"missing"`, `"deleted"` or `"error"`.
|
||||
It tells deleted from missing by remembering if it has seen a value for that id.
|
||||
`ResourceState` renders the whole page for the states without a value, with a link back to the page at `listPath`.
|
||||
The `useHost`, `useInstance`, `useUser`, `useRole` and `useModPack` hooks return `[value, synced, error]` so they can be used the same way.
|
||||
|
||||
|
||||
## Collecting Statistics
|
||||
|
||||
Clusterio comes with its own Prometheus client implementation, one part due to Not Invented Here and another part due to collectors in prom-client being difficult to get to work nicely with collecting data from plugins optionally loaded at runtime on different computers.
|
||||
|
||||
+1
-2
@@ -28,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -46,7 +46,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"express": "catalog:",
|
||||
"form-data": "^4.0.6",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"mocha": "^11.8.0",
|
||||
"oxlint": "^1.88.0",
|
||||
|
||||
@@ -23,13 +23,14 @@ import util from "util";
|
||||
import winston from "winston";
|
||||
import DailyRotateFile from "winston-daily-rotate-file";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
// homebrew modules
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
import "./src/set_lazy_schema_compilation.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Controller from "./src/Controller.js";
|
||||
import type Controller from "./src/Controller.js";
|
||||
import UserManager from "./src/UserManager.js";
|
||||
import UserRecord from "./src/UserRecord.js";
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
|
||||
@@ -186,14 +187,6 @@ async function handleBootstrapCommand(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(userManager.signUserToken(user));
|
||||
|
||||
} else if (subCommand === "generate-host-token") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
// Auth secret is never null after startup
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
let admin = userManager.getByName(args.name);
|
||||
if (!admin) {
|
||||
@@ -203,6 +196,7 @@ async function handleBootstrapCommand(
|
||||
}
|
||||
let ctlConfig = new lib.CtlConfig("control");
|
||||
|
||||
const { default: Controller } = await import("./src/Controller.js");
|
||||
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
ctlConfig.set(
|
||||
"ctl.controller_token",
|
||||
@@ -322,9 +316,6 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
|
||||
yargs.positional("name", { describe: "Name of the user", type: "string" });
|
||||
})
|
||||
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
|
||||
yargs.positional("id", { describe: "ID of the host", type: "number" });
|
||||
})
|
||||
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
|
||||
yargs.option("output", {
|
||||
describe: "Path to output config (- for stdout)", type: "string",
|
||||
@@ -475,6 +466,8 @@ async function startup() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Loaded here as the server is not needed by the other commands
|
||||
const { default: Controller } = await import("./src/Controller.js");
|
||||
controller = await Controller.fromConfig(
|
||||
controllerConfig,
|
||||
pluginInfos,
|
||||
|
||||
@@ -6,6 +6,7 @@ export { default as HostConnection } from "./src/HostConnection.js";
|
||||
export { default as InstanceRecord } from "./src/InstanceRecord.js";
|
||||
export { default as InstanceManager } from "./src/InstanceManager.js";
|
||||
export { default as HostRecord } from "./src/HostRecord.js";
|
||||
export { default as HostPairingManager, HostPairingError } from "./src/HostPairingManager.js";
|
||||
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
|
||||
export { default as UserManager } from "./src/UserManager.js";
|
||||
export { default as UserRecord } from "./src/UserRecord.js";
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -81,10 +81,11 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
this.handle(lib.HostRevokeRequest, this.handleHostRevokeRequest.bind(this));
|
||||
this.handle(lib.HostListRequest, this.handleHostListRequest.bind(this));
|
||||
this.handle(lib.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this));
|
||||
this.handle(lib.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this));
|
||||
this.handle(lib.HostPairingListRequest, this.handleHostPairingListRequest.bind(this));
|
||||
this.handle(lib.HostPairingApproveRequest, this.handleHostPairingApproveRequest.bind(this));
|
||||
this.handle(lib.HostPairingRejectRequest, this.handleHostPairingRejectRequest.bind(this));
|
||||
this.handle(lib.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this));
|
||||
this.handle(lib.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
|
||||
@@ -248,49 +249,48 @@ export default class ControlConnection extends BaseConnection {
|
||||
this._controller.config.setProp(field as keyof lib.ControllerConfigFields, prop, value, "control");
|
||||
}
|
||||
|
||||
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
|
||||
async handleHostRevokeRequest(request: lib.HostRevokeRequest) {
|
||||
const host = this._controller.hosts.getMutable(request.hostId);
|
||||
if (!host) {
|
||||
throw new Error(`Unknown host id (${request.hostId})`);
|
||||
throw new lib.NotFoundError(`Unknown host id (${request.hostId})`);
|
||||
}
|
||||
|
||||
host.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
this._controller.hosts.delete(host);
|
||||
this._controller.deleteNote("host", request.hostId, this.user.name);
|
||||
const system = this._controller.systems.getMutable(request.hostId);
|
||||
if (system) {
|
||||
this._controller.systems.delete(system);
|
||||
}
|
||||
|
||||
// Disconnected first so unassigning the instances does not depend on the host responding.
|
||||
const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId);
|
||||
if (hostConnection) {
|
||||
hostConnection.connector.terminate();
|
||||
}
|
||||
|
||||
this._controller.hosts.set(host);
|
||||
const assigned = [...this._controller.instances.values()].filter(
|
||||
instance => instance.config.get("instance.assigned_host") === request.hostId
|
||||
);
|
||||
for (const instance of assigned) {
|
||||
await this._controller.instances.unassignInstance(instance.id);
|
||||
}
|
||||
logger.audit(`User ${this.user.name} revoked host ${host.name} (${host.id})`);
|
||||
}
|
||||
|
||||
async handleHostListRequest(): Promise<lib.HostDetails[]> {
|
||||
return [...this._controller.hosts.values()].map(host => host.toHostDetails());
|
||||
}
|
||||
|
||||
async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) {
|
||||
let hostId = message.hostId;
|
||||
if (hostId === undefined) {
|
||||
hostId = Math.random() * 2**31 | 0;
|
||||
}
|
||||
return this._controller.generateHostToken(hostId);
|
||||
async handleHostPairingListRequest(): Promise<lib.HostPairingDetails[]> {
|
||||
this._controller.hostPairing.prune();
|
||||
return [...this._controller.hostPairing.records.values()];
|
||||
}
|
||||
|
||||
async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) {
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
async handleHostPairingApproveRequest(request: lib.HostPairingApproveRequest) {
|
||||
return this._controller.hostPairing.approve(request.code, request.name, this.user.name);
|
||||
}
|
||||
|
||||
hostConfig.set("host.controller_url", this._controller.getControllerUrl());
|
||||
if (request.id !== undefined) {
|
||||
hostConfig.set("host.id", request.id);
|
||||
}
|
||||
if (request.name !== undefined) {
|
||||
hostConfig.set("host.name", request.name);
|
||||
}
|
||||
if (request.generateToken) {
|
||||
this.user.checkPermission("core.host.generate_token");
|
||||
hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id")));
|
||||
}
|
||||
return hostConfig.toJSON();
|
||||
async handleHostPairingRejectRequest(request: lib.HostPairingRejectRequest) {
|
||||
this._controller.hostPairing.reject(request.code, this.user.name);
|
||||
}
|
||||
|
||||
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import UserRecord from "./UserRecord.js";
|
||||
import UserManager from "./UserManager.js";
|
||||
import WsServer from "./WsServer.js";
|
||||
import HostRecord from "./HostRecord.js";
|
||||
import HostPairingManager from "./HostPairingManager.js";
|
||||
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
|
||||
import ControllerRouter from "./ControllerRouter.js";
|
||||
import InstanceManager from "./InstanceManager.js";
|
||||
@@ -91,6 +92,8 @@ export default class Controller {
|
||||
users: UserManager;
|
||||
/** Instance manager for the cluster, also contains access to the user records */
|
||||
instances: InstanceManager;
|
||||
/** Pending requests from hosts to join the cluster */
|
||||
hostPairing: HostPairingManager;
|
||||
|
||||
// Possible states are new, starting, running, stopping, stopped
|
||||
private _state: string = "new";
|
||||
@@ -296,6 +299,7 @@ export default class Controller {
|
||||
|
||||
this.users = new UserManager(users, roles, config);
|
||||
this.instances = new InstanceManager(instances, this);
|
||||
this.hostPairing = new HostPairingManager(this);
|
||||
|
||||
this.modStore.on("change", mod => {
|
||||
this.modsUpdated([mod]);
|
||||
@@ -305,6 +309,7 @@ export default class Controller {
|
||||
this.subscriptions = new lib.SubscriptionController(config.get("controller.max_subscription_filters"));
|
||||
this.subscriptions.handle(lib.SystemInfoUpdateEvent, this.handleSystemInfoSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.HostUpdatesEvent, this.handleHostSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsSubscription.bind(this));
|
||||
this.subscriptions.handle(
|
||||
lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsSubscription.bind(this)
|
||||
@@ -318,6 +323,7 @@ export default class Controller {
|
||||
// Handle updates for datastores
|
||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||
this.hosts.on("update", this.hostsUpdated.bind(this));
|
||||
this.hostPairing.records.on("update", this.hostPairingRequestsUpdated.bind(this));
|
||||
this.instances.records.on("update", this.instanceUpdated.bind(this));
|
||||
this.saves.on("update", this.savesUpdated.bind(this));
|
||||
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
||||
@@ -420,7 +426,7 @@ export default class Controller {
|
||||
this.app.use(this.devServer.middlewares);
|
||||
}
|
||||
|
||||
if (!args.dev) {
|
||||
if (!this.devServer) {
|
||||
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
|
||||
|
||||
let manifest = await Controller.loadJsonObject(manifestPath);
|
||||
@@ -489,6 +495,7 @@ export default class Controller {
|
||||
logger.info(`Listening for HTTPS on port ${(this.httpsServer.address() as AddressInfo).port}`);
|
||||
}
|
||||
|
||||
this.hostPairing.start();
|
||||
this.onAutosaveIntervalChanged();
|
||||
this.onSystemMetricsIntervalChanged();
|
||||
|
||||
@@ -527,14 +534,14 @@ export default class Controller {
|
||||
},
|
||||
plugins: [webUiVite.clusterioDevPlugins(pluginDirs)],
|
||||
});
|
||||
if (args.dev) {
|
||||
this.app.locals.webBundle = {
|
||||
main: this.devServer.config.plugins.find(p => p.name === "clusterio:app")!.api.entry,
|
||||
imports: Object.fromEntries(webUiVite.sharedModules.map(
|
||||
name => [name, webUiVite.devSharedUrl(name)]
|
||||
)),
|
||||
};
|
||||
}
|
||||
// Plugins served from source import the shared modules from the development server, the web
|
||||
// interface has to use the same copies, so it is served from source as well with --dev-plugin.
|
||||
this.app.locals.webBundle = {
|
||||
main: this.devServer.config.plugins.find(p => p.name === "clusterio:app")!.api.entry,
|
||||
imports: Object.fromEntries(webUiVite.sharedModules.map(
|
||||
name => [name, webUiVite.devSharedUrl(name)]
|
||||
)),
|
||||
};
|
||||
this.app.locals.devServer = this.devServer;
|
||||
}
|
||||
|
||||
@@ -582,6 +589,8 @@ export default class Controller {
|
||||
this.autosaveInterval = undefined;
|
||||
}
|
||||
|
||||
this.hostPairing.stop();
|
||||
|
||||
if (this.clusterLogIndex) {
|
||||
await this.clusterLogIndex.save();
|
||||
}
|
||||
@@ -869,6 +878,10 @@ export default class Controller {
|
||||
|
||||
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
|
||||
// migrate: host token permissions were removed with host pairing in 2.1
|
||||
const removedPermissions = new Set([
|
||||
"core.host.generate_token", "core.host.revoke_token", "core.host.create_config",
|
||||
]);
|
||||
return serialized.map(json => {
|
||||
json.permissions = json.permissions.map(permission => {
|
||||
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
|
||||
@@ -876,7 +889,7 @@ export default class Controller {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
});
|
||||
}).filter(permission => !removedPermissions.has(permission));
|
||||
return lib.Role.pluginMigrations.reduce(json);
|
||||
});
|
||||
}
|
||||
@@ -1012,11 +1025,49 @@ export default class Controller {
|
||||
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a host token
|
||||
*
|
||||
* @param token - Host token to check.
|
||||
* @returns ID of the host the token was issued to.
|
||||
* @throws if the token is invalid, its host is not registered or it was issued before tokenValidAfter.
|
||||
*/
|
||||
verifyHostToken(token: string): number {
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const payload = jwt.verify(token, this.authSecret, { audience: ["host", "slave"] });
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
const hostId = payload.host !== undefined ? payload.host : payload.slave;
|
||||
const host = this.hosts.get(hostId);
|
||||
if (!host) {
|
||||
throw new Error("host is not registered, start the host again to request to join the cluster");
|
||||
}
|
||||
if (!payload.iat || payload.iat < host.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
return hostId;
|
||||
}
|
||||
|
||||
hostsUpdated(hosts: HostRecord[]) {
|
||||
let updates = hosts.map(host => host.toHostDetails());
|
||||
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
|
||||
}
|
||||
|
||||
hostPairingRequestsUpdated(requests: lib.HostPairingDetails[]) {
|
||||
this.subscriptions.broadcast(new lib.HostPairingUpdatesEvent(requests));
|
||||
}
|
||||
|
||||
async handleHostPairingSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const requests = [...this.hostPairing.records.values()].filter(
|
||||
pairing => pairing.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return requests.length ? new lib.HostPairingUpdatesEvent(requests) : null;
|
||||
}
|
||||
|
||||
async handleHostSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
|
||||
@@ -34,19 +34,16 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
this.plugins = new Map(Object.entries(registerData.plugins));
|
||||
|
||||
const previousHostInfo = this._controller.hosts.get(registerData.id);
|
||||
this.info = new HostRecord(
|
||||
registerData.id,
|
||||
previousHostInfo?.name ?? "",
|
||||
registerData.version,
|
||||
this.plugins,
|
||||
true,
|
||||
remoteAddress,
|
||||
previousHostInfo?.publicAddress,
|
||||
previousHostInfo?.tokenValidAfter,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
// Hosts are only created by approving them, WsServer rejects hosts without a record.
|
||||
const record = this._controller.hosts.getMutable(registerData.id);
|
||||
if (!record) {
|
||||
throw new Error(`Host ${registerData.id} is not registered`);
|
||||
}
|
||||
record.version = registerData.version;
|
||||
record.plugins = this.plugins;
|
||||
record.connected = true;
|
||||
record.remoteAddress = remoteAddress;
|
||||
this.info = record;
|
||||
this._controller.hosts.set(this.info);
|
||||
|
||||
this._checkPluginVersions();
|
||||
@@ -71,7 +68,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this._controller.instances.records.setMany(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
this._saveInfo();
|
||||
});
|
||||
|
||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||
@@ -175,10 +172,17 @@ export default class HostConnection extends BaseConnection {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Store changes to the host record, unless the host was revoked */
|
||||
private _saveInfo() {
|
||||
if (this._controller.hosts.getMutable(this.id) === this.info) {
|
||||
this._controller.hosts.set(this.info);
|
||||
}
|
||||
}
|
||||
|
||||
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
|
||||
this.info.name = event.update.name;
|
||||
this.info.publicAddress = event.update.publicAddress;
|
||||
this._controller.hosts.set(this.info);
|
||||
this._saveInfo();
|
||||
}
|
||||
|
||||
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import type Controller from "./Controller.js";
|
||||
|
||||
import crypto from "crypto";
|
||||
import net from "net";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import HostRecord from "./HostRecord.js";
|
||||
|
||||
/** Symbols used in pairing codes, avoids 0/O and 1/I/L */
|
||||
const codeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
const codeLength = 6;
|
||||
|
||||
/** Time a request is kept after the last poll from the host */
|
||||
export const pollTimeoutMs = 30e3;
|
||||
/** Maximum number of pending requests from one address */
|
||||
export const maxPendingPerAddress = 3;
|
||||
/** Maximum length of a host name */
|
||||
export const maxNameLength = 100;
|
||||
/** Control and bidirectional override characters refused in host names */
|
||||
const nameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
|
||||
|
||||
/** Result of an approved request, sent to the host */
|
||||
export interface HostPairingResult {
|
||||
hostId: number,
|
||||
token: string,
|
||||
name: string,
|
||||
}
|
||||
|
||||
/** Error with an HTTP status code to respond to the host with */
|
||||
export class HostPairingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
/** Seconds to wait before trying again, for status 429 */
|
||||
public retryAfter?: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type PairingRequest = {
|
||||
/** Secret the host polls with, never shown to users */
|
||||
secret: string,
|
||||
details: lib.HostPairingDetails,
|
||||
/** Address with IPv6 addresses grouped by /64, used for limits */
|
||||
addressKey: string,
|
||||
lastPollMs: number,
|
||||
} & (
|
||||
{ state: "pending" | "rejected" } | { state: "approved", result: HostPairingResult }
|
||||
);
|
||||
|
||||
/**
|
||||
* Strips the IPv4 mapped prefix and groups IPv6 addresses by their /64
|
||||
*
|
||||
* @param address - Address to make a key for
|
||||
* @returns key used to count requests from an address.
|
||||
*/
|
||||
export function addressKey(address: string) {
|
||||
if (address.startsWith("::ffff:") && net.isIPv4(address.slice(7))) {
|
||||
return address.slice(7);
|
||||
}
|
||||
if (!net.isIPv6(address)) {
|
||||
return address;
|
||||
}
|
||||
const [head, tail = ""] = address.split("::");
|
||||
const headParts = head ? head.split(":") : [];
|
||||
const tailParts = tail ? tail.split(":") : [];
|
||||
const missing = 8 - headParts.length - tailParts.length;
|
||||
const parts = address.includes("::")
|
||||
? [...headParts, ...new Array(missing).fill("0"), ...tailParts]
|
||||
: headParts;
|
||||
return `${parts.slice(0, 4).map(part => part.toLowerCase().replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a pairing code typed by a user
|
||||
*
|
||||
* @param code - Code as typed, may contain dashes and lower case letters.
|
||||
* @returns code formatted as shown by the host.
|
||||
*/
|
||||
export function normaliseCode(code: string) {
|
||||
const plain = code.toUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||
return `${plain.slice(0, 3)}-${plain.slice(3)}`;
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
let code = "";
|
||||
for (let i = 0; i < codeLength; i++) {
|
||||
code += codeAlphabet[crypto.randomInt(codeAlphabet.length)];
|
||||
}
|
||||
return normaliseCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track of requests from hosts to join the cluster
|
||||
*
|
||||
* Requests only live in memory, and only while the host keeps polling
|
||||
* for the result.
|
||||
*/
|
||||
export default class HostPairingManager {
|
||||
/** Pending requests by code, broadcast to subscribed control links */
|
||||
records = new lib.SubscribableDatastore<lib.HostPairingDetails>();
|
||||
private _bySecret = new Map<string, PairingRequest>();
|
||||
private _byCode = new Map<string, PairingRequest>();
|
||||
private _pruneInterval?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private _controller: Controller,
|
||||
) { }
|
||||
|
||||
start() {
|
||||
this._pruneInterval = setInterval(() => this.prune(), 5e3);
|
||||
this._pruneInterval.unref();
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this._pruneInterval);
|
||||
this._pruneInterval = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and trim a host name given by a host or a user
|
||||
*
|
||||
* @param name - Name to check.
|
||||
* @returns the trimmed name.
|
||||
*/
|
||||
static validateName(name: unknown) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
throw new HostPairingError("Host name is required", 400);
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length > maxNameLength) {
|
||||
throw new HostPairingError(`Host name must be at most ${maxNameLength} characters`, 400);
|
||||
}
|
||||
// Names come from unauthenticated requests and are printed to terminals and logs.
|
||||
if (nameControlCharacters.test(trimmed)) {
|
||||
throw new HostPairingError("Host name may not contain control characters", 400);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an approved host with a new id and token
|
||||
*
|
||||
* @param name - Name of the host.
|
||||
* @returns the id, token and name of the new host.
|
||||
*/
|
||||
createHost(name: string): HostPairingResult {
|
||||
const hosts = this._controller.hosts;
|
||||
let hostId;
|
||||
do {
|
||||
hostId = crypto.randomInt(1, 2 ** 31);
|
||||
} while (hosts.has(hostId));
|
||||
|
||||
hosts.set(new HostRecord(
|
||||
hostId, name, "", new Map(), false, "", "", Math.floor(Date.now() / 1000),
|
||||
));
|
||||
return { hostId, token: this._controller.generateHostToken(hostId), name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pending request
|
||||
*
|
||||
* @param name - Name the host asked for.
|
||||
* @param version - Version of the host.
|
||||
* @param remoteAddress - Address the request came from.
|
||||
* @returns the secret to poll with and the code to show the user.
|
||||
*/
|
||||
create(name: string, version: string, remoteAddress: string) {
|
||||
this.prune();
|
||||
const key = addressKey(remoteAddress);
|
||||
const now = Date.now();
|
||||
const pendingFromAddress = [...this._bySecret.values()].filter(
|
||||
request => request.addressKey === key && request.state === "pending"
|
||||
).length;
|
||||
if (pendingFromAddress >= maxPendingPerAddress) {
|
||||
throw new HostPairingError("Too many pending requests from this address", 429, 30);
|
||||
}
|
||||
const maxPending = this._controller.config.get("controller.host_pairing_max_pending");
|
||||
if (maxPending && this.records.size >= maxPending) {
|
||||
throw new HostPairingError("Too many pending requests", 429, 30);
|
||||
}
|
||||
|
||||
let code;
|
||||
do {
|
||||
code = generateCode();
|
||||
} while (this._byCode.has(code));
|
||||
|
||||
const request: PairingRequest = {
|
||||
secret: crypto.randomBytes(32).toString("base64url"),
|
||||
details: new lib.HostPairingDetails(code, name, remoteAddress, version, now),
|
||||
addressKey: key,
|
||||
lastPollMs: now,
|
||||
state: "pending",
|
||||
};
|
||||
this._bySecret.set(request.secret, request);
|
||||
this._byCode.set(code, request);
|
||||
this.records.set(request.details);
|
||||
logger.verbose(`Host pairing request ${code} for "${name}" from ${remoteAddress}`);
|
||||
return { requestId: request.secret, code };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for the result of a request
|
||||
*
|
||||
* @param secret - Request id returned when the request was created.
|
||||
* @returns the result if the request was approved, or null if still pending.
|
||||
*/
|
||||
poll(secret: string): HostPairingResult | null {
|
||||
this.prune();
|
||||
const request = this._bySecret.get(secret);
|
||||
if (!request) {
|
||||
throw new HostPairingError("Request not found", 404);
|
||||
}
|
||||
request.lastPollMs = Date.now();
|
||||
if (request.state === "approved") {
|
||||
this._remove(request);
|
||||
return request.result;
|
||||
}
|
||||
if (request.state === "rejected") {
|
||||
this._remove(request);
|
||||
throw new HostPairingError("Request was rejected", 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending request
|
||||
*
|
||||
* @param secret - Request id returned when the request was created.
|
||||
*/
|
||||
cancel(secret: string) {
|
||||
const request = this._bySecret.get(secret);
|
||||
if (request && request.state === "pending") {
|
||||
logger.verbose(`Host pairing request ${request.details.code} cancelled by the host`);
|
||||
this._remove(request);
|
||||
}
|
||||
}
|
||||
|
||||
private _pending(code: string) {
|
||||
this.prune();
|
||||
const request = this._byCode.get(normaliseCode(code));
|
||||
if (!request || request.state !== "pending") {
|
||||
throw new lib.NotFoundError(`No pending host request with code ${code}`);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending request, creating the host
|
||||
*
|
||||
* @param code - Code of the request.
|
||||
* @param name - Name to give the host, defaults to the requested name.
|
||||
* @param approvedBy - Name of the user approving it, for logging.
|
||||
* @returns the id of the new host.
|
||||
*/
|
||||
approve(code: string, name: string | undefined, approvedBy: string) {
|
||||
const request = this._pending(code);
|
||||
let hostName;
|
||||
try {
|
||||
hostName = name === undefined ? request.details.name : HostPairingManager.validateName(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.ValidationError(err.message);
|
||||
}
|
||||
|
||||
const result = this.createHost(hostName);
|
||||
Object.assign(request, { state: "approved", result });
|
||||
this.records.delete(request.details);
|
||||
logger.audit(
|
||||
`User ${approvedBy} approved host ${result.name} (${result.hostId}) with code ` +
|
||||
`${request.details.code} from ${request.details.remoteAddress}`
|
||||
);
|
||||
return result.hostId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a pending request
|
||||
*
|
||||
* @param code - Code of the request.
|
||||
* @param rejectedBy - Name of the user rejecting it, for logging.
|
||||
*/
|
||||
reject(code: string, rejectedBy: string) {
|
||||
const request = this._pending(code);
|
||||
request.state = "rejected";
|
||||
this.records.delete(request.details);
|
||||
logger.audit(
|
||||
`User ${rejectedBy} rejected host ${request.details.name} with code ` +
|
||||
`${request.details.code} from ${request.details.remoteAddress}`
|
||||
);
|
||||
}
|
||||
|
||||
private _remove(request: PairingRequest) {
|
||||
this._bySecret.delete(request.secret);
|
||||
this._byCode.delete(request.details.code);
|
||||
this.records.delete(request.details);
|
||||
}
|
||||
|
||||
/** Remove requests the host stopped polling for or that waited too long */
|
||||
prune() {
|
||||
const now = Date.now();
|
||||
const timeoutMs = this._controller.config.get("controller.host_pairing_timeout") * 1000;
|
||||
for (const request of this._bySecret.values()) {
|
||||
const timedOut = request.state === "pending" && timeoutMs > 0
|
||||
&& now - request.details.createdAtMs > timeoutMs;
|
||||
if (!timedOut && now - request.lastPollMs <= pollTimeoutMs) {
|
||||
continue;
|
||||
}
|
||||
if (request.state === "approved") {
|
||||
logger.warn(
|
||||
`Approved host ${request.result.name} (${request.result.hostId}) was never picked up ` +
|
||||
"by the host, revoke it on the hosts page if it is not needed"
|
||||
);
|
||||
}
|
||||
this._remove(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,21 +252,9 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
try {
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) {
|
||||
if (this.controller.verifyHostToken(data.token) !== data.id) {
|
||||
throw new Error("missmatched host id");
|
||||
}
|
||||
|
||||
let host = this.controller.hosts.get(data.id);
|
||||
if (!tokenPayload.iat || tokenPayload.iat < (host?.tokenValidAfter ?? 0)) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
@@ -303,6 +291,13 @@ ${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
// The host may have been revoked while waiting for the old connection to close.
|
||||
if (!this.controller.hosts.has(data.id)) {
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(lib.ConnectionClosed.Unauthorized, "Authentication failed: host was revoked");
|
||||
return;
|
||||
}
|
||||
|
||||
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
|
||||
|
||||
// Handle close before HostConnection does. This avoids a host
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import express, { type Application, type Request, type Response } from "express";
|
||||
import Controller from "./Controller.js";
|
||||
import HostPairingManager, { HostPairingError } from "./HostPairingManager.js";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
@@ -188,17 +189,7 @@ function validateHostToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let host = req.app.locals.controller.hosts.get(tokenPayload.host);
|
||||
if (!host) {
|
||||
throw new Error("invalid host");
|
||||
}
|
||||
if (!tokenPayload.iat || tokenPayload.iat < (host.tokenValidAfter ?? 0)) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
req.app.locals.controller.verifyHostToken(token);
|
||||
} catch (err) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
@@ -215,19 +206,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
res.locals.user = user;
|
||||
|
||||
res.locals.user = userFromToken(req.app.locals.controller, token);
|
||||
} catch (err) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
@@ -236,6 +215,121 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
next();
|
||||
}
|
||||
|
||||
function userFromToken(controller: Controller, token: string) {
|
||||
const tokenPayload = jwt.verify(token, controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let user = controller.users.getByName(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// Responds with the status of a pairing error, returns false for other errors.
|
||||
function sendHostPairingError(res: Response, err: unknown) {
|
||||
if (!(err instanceof HostPairingError)) {
|
||||
return false;
|
||||
}
|
||||
if (err.retryAfter !== undefined) {
|
||||
res.set("Retry-After", String(err.retryAfter));
|
||||
}
|
||||
res.status(err.status).json({ error: err.message });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks recovery mode and the request id for the pairing endpoints, returns false if responded to.
|
||||
function checkHostPairingRequest(req: Request, res: Response, needsRequestId: boolean) {
|
||||
if (req.app.locals.controller.recoveryMode) {
|
||||
res.status(503).json({ error: "Controller is in recovery mode" });
|
||||
return false;
|
||||
}
|
||||
if (needsRequestId && typeof req.body?.requestId !== "string") {
|
||||
res.status(400).json({ error: "requestId is required" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Request from a host for a host to join the cluster.
|
||||
function createHostPairing(req: Request, res: Response) {
|
||||
if (!checkHostPairingRequest(req, res, false)) {
|
||||
return;
|
||||
}
|
||||
const controller = req.app.locals.controller;
|
||||
const remoteAddress = controller.wsServer.remoteAddr(req);
|
||||
try {
|
||||
const name = HostPairingManager.validateName(req.body?.name);
|
||||
const version = typeof req.body?.version === "string" ? req.body.version.slice(0, 64) : "";
|
||||
|
||||
// A user allowed to approve hosts can skip the wait by passing their token.
|
||||
const token = getAccessToken(req);
|
||||
if (token !== undefined) {
|
||||
let user;
|
||||
try {
|
||||
user = userFromToken(controller, token);
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: "Invalid user token" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
user.checkPermission("core.host.approve");
|
||||
} catch (err) {
|
||||
logger.audit(
|
||||
`User ${user.name} tried to approve host ${name} from ${remoteAddress} without permission`
|
||||
);
|
||||
res.status(403).json({ error: `User ${user.name} is not allowed to approve hosts` });
|
||||
return;
|
||||
}
|
||||
const result = controller.hostPairing.createHost(name);
|
||||
logger.audit(
|
||||
`User ${user.name} approved host ${result.name} (${result.hostId}) from ${remoteAddress} ` +
|
||||
"with their token"
|
||||
);
|
||||
res.status(200).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(202).json(controller.hostPairing.create(name, version, remoteAddress));
|
||||
} catch (err) {
|
||||
if (!sendHostPairingError(res, err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll from a host for the result of its request.
|
||||
function pollHostPairing(req: Request, res: Response) {
|
||||
if (!checkHostPairingRequest(req, res, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = req.app.locals.controller.hostPairing.poll(req.body.requestId);
|
||||
if (result) {
|
||||
res.status(200).json(result);
|
||||
} else {
|
||||
res.status(202).json({ status: "pending" });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!sendHostPairingError(res, err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel from a host that stopped waiting.
|
||||
function cancelHostPairing(req: Request, res: Response) {
|
||||
if (!checkHostPairingRequest(req, res, true)) {
|
||||
return;
|
||||
}
|
||||
req.app.locals.controller.hostPairing.cancel(req.body.requestId);
|
||||
res.sendStatus(204);
|
||||
}
|
||||
|
||||
// Handle an uploaded export package.
|
||||
async function uploadExport(req: Request, res: Response) {
|
||||
if (req.get("Content-Type") !== "application/zip") {
|
||||
@@ -686,6 +780,18 @@ export function addRouteHandlers(app: Application) {
|
||||
);
|
||||
app.get("/api/plugins", getPlugins);
|
||||
app.get("/api/cluster-name", getClusterName);
|
||||
// Set before parsing so errors from the body parser and token check are not cached either.
|
||||
const noStore = (req: Request, res: Response, next: any) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
next();
|
||||
};
|
||||
const pairingJson = express.json({ limit: "4kb" });
|
||||
app.post("/api/host-pairing", noStore, pairingJson, createHostPairing);
|
||||
app.post("/api/host-pairing/poll", noStore, pairingJson, pollHostPairing);
|
||||
app.post("/api/host-pairing/cancel", noStore, pairingJson, cancelHostPairing);
|
||||
app.get("/api/host-pairing/check", noStore, validateHostToken, (req: Request, res: Response) => {
|
||||
res.json({ valid: true });
|
||||
});
|
||||
app.put("/api/upload-export",
|
||||
validateHostToken,
|
||||
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This needs to be a separate file due to ECMAScript Modules evaluating code in
|
||||
// imported modules before any code in the file that imports it.
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -4,3 +4,113 @@ Installer for Clusterio.
|
||||
|
||||
Install the latest [Node.js LTS release](https://nodejs.org/en/download) before running the installer.
|
||||
See [the Clusterio installation guide](https://github.com/clusterio/clusterio#installation) for platform-specific instructions.
|
||||
|
||||
Run the installer in a new empty directory:
|
||||
|
||||
npm init "@clusterio"
|
||||
|
||||
It asks which components to install and how to set them up.
|
||||
Every question can be answered with an option instead, pass options to the installer after `--`:
|
||||
|
||||
npm init "@clusterio" -- --mode host --host-name my-host --controller-url https://example.com/
|
||||
|
||||
The installer runs without prompts when all the questions for the mode are answered with options.
|
||||
|
||||
|
||||
## Modes
|
||||
|
||||
- `standalone`: controller and host on this computer. The host is approved like any other host the first time it starts.
|
||||
- `controller`: controller only.
|
||||
- `host`: host only, joins the cluster of an existing controller, see [Setting up a host](#setting-up-a-host).
|
||||
- `ctl`: the clusterioctl command line interface only.
|
||||
- `plugins`: plugins only, for adding plugins to an existing installation.
|
||||
- `plugin-template`: files for writing a new plugin.
|
||||
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Modes | Description |
|
||||
| --- | --- | --- |
|
||||
| `--mode <mode>` | all | Operating mode to install. |
|
||||
| `--admin <name>` | controller | Name of the admin account to create. |
|
||||
| `--http-port <port>` | 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>` | ctl | Authentication token saved for clusterioctl. |
|
||||
| `--public-address <address>` | host | DNS name or IP address players connect to this computer with. |
|
||||
| `--factorio-dir <path>` | host | Path to the Factorio installation. |
|
||||
| `--self-signed-cert <path>` | host | Certificate to trust for a controller using a self-signed certificate. It is added to the run scripts. |
|
||||
| `--remote-npm`, `--no-remote-npm` | controller, host | Allow or deny remote updates, plugin updates and plugin installs from the web interface. |
|
||||
| `--plugins <package...>` | all | Plugins to install, pass `--plugins` without packages to install none. |
|
||||
| `--plugin-template <template...>`, `--plugin-name <name>` | plugin-template | Templates to use and name of the new plugin. |
|
||||
| `--log-level <level>` | all | Log level to print, defaults to info. |
|
||||
| `--allow-install-as-root` | all | Linux only, allow installing as root (not recommended). |
|
||||
| `--migrate-rename` | | Migrate an installation from before the slave/master rename. |
|
||||
|
||||
Options for the `controller` and `host` modes also work in the `standalone` mode, except for `--controller-url` as the host connects to the controller on the same computer.
|
||||
`--host-name` defaults to `local` in the `standalone` mode.
|
||||
|
||||
|
||||
## Setting up a host
|
||||
|
||||
A new host has to be approved before it can join the cluster:
|
||||
|
||||
1. Run the installer with the host mode on the computer for the host.
|
||||
It asks for a name for the host, the controller URL, the public address, the Factorio installation, whether to allow remote updates and which plugins to install.
|
||||
|
||||
2. 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 of "my-host" with code ABC-DEF, open this link to approve it:
|
||||
https://example.com/hosts?approve=ABC-DEF
|
||||
Or run: npx clusterioctl host approve ABC-DEF
|
||||
|
||||
3. Open the link to approve the request in the web interface, where its name can be changed first.
|
||||
It can also be approved under Add host on the Hosts page, or with `npx clusterioctl host approve ABC-DEF`.
|
||||
Only approve a request if its code matches the one shown by the host.
|
||||
Approving requires the `core.host.approve` permission.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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 requests to join the cluster again, and the host gets a new id once approved.
|
||||
|
||||
To move a host to another controller, change `host.controller_url` with `npx clusteriohost config set host.controller_url <url>`.
|
||||
The new controller does not accept the token of the host, so the host requests to join it 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.
|
||||
The host requests to join the cluster the first time the container starts.
|
||||
|
||||
```dockerfile
|
||||
FROM node:22
|
||||
# Install the Factorio headless server to /opt/factorio here
|
||||
RUN mkdir /clusterio && chown node:node /clusterio
|
||||
USER node
|
||||
WORKDIR /clusterio
|
||||
RUN npm init "@clusterio" -- --mode host --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 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 my-host-image
|
||||
docker logs my-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 `USER_TOKEN` to approve the host without waiting.
|
||||
- Stop the container after revoking its host, otherwise a restart requests to join again.
|
||||
+167
-188
@@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import checkbox from "@inquirer/checkbox";
|
||||
import confirm from "@inquirer/confirm";
|
||||
import input from "@inquirer/input";
|
||||
import select from "@inquirer/select";
|
||||
import child_process from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import inquirer from "inquirer";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import stream from "node:stream";
|
||||
import util from "node:util";
|
||||
import yargs from "yargs";
|
||||
@@ -16,15 +20,13 @@ const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
import { escapeArg } from "./escape_arg.js";
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
||||
// One option is to not call rl.close: SBoudrias/Inquirer.js#767
|
||||
// But this comes with its own issues: SBoudrias/Inquirer.js#899
|
||||
// See nodejs/node#21771, nodejs/node#30701 and nodejs/node#31762
|
||||
// The solution below was adapted from aws-amplify/amplify-cli#12347
|
||||
if (process.platform === "win32") {
|
||||
const _inquirer_close = inquirer.ui.Prompt.prototype.close;
|
||||
inquirer.ui.Prompt.prototype.close = function() {
|
||||
this.rl.terminal = false;
|
||||
_inquirer_close.call(this);
|
||||
const close = readline.Interface.prototype.close;
|
||||
readline.Interface.prototype.close = function() {
|
||||
this.terminal = false;
|
||||
close.call(this);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,21 +163,48 @@ async function execCtl(args) {
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
|
||||
function validateHostToken(token) {
|
||||
let parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
// Same characters as the controller refuses in host names.
|
||||
const hostNameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
|
||||
|
||||
let parsed;
|
||||
function validateHostName(value) {
|
||||
if (!value || !value.trim()) {
|
||||
return "Host name may not be empty";
|
||||
}
|
||||
if (value.trim().length > 100) {
|
||||
return "Host name may be at most 100 characters";
|
||||
}
|
||||
if (hostNameControlCharacters.test(value)) {
|
||||
return "Host name may not contain control characters";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateControllerUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
|
||||
url = new URL(value);
|
||||
} catch (err) {
|
||||
throw new InstallError("Invalid token");
|
||||
return "Controller URL must be a URL like https://example.com/";
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return "Controller URL must start with http:// or https://";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parsed.aud !== "host" || !Number.isInteger(parsed.host)) {
|
||||
throw new InstallError("Invalid token");
|
||||
// The host requires the controller URL to end with a slash.
|
||||
function normaliseControllerUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (!url.pathname.endsWith("/")) {
|
||||
url.pathname += "/";
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
// Remote updates, plugin updates and plugin installs are allowed or denied together.
|
||||
async function setRemoteUpdates(exec, component, allow) {
|
||||
for (let field of ["allow_remote_updates", "allow_plugin_updates", "allow_plugin_install"]) {
|
||||
await exec(["config", "set", `${component}.${field}`, allow]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,96 +539,87 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
async function inquirerMissingArgs(args) {
|
||||
async function promptMissingArgs(args) {
|
||||
let answers = {};
|
||||
if (args.mode) { answers.mode = args.mode; }
|
||||
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "mode",
|
||||
message: "Operating mode to install",
|
||||
default: "standalone",
|
||||
choices: [
|
||||
{ name: "Standalone (install both controller and host on this computer)", value: "standalone" },
|
||||
{ name: "Controller only", value: "controller" },
|
||||
{ name: "Host only", value: "host" },
|
||||
{ name: "Ctl only", value: "ctl" },
|
||||
{ name: "Plugins only", value: "plugins" },
|
||||
{ name: "Plugin template", value: "plugin-template" },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
answers.mode ??= await select({
|
||||
message: "Operating mode to install",
|
||||
default: "standalone",
|
||||
choices: [
|
||||
{ name: "Standalone (install both controller and host on this computer)", value: "standalone" },
|
||||
{ name: "Controller only", value: "controller" },
|
||||
{ name: "Host only", value: "host" },
|
||||
{ name: "Ctl only", value: "ctl" },
|
||||
{ name: "Plugins only", value: "plugins" },
|
||||
{ name: "Plugin template", value: "plugin-template" },
|
||||
],
|
||||
});
|
||||
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
if (args.admin) { answers.admin = args.admin; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "admin",
|
||||
message: "Admin account name",
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
answers.admin ??= await input({
|
||||
message: "Admin account name",
|
||||
validate: value => {
|
||||
if (!value) {
|
||||
return "May not be empty";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
], answers);
|
||||
});
|
||||
|
||||
if (args.httpPort) { answers.httpPort = args.httpPort; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "httpPort",
|
||||
message: "HTTP port to listen on",
|
||||
default: 8080,
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
if (!Number.isInteger(Number(input))) {
|
||||
return "Must be a number";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
answers.httpPort ??= await input({
|
||||
message: "HTTP port to listen on",
|
||||
default: "8080",
|
||||
validate: value => {
|
||||
if (!value) {
|
||||
return "May not be empty";
|
||||
}
|
||||
if (!Number.isInteger(Number(value))) {
|
||||
return "Must be a number";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
], answers);
|
||||
});
|
||||
}
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
answers.hostName = args.hostName ?? "local";
|
||||
const result = validateHostName(answers.hostName);
|
||||
if (result !== true) {
|
||||
throw new InstallError(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
if (args.hostName) { answers.hostName = args.hostName; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "hostName",
|
||||
message: "Name of host",
|
||||
},
|
||||
], answers);
|
||||
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
||||
answers.hostName ??= await input({
|
||||
message: "Name of host",
|
||||
default: os.hostname(),
|
||||
validate: validateHostName,
|
||||
});
|
||||
answers.controllerUrl ??= await input({
|
||||
message: "Controller URL",
|
||||
validate: validateControllerUrl,
|
||||
});
|
||||
|
||||
// The prompts do 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 (["host", "ctl"].includes(answers.mode)) {
|
||||
if (answers.mode === "ctl") {
|
||||
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "controllerUrl",
|
||||
message: "Controller URL",
|
||||
},
|
||||
], answers);
|
||||
answers.controllerUrl ??= await input({ message: "Controller URL" });
|
||||
|
||||
if (args.controllerToken) { answers.controllerToken = args.controllerToken; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "controllerToken",
|
||||
message: "Controller authentication Token",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
validateHostToken(answers.controllerToken);
|
||||
answers.controllerToken ??= await input({ message: "Controller authentication Token" });
|
||||
}
|
||||
|
||||
if (["standalone", "host"].includes(answers.mode)) {
|
||||
@@ -612,14 +632,10 @@ async function inquirerMissingArgs(args) {
|
||||
myIp = await result.text();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "publicAddress",
|
||||
message: "Public DNS/IP address of this server",
|
||||
default: myIp,
|
||||
},
|
||||
], answers);
|
||||
answers.publicAddress ??= await input({
|
||||
message: "Public DNS/IP address of this server",
|
||||
default: myIp,
|
||||
});
|
||||
|
||||
if (args.factorioDir) { answers.factorioDir = args.factorioDir; }
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
@@ -630,28 +646,17 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
choices: [
|
||||
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
|
||||
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
|
||||
{ name: "Provide path manually", value: null },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
answers.factorioDir ??= await select({
|
||||
message: "Path to Factorio installation",
|
||||
choices: [
|
||||
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
|
||||
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
|
||||
{ name: "Provide path manually", value: null },
|
||||
],
|
||||
});
|
||||
|
||||
if (answers.factorioDir === null) {
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
askAnswered: true,
|
||||
},
|
||||
], answers);
|
||||
answers.factorioDir = await input({ message: "Path to Factorio installation" });
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
}
|
||||
@@ -659,14 +664,10 @@ async function inquirerMissingArgs(args) {
|
||||
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "remoteNpm",
|
||||
message: "Allow remote updates via npm?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
answers.remoteNpm ??= await confirm({
|
||||
message: "Allow remote updates and plugin installs from the web interface?",
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (answers.mode === "plugin-template") {
|
||||
@@ -677,29 +678,23 @@ async function inquirerMissingArgs(args) {
|
||||
answers.pluginName = args["plugin-name"];
|
||||
}
|
||||
answers.plugins = [];
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "pluginTemplate",
|
||||
message: "Plugin templates to use",
|
||||
choices: [
|
||||
{ name: "Controller", value: "controller" },
|
||||
{ name: "Host", value: "host" },
|
||||
{ name: "Instance", value: "instance" },
|
||||
{ name: "Lua Module", value: "module" },
|
||||
{ name: "Command Line", value: "ctl" },
|
||||
{ name: "Web UI", value: "web" },
|
||||
{ name: "No Typescript", value: "js" },
|
||||
{ name: "No Config", value: "no_config" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "pluginName",
|
||||
message: "Name of your new plugin",
|
||||
default: path.basename(path.resolve()),
|
||||
},
|
||||
], answers);
|
||||
answers.pluginTemplate ??= await checkbox({
|
||||
message: "Plugin templates to use",
|
||||
choices: [
|
||||
{ name: "Controller", value: "controller" },
|
||||
{ name: "Host", value: "host" },
|
||||
{ name: "Instance", value: "instance" },
|
||||
{ name: "Lua Module", value: "module" },
|
||||
{ name: "Command Line", value: "ctl" },
|
||||
{ name: "Web UI", value: "web" },
|
||||
{ name: "No Typescript", value: "js" },
|
||||
{ name: "No Config", value: "no_config" },
|
||||
],
|
||||
});
|
||||
answers.pluginName ??= await input({
|
||||
message: "Name of your new plugin",
|
||||
default: path.basename(path.resolve()),
|
||||
});
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
@@ -707,15 +702,11 @@ async function inquirerMissingArgs(args) {
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "plugins",
|
||||
message: "Plugins to install",
|
||||
choices: availablePlugins,
|
||||
pageSize: 20,
|
||||
},
|
||||
], answers);
|
||||
answers.plugins ??= await checkbox({
|
||||
message: "Plugins to install",
|
||||
choices: availablePlugins,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
return answers;
|
||||
}
|
||||
@@ -743,13 +734,13 @@ async function main() {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
nargs: 1, describe: "Host name [standalone/host]", type: "string",
|
||||
})
|
||||
.option("controller-url", {
|
||||
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
|
||||
})
|
||||
.option("controller-token", {
|
||||
nargs: 1, describe: "Controller authentication token [host/ctl]", type: "string",
|
||||
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",
|
||||
@@ -761,7 +752,8 @@ async function main() {
|
||||
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
|
||||
})
|
||||
.option("remote-npm", {
|
||||
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
|
||||
nargs: 0, type: "boolean", description:
|
||||
"Allow remote updates and plugin installs from the web interface [standalone/controller/host]",
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
@@ -798,7 +790,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
let answers = await promptMissingArgs(args);
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (answers.pluginTemplate) {
|
||||
@@ -816,44 +808,28 @@ async function main() {
|
||||
await execController(["config", "create"]);
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
||||
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
|
||||
await setRemoteUpdates(execController, "controller", answers.remoteNpm);
|
||||
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
||||
adminToken = result.stdout.split("\n").slice(-2)[0];
|
||||
}
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
if (["standalone", "host"].includes(answers.mode)) {
|
||||
logger.info("Setting up host");
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.name", "local"]);
|
||||
|
||||
let result = await execHost(["config", "show", "host.id"]);
|
||||
let hostId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
|
||||
|
||||
result = await execController(["bootstrap", "generate-host-token", hostId]);
|
||||
let hostToken = result.stdout.split("\n").slice(-2)[0];
|
||||
|
||||
// Default to localhost on correct port for host in standalone mode
|
||||
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
|
||||
await execHost(["config", "set", "host.controller_token", hostToken]);
|
||||
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
||||
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
||||
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
||||
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
logger.info("Setting up host");
|
||||
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.id", hostId]);
|
||||
if (answers.mode === "standalone") {
|
||||
// Default to localhost on correct port for host in standalone mode
|
||||
answers.controllerUrl = `http://localhost:${answers.httpPort}/`;
|
||||
}
|
||||
|
||||
if (["standalone", "host"].includes(answers.mode)) {
|
||||
// The host requests to join the cluster when it is started.
|
||||
await execHost(["config", "set", "host.name", answers.hostName]);
|
||||
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
|
||||
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
|
||||
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
||||
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
||||
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
||||
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
||||
await setRemoteUpdates(execHost, "host", answers.remoteNpm);
|
||||
}
|
||||
|
||||
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
@@ -878,7 +854,10 @@ async function main() {
|
||||
if (import.meta.main) {
|
||||
main().catch(err => {
|
||||
process.exitCode = 1;
|
||||
if (err instanceof InstallError) {
|
||||
if (err.name === "ExitPromptError") {
|
||||
// Ctrl+C at a prompt
|
||||
process.exitCode = 130;
|
||||
} else if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
logger.fatal(`
|
||||
|
||||
@@ -28,12 +28,15 @@
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^5.2.8",
|
||||
"@inquirer/confirm": "^6.3.5",
|
||||
"@inquirer/input": "^5.1.9",
|
||||
"@inquirer/select": "^5.2.8",
|
||||
"chalk": "catalog:",
|
||||
"inquirer": "^8.2.7",
|
||||
"yargs": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"clusterioctl": "./dist/node/ctl.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,7 +5,6 @@ import os from "os";
|
||||
import child_process from "child_process";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
@@ -51,62 +50,78 @@ hostCommands.add(new lib.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
/** Format a duration in milliseconds as a short age like "1m 5s" */
|
||||
function formatAge(ms: number) {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m`;
|
||||
}
|
||||
|
||||
/** Strip case and separators the same way the controller does when matching pairing codes */
|
||||
function plainCode(code: string) {
|
||||
return code.toUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||
}
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["generate-token", "Generate token for a host", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
|
||||
}],
|
||||
handler: async function(args: { id?: number }, ctl: Ctl) {
|
||||
let hostId = typeof args.id === "number" ? args.id : undefined;
|
||||
let response = await ctl.send(new lib.HostGenerateTokenRequest(hostId));
|
||||
print(response);
|
||||
definition: ["pending", "List host installers waiting for approval"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let requests = await ctl.send(new lib.HostPairingListRequest());
|
||||
if (!requests.length) {
|
||||
print("No pending host requests");
|
||||
return;
|
||||
}
|
||||
let now = Date.now();
|
||||
print(asTable(requests.map(request => ({
|
||||
code: request.code,
|
||||
name: request.name,
|
||||
address: request.remoteAddress,
|
||||
age: formatAge(now - request.createdAtMs),
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
|
||||
definition: ["approve <code>", "Approve a host installer waiting for approval", (yargs) => {
|
||||
yargs.positional("code", { describe: "Code shown by the installer", type: "string" });
|
||||
yargs.option("name", {
|
||||
type: "string", nargs: 1, describe: "Name to give the host instead of the one it asked for",
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { code: string, name?: string }, ctl: Ctl) {
|
||||
let name = args.name?.trim();
|
||||
if (name === undefined) {
|
||||
let requests = await ctl.send(new lib.HostPairingListRequest());
|
||||
name = requests.find(request => plainCode(request.code) === plainCode(args.code))?.name;
|
||||
}
|
||||
let hostId = await ctl.send(new lib.HostPairingApproveRequest(args.code, args.name));
|
||||
print(name ? `Approved host ${name} with id ${hostId}` : `Approved host with id ${hostId}`);
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["reject <code>", "Reject a host installer waiting for approval", (yargs) => {
|
||||
yargs.positional("code", { describe: "Code shown by the installer", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { code: string }, ctl: Ctl) {
|
||||
await ctl.send(new lib.HostPairingRejectRequest(args.code));
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["revoke <host>", "Remove a host from the cluster and disconnect it", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to revoke", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.send(new lib.HostRevokeTokensRequest(hostId));
|
||||
await ctl.send(new lib.HostRevokeRequest(hostId));
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["create-config", "Create host config", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
|
||||
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
|
||||
yargs.option("generate-token", {
|
||||
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
|
||||
});
|
||||
yargs.option("output", {
|
||||
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { id?: number, name?: string, generateToken: boolean, output: string },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let config = await ctl.send(
|
||||
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
|
||||
);
|
||||
|
||||
let content = JSON.stringify(config, null, "\t");
|
||||
if (args.output === "-") {
|
||||
print(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new lib.CommandError(`File ${args.output} already exists`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["update <host>", "Update the host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to update", type: "string" });
|
||||
@@ -271,7 +286,7 @@ hostPluginCommands.add(new lib.Command({
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
|
||||
print(asTable(plugins.map(p => ({
|
||||
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
|
||||
title: p.title, version: p.version, state: p.state, npmPackage: p.npmPackage,
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -132,6 +132,10 @@ Lists up all configuration entries with their currently configured values.
|
||||
### `run`
|
||||
|
||||
Runs the host.
|
||||
A host that has not been approved prints a code and a link and waits for approval before it connects, see [Setting up a host](https://github.com/clusterio/clusterio/blob/master/packages/create/README.md#setting-up-a-host).
|
||||
If the controller no longer accepts the token of the host when it starts, for example because the host was revoked, it asks to be approved again.
|
||||
If its request is rejected it exits with code 8 and the run scripts created by the installer do not restart it.
|
||||
A host that is revoked while it runs shuts down.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
+41
-14
@@ -20,11 +20,14 @@ import winston from "winston";
|
||||
import "winston-daily-rotate-file";
|
||||
|
||||
// internal libraries
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
import "./src/set_lazy_schema_compilation.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Host from "./src/Host.js";
|
||||
import type 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 +137,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,19 +219,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(
|
||||
"Controller requires an access token for socket operations. As clusterio\n"+
|
||||
"hosts depends upon this, please set your token using the command npx\n"+
|
||||
"clusteriohost config set host.controller_token <token>. You can generate an\n"+
|
||||
"auth token using npx clusterioctl generate-host-token."
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure url ends with /
|
||||
if (!hostConfig.get("host.controller_url").endsWith("/")) {
|
||||
logger.fatal("ERROR invalid config!");
|
||||
@@ -233,6 +227,39 @@ async function startHost() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.recovery) {
|
||||
const abort = new AbortController();
|
||||
const onSignal = () => { abort.abort(); };
|
||||
process.on("SIGINT", onSignal);
|
||||
process.on("SIGTERM", onSignal);
|
||||
process.on("SIGHUP", 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);
|
||||
process.off("SIGHUP", onSignal);
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded here as the server is not needed by the other commands
|
||||
const { default: Host } = await import("./src/Host.js");
|
||||
let hostConnector = new HostConnector(hostConfig, pluginInfos);
|
||||
host = await Host.fromConfig(
|
||||
hostConfig,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -33,13 +33,17 @@
|
||||
"clusteriohost": "./dist/node/host.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@jimp/core": "^1.6.1",
|
||||
"@jimp/js-png": "^1.6.1",
|
||||
"@jimp/plugin-crop": "^1.6.1",
|
||||
"@jimp/plugin-resize": "^1.6.1",
|
||||
"@jimp/types": "^1.6.1",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"jimp": "^1.6.1",
|
||||
"pidusage": "^4.1.0",
|
||||
"rcon-client": "^4.2.5",
|
||||
"semver": "catalog:",
|
||||
|
||||
@@ -1308,6 +1308,16 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the instance once a start, stop or save in progress has finished */
|
||||
async _stopWhenSettled() {
|
||||
// Stopping concurrently with these races them and asserts on the server state.
|
||||
// Checked right before stop() as another waiter may have started stopping.
|
||||
while (transitoryStatuses.has(this._status)) {
|
||||
await this._waitForSettledStatus();
|
||||
}
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async kill() {
|
||||
if (this._status === "stopped") {
|
||||
return;
|
||||
@@ -1494,7 +1504,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
|
||||
await this.stop();
|
||||
await this._stopWhenSettled();
|
||||
// Stopping invalidates this instance object, a new one must be created by the host
|
||||
// To do this we simulate a waking an instance and sending a start request
|
||||
const instanceConnection = await this._host._connectInstance(this.id);
|
||||
@@ -1502,7 +1512,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async handleInstanceStopRequest() {
|
||||
await this.stop();
|
||||
await this._stopWhenSettled();
|
||||
}
|
||||
|
||||
async handleInstanceKillRequest() {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Export of item icons and locale
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import { Jimp, JimpMime, type JimpInstance } from "jimp";
|
||||
import { createJimp } from "@jimp/core";
|
||||
import png from "@jimp/js-png";
|
||||
import * as crop from "@jimp/plugin-crop";
|
||||
import * as resize from "@jimp/plugin-resize";
|
||||
import type {} from "@jimp/types"; // needed to name jimp types in export.d.ts
|
||||
import yazl from "yazl";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -160,7 +164,9 @@ async function loadFile(
|
||||
return await modZip.zip.readFile(path.posix.join(modZip.root, filePath));
|
||||
}
|
||||
|
||||
type Image = JimpInstance;
|
||||
// Only the parts of jimp the export uses
|
||||
const Jimp = createJimp({ formats: [png], plugins: [crop.methods, resize.methods] });
|
||||
type Image = InstanceType<typeof Jimp>;
|
||||
type IconCache = Map<string, Image | null>;
|
||||
async function loadIcon(
|
||||
server: FactorioServer,
|
||||
@@ -423,7 +429,13 @@ async function exportIcons(
|
||||
state.metadata[baseName] ??= metadata;
|
||||
let count = 0;
|
||||
for (let prototype of prototypes) {
|
||||
const icon = await exportIcon(server, modVersions, files, state, prototype);
|
||||
let icon: lib.ExportMetadataEntry["icon"];
|
||||
try {
|
||||
icon = await exportIcon(server, modVersions, files, state, prototype);
|
||||
} catch (err: any) {
|
||||
// One broken icon should not stop the rest of the export
|
||||
server._logger.warn(`Skipping icon of ${prototype.type} ${prototype.name}: ${err.message}`);
|
||||
}
|
||||
if (icon) {
|
||||
count += 1;
|
||||
}
|
||||
@@ -494,6 +506,7 @@ async function exportLocale(
|
||||
modZip = await loadZip(server, modVersions, zipCache, mod);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
server._logger.warn(`Skipping locale of ${mod}, its mod zip was not found`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
@@ -673,7 +686,7 @@ async function buildExport(server: FactorioServer, files: ExportFiles) {
|
||||
// Crop sheet to actual used height and write single spritesheet + metadata.
|
||||
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
|
||||
zip.addBuffer(await state.sheet.getBuffer(JimpMime.png), "export/spritesheet.png");
|
||||
zip.addBuffer(await state.sheet.getBuffer("image/png"), "export/spritesheet.png");
|
||||
addJson("export/metadata.json", state.metadata satisfies lib.ExportMetadata);
|
||||
zip.end();
|
||||
|
||||
@@ -684,6 +697,8 @@ async function buildExport(server: FactorioServer, files: ExportFiles) {
|
||||
|
||||
|
||||
// For testing only
|
||||
export const _createSheetState = createSheetState;
|
||||
export const _exportIcons = exportIcons;
|
||||
export const _exportLocale = exportLocale;
|
||||
export const _loadSimpleIcon = loadSimpleIcon;
|
||||
export const _loadLayeredIcon = loadLayeredIcon;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
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" | "retryDelayMs">> & PairHostOptions,
|
||||
) {
|
||||
let delayMs = options.pollIntervalMs;
|
||||
while (true) {
|
||||
await sleep(delayMs, undefined, { signal: options.signal });
|
||||
delayMs = options.pollIntervalMs;
|
||||
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.
|
||||
delayMs = options.retryDelayMs;
|
||||
options.onRetry?.(err, delayMs);
|
||||
continue;
|
||||
}
|
||||
if (response.status === 200) {
|
||||
return pairingResult(response);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (isTemporary(response.status)) {
|
||||
delayMs = retryDelay(response, options.retryDelayMs);
|
||||
options.onRetry?.(responseError(response), delayMs);
|
||||
continue;
|
||||
}
|
||||
if (response.status !== 202) {
|
||||
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.
|
||||
* @param signal - Signal to cancel the check with.
|
||||
* @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, signal?: AbortSignal) {
|
||||
const response = await request(controllerUrl, "api/host-pairing/check", { token, signal });
|
||||
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, options.signal);
|
||||
} catch (err: any) {
|
||||
if (options.signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
// 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 => {
|
||||
const approveUrl = new URL(`hosts?approve=${code}`, controllerUrl).href;
|
||||
// Logged as warnings for the systemd service, which only prints warnings and above.
|
||||
// The link is logged on its own so terminals can make it clickable without it wrapping.
|
||||
logger.warn(`Waiting for approval of "${name}" with code ${code}, open this link to approve it:`);
|
||||
logger.warn(approveUrl);
|
||||
logger.warn(`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}`);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// This needs to be a separate file due to ECMAScript Modules evaluating code in
|
||||
// imported modules before any code in the file that imports it.
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
"compile_validator": "node scripts/compile_validator.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface ControllerConfigFields {
|
||||
"controller.default_mod_pack_id": number | null;
|
||||
"controller.default_role_id": number | null;
|
||||
"controller.autosave_interval": number;
|
||||
"controller.host_pairing_timeout": number;
|
||||
"controller.host_pairing_max_pending": number;
|
||||
"controller.mod_portal_cache_duration_minutes": number;
|
||||
"controller.mod_portal_page_size": number;
|
||||
"controller.allow_remote_updates": boolean;
|
||||
@@ -98,6 +100,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.host_pairing_timeout": {
|
||||
title: "Host Pairing Timeout",
|
||||
description: "Time in seconds a host can wait for approval before it has to request again, 0 for no limit.",
|
||||
type: "number",
|
||||
initialValue: 3600,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
"controller.host_pairing_max_pending": {
|
||||
title: "Host Pairing Max Pending",
|
||||
description: "Maximum number of hosts that can wait for approval at the same time, 0 for no limit.",
|
||||
type: "number",
|
||||
initialValue: 50,
|
||||
validator: validators.all(validators.integer, validators.greaterThanEqualZero),
|
||||
},
|
||||
"controller.http_port": {
|
||||
title: "HTTP Port",
|
||||
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* A pending request from a host to join the cluster
|
||||
*
|
||||
* Requests are kept in memory on the controller only while the host
|
||||
* waits for them to be approved or rejected.
|
||||
*/
|
||||
export default class HostPairingDetails {
|
||||
constructor(
|
||||
/** Code shown by the host, used to identify the request. */
|
||||
public code: string,
|
||||
/** Name the host asked to have. */
|
||||
public name: string,
|
||||
/** Address the request was made from. */
|
||||
public remoteAddress: string,
|
||||
/** Version of the host that made the request. */
|
||||
public version = "",
|
||||
/** Millisecond Unix timestamp the request was made at. */
|
||||
public createdAtMs = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at. */
|
||||
public updatedAtMs = 0,
|
||||
/** True if this request is no longer pending. */
|
||||
public isDeleted = false,
|
||||
) { }
|
||||
|
||||
get id() {
|
||||
return this.code;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"code": Type.String(),
|
||||
"name": Type.String(),
|
||||
"remoteAddress": Type.String(),
|
||||
"version": Type.Optional(Type.String()),
|
||||
"createdAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"isDeleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.code,
|
||||
json.name,
|
||||
json.remoteAddress,
|
||||
json.version,
|
||||
json.createdAtMs,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json: Static<typeof HostPairingDetails.jsonSchema> = {
|
||||
code: this.code,
|
||||
name: this.name,
|
||||
remoteAddress: this.remoteAddress,
|
||||
};
|
||||
if (this.version !== "") {
|
||||
json.version = this.version;
|
||||
}
|
||||
if (this.createdAtMs !== 0) {
|
||||
json.createdAtMs = this.createdAtMs;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted) {
|
||||
json.isDeleted = this.isDeleted;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export { default as Role } from "./Role.js";
|
||||
export { default as Note } from "./Note.js";
|
||||
export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails.js";
|
||||
export { default as HostDetails } from "./HostDetails.js";
|
||||
export { default as HostPairingDetails } from "./HostPairingDetails.js";
|
||||
export { default as UserDetails } from "./UserDetails.js";
|
||||
export type { IUser } from "./UserDetails.js";
|
||||
export * from "./composites.js";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js";
|
||||
import { JsonNumber, StringEnum, StringKey, jsonArray, plainJson } from "./composites.js";
|
||||
import { type LogEntry, levels } from "../logging.js";
|
||||
import { ControllerConfig, HostConfig } from "../config/index.js";
|
||||
import { ControllerConfig } from "../config/index.js";
|
||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
|
||||
import { PluginDetails } from "./messages_plugin.js";
|
||||
import HostPairingDetails from "./HostPairingDetails.js";
|
||||
|
||||
const LogEntrySchema = Type.Object({
|
||||
"level": StringKey(levels),
|
||||
@@ -120,52 +121,81 @@ export class ControllerConfigSetPropRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class HostGenerateTokenRequest {
|
||||
declare ["constructor"]: typeof HostGenerateTokenRequest;
|
||||
export class HostPairingListRequest {
|
||||
declare ["constructor"]: typeof HostPairingListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.generate_token" as const;
|
||||
|
||||
constructor(
|
||||
public hostId?: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"hostId": Type.Optional(Type.Integer()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.hostId);
|
||||
}
|
||||
|
||||
static Response = JsonString;
|
||||
static permission = "core.host.approve" as const;
|
||||
static Response = jsonArray(HostPairingDetails);
|
||||
}
|
||||
|
||||
export class HostConfigCreateRequest {
|
||||
declare ["constructor"]: typeof HostConfigCreateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.create_config" as const;
|
||||
export class HostPairingUpdatesEvent {
|
||||
declare ["constructor"]: typeof HostPairingUpdatesEvent;
|
||||
static type = "event" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "control" as const;
|
||||
static permission = "core.host.approve" as const;
|
||||
|
||||
constructor(
|
||||
public id: number | undefined,
|
||||
public name: string | undefined,
|
||||
public generateToken: boolean,
|
||||
public updates: HostPairingDetails[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"id": Type.Optional(Type.Integer()),
|
||||
"name": Type.Optional(Type.String()),
|
||||
"generateToken": Type.Boolean(),
|
||||
"updates": Type.Array(HostPairingDetails.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id, json.name, json.generateToken);
|
||||
return new this(json.updates.map(update => HostPairingDetails.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
export class HostPairingApproveRequest {
|
||||
declare ["constructor"]: typeof HostPairingApproveRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.approve" as const;
|
||||
|
||||
constructor(
|
||||
/** Code of the pending request, case and dashes are ignored. */
|
||||
public code: string,
|
||||
/** Name to give the host instead of the one it asked for. */
|
||||
public name?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"code": Type.String(),
|
||||
"name": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.code, json.name);
|
||||
}
|
||||
|
||||
static Response = plainJson(HostConfig.jsonSchema);
|
||||
/** Id of the host created. */
|
||||
static Response = JsonNumber;
|
||||
}
|
||||
|
||||
export class HostPairingRejectRequest {
|
||||
declare ["constructor"]: typeof HostPairingRejectRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.approve" as const;
|
||||
|
||||
constructor(
|
||||
/** Code of the pending request, case and dashes are ignored. */
|
||||
public code: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"code": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.code);
|
||||
}
|
||||
}
|
||||
|
||||
export class GetFactorioCredentialsRequest {
|
||||
|
||||
@@ -259,12 +259,12 @@ export class SyncUserListsEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class HostRevokeTokensRequest {
|
||||
declare["constructor"]: typeof HostRevokeTokensRequest;
|
||||
export class HostRevokeRequest {
|
||||
declare ["constructor"]: typeof HostRevokeRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.revoke_token" as const;
|
||||
static permission = "core.host.revoke" as const;
|
||||
|
||||
constructor(
|
||||
public hostId: number,
|
||||
|
||||
@@ -4,57 +4,94 @@ import type { PluginNodeEnvInfo } from "../plugin.js";
|
||||
import { Address, MessageRequest } from "./messages_core.js";
|
||||
import type { IUser } from "./UserDetails.js";
|
||||
|
||||
export const pluginStateSchema = Type.Union([
|
||||
Type.Literal("loaded"),
|
||||
Type.Literal("disabled"),
|
||||
Type.Literal("unloadOnRestart"),
|
||||
Type.Literal("loadOnRestart"),
|
||||
Type.Literal("noEntrypoint"),
|
||||
Type.Literal("pendingInstall"),
|
||||
Type.Literal("removed"),
|
||||
Type.Literal("removedLoaded"),
|
||||
Type.Literal("nameTaken"),
|
||||
]);
|
||||
/**
|
||||
* State of a plugin on a machine
|
||||
*
|
||||
* - loaded: Running and loaded again on the next restart
|
||||
* - disabled: Not loaded as `<plugin>.load_plugin` is false
|
||||
* - unloadOnRestart: Running, but disabled since startup
|
||||
* - loadOnRestart: Not running, enabled since startup or skipped by recovery mode
|
||||
* - noEntrypoint: Has no entrypoint for this machine, so it is never loaded here
|
||||
* - pendingInstall: Installed since startup, loaded on the next restart
|
||||
* - removed: Uninstalled since startup while not running
|
||||
* - removedLoaded: Uninstalled since startup, running until the next restart
|
||||
* - nameTaken: Not loaded as another package provides a plugin with the same name
|
||||
*/
|
||||
export type PluginState = Static<typeof pluginStateSchema>;
|
||||
|
||||
/* This is similar to other plugin definitions but with lots removed */
|
||||
export class PluginDetails {
|
||||
constructor(
|
||||
public name: string,
|
||||
public title: string,
|
||||
public version: string,
|
||||
public loaded: boolean,
|
||||
public enabled: boolean,
|
||||
public state: PluginState,
|
||||
public description?: string,
|
||||
public npmPackage?: string,
|
||||
/** Version installed on disk, differs from version when an update is waiting for a restart */
|
||||
public installedVersion?: string,
|
||||
/** Installed after the machine started, will be loaded on the next restart */
|
||||
public pendingInstall?: boolean,
|
||||
/** Uninstalled after the machine started, will not be loaded on the next restart */
|
||||
public removed?: boolean,
|
||||
/** Name in the root package.json dependencies, differs from npmPackage for aliases */
|
||||
public dependencyName?: string,
|
||||
/** Has no entrypoint for this machine, so it is never loaded here */
|
||||
public noEntrypoint?: boolean,
|
||||
/** Not loaded as another package provides a plugin with the same name */
|
||||
public nameTaken?: boolean,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
title: Type.String(),
|
||||
version: Type.String(),
|
||||
// Missing from 2.0.3 and older, which require loaded and enabled instead
|
||||
state: Type.Optional(pluginStateSchema),
|
||||
loaded: Type.Boolean(),
|
||||
enabled: Type.Boolean(),
|
||||
description: Type.Optional(Type.String()),
|
||||
npmPackage: Type.Optional(Type.String()),
|
||||
installedVersion: Type.Optional(Type.String()),
|
||||
pendingInstall: Type.Optional(Type.Boolean()),
|
||||
removed: Type.Optional(Type.Boolean()),
|
||||
dependencyName: Type.Optional(Type.String()),
|
||||
noEntrypoint: Type.Optional(Type.Boolean()),
|
||||
nameTaken: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name, json.title, json.version, json.loaded, json.enabled,
|
||||
json.description, json.npmPackage, json.installedVersion, json.pendingInstall, json.removed,
|
||||
json.dependencyName, json.noEntrypoint, json.nameTaken,
|
||||
json.name, json.title, json.version, json.state ?? this.loadState(json.loaded, json.enabled),
|
||||
json.description, json.npmPackage, json.installedVersion, json.dependencyName,
|
||||
);
|
||||
}
|
||||
|
||||
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) {
|
||||
toJSON(): Static<typeof PluginDetails.jsonSchema> {
|
||||
return {
|
||||
name: this.name,
|
||||
title: this.title,
|
||||
version: this.version,
|
||||
state: this.state,
|
||||
loaded: this.loaded,
|
||||
enabled: this.loadsOnRestart,
|
||||
description: this.description,
|
||||
npmPackage: this.npmPackage,
|
||||
installedVersion: this.installedVersion,
|
||||
dependencyName: this.dependencyName,
|
||||
};
|
||||
}
|
||||
|
||||
/** State of a plugin known since startup */
|
||||
static loadState(loaded: boolean, enabled: boolean): PluginState {
|
||||
if (loaded) {
|
||||
return enabled ? "loaded" : "unloadOnRestart";
|
||||
}
|
||||
return enabled ? "loadOnRestart" : "disabled";
|
||||
}
|
||||
|
||||
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, state: PluginState) {
|
||||
return new this(
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled,
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, state,
|
||||
pluginInfo.description, pluginInfo.npmPackage,
|
||||
);
|
||||
}
|
||||
@@ -63,10 +100,25 @@ export class PluginDetails {
|
||||
static fromPackageJson(packageName: string, packageJson: any, npmPackage?: string, dependencyName?: string) {
|
||||
const description = typeof packageJson.description === "string" ? packageJson.description : undefined;
|
||||
return new this(
|
||||
packageName, packageName, packageJson.version, false, false,
|
||||
description, npmPackage, packageJson.version, true, undefined, dependencyName,
|
||||
packageName, packageName, packageJson.version, "pendingInstall",
|
||||
description, npmPackage, packageJson.version, dependencyName,
|
||||
);
|
||||
}
|
||||
|
||||
/** Running on the machine now */
|
||||
get loaded() {
|
||||
return ["loaded", "unloadOnRestart", "removedLoaded"].includes(this.state);
|
||||
}
|
||||
|
||||
/** Running on the machine after the next restart */
|
||||
get loadsOnRestart() {
|
||||
return ["loaded", "loadOnRestart", "pendingInstall"].includes(this.state);
|
||||
}
|
||||
|
||||
/** Uninstalled since startup */
|
||||
get removed() {
|
||||
return this.state === "removed" || this.state === "removedLoaded";
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginListRequest {
|
||||
|
||||
@@ -166,6 +166,7 @@ export abstract class Datastore<
|
||||
"update": [ updates: U[] ],
|
||||
}> {
|
||||
private dirty = false;
|
||||
private saving: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private provider: DatastoreProvider<K, V> = new MemoryDatastoreProvider(),
|
||||
@@ -181,10 +182,22 @@ export abstract class Datastore<
|
||||
|
||||
// Save the datastore to the provider
|
||||
async save() {
|
||||
if (this.dirty) {
|
||||
await this.provider.save(this.data);
|
||||
// Saves run one at a time so an older snapshot can't be written after a newer one.
|
||||
const save = this.saving.catch(() => {}).then(async () => {
|
||||
if (!this.dirty) {
|
||||
return;
|
||||
}
|
||||
// Cleared before writing so changes made while saving are saved by the next save.
|
||||
this.dirty = false;
|
||||
}
|
||||
try {
|
||||
await this.provider.save(this.data);
|
||||
} catch (err) {
|
||||
this.dirty = true;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
this.saving = save;
|
||||
await save;
|
||||
}
|
||||
|
||||
// Load the datastore from the provider
|
||||
|
||||
@@ -29,8 +29,10 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.ControllerConfigSetRequest,
|
||||
controller.ControllerConfigSetFieldRequest,
|
||||
controller.ControllerConfigSetPropRequest,
|
||||
controller.HostGenerateTokenRequest,
|
||||
controller.HostConfigCreateRequest,
|
||||
controller.HostPairingListRequest,
|
||||
controller.HostPairingUpdatesEvent,
|
||||
controller.HostPairingApproveRequest,
|
||||
controller.HostPairingRejectRequest,
|
||||
controller.GetFactorioCredentialsRequest,
|
||||
controller.LogSetSubscriptionsRequest,
|
||||
controller.LogQueryRequest,
|
||||
@@ -59,7 +61,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
host.ControllerConnectionEvent,
|
||||
host.PrepareControllerDisconnectRequest,
|
||||
host.SyncUserListsEvent,
|
||||
host.HostRevokeTokensRequest,
|
||||
host.HostRevokeRequest,
|
||||
|
||||
instance.InstanceDetailsGetRequest,
|
||||
instance.InstanceDetailsListRequest,
|
||||
|
||||
@@ -196,19 +196,16 @@ export const corePermissions = [
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "core.host.generate_token",
|
||||
title: "Generate host token",
|
||||
description: "Generate tokens for hosts to connect to the cluster with.",
|
||||
name: "core.host.approve",
|
||||
title: "Approve hosts",
|
||||
description:
|
||||
"List, approve and reject requests from hosts to join the cluster, and approve hosts " +
|
||||
"directly by starting them with a user token. Approving gives the new host full access to the cluster.",
|
||||
},
|
||||
{
|
||||
name: "core.host.revoke_token",
|
||||
title: "Revoke host token",
|
||||
description: "Revoke access token and terminate existing connection for hosts.",
|
||||
},
|
||||
{
|
||||
name: "core.host.create_config",
|
||||
title: "Create host config",
|
||||
description: "Create host configs via the controller.",
|
||||
name: "core.host.revoke",
|
||||
title: "Revoke hosts",
|
||||
description: "Remove hosts from the cluster, disconnecting them and making their access tokens invalid.",
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -128,14 +128,12 @@ export async function listPluginDetails(
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
const dir = await realDir(path.dirname(pluginInfo.packagePath));
|
||||
knownDirs.add(dir);
|
||||
const loaded = isLoaded(pluginInfo);
|
||||
// Without an entrypoint for this machine the plugin has no load_plugin field here
|
||||
const loadable = hasEntrypoint(pluginInfo);
|
||||
const plugin = PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo, isLoaded(pluginInfo), loadable && isEnabled(pluginInfo),
|
||||
pluginInfo,
|
||||
hasEntrypoint(pluginInfo) ? PluginDetails.loadState(loaded, isEnabled(pluginInfo)) : "noEntrypoint",
|
||||
);
|
||||
if (!loadable) {
|
||||
plugin.noEntrypoint = true;
|
||||
}
|
||||
if (Object.hasOwn(dependencies, pluginInfo.requirePath)) {
|
||||
plugin.dependencyName = pluginInfo.requirePath;
|
||||
}
|
||||
@@ -147,7 +145,7 @@ export async function listPluginDetails(
|
||||
packageJson = await readPackageJson(path.dirname(pluginInfo.packagePath), false);
|
||||
if (packageJson === undefined) {
|
||||
// Uninstalled since startup, it won't be found on the next restart
|
||||
plugin.removed = true;
|
||||
plugin.state = loaded ? "removedLoaded" : "removed";
|
||||
}
|
||||
} catch {
|
||||
// Ignored: npm is still writing it
|
||||
@@ -185,8 +183,7 @@ export async function listPluginDetails(
|
||||
const plugin = PluginDetails.fromPackageJson(packageName, packageJson, npmPackage, dependencyName);
|
||||
if (takenDirs.has(dir)) {
|
||||
// Skipped on startup as its plugin name is taken, will be again
|
||||
plugin.pendingInstall = undefined;
|
||||
plugin.nameTaken = true;
|
||||
plugin.state = "nameTaken";
|
||||
}
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
@@ -480,6 +480,8 @@ export class EventSubscriber<E, S = null> {
|
||||
protected Event: EventClass<E>,
|
||||
public control: Link,
|
||||
protected filteredHandler?: EventSubscriberCallback<E>,
|
||||
/** Nothing is sent to the controller while this returns false, for accounts without permission */
|
||||
protected permitted: () => boolean = () => true,
|
||||
) {
|
||||
control.handle(Event, this._handleEvent.bind(this));
|
||||
// The events below exist only on websocket connectors, we can't early return because of test mocking
|
||||
@@ -670,7 +672,7 @@ export class EventSubscriber<E, S = null> {
|
||||
* Update the subscription with the controller based on current handler counts
|
||||
*/
|
||||
private async _sendRequest(action: SubscriptionAction, filters: SubscriptionFilters) {
|
||||
if (!this.control.connector.valid) {
|
||||
if (!this.control.connector.valid || !this.permitted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -725,6 +727,16 @@ export class MapSubscriber<
|
||||
/** Current value of the subscribed resource */
|
||||
values = new Map<K, V>();
|
||||
|
||||
override handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
|
||||
if (event === "connect") {
|
||||
// A new session is only sent values that exist, not ones removed while disconnected,
|
||||
// so everything is fetched again.
|
||||
this.values.clear();
|
||||
this.lastUpdatedMs = -1;
|
||||
}
|
||||
super.handleConnectionEvent(event);
|
||||
}
|
||||
|
||||
protected override getLastUpdatedTimeMs(event: E) {
|
||||
let maxTime = this.lastUpdatedMs;
|
||||
for (const update of event.updates) {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
@@ -45,7 +45,6 @@
|
||||
"os-browserify": "^0.3.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
"rc-field-form": "^1.44.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
@@ -53,7 +52,6 @@
|
||||
"react-router-dom": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"typescript": "catalog:",
|
||||
"util": "^0.12.5",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
@@ -61,6 +59,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/semver": "catalog:"
|
||||
"@types/semver": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
|
||||
import { Descriptions, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
@@ -17,12 +17,13 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import ResourceState from "./ResourceState";
|
||||
import {
|
||||
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
|
||||
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
|
||||
} from "./system_metrics";
|
||||
import { formatTimestamp } from "../util/time_format";
|
||||
import { useSystems } from "../model/system";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
@@ -98,51 +99,55 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
type RevokeHostButtonProps = { host: lib.HostDetails, instances: ReadonlyMap<number, lib.InstanceDetails> };
|
||||
|
||||
function RevokeHostButton({ host, instances }: RevokeHostButtonProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const navigate = useNavigate();
|
||||
const assigned = [...instances.values()].map(instance => instance.name);
|
||||
|
||||
return <Popconfirm
|
||||
title={`Revoke access for ${host.name}?`}
|
||||
description={<>
|
||||
The host is removed from the cluster and disconnected.<br />
|
||||
{assigned.length > 0 && <>Its instances become unassigned: {assigned.join(", ")}<br /></>}
|
||||
To add it back, start the host again and approve it.
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Revoke"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => control.send(
|
||||
new lib.HostRevokeRequest(host.id)
|
||||
).then(() => {
|
||||
notify(`Revoked access for host ${host.name}`, "success");
|
||||
navigate("/hosts");
|
||||
}).catch(notifyErrorHandler(`Error revoking access for host ${host.name}`))}
|
||||
>
|
||||
<Button danger>Revoke host access</Button>
|
||||
</Popconfirm>;
|
||||
}
|
||||
|
||||
export default function HostViewPage() {
|
||||
let params = useParams();
|
||||
let hostId = Number(params.id);
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let [instances] = useInstances();
|
||||
const [systems] = useSystems();
|
||||
const system = systems.get(hostId);
|
||||
const [host, synced] = useHost(hostId);
|
||||
const [host, synced, error] = useHost(hostId);
|
||||
const state = useResourceState(host, synced, error, hostId);
|
||||
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
|
||||
const canShowNpm = hasNpmButtonPermission(false);
|
||||
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
|
||||
|
||||
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
|
||||
if (!host) {
|
||||
if (!synced) {
|
||||
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={String(hostId)} />
|
||||
<p>Host with id {hostId} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="Host" id={hostId} nav={nav} listPath="/hosts" error={error} />;
|
||||
}
|
||||
|
||||
let hostButtons = <Space wrap>
|
||||
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
account.hasPermission("core.host.revoke_token")
|
||||
&& <Popconfirm
|
||||
title={`Revoke tokens of ${host.name}?`}
|
||||
placement="bottomRight"
|
||||
okText="Revoke Tokens"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(new lib.HostRevokeTokensRequest(host.id!))
|
||||
.then(() => notify("Host tokens revoked"))
|
||||
.catch(notifyErrorHandler(`Error revoking tokens for host id:${host.id}`));
|
||||
}}
|
||||
>
|
||||
<Button danger>
|
||||
Revoke tokens
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{account.hasPermission("core.host.revoke") && <RevokeHostButton host={host} instances={hostInstances} />}
|
||||
{canShowNpm && (
|
||||
<NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
)}
|
||||
@@ -175,13 +180,6 @@ export default function HostViewPage() {
|
||||
<Descriptions.Item label="Disk Usage"><MetricDiskRatio system={system} /></Descriptions.Item>
|
||||
<Descriptions.Item label="Disk"><MetricDiskUsed system={system} /></Descriptions.Item>
|
||||
<Descriptions.Item label="Public Address">{host.publicAddress}</Descriptions.Item>
|
||||
{
|
||||
host.tokenValidAfter
|
||||
? <Descriptions.Item label="Tokens valid after:">
|
||||
{formatTimestamp(host.tokenValidAfter*1000)}
|
||||
</Descriptions.Item>
|
||||
: null
|
||||
}
|
||||
</Descriptions>
|
||||
<ResourceNotes resourceType="host" resourceId={hostId} />
|
||||
{account.hasPermission("core.instance.list") && <>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useContext, useRef, useState, useEffect } from "react";
|
||||
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
|
||||
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Badge, Button, Descriptions, Flex, Input, Modal, Result, Space, Spin, Switch, Table, Tooltip, Typography,
|
||||
} from "antd";
|
||||
import { ExclamationCircleOutlined, CopyOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -16,9 +19,10 @@ import {
|
||||
MetricDiskUsed, MetricDiskRatio,
|
||||
RestartRequired,
|
||||
} from "./system_metrics";
|
||||
import { useHosts } from "../model/host";
|
||||
import { useHostPairings, useHosts } from "../model/host";
|
||||
import { useSystems } from "../model/system";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
@@ -27,133 +31,228 @@ import Link from "./Link";
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
function GenerateHostTokenButton() {
|
||||
let control = useContext(ControlContext);
|
||||
let [open, setOpen] = useState(false);
|
||||
let [token, setToken] = useState<string|null>(null);
|
||||
let [hostId, setHostId] = useState<number|null>(null);
|
||||
let [form] = Form.useForm();
|
||||
let [pluginList, setPluginList] = useState<lib.PluginWebApi[]>([]);
|
||||
/** Current time in milliseconds, updated every second while active */
|
||||
function useNow(active: boolean) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/plugins`);
|
||||
if (response.ok) {
|
||||
const plugins = await response.json();
|
||||
setPluginList(plugins);
|
||||
} else {
|
||||
notify("Failed to load plugin list");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function generateToken() {
|
||||
let id;
|
||||
let values = form.getFieldsValue();
|
||||
if (values.hostId) {
|
||||
id = Number.parseInt(values.hostId, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
form.setFields([{ name: "hostId", errors: ["Must be an integer"] }]);
|
||||
return;
|
||||
}
|
||||
form.setFields([{ name: "hostId", errors: [] }]);
|
||||
if (!active) {
|
||||
return undefined;
|
||||
}
|
||||
setNow(Date.now());
|
||||
const interval = setInterval(() => { setNow(Date.now()); }, 1000);
|
||||
return () => { clearInterval(interval); };
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
let newToken = await control.send(new lib.HostGenerateTokenRequest(id));
|
||||
setToken(newToken);
|
||||
setHostId(id ?? null);
|
||||
function AddHostModal(props: { open: boolean, onClose: () => void }) {
|
||||
const [requests, synced] = useHostPairings();
|
||||
const [, setParams] = useSearchParams();
|
||||
const now = useNow(props.open);
|
||||
|
||||
const controllerUrl = new URL(webRoot, document.location.href).href;
|
||||
const installCommand = `npm init "@clusterio" -- --mode host --controller-url ${controllerUrl}`;
|
||||
|
||||
function review(code: string) {
|
||||
setParams(current => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("approve", code);
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
|
||||
// Generate a new random
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
generateToken().catch(notifyErrorHandler("Error generating token"));
|
||||
}
|
||||
}, [open]);
|
||||
return <Modal
|
||||
title="Add Host"
|
||||
open={props.open}
|
||||
footer={null}
|
||||
onCancel={props.onClose}
|
||||
width="800px"
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
Run the installer in an empty directory on the machine the host will run on, 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
|
||||
message="Copied host setup commands to clipboard"
|
||||
text={["mkdir clusterio", "cd clusterio", installCommand].join("\n")}
|
||||
/>
|
||||
<p>> mkdir clusterio</p>
|
||||
<p>> cd clusterio</p>
|
||||
<p>
|
||||
> npm init "@clusterio" -- --mode host
|
||||
--controller-url <span className="highlight">{controllerUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Typography.Paragraph>
|
||||
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>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={!synced}
|
||||
rowKey="code"
|
||||
dataSource={[...requests.values()].sort((a, b) => a.createdAtMs - b.createdAtMs)}
|
||||
locale={{ emptyText: "No hosts are waiting for approval" }}
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
dataIndex: "code",
|
||||
render: (_, request) => <Typography.Text code strong>{request.code}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "Address",
|
||||
dataIndex: "remoteAddress",
|
||||
},
|
||||
{
|
||||
title: "Age",
|
||||
key: "age",
|
||||
render: (_, request) => <Tooltip title={new Date(request.createdAtMs).toLocaleString()}>
|
||||
<span>{formatDuration(Math.max(0, now - request.createdAtMs))}</span>
|
||||
</Tooltip>,
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
align: "right",
|
||||
render: (_, request) => <Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => { review(request.code); }}
|
||||
>Review</Button>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
const pluginString = pluginList.filter(p => p.npmPackage).map(p => `"${p.npmPackage}"`).join(" ");
|
||||
return <>
|
||||
<Button
|
||||
onClick={() => { setOpen(true); }}
|
||||
>Generate Token</Button>
|
||||
<Modal
|
||||
title="Generate Host Token"
|
||||
open={open}
|
||||
footer={null}
|
||||
onCancel={() => {
|
||||
setOpen(false);
|
||||
setToken(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
width="700px"
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark="optional">
|
||||
<Form.Item name="hostId" label="Host ID">
|
||||
<InputNumber onChange={() => {
|
||||
generateToken().catch(notifyErrorHandler("Error generating token"));
|
||||
}} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
/** Format a code like the controller does, matching any case and without the dash */
|
||||
function normaliseCode(code: string) {
|
||||
const plain = code.toUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||
return `${plain.slice(0, 3)}-${plain.slice(3)}`;
|
||||
}
|
||||
|
||||
/** Dialog opened by the link a waiting host prints, for approving that host */
|
||||
function ApproveHostModal(props: { code: string, onClose: () => void }) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [requests, synced] = useHostPairings();
|
||||
const code = normaliseCode(props.code);
|
||||
const request = requests.get(code);
|
||||
const [name, setName] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const now = useNow(true);
|
||||
const permitted = account.hasPermission("core.host.approve");
|
||||
const hostName = (name ?? request?.name ?? "").trim();
|
||||
|
||||
function approve() {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
control.send(
|
||||
new lib.HostPairingApproveRequest(request.code, hostName !== request.name ? hostName : undefined)
|
||||
).then(() => {
|
||||
notify(`Approved host ${hostName}`, "success");
|
||||
props.onClose();
|
||||
}).catch(
|
||||
notifyErrorHandler(`Error approving host ${request.name}`)
|
||||
).finally(() => {
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function reject() {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
control.send(new lib.HostPairingRejectRequest(request.code)).then(() => {
|
||||
notify(`Rejected host ${request.name}`);
|
||||
props.onClose();
|
||||
}).catch(
|
||||
notifyErrorHandler(`Error rejecting host ${request.name}`)
|
||||
).finally(() => {
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
let content;
|
||||
if (!permitted) {
|
||||
content = <Result
|
||||
status="403"
|
||||
title="You are not allowed to approve hosts"
|
||||
subTitle="Ask someone with the Approve hosts permission to open this link."
|
||||
/>;
|
||||
} else if (!request && !synced) {
|
||||
content = <Flex justify="center" style={{ padding: 48 }}><Spin size="large" /></Flex>;
|
||||
} else if (!request) {
|
||||
content = <Result
|
||||
status="warning"
|
||||
title={<>No host is waiting with code <span style={{ whiteSpace: "nowrap" }}>{code}</span></>}
|
||||
subTitle={
|
||||
"It may have been approved or rejected already, or stopped waiting. " +
|
||||
"A host that is still waiting shows a new code."
|
||||
}
|
||||
/>;
|
||||
} else {
|
||||
content = <>
|
||||
<Typography.Paragraph>
|
||||
Host IDs are randomly generated and unique, leave blank to use a new ID,
|
||||
otherwise the ID of an existing host can be found using:
|
||||
A host is asking to join the cluster.
|
||||
Only approve it if the code below matches the code shown by the host,
|
||||
an approved host has full access to the cluster.
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message="Copied get host id to clipboard"
|
||||
text="npx clusteriohost config get host.id"
|
||||
/>
|
||||
npx clusteriohost config get host.id
|
||||
</div>
|
||||
{token !== null && <>
|
||||
<Typography.Paragraph>
|
||||
Host auth token:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message="Copied host token to clipboard"
|
||||
text={token}
|
||||
/>
|
||||
{token}
|
||||
</div>
|
||||
<Typography.Paragraph>
|
||||
You can set the token on an existing host with the following command:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message="Copied set host id to clipboard"
|
||||
text={`npx clusteriohost config set host.id ${token}`}
|
||||
/>
|
||||
npx clusteriohost config set host.id {token}
|
||||
</div>
|
||||
<Typography.Paragraph>
|
||||
A new host can be setup using the following commands:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message={"Copied host setup commands to clipboard"}
|
||||
text={`\
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-headless \
|
||||
--controller-url ${document.location.origin}/ --host-name "${hostId ? `Host ${hostId}` : "Host"}" \
|
||||
--public-address localhost ${pluginString.length ? "--plugins" : ""} ${pluginString}`
|
||||
}/>
|
||||
<p>> mkdir clusterio</p>
|
||||
<p>> cd clusterio</p>
|
||||
<p>
|
||||
> npm init "@clusterio" --
|
||||
--controller-token <span className="highlight">{token} </span>
|
||||
--mode "host"
|
||||
--download-headless
|
||||
--controller-url <span className="highlight">{document.location.origin}/ </span>
|
||||
--host-name <span className="highlight">{hostId ? `Host ${hostId}` : "Host"} </span>
|
||||
--public-address <span className="highlight">localhost </span>
|
||||
{pluginString.length ? "--plugins" : ""} <span className="highlight">{pluginString}</span>
|
||||
</p>
|
||||
<p>> ./run-host</p>
|
||||
</div>
|
||||
</>}
|
||||
</Modal>
|
||||
<Flex justify="center" style={{ margin: "24px 0" }}>
|
||||
<Typography.Text code strong style={{ fontSize: 32, letterSpacing: 4 }}>{request.code}</Typography.Text>
|
||||
</Flex>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="Address">{request.remoteAddress}</Descriptions.Item>
|
||||
{request.version && <Descriptions.Item label="Version">{request.version}</Descriptions.Item>}
|
||||
<Descriptions.Item label="Waiting for">
|
||||
{formatDuration(Math.max(0, now - request.createdAtMs))}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Text strong>Name</Typography.Text>
|
||||
<Input
|
||||
size="large"
|
||||
aria-label="Host name"
|
||||
value={name ?? request.name}
|
||||
onChange={event => { setName(event.target.value); }}
|
||||
onPressEnter={approve}
|
||||
style={{ margin: "4px 0 24px" }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
disabled={busy || !hostName}
|
||||
loading={busy}
|
||||
onClick={approve}
|
||||
>Approve</Button>
|
||||
<Flex justify="center" style={{ marginTop: 12 }}>
|
||||
<Button type="link" danger disabled={busy} onClick={reject}>Reject</Button>
|
||||
</Flex>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <Modal title="Approve host" open footer={null} onCancel={props.onClose} width="520px">
|
||||
{content}
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function AddHostButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [requests] = useHostPairings();
|
||||
return <>
|
||||
<Badge count={requests.size} title={`${requests.size} waiting for approval`}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setOpen(true); }}>Add host</Button>
|
||||
</Badge>
|
||||
<AddHostModal open={open} onClose={() => { setOpen(false); }} />
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -212,11 +311,22 @@ export default function HostsPage() {
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
|
||||
const rowNav = useRowNavigation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const approveCode = params.get("approve");
|
||||
|
||||
function closeApprove() {
|
||||
setParams(current => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.delete("approve");
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Hosts" }]}>
|
||||
{approveCode !== null && <ApproveHostModal code={approveCode} onClose={closeApprove} />}
|
||||
<PageHeader
|
||||
title="Hosts"
|
||||
extra={account.hasPermission("core.host.generate_token") ? <GenerateHostTokenButton /> : undefined}
|
||||
extra={account.hasPermission("core.host.approve") ? <AddHostButton /> : undefined}
|
||||
/>
|
||||
<Table
|
||||
style={{ overflowX: "auto" }}
|
||||
|
||||
@@ -8,6 +8,8 @@ import FactorioIcon from "./FactorioIcon";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useModPack } from "../model/mod_pack";
|
||||
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import ResourceState from "./ResourceState";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -39,7 +41,8 @@ export default function IconReferencePage() {
|
||||
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [modPack] = useModPack(modPackId);
|
||||
const [modPack, synced, error] = useModPack(modPackId);
|
||||
const state = useResourceState(modPack, synced, error, modPackId);
|
||||
const manifest = modPack?.exportManifest;
|
||||
const prototypes = useExportPrototypeMetadata(modPack);
|
||||
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
|
||||
@@ -100,6 +103,10 @@ export default function IconReferencePage() {
|
||||
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
|
||||
{ name: "Icon Reference" },
|
||||
];
|
||||
if (!modPack) {
|
||||
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout nav={nav}>
|
||||
<PageHeader title="Icon Reference" />
|
||||
|
||||
@@ -25,8 +25,10 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
|
||||
const instanceId = props.instance.id;
|
||||
const instanceStatus = props.instance.status;
|
||||
const showStart = ["stopped", "stopping"].includes(instanceStatus);
|
||||
const showStop = ["running", "starting"].includes(instanceStatus);
|
||||
// While starting or stopping the button shows the action in progress as loading.
|
||||
const showStart = ["stopped", "starting"].includes(instanceStatus);
|
||||
const showStop = ["running", "stopping"].includes(instanceStatus);
|
||||
const transitioning = ["starting", "stopping"].includes(instanceStatus);
|
||||
|
||||
function actionFinish() {
|
||||
setSwitching(false);
|
||||
@@ -37,7 +39,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.instance.start") && showStart !== switching) {
|
||||
if (account.hasPermission("core.instance.start") && showStart) {
|
||||
actions.push({
|
||||
key: "start",
|
||||
label: "Start",
|
||||
@@ -101,7 +103,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
loading={switching || transitioning}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>
|
||||
<Popconfirm
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Spin, Tooltip, Typography,
|
||||
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tooltip, Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
@@ -16,6 +16,7 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import ResourceState from "./ResourceState";
|
||||
import InstanceConfigTree from "./InstanceConfigTree";
|
||||
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
|
||||
import InstanceRcon from "./InstanceRcon";
|
||||
@@ -31,6 +32,7 @@ import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import Link from "./Link";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
import useLocalStorage from "../util/useLocalStorage";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import { MetricRelativeDate } from "./system_metrics";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
@@ -208,26 +210,15 @@ export default function InstanceViewPage() {
|
||||
let navigate = useNavigate();
|
||||
|
||||
let account = useAccount();
|
||||
let [instance, synced] = useInstance(instanceId);
|
||||
let [instance, synced, error] = useInstance(instanceId);
|
||||
const state = useResourceState(instance, synced, error, instanceId);
|
||||
let [host] = useHost(instance?.assignedHost);
|
||||
|
||||
let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }];
|
||||
if (!instance) {
|
||||
if (!synced) {
|
||||
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Alert
|
||||
message={"Instance not found"}
|
||||
showIcon
|
||||
description={<>Instance with id {instanceId} was not found on the controller.</>}
|
||||
type="warning"
|
||||
action={
|
||||
<Link to="/instances">Go back to instances list</Link>
|
||||
}
|
||||
/>
|
||||
</PageLayout>;
|
||||
return <ResourceState
|
||||
state={state} type="Instance" id={instanceId} nav={nav} listPath="/instances" error={error}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FieldData } from "rc-field-form/lib/interface";
|
||||
|
||||
import {
|
||||
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig, Alert, notification,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig, Alert, notification, FormProps,
|
||||
} from "antd";
|
||||
|
||||
import {
|
||||
@@ -23,10 +22,12 @@ import { useModPack } from "../model/mod_pack";
|
||||
import { useMods } from "../model/mods";
|
||||
import { useClipboard } from "../util/clipboard";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import ResourceState from "./ResourceState";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { InputPartialVersion } from "./InputVersion";
|
||||
import Link from "./Link";
|
||||
@@ -35,6 +36,8 @@ const { logger } = lib;
|
||||
const { Text } = Typography;
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
type FieldData = NonNullable<FormProps["fields"]>[number];
|
||||
|
||||
type ModChange =
|
||||
{
|
||||
type: "name" | "description"
|
||||
@@ -1182,7 +1185,8 @@ export default function ModPackViewPage() {
|
||||
let modPackId = Number(params.id);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [modPack, synced] = useModPack(modPackId);
|
||||
const [modPack, synced, error] = useModPack(modPackId);
|
||||
const state = useResourceState(modPack, synced, error, modPackId);
|
||||
let prototypes: any = useExportedAsset(modPack, "settings");
|
||||
let locale: Map<any, any> = useExportedAsset(modPack, "locale");
|
||||
let [changes, setChanges] = useState<ModChange[]>([]);
|
||||
@@ -1252,17 +1256,7 @@ export default function ModPackViewPage() {
|
||||
|
||||
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }];
|
||||
if (!modifiedModPack || !modPack) {
|
||||
if (!synced) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={String(modPackId)} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="Mod Pack Not Found" />
|
||||
<p>Mod pack with id {modPackId} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -35,6 +35,8 @@ type MachinePluginsState = {
|
||||
unknown: Machine[],
|
||||
/** Fetch the plugin lists of connected machines now, instead of waiting for the next system metrics */
|
||||
refresh: (addresses?: lib.AddressShorthand[]) => Promise<void>,
|
||||
/** Why machines is not available, if it's not just still loading */
|
||||
error: Error | null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -44,7 +46,7 @@ type MachinePluginsState = {
|
||||
export function useMachinePlugins(): MachinePluginsState {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [hosts, hostsSynced] = useHosts();
|
||||
const [hosts, hostsSynced, hostsError] = useHosts();
|
||||
const [systems, systemsSynced] = useSystems();
|
||||
// Lists fetched directly, used until the system metrics of the machine update
|
||||
const [listed, setListed] = useState(new Map<string, ListedPlugins>());
|
||||
@@ -88,8 +90,12 @@ export function useMachinePlugins(): MachinePluginsState {
|
||||
}
|
||||
}, [canList, hostsSynced, connectedKey]);
|
||||
|
||||
if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) {
|
||||
return { machines: undefined, unknown: [], refresh };
|
||||
if (!canList) {
|
||||
const error = new lib.PermissionError("Missing permission core.plugin.list");
|
||||
return { machines: undefined, unknown: [], refresh, error };
|
||||
}
|
||||
if (!hostsSynced || (!systemsSynced && !listed.size)) {
|
||||
return { machines: undefined, unknown: [], refresh, error: hostsError };
|
||||
}
|
||||
const machines: MachinePlugins[] = [];
|
||||
const unknown: Machine[] = [];
|
||||
@@ -105,7 +111,7 @@ export function useMachinePlugins(): MachinePluginsState {
|
||||
unknown.push(machine);
|
||||
}
|
||||
}
|
||||
return { machines, unknown, refresh };
|
||||
return { machines, unknown, refresh, error: null };
|
||||
}
|
||||
|
||||
/** Latest version published on npm of each package */
|
||||
@@ -160,7 +166,7 @@ export function PluginVersions(props: { status: PluginStatus, latest?: string })
|
||||
.sort(compareVersions)
|
||||
.reverse();
|
||||
const count = (filter: (machine: MachinePlugin) => boolean) => machines.filter(filter).length;
|
||||
const missing = count(machine => (!machine.plugin || Boolean(machine.plugin.removed)) && !machine.nameTaken);
|
||||
const missing = count(machine => machine.state === "missing" || machine.state === "dependencyTaken");
|
||||
|
||||
return <>
|
||||
{versions.map(version => {
|
||||
@@ -175,14 +181,14 @@ export function PluginVersions(props: { status: PluginStatus, latest?: string })
|
||||
</div>;
|
||||
})}
|
||||
{count(isPending) > 0 && <CountLine label="Restart pending on" count={count(isPending)} />}
|
||||
{count(machine => machine.disabled) > 0 && <CountLine
|
||||
label="Disabled on" count={count(machine => machine.disabled)} type="secondary"
|
||||
{count(machine => machine.state === "disabled") > 0 && <CountLine
|
||||
label="Disabled on" count={count(machine => machine.state === "disabled")} type="secondary"
|
||||
/>}
|
||||
{count(machine => machine.noEntrypoint) > 0 && <CountLine
|
||||
label="No entrypoint on" count={count(machine => machine.noEntrypoint)} type="secondary"
|
||||
{count(machine => machine.state === "noEntrypoint") > 0 && <CountLine
|
||||
label="No entrypoint on" count={count(machine => machine.state === "noEntrypoint")} type="secondary"
|
||||
/>}
|
||||
{count(machine => machine.nameTaken) > 0 && <CountLine
|
||||
label="From another package on" count={count(machine => machine.nameTaken)} type="secondary"
|
||||
{count(machine => machine.state === "nameTaken") > 0 && <CountLine
|
||||
label="From another package on" count={count(machine => machine.state === "nameTaken")} type="secondary"
|
||||
/>}
|
||||
{missing > 0 && <CountLine label="Not installed on" count={missing} type="warning" />}
|
||||
{unknown.length > 0 && <CountLine label="Plugins not reported by" count={unknown.length} type="warning" />}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Descriptions, Spin } from "antd";
|
||||
import { Alert, Descriptions } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { PluginWebApi } from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PageHeader from "./PageHeader";
|
||||
import ResourceState from "./ResourceState";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import notify from "../util/notify";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import { usePluginInstaller } from "./PluginInstall";
|
||||
import { PluginMachinesTable, PluginUpdateButton, useLatestVersions, useMachinePlugins } from "./PluginClusterStatus";
|
||||
import { PluginStatus, computePluginStatuses, pluginStatusKey } from "../util/plugin_status";
|
||||
@@ -89,18 +89,18 @@ export default function PluginViewPage() {
|
||||
let params = useParams();
|
||||
const control = useContext(ControlContext);
|
||||
let [pluginList, setPluginList] = useState<PluginWebApi[]>();
|
||||
const [pluginListError, setPluginListError] = useState<Error | null>(null);
|
||||
let pluginName = String(params.name);
|
||||
const { machines, unknown, refresh } = useMachinePlugins();
|
||||
const { machines, unknown, refresh, error: machinesError } = useMachinePlugins();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/plugins`);
|
||||
if (response.ok) {
|
||||
setPluginList(await response.json());
|
||||
} else {
|
||||
notify("Failed to load plugin list");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load plugin list: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
})();
|
||||
setPluginList(await response.json());
|
||||
})().catch(setPluginListError);
|
||||
}, []);
|
||||
|
||||
const pluginMeta = pluginList?.find(p => p.name === pluginName);
|
||||
@@ -110,19 +110,16 @@ export default function PluginViewPage() {
|
||||
: undefined;
|
||||
const pluginInfo = control.pluginInfos.get(pluginName);
|
||||
const pluginTitle = pluginInfo?.title ?? status?.title ?? pluginName;
|
||||
// Machines are only needed for plugins not on the controller, which isn't known until the list has loaded
|
||||
const error = pluginListError ?? (pluginList ? machinesError : null);
|
||||
const state = useResourceState(
|
||||
pluginList && (pluginMeta ?? status), Boolean(pluginList && machines), error, pluginName
|
||||
);
|
||||
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
|
||||
if (!pluginList || (!pluginMeta && !machines)) {
|
||||
return <PageLayout nav={nav}>
|
||||
<Descriptions bordered size="small" title={pluginTitle} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (!pluginMeta && !status) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="Plugin not found" />
|
||||
<p>Plugin with name {pluginName} was not found on the controller or any host.</p>
|
||||
</PageLayout>;
|
||||
if (state !== "ok") {
|
||||
return <ResourceState
|
||||
state={state} type="Plugin" id={pluginTitle} nav={nav} listPath="/plugins" error={error}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { Result, ResultProps, Spin } from "antd";
|
||||
|
||||
import Link from "./Link";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import type { ResourceStatus } from "../util/useResourceState";
|
||||
|
||||
type ResourceStateProps = {
|
||||
/** Status from useResourceState */
|
||||
state: ResourceStatus;
|
||||
/** Capitalised name of the kind of resource, e.g. "Mod pack" */
|
||||
type: string;
|
||||
/** Id or name of the resource */
|
||||
id: number | string;
|
||||
nav: React.ComponentProps<typeof PageLayout>["nav"];
|
||||
/** Page listing this kind of resource */
|
||||
listPath: string;
|
||||
/** Error from the subscription */
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
/** Whole page to render in place of a view page when its resource can't be shown */
|
||||
export default function ResourceState(props: ResourceStateProps) {
|
||||
const { state, type, id, nav } = props;
|
||||
if (state === "ok") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state === "loading") {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={String(id)} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
const results: Record<typeof state, ResultProps> = {
|
||||
missing: { status: "warning", title: `${type} not found`, subTitle: `${type} ${id} was not found.` },
|
||||
deleted: { status: "warning", title: `${type} ${id} has been deleted` },
|
||||
error: { status: "error", title: `Could not load ${type.toLowerCase()}`, subTitle: props.error?.message },
|
||||
};
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Result
|
||||
{...results[state]}
|
||||
extra={<Link to={props.listPath}>Back to {type.toLowerCase()} list</Link>}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useContext, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd";
|
||||
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Tooltip } from "antd";
|
||||
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -12,7 +12,9 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import ResourceState from "./ResourceState";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
|
||||
|
||||
/** Splits a permission name into parts, removing the last part */
|
||||
@@ -88,7 +90,7 @@ function PermissionItem({
|
||||
|
||||
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
|
||||
function PermissionGroup({
|
||||
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
|
||||
groupName, permissions, canUpdate, baselineState, permissionState, setPermissions,
|
||||
}: {
|
||||
groupName: string;
|
||||
permissions: lib.Permission[];
|
||||
@@ -96,17 +98,10 @@ function PermissionGroup({
|
||||
|
||||
baselineState: Record<string, boolean>;
|
||||
permissionState: Record<string, boolean>;
|
||||
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
setEdited?: (value: boolean) => void;
|
||||
setPermissions: (names: string[], value: boolean) => void;
|
||||
}) {
|
||||
function setGroup(value: boolean) {
|
||||
setPermissionState((prev: any) => {
|
||||
const next = { ...prev };
|
||||
for (let p of permissions) {
|
||||
next[p.name] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPermissions(permissions.map(p => p.name), value);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -129,7 +124,7 @@ function PermissionGroup({
|
||||
checked={permissionState[p.name]}
|
||||
baseline={baselineState[p.name]}
|
||||
disabled={!canUpdate}
|
||||
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
|
||||
onChange={v => setPermissions([p.name], v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -169,12 +164,13 @@ export default function RoleViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [role, synced] = useRole(roleId);
|
||||
const [role, synced, error] = useRole(roleId);
|
||||
const state = useResourceState(role, synced, error, roleId);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
|
||||
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
|
||||
// Local edits on top of the live role
|
||||
const [nameEdit, setNameEdit] = useState<string>();
|
||||
const [descriptionEdit, setDescriptionEdit] = useState<string>();
|
||||
const [permissionEdits, setPermissionEdits] = useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
// Hidden below lg, where the site sider leaves too little room; undefined until measured
|
||||
const screens = Grid.useBreakpoint();
|
||||
@@ -183,38 +179,41 @@ export default function RoleViewPage() {
|
||||
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
|
||||
const canUpdate = Boolean(account.hasPermission("core.role.update"));
|
||||
|
||||
const baselineState = useMemo(() => Object.fromEntries(
|
||||
allPermissions.map(p => [p.name, Boolean(role?.permissions.has(p.name))])
|
||||
), [role, allPermissions]);
|
||||
|
||||
// Drop edits that now match the role, and all edits if we can no longer apply them
|
||||
useEffect(() => {
|
||||
if (!role) {
|
||||
if (!role || !canUpdate) {
|
||||
setNameEdit(undefined);
|
||||
setDescriptionEdit(undefined);
|
||||
setPermissionEdits(prev => (Object.keys(prev).length ? {} : prev));
|
||||
return;
|
||||
}
|
||||
setNameEdit(prev => (prev === role.name ? undefined : prev));
|
||||
setDescriptionEdit(prev => (prev === role.description ? undefined : prev));
|
||||
setPermissionEdits(prev => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([k, v]) => v !== baselineState[k]));
|
||||
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
|
||||
});
|
||||
}, [role, canUpdate, baselineState]);
|
||||
|
||||
const base = Object.fromEntries(
|
||||
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
|
||||
);
|
||||
useEffect(() => {
|
||||
setNameEdit(undefined);
|
||||
setDescriptionEdit(undefined);
|
||||
setPermissionEdits({});
|
||||
}, [roleId]);
|
||||
|
||||
setName(role.name);
|
||||
setDescription(role.description);
|
||||
setPermissionState(base);
|
||||
setBaselineState(base);
|
||||
}, [role, allPermissions]);
|
||||
|
||||
// If no role then display either the loading screen or the not found screen
|
||||
if (!role) {
|
||||
const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }];
|
||||
|
||||
if (!synced) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={String(roleId)} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="Role not found" />
|
||||
<p>Role with id {roleId} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="Role" id={roleId} nav={nav} listPath="/roles" error={error} />;
|
||||
}
|
||||
|
||||
const name = nameEdit ?? role.name;
|
||||
const description = descriptionEdit ?? role.description;
|
||||
const permissionState = { ...baselineState, ...permissionEdits };
|
||||
|
||||
// Apply the filter by searching all permission fields
|
||||
const filtered = allPermissions.filter(p => {
|
||||
const q = search.toLowerCase();
|
||||
@@ -243,27 +242,33 @@ export default function RoleViewPage() {
|
||||
const grantedGroups = new Set(allPermissions.filter(p => permissionState[p.name]).map(p => getGroupName(p.name)));
|
||||
|
||||
// Check if any changes have been made to the permissions or role metadata
|
||||
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
|
||||
const permissionChanged = Object.keys(permissionEdits).some(k => permissionEdits[k] !== baselineState[k]);
|
||||
const metaChanged = name !== role.name || description !== role.description;
|
||||
const edited = permissionChanged || metaChanged;
|
||||
|
||||
/** Applies all pending changes */
|
||||
function applyChanges() {
|
||||
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
|
||||
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
|
||||
.then(() => {
|
||||
setBaselineState(permissionState);
|
||||
})
|
||||
.catch(notifyErrorHandler("Error applying changes"));
|
||||
function setPermissions(names: string[], value: boolean) {
|
||||
setPermissionEdits(prev => {
|
||||
const next = { ...prev };
|
||||
for (let permissionName of names) {
|
||||
next[permissionName] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Revert any pending changes */
|
||||
function revertChanges() {
|
||||
setPermissionState(baselineState);
|
||||
if (role) {
|
||||
setName(role.name);
|
||||
setDescription(role.description);
|
||||
}
|
||||
setNameEdit(undefined);
|
||||
setDescriptionEdit(undefined);
|
||||
setPermissionEdits({});
|
||||
}
|
||||
|
||||
/** Applies all pending changes */
|
||||
function applyChanges() {
|
||||
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
|
||||
control.send(new lib.RoleUpdateRequest(roleId, name, description, newPermissions))
|
||||
.then(revertChanges)
|
||||
.catch(notifyErrorHandler("Error applying changes"));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -320,12 +325,12 @@ export default function RoleViewPage() {
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
|
||||
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
|
||||
<Input value={name} disabled={!canUpdate} onChange={e => setNameEdit(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
|
||||
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
|
||||
<Input value={description} disabled={!canUpdate} onChange={e => setDescriptionEdit(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<ResourceNotes resourceType="role" resourceId={roleId} />
|
||||
@@ -363,7 +368,7 @@ export default function RoleViewPage() {
|
||||
permissions={permissions}
|
||||
permissionState={permissionState}
|
||||
baselineState={baselineState}
|
||||
setPermissionState={setPermissionState}
|
||||
setPermissions={setPermissions}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button, Col, Descriptions, Form, Input, Popconfirm,
|
||||
Row, Table, Tag, Select, Space, Spin, Switch,
|
||||
Row, Table, Tag, Select, Space, Switch,
|
||||
} from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
@@ -18,7 +18,9 @@ import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import ResourceState from "./ResourceState";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import Link from "./Link";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
@@ -40,8 +42,9 @@ export default function UserViewPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [instances] = useInstances();
|
||||
const [user, synced] = useUser(userName);
|
||||
let [roles] = useRoles();
|
||||
const [user, synced, error] = useUser(userName);
|
||||
const state = useResourceState(user, synced, error, userName);
|
||||
let [roles, rolesSynced] = useRoles();
|
||||
let [form] = Form.useForm();
|
||||
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
|
||||
namespace: "stats", defaultSortKey: "instance",
|
||||
@@ -62,22 +65,25 @@ export default function UserViewPage() {
|
||||
}
|
||||
}, [account, form, user, banReasonDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !account.hasPermission("core.user.update_roles")) {
|
||||
return;
|
||||
}
|
||||
const current: number[] = form.getFieldValue("roles") ?? [];
|
||||
// Keep local edits, but drop roles deleted since
|
||||
const next = rolesDirty
|
||||
? current.filter(id => !rolesSynced || roles.has(id))
|
||||
: [...user.roleIds];
|
||||
if (next.length !== current.length || next.some((id, i) => id !== current[i])) {
|
||||
form.setFieldsValue({ "roles": next });
|
||||
}
|
||||
}, [account, form, user, roles, rolesSynced, rolesDirty]);
|
||||
|
||||
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
|
||||
if (!user) {
|
||||
if (!synced) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={userName} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="User not found" />
|
||||
<p>User with name {userName} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="User" id={userName} nav={nav} listPath="/users" error={error} />;
|
||||
}
|
||||
|
||||
// TODO: Update displayed roles when user roles change and have not been edited locally
|
||||
let roleSelector = <Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
|
||||
@@ -40,6 +40,7 @@ export { default as PageLayout } from "./components/PageLayout";
|
||||
export { default as SavesList } from "./components/SavesList";
|
||||
export { default as SectionHeader } from "./components/SectionHeader";
|
||||
export { default as ResourceNotes } from "./components/ResourceNotes";
|
||||
export { default as ResourceState } from "./components/ResourceState";
|
||||
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
@@ -48,3 +49,4 @@ export { default as FactorioIcon } from "./components/FactorioIcon";
|
||||
export { default as useTableQueryState } from "./util/useTableQueryState";
|
||||
export { default as useColumnSearch } from "./util/useColumnSearch";
|
||||
export { default as useRowNavigation } from "./util/useRowNavigation";
|
||||
export { default as useResourceState, type ResourceStatus } from "./util/useResourceState";
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
export function useHost(id?: number) {
|
||||
const [hosts, synced] = useHosts();
|
||||
return [id !== undefined ? hosts.get(id) : undefined, synced] as const;
|
||||
const [hosts, synced, error] = useHosts();
|
||||
return [id !== undefined ? hosts.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useHosts() {
|
||||
@@ -11,3 +11,10 @@ export function useHosts() {
|
||||
const subscribe = useCallback((callback: () => void) => control.hosts.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => control.hosts.getSnapshot());
|
||||
}
|
||||
|
||||
/** Requests from hosts waiting to be approved, empty if the account may not approve hosts */
|
||||
export function useHostPairings() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => control.hostPairings.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => control.hostPairings.getSnapshot());
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { Config, InstanceConfigGetRequest } from "@clusterio/lib";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
export function useInstance(id?: number) {
|
||||
const [instances, synced] = useInstances();
|
||||
return [id !== undefined ? instances.get(id) : undefined, synced] as const;
|
||||
const [instances, synced, error] = useInstances();
|
||||
return [id !== undefined ? instances.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useInstances() {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { defaultModPackStore } from "../store/default_mod_pack_store";
|
||||
|
||||
|
||||
export function useModPack(id?: number) {
|
||||
const [modPacks, synced] = useModPacks();
|
||||
return [id !== undefined ? modPacks.get(id) : undefined, synced] as const;
|
||||
const [modPacks, synced, error] = useModPacks();
|
||||
return [id !== undefined ? modPacks.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useModPacks() {
|
||||
|
||||
@@ -12,8 +12,8 @@ export function useRolesOfUser(user?: lib.UserDetails) {
|
||||
}
|
||||
|
||||
export function useRole(id?: number) {
|
||||
const [roles, synced] = useRoles();
|
||||
return [id !== undefined ? roles.get(id) : undefined, synced] as const;
|
||||
const [roles, synced, error] = useRoles();
|
||||
return [id !== undefined ? roles.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
|
||||
@@ -89,8 +89,8 @@ export function sortLastSeen(
|
||||
}
|
||||
|
||||
export function useUser(name?: string) {
|
||||
const [users, synced] = useUsers();
|
||||
return [name !== undefined ? users.get(name.toLowerCase()) : undefined, synced] as const;
|
||||
const [users, synced, error] = useUsers();
|
||||
return [name !== undefined ? users.get(name.toLowerCase()) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useUsers() {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
/**
|
||||
* Subscriber for the requests from hosts waiting to be approved
|
||||
*
|
||||
* The requests only live in the controller's memory and it does not tell
|
||||
* subscribers about requests removed while nothing was subscribed, so they
|
||||
* are cleared when subscribing again after the last handler unsubscribed.
|
||||
*/
|
||||
export class HostPairingSubscriber extends lib.MapSubscriber<lib.HostPairingUpdatesEvent> {
|
||||
private _handlers = 0;
|
||||
|
||||
/**
|
||||
* @param control - Link to the controller.
|
||||
* @param permitted - Returns true if the account may approve hosts.
|
||||
*/
|
||||
constructor(control: lib.Link, permitted: () => boolean) {
|
||||
super(lib.HostPairingUpdatesEvent, control, undefined, permitted);
|
||||
}
|
||||
|
||||
override subscribe(handler: lib.EventSubscriberCallback<lib.HostPairingUpdatesEvent>) {
|
||||
if (this._handlers === 0) {
|
||||
this.values.clear();
|
||||
this.lastUpdatedMs = -1;
|
||||
}
|
||||
this._handlers += 1;
|
||||
const unsubscribe = super.subscribe(handler);
|
||||
let unsubscribed = false;
|
||||
return () => {
|
||||
if (unsubscribed) {
|
||||
return;
|
||||
}
|
||||
unsubscribed = true;
|
||||
unsubscribe();
|
||||
// Deferred like the unsubscribe in EventSubscriber, so moving between pages
|
||||
// that both subscribe keeps the requests.
|
||||
setTimeout(() => {
|
||||
this._handlers -= 1;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,26 @@ export type Machine = {
|
||||
};
|
||||
export type MachinePlugins = Machine & { plugins: lib.PluginDetails[] };
|
||||
|
||||
/**
|
||||
* What happens with a plugin on one machine, in order of precedence
|
||||
*
|
||||
* - nameTaken: Another package here provides the plugin by the same name, startup skips this one
|
||||
* - dependencyTaken: Not installed, and the group's npm package name is a dependency here for
|
||||
* another plugin, such as an alias
|
||||
* - missing: Not installed
|
||||
* - noEntrypoint: Installed but never loaded on this machine as it has no entrypoint for it
|
||||
* - disabled: Installed but not loaded after the next restart
|
||||
* - enabled: Installed and loaded after the next restart
|
||||
*/
|
||||
export type MachinePluginState =
|
||||
"nameTaken" | "dependencyTaken" | "missing" | "noEntrypoint" | "disabled" | "enabled";
|
||||
|
||||
/** A plugin on one machine, with the version it runs now and the version it will run after a restart */
|
||||
export type MachinePlugin = Machine & {
|
||||
plugin?: lib.PluginDetails,
|
||||
state: MachinePluginState,
|
||||
running?: string,
|
||||
afterRestart?: string,
|
||||
disabled: boolean,
|
||||
/** Installed but never loaded on this machine as it has no entrypoint for it */
|
||||
noEntrypoint: boolean,
|
||||
/** The group's npm package name is a dependency here for another plugin, such as an alias */
|
||||
dependencyTaken: boolean,
|
||||
/** Another package here provides the plugin by the same name, startup skips this one */
|
||||
nameTaken: boolean,
|
||||
/** A host from before installedVersion was reported, it can't install a specific version */
|
||||
oldHost: boolean,
|
||||
};
|
||||
@@ -53,45 +61,51 @@ export function compareVersions(a: string, b: string) {
|
||||
return strcmp(a, b);
|
||||
}
|
||||
|
||||
/** Not uninstalled since startup and not skipped by name, so its plugin name is in use */
|
||||
function holdsName(plugin: lib.PluginDetails) {
|
||||
return !plugin.removed && plugin.state !== "pendingInstall" && plugin.state !== "nameTaken";
|
||||
}
|
||||
|
||||
function machinePluginState(
|
||||
plugins: lib.PluginDetails[], plugin: lib.PluginDetails | undefined, key: string, names: Set<string>,
|
||||
): MachinePluginState {
|
||||
if (plugin?.state === "nameTaken") {
|
||||
return "nameTaken";
|
||||
}
|
||||
const absent = !plugin || plugin.removed;
|
||||
if ((absent || plugin.state === "pendingInstall") && plugins.some(
|
||||
candidate => pluginStatusKey(candidate) !== key && names.has(candidate.name) && holdsName(candidate)
|
||||
)) {
|
||||
return "nameTaken";
|
||||
}
|
||||
if (absent) {
|
||||
return plugins.some(candidate => candidate.dependencyName === key && !candidate.removed)
|
||||
? "dependencyTaken" : "missing";
|
||||
}
|
||||
if (plugin.state === "noEntrypoint") {
|
||||
return "noEntrypoint";
|
||||
}
|
||||
return plugin.loadsOnRestart ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
/** The plugin with the given key on one machine, names are the plugin names known for that key */
|
||||
function machinePlugin(machine: MachinePlugins, key: string, names: Set<string>): MachinePlugin {
|
||||
const { plugins, ...rest } = machine;
|
||||
// A package removed and installed again is listed twice, the removed one may still be running
|
||||
const candidates = plugins.filter(candidate => pluginStatusKey(candidate) === key);
|
||||
const plugin = candidates.find(candidate => !candidate.removed) ?? candidates[0];
|
||||
const absent = !plugin || Boolean(plugin.removed);
|
||||
const dependencyTaken = absent && plugins.some(
|
||||
candidate => candidate.dependencyName === key && !candidate.removed
|
||||
);
|
||||
const nameTaken = Boolean(plugin?.nameTaken) || (absent || Boolean(plugin.pendingInstall)) && plugins.some(
|
||||
candidate => (
|
||||
pluginStatusKey(candidate) !== key && names.has(candidate.name)
|
||||
&& !candidate.removed && !candidate.pendingInstall && !candidate.nameTaken
|
||||
)
|
||||
);
|
||||
const noEntrypoint = Boolean(plugin?.noEntrypoint);
|
||||
const state = machinePluginState(plugins, plugin, key, names);
|
||||
const oldHost = !machine.isController && plugins.length > 0 && plugins.every(
|
||||
candidate => candidate.installedVersion === undefined && !candidate.removed
|
||||
);
|
||||
const entry: MachinePlugin = {
|
||||
...rest, plugin, disabled: false, noEntrypoint, dependencyTaken, nameTaken, oldHost,
|
||||
return {
|
||||
...rest,
|
||||
plugin,
|
||||
state,
|
||||
oldHost,
|
||||
running: candidates.find(candidate => candidate.loaded)?.version,
|
||||
afterRestart: state === "enabled" && plugin ? plugin.installedVersion ?? plugin.version : undefined,
|
||||
};
|
||||
// What runs now follows loaded, what runs after a restart follows enabled and what is on disk
|
||||
entry.running = candidates.find(candidate => candidate.loaded)?.version;
|
||||
if (nameTaken) {
|
||||
return entry;
|
||||
}
|
||||
if (plugin?.pendingInstall) {
|
||||
// Plugins load by default, so a new install runs after the next restart
|
||||
entry.afterRestart = plugin.installedVersion ?? plugin.version;
|
||||
} else if (plugin && !plugin.removed && !noEntrypoint) {
|
||||
if (plugin.enabled) {
|
||||
entry.afterRestart = plugin.installedVersion ?? plugin.version;
|
||||
} else {
|
||||
entry.disabled = true;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Group the plugin lists of every machine by plugin */
|
||||
@@ -107,7 +121,7 @@ export function computePluginStatuses(
|
||||
if (!titles.has(key) || plugin.loaded) {
|
||||
titles.set(key, plugin.title);
|
||||
}
|
||||
if (!plugin.pendingInstall && !plugin.nameTaken) {
|
||||
if (plugin.state !== "pendingInstall" && plugin.state !== "nameTaken") {
|
||||
pluginNames.set(key, (pluginNames.get(key) ?? new Set()).add(plugin.name));
|
||||
}
|
||||
}
|
||||
@@ -154,15 +168,15 @@ export function planUpdate(
|
||||
if (installed && compareVersions(plugin.installedVersion ?? plugin.version, version) >= 0) {
|
||||
continue;
|
||||
}
|
||||
const kind = !installed || plugin.pendingInstall ? "install" : "update";
|
||||
const kind = !installed || plugin.state === "pendingInstall" ? "install" : "update";
|
||||
const skip = (reason: string, skipKind: PackageOperation["kind"] = kind) => {
|
||||
plan.skipped.push({ kind: skipKind, target, reason });
|
||||
};
|
||||
if (machine.offline) {
|
||||
skip("offline");
|
||||
} else if (machine.nameTaken) {
|
||||
} else if (machine.state === "nameTaken") {
|
||||
skip("another package provides this plugin", "install");
|
||||
} else if (machine.dependencyTaken) {
|
||||
} else if (machine.state === "dependencyTaken") {
|
||||
skip(`another package is installed as ${status.npmPackage}`, "install");
|
||||
} else if (installed && !plugin.npmPackage) {
|
||||
// An alias, private or local package, installing the group's npm package would replace it
|
||||
@@ -211,18 +225,20 @@ export function machineStatusLabels(machine: MachinePlugin) {
|
||||
if (machine.offline) {
|
||||
labels.push("Offline");
|
||||
}
|
||||
if (machine.nameTaken) {
|
||||
if (machine.state === "nameTaken") {
|
||||
labels.push("Another package provides this plugin");
|
||||
} else if (!machine.plugin || machine.plugin.removed) {
|
||||
labels.push(machine.dependencyTaken ? "Another package is installed by this name" : "Not installed");
|
||||
} else if (machine.state === "dependencyTaken") {
|
||||
labels.push("Another package is installed by this name");
|
||||
} else if (machine.state === "missing") {
|
||||
labels.push("Not installed");
|
||||
} else {
|
||||
if (!machine.plugin.npmPackage) {
|
||||
if (!machine.plugin?.npmPackage) {
|
||||
labels.push("Not installed from npm");
|
||||
}
|
||||
if (machine.noEntrypoint) {
|
||||
if (machine.state === "noEntrypoint") {
|
||||
labels.push("No entrypoint for this machine");
|
||||
}
|
||||
if (machine.disabled) {
|
||||
if (machine.state === "disabled") {
|
||||
labels.push("Disabled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export type ResourceStatus = "loading" | "ok" | "missing" | "deleted" | "error";
|
||||
|
||||
export function getResourceStatus(
|
||||
exists: boolean,
|
||||
seen: boolean,
|
||||
synced: boolean,
|
||||
error: Error | null,
|
||||
): ResourceStatus {
|
||||
if (exists) {
|
||||
return "ok";
|
||||
}
|
||||
// A retry only clears the error when its response arrives, which can be after the update that synced it
|
||||
if (synced) {
|
||||
return seen ? "deleted" : "missing";
|
||||
}
|
||||
return error ? "error" : "loading";
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of the resource shown on a view page, for use with the ResourceState component
|
||||
*
|
||||
* @param value - The resource, undefined or null if the subscription does not have it.
|
||||
* @param synced - The synced property from the subscription.
|
||||
* @param error - The error property from the subscription.
|
||||
* @param id - Identifies the resource shown, the router reuses the page when navigating between resources.
|
||||
* @returns "ok" if there is a value to show, otherwise why there isn't.
|
||||
*/
|
||||
export default function useResourceState(value: unknown, synced: boolean, error: Error | null, id: number | string) {
|
||||
const seenId = useRef<number | string>(undefined);
|
||||
const exists = value !== undefined && value !== null;
|
||||
if (exists) {
|
||||
seenId.current = id;
|
||||
}
|
||||
return getResourceStatus(exists, seenId.current === id, synced, error);
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import packageJson from "../../package.json";
|
||||
import * as WebPlugin from "../BaseWebPlugin";
|
||||
import { HostPairingSubscriber } from "./host_pairing";
|
||||
|
||||
const { logFilter, logger } = lib;
|
||||
|
||||
function rolesHavePermission(roles: lib.AccountRole[] | null, permission: lib.PermissionName) {
|
||||
return (roles ?? []).some(
|
||||
role => role.permissions.includes("core.admin") || role.permissions.includes(permission)
|
||||
);
|
||||
}
|
||||
|
||||
type accountHandler = (account: lib.AccountDetails) => void;
|
||||
type hostHandler = (hostDetails: lib.HostDetails) => void;
|
||||
type instanceHandler = (instanceDetails: lib.InstanceDetails) => void;
|
||||
@@ -67,6 +74,7 @@ export class Control extends lib.Link {
|
||||
/** Updates handled by the subscription service */
|
||||
systems = new lib.MapSubscriber(lib.SystemInfoUpdateEvent, this);
|
||||
hosts = new lib.MapSubscriber(lib.HostUpdatesEvent, this);
|
||||
hostPairings = new HostPairingSubscriber(this, () => rolesHavePermission(this.accountRoles, "core.host.approve"));
|
||||
instances = new lib.MapSubscriber(lib.InstanceDetailsUpdatesEvent, this);
|
||||
saves = new lib.MapSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this);
|
||||
modPacks = new lib.MapSubscriber(lib.ModPackUpdatesEvent, this);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["src/index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,9 +24,13 @@ type IpcFinished = {
|
||||
type IpcQueue = { queue: unknown, player?: string };
|
||||
|
||||
export default async function(context: InstancePluginContext) {
|
||||
const { instance, logger, plugin } = context;
|
||||
const { instance, host, logger, plugin } = context;
|
||||
let syncStarted = false;
|
||||
let queueEventsApplied = 0;
|
||||
// Contributions made while the controller is unreachable, summed per technology.
|
||||
// Unlike finished research they can't be recovered from the game state.
|
||||
const pendingContributions = new Map<string, IpcContribution>();
|
||||
let finishedWhileDisconnected = false;
|
||||
|
||||
// Commands are sent one at a time so they execute in order
|
||||
let rconQueue: Promise<unknown> = Promise.resolve();
|
||||
@@ -69,11 +73,68 @@ export default async function(context: InstancePluginContext) {
|
||||
}
|
||||
|
||||
async function researchContribution(tech: IpcContribution) {
|
||||
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
|
||||
if (host.connector.connected) {
|
||||
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
|
||||
return;
|
||||
}
|
||||
let pending = pendingContributions.get(tech.name);
|
||||
if (pending && pending.level === tech.level) {
|
||||
pending.contribution += tech.contribution;
|
||||
} else if (!pending || pending.level < tech.level) {
|
||||
// A lower level finished here, the technology sync reports it
|
||||
pendingContributions.set(tech.name, { ...tech });
|
||||
}
|
||||
}
|
||||
|
||||
async function researchFinished(tech: IpcFinished) {
|
||||
instance.sendTo("controller", new FinishedEvent(tech.name, tech.level));
|
||||
if (host.connector.connected) {
|
||||
instance.sendTo("controller", new FinishedEvent(tech.name, tech.level));
|
||||
return;
|
||||
}
|
||||
finishedWhileDisconnected = true;
|
||||
}
|
||||
|
||||
function sendPendingContributions() {
|
||||
for (let tech of pendingContributions.values()) {
|
||||
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
|
||||
}
|
||||
pendingContributions.clear();
|
||||
}
|
||||
|
||||
async function syncTechnologies() {
|
||||
let dumpJson = await sendOrderedRcon("/sc research_sync.dump_technologies()");
|
||||
let techsToSend = [];
|
||||
let instanceTechs = new Map();
|
||||
for (let tech of JSON.parse(dumpJson)) {
|
||||
techsToSend.push(new TechnologySync(
|
||||
tech.name,
|
||||
tech.level,
|
||||
tech.progress || null,
|
||||
tech.researched,
|
||||
));
|
||||
instanceTechs.set(tech.name, tech);
|
||||
}
|
||||
|
||||
let controllerTechs = await instance.sendTo("controller", new SyncTechnologiesRequest(techsToSend));
|
||||
syncStarted = true;
|
||||
let techsToSync = [];
|
||||
for (let controllerTech of controllerTechs) {
|
||||
let { name, level, progress, researched } = controllerTech;
|
||||
let instanceTech = instanceTechs.get(name);
|
||||
if (
|
||||
!instanceTech
|
||||
|| instanceTech.level !== level
|
||||
|| (instanceTech.progress || null) !== progress
|
||||
|| instanceTech.researched !== researched
|
||||
) {
|
||||
techsToSync.push(controllerTech);
|
||||
}
|
||||
}
|
||||
|
||||
if (techsToSync.length) {
|
||||
let syncJson = lib.escapeString(JSON.stringify(techsToSync));
|
||||
await sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
|
||||
}
|
||||
}
|
||||
|
||||
instance.server.on("ipc-research_sync:contribution", (tech: IpcContribution) => {
|
||||
@@ -128,52 +189,35 @@ export default async function(context: InstancePluginContext) {
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
let dumpJson = await sendOrderedRcon("/sc research_sync.dump_technologies()");
|
||||
let techsToSend = [];
|
||||
let instanceTechs = new Map();
|
||||
for (let tech of JSON.parse(dumpJson)) {
|
||||
techsToSend.push(new TechnologySync(
|
||||
tech.name,
|
||||
tech.level,
|
||||
tech.progress || null,
|
||||
tech.researched,
|
||||
));
|
||||
instanceTechs.set(tech.name, tech);
|
||||
}
|
||||
|
||||
let controllerTechs = await instance.sendTo("controller", new SyncTechnologiesRequest(techsToSend));
|
||||
syncStarted = true;
|
||||
let techsToSync = [];
|
||||
for (let controllerTech of controllerTechs) {
|
||||
let { name, level, progress, researched } = controllerTech;
|
||||
let instanceTech = instanceTechs.get(name);
|
||||
if (
|
||||
!instanceTech
|
||||
|| instanceTech.level !== level
|
||||
|| (instanceTech.progress || null) !== progress
|
||||
|| instanceTech.researched !== researched
|
||||
) {
|
||||
techsToSync.push(controllerTech);
|
||||
}
|
||||
}
|
||||
|
||||
if (techsToSync.length) {
|
||||
let syncJson = lib.escapeString(JSON.stringify(techsToSync));
|
||||
await sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
|
||||
}
|
||||
await syncTechnologies();
|
||||
|
||||
// A first start counts as turning following on, which seeds an empty cluster queue
|
||||
await syncQueue(true);
|
||||
});
|
||||
|
||||
// QueueEvents sent while the controller session was down are lost, so take the cluster queue again.
|
||||
// An edit made on this instance during the outage was never sent and is replaced: the cluster queue wins.
|
||||
// Events are lost both ways while the controller session is down. Contributions are kept above, finished
|
||||
// research comes from the technology sync, and the cluster queue replaces queue edits made meanwhile.
|
||||
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
|
||||
if (event !== "connect" || !syncStarted || !["starting", "running"].includes(instance.status)) {
|
||||
if (event !== "connect" && event !== "resume") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await syncQueue(false);
|
||||
sendPendingContributions();
|
||||
if (!syncStarted || !["starting", "running"].includes(instance.status)) {
|
||||
return;
|
||||
}
|
||||
if (event === "connect" || finishedWhileDisconnected) {
|
||||
finishedWhileDisconnected = false;
|
||||
try {
|
||||
await syncTechnologies();
|
||||
} catch (err) {
|
||||
finishedWhileDisconnected = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (event === "connect") {
|
||||
await syncQueue(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
}
|
||||
|
||||
@@ -332,7 +332,8 @@ function research_sync.sync_technologies(data)
|
||||
end
|
||||
tech.researched = true
|
||||
progress = nil
|
||||
elseif tech_data[progressIndex] then
|
||||
-- may have finished here since the dump this sync is answering
|
||||
elseif tech_data[progressIndex] and not tech.researched then
|
||||
send_contribution(tech)
|
||||
progress = tech_data[progressIndex]
|
||||
set_technology_progress(tech, progress)
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -164,6 +164,55 @@ describe("research_sync controller", function() {
|
||||
});
|
||||
});
|
||||
|
||||
// What instances send on reconnecting: the contributions summed during the outage, then a technology sync
|
||||
describe("reconnect", function() {
|
||||
const contribute = (name, level, contribution) => mock.getHandler(controller, ContributionEvent)(
|
||||
new ContributionEvent(name, level, contribution), src
|
||||
);
|
||||
const syncTechs = async (...techs) => new Map(
|
||||
(await mock.getHandler(controller, SyncTechnologiesRequest)(new SyncTechnologiesRequest(techs), src))
|
||||
.map(tech => [tech.name, tech])
|
||||
);
|
||||
const finishedEvents = () => sent.filter(([, m]) => m instanceof FinishedEvent).map(([, m]) => m);
|
||||
|
||||
it("should count progress made on every instance during the outage", async function() {
|
||||
await contribute("automation", 1, 0.25);
|
||||
// A and B each contributed 0.25 from the controller's 0.25
|
||||
await contribute("automation", 1, 0.25);
|
||||
await syncTechs(new TechnologySync("automation", 1, 0.5, false));
|
||||
await contribute("automation", 1, 0.25);
|
||||
let techs = await syncTechs(new TechnologySync("automation", 1, 0.5, false));
|
||||
assert.equal(techs.get("automation").progress, 0.75);
|
||||
});
|
||||
it("should broadcast research finished during the outage once", async function() {
|
||||
await contribute("automation", 1, 0.5);
|
||||
await syncTechs(new TechnologySync("automation", 1, null, true));
|
||||
await syncTechs(new TechnologySync("automation", 1, null, true));
|
||||
assert.deepEqual(finishedEvents(), [new FinishedEvent("automation", 1)]);
|
||||
});
|
||||
const tech = (level, progress) => new TechnologySync("mining-productivity-3", level, progress, false);
|
||||
|
||||
it("should take a higher infinite research level from an instance", async function() {
|
||||
await contribute("mining-productivity-3", 4, 0.9);
|
||||
let techs = await syncTechs(tech(5, 0.2));
|
||||
assert.deepEqual(finishedEvents(), [new FinishedEvent("mining-productivity-3", 4)]);
|
||||
assert.deepEqual(techs.get("mining-productivity-3"), tech(5, 0.2));
|
||||
});
|
||||
it("should return a higher infinite research level to an instance", async function() {
|
||||
await syncTechs(tech(6, 0.1));
|
||||
sent.length = 0;
|
||||
let techs = await syncTechs(tech(4, 0.5));
|
||||
assert.equal(finishedEvents().length, 0);
|
||||
assert.deepEqual(techs.get("mining-productivity-3"), tech(6, 0.1));
|
||||
});
|
||||
it("should ignore contributions to a level finished elsewhere", async function() {
|
||||
await syncTechs(tech(5, null));
|
||||
await contribute("mining-productivity-3", 4, 0.5);
|
||||
let techs = await syncTechs();
|
||||
assert.deepEqual(techs.get("mining-productivity-3"), tech(5, null));
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist the queue across loads", async function() {
|
||||
await update(["a", "b"]);
|
||||
await controller.hooks.save.invoke();
|
||||
|
||||
@@ -5,11 +5,12 @@ import * as mock from "../../../test/mock.js";
|
||||
import loadInstancePlugin from "../dist/node/instance.js";
|
||||
import { plugin as info } from "../dist/node/index.js";
|
||||
import {
|
||||
QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest,
|
||||
ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest,
|
||||
TechnologySync,
|
||||
} from "../dist/node/messages.js";
|
||||
|
||||
describe("research_sync instance", function() {
|
||||
let instance, sent;
|
||||
let instance, host, sent;
|
||||
const clusterQueue = ["a", "b"];
|
||||
|
||||
before(function() {
|
||||
@@ -20,7 +21,7 @@ describe("research_sync instance", function() {
|
||||
}
|
||||
});
|
||||
beforeEach(async function() {
|
||||
({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info));
|
||||
({ instance, host } = await mock.loadInstancePlugin(loadInstancePlugin, info));
|
||||
sent = [];
|
||||
instance.sendTo = async (dst, message) => {
|
||||
sent.push(message);
|
||||
@@ -95,6 +96,7 @@ describe("research_sync instance", function() {
|
||||
await instance.hooks.start.invoke();
|
||||
sent.length = 0;
|
||||
instance.server.reset();
|
||||
instance.server.rconCommandResults.set("/sc research_sync.dump_technologies()", "[]");
|
||||
queueState({ follow: true, first: false });
|
||||
});
|
||||
|
||||
@@ -108,6 +110,31 @@ describe("research_sync instance", function() {
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.equal(sent.length, 0);
|
||||
});
|
||||
it("should sync technologies before the queue on a new session", async function() {
|
||||
await instance.hooks.controllerConnectionEvent.invoke("connect");
|
||||
assert.ok(sent[0] instanceof SyncTechnologiesRequest);
|
||||
assert.ok(sent[1] instanceof QueueSyncRequest);
|
||||
});
|
||||
it("should apply research finished elsewhere during the outage", async function() {
|
||||
instance.server.rconCommandResults.set(
|
||||
"/sc research_sync.dump_technologies()",
|
||||
JSON.stringify([{ name: "automation", level: 1, progress: 0.5, researched: false }]),
|
||||
);
|
||||
let controllerTechs = [new TechnologySync("automation", 1, null, true)];
|
||||
let sendTo = instance.sendTo;
|
||||
instance.sendTo = async (dst, message) => {
|
||||
if (message instanceof SyncTechnologiesRequest) {
|
||||
sent.push(message);
|
||||
return controllerTechs;
|
||||
}
|
||||
return sendTo(dst, message);
|
||||
};
|
||||
await instance.hooks.controllerConnectionEvent.invoke("connect");
|
||||
let syncRequest = sent.find(m => m instanceof SyncTechnologiesRequest);
|
||||
assert.deepEqual(syncRequest.technologies, [new TechnologySync("automation", 1, 0.5, false)]);
|
||||
let syncJson = lib.escapeString(JSON.stringify(controllerTechs));
|
||||
assert.ok(instance.server.rconCommands.includes(`/sc research_sync.sync_technologies("${syncJson}")`));
|
||||
});
|
||||
it("should do nothing while stopped", async function() {
|
||||
instance.status = "stopped";
|
||||
await instance.hooks.controllerConnectionEvent.invoke("connect");
|
||||
@@ -122,6 +149,9 @@ describe("research_sync instance", function() {
|
||||
// reply resolves the request's await
|
||||
instance.sendTo = async (dst, message) => {
|
||||
sent.push(message);
|
||||
if (message instanceof SyncTechnologiesRequest) {
|
||||
return [];
|
||||
}
|
||||
mock.getHandler(instance, QueueEvent)(new QueueEvent(["new"], null, "B"));
|
||||
return ["old"];
|
||||
};
|
||||
@@ -130,6 +160,84 @@ describe("research_sync instance", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller outage", function() {
|
||||
beforeEach(async function() {
|
||||
queueState({ follow: false });
|
||||
await instance.hooks.start.invoke();
|
||||
sent.length = 0;
|
||||
host.connector.connected = false;
|
||||
});
|
||||
const contributions = () => sent.filter(m => m instanceof ContributionEvent);
|
||||
const techSyncs = () => sent.filter(m => m instanceof SyncTechnologiesRequest);
|
||||
function contribute(name, level, contribution) {
|
||||
instance.server.emit("ipc-research_sync:contribution", { name, level, contribution });
|
||||
}
|
||||
|
||||
it("should send contributions while connected", async function() {
|
||||
host.connector.connected = true;
|
||||
contribute("automation", 1, 0.25);
|
||||
instance.server.emit("ipc-research_sync:finished", { name: "automation", level: 1 });
|
||||
assert.ok(sent[0] instanceof ContributionEvent);
|
||||
assert.ok(sent[1] instanceof FinishedEvent);
|
||||
});
|
||||
it("should sum contributions and send them on a new session", async function() {
|
||||
contribute("automation", 1, 0.25);
|
||||
contribute("automation", 1, 0.25);
|
||||
contribute("logistics", 1, 0.125);
|
||||
assert.equal(sent.length, 0);
|
||||
host.connector.connected = true;
|
||||
await instance.hooks.controllerConnectionEvent.invoke("connect");
|
||||
assert.deepEqual(
|
||||
contributions().map(m => [m.name, m.level, m.contribution]),
|
||||
[["automation", 1, 0.5], ["logistics", 1, 0.125]],
|
||||
);
|
||||
assert.ok(sent.indexOf(contributions()[1]) < sent.indexOf(techSyncs()[0]));
|
||||
});
|
||||
it("should keep only contributions to the highest level", async function() {
|
||||
contribute("mining-productivity-3", 3, 0.5);
|
||||
contribute("mining-productivity-3", 4, 0.25);
|
||||
contribute("mining-productivity-3", 3, 0.5);
|
||||
host.connector.connected = true;
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.deepEqual(
|
||||
contributions().map(m => [m.name, m.level, m.contribution]),
|
||||
[["mining-productivity-3", 4, 0.25]],
|
||||
);
|
||||
});
|
||||
it("should send contributions once", async function() {
|
||||
contribute("automation", 1, 0.25);
|
||||
host.connector.connected = true;
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.equal(contributions().length, 1);
|
||||
});
|
||||
it("should not sync technologies on resume when nothing finished", async function() {
|
||||
contribute("automation", 1, 0.25);
|
||||
host.connector.connected = true;
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.equal(techSyncs().length, 0);
|
||||
});
|
||||
it("should sync technologies on resume when research finished", async function() {
|
||||
instance.server.emit("ipc-research_sync:finished", { name: "automation", level: 1 });
|
||||
assert.equal(sent.length, 0);
|
||||
host.connector.connected = true;
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.equal(techSyncs().length, 1);
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.equal(techSyncs().length, 1);
|
||||
});
|
||||
it("should retry the technology sync after it fails on resume", async function() {
|
||||
instance.server.emit("ipc-research_sync:finished", { name: "automation", level: 1 });
|
||||
host.connector.connected = true;
|
||||
let sendTo = instance.sendTo;
|
||||
instance.sendTo = async () => { throw new Error("gone"); };
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
instance.sendTo = sendTo;
|
||||
await instance.hooks.controllerConnectionEvent.invoke("resume");
|
||||
assert.equal(techSyncs().length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should forward queue changes from the game", async function() {
|
||||
instance.server.emit("ipc-research_sync:queue", { queue: {}, player: "Bob" });
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
* added and removed messages are only warned about since breaking
|
||||
* compatibility is unavoidable there. Optional properties that only one
|
||||
* version has are left out of the comparison, as the other version ignores
|
||||
* them when received and does not need them when sent.
|
||||
* them when received and does not need them when sent. Control clients are
|
||||
* left out too, as the web UI is served by the controller it talks to, so
|
||||
* control is removed from src and dst and messages that only go between
|
||||
* control and the controller are skipped.
|
||||
*
|
||||
* Intended breaks are listed in scripts/version-compat-breaks.json, see
|
||||
* scripts/version-compat-breaks.js for the format.
|
||||
@@ -196,6 +199,15 @@ const dumpMessageSpecs = `
|
||||
console.log(JSON.stringify(specs));
|
||||
`;
|
||||
|
||||
function withoutControl(spec) {
|
||||
const strip = types => types.filter(type => type !== "control");
|
||||
return { ...spec, src: strip(spec.src), dst: strip(spec.dst) };
|
||||
}
|
||||
|
||||
function isControlOnly(spec) {
|
||||
return [...spec.src, ...spec.dst].every(type => type === "control" || type === "controller");
|
||||
}
|
||||
|
||||
async function messageSpecs(libPath) {
|
||||
const result = await execFile(process.execPath, ["-e", dumpMessageSpecs, libPath]);
|
||||
return new Map(Object.entries(JSON.parse(result.stdout)));
|
||||
@@ -215,12 +227,19 @@ async function checkMessageCompat(publishedVersion, knownBreaks) {
|
||||
console.log(`| comparing ${localSpecs.size} local with ${publishedSpecs.size} published link messages`);
|
||||
|
||||
const changed = [];
|
||||
for (const [name, localSpec] of localSpecs) {
|
||||
const publishedSpec = publishedSpecs.get(name);
|
||||
if (!publishedSpec) {
|
||||
annotate(`Link ${name} does not exist in ${publishedVersion} which cannot receive it`);
|
||||
for (const [name, fullLocalSpec] of localSpecs) {
|
||||
const fullPublishedSpec = publishedSpecs.get(name);
|
||||
if (!fullPublishedSpec) {
|
||||
if (!isControlOnly(fullLocalSpec)) {
|
||||
annotate(`Link ${name} does not exist in ${publishedVersion} which cannot receive it`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isControlOnly(fullLocalSpec) && isControlOnly(fullPublishedSpec)) {
|
||||
continue;
|
||||
}
|
||||
const localSpec = withoutControl(fullLocalSpec);
|
||||
const publishedSpec = withoutControl(fullPublishedSpec);
|
||||
const differs = Object.keys(localSpec).filter(
|
||||
field => stableStringify(localSpec[field]) !== stableStringify(publishedSpec[field])
|
||||
);
|
||||
@@ -238,8 +257,8 @@ async function checkMessageCompat(publishedVersion, knownBreaks) {
|
||||
changed.push({ name, fields, localSpec, publishedSpec });
|
||||
}
|
||||
}
|
||||
for (const name of publishedSpecs.keys()) {
|
||||
if (!localSpecs.has(name)) {
|
||||
for (const [name, publishedSpec] of publishedSpecs) {
|
||||
if (!localSpecs.has(name) && !isControlOnly(publishedSpec)) {
|
||||
annotate(`Link ${name} was removed after ${publishedVersion} which may still send it`);
|
||||
}
|
||||
}
|
||||
@@ -279,6 +298,48 @@ async function pollCtl(ctl, cliArgs, expectRegex, description, timeoutMs = 30e3)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the host with the running controller and write its config
|
||||
*
|
||||
* The pairing API is probed instead of checking which side is local, as releases that support
|
||||
* pairing no longer have ctl host create-config to fall back on.
|
||||
*
|
||||
* @returns true if the host was paired, false if the controller predates host pairing.
|
||||
*/
|
||||
async function pairHostWithController() {
|
||||
// Written by bootstrap create-ctl-config, holds the admin token which approves the host directly.
|
||||
const ctlConfig = JSON.parse(await fs.readFile(path.join(controllerDir, "config-ctl.json"), "utf8"));
|
||||
const controllerUrl = ctlConfig["ctl.controller_url"];
|
||||
console.log(`| pairing host with ${controllerUrl}`);
|
||||
// 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.
|
||||
const hostConfig = {
|
||||
"host.id": pairing.hostId,
|
||||
"host.name": pairing.name,
|
||||
"host.controller_url": controllerUrl,
|
||||
"host.controller_token": pairing.token,
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(hostDir, "config-host.json"), JSON.stringify(hostConfig, null, "\t"), { flag: "wx", mode: 0o600 }
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
const breaks = await loadBreaks();
|
||||
@@ -337,10 +398,12 @@ async function main() {
|
||||
);
|
||||
|
||||
try {
|
||||
await run("ctl", ctl, [
|
||||
"host", "create-config", "--id", "1", "--name", "host", "--generate-token",
|
||||
"--output", path.join(hostDir, "config-host.json"),
|
||||
], controllerDir);
|
||||
if (!await pairHostWithController()) {
|
||||
await run("ctl", ctl, [
|
||||
"host", "create-config", "--id", "1", "--name", "host", "--generate-token",
|
||||
"--output", path.join(hostDir, "config-host.json"),
|
||||
], controllerDir);
|
||||
}
|
||||
|
||||
// Started host is only logged after the connection to the controller is established.
|
||||
const hostProc = await spawnDaemon("host", host, ["run"], hostDir, /Started host/);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, ControlConnection, ControllerHooks, InstanceManager } from "@clusterio/controller";
|
||||
import {
|
||||
Controller, ControlConnection, ControllerHooks, HostConnection, HostRecord, InstanceManager, InstanceRecord,
|
||||
} from "@clusterio/controller";
|
||||
import { MockConnector, MockController, MockLogger } from "../mock.js";
|
||||
import "../setup_logging.js";
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
@@ -272,4 +275,164 @@ describe("controller/src/ControlConnection", function() {
|
||||
assert.equal(player.connector.terminated, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host management", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {ControlConnection} */
|
||||
let connection;
|
||||
let audits;
|
||||
let originalAudit;
|
||||
|
||||
beforeEach(function() {
|
||||
const config = new lib.ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
||||
controller = new Controller(lib.logger, [], config);
|
||||
const connector = new lib.VirtualConnector(addr("controller"), addr({ controlId: 1 }));
|
||||
const user = controller.users.getOrCreateUser("admin");
|
||||
connection = new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1);
|
||||
audits = [];
|
||||
originalAudit = lib.logger.audit;
|
||||
lib.logger.audit = msg => { audits.push(msg); };
|
||||
});
|
||||
afterEach(function() {
|
||||
lib.logger.audit = originalAudit;
|
||||
});
|
||||
|
||||
describe(".handleHostRevokeRequest()", function() {
|
||||
let record;
|
||||
let broadcasts;
|
||||
|
||||
beforeEach(function() {
|
||||
record = new HostRecord(10, "host-10", "", new Map());
|
||||
controller.hosts.set(record);
|
||||
broadcasts = [];
|
||||
controller.subscriptions.broadcast = event => {
|
||||
if (event instanceof lib.HostUpdatesEvent) {
|
||||
broadcasts.push(...event.updates.map(host => ({ id: host.id, isDeleted: host.isDeleted })));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function assignInstance(id, name, hostId) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.set("instance.id", id);
|
||||
instanceConfig.set("instance.name", name);
|
||||
instanceConfig.set("instance.assigned_host", hostId);
|
||||
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
|
||||
}
|
||||
|
||||
async function revoke(hostId = 10) {
|
||||
await connection.handleHostRevokeRequest(new lib.HostRevokeRequest(hostId));
|
||||
}
|
||||
|
||||
it("deletes the host record", async function() {
|
||||
await revoke();
|
||||
assert.equal(controller.hosts.has(10), false);
|
||||
assert.deepEqual(broadcasts, [{ id: 10, isDeleted: true }]);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.match(audits[0], /User admin revoked host host-10 \(10\)/);
|
||||
});
|
||||
it("deletes the note of the host", async function() {
|
||||
controller.setNote("host", "10", "rack 3", "someone");
|
||||
controller.setNote("host", "11", "rack 4", "someone");
|
||||
await revoke();
|
||||
const note = controller.notes.get("host/10");
|
||||
assert.equal(note.isDeleted, true);
|
||||
assert.equal(note.content, "");
|
||||
assert.equal(note.updatedBy, "admin");
|
||||
assert.equal(controller.notes.get("host/11").isDeleted, false);
|
||||
});
|
||||
it("terminates the connection of the host", async function() {
|
||||
let terminated = false;
|
||||
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
|
||||
await revoke();
|
||||
assert.equal(terminated, true);
|
||||
});
|
||||
it("does not leave a host record behind when the connection closes", async function() {
|
||||
const connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
|
||||
connector.terminate = () => { connector.emit("close"); };
|
||||
const hostConnection = new HostConnection(
|
||||
new lib.RegisterHostData("", "2.0.0", 10, {}), connector, controller, "10.0.0.1"
|
||||
);
|
||||
controller.wsServer.hostConnections.set(10, hostConnection);
|
||||
assert.equal(controller.hosts.get(10).connected, true);
|
||||
|
||||
await revoke();
|
||||
assert.equal(controller.hosts.has(10), false, "host record was recreated");
|
||||
assert.deepEqual(broadcasts.at(-1), { id: 10, isDeleted: true });
|
||||
});
|
||||
it("unassigns the instances of the host without waiting for it", async function() {
|
||||
let closing = false;
|
||||
controller.wsServer.hostConnections.set(10, {
|
||||
connector: { terminate() { closing = true; }, get closing() { return closing; } },
|
||||
send: () => new Promise(() => {}),
|
||||
});
|
||||
assignInstance(101, "alpha", 10);
|
||||
assignInstance(102, "beta", 10);
|
||||
assignInstance(201, "gamma", 20);
|
||||
|
||||
await revoke();
|
||||
assert.equal(controller.hosts.has(10), false);
|
||||
for (const id of [101, 102]) {
|
||||
const instance = controller.instances.get(id);
|
||||
assert.equal(instance.config.get("instance.assigned_host"), null);
|
||||
assert.equal(instance.status, "unassigned");
|
||||
}
|
||||
assert.equal(controller.instances.get(201).config.get("instance.assigned_host"), 20);
|
||||
});
|
||||
it("removes the system metrics of the host", async function() {
|
||||
controller.systems.set(await lib.gatherSystemInfo(10, false, false, 0));
|
||||
controller.systems.set(await lib.gatherSystemInfo("controller", false, false, 0));
|
||||
await revoke();
|
||||
assert.equal(controller.systems.has(10), false);
|
||||
assert.equal(controller.systems.has("controller"), true);
|
||||
});
|
||||
it("throws NotFoundError for an unknown host", async function() {
|
||||
await assert.rejects(revoke(99), new lib.NotFoundError("Unknown host id (99)"));
|
||||
assert.equal(controller.hosts.has(10), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host pairing requests", function() {
|
||||
it("lists pending requests", async function() {
|
||||
const first = controller.hostPairing.create("Host A", "2.0.0", "10.0.0.1");
|
||||
const second = controller.hostPairing.create("Host B", "2.0.0", "10.0.0.2");
|
||||
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
|
||||
assert(list.every(details => details instanceof lib.HostPairingDetails));
|
||||
assert.deepEqual(list.map(details => details.code).sort(), [first.code, second.code].sort());
|
||||
assert.equal(list.find(details => details.code === first.code).name, "Host A");
|
||||
});
|
||||
it("leaves out expired, approved and rejected requests when listing", async function() {
|
||||
const expired = controller.hostPairing.create("Host A", "", "10.0.0.1");
|
||||
const approved = controller.hostPairing.create("Host B", "", "10.0.0.2");
|
||||
const rejected = controller.hostPairing.create("Host C", "", "10.0.0.3");
|
||||
const pending = controller.hostPairing.create("Host D", "", "10.0.0.4");
|
||||
controller.hostPairing._bySecret.get(expired.requestId).lastPollMs -= 60e3;
|
||||
controller.hostPairing.approve(approved.code, undefined, "admin");
|
||||
controller.hostPairing.reject(rejected.code, "admin");
|
||||
|
||||
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
|
||||
assert.deepEqual(list.map(details => details.code), [pending.code]);
|
||||
});
|
||||
it("approves a request and returns the new host id", async function() {
|
||||
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
|
||||
const hostId = await connection.handleHostPairingApproveRequest(
|
||||
new lib.HostPairingApproveRequest(code.toLowerCase().replace("-", ""))
|
||||
);
|
||||
assert.equal(typeof hostId, "number");
|
||||
assert.equal(controller.hosts.get(hostId).name, "Host A");
|
||||
assert.equal(controller.hostPairing.poll(requestId).hostId, hostId);
|
||||
assert(audits.some(msg => msg.startsWith(`User admin approved host Host A (${hostId})`)));
|
||||
});
|
||||
it("rejects a request", async function() {
|
||||
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
|
||||
await connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code.toLowerCase()));
|
||||
assert.equal(controller.hostPairing.records.has(code), false);
|
||||
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,75 @@ describe("controller/src/Controller", function() {
|
||||
assert(loaded.modPacks.size > 0, "default mod packs were not added");
|
||||
await fs.access(modsDirectory);
|
||||
});
|
||||
it("should start and stop with an existing database", async function() {
|
||||
this.timeout(10000);
|
||||
const databaseDirectory = path.join(dir, "database");
|
||||
await fs.mkdir(databaseDirectory);
|
||||
const records = {
|
||||
"hosts.json": [new HostRecord(5, "existing-host", controllerVersion, new Map(), true)],
|
||||
"instances.json": [
|
||||
new InstanceRecord(new InstanceConfig("controller", {
|
||||
"instance.id": 100, "instance.assigned_host": 5,
|
||||
}), "running"),
|
||||
new InstanceRecord(new InstanceConfig("controller", { "instance.id": 101 }), "unassigned"),
|
||||
],
|
||||
"mod-packs.json": [ModPack.fromJSON({ id: 7, name: "existing-pack" })],
|
||||
"users.json": [new UserRecord(0, "admin", new Set([0]), new Set(), true)],
|
||||
};
|
||||
for (const [file, content] of Object.entries(records)) {
|
||||
await fs.writeFile(path.join(databaseDirectory, file), JSON.stringify(content));
|
||||
}
|
||||
|
||||
// Port 0 means not configured, so ask the OS for a free one.
|
||||
const portProbe = http.createServer();
|
||||
portProbe.listen(0, "127.0.0.1");
|
||||
await events.once(portProbe, "listening");
|
||||
const port = portProbe.address().port;
|
||||
portProbe.close();
|
||||
await events.once(portProbe, "close");
|
||||
|
||||
const config = new ControllerConfig("controller", {
|
||||
"controller.version": controllerVersion,
|
||||
"controller.database_directory": databaseDirectory,
|
||||
"controller.mods_directory": path.join(dir, "mods"),
|
||||
"controller.http_port": port,
|
||||
"controller.bind_address": "127.0.0.1",
|
||||
});
|
||||
|
||||
const errors = [];
|
||||
const errorCapture = new lib.ConsoleTransport({
|
||||
level: "error",
|
||||
filter: info => {
|
||||
// pnpm build does not produce the production web bundle
|
||||
if (!info.message.includes("Missing main entry")) {
|
||||
errors.push(info.message);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
lib.logger.add(errorCapture);
|
||||
let loaded;
|
||||
try {
|
||||
loaded = await Controller.fromConfig(config, [], lib.logger);
|
||||
await loaded.start({ logDirectory: path.join(dir, "logs"), _: ["run"] });
|
||||
assert.deepEqual(
|
||||
[...loaded.instances.values()].map(instance => [instance.id, instance.status]),
|
||||
[[100, "unknown"], [101, "unassigned"]],
|
||||
);
|
||||
assert.equal(loaded.hosts.get(5).connected, false);
|
||||
assert.equal(loaded.modPacks.get(7).name, "existing-pack");
|
||||
assert(loaded.users.getByName("admin"), "existing user was not loaded");
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(response.status, 200);
|
||||
await response.text();
|
||||
} finally {
|
||||
await loaded?.stop();
|
||||
lib.logger.remove(errorCapture);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(databaseDirectory, "instances.json"), "utf8"));
|
||||
assert.deepEqual(saved.map(instance => instance.config["instance.id"]), [100, 101]);
|
||||
});
|
||||
describe("with a plugin version change", function() {
|
||||
before(function() {
|
||||
addPluginConfigFields([{ name: "from_config_plugin", version: "1.2.0", instanceEntrypoint: "x" }]);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { HostConnection } from "@clusterio/controller";
|
||||
import { Controller, HostConnection, HostRecord } from "@clusterio/controller";
|
||||
import { MockConnector } from "../mock.js";
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
function makeSave(instanceId, name) {
|
||||
return new lib.SaveDetails(instanceId, "file", name, 0, 0, false, false, 0, false);
|
||||
@@ -40,6 +43,99 @@ function makeInstances(assignments, names = new Map()) {
|
||||
}
|
||||
|
||||
describe("controller/src/HostConnection", function() {
|
||||
describe("host record", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
let record;
|
||||
let connector;
|
||||
let updates;
|
||||
|
||||
beforeEach(function() {
|
||||
controller = new Controller(lib.logger, [], new lib.ControllerConfig("controller"));
|
||||
record = new HostRecord(10, "host-10", "1.0.0", new Map(), false, "", "203.0.113.1", 123);
|
||||
controller.hosts.set(record);
|
||||
connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
|
||||
updates = [];
|
||||
controller.hosts.on("update", changed => {
|
||||
updates.push(...changed.map(host => ({ id: host.id, name: host.name, isDeleted: host.isDeleted })));
|
||||
});
|
||||
});
|
||||
|
||||
function connect(id = 10) {
|
||||
return new HostConnection(
|
||||
new lib.RegisterHostData("", "2.0.0", id, {}), connector, controller, "10.0.0.1"
|
||||
);
|
||||
}
|
||||
|
||||
function revoke() {
|
||||
controller.hosts.delete(record);
|
||||
updates.length = 0;
|
||||
}
|
||||
|
||||
it("should refuse a host without a record", function() {
|
||||
assert.throws(() => connect(11), /Host 11 is not registered/);
|
||||
assert.equal(controller.hosts.has(11), false);
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
it("should update the existing record on connect", function() {
|
||||
const connection = connect();
|
||||
assert.equal(controller.hosts.get(10), record, "host record was replaced");
|
||||
assert.equal(connection.id, 10);
|
||||
assert.equal(record.connected, true);
|
||||
assert.equal(record.version, "2.0.0");
|
||||
assert.equal(record.remoteAddress, "10.0.0.1");
|
||||
assert.equal(record.name, "host-10");
|
||||
assert.equal(record.publicAddress, "203.0.113.1");
|
||||
assert.equal(record.tokenValidAfter, 123);
|
||||
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
|
||||
});
|
||||
it("should mark the host disconnected when the connection closes", function() {
|
||||
connect();
|
||||
updates.length = 0;
|
||||
connector.emit("close");
|
||||
assert.equal(controller.hosts.get(10), record);
|
||||
assert.equal(record.connected, false);
|
||||
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
|
||||
});
|
||||
it("should save info updates from the host", async function() {
|
||||
const connection = connect();
|
||||
updates.length = 0;
|
||||
await connection.handleHostInfoUpdateEvent(
|
||||
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
|
||||
);
|
||||
assert.equal(controller.hosts.get(10).name, "renamed");
|
||||
assert.equal(controller.hosts.get(10).publicAddress, "198.51.100.1");
|
||||
assert.deepEqual(updates, [{ id: 10, name: "renamed", isDeleted: false }]);
|
||||
});
|
||||
it("should not recreate the record of a revoked host when the connection closes", function() {
|
||||
connect();
|
||||
revoke();
|
||||
connector.emit("close");
|
||||
assert.equal(controller.hosts.has(10), false, "host record was recreated");
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
it("should not recreate the record of a revoked host on info updates", async function() {
|
||||
const connection = connect();
|
||||
revoke();
|
||||
await connection.handleHostInfoUpdateEvent(
|
||||
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
|
||||
);
|
||||
assert.equal(controller.hosts.has(10), false, "host record was recreated");
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
it("should not overwrite a newer record with the same id", function() {
|
||||
connect();
|
||||
revoke();
|
||||
const newer = new HostRecord(10, "newer", "", new Map());
|
||||
controller.hosts.set(newer);
|
||||
updates.length = 0;
|
||||
connector.emit("close");
|
||||
assert.equal(controller.hosts.get(10), newer);
|
||||
assert.equal(newer.name, "newer");
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".handleInstanceSaveDetailsUpdatesEvent()", function() {
|
||||
// Instance 1 is on host 10, instance 2 is on host 20.
|
||||
const assignments = new Map([[1, 10], [2, 20]]);
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, HostPairingManager, HostPairingError, HostRecord } from "@clusterio/controller";
|
||||
import {
|
||||
pollTimeoutMs, maxPendingPerAddress, maxNameLength,
|
||||
} from "@clusterio/controller/dist/node/src/HostPairingManager.js";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
const codePattern = /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/;
|
||||
|
||||
function makeController() {
|
||||
const config = new lib.ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
||||
return new Controller(lib.logger, [], config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make crypto.randomInt return the given values before falling back to random ones
|
||||
*
|
||||
* @param {number[]} values - Values to return, in order.
|
||||
* @returns {() => void} function restoring crypto.randomInt.
|
||||
*/
|
||||
function stubRandomInt(values) {
|
||||
const original = crypto.randomInt;
|
||||
crypto.randomInt = (...args) => (values.length ? values.shift() : original(...args));
|
||||
return () => { crypto.randomInt = original; };
|
||||
}
|
||||
|
||||
function assertPairingError(fn, status, message) {
|
||||
assert.throws(fn, err => {
|
||||
assert(err instanceof HostPairingError, `expected HostPairingError, got ${err}`);
|
||||
assert.equal(err.status, status);
|
||||
if (message) {
|
||||
assert.equal(err.message, message);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
describe("controller/src/HostPairingManager", function() {
|
||||
|
||||
describe("HostPairingManager.validateName()", function() {
|
||||
it("should require a non-empty string", function() {
|
||||
for (const name of [undefined, null, 5, {}, "", " "]) {
|
||||
assertPairingError(() => HostPairingManager.validateName(name), 400, "Host name is required");
|
||||
}
|
||||
});
|
||||
it("should trim the name", function() {
|
||||
assert.equal(HostPairingManager.validateName(" Host A \t"), "Host A");
|
||||
});
|
||||
it("should limit the length of the name", function() {
|
||||
const longest = "x".repeat(maxNameLength);
|
||||
assert.equal(maxNameLength, 100);
|
||||
assert.equal(HostPairingManager.validateName(longest), longest);
|
||||
assert.equal(HostPairingManager.validateName(` ${longest} `), longest);
|
||||
assertPairingError(
|
||||
() => HostPairingManager.validateName(`${longest}x`),
|
||||
400, "Host name must be at most 100 characters",
|
||||
);
|
||||
});
|
||||
it("should refuse control and bidirectional override characters", function() {
|
||||
for (const name of ["x\u001b]0;title\u0007", "line\nfaked", "a\rb", "del\u007f", "c1\u009b", "x\u202ey"]) {
|
||||
assertPairingError(
|
||||
() => HostPairingManager.validateName(name), 400, "Host name may not contain control characters",
|
||||
);
|
||||
}
|
||||
});
|
||||
it("should allow other unicode characters", function() {
|
||||
for (const name of ["Fabrikk p\u00e5 \u00d8stlandet", "\u5de5\u5382 1", "box \u{1F44D}\u200d"]) {
|
||||
assert.equal(HostPairingManager.validateName(name), name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("class HostPairingManager", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {HostPairingManager} */
|
||||
let manager;
|
||||
let updates;
|
||||
let restoreRandomInt;
|
||||
|
||||
beforeEach(function() {
|
||||
controller = makeController();
|
||||
manager = controller.hostPairing;
|
||||
updates = [];
|
||||
manager.records.on("update", changed => {
|
||||
updates.push(...changed.map(details => ({ code: details.code, isDeleted: details.isDeleted })));
|
||||
});
|
||||
restoreRandomInt = undefined;
|
||||
});
|
||||
afterEach(function() {
|
||||
manager.stop();
|
||||
if (restoreRandomInt) {
|
||||
restoreRandomInt();
|
||||
}
|
||||
});
|
||||
|
||||
function internal(requestId) {
|
||||
return manager._bySecret.get(requestId);
|
||||
}
|
||||
|
||||
// Pretend the host last polled the request ms milliseconds earlier.
|
||||
function idle(requestId, ms) {
|
||||
internal(requestId).lastPollMs -= ms;
|
||||
}
|
||||
|
||||
describe(".createHost()", function() {
|
||||
it("should create a host record with a valid token", function() {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const result = manager.createHost("Host A");
|
||||
|
||||
assert.equal(result.name, "Host A");
|
||||
assert(Number.isInteger(result.hostId), "hostId is not an integer");
|
||||
assert(result.hostId >= 1 && result.hostId < 2 ** 31, `hostId ${result.hostId} out of range`);
|
||||
|
||||
const record = controller.hosts.get(result.hostId);
|
||||
assert(record instanceof HostRecord, "host record was not created");
|
||||
assert.equal(record.name, "Host A");
|
||||
assert.equal(record.connected, false);
|
||||
assert(record.tokenValidAfter >= before, "tokenValidAfter predates the call");
|
||||
assert(record.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
|
||||
|
||||
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
|
||||
assert.equal(payload.host, result.hostId);
|
||||
assert(payload.iat >= record.tokenValidAfter, "token is not valid for the new record");
|
||||
});
|
||||
it("should not reuse the id of an existing host", function() {
|
||||
controller.hosts.set(new HostRecord(5, "existing", "", new Map()));
|
||||
restoreRandomInt = stubRandomInt([5, 5, 6]);
|
||||
const result = manager.createHost("Host A");
|
||||
assert.equal(result.hostId, 6);
|
||||
assert.equal(controller.hosts.get(5).name, "existing");
|
||||
assert.equal(controller.hosts.get(6).name, "Host A");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".create()", function() {
|
||||
it("should create a pending request", function() {
|
||||
const before = Date.now();
|
||||
const { requestId, code } = manager.create("Host A", "2.0.0", "10.0.0.1");
|
||||
|
||||
assert.equal(typeof requestId, "string");
|
||||
assert(requestId.length >= 32, "requestId is too short to be a secret");
|
||||
assert.match(code, codePattern);
|
||||
assert.notEqual(requestId, code);
|
||||
|
||||
const details = manager.records.get(code);
|
||||
assert(details instanceof lib.HostPairingDetails);
|
||||
assert.equal(details.code, code);
|
||||
assert.equal(details.name, "Host A");
|
||||
assert.equal(details.version, "2.0.0");
|
||||
assert.equal(details.remoteAddress, "10.0.0.1");
|
||||
assert(details.createdAtMs >= before && details.createdAtMs <= Date.now());
|
||||
assert(details.updatedAtMs > 0);
|
||||
assert.equal(details.isDeleted, false);
|
||||
assert(!JSON.stringify(details).includes(requestId), "requestId is exposed to users");
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }]);
|
||||
});
|
||||
it("should create unique codes", function() {
|
||||
const codes = new Set();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
||||
assert.match(code, codePattern);
|
||||
codes.add(code);
|
||||
}
|
||||
assert.equal(codes.size, 50);
|
||||
});
|
||||
it("should not reuse the code of a pending request", function() {
|
||||
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
assert.equal(manager.create("Host A", "", "10.0.0.1").code, "222-222");
|
||||
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
|
||||
});
|
||||
it("should not reuse the code of an approved request not yet picked up", function() {
|
||||
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
assert.equal(code, "222-222");
|
||||
assert.equal(manager.approve(code, undefined, "admin"), 1234);
|
||||
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
|
||||
});
|
||||
});
|
||||
|
||||
describe("limits", function() {
|
||||
it("should limit pending requests per address", function() {
|
||||
assert.equal(maxPendingPerAddress, 3);
|
||||
for (let i = 0; i < maxPendingPerAddress; i++) {
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
}
|
||||
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
|
||||
assert(err instanceof HostPairingError);
|
||||
assert.equal(err.status, 429);
|
||||
assert.equal(err.retryAfter, 30);
|
||||
assert.equal(err.message, "Too many pending requests from this address");
|
||||
return true;
|
||||
});
|
||||
// Other addresses are not affected
|
||||
manager.create("Host", "", "10.0.0.2");
|
||||
assert.equal(manager.records.size, maxPendingPerAddress + 1);
|
||||
});
|
||||
it("should count IPv4 mapped addresses as the IPv4 address", function() {
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
manager.create("Host", "", "::ffff:10.0.0.1");
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "::ffff:10.0.0.1"), 429);
|
||||
});
|
||||
it("should group IPv6 addresses by /64", function() {
|
||||
manager.create("Host", "", "2001:db8:0:1::1");
|
||||
manager.create("Host", "", "2001:db8:0:1::2");
|
||||
manager.create("Host", "", "2001:db8::1:ffff:0:0:3");
|
||||
assertPairingError(
|
||||
() => manager.create("Host", "", "2001:0db8:0000:0001:0000:0000:0000:0004"),
|
||||
429, "Too many pending requests from this address",
|
||||
);
|
||||
manager.create("Host", "", "2001:db8:0:2::1");
|
||||
});
|
||||
it("should free the slot when a request is no longer pending", function() {
|
||||
const first = manager.create("Host", "", "10.0.0.1");
|
||||
const second = manager.create("Host", "", "10.0.0.1");
|
||||
const third = manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
||||
|
||||
manager.cancel(first.requestId);
|
||||
const fourth = manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
||||
|
||||
// Approved and rejected results waiting to be picked up do not count
|
||||
manager.approve(second.code, undefined, "admin");
|
||||
manager.reject(third.code, "admin");
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
||||
assert(manager.records.has(fourth.code));
|
||||
});
|
||||
it("should limit the total number of pending requests", function() {
|
||||
const maxPending = controller.config.get("controller.host_pairing_max_pending");
|
||||
assert.equal(maxPending, 50);
|
||||
for (let i = 0; i < maxPending; i++) {
|
||||
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
||||
}
|
||||
assert.throws(() => manager.create("Host", "", "10.2.0.1"), err => {
|
||||
assert(err instanceof HostPairingError);
|
||||
assert.equal(err.status, 429);
|
||||
assert.equal(err.retryAfter, 30);
|
||||
assert.equal(err.message, "Too many pending requests");
|
||||
return true;
|
||||
});
|
||||
assert.equal(manager.records.size, maxPending);
|
||||
});
|
||||
it("should not limit the total number of pending requests when set to 0", function() {
|
||||
controller.config.set("controller.host_pairing_max_pending", 0);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
||||
}
|
||||
assert.equal(manager.records.size, 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".poll()", function() {
|
||||
it("should return null while pending", function() {
|
||||
const { requestId } = manager.create("Host A", "", "10.0.0.1");
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
});
|
||||
it("should throw 404 for an unknown request", function() {
|
||||
assertPairingError(() => manager.poll("invalid"), 404, "Request not found");
|
||||
});
|
||||
it("should not accept the code in place of the request id", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.poll(code), 404);
|
||||
});
|
||||
it("should hand over an approved result once", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
const result = manager.poll(requestId);
|
||||
assert.deepEqual(Object.keys(result).sort(), ["hostId", "name", "token"]);
|
||||
assert.equal(result.hostId, hostId);
|
||||
assert.equal(result.name, "Host A");
|
||||
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
|
||||
assert.equal(payload.host, hostId);
|
||||
|
||||
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
|
||||
assert.equal(manager._byCode.has(code), false);
|
||||
});
|
||||
it("should report a rejection once", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
assertPairingError(() => manager.poll(requestId), 403, "Request was rejected");
|
||||
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".cancel()", function() {
|
||||
it("should remove a pending request", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.cancel(requestId);
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
||||
});
|
||||
it("should ignore unknown requests", function() {
|
||||
manager.cancel("invalid");
|
||||
});
|
||||
it("should not throw away an approved result", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
manager.cancel(requestId);
|
||||
assert.equal(manager.poll(requestId).hostId, hostId);
|
||||
});
|
||||
it("should not throw away a rejection", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
manager.cancel(requestId);
|
||||
assertPairingError(() => manager.poll(requestId), 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".approve()", function() {
|
||||
it("should create the host with the requested name", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
assert.equal(controller.hosts.get(hostId).name, "Host A");
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
});
|
||||
it("should accept codes in any case and without the dash", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
|
||||
assert(controller.hosts.has(hostId));
|
||||
});
|
||||
it("should use and trim the name given", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, " Renamed ", "admin");
|
||||
assert.equal(controller.hosts.get(hostId).name, "Renamed");
|
||||
assert.equal(manager.poll(requestId).name, "Renamed");
|
||||
});
|
||||
it("should reject an invalid name and keep the request pending", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
assert.throws(
|
||||
() => manager.approve(code, "", "admin"),
|
||||
new lib.ValidationError("Host name is required"),
|
||||
);
|
||||
assert.throws(
|
||||
() => manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
|
||||
new lib.ValidationError("Host name must be at most 100 characters"),
|
||||
);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
manager.approve(code, undefined, "admin");
|
||||
assert.equal(controller.hosts.size, 1);
|
||||
});
|
||||
it("should throw NotFoundError for an unknown code", function() {
|
||||
assert.throws(
|
||||
() => manager.approve("ZZZ-ZZZ", undefined, "admin"),
|
||||
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
|
||||
);
|
||||
});
|
||||
it("should only approve a request once", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.approve(code, undefined, "admin");
|
||||
assert.throws(() => manager.approve(code, undefined, "admin"), lib.NotFoundError);
|
||||
assert.equal(controller.hosts.size, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".reject()", function() {
|
||||
it("should remove the request from the pending list", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code.toLowerCase(), "admin");
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
});
|
||||
it("should throw NotFoundError for an unknown code", function() {
|
||||
assert.throws(
|
||||
() => manager.reject("ZZZ-ZZZ", "admin"),
|
||||
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
|
||||
);
|
||||
});
|
||||
it("should not reject an approved request", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
||||
assert.equal(manager.poll(requestId).hostId, hostId);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".prune()", function() {
|
||||
let warnings;
|
||||
let originalWarn;
|
||||
beforeEach(function() {
|
||||
warnings = [];
|
||||
originalWarn = lib.logger.warn;
|
||||
lib.logger.warn = msg => { warnings.push(msg); };
|
||||
});
|
||||
afterEach(function() {
|
||||
lib.logger.warn = originalWarn;
|
||||
});
|
||||
|
||||
it("should expire a request the host stopped polling", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
idle(requestId, pollTimeoutMs + 1);
|
||||
manager.prune();
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
||||
});
|
||||
it("should keep a request that is still polled", function() {
|
||||
assert.equal(pollTimeoutMs, 30e3);
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
idle(requestId, pollTimeoutMs - 1000);
|
||||
manager.prune();
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
idle(requestId, pollTimeoutMs - 1000);
|
||||
manager.prune();
|
||||
assert(manager.records.has(code));
|
||||
});
|
||||
it("should expire a request that has been pending for too long", function() {
|
||||
const timeoutMs = controller.config.get("controller.host_pairing_timeout") * 1000;
|
||||
assert.equal(timeoutMs, 60 * 60e3);
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
internal(requestId).details.createdAtMs -= timeoutMs - 1000;
|
||||
manager.prune();
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
internal(requestId).details.createdAtMs -= 1001;
|
||||
manager.prune();
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
});
|
||||
it("should not limit how long a request is pending when the timeout is 0", function() {
|
||||
controller.config.set("controller.host_pairing_timeout", 0);
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
internal(requestId).details.createdAtMs -= 365 * 24 * 60 * 60e3;
|
||||
manager.prune();
|
||||
assert(manager.records.has(code));
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
});
|
||||
it("should drop and warn about an approved host never picked up", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
idle(requestId, pollTimeoutMs + 1);
|
||||
manager.prune();
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], new RegExp(`Approved host Host A \\(${hostId}\\) was never picked up`));
|
||||
// The host stays until revoked
|
||||
assert(controller.hosts.has(hostId));
|
||||
});
|
||||
it("should drop a rejection never picked up", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
idle(requestId, pollTimeoutMs + 1);
|
||||
manager.prune();
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.equal(warnings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Controller integration", function() {
|
||||
it("should broadcast changes to pending requests", function() {
|
||||
const broadcasts = [];
|
||||
controller.subscriptions.broadcast = event => {
|
||||
if (event instanceof lib.HostPairingUpdatesEvent) {
|
||||
broadcasts.push(event.updates.map(details => [details.code, details.isDeleted]));
|
||||
}
|
||||
};
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
assert.deepEqual(broadcasts, [[[code, false]], [[code, true]]]);
|
||||
});
|
||||
it("should send pending requests to new subscribers", async function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const event = await controller.handleHostPairingSubscription(
|
||||
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, 0)
|
||||
);
|
||||
assert(event instanceof lib.HostPairingUpdatesEvent);
|
||||
assert.deepEqual(event.updates.map(details => details.code), [code]);
|
||||
|
||||
const lastUpdate = manager.records.get(code).updatedAtMs;
|
||||
assert.equal(await controller.handleHostPairingSubscription(
|
||||
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
|
||||
), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+126
-1
@@ -1,7 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
import { Controller, WsServer } from "@clusterio/controller";
|
||||
import jwt from "jsonwebtoken";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, HostRecord, WsServer } from "@clusterio/controller";
|
||||
import { ControllerConfig } from "@clusterio/lib";
|
||||
import packageJson from "@clusterio/controller/package.json" with { type: "json" };
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
class MockWebSocket extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.sent = [];
|
||||
this.closed = null;
|
||||
}
|
||||
|
||||
send(message) {
|
||||
this.sent.push(JSON.parse(message));
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
this.closed = { code, reason };
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.emit("close", 1006, "");
|
||||
}
|
||||
}
|
||||
|
||||
describe("controller/src/WsServer", function() {
|
||||
describe("class WsServer", function() {
|
||||
@@ -88,4 +113,104 @@ describe("controller/src/WsServer", function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(".registerHost()", function() {
|
||||
const secret = Buffer.from("TestSecretDoNotUse");
|
||||
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {WsServer} */
|
||||
let wsServer;
|
||||
let socket;
|
||||
beforeEach(function() {
|
||||
const config = new ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", secret.toString("base64"));
|
||||
controller = new Controller(lib.logger, [], config);
|
||||
wsServer = controller.wsServer;
|
||||
socket = new MockWebSocket();
|
||||
});
|
||||
afterEach(function() {
|
||||
for (const connection of wsServer.hostConnections.values()) {
|
||||
connection.connector.terminate();
|
||||
}
|
||||
});
|
||||
|
||||
function addHost(id, tokenValidAfter = 0) {
|
||||
const record = new HostRecord(id, `host-${id}`, "", new Map(), false, "", "203.0.113.1", tokenValidAfter);
|
||||
controller.hosts.set(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function register(token, id = 5) {
|
||||
await wsServer.registerHost(new lib.RegisterHostData(token, packageJson.version, id, {}), socket, req);
|
||||
}
|
||||
|
||||
function assertRejected(reason) {
|
||||
assert.deepEqual(socket.closed, { code: lib.ConnectionClosed.Unauthorized, reason });
|
||||
assert.equal(socket.closed.code, 3000);
|
||||
assert.equal(wsServer.hostConnections.size, 0);
|
||||
assert(!socket.sent.some(message => message.type === "ready"), "ready was sent");
|
||||
}
|
||||
|
||||
it("should reject a validly signed token for a host that is not registered", async function() {
|
||||
await register(controller.generateHostToken(5));
|
||||
assertRejected(
|
||||
"Authentication failed: host is not registered, start the host again to request to join the cluster"
|
||||
);
|
||||
assert.equal(controller.hosts.has(5), false, "host record was created");
|
||||
});
|
||||
it("should accept a registered host", async function() {
|
||||
const record = addHost(5);
|
||||
await register(controller.generateHostToken(5));
|
||||
assert.equal(socket.closed, null);
|
||||
assert(socket.sent.some(message => message.type === "ready"), "ready was not sent");
|
||||
assert(wsServer.hostConnections.has(5));
|
||||
|
||||
assert.equal(controller.hosts.size, 1);
|
||||
assert.equal(controller.hosts.get(5), record, "host record was replaced");
|
||||
assert.equal(record.connected, true);
|
||||
assert.equal(record.version, packageJson.version);
|
||||
assert.equal(record.remoteAddress, "10.0.0.1");
|
||||
assert.equal(record.name, "host-5");
|
||||
assert.equal(record.publicAddress, "203.0.113.1");
|
||||
|
||||
wsServer.hostConnections.get(5).connector.terminate();
|
||||
assert.equal(wsServer.hostConnections.has(5), false);
|
||||
assert.equal(controller.hosts.get(5).connected, false);
|
||||
});
|
||||
it("should reject a token issued before tokenValidAfter", async function() {
|
||||
const token = controller.generateHostToken(5);
|
||||
addHost(5, Math.floor(Date.now() / 1000) + 60);
|
||||
await register(token);
|
||||
assertRejected("Authentication failed: invalid token");
|
||||
});
|
||||
it("should accept a token issued at tokenValidAfter", async function() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
addHost(5, now);
|
||||
await register(jwt.sign({ aud: "host", host: 5, iat: now }, secret));
|
||||
assert.equal(socket.closed, null);
|
||||
assert(wsServer.hostConnections.has(5));
|
||||
});
|
||||
it("should reject a host revoked while its previous connection closes", async function() {
|
||||
const record = addHost(5);
|
||||
wsServer.hostConnections.set(5, {
|
||||
remoteAddress: "10.0.0.1",
|
||||
async disconnect() {
|
||||
controller.hosts.delete(record);
|
||||
wsServer.hostConnections.delete(5);
|
||||
},
|
||||
});
|
||||
await register(controller.generateHostToken(5));
|
||||
assertRejected("Authentication failed: host was revoked");
|
||||
assert.equal(controller.hosts.has(5), false, "host record was recreated");
|
||||
});
|
||||
it("should reject hosts in recovery mode", async function() {
|
||||
addHost(5);
|
||||
controller.recoveryMode = true;
|
||||
await register(controller.generateHostToken(5));
|
||||
assert.deepEqual(socket.closed, {
|
||||
code: lib.ConnectionClosed.RecoveryMode,
|
||||
reason: "Connection Refused: Controller in recovery mode",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+299
-17
@@ -1,9 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
import FormData from "form-data";
|
||||
import http from "node:http";
|
||||
|
||||
import { wait, expositionContentType } from "@clusterio/lib";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { SubscribableDatastore, wait, expositionContentType } from "@clusterio/lib";
|
||||
import { Controller, HostPairingManager, HostRecord } from "@clusterio/controller";
|
||||
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
|
||||
import * as mock from "../mock.js";
|
||||
|
||||
@@ -211,6 +213,291 @@ describe("controller/src/routes", function() {
|
||||
assert.deepEqual(data, { name: "Test Cluster" });
|
||||
});
|
||||
});
|
||||
describe("/api/host-pairing", function() {
|
||||
let endpoint, adminToken, playerToken;
|
||||
beforeEach(function() {
|
||||
endpoint = `http://localhost:${port}/api/host-pairing`;
|
||||
// The mock controller lacks what pairing needs, use the real implementations.
|
||||
controller.hosts = new SubscribableDatastore();
|
||||
controller.hostPairing = new HostPairingManager(controller);
|
||||
controller.generateHostToken = Controller.prototype.generateHostToken;
|
||||
controller.verifyHostToken = Controller.prototype.verifyHostToken;
|
||||
controller.wsServer.remoteAddr = req => req.socket.remoteAddress;
|
||||
adminToken = controller.users.signUserToken(controller.users.getByName("test"));
|
||||
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
|
||||
});
|
||||
|
||||
function post(path, body, headers = {}) {
|
||||
return fetch(`${endpoint}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: typeof body === "string" || body === undefined ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function assertResponse(response, status, body) {
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
if (body !== undefined) {
|
||||
assert.deepEqual(await response.json(), body);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRequest(name = "Host A") {
|
||||
const response = await post("", { name });
|
||||
assert.equal(response.status, 202);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function check(headers) {
|
||||
return fetch(`${endpoint}/check`, { headers });
|
||||
}
|
||||
|
||||
function assertNothingCreated() {
|
||||
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
|
||||
assert.equal(controller.hosts.size, 0, "a host was created");
|
||||
}
|
||||
|
||||
describe("POST /api/host-pairing", function() {
|
||||
it("should create a pending request", async function() {
|
||||
const response = await post("", { name: "Host A", version: "2.0.0" });
|
||||
assert.equal(response.status, 202);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.deepEqual(Object.keys(body).sort(), ["code", "requestId"]);
|
||||
assert.match(body.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
|
||||
|
||||
const details = controller.hostPairing.records.get(body.code);
|
||||
assert.equal(details.name, "Host A");
|
||||
assert.equal(details.version, "2.0.0");
|
||||
assert.match(details.remoteAddress, /127\.0\.0\.1|::1/);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
});
|
||||
it("should trim the name and limit the version", async function() {
|
||||
let { code } = await (await post("", { name: " Host A ", version: "x".repeat(100) })).json();
|
||||
let details = controller.hostPairing.records.get(code);
|
||||
assert.equal(details.name, "Host A");
|
||||
assert.equal(details.version, "x".repeat(64));
|
||||
|
||||
({ code } = await (await post("", { name: "Host B", version: 5 })).json());
|
||||
details = controller.hostPairing.records.get(code);
|
||||
assert.equal(details.version, "");
|
||||
});
|
||||
it("should respond with 400 for a missing or invalid name", async function() {
|
||||
const required = { error: "Host name is required" };
|
||||
await assertResponse(await post("", {}), 400, required);
|
||||
await assertResponse(await post("", { name: "" }), 400, required);
|
||||
await assertResponse(await post("", { name: " " }), 400, required);
|
||||
await assertResponse(await post("", { name: 5 }), 400, required);
|
||||
await assertResponse(await post("", undefined), 400, required);
|
||||
await assertResponse(await post("", "name=Host", { "Content-Type": "text/plain" }), 400, required);
|
||||
await assertResponse(
|
||||
await post("", { name: "x".repeat(101) }),
|
||||
400, { error: "Host name must be at most 100 characters" },
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 400 for malformed JSON", async function() {
|
||||
controller.app.set("env", "test"); // Silence the default error handler
|
||||
const response = await post("", "{\"name\":");
|
||||
assert.equal(response.status, 400);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 413 for a body over 4kb", async function() {
|
||||
controller.app.set("env", "test"); // Silence the default error handler
|
||||
const response = await post("", { name: "x".repeat(5000) });
|
||||
assert.equal(response.status, 413);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 429 and Retry-After when limited", async function() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createRequest();
|
||||
}
|
||||
const response = await post("", { name: "Host A" });
|
||||
assert.equal(response.headers.get("retry-after"), "30");
|
||||
await assertResponse(response, 429, { error: "Too many pending requests from this address" });
|
||||
assert.equal(controller.hostPairing.records.size, 3);
|
||||
});
|
||||
it("should respond with 503 in recovery mode", async function() {
|
||||
controller.recoveryMode = true;
|
||||
const recovery = { error: "Controller is in recovery mode" };
|
||||
await assertResponse(await post("", { name: "Host A" }), 503, recovery);
|
||||
await assertResponse(
|
||||
await post("", { name: "Host A" }, { "Authorization": `Bearer ${adminToken}` }), 503, recovery
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should approve directly with the token of a user allowed to approve hosts", async function() {
|
||||
for (const headers of [
|
||||
{ "Authorization": `Bearer ${adminToken}` },
|
||||
{ "Authorization": `bearer ${adminToken}` },
|
||||
{ "X-Access-Token": adminToken },
|
||||
]) {
|
||||
const response = await post("", { name: "Host A" }, headers);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
|
||||
assert.equal(body.name, "Host A");
|
||||
assert.equal(controller.hosts.get(body.hostId).name, "Host A");
|
||||
const payload = jwt.verify(body.token, controller.authSecret, { audience: "host" });
|
||||
assert.equal(payload.host, body.hostId);
|
||||
}
|
||||
assert.equal(controller.hosts.size, 3);
|
||||
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
|
||||
});
|
||||
it("should validate the name before approving directly", async function() {
|
||||
await assertResponse(
|
||||
await post("", {}, { "Authorization": `Bearer ${adminToken}` }),
|
||||
400, { error: "Host name is required" },
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 403 for a user not allowed to approve hosts", async function() {
|
||||
await assertResponse(
|
||||
await post("", { name: "Host A" }, { "Authorization": `Bearer ${playerToken}` }),
|
||||
403, { error: "User player is not allowed to approve hosts" },
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 401 for an invalid token without creating a request", async function() {
|
||||
// The admin user's tokens are revoked after signing this one
|
||||
const revokedToken = adminToken;
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
|
||||
user.saveRecord();
|
||||
controller.hosts.set(new HostRecord(5, "five", "", new Map()));
|
||||
const hostToken = controller.generateHostToken(5);
|
||||
controller.hosts.delete(controller.hosts.get(5));
|
||||
|
||||
for (const headers of [
|
||||
{ "Authorization": "Bearer invalid" },
|
||||
{ "X-Access-Token": "invalid" },
|
||||
{ "Authorization": `Bearer ${controller.users.signUserToken({ id: "nobody" })}` },
|
||||
{ "Authorization": `Bearer ${revokedToken}` },
|
||||
{ "Authorization": `Bearer ${hostToken}` },
|
||||
]) {
|
||||
await assertResponse(
|
||||
await post("", { name: "Host A" }, headers), 401, { error: "Invalid user token" }
|
||||
);
|
||||
}
|
||||
assertNothingCreated();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/host-pairing/poll", function() {
|
||||
it("should respond with 202 while pending", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
|
||||
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
|
||||
});
|
||||
it("should hand over the result of an approved request once", async function() {
|
||||
const { requestId, code } = await createRequest();
|
||||
const hostId = controller.hostPairing.approve(code, "Renamed", "test");
|
||||
|
||||
const response = await post("/poll", { requestId });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
|
||||
assert.equal(body.hostId, hostId);
|
||||
assert.equal(body.name, "Renamed");
|
||||
assert.equal(jwt.verify(body.token, controller.authSecret, { audience: "host" }).host, hostId);
|
||||
|
||||
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
|
||||
});
|
||||
it("should respond with 403 once for a rejected request", async function() {
|
||||
const { requestId, code } = await createRequest();
|
||||
controller.hostPairing.reject(code, "test");
|
||||
await assertResponse(await post("/poll", { requestId }), 403, { error: "Request was rejected" });
|
||||
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
|
||||
});
|
||||
it("should respond with 404 for an unknown request", async function() {
|
||||
const { code } = await createRequest();
|
||||
const notFound = { error: "Request not found" };
|
||||
await assertResponse(await post("/poll", { requestId: "invalid" }), 404, notFound);
|
||||
await assertResponse(await post("/poll", { requestId: code }), 404, notFound);
|
||||
});
|
||||
it("should respond with 400 without a requestId in the body", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
const required = { error: "requestId is required" };
|
||||
await assertResponse(await post("/poll", {}), 400, required);
|
||||
await assertResponse(await post("/poll", { requestId: 5 }), 400, required);
|
||||
await assertResponse(await post(`/poll?requestId=${encodeURIComponent(requestId)}`, {}), 400, required);
|
||||
});
|
||||
it("should respond with 503 in recovery mode", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
controller.recoveryMode = true;
|
||||
await assertResponse(
|
||||
await post("/poll", { requestId }), 503, { error: "Controller is in recovery mode" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/host-pairing/cancel", function() {
|
||||
it("should cancel a pending request", async function() {
|
||||
const { requestId, code } = await createRequest();
|
||||
await assertResponse(await post("/cancel", { requestId }), 204);
|
||||
assert.equal(controller.hostPairing.records.has(code), false);
|
||||
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
|
||||
});
|
||||
it("should respond with 204 for an unknown request", async function() {
|
||||
const { code } = await createRequest();
|
||||
await assertResponse(await post("/cancel", { requestId: "invalid" }), 204);
|
||||
await assertResponse(await post("/cancel", { requestId: code }), 204);
|
||||
assert(controller.hostPairing.records.has(code), "cancelled by the code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("request ids", function() {
|
||||
it("should not be accepted in the URL", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
const id = encodeURIComponent(requestId);
|
||||
for (const [method, path] of [
|
||||
["GET", `/poll?requestId=${id}`],
|
||||
["GET", `/poll/${id}`],
|
||||
["POST", `/poll/${id}`],
|
||||
["POST", `/${id}`],
|
||||
["GET", `/${id}`],
|
||||
["POST", `/cancel/${id}`],
|
||||
]) {
|
||||
const response = await fetch(`${endpoint}${path}`, { method });
|
||||
assert.equal(response.status, 404, `${method} ${path}`);
|
||||
}
|
||||
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/host-pairing/check", function() {
|
||||
it("should respond with 200 for a valid host token", async function() {
|
||||
const { hostId, token } = controller.hostPairing.createHost("Host A");
|
||||
for (const headers of [
|
||||
{ "Authorization": `Bearer ${token}` },
|
||||
{ "X-Access-Token": token },
|
||||
]) {
|
||||
await assertResponse(await check(headers), 200, { valid: true });
|
||||
}
|
||||
assert(controller.hosts.has(hostId));
|
||||
});
|
||||
it("should respond with 200 for a host token from before the slave rename", async function() {
|
||||
const { hostId } = controller.hostPairing.createHost("Host A");
|
||||
const token = jwt.sign({ aud: "slave", slave: hostId }, controller.authSecret);
|
||||
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
|
||||
});
|
||||
it("should respond with 401 without a valid host token", async function() {
|
||||
controller.hostPairing.createHost("Host A");
|
||||
assert.equal((await check({})).status, 401);
|
||||
assert.equal((await check({ "Authorization": "Bearer invalid" })).status, 401);
|
||||
assert.equal((await check({ "Authorization": `Bearer ${adminToken}` })).status, 401);
|
||||
});
|
||||
it("should respond with 401 once the host is revoked", async function() {
|
||||
const { hostId, token } = controller.hostPairing.createHost("Host A");
|
||||
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
|
||||
controller.hosts.delete(controller.hosts.get(hostId));
|
||||
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("/api/upload-save", function() {
|
||||
let endpoint, testToken;
|
||||
beforeEach(function() {
|
||||
@@ -308,27 +595,25 @@ describe("controller/src/routes", function() {
|
||||
let form;
|
||||
form = new FormData();
|
||||
form.append("instance_id", "123");
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "text/plain" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "text/plain" }), "test.zip");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { request_errors: ["invalid file Content-Type"] });
|
||||
form = new FormData();
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
|
||||
form.append("instance_id", "123");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(
|
||||
@@ -336,14 +621,13 @@ describe("controller/src/routes", function() {
|
||||
);
|
||||
form = new FormData();
|
||||
form.append("instance_id", "123");
|
||||
form.append("file", "totally a zip file", { filename: "test.txt", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.txt");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(
|
||||
@@ -351,14 +635,13 @@ describe("controller/src/routes", function() {
|
||||
);
|
||||
form = new FormData();
|
||||
form.append("instance_id", "invalid");
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(
|
||||
@@ -410,14 +693,13 @@ describe("controller/src/routes", function() {
|
||||
let form;
|
||||
form = new FormData();
|
||||
form.append("instance_id", "123");
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
|
||||
|
||||
+110
@@ -5,6 +5,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { escapeArg } from "../packages/create/escape_arg.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const installer = path.resolve(import.meta.dirname, "../packages/create/create.js");
|
||||
const discoveryUrl = new URL("../packages/lib/dist/node/src/load_plugin_list.js", import.meta.url).href;
|
||||
@@ -123,4 +125,112 @@ describe("create installer CLI", function() {
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("host setup", function() {
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const hostBin = path.join("node_modules", ".bin", `clusteriohost${scriptExt}`);
|
||||
const controllerBin = path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`);
|
||||
const hostSet = (field, value) => `${hostBin} config set ${field} ${escapeArg(String(value))}`;
|
||||
const controllerUrl = "http://controller.example.com:8080/";
|
||||
|
||||
function run(args, env = {}) {
|
||||
// Record the commands for npm and the clusterio CLIs instead of running them.
|
||||
const mockExec = `
|
||||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { PassThrough } from "node:stream";
|
||||
childProcess.execFile = (command, options, callback) => {
|
||||
fs.appendFileSync(process.env.CREATE_TEST_COMMANDS, JSON.stringify(command) + "\\n");
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
queueMicrotask(() => {
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
callback(null, "", "");
|
||||
});
|
||||
return { stdout, stderr };
|
||||
};
|
||||
`;
|
||||
return execFileAsync(process.execPath, [
|
||||
"--import", `data:text/javascript,${encodeURIComponent(mockExec)}`,
|
||||
installer,
|
||||
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
|
||||
...args,
|
||||
], {
|
||||
cwd: directory,
|
||||
env: { ...process.env, CREATE_TEST_COMMANDS: path.join(directory, "commands.jsonl"), ...env },
|
||||
});
|
||||
}
|
||||
|
||||
// Commands run by the installer, without the getent lookups made when writing systemd units.
|
||||
async function commands() {
|
||||
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(url = controllerUrl) {
|
||||
return [
|
||||
"--mode", "host", "--host-name", "Test Host", "--controller-url", url,
|
||||
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm", "--plugins",
|
||||
];
|
||||
}
|
||||
|
||||
it("writes the host config and leaves joining the cluster to the host", async function() {
|
||||
const { stdout } = await run(hostArgs());
|
||||
assert.match(stdout, /Successfully installed 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),
|
||||
]);
|
||||
await fs.access(path.join(directory, `run-host${process.platform === "win32" ? ".cmd" : ".sh"}`));
|
||||
});
|
||||
|
||||
it("adds the trailing slash the host requires to the controller URL", async function() {
|
||||
await run(hostArgs(controllerUrl.slice(0, -1)));
|
||||
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
|
||||
});
|
||||
|
||||
it("leaves joining the cluster to the host of a standalone install", async function() {
|
||||
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
|
||||
const { stdout } = await run([
|
||||
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
|
||||
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
|
||||
]);
|
||||
assert.match(stdout, /Successfully installed standalone/);
|
||||
|
||||
const recorded = await commands();
|
||||
for (const expected of [
|
||||
hostSet("host.name", "local"),
|
||||
hostSet("host.controller_url", "http://localhost:8123/"),
|
||||
`${controllerBin} config set controller.allow_remote_updates true`,
|
||||
`${controllerBin} config set controller.allow_plugin_updates true`,
|
||||
`${controllerBin} config set controller.allow_plugin_install true`,
|
||||
hostSet("host.allow_remote_updates", true),
|
||||
hostSet("host.allow_plugin_updates", true),
|
||||
hostSet("host.allow_plugin_install", true),
|
||||
]) {
|
||||
assert(recorded.includes(expected), `missing ${expected}`);
|
||||
}
|
||||
assert(!recorded.some(command => command.includes("host.controller_token")));
|
||||
});
|
||||
|
||||
it("uses the host name given for a standalone install", async function() {
|
||||
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
|
||||
await run([
|
||||
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
|
||||
"--factorio-dir", "factorio", "--remote-npm", "--plugins", "--host-name", "Test Host",
|
||||
]);
|
||||
assert((await commands()).includes(hostSet("host.name", "Test Host")));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -302,8 +302,7 @@ describe("host/src/Host", function() {
|
||||
await host.loadPlugins();
|
||||
const plugins = await host.handlePluginListRequest(new lib.PluginListRequest());
|
||||
const plugin = plugins.find(candidate => candidate.name === "controller_only");
|
||||
assert.equal(plugin.loaded, false);
|
||||
assert.equal(plugin.noEntrypoint, true);
|
||||
assert.equal(plugin.state, "noEntrypoint");
|
||||
const [entry] = computePluginStatuses([
|
||||
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
|
||||
]).get("controller_only").machines;
|
||||
|
||||
@@ -268,6 +268,46 @@ describe("class Instance", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".handleInstanceStopRequest()", function() {
|
||||
let calls;
|
||||
beforeEach(function() {
|
||||
calls = [];
|
||||
instance.server._state = "running";
|
||||
instance.server.stop = async () => { calls.push("stop"); instance.server._state = "stopping"; };
|
||||
instance.server.kill = async () => { calls.push("kill"); };
|
||||
instance.sendSaveListUpdate = async () => {};
|
||||
});
|
||||
|
||||
it("should stop once a start in progress finishes", async function() {
|
||||
instance.notifyStatus("starting");
|
||||
let stopped = instance.handleInstanceStopRequest();
|
||||
await wait(10);
|
||||
assert.deepEqual(calls, [], "stopped while starting");
|
||||
instance.notifyStatus("running");
|
||||
await stopped;
|
||||
assert.deepEqual(calls, ["stop"]);
|
||||
});
|
||||
|
||||
it("should do nothing if a start in progress fails", async function() {
|
||||
instance.notifyStatus("starting");
|
||||
let stopped = instance.handleInstanceStopRequest();
|
||||
await wait(10);
|
||||
instance.notifyStatus("stopped");
|
||||
await stopped;
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
it("should not kill the server when stopped while stopping", async function() {
|
||||
instance.notifyStatus("running");
|
||||
let first = instance.handleInstanceStopRequest();
|
||||
let second = instance.handleInstanceStopRequest();
|
||||
await first;
|
||||
instance.notifyStatus("stopped");
|
||||
await second;
|
||||
assert.deepEqual(calls, ["stop"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".checkModPackVersion()", function() {
|
||||
function modPack(factorioVersion) {
|
||||
return lib.ModPack.fromJSON({ name: "pack", factorio_version: factorioVersion });
|
||||
|
||||
+41
-12
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
|
||||
import { FactorioServer } from "@clusterio/host/dist/node/src/server.js";
|
||||
import {
|
||||
_exportLocale, _loadLayeredIcon, _loadSimpleIcon, _warnMissingBuiltinIcons,
|
||||
_createSheetState, _exportIcons, _exportLocale, _loadLayeredIcon, _loadSimpleIcon, _warnMissingBuiltinIcons,
|
||||
} from "@clusterio/host/dist/node/src/export.js";
|
||||
|
||||
|
||||
@@ -14,7 +14,25 @@ describe("host/src/export", function() {
|
||||
testServer = await FactorioServer.fromOptions(path.join("test", "file", "factorio"), writePath, {});
|
||||
});
|
||||
|
||||
let warnings;
|
||||
let originalLogger;
|
||||
beforeEach(function() {
|
||||
warnings = [];
|
||||
originalLogger = testServer._logger;
|
||||
testServer._logger = Object.create(originalLogger, { warn: { value: message => warnings.push(message) } });
|
||||
});
|
||||
afterEach(function() {
|
||||
testServer._logger = originalLogger;
|
||||
});
|
||||
|
||||
describe("exportLocale()", function() {
|
||||
it("warns about mods without a zip and exports the rest", async function() {
|
||||
const locale = await _exportLocale(
|
||||
testServer, new Map([["missing_mod", "1.0.0"]]), new Map(), ["base", "missing_mod"], "en"
|
||||
);
|
||||
assert.equal(locale.size > 0, true);
|
||||
assert.deepEqual(warnings, ["Skipping locale of missing_mod, its mod zip was not found"]);
|
||||
});
|
||||
it("returns a flattened mapping with locale definitions", async function() {
|
||||
let locale = await _exportLocale(testServer, new Map(), new Map(), ["base"], "en");
|
||||
assert.deepEqual(locale, new Map([
|
||||
@@ -32,6 +50,28 @@ describe("host/src/export", function() {
|
||||
|
||||
// test-icon.png is a red 64px icon followed by a blue 32px mipmap
|
||||
const red = 0xff0000ff;
|
||||
describe("exportIcons()", function() {
|
||||
it("skips icons that fail to load and exports the rest", async function() {
|
||||
const state = await _createSheetState();
|
||||
const icon = "__base__/graphics/icons/test-icon.png";
|
||||
const prototypes = [
|
||||
{ type: "item", name: "first", icon, icon_size: 64 },
|
||||
{ type: "item", name: "no-layers", icons: [] },
|
||||
{ type: "item", name: "unknown-mod", icon: "__not-a-mod__/graphics/icons/x.png", icon_size: 64 },
|
||||
{ type: "item", name: "last", icon, icon_size: 64 },
|
||||
];
|
||||
const count = await _exportIcons(testServer, new Map(), { zipCache: new Map() }, prototypes, "item", state);
|
||||
assert.equal(count, 2);
|
||||
assert.deepEqual(
|
||||
state.metadata.item.map(entry => [entry.name, Boolean(entry.icon)]),
|
||||
[["first", true], ["no-layers", false], ["unknown-mod", false], ["last", true]],
|
||||
);
|
||||
assert.equal(warnings.length, 2);
|
||||
assert.match(warnings[0], /^Skipping icon of item no-layers: /);
|
||||
assert.match(warnings[1], /^Skipping icon of item unknown-mod: Got path for unknown mod not-a-mod/);
|
||||
});
|
||||
});
|
||||
|
||||
// Stands in for the data dir of a full install
|
||||
const graphicsDataDir = path.join("test", "file", "factorio-graphics");
|
||||
describe("loadSimpleIcon()", function() {
|
||||
@@ -82,17 +122,6 @@ describe("host/src/export", function() {
|
||||
});
|
||||
});
|
||||
describe("warnMissingBuiltinIcons()", function() {
|
||||
let warnings;
|
||||
let logger;
|
||||
beforeEach(function() {
|
||||
warnings = [];
|
||||
logger = testServer._logger;
|
||||
testServer._logger = { warn: msg => warnings.push(msg) };
|
||||
});
|
||||
afterEach(function() {
|
||||
testServer._logger = logger;
|
||||
});
|
||||
|
||||
it("warns once with the number of missing builtin files", function() {
|
||||
_warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([
|
||||
["__base__/graphics/icons/a.png", null],
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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("keeps polling while the controller is busy", async function() {
|
||||
const handler = approveAfter(0);
|
||||
let busy = 2;
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll" && busy > 0) {
|
||||
busy -= 1;
|
||||
return [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, ["Recovery mode", "Recovery mode"]);
|
||||
});
|
||||
|
||||
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);
|
||||
const warnings = [];
|
||||
const originalWarn = lib.logger.warn;
|
||||
lib.logger.warn = message => { warnings.push(message); };
|
||||
try {
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
} finally {
|
||||
lib.logger.warn = originalWarn;
|
||||
}
|
||||
assert.deepEqual(stub.requests[0].body, { name: "My Host", version: "2.1.0" });
|
||||
// The systemd service created by the installer only prints warnings and above.
|
||||
assert(warnings.includes(`${stub.url}hosts?approve=ABC-DE1`), "approval link is not a warning");
|
||||
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("stops when aborted while checking the saved token", async function() {
|
||||
config.set("host.controller_token", "some-token");
|
||||
const abort = new AbortController();
|
||||
stub.handler = () => {
|
||||
abort.abort();
|
||||
return new Promise(() => {});
|
||||
};
|
||||
await assert.rejects(
|
||||
ensurePaired(config, { version: "2.1.0", signal: abort.signal }),
|
||||
{ name: "AbortError" },
|
||||
);
|
||||
assert.equal(config.get("host.controller_token"), "some-token");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+290
-68
@@ -10,11 +10,12 @@ import * as libBuildMod from "@clusterio/lib/build_mod.js";
|
||||
|
||||
import * as testStrings from "../lib/factorio/test_strings.js";
|
||||
import {
|
||||
TestControl, TestControlConnector, url, controlToken,
|
||||
TestControl, TestControlConnector, TestHostConnector, url, controlToken, hostId,
|
||||
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
|
||||
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
|
||||
requiresFactorio, hasFactorio,
|
||||
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
|
||||
} from "./index.js";
|
||||
import { pairHost, checkHostToken, PairingError } from "@clusterio/host";
|
||||
import { externalTest, slowTest } from "../common.js";
|
||||
import { getTestRconPort } from "./rcon_port.js";
|
||||
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
|
||||
@@ -31,6 +32,18 @@ async function checkInstanceStatus(id, status) {
|
||||
assert.equal(instances.get(44).status, status, "incorrect instance status");
|
||||
}
|
||||
|
||||
// Factorio directory relative to temp/test where the hosts are run from
|
||||
const hostFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
|
||||
/** Id, token and name of the alt-host, paired the first time it starts */
|
||||
const altHost = { hostId: undefined, token: undefined, name: undefined };
|
||||
|
||||
async function pairAltHost() {
|
||||
if (altHost.hostId === undefined) {
|
||||
Object.assign(altHost, await pairTestHost("alt-host"));
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAltHost(config = "alt-host-config.json") {
|
||||
return await spawnNode("alt-host:", `../../packages/host run --config ${config}`, /Started host/);
|
||||
}
|
||||
@@ -41,10 +54,10 @@ async function startAltHost() {
|
||||
await fs.rm(configPath, { force: true });
|
||||
await fs.rm(path.join("temp", "test", "alt-instances"), { force: true, recursive: true, maxRetries: 10 });
|
||||
await fs.rm(path.join("temp", "test", "alt-mods"), { force: true, recursive: true, maxRetries: 10 });
|
||||
await execCtl(`host create-config --id 5 --name alt-host --generate-token --output ${config}`);
|
||||
await pairAltHost();
|
||||
await writeHostConfig(configPath, altHost);
|
||||
await execHost(`--config ${config} config set host.instances_directory alt-instances`);
|
||||
const dir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
await execHost(`--config ${config} config set host.factorio_directory ${dir}`);
|
||||
await execHost(`--config ${config} config set host.factorio_directory ${hostFactorioDir}`);
|
||||
await execHost(`--config ${config} config set host.mods_directory alt-mods`);
|
||||
return await spawnAltHost(config);
|
||||
}
|
||||
@@ -57,7 +70,7 @@ async function stopAltHost(hostProcess) {
|
||||
"timeout"
|
||||
).catch(() => {});
|
||||
try {
|
||||
await execCtl("host stop 5");
|
||||
await execCtl(`host stop ${altHost.hostId}`);
|
||||
} catch (err) {
|
||||
// Some tests cause the host to stop
|
||||
}
|
||||
@@ -105,6 +118,28 @@ function jsonArg(value) {
|
||||
return `"${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Run a ctl command in this process and return what it printed */
|
||||
async function execCtlOutput(command) {
|
||||
const lines = [];
|
||||
const originalLog = console.log; // eslint-disable-line no-console
|
||||
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
||||
try {
|
||||
await execCtl(command);
|
||||
} finally {
|
||||
console.log = originalLog; // eslint-disable-line no-console
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Validator for assert.rejects expecting a PairingError with the given HTTP status */
|
||||
function pairingError(status) {
|
||||
return err => {
|
||||
assert(err instanceof PairingError, `Expected a PairingError, got ${err.stack}`);
|
||||
assert.equal(err.status, status, err.message);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
describe("Integration of Clusterio", function() {
|
||||
describe("clusteriocontroller", function() {
|
||||
describe("bootstrap generate-user-token", function() {
|
||||
@@ -281,11 +316,15 @@ describe("Integration of Clusterio", function() {
|
||||
describe("hostUpdateEventHandler()", function() {
|
||||
it("should trigger when a new host is added", async function() {
|
||||
slowTest(this);
|
||||
// Pairing sends an update for the new host before it connects, which is not what is tested here.
|
||||
// The round trip makes sure that update has been received before clearing the updates.
|
||||
await pairAltHost();
|
||||
await getControl().send(new lib.HostListRequest());
|
||||
getControl().hostUpdates = [];
|
||||
|
||||
await runWithAltHost(async () => {
|
||||
await execCtl("instance create alt-test --id 99");
|
||||
await execCtl("instance assign alt-test 5");
|
||||
await execCtl(`instance assign alt-test ${altHost.hostId}`);
|
||||
});
|
||||
|
||||
let sawUpdate = false;
|
||||
@@ -311,7 +350,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
let result = await getControl().send(new lib.HostListRequest());
|
||||
let hosts = new Map(result.map(instance => [instance.id, instance]));
|
||||
assert(hosts.has(5), "Host list was not updated");
|
||||
assert(hosts.has(altHost.hostId), "Host list was not updated");
|
||||
});
|
||||
});
|
||||
it("should download mods from controller", async function() {
|
||||
@@ -325,7 +364,7 @@ describe("Integration of Clusterio", function() {
|
||||
}
|
||||
assert(!await checkModDownloaded(), "mod was downloaded before the test");
|
||||
await execCtl("instance create alt-mod --id 98");
|
||||
await execCtl("instance assign alt-mod 5");
|
||||
await execCtl(`instance assign alt-mod ${altHost.hostId}`);
|
||||
await execCtl("instance save create alt-mod");
|
||||
assert(await checkModDownloaded(), "mod was not downloaded by the test");
|
||||
});
|
||||
@@ -340,7 +379,7 @@ describe("Integration of Clusterio", function() {
|
||||
hostProcess = await startAltHost();
|
||||
await execCtl("instance create alt-start --id 97");
|
||||
await execCtl(`instance config set alt-start factorio.rcon_port ${await getTestRconPort()}`);
|
||||
await execCtl("instance assign alt-start 5");
|
||||
await execCtl(`instance assign alt-start ${altHost.hostId}`);
|
||||
await execCtl("instance config set alt-start instance.auto_start true");
|
||||
const visibility = jsonArg({ lan: false, public: false });
|
||||
await execCtlProcess(`instance config set-prop alt-start factorio.settings visibility ${visibility}`);
|
||||
@@ -443,14 +482,12 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
it("should load the plugin on the controller", async function() {
|
||||
const details = await pluginDetails("controller");
|
||||
assert(details.enabled, "test_plugin is not enabled on the controller");
|
||||
assert(details.loaded, "test_plugin is not loaded on the controller");
|
||||
assert.equal(details.state, "loaded", "test_plugin is not loaded on the controller");
|
||||
});
|
||||
|
||||
it("should load the plugin on the host", async function() {
|
||||
const details = await pluginDetails({ hostId: 4 });
|
||||
assert(details.enabled, "test_plugin is not enabled on the host");
|
||||
assert(details.loaded, "test_plugin is not loaded on the host");
|
||||
const details = await pluginDetails({ hostId });
|
||||
assert.equal(details.state, "loaded", "test_plugin is not loaded on the host");
|
||||
});
|
||||
|
||||
it("should report the plugin's title and version", async function() {
|
||||
@@ -470,7 +507,7 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
|
||||
it("should route a plugin request from control to a host", async function() {
|
||||
const received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("never-broadcast"));
|
||||
const received = await getControl().sendTo({ hostId }, new HostEchoReceived("never-broadcast"));
|
||||
assert.equal(received, false);
|
||||
});
|
||||
|
||||
@@ -480,7 +517,7 @@ describe("Integration of Clusterio", function() {
|
||||
// The broadcast is sent without waiting for the hosts to receive it.
|
||||
let received = false;
|
||||
for (let attempt = 0; attempt < 100 && !received; attempt++) {
|
||||
received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("to-hosts"));
|
||||
received = await getControl().sendTo({ hostId }, new HostEchoReceived("to-hosts"));
|
||||
if (!received) {
|
||||
await lib.wait(20);
|
||||
}
|
||||
@@ -593,31 +630,31 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("host config", function() {
|
||||
it("changes host config", async function() {
|
||||
await execCtl("host config set 4 host.name My-Host");
|
||||
const result = await execCtlProcess("host config list 4");
|
||||
await execCtl(`host config set ${hostId} host.name My-Host`);
|
||||
const result = await execCtlProcess(`host config list ${hostId}`);
|
||||
assert(/My-Host/.test(result.stdout), "New host name not in config output");
|
||||
});
|
||||
it("should not allow setting host.controller_token", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("host config set 4 host.controller_token xyz"),
|
||||
execCtl(`host config set ${hostId} host.controller_token xyz`),
|
||||
/Field 'host\.controller_token' is not accessible/,
|
||||
);
|
||||
});
|
||||
it("should not allow setting host.id", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("host config set 4 host.id 200"),
|
||||
execCtl(`host config set ${hostId} host.id 200`),
|
||||
/Setting 'host\.id' while host is running is not supported/,
|
||||
);
|
||||
});
|
||||
it("should not leak host.controller_token", async function() {
|
||||
let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest());
|
||||
let result = await getControl().sendTo({ hostId }, new lib.HostConfigGetRequest());
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host plugin list", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host plugin list 4");
|
||||
await execCtl(`host plugin list ${hostId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -625,7 +662,7 @@ describe("Integration of Clusterio", function() {
|
||||
it("runs", async function() {
|
||||
// In dev plugins have no npm package, so best we can do is get an error from the host
|
||||
await assert.rejects(
|
||||
execCtl("host plugin update 4 foo"),
|
||||
execCtl(`host plugin update ${hostId} foo`),
|
||||
/Plugin foo is not installed on this machine/
|
||||
);
|
||||
});
|
||||
@@ -637,7 +674,7 @@ describe("Integration of Clusterio", function() {
|
||||
// Default is to disallow updates, changing this value would require a restart
|
||||
// Additionally, it can not be changed via ctl, so best we can do is get an error from the host
|
||||
await assert.rejects(
|
||||
execCtl("host plugin install 4 foo"),
|
||||
execCtl(`host plugin install ${hostId} foo`),
|
||||
/Plugin installs are disabled on this machine/
|
||||
);
|
||||
});
|
||||
@@ -646,15 +683,15 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("host update", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host update 4");
|
||||
await execCtl(`host update ${hostId}`);
|
||||
});
|
||||
it("accepts --all", async function() {
|
||||
await execCtl("host update 4 --all");
|
||||
await execCtl(`host update ${hostId} --all`);
|
||||
});
|
||||
it("accepts --restart", async function() {
|
||||
// We cannot restart the host, so we check for host error instead
|
||||
await assert.rejects(
|
||||
execCtl("host update 4 --restart"),
|
||||
execCtl(`host update ${hostId} --restart`),
|
||||
/Cannot restart, host does not have a process monitor to restart it./
|
||||
);
|
||||
});
|
||||
@@ -664,7 +701,7 @@ describe("Integration of Clusterio", function() {
|
||||
it("runs", async function() {
|
||||
// We cannot restart the host, so we check for host error instead
|
||||
await assert.rejects(
|
||||
execCtl("host restart 4"),
|
||||
execCtl(`host restart ${hostId}`),
|
||||
/Cannot restart, host does not have a process monitor to restart it./
|
||||
);
|
||||
});
|
||||
@@ -672,41 +709,226 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("host mark-restart-required", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host mark-restart-required 4");
|
||||
await execCtl(`host mark-restart-required ${hostId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host generate-token", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host generate-token --id 42");
|
||||
describe("host pending, approve and reject", function() {
|
||||
let pairings = [];
|
||||
afterEach(async function() {
|
||||
// Stop hosts left waiting by a failed test
|
||||
for (const pairing of pairings) {
|
||||
pairing.abort.abort();
|
||||
}
|
||||
await Promise.allSettled(pairings.map(pairing => pairing.result));
|
||||
pairings = [];
|
||||
});
|
||||
it("runs without an id", async function() {
|
||||
await execCtl("host generate-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host revoke-token", async function() {
|
||||
it("should disconnect existing host", async function() {
|
||||
slowTest(this);
|
||||
let sawDisconnected;
|
||||
await runWithAltHost(async () => {
|
||||
const control = getControl();
|
||||
control.hostUpdates = [];
|
||||
await wait(50); // Allow for host to update instances
|
||||
await execCtl("host revoke-token 5");
|
||||
await wait(50); // Allow controller to notify control
|
||||
for (let update of control.hostUpdates) {
|
||||
if (update.name !== "alt-host") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!update.connected) {
|
||||
sawDisconnected = true;
|
||||
}
|
||||
}
|
||||
// Start pairing like a host without a user token, returns once the request is pending.
|
||||
async function requestPairing(name) {
|
||||
const abort = new AbortController();
|
||||
let onPending;
|
||||
const pending = new Promise(resolve => { onPending = resolve; });
|
||||
const result = pairHost({
|
||||
controllerUrl: url, name, pollIntervalMs: 100, signal: abort.signal, onPending,
|
||||
});
|
||||
const pairing = { abort, result, code: undefined };
|
||||
pairings.push(pairing);
|
||||
const ended = result.then(() => {
|
||||
throw new Error("Pairing finished before the request was pending");
|
||||
});
|
||||
ended.catch(() => {}); // Only of interest while racing the pending request
|
||||
pairing.code = await Promise.race([pending, ended]);
|
||||
return pairing;
|
||||
}
|
||||
|
||||
assert(sawDisconnected, "No host update with status disconnected was sent after revoking token");
|
||||
it("should list a waiting request and hand the host over when approved", async function() {
|
||||
const control = getControl();
|
||||
control.hostPairingUpdates = [];
|
||||
const pairing = await requestPairing("pending-host");
|
||||
assert.match(pairing.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
|
||||
|
||||
const pendingOutput = await execCtlOutput("host pending");
|
||||
const pendingLines = pendingOutput.split("\n");
|
||||
assert(
|
||||
pendingLines.some(line => line.includes(pairing.code) && line.includes("pending-host")),
|
||||
`Request missing from host pending output:\n${pendingOutput}`,
|
||||
);
|
||||
assert(
|
||||
control.hostPairingUpdates.some(update => update.code === pairing.code && !update.isDeleted),
|
||||
"No pairing update was sent for the new request",
|
||||
);
|
||||
|
||||
// Codes are matched ignoring case and separators
|
||||
const typedCode = pairing.code.replace("-", "").toLowerCase();
|
||||
const approveOutput = await execCtlOutput(`host approve ${typedCode} --name approved-host`);
|
||||
const approved = await pairing.result;
|
||||
assert.equal(approved.name, "approved-host");
|
||||
assert(approveOutput.includes(String(approved.hostId)), `Host id missing from: ${approveOutput}`);
|
||||
assert(
|
||||
control.hostPairingUpdates.some(update => update.code === pairing.code && update.isDeleted),
|
||||
"No pairing update was sent for the approved request",
|
||||
);
|
||||
|
||||
const requests = await control.send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.code === pairing.code), "Approved request is still pending");
|
||||
const hosts = await control.send(new lib.HostListRequest());
|
||||
assert.equal(hosts.find(host => host.id === approved.hostId)?.name, "approved-host");
|
||||
assert.equal(await checkHostToken(url, approved.token), true);
|
||||
|
||||
await execCtl(`host revoke ${approved.hostId}`);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.code === pairing.code), "Rejected request is still pending");
|
||||
const hosts = await getControl().send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
|
||||
});
|
||||
|
||||
it("should remove the request when the host stops waiting", async function() {
|
||||
const pairing = await requestPairing("cancelled-host");
|
||||
pairing.abort.abort();
|
||||
await assert.rejects(pairing.result, err => err.name === "AbortError");
|
||||
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.code === pairing.code), "Cancelled request is still pending");
|
||||
});
|
||||
|
||||
it("should fail for a code that is not pending", async function() {
|
||||
await assert.rejects(execCtl("host approve 222-222"), /No pending host request with code 222-222/);
|
||||
await assert.rejects(execCtl("host reject 222-222"), /No pending host request with code 222-222/);
|
||||
});
|
||||
|
||||
it("should refuse an invalid user token instead of waiting for approval", async function() {
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: url, name: "invalid-token-host", token: "invalid" }), pairingError(401)
|
||||
);
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.name === "invalid-token-host"), "Request was created");
|
||||
});
|
||||
|
||||
it("should refuse a user token without permission to approve hosts", async function() {
|
||||
await getControl().send(new lib.UserCreateRequest("pairing-player"));
|
||||
try {
|
||||
const token = jwt.sign(
|
||||
{ aud: "user", user: "pairing-player" }, Buffer.from("TestSecretDoNotUse", "base64")
|
||||
);
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: url, name: "unapproved-host", token }), pairingError(403)
|
||||
);
|
||||
} finally {
|
||||
await getControl().send(new lib.UserDeleteRequest("pairing-player"));
|
||||
}
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.name === "unapproved-host"), "Request was created");
|
||||
const hosts = await getControl().send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.name === "unapproved-host"), "Host was added");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host revoke", function() {
|
||||
it("should remove the host and invalidate its token", async function() {
|
||||
const pairing = await pairTestHost("revokee-host");
|
||||
assert.equal(await checkHostToken(url, pairing.token), true);
|
||||
await execCtl(`note set host ${pairing.hostId} short-lived`);
|
||||
|
||||
const control = getControl();
|
||||
control.hostUpdates = [];
|
||||
await execCtl(`host revoke ${pairing.hostId}`);
|
||||
assert(
|
||||
control.hostUpdates.some(update => update.id === pairing.hostId && update.isDeleted),
|
||||
"No host update with isDeleted was sent",
|
||||
);
|
||||
const hosts = await control.send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
||||
assert.equal(await checkHostToken(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");
|
||||
});
|
||||
|
||||
it("should unassign the instances of the host", async function() {
|
||||
const pairing = await pairTestHost("assigned-host");
|
||||
// Instances can only be assigned to connected hosts, a link that accepts the assignment is enough.
|
||||
const connector = new TestHostConnector(url, 0.2);
|
||||
connector.token = pairing.token;
|
||||
connector.hostId = pairing.hostId;
|
||||
const link = new lib.Link(connector);
|
||||
link.handle(lib.InstanceAssignInternalRequest, async () => {});
|
||||
await connector.connect();
|
||||
await execCtl("instance create revoke-assigned --id 45");
|
||||
try {
|
||||
await execCtl(`instance assign revoke-assigned ${pairing.hostId}`);
|
||||
connector.setClosing(); // Stops it from reconnecting after being revoked
|
||||
await execCtl(`host revoke ${pairing.hostId}`);
|
||||
|
||||
const hosts = await getControl().send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
||||
const instance = await getControl().send(new lib.InstanceDetailsGetRequest(45));
|
||||
assert.equal(instance.assignedHost, undefined);
|
||||
assert.equal(instance.status, "unassigned");
|
||||
} finally {
|
||||
await execCtl("instance delete revoke-assigned");
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
const pairing = await pairTestHost("running-revokee");
|
||||
await writeHostConfig(path.join("temp", "test", config), pairing, {
|
||||
"host.factorio_directory": hostFactorioDir,
|
||||
"host.instances_directory": "revoke-instances",
|
||||
"host.mods_directory": "revoke-mods",
|
||||
});
|
||||
const hostProcess = await spawnNode(
|
||||
"revoke-host:", `../../packages/host run --config ${config}`, /Started host/
|
||||
);
|
||||
try {
|
||||
const exited = events.once(hostProcess, "exit");
|
||||
await execCtl(`host revoke ${pairing.hostId}`);
|
||||
assert.notEqual(
|
||||
await lib.timeout(exited, 10e3, "timeout"), "timeout", "Host did not stop after being revoked"
|
||||
);
|
||||
} finally {
|
||||
if (hostProcess.exitCode === null && hostProcess.signalCode === null) {
|
||||
hostProcess.kill("SIGINT");
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -751,7 +973,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("instance assign", function() {
|
||||
it("creates the instance files", async function() {
|
||||
await execCtl("instance assign test 4");
|
||||
await execCtl(`instance assign test ${hostId}`);
|
||||
await assert.doesNotReject(
|
||||
fs.access(path.join(instancesDir, "test")),
|
||||
"Instance directory was not created"
|
||||
@@ -1046,14 +1268,14 @@ describe("Integration of Clusterio", function() {
|
||||
assert.equal(controllerEntry.host_id, undefined);
|
||||
assert.equal(controllerEntry.instance_id, undefined);
|
||||
|
||||
const hostEntry = await findLog({ hostIds: [4] }, "test_plugin host echo log-tagging");
|
||||
const hostEntry = await findLog({ hostIds: [hostId] }, "test_plugin host echo log-tagging");
|
||||
assert.equal(hostEntry.plugin, "test_plugin");
|
||||
assert.equal(hostEntry.host_id, 4);
|
||||
assert.equal(hostEntry.host_id, hostId);
|
||||
assert.equal(hostEntry.instance_id, undefined);
|
||||
|
||||
const instanceEntry = await findLog({ instanceIds: [44] }, "test_plugin instance echo log-tagging");
|
||||
assert.equal(instanceEntry.plugin, "test_plugin");
|
||||
assert.equal(instanceEntry.host_id, 4);
|
||||
assert.equal(instanceEntry.host_id, hostId);
|
||||
assert.equal(instanceEntry.instance_id, 44);
|
||||
});
|
||||
});
|
||||
@@ -1523,7 +1745,7 @@ describe("Integration of Clusterio", function() {
|
||||
before(async function() {
|
||||
slowTest(this);
|
||||
await execCtlProcess("instance create spam --id 66");
|
||||
await execCtlProcess("instance assign spam 4");
|
||||
await execCtlProcess(`instance assign spam ${hostId}`);
|
||||
await execCtlProcess("instance create unassign --id 77");
|
||||
});
|
||||
after(async function() {
|
||||
@@ -1550,7 +1772,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
hostProcess = await startAltHost();
|
||||
await execCtlProcess("instance create save-test --id 88");
|
||||
await execCtlProcess("instance assign save-test 5");
|
||||
await execCtlProcess(`instance assign save-test ${altHost.hostId}`);
|
||||
});
|
||||
after(async function() {
|
||||
await stopAltHost(hostProcess);
|
||||
@@ -1978,9 +2200,9 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("note set", function() {
|
||||
it("should set the note of the given resource", async function() {
|
||||
await execCtl("note set host 4 basement-box");
|
||||
await execCtl(`note set host ${hostId} basement-box`);
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
const note = notes.find(n => n.id === "host/4");
|
||||
const note = notes.find(n => n.id === `host/${hostId}`);
|
||||
assert.equal(note.content, "basement-box");
|
||||
assert.equal(note.updatedBy, "test");
|
||||
});
|
||||
@@ -2001,7 +2223,7 @@ describe("Integration of Clusterio", function() {
|
||||
const originalLog = console.log; // eslint-disable-line no-console
|
||||
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
||||
try {
|
||||
await execCtl("note get host 4");
|
||||
await execCtl(`note get host ${hostId}`);
|
||||
} finally {
|
||||
console.log = originalLog; // eslint-disable-line no-console
|
||||
}
|
||||
@@ -2017,9 +2239,9 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("note delete", function() {
|
||||
it("should remove the note of the given resource", async function() {
|
||||
await execCtl("note delete host 4");
|
||||
await execCtl(`note delete host ${hostId}`);
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
assert(!notes.find(n => n.id === "host/4"), "Note was not deleted");
|
||||
assert(!notes.find(n => n.id === `host/${hostId}`), "Note was not deleted");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+52
-12
@@ -12,6 +12,7 @@ import { LineSplitter, logger } from "@clusterio/lib";
|
||||
|
||||
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
|
||||
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
|
||||
import { pairHost } from "@clusterio/host";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
@@ -26,6 +27,7 @@ export class TestControl extends lib.Link {
|
||||
constructor(connector, subscribe = true) {
|
||||
super(connector);
|
||||
this.hostUpdates = [];
|
||||
this.hostPairingUpdates = [];
|
||||
this.instanceUpdates = [];
|
||||
this.saveUpdates = [];
|
||||
this.modUpdates = [];
|
||||
@@ -39,6 +41,9 @@ export class TestControl extends lib.Link {
|
||||
this.send(
|
||||
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
|
||||
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
|
||||
this.send(
|
||||
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true)
|
||||
).catch(err => logger.error(`Error setting host pairing subscriptions:\n${err.stack}`));
|
||||
this.send(
|
||||
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
|
||||
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
|
||||
@@ -58,6 +63,7 @@ export class TestControl extends lib.Link {
|
||||
|
||||
this.handle(lib.AccountUpdateEvent);
|
||||
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
|
||||
this.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.bind(this));
|
||||
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
|
||||
@@ -69,6 +75,10 @@ export class TestControl extends lib.Link {
|
||||
this.hostUpdates.push(...event.updates);
|
||||
}
|
||||
|
||||
async handleHostPairingUpdatesEvent(event) {
|
||||
this.hostPairingUpdates.push(...event.updates);
|
||||
}
|
||||
|
||||
async handleInstanceDetailsUpdatesEvent(event) {
|
||||
this.instanceUpdates.push(...event.updates);
|
||||
}
|
||||
@@ -181,6 +191,8 @@ const baseHostConfig = loadJSON("config-host.json");
|
||||
export let url = "https://localhost:4443/";
|
||||
// Signed in the root before hook, as the user has to exist before the token is issued
|
||||
export let controlToken;
|
||||
// Id of the test host, assigned by the controller when it is paired in the root before hook
|
||||
export let hostId;
|
||||
export let instancesDir = path.join("temp", "test", "instances");
|
||||
export let modsDir = path.join("temp", "test", "mods");
|
||||
export let databaseDir = path.join("temp", "test", "database");
|
||||
@@ -205,17 +217,17 @@ export function exec(command, options = {}) {
|
||||
}
|
||||
|
||||
export function execController(...args) {
|
||||
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
|
||||
args[0] = `node --enable-source-maps ../../packages/controller/dist/node/controller.js ${args[0]}`;
|
||||
return exec(...args);
|
||||
}
|
||||
|
||||
export function execHost(...args) {
|
||||
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
|
||||
args[0] = `node --enable-source-maps ../../packages/host/dist/node/host.js ${args[0]}`;
|
||||
return exec(...args);
|
||||
}
|
||||
|
||||
export function execCtlProcess(...args) {
|
||||
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
|
||||
args[0] = `node --enable-source-maps ../../packages/ctl/dist/node/ctl.js ${args[0]}`;
|
||||
return exec(...args);
|
||||
}
|
||||
|
||||
@@ -253,6 +265,35 @@ export function getControl() {
|
||||
return control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair a new host with the test controller, approved right away with the admin token
|
||||
*
|
||||
* @param {string} name - Name to give the host.
|
||||
* @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host.
|
||||
*/
|
||||
export async function pairTestHost(name) {
|
||||
return await pairHost({ controllerUrl: url, name, token: controlToken });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new host config for a paired host, with the fields the installer and pairing set
|
||||
*
|
||||
* @param {string} configPath - Path to write the config to.
|
||||
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
|
||||
* @param {Record<string, unknown>} fields - Other fields to set in the config.
|
||||
*/
|
||||
export async function writeHostConfig(configPath, pairing, fields = {}) {
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
hostConfig.set("host.id", pairing.hostId);
|
||||
hostConfig.set("host.name", pairing.name);
|
||||
hostConfig.set("host.controller_url", url);
|
||||
hostConfig.set("host.controller_token", pairing.token);
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
hostConfig.set(name, value);
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** @returns {Promise<child_process.ChildProcess>} */
|
||||
export function spawn(name, cmd, waitFor, options = {}) {
|
||||
|
||||
@@ -270,22 +311,19 @@ export function spawn(name, cmd, waitFor, options = {}) {
|
||||
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||
let onDataOut = line => {
|
||||
let onData = line => {
|
||||
line = line.toString("utf8");
|
||||
if (waitFor.test(line)) {
|
||||
if (silent) {
|
||||
stdout.off("data", onDataOut);
|
||||
stderr.off("data", onDataErr);
|
||||
stdout.off("data", onData);
|
||||
stderr.off("data", onData);
|
||||
}
|
||||
resolve(process);
|
||||
}
|
||||
log(name, line);
|
||||
};
|
||||
let onDataErr = line => {
|
||||
log(name, line.toString("utf8"));
|
||||
};
|
||||
stdout.on("data", onDataOut);
|
||||
stderr.on("data", onDataErr);
|
||||
stdout.on("data", onData);
|
||||
stderr.on("data", onData);
|
||||
process.stdout.pipe(stdout);
|
||||
process.stderr.pipe(stderr);
|
||||
});
|
||||
@@ -366,7 +404,9 @@ before(async function() {
|
||||
haveFactorioInstall = factorioVersions.size > 0;
|
||||
|
||||
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
await execCtlProcess("host create-config --id 4 --name host --generate-token");
|
||||
const pairing = await pairTestHost("host");
|
||||
hostId = pairing.hostId;
|
||||
await writeHostConfig(hostConfigPath, pairing);
|
||||
await execHost("config create");
|
||||
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import fs from "node:fs/promises";
|
||||
import { slowTest, testMatrix } from "../common.js";
|
||||
import {
|
||||
execCtl, execCtlProcess, sendRcon, getControl,
|
||||
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir,
|
||||
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir, hostId,
|
||||
} from "./index.js";
|
||||
|
||||
import { getTestRconPort } from "./rcon_port.js";
|
||||
@@ -96,7 +96,7 @@ describe("Clusterio Instance", function() {
|
||||
await execCtl(`instance config set ${instName} factorio.enable_whitelist true`);
|
||||
await execCtlProcess(`instance config set-prop ${instName} factorio.settings visibility "${visibility}"`);
|
||||
await execCtl(`instance config set-prop ${instName} factorio.settings require_user_verification false`);
|
||||
await execCtl(`instance assign ${instName} 4`);
|
||||
await execCtl(`instance assign ${instName} ${hostId}`);
|
||||
// Exclude main test instance from start-all to prevent interference
|
||||
await execCtl(`instance config set ${instName} instance.exclude_from_start_all true`);
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("Clusterio Instance", function() {
|
||||
await execCtl(`instance config set ${instAltName} factorio.enable_whitelist true`);
|
||||
await execCtlProcess(`instance config set-prop ${instAltName} factorio.settings visibility "${visibility}"`);
|
||||
await execCtl(`instance config set-prop ${instAltName} factorio.settings require_user_verification false`);
|
||||
await execCtl(`instance assign ${instAltName} 4`);
|
||||
await execCtl(`instance assign ${instAltName} ${hostId}`);
|
||||
// Exclude alt test instance from start-all to prevent interference
|
||||
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
|
||||
await execCtl(`instance start ${instAltName}`);
|
||||
@@ -340,9 +340,9 @@ describe("start-all and stop-all commands", function() {
|
||||
await execCtl(`instance create ${testInstName3} --id ${testInstId3}`);
|
||||
|
||||
// Configure instances
|
||||
await execCtl(`instance assign ${testInstName1} 4`);
|
||||
await execCtl(`instance assign ${testInstName2} 4`);
|
||||
await execCtl(`instance assign ${testInstName3} 4`);
|
||||
await execCtl(`instance assign ${testInstName1} ${hostId}`);
|
||||
await execCtl(`instance assign ${testInstName2} ${hostId}`);
|
||||
await execCtl(`instance assign ${testInstName3} ${hostId}`);
|
||||
const visibility = JSON.stringify({ lan: false, public: false }).replace(
|
||||
/"/g, process.platform === "win32" ? '""' : '\\"'
|
||||
);
|
||||
@@ -474,4 +474,26 @@ describe("start-all and stop-all commands", function() {
|
||||
assert.equal(await getInstanceStatus(testInstId3), "stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop while starting", function() {
|
||||
it("stops the instance once it has started", async function() {
|
||||
this.timeout(30000);
|
||||
const control = getControl();
|
||||
const started = control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStartRequest());
|
||||
// Unroutable until the host has connected the instance
|
||||
for (;;) {
|
||||
try {
|
||||
await control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStopRequest());
|
||||
break;
|
||||
} catch (err) {
|
||||
if (!(err instanceof lib.UnavailableError)) {
|
||||
throw err;
|
||||
}
|
||||
await lib.wait(10);
|
||||
}
|
||||
}
|
||||
await started;
|
||||
assert.equal(await getInstanceStatus(testInstId1), "stopped");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,25 +3,36 @@ import events from "node:events";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import {
|
||||
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl,
|
||||
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl, hostId, pairTestHost,
|
||||
} from "./index.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib";
|
||||
import { slowTest } from "../common.js";
|
||||
|
||||
let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
// Valid token for a host id that was never approved
|
||||
let tokenUnregistered = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
|
||||
describe("Integration of lib/link", function() {
|
||||
let control;
|
||||
let controlConnector;
|
||||
let hostConnector;
|
||||
// Hosts can only connect once approved, so the host connections use a paired host
|
||||
let linkHost;
|
||||
before(async function() {
|
||||
linkHost = await pairTestHost("link-test");
|
||||
});
|
||||
after(async function() {
|
||||
if (linkHost) {
|
||||
await getControl().send(new lib.HostRevokeRequest(linkHost.hostId));
|
||||
}
|
||||
});
|
||||
beforeEach(async function() {
|
||||
controlConnector = new TestControlConnector(url, 0.2);
|
||||
controlConnector.token = controlToken;
|
||||
control = new TestControl(controlConnector, false);
|
||||
hostConnector = new TestHostConnector(url, 0.2);
|
||||
hostConnector.token = tokenHost;
|
||||
hostConnector.hostId = 0;
|
||||
hostConnector.token = linkHost.token;
|
||||
hostConnector.hostId = linkHost.hostId;
|
||||
});
|
||||
afterEach(async function() {
|
||||
await controlConnector.disconnect();
|
||||
@@ -138,16 +149,29 @@ describe("Integration of lib/link", function() {
|
||||
});
|
||||
|
||||
it("should refuse connection with mismatched ids", async function() {
|
||||
hostConnector.hostId = 1;
|
||||
hostConnector.hostId = linkHost.hostId + 1;
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error("Authentication failed: missmatched host id")
|
||||
);
|
||||
});
|
||||
|
||||
it("should refuse connection for a host that is not registered", async function() {
|
||||
hostConnector.token = tokenUnregistered;
|
||||
hostConnector.hostId = 0;
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error(
|
||||
"Authentication failed: host is not registered, start the host again to request to join the cluster"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should close old connections from the same address", async function() {
|
||||
slowTest(this);
|
||||
const hostConnectorFirst = new TestHostConnector(url, 0.2);
|
||||
// Without a link the disconnect prepare goes unanswered and the controller waits 10 s for it
|
||||
const hostLinkFirst = new lib.Link(new TestHostConnector(url, 0.2));
|
||||
const hostConnectorFirst = hostLinkFirst.connector;
|
||||
hostConnectorFirst.token = hostConnector.token;
|
||||
hostConnectorFirst.hostId = hostConnector.hostId;
|
||||
const onceClose = events.once(hostConnectorFirst, "close");
|
||||
@@ -163,8 +187,8 @@ describe("Integration of lib/link", function() {
|
||||
it("should refuse duplicate host connections", async function() {
|
||||
this.skip(); // Can't be checked because the remote address for both connections is the same
|
||||
|
||||
hostConnector.token = jwt.sign({ aud: "host", host: 4 }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
hostConnector.hostId = 4; // Same as already running host
|
||||
hostConnector.token = jwt.sign({ aud: "host", host: hostId }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
hostConnector.hostId = hostId; // Same as already running host
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error("Authentication failed: missmatched host id")
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user