mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3f4d445f | ||
|
|
00823262d5 | ||
|
|
a5bd43637e | ||
|
|
3ae486143d | ||
|
|
b4856bdd55 | ||
|
|
80f244fddd | ||
|
|
296d1dce99 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
name: Install dependencies
|
||||
description: Set up pnpm and Node.js and run pnpm install
|
||||
description: Set up pnpm and Node.js, restore caches and run pnpm install
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
@@ -12,15 +12,40 @@ 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
|
||||
|
||||
# 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]')"
|
||||
# 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 }}-
|
||||
|
||||
- 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,10 +4,6 @@ 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,12 +7,6 @@ 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
|
||||
|
||||
@@ -28,33 +22,17 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 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.
|
||||
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
|
||||
# Node.js release schedule: https://nodejs.org/en/about/releases/
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
node-version: [26.x]
|
||||
node-version: [24.x, 26.x]
|
||||
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||
|
||||
# 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]
|
||||
|
||||
|
||||
# Coverage is performed on the older of the supported node versions
|
||||
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:
|
||||
@@ -67,23 +45,21 @@ 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 --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.coverage }}
|
||||
run: pnpm run ci-cover --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
|
||||
run: pnpm run ci-cover
|
||||
- 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 }}-${{ matrix.tests }}
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
|
||||
path: temp/test/logs
|
||||
|
||||
version-compat:
|
||||
@@ -111,6 +87,8 @@ 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
|
||||
|
||||
@@ -31,6 +31,27 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added a player proxy on the controller that accepts Factorio clients on a single UDP port, sends each player to an instance picked by plugins or the instance they were last on, and starts stopped instances on demand. It listens on port 34197 by default. Instances can also get a dedicated proxy port with `instance.player_proxy_port`. See [Player proxy](/docs/player-proxy.md).
|
||||
- Added the Lobby plugin, which sends players connecting through the proxy to a lobby running their Factorio version where they pick the instance to join, creating the lobby on a host with that version if there is none, and can list the cluster and individual instances, including stopped ones, in the Factorio server browser.
|
||||
|
||||
### Features
|
||||
|
||||
- Added the `chooseSave` instance hook for plugins to pick or create the save an instance starts with, and `setSaveScenario` in `@clusterio/host` to make the save patcher stop loading the freeplay scenario of a save.
|
||||
- Added the `playerProxyRoute` controller hook for plugins to decide which instance a player connecting through the player proxy goes to, given the name, Factorio version and mods of the player.
|
||||
- Added `HostFactorioVersionsRequest` for the controller to ask a host which Factorio versions it has installed and whether it can download others.
|
||||
|
||||
### Changes
|
||||
|
||||
- Changed the default `host.factorio_port_range` of new hosts to 34200-34299, so it doesn't include the player proxy port 34197.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed map positions after the first one in exchange strings being decoded relative to the wrong previous position.
|
||||
|
||||
## Version 2.0.3
|
||||
|
||||
### Features
|
||||
|
||||
@@ -73,6 +73,7 @@ Plugins add functionality to Factorio servers, Clusterio itself or both.
|
||||
These are the plugins supported and maintained by the Clusterio developers:
|
||||
|
||||
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
|
||||
- [Lobby](/plugins/lobby/README.md): lobby servers where players pick the server to join, and listing of the cluster in the Factorio server browser.
|
||||
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked and the research queue between servers.
|
||||
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
|
||||
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
|
||||
@@ -251,7 +252,6 @@ 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.
|
||||
|
||||
@@ -273,23 +273,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
|
||||
## Setting up clusterioctl
|
||||
|
||||
@@ -45,7 +45,6 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
|
||||
**initialValue** (optional if optional is true):
|
||||
Value this config field should take in a newly initialized config.
|
||||
This can also be a synchronous function returning the value to use.
|
||||
The function is called with the config after its stored fields are loaded, so it can base the value on other fields.
|
||||
|
||||
**validator** (optional):
|
||||
Function called with the value being set and the config it belongs to.
|
||||
|
||||
+36
-56
@@ -48,23 +48,6 @@ 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.
|
||||
@@ -110,7 +93,7 @@ Defaults to null meaning the header is never trusted.
|
||||
### controller.public_url
|
||||
|
||||
Public address the controller is accessible on.
|
||||
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config`, and 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 `clusterioctl host create-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.
|
||||
@@ -231,6 +214,25 @@ Must be greater than 0.
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.player_proxy_port
|
||||
|
||||
UDP port the controller accepts connections from Factorio clients on.
|
||||
Players connecting to it are forwarded to an instance, which is started if it's stopped, see [Player proxy](player-proxy.md).
|
||||
Disabled when null.
|
||||
Must be greater than 0.
|
||||
Requires a restart of the controller to take effect.
|
||||
|
||||
Defaults to 34197, the default port of Factorio servers.
|
||||
|
||||
|
||||
### controller.player_proxy_default_instance_id
|
||||
|
||||
ID of the instance players connecting through the player proxy are sent to when no plugin picks an instance for them and they have not been on any instance before.
|
||||
If null, a running instance is picked.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.factorio_username
|
||||
|
||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||
@@ -294,7 +296,6 @@ 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.
|
||||
@@ -344,7 +345,6 @@ 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.
|
||||
@@ -354,7 +354,6 @@ Defaults to a random 31 bit number.
|
||||
|
||||
Directory to look for the Factorio server(s) in.
|
||||
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
|
||||
The headless build has no graphics, so when exporting data from an instance running it the base game's icons are read from a full install of the same major and minor version in this directory instead, if there is one.
|
||||
|
||||
Defaults to "factorio".
|
||||
|
||||
@@ -385,9 +384,7 @@ Defaults to "http://localhost:8080/".
|
||||
### host.controller_token
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
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.
|
||||
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 value can only be read and changed from the host itself.
|
||||
|
||||
Defaults to "enter token here".
|
||||
@@ -404,11 +401,13 @@ Defaults to "localhost".
|
||||
### host.factorio_port_range
|
||||
|
||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34200-34299" or "34100, 34200-34210".
|
||||
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
|
||||
Starting an instance fails if all ports in the range are taken.
|
||||
|
||||
Defaults to "34100-34199".
|
||||
Defaults to "34200-34299", which leaves out 34197 used by controller.player_proxy_port.
|
||||
Host configs created by older versions of Clusterio have "34100-34199" saved, which includes 34197.
|
||||
Ports in use by another process are skipped when assigning, so this only conflicts if the host is started before a controller on the same machine.
|
||||
|
||||
|
||||
### host.factorio_username
|
||||
@@ -440,7 +439,6 @@ 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.
|
||||
@@ -513,6 +511,15 @@ Does not affect start or stop of the instance while the host is running.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.player_proxy_port
|
||||
|
||||
Extra UDP port the player proxy on the controller listens on that sends players straight to this instance, starting it if it's stopped.
|
||||
Only used when controller.player_proxy_port is set, see [Player proxy](player-proxy.md).
|
||||
Must be greater than 0, and should not be in the host.factorio_port_range of a host on the same machine as the controller.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### instance.exclude_from_start_all
|
||||
|
||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||
@@ -521,31 +528,6 @@ This allows you to prevent certain instances from being started when using the b
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.is_template
|
||||
|
||||
If enabled this instance is a template other instances can inherit config values from.
|
||||
Templates cannot be assigned to a host and therefore never start.
|
||||
Hidden from `instance list` in clusterioctl unless `--templates` is passed, and listed separately in the web interface.
|
||||
An instance cannot stop being a template while other instances inherit from it.
|
||||
Hidden in the web interface, set when creating the template.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.template_id
|
||||
|
||||
ID of the template to inherit config values from.
|
||||
Inherited fields take their value from the template and follow it when it changes, setting a field on this instance stops inheriting it.
|
||||
Object fields like `factorio.settings` are inherited per property, properties set on this instance override the ones from the template.
|
||||
When set on an existing instance fields with the same value as the template or the default value become inherited, except the server name in `factorio.settings`.
|
||||
`instance.name`, `instance.is_template`, `instance.template_id`, the port fields, `factorio.rcon_password` and hidden fields are never inherited.
|
||||
A template may itself inherit from another template, but not from itself through a chain of templates.
|
||||
The fields currently inherited are stored in the hidden `instance.inherited_fields` field.
|
||||
In the web interface inherited values are linked and read only until they are unlinked.
|
||||
|
||||
Defaults to null meaning no template.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
@@ -639,8 +621,6 @@ Most plugins require the use of save patching to function.
|
||||
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
|
||||
Plugins that need save patching can't be loaded on the instance while this is disabled.
|
||||
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
|
||||
Saves patched before are restored to their original control.lua and have the modules removed when started with this disabled.
|
||||
Data the modules stored in the game is left in place.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
@@ -791,9 +771,9 @@ Defaults to 5.
|
||||
|
||||
Whether to load the given plugin on the instance.
|
||||
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
|
||||
Loading a plugin that requires save patching, script commands or Lua UDP is rejected while factorio.enable_save_patching, factorio.enable_script_commands or factorio.enable_lua_udp respectively is disabled on the instance.
|
||||
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
|
||||
|
||||
Defaults to true, or false if the instance does not have the features the plugin requires.
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Ctl Configuration
|
||||
|
||||
@@ -87,12 +87,10 @@ 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` and `config-host.json`:
|
||||
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:
|
||||
|
||||
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
|
||||
@@ -125,7 +123,6 @@ 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.
|
||||
@@ -257,7 +254,6 @@ 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.
|
||||
@@ -342,8 +338,7 @@ 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 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.
|
||||
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.
|
||||
|
||||
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,47 +59,3 @@ 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`.
|
||||
|
||||
|
||||
## Renamed lib functions
|
||||
|
||||
`lib.safeOutputFile` is renamed to `lib.atomicOverwriteFile`.
|
||||
It takes the same arguments.
|
||||
|
||||
|
||||
## 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.records` 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.
|
||||
|
||||
|
||||
## Host manager
|
||||
|
||||
`controller.hosts` is now a `HostManager`, like `controller.users` is a `UserManager`.
|
||||
`get`, `getMutable`, `has` and `values` work as before.
|
||||
Everything else on the datastore, like `set`, `delete`, `size` and the `update` event, is on `controller.hosts.records`.
|
||||
|
||||
The host manager also signs and checks host tokens and creates and deletes hosts:
|
||||
|
||||
- `controller.generateHostToken(hostId)` is now `controller.hosts.signHostToken(hostId)`.
|
||||
- `controller.verifyHostToken(token)` is now `controller.hosts.verifyHostToken(token)`.
|
||||
It returns the `HostRecord` instead of the host id.
|
||||
- `controller.hostPairing.createHost(name)` is now `controller.hosts.createHost(name)`.
|
||||
It returns the new `HostRecord` instead of `{ hostId, token, name }`, sign a token for it with `signHostToken`.
|
||||
- `controller.hosts.revokeHost(hostId, revokedBy)` deletes a host the same way `HostRevokeRequest` does.
|
||||
|
||||
User tokens are checked with `controller.users.verifyUserToken(token)`, which returns the user or throws if the token is invalid.
|
||||
@@ -32,63 +32,6 @@ 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.
|
||||
@@ -176,11 +119,6 @@ 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.
|
||||
|
||||
@@ -27,7 +27,6 @@ The save patching is based on the built in [event_handler](https://github.com/wu
|
||||
Mods on the other hand work without modifications.
|
||||
|
||||
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
|
||||
A save that was patched before has its modules removed again when it's started with save patching disabled.
|
||||
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
|
||||
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
|
||||
|
||||
|
||||
+14
-46
@@ -16,40 +16,17 @@ The easiest way to do this is to run `npx clusteriocontroller bootstrap create-c
|
||||
|
||||
Lists all hosts.
|
||||
|
||||
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.
|
||||
### Generate token
|
||||
|
||||
### List pending hosts
|
||||
ctl> host generate-token [--id <host id>]
|
||||
|
||||
ctl> host pending
|
||||
Generate a token for a host. `--id` is the host id.
|
||||
|
||||
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.
|
||||
### Create config
|
||||
|
||||
### Approve host
|
||||
ctl> host create-config [--id <host id>] [--name <host name>] [--generate-token <bool>] [--output <path to output>]
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Update Clusterio
|
||||
|
||||
@@ -79,16 +56,15 @@ Marks the host as requiring a restart, the same way a plugin update does. Use it
|
||||
|
||||
### List Instances
|
||||
|
||||
ctl> instance list [--templates]
|
||||
ctl> instance list
|
||||
|
||||
List instances.
|
||||
Templates are only listed if `--templates` is passed.
|
||||
|
||||
### Create instance
|
||||
|
||||
ctl> instance create <name> [--id <instance id>] [--from <instance id>] [--template <instance>] [--as-template]
|
||||
ctl> instance create <name> [--id <instance id>]
|
||||
|
||||
Create a new instance. `name` is the instance name. `--id` is the optional id. `--from` copies the config of an existing instance. `--template` makes the new instance inherit all config values from the given template. `--as-template` creates the instance as a template, see `instance.is_template` in the [configuration docs](configuration.md). Use `--as-template --from <instance>` to make a template out of an existing instance.
|
||||
Create a new instance. `name` is the instance name. `--id` is the optional id.
|
||||
|
||||
### Kill instance
|
||||
|
||||
@@ -121,7 +97,7 @@ Most notably Clusterio records wall clock time while the save time is recorded i
|
||||
|
||||
ctl> instance config list <instance>
|
||||
|
||||
List config values for an instance. `instance` is the instance whose config values to list. Values inherited from a template are marked with `(inherited)`, object fields also list the properties set on the instance.
|
||||
List config values for an instance. `instance` is the instance whose config values to list.
|
||||
|
||||
#### Set config value
|
||||
|
||||
@@ -133,17 +109,11 @@ Set field in instance config. `instance` is instance to set. `field` is field to
|
||||
|
||||
To be written.
|
||||
|
||||
#### Inherit config value
|
||||
|
||||
ctl> instance config inherit <instance> <field> [prop]
|
||||
|
||||
Make a field in the instance config take its value from the template again, undoing a previous `set`. `instance` is the instance to update, `field` is the field to inherit, `prop` limits it to one property of an object field.
|
||||
|
||||
#### Edit instance config
|
||||
|
||||
ctl> instance config edit <instance> [editor]
|
||||
|
||||
Edit instance config in a text editor. Once file is saved and editor has been exited, changed config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
|
||||
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
|
||||
|
||||
### Assign instance to host
|
||||
|
||||
@@ -300,11 +270,9 @@ To be written.
|
||||
|
||||
## Plugins (Remote)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### List
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Player Proxy
|
||||
|
||||
The controller can accept connections from Factorio clients on a single UDP port and forward each player to an instance in the cluster.
|
||||
Players then only need to know one address, and instances that are stopped are started when someone connects to them.
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
- [Enabling the proxy](#enabling-the-proxy)
|
||||
- [How players are routed](#how-players-are-routed)
|
||||
- [Dedicated instance ports](#dedicated-instance-ports)
|
||||
- [Lobbies and the server browser](#lobbies-and-the-server-browser)
|
||||
- [Limitations](#limitations)
|
||||
|
||||
|
||||
## Enabling the proxy
|
||||
|
||||
The proxy listens on UDP port 34197, the default port of Factorio servers, unless [controller.player_proxy_port](configuration.md#controllerplayer_proxy_port) is changed.
|
||||
Set it to null to turn the proxy off:
|
||||
|
||||
npx clusteriocontroller config set controller.player_proxy_port null
|
||||
|
||||
Make sure the port is reachable from the Internet.
|
||||
The controller connects to the game port of the instances from the address the host connected to the controller from, so the game ports of the instances must be reachable from the controller.
|
||||
Instances behind the proxy should have `visibility.public` turned off in their `factorio.settings`, otherwise they are listed in the server browser with their own address in addition to the proxy.
|
||||
If the port is taken, for example by an instance of a host on the same machine, the controller logs an error and runs without the proxy.
|
||||
|
||||
|
||||
## How players are routed
|
||||
|
||||
A Factorio client starts a connection by sending its version, and only sends the name of the player after the server has answered.
|
||||
The proxy answers the first message itself with the version of the client, waits for the name and list of enabled mods, and then picks an instance in this order:
|
||||
|
||||
1. The first instance returned by a plugin from the `playerProxyRoute` controller hook, see [Writing plugins](writing-plugins.md#defining-the-plugin-entrypoint).
|
||||
2. The instance the port the player connected to is dedicated to, see below.
|
||||
3. The instance the player was last on.
|
||||
4. [controller.player_proxy_default_instance_id](configuration.md#controllerplayer_proxy_default_instance_id).
|
||||
5. Any running instance.
|
||||
|
||||
If the chosen instance is stopped it is started, and the player is connected once it's running.
|
||||
Factorio clients give up after roughly 10 seconds, so the first attempt to join an instance that takes longer to start fails, but trying again once it's up works.
|
||||
The lobby plugin avoids this by letting players wait in a lobby instead.
|
||||
|
||||
The version of the client is not checked against the instance.
|
||||
Factorio clients only check the version in the answer to their first message, which the proxy sends, so a player sent to an instance running a different version does not get Factorio's version mismatch message and fails later in the handshake instead.
|
||||
The proxy logs a warning when this happens.
|
||||
|
||||
|
||||
## Dedicated instance ports
|
||||
|
||||
[instance.player_proxy_port](configuration.md#instanceplayer_proxy_port) makes the proxy listen on another port that always sends players to that instance.
|
||||
This lets each instance have its own address while still being started on demand.
|
||||
|
||||
|
||||
## Lobbies and the server browser
|
||||
|
||||
The [lobby plugin](../plugins/lobby/README.md) builds on the proxy:
|
||||
|
||||
- Players connecting to the proxy go to a lobby instance running their Factorio version, where an in-game list lets them pick the instance to join.
|
||||
Lobbies are created on demand on a host with that Factorio version, so different versions can be run behind the same port without configuring anything.
|
||||
Picking a stopped instance starts it, and the player is moved there once it's running.
|
||||
- The cluster is listed as one game in the Factorio server browser, with the players online in the whole cluster.
|
||||
- Instances can be listed as their own games in the server browser on a dedicated port, also while they are stopped.
|
||||
Players joining a stopped instance from the server browser wait in a lobby while it starts.
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
- Every player reaches the instances from the address of the controller, so bans by IP address and limits on connection attempts per address in Factorio apply to all players behind the proxy together.
|
||||
- Factorio's network protocol is not stable between versions, the proxy is written for Factorio 2.0 and 2.1.
|
||||
- All game traffic passes through the controller, which adds latency if the controller and the instances are far apart.
|
||||
@@ -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, 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`:
|
||||
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`:
|
||||
|
||||
**For run-host.cmd**
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ 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)
|
||||
|
||||
@@ -319,7 +318,6 @@ export const plugin = {
|
||||
```
|
||||
|
||||
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
|
||||
Instances that have the feature turned off get the plugin disabled by default when it is added.
|
||||
|
||||
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
|
||||
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
|
||||
@@ -844,34 +842,6 @@ 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.
|
||||
|
||||
+2
-1
@@ -28,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -46,6 +46,7 @@
|
||||
"@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,14 +23,13 @@ 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 type Controller from "./src/Controller.js";
|
||||
import 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" };
|
||||
@@ -187,6 +186,14 @@ 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) {
|
||||
@@ -196,7 +203,6 @@ 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",
|
||||
@@ -209,7 +215,7 @@ async function handleBootstrapCommand(
|
||||
console.log(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await lib.atomicOverwriteFile(args.output, content, { mode: 0o600 });
|
||||
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +322,9 @@ 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",
|
||||
@@ -466,8 +475,6 @@ 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,
|
||||
|
||||
@@ -5,14 +5,16 @@ export { default as ControlConnection } from "./src/ControlConnection.js";
|
||||
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 HostManager } from "./src/HostManager.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";
|
||||
export { default as User } from "./src/User.js";
|
||||
export { default as WsServer } from "./src/WsServer.js";
|
||||
export {
|
||||
default as PlayerProxyServer, type PlayerProxyRouteRequest, type PlayerProxyRoute,
|
||||
} from "./src/PlayerProxyServer.js";
|
||||
export * as proxyProtocol from "./src/proxy_protocol.js";
|
||||
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import type ControlConnection from "./ControlConnection.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import type { PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||
@@ -28,6 +29,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
this.modsUpdated = this.newHook();
|
||||
this.rolesUpdated = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
this.playerProxyRoute = this.newHook();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,6 +268,24 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
||||
|
||||
/**
|
||||
* Called to pick the instance a player connecting through the player proxy goes to
|
||||
*
|
||||
* Invoked when a Factorio client has sent its name to the player proxy
|
||||
* enabled with controller.player_proxy_port. Handlers return the ID of
|
||||
* the instance to send the player to, or undefined to leave the choice
|
||||
* to other plugins and the controller. If the instance is stopped the
|
||||
* proxy starts it, which can take longer than the client is willing to
|
||||
* wait.
|
||||
*
|
||||
* Factorio clients connect twice when joining a server they don't have a
|
||||
* key for, so a handler must give the same answer for both attempts.
|
||||
*
|
||||
* @param request - Name, version and connection details of the player.
|
||||
* @returns ID of instance to route the player to.
|
||||
*/
|
||||
readonly playerProxyRoute: lib.AsyncHook<[request: PlayerProxyRouteRequest], number | undefined>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
|
||||
@@ -8,7 +8,6 @@ import { logFilter, logger } from "@clusterio/lib";
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import * as routes from "./routes.js";
|
||||
import Controller from "./Controller.js";
|
||||
|
||||
@@ -82,11 +81,10 @@ 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.HostRevokeRequest, this.handleHostRevokeRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
this.handle(lib.HostListRequest, this.handleHostListRequest.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.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this));
|
||||
this.handle(lib.HostConfigCreateRequest, this.handleHostConfigCreateRequest.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));
|
||||
@@ -95,7 +93,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigInheritFieldRequest, this.handleInstanceConfigInheritFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceRenameSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
|
||||
@@ -251,25 +248,49 @@ export default class ControlConnection extends BaseConnection {
|
||||
this._controller.config.setProp(field as keyof lib.ControllerConfigFields, prop, value, "control");
|
||||
}
|
||||
|
||||
async handleHostRevokeRequest(request: lib.HostRevokeRequest) {
|
||||
await this._controller.hosts.revokeHost(request.hostId, this.user.name);
|
||||
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
|
||||
const host = this._controller.hosts.getMutable(request.hostId);
|
||||
if (!host) {
|
||||
throw new Error(`Unknown host id (${request.hostId})`);
|
||||
}
|
||||
|
||||
host.tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
|
||||
const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId);
|
||||
if (hostConnection) {
|
||||
hostConnection.connector.terminate();
|
||||
}
|
||||
|
||||
this._controller.hosts.set(host);
|
||||
}
|
||||
|
||||
async handleHostListRequest(): Promise<lib.HostDetails[]> {
|
||||
return [...this._controller.hosts.values()].map(host => host.toHostDetails());
|
||||
}
|
||||
|
||||
async handleHostPairingListRequest(): Promise<lib.HostPairingDetails[]> {
|
||||
this._controller.hostPairing.prune();
|
||||
return [...this._controller.hostPairing.records.values()];
|
||||
async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) {
|
||||
let hostId = message.hostId;
|
||||
if (hostId === undefined) {
|
||||
hostId = Math.random() * 2**31 | 0;
|
||||
}
|
||||
return this._controller.generateHostToken(hostId);
|
||||
}
|
||||
|
||||
async handleHostPairingApproveRequest(request: lib.HostPairingApproveRequest) {
|
||||
return this._controller.hostPairing.approve(request.code, request.name, this.user.name);
|
||||
}
|
||||
async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) {
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
|
||||
async handleHostPairingRejectRequest(request: lib.HostPairingRejectRequest) {
|
||||
this._controller.hostPairing.reject(request.code, 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 handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
|
||||
@@ -294,12 +315,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
||||
}
|
||||
instanceConfig.update(request.config, false, "control");
|
||||
this._controller.instances.checkInheritance(
|
||||
instanceConfig, instanceConfig.get("instance.is_template"), instanceConfig.get("instance.template_id")
|
||||
);
|
||||
if (Object.hasOwn(request.config, "instance.template_id")) {
|
||||
this._controller.instances.applyTemplate(instanceConfig, Object.keys(request.config));
|
||||
}
|
||||
await this._controller.instances.createInstance(instanceConfig);
|
||||
}
|
||||
|
||||
@@ -340,41 +355,17 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
this.checkStagedInheritance(instance);
|
||||
instance.config.commitStaging();
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
const props = typeof fieldValue === "object"
|
||||
? Object.keys(fieldValue).filter(prop => fieldValue[prop] !== undefined)
|
||||
: undefined;
|
||||
this._controller.instances.overrideField(instance, fieldName, props);
|
||||
}
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
private checkStagedInheritance(instance: InstanceRecord) {
|
||||
const staged = instance.config.staging;
|
||||
this._controller.instances.checkInheritance(
|
||||
instance.config, staged.get("instance.is_template"), staged.get("instance.template_id")
|
||||
);
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
const field = request.field as keyof lib.InstanceConfigFields;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
if (["instance.is_template", "instance.template_id"].includes(field)) {
|
||||
try {
|
||||
instance.config.stage(field, request.value, "control");
|
||||
this.checkStagedInheritance(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
instance.config.set(field, request.value, "control");
|
||||
this._controller.instances.overrideField(instance, field);
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
@@ -383,18 +374,9 @@ export default class ControlConnection extends BaseConnection {
|
||||
let { field, prop, value } = request;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
if (value !== undefined) {
|
||||
this._controller.instances.overrideField(instance, field, [prop]);
|
||||
}
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceConfigInheritFieldRequest(request: lib.InstanceConfigInheritFieldRequest) {
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
this._controller.instances.inheritField(request.instanceId, request.field, request.prop);
|
||||
await this._controller.instanceConfigUpdated(this._controller.instances.getForRequest(request.instanceId));
|
||||
}
|
||||
|
||||
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
|
||||
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import events from "events";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import jwt from "jsonwebtoken";
|
||||
import path from "path";
|
||||
import semver from "semver";
|
||||
import stream from "stream";
|
||||
@@ -27,12 +28,11 @@ import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import UserManager from "./UserManager.js";
|
||||
import WsServer from "./WsServer.js";
|
||||
import HostManager from "./HostManager.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";
|
||||
import PlayerProxyServer, { type PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||
|
||||
const endpointDurationSummary = new Summary(
|
||||
"clusterio_controller_http_endpoint_duration_seconds",
|
||||
@@ -85,17 +85,14 @@ export default class Controller {
|
||||
trustedProxies: BlockList;
|
||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||
private _events = new events.EventEmitter<ControllerEvents>();
|
||||
playerProxyServer: PlayerProxyServer | null = null;
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions: lib.SubscriptionController;
|
||||
/** Host manager for the cluster, also contains access to the host records */
|
||||
hosts: HostManager;
|
||||
/** User manager for the cluster, also contains access to the user records */
|
||||
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";
|
||||
@@ -140,7 +137,7 @@ export default class Controller {
|
||||
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
|
||||
try {
|
||||
const releases = await lib.fetchLatestReleases();
|
||||
await lib.atomicOverwriteFile(cachePath, JSON.stringify(releases, null, "\t"));
|
||||
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
|
||||
return releases;
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
|
||||
@@ -228,8 +225,6 @@ export default class Controller {
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
|
||||
await this.validateExports(modPacks, "static");
|
||||
|
||||
// Add default mod packs
|
||||
if (modPacks.size === 0) {
|
||||
modPacks.setMany(lib.ModPack.defaultModPacks);
|
||||
@@ -274,7 +269,7 @@ export default class Controller {
|
||||
public recoveryMode: boolean = false,
|
||||
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
|
||||
/** Mapping of host id to host record */
|
||||
hosts = new lib.SubscribableDatastore<HostRecord>(),
|
||||
public hosts = new lib.SubscribableDatastore<HostRecord>(),
|
||||
/** Mapping of instance id to instance record */
|
||||
instances = new lib.SubscribableDatastore<InstanceRecord>(),
|
||||
/** Mapping of save id to save details */
|
||||
@@ -293,7 +288,6 @@ export default class Controller {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
this.config = config;
|
||||
|
||||
this.app = express();
|
||||
this.app.locals.controller = this;
|
||||
this.app.locals.streams = new Map();
|
||||
@@ -301,10 +295,8 @@ export default class Controller {
|
||||
this.trustedProxies = this.parseTrustedProxies();
|
||||
this.wsServer = new WsServer(this);
|
||||
|
||||
this.hosts = new HostManager(hosts, this);
|
||||
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]);
|
||||
@@ -314,7 +306,6 @@ 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)
|
||||
@@ -327,8 +318,7 @@ export default class Controller {
|
||||
|
||||
// Handle updates for datastores
|
||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||
this.hosts.records.on("update", this.hostsUpdated.bind(this));
|
||||
this.hostPairing.records.on("update", this.hostPairingRequestsUpdated.bind(this));
|
||||
this.hosts.on("update", this.hostsUpdated.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));
|
||||
@@ -431,7 +421,7 @@ export default class Controller {
|
||||
this.app.use(this.devServer.middlewares);
|
||||
}
|
||||
|
||||
if (!this.devServer) {
|
||||
if (!args.dev) {
|
||||
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
|
||||
|
||||
let manifest = await Controller.loadJsonObject(manifestPath);
|
||||
@@ -446,7 +436,18 @@ export default class Controller {
|
||||
this.app.locals.staticRoot = this.config.get("controller.static_url");
|
||||
|
||||
// Load plugins
|
||||
// Created before plugins load so they can use it, but only started after they have attached their hooks
|
||||
const playerProxyPort = this.config.get("controller.player_proxy_port");
|
||||
if (playerProxyPort !== null) {
|
||||
this.playerProxyServer = new PlayerProxyServer(
|
||||
this,
|
||||
logger.child({ component: "player_proxy" }),
|
||||
playerProxyPort,
|
||||
);
|
||||
}
|
||||
|
||||
await this.loadPlugins();
|
||||
await this.playerProxyServer?.start();
|
||||
|
||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||
@@ -500,7 +501,6 @@ export default class Controller {
|
||||
logger.info(`Listening for HTTPS on port ${(this.httpsServer.address() as AddressInfo).port}`);
|
||||
}
|
||||
|
||||
this.hostPairing.start();
|
||||
this.onAutosaveIntervalChanged();
|
||||
this.onSystemMetricsIntervalChanged();
|
||||
|
||||
@@ -539,14 +539,14 @@ export default class Controller {
|
||||
},
|
||||
plugins: [webUiVite.clusterioDevPlugins(pluginDirs)],
|
||||
});
|
||||
// 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)]
|
||||
)),
|
||||
};
|
||||
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)]
|
||||
)),
|
||||
};
|
||||
}
|
||||
this.app.locals.devServer = this.devServer;
|
||||
}
|
||||
|
||||
@@ -594,8 +594,6 @@ export default class Controller {
|
||||
this.autosaveInterval = undefined;
|
||||
}
|
||||
|
||||
this.hostPairing.stop();
|
||||
|
||||
if (this.clusterLogIndex) {
|
||||
await this.clusterLogIndex.save();
|
||||
}
|
||||
@@ -606,6 +604,11 @@ export default class Controller {
|
||||
|
||||
await this.hooks.shutdown.invoke();
|
||||
|
||||
if (this.playerProxyServer) {
|
||||
await this.playerProxyServer.stop();
|
||||
this.playerProxyServer = null;
|
||||
}
|
||||
|
||||
await this.wsServer.stop();
|
||||
|
||||
let stopTasks = [];
|
||||
@@ -799,7 +802,7 @@ export default class Controller {
|
||||
await Promise.all([
|
||||
this.config.save(),
|
||||
this.systems.save(),
|
||||
this.hosts.records.save(),
|
||||
this.hosts.save(),
|
||||
this.instances.records.save(),
|
||||
this.saves.save(),
|
||||
this.modPacks.save(),
|
||||
@@ -881,43 +884,8 @@ export default class Controller {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove export manifests that reference files missing from staticDirectory */
|
||||
static async validateExports(modPacks: lib.SubscribableDatastore<lib.ModPack>, staticDirectory: string) {
|
||||
const invalid: lib.ModPack[] = [];
|
||||
for (const modPack of modPacks.valuesMutable()) {
|
||||
if (!modPack.exportManifest) {
|
||||
continue;
|
||||
}
|
||||
const missing: string[] = [];
|
||||
for (const file of Object.values(modPack.exportManifest.assets)) {
|
||||
const valid = path.basename(file) === file
|
||||
&& await fs.stat(path.join(staticDirectory, file)).then(s => s.isFile(), () => false);
|
||||
if (!valid) {
|
||||
missing.push(file);
|
||||
}
|
||||
}
|
||||
if (missing.length) {
|
||||
Controller.dropExport(modPack, `missing ${missing.join(", ")}`);
|
||||
invalid.push(modPack);
|
||||
}
|
||||
}
|
||||
if (invalid.length) {
|
||||
modPacks.setMany(invalid);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the export manifest of a mutable mod pack, caller must save it */
|
||||
static dropExport(modPack: lib.ModPack, reason: string) {
|
||||
logger.warn(`Removing export data of mod pack ${modPack.name} (id ${modPack.id}): ${reason}`);
|
||||
modPack.exportManifest = undefined;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -925,7 +893,7 @@ export default class Controller {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
}).filter(permission => !removedPermissions.has(permission));
|
||||
});
|
||||
return lib.Role.pluginMigrations.reduce(json);
|
||||
});
|
||||
}
|
||||
@@ -1020,7 +988,6 @@ export default class Controller {
|
||||
express.static(path.join(import.meta.dirname, "..", "..", "web", "static"), staticOptions)
|
||||
);
|
||||
app.use("/static", express.static("static", staticOptions)); // Used for data export files
|
||||
app.use("/static", routes.missingExportAsset);
|
||||
|
||||
// Add API routes
|
||||
routes.addRouteHandlers(app);
|
||||
@@ -1052,25 +1019,21 @@ export default class Controller {
|
||||
return systems.length ? new lib.SystemInfoUpdateEvent(systems) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for host
|
||||
*
|
||||
* @returns access token for host
|
||||
* @param hostId - ID of host to generate a token for.
|
||||
*/
|
||||
generateHostToken(hostId: number): string {
|
||||
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1081,6 +1044,94 @@ export default class Controller {
|
||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the instance a player connecting through the player proxy goes to
|
||||
*
|
||||
* Plugins get the first say through the playerProxyRoute hook, then the
|
||||
* instance the proxy port is dedicated to, the instance the player was
|
||||
* last on, controller.player_proxy_default_instance_id, and finally any
|
||||
* running instance.
|
||||
*
|
||||
* @param request - Details about the connecting player.
|
||||
* @returns ID of the instance to route to, or undefined if there is none.
|
||||
*/
|
||||
async resolvePlayerRoute(request: PlayerProxyRouteRequest): Promise<number | undefined> {
|
||||
const routable = (id: number | undefined) => {
|
||||
const instance = id === undefined ? undefined : this.instances.get(id);
|
||||
return Boolean(instance && !instance.isDeleted && instance.config.get("instance.assigned_host") !== null);
|
||||
};
|
||||
|
||||
for (const result of await this.hooks.playerProxyRoute.collect(request)) {
|
||||
if (routable(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (routable(request.portInstanceId)) {
|
||||
return request.portInstanceId;
|
||||
}
|
||||
|
||||
const lastInstanceId = this._lastInstanceOfPlayer(request.username);
|
||||
if (routable(lastInstanceId)) {
|
||||
return lastInstanceId;
|
||||
}
|
||||
|
||||
const defaultId = this.config.get("controller.player_proxy_default_instance_id") ?? undefined;
|
||||
if (routable(defaultId)) {
|
||||
return defaultId;
|
||||
}
|
||||
|
||||
for (const instance of this.instances.values()) {
|
||||
if (instance.status === "running") {
|
||||
return instance.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address players reach the player proxy on
|
||||
*
|
||||
* @param port -
|
||||
* Proxy port to give the address for, defaults to the main port.
|
||||
* @returns controller.player_proxy_public_address, or the host name of
|
||||
* controller.public_url with the port if not set. Undefined if the
|
||||
* proxy is disabled or neither is set.
|
||||
*/
|
||||
getPlayerProxyAddress(port?: number) {
|
||||
if (!this.playerProxyServer) {
|
||||
return undefined;
|
||||
}
|
||||
const configured = this.config.get("controller.player_proxy_public_address");
|
||||
if (configured) {
|
||||
if (port === undefined) {
|
||||
return configured;
|
||||
}
|
||||
// Swap the port, host may be a name, IPv4 address or IPv6 address in brackets
|
||||
const host = /^(\[[^\]]+\]|[^:]+)(?::\d+)?$/.exec(configured)?.[1] ?? `[${configured}]`;
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
const publicUrl = this.config.get("controller.public_url");
|
||||
if (!publicUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return `${new URL(publicUrl).hostname}:${port ?? this.playerProxyServer.port}`;
|
||||
}
|
||||
|
||||
private _lastInstanceOfPlayer(name: string) {
|
||||
const user = this.users.getByName(name);
|
||||
let lastId: number | undefined;
|
||||
let lastJoinMs = -Infinity;
|
||||
for (const [instanceId, stats] of user?.instanceStats ?? []) {
|
||||
const joinMs = stats.lastJoinAt?.getTime() ?? -Infinity;
|
||||
if (joinMs > lastJoinMs) {
|
||||
lastJoinMs = joinMs;
|
||||
lastId = instanceId;
|
||||
}
|
||||
}
|
||||
return lastId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all saves currently stored for the given instance, if any
|
||||
* @param instanceId - Id of Instance to clear saves for.
|
||||
|
||||
@@ -34,17 +34,20 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
this.plugins = new Map(Object.entries(registerData.plugins));
|
||||
|
||||
// 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.records.set(this.info);
|
||||
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,
|
||||
);
|
||||
this._controller.hosts.set(this.info);
|
||||
|
||||
this._checkPluginVersions();
|
||||
|
||||
@@ -68,7 +71,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this._controller.instances.records.setMany(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this.saveRecord();
|
||||
this._controller.hosts.set(this.info);
|
||||
});
|
||||
|
||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||
@@ -172,17 +175,10 @@ export default class HostConnection extends BaseConnection {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Store changes to the host record, unless the host was revoked */
|
||||
saveRecord() {
|
||||
if (this._controller.hosts.getMutable(this.id) === this.info) {
|
||||
this._controller.hosts.records.set(this.info);
|
||||
}
|
||||
}
|
||||
|
||||
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
|
||||
this.info.name = event.update.name;
|
||||
this.info.publicAddress = event.update.publicAddress;
|
||||
this.saveRecord();
|
||||
this._controller.hosts.set(this.info);
|
||||
}
|
||||
|
||||
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import HostRecord from "./HostRecord.js";
|
||||
|
||||
/**
|
||||
* Access layer for hosts on the controller.
|
||||
*
|
||||
* @alias module:controller/src/HostManager
|
||||
*/
|
||||
export default class HostManager {
|
||||
constructor(
|
||||
public readonly records: lib.SubscribableDatastore<HostRecord>,
|
||||
private readonly _controller: Controller,
|
||||
) { }
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: HostRecord["id"]): boolean {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: HostRecord["id"]): HostRecord | undefined {
|
||||
return this.records.getMutable(id);
|
||||
}
|
||||
|
||||
get(id: HostRecord["id"]): Readonly<HostRecord> | undefined {
|
||||
return this.records.get(id);
|
||||
}
|
||||
|
||||
valuesMutable(): IterableIterator<HostRecord> {
|
||||
return this.records.values();
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<HostRecord>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get host by ID for a request.
|
||||
*
|
||||
* @param hostId - ID of host to get.
|
||||
* @returns The host record.
|
||||
* @throws {module:lib.NotFoundError} if the host does not exist.
|
||||
*/
|
||||
getForRequest(hostId: number): HostRecord {
|
||||
const host = this.records.getMutable(hostId);
|
||||
if (!host) {
|
||||
throw new lib.NotFoundError(`Unknown host id (${hostId})`);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a host record with a new random id
|
||||
*
|
||||
* @param name - Name of the host.
|
||||
* @returns the created host record.
|
||||
*/
|
||||
createHost(name: string): HostRecord {
|
||||
let hostId;
|
||||
do {
|
||||
hostId = crypto.randomInt(1, 2 ** 31);
|
||||
} while (this.records.has(hostId));
|
||||
|
||||
const host = new HostRecord(
|
||||
hostId, name, "", new Map(), false, "", "", Math.floor(Date.now() / 1000),
|
||||
);
|
||||
this.records.set(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a host, disconnect it and unassign its instances
|
||||
*
|
||||
* @param hostId - ID of host to revoke.
|
||||
* @param revokedBy - Name of the user revoking it, for logging.
|
||||
*/
|
||||
async revokeHost(hostId: number, revokedBy: string) {
|
||||
const host = this.getForRequest(hostId);
|
||||
this.records.delete(host);
|
||||
this._controller.deleteNote("host", hostId, revokedBy);
|
||||
const system = this._controller.systems.getMutable(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(hostId);
|
||||
if (hostConnection) {
|
||||
hostConnection.connector.terminate();
|
||||
}
|
||||
const assigned = [...this._controller.instances.values()].filter(
|
||||
instance => instance.config.get("instance.assigned_host") === hostId
|
||||
);
|
||||
for (const instance of assigned) {
|
||||
await this._controller.instances.unassignInstance(instance.id);
|
||||
}
|
||||
logger.audit(`User ${revokedBy} revoked host ${host.name} (${host.id})`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sign access token for the given host
|
||||
*
|
||||
* @param hostId - ID of host to sign token for.
|
||||
* @returns JWT access token for the host.
|
||||
*/
|
||||
signHostToken(hostId: number): string {
|
||||
return jwt.sign({ aud: "host", host: hostId }, this._controller.authSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a host token
|
||||
*
|
||||
* @param token - Host token to check.
|
||||
* @returns 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): HostRecord {
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const payload = jwt.verify(token, this._controller.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.records.getMutable(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 host;
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
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";
|
||||
|
||||
/** 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 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 host = this._controller.hosts.createHost(hostName);
|
||||
const result = { hostId: host.id, token: this._controller.hosts.signHostToken(host.id), name: host.name };
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
|
||||
type InheritedFields = lib.InstanceConfigFields["instance.inherited_fields"];
|
||||
type InheritableField = keyof lib.InstanceConfigFields & string;
|
||||
|
||||
export default class InstanceManager {
|
||||
private static readonly DefaultFactorioSettings = {
|
||||
tags: ["clusterio"],
|
||||
@@ -29,9 +24,6 @@ export default class InstanceManager {
|
||||
non_blocking_saving: false,
|
||||
} as const;
|
||||
|
||||
/** Non-zero while template values are being copied into inheriting instances */
|
||||
private _inheriting = 0;
|
||||
|
||||
constructor(
|
||||
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
|
||||
private readonly _controller: Controller,
|
||||
@@ -114,271 +106,11 @@ export default class InstanceManager {
|
||||
*/
|
||||
addInstanceHooks(instance: InstanceRecord): void {
|
||||
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
|
||||
if (field === "instance.template_id") {
|
||||
this._linkMatchingFields(instance);
|
||||
} else if (!this._inheriting && instance.config.isInherited(field)) {
|
||||
this._unlinkEditedField(instance, field, curr, prev);
|
||||
}
|
||||
this.records.set(instance);
|
||||
this._notifyConfigFieldChanged(instance, field, curr, prev);
|
||||
this._updateInheritors(instance, field, curr);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Inheritance */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the instances that inherit directly from the given template.
|
||||
*
|
||||
* @param templateId - ID of the template.
|
||||
* @returns Instances with `instance.template_id` set to templateId.
|
||||
*/
|
||||
inheritorsOf(templateId: number): InstanceRecord[] {
|
||||
return [...this.records.values()].filter(
|
||||
instance => instance.config.get("instance.template_id") === templateId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that new values for `instance.is_template` and `instance.template_id` are allowed.
|
||||
*
|
||||
* @param config - Current config of the instance.
|
||||
* @param isTemplate - New value for `instance.is_template`.
|
||||
* @param templateId - New value for `instance.template_id`.
|
||||
* @throws {module:lib.ValidationError} if the template is not a template or would create a cycle.
|
||||
* @throws {module:lib.ConflictError} if the instance is assigned or still has inheritors.
|
||||
*/
|
||||
checkInheritance(config: lib.InstanceConfig, isTemplate: boolean, templateId: number | null): void {
|
||||
const instanceId = config.get("instance.id");
|
||||
if (isTemplate && config.get("instance.assigned_host") !== null) {
|
||||
throw new lib.ConflictError("Instance must be unassigned before it can be made a template");
|
||||
}
|
||||
if (!isTemplate && config.get("instance.is_template")) {
|
||||
const count = this.inheritorsOf(instanceId).length;
|
||||
if (count) {
|
||||
throw new lib.ConflictError(`Instance is the template of ${count} other instance(s)`);
|
||||
}
|
||||
}
|
||||
if (templateId === null) {
|
||||
return;
|
||||
}
|
||||
const template = this.records.get(templateId);
|
||||
if (!template) {
|
||||
throw new lib.NotFoundError(`Instance with ID ${templateId} does not exist`);
|
||||
}
|
||||
if (!template.config.get("instance.is_template")) {
|
||||
throw new lib.ValidationError(`Instance ${template.config.get("instance.name")} is not a template`);
|
||||
}
|
||||
const visited = new Set<number>();
|
||||
for (let id: number | null = templateId; id !== null && !visited.has(id);) {
|
||||
if (id === instanceId) {
|
||||
throw new lib.ValidationError("Templates cannot inherit from themselves");
|
||||
}
|
||||
visited.add(id);
|
||||
id = this.records.get(id)?.config.get("instance.template_id") ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the inheritable values from the template set in `instance.template_id`
|
||||
* into a config that is not yet part of the cluster and mark them inherited.
|
||||
*
|
||||
* @param config - Config of the instance to be created.
|
||||
* @param own - Fields set by the creator, object fields keep their properties as overrides.
|
||||
*/
|
||||
applyTemplate(config: lib.InstanceConfig, own: string[] = []): void {
|
||||
const template = this._templateOf(config);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const inherited: InheritedFields = {};
|
||||
for (const name of InstanceManager._inheritableFields()) {
|
||||
const def = lib.InstanceConfig.fieldDefinitions[name];
|
||||
if (def.type === "object") {
|
||||
inherited[name] = own.includes(name) ? Object.keys(config.get(name) as object ?? {}) : [];
|
||||
} else if (!own.includes(name)) {
|
||||
inherited[name] = true;
|
||||
}
|
||||
}
|
||||
config.set("instance.inherited_fields", inherited);
|
||||
config.update(InstanceManager._inheritedValues(config, template.config, Object.keys(inherited)), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a field, or a property of an object field, take its value from the template again.
|
||||
*
|
||||
* @param instanceId - ID of the instance to update.
|
||||
* @param field - Name of the field to inherit.
|
||||
* @param prop - Property of an object field to inherit, inherits all properties if not given.
|
||||
* @throws {module:lib.ValidationError} if the instance has no template or the field cannot be inherited.
|
||||
*/
|
||||
inheritField(instanceId: number, field: string, prop?: string): void {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
if (!this._templateOf(instance.config)) {
|
||||
throw new lib.ValidationError("Instance does not have a template");
|
||||
}
|
||||
if (!lib.InstanceConfig.isInheritable(field)) {
|
||||
throw new lib.ValidationError(`Field ${field} cannot be inherited`);
|
||||
}
|
||||
const name = field as InheritableField;
|
||||
const inherited = { ...instance.config.get("instance.inherited_fields") };
|
||||
if (lib.InstanceConfig.fieldDefinitions[name].type !== "object") {
|
||||
inherited[field] = true;
|
||||
} else if (prop === undefined) {
|
||||
inherited[field] = [];
|
||||
} else {
|
||||
const entry = inherited[field];
|
||||
const overrides = entry instanceof Array ? entry : Object.keys(instance.config.get(name) as object ?? {});
|
||||
inherited[field] = overrides.filter(p => p !== prop);
|
||||
}
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
this._copyInheritedValues(instance, [field]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop inheriting the given field or properties, used when a value is explicitly set.
|
||||
*
|
||||
* @param instance - Instance the field was set on.
|
||||
* @param field - Name of the field set.
|
||||
* @param props - Properties set on an object field.
|
||||
*/
|
||||
overrideField(instance: InstanceRecord, field: string, props?: string[]): void {
|
||||
const entry = instance.config.get("instance.inherited_fields")[field];
|
||||
if (!instance.config.isInherited(field)) {
|
||||
return;
|
||||
}
|
||||
const inherited = { ...instance.config.get("instance.inherited_fields") };
|
||||
if (entry instanceof Array && props) {
|
||||
inherited[field] = [...new Set([...entry, ...props])];
|
||||
} else {
|
||||
delete inherited[field];
|
||||
}
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
}
|
||||
|
||||
private _templateOf(config: lib.InstanceConfig) {
|
||||
const templateId = config.get("instance.template_id");
|
||||
return templateId !== null ? this.records.get(templateId) : undefined;
|
||||
}
|
||||
|
||||
private static _inheritableFields() {
|
||||
return Object.keys(lib.InstanceConfig.fieldDefinitions).filter(
|
||||
name => lib.InstanceConfig.isInheritable(name)
|
||||
) as InheritableField[];
|
||||
}
|
||||
|
||||
/** Default value of a field for the given config */
|
||||
private static _defaultValue(name: InheritableField, config: lib.InstanceConfig): unknown {
|
||||
const def = lib.InstanceConfig.fieldDefinitions[name];
|
||||
if (name === "factorio.settings") {
|
||||
return InstanceManager.DefaultFactorioSettings;
|
||||
}
|
||||
return typeof def.initialValue === "function" ? def.initialValue(config) : def.initialValue ?? null;
|
||||
}
|
||||
|
||||
/** Values the given inherited fields take from the template, with object overrides applied */
|
||||
private static _inheritedValues(config: lib.InstanceConfig, template: lib.InstanceConfig, fields: string[]) {
|
||||
const inherited = config.get("instance.inherited_fields");
|
||||
const values: Record<string, any> = {};
|
||||
for (const field of fields) {
|
||||
const name = field as InheritableField;
|
||||
const entry = inherited[field];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const value = structuredClone(template.get(name)) as any;
|
||||
if (entry instanceof Array) {
|
||||
const own = config.get(name) as Record<string, unknown> ?? {};
|
||||
for (const prop of entry) {
|
||||
if (Object.hasOwn(own, prop)) {
|
||||
value[prop] = structuredClone(own[prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
values[field] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Update the given inherited fields of a live instance from its template */
|
||||
private _copyInheritedValues(instance: InstanceRecord, fields: string[]): void {
|
||||
const template = this._templateOf(instance.config);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const values = InstanceManager._inheritedValues(instance.config, template.config, fields);
|
||||
this._inheriting += 1;
|
||||
try {
|
||||
instance.config.update(values, true);
|
||||
} finally {
|
||||
this._inheriting -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlink a field, or the changed properties of an object field, edited on the instance */
|
||||
private _unlinkEditedField(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): void {
|
||||
const entry = instance.config.get("instance.inherited_fields")[field];
|
||||
if (!(entry instanceof Array)) {
|
||||
this.overrideField(instance, field);
|
||||
return;
|
||||
}
|
||||
const currObj = curr as Record<string, unknown> ?? {};
|
||||
const prevObj = prev as Record<string, unknown> ?? {};
|
||||
const changed = Object.keys(currObj).filter(p => !isDeepStrictEqual(currObj[p], prevObj[p]));
|
||||
const removed = Object.keys(prevObj).filter(p => !Object.hasOwn(currObj, p));
|
||||
const overrides = [...new Set([...entry, ...changed])].filter(p => !removed.includes(p));
|
||||
const inherited = { ...instance.config.get("instance.inherited_fields"), [field]: overrides };
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
if (removed.length) {
|
||||
// Removed properties fall back to the template value
|
||||
this._copyInheritedValues(instance, [field]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute inherited fields after template_id changed, values equal to the template or default are inherited */
|
||||
private _linkMatchingFields(instance: InstanceRecord): void {
|
||||
const template = this._templateOf(instance.config);
|
||||
const inherited: InheritedFields = {};
|
||||
if (template) {
|
||||
for (const name of InstanceManager._inheritableFields()) {
|
||||
const value = instance.config.get(name) as any;
|
||||
const templateValue = template.config.get(name) as any;
|
||||
const defaultValue = InstanceManager._defaultValue(name, instance.config) as any;
|
||||
if (lib.InstanceConfig.fieldDefinitions[name].type === "object") {
|
||||
inherited[name] = Object.keys(value ?? {}).filter(prop => !(
|
||||
isDeepStrictEqual(value[prop], templateValue?.[prop])
|
||||
|| defaultValue && isDeepStrictEqual(value[prop], defaultValue[prop])
|
||||
) || name === "factorio.settings" && prop === "name");
|
||||
} else if (
|
||||
isDeepStrictEqual(value, templateValue)
|
||||
|| defaultValue !== undefined && isDeepStrictEqual(value, defaultValue)
|
||||
) {
|
||||
inherited[name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
instance.config.set("instance.inherited_fields", inherited);
|
||||
this._copyInheritedValues(instance, Object.keys(inherited));
|
||||
}
|
||||
|
||||
/** Copy a changed value from a template to the instances inheriting it */
|
||||
private _updateInheritors(template: InstanceRecord, field: string, value: unknown): void {
|
||||
if (!lib.InstanceConfig.isInheritable(field)) {
|
||||
return;
|
||||
}
|
||||
for (const instance of this.inheritorsOf(template.id)) {
|
||||
if (!instance.config.isInherited(field)) {
|
||||
continue;
|
||||
}
|
||||
this._copyInheritedValues(instance, [field]);
|
||||
this._controller.instanceConfigUpdated(instance).catch(err => {
|
||||
logger.error(`Failed to send config of instance ${instance.id} to its host:\n${err.stack}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
@@ -405,23 +137,12 @@ export default class InstanceManager {
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
const serverName = `${controllerName} - ${instanceConfig.get("instance.name")}`;
|
||||
const settings = instanceConfig.get("factorio.settings");
|
||||
if (!instanceConfig.isInherited("factorio.settings")) {
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: serverName,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...settings,
|
||||
});
|
||||
} else if (instanceConfig.isInherited("factorio.settings", "name")) {
|
||||
// Each instance gets its own server name
|
||||
instanceConfig.setProp("factorio.settings", "name", serverName);
|
||||
const inherited = instanceConfig.get("instance.inherited_fields");
|
||||
instanceConfig.set("instance.inherited_fields", {
|
||||
...inherited, "factorio.settings": [...inherited["factorio.settings"] as string[], "name"],
|
||||
});
|
||||
}
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...instanceConfig.get("factorio.settings"),
|
||||
});
|
||||
|
||||
const instance = new InstanceRecord(instanceConfig, "unassigned");
|
||||
this.addInstanceHooks(instance);
|
||||
@@ -465,9 +186,6 @@ export default class InstanceManager {
|
||||
if (currentAssignedHost === hostId) {
|
||||
return;
|
||||
}
|
||||
if (hostId !== null && instance.config.get("instance.is_template")) {
|
||||
throw new lib.ValidationError("Templates cannot be assigned to a host");
|
||||
}
|
||||
|
||||
let newHostConnection: HostConnection | undefined;
|
||||
if (hostId !== null) {
|
||||
@@ -534,10 +252,6 @@ export default class InstanceManager {
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
const inheritors = this.inheritorsOf(instanceId).length;
|
||||
if (inheritors) {
|
||||
throw new lib.ConflictError(`Instance is the template of ${inheritors} other instance(s)`);
|
||||
}
|
||||
|
||||
if (hostId !== null) {
|
||||
await this._controller.sendTo(
|
||||
|
||||
@@ -83,9 +83,6 @@ export default class InstanceRecord {
|
||||
this.startedAtMs,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
this.config.get("instance.is_template"),
|
||||
this.config.get("instance.template_id") ?? undefined,
|
||||
this.config.get("factorio.mod_pack_id") ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import dgram from "node:dgram";
|
||||
import { isIPv6 } from "node:net";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import * as protocol from "./proxy_protocol.js";
|
||||
import { MessageType } from "./proxy_protocol.js";
|
||||
|
||||
const SESSION_IDLE_TIMEOUT_MS = 30_000;
|
||||
const INSTANCE_START_TIMEOUT_MS = 120_000;
|
||||
const UPSTREAM_RETRY_INTERVAL_MS = 500;
|
||||
const UPSTREAM_RETRY_COUNT = 20;
|
||||
const PROBE_TIMEOUT_MS = 2_000;
|
||||
const MAX_PENDING_PACKETS = 256;
|
||||
|
||||
/** Details about a player connecting through the proxy that is used to decide where to send them */
|
||||
export interface PlayerProxyRouteRequest {
|
||||
/** Name the player connects with */
|
||||
username: string;
|
||||
/** Factorio version and build of the connecting client */
|
||||
version: protocol.ApplicationVersion;
|
||||
/** Address and port the client connects from */
|
||||
clientAddress: string;
|
||||
clientPort: number;
|
||||
/** Proxy port the client connected to */
|
||||
listenPort: number;
|
||||
/** Instance the proxy port is dedicated to, if any */
|
||||
portInstanceId?: number;
|
||||
/** Mods enabled in the client, including built in mods like base, or undefined if they couldn't be read */
|
||||
mods?: protocol.ModId[];
|
||||
}
|
||||
|
||||
export interface PlayerProxyRoute {
|
||||
instanceId: number;
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
port: number;
|
||||
socket: dgram.Socket;
|
||||
instanceId?: number;
|
||||
};
|
||||
|
||||
type ClientSession = {
|
||||
key: string;
|
||||
listener: Listener;
|
||||
clientAddress: string;
|
||||
clientPort: number;
|
||||
lastSeenMs: number;
|
||||
/** Raw ConnectionRequest received from the client */
|
||||
request?: protocol.ConnectionRequest & { buffer: Buffer };
|
||||
/** Request id the proxy answered the client with */
|
||||
proxyServerRequestId: number;
|
||||
username?: string;
|
||||
/** Fragments of the client's ConnectionRequestReplyConfirm by fragment number */
|
||||
confirmFragments: Map<number, Buffer>;
|
||||
confirmMessageId?: number;
|
||||
confirmLastFragment?: number;
|
||||
confirmServerRequestIdOffset?: number;
|
||||
state: "handshake" | "routing" | "connecting" | "connected" | "failed";
|
||||
route?: PlayerProxyRoute;
|
||||
upstream?: dgram.Socket;
|
||||
upstreamRetry?: ReturnType<typeof setInterval>;
|
||||
serverRequestId?: number;
|
||||
/** Packets received from the client before the upstream connection was ready */
|
||||
pending: Buffer[];
|
||||
};
|
||||
|
||||
function randomUInt32() {
|
||||
return Math.floor(Math.random() * 2 ** 32) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the IPv4-mapped prefix from addresses so they can be used with udp4 sockets
|
||||
* @param address - Address to normalise.
|
||||
* @returns The address with any ::ffff: prefix removed.
|
||||
*/
|
||||
function normaliseAddress(address: string) {
|
||||
return address.startsWith("::ffff:") ? address.slice(7) : address;
|
||||
}
|
||||
|
||||
/**
|
||||
* UDP proxy for Factorio players
|
||||
*
|
||||
* Accepts connections from Factorio clients, completes the first step of the
|
||||
* connection handshake itself to learn the name and version of the player,
|
||||
* and then forwards the connection to the instance picked by the controller.
|
||||
* Stopped instances are started on demand.
|
||||
*/
|
||||
export default class PlayerProxyServer {
|
||||
private _listeners = new Map<number, Listener>();
|
||||
private _sessions = new Map<string, ClientSession>();
|
||||
private _cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private _closed = false;
|
||||
private _onInstancesUpdated = () => {
|
||||
this.syncListeners().catch(err => {
|
||||
this._logger.error(`Unexpected error syncing proxy ports:\n${err.stack}`);
|
||||
});
|
||||
};
|
||||
|
||||
/** Last version and build seen from each instance's Factorio server */
|
||||
readonly serverVersions = new Map<number, protocol.ApplicationVersion>();
|
||||
|
||||
constructor(
|
||||
private _controller: Controller,
|
||||
private _logger: lib.Logger,
|
||||
private _port: number,
|
||||
) {}
|
||||
|
||||
get port() {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
/** Ports currently listened on, mapped to the instance they are dedicated to */
|
||||
get listeners(): ReadonlyMap<number, number | undefined> {
|
||||
return new Map([...this._listeners.values()].map(listener => [listener.port, listener.instanceId]));
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._listen(this._port);
|
||||
} catch (err: any) {
|
||||
// Not fatal so the rest of the controller still works, for example if a host on the
|
||||
// same machine runs an instance on the port.
|
||||
this._logger.error(
|
||||
`Failed to listen on player proxy port ${this._port}, players can't connect through the ` +
|
||||
`proxy: ${err.message}`
|
||||
);
|
||||
}
|
||||
this._controller.instances.records.on("update", this._onInstancesUpdated);
|
||||
await this.syncListeners();
|
||||
|
||||
this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS);
|
||||
this._cleanupTimer.unref();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
this._closed = true;
|
||||
this._controller.instances.records.off("update", this._onInstancesUpdated);
|
||||
|
||||
clearInterval(this._cleanupTimer);
|
||||
for (const session of this._sessions.values()) {
|
||||
this._closeSession(session);
|
||||
}
|
||||
this._sessions.clear();
|
||||
|
||||
await Promise.all([...this._listeners.values()].map(listener => this._closeListener(listener)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open and close dedicated instance ports to match instance.player_proxy_port
|
||||
*/
|
||||
async syncListeners() {
|
||||
await this._syncListenersSerial.invoke();
|
||||
}
|
||||
|
||||
private _syncListenersSerial = new lib.AsyncSerialMergingCallback(async () => {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wanted = new Map<number, number>();
|
||||
for (const instance of this._controller.instances.values()) {
|
||||
const port = instance.config.get("instance.player_proxy_port");
|
||||
if (port === null || instance.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (port === this._port) {
|
||||
this._logger.warn(`Instance ${instance.id} uses the main player proxy port ${port}, ignoring it`);
|
||||
continue;
|
||||
}
|
||||
if (wanted.has(port)) {
|
||||
this._logger.warn(
|
||||
`Instances ${wanted.get(port)} and ${instance.id} both use player proxy port ${port}, ` +
|
||||
`only routing it to ${wanted.get(port)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
wanted.set(port, instance.id);
|
||||
}
|
||||
|
||||
for (const listener of [...this._listeners.values()]) {
|
||||
if (listener.port === this._port) {
|
||||
continue;
|
||||
}
|
||||
const instanceId = wanted.get(listener.port);
|
||||
if (instanceId === undefined) {
|
||||
await this._closeListener(listener);
|
||||
} else {
|
||||
listener.instanceId = instanceId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [port, instanceId] of wanted) {
|
||||
if (!this._listeners.has(port)) {
|
||||
try {
|
||||
await this._listen(port, instanceId);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Failed to listen on player proxy port ${port}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
private async _listen(port: number, instanceId?: number) {
|
||||
const socket = dgram.createSocket("udp4");
|
||||
const listener: Listener = { port, socket, instanceId };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.bind(port, () => {
|
||||
socket.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
socket.on("error", err => {
|
||||
this._logger.error(`Player proxy socket on port ${port} errored: ${err.message}`);
|
||||
});
|
||||
socket.on("message", (message, rinfo) => {
|
||||
try {
|
||||
this._handleClientMessage(listener, message, rinfo);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unexpected error handling player proxy packet:\n${err.stack}`);
|
||||
}
|
||||
});
|
||||
this._listeners.set(port, listener);
|
||||
this._logger.info(
|
||||
instanceId === undefined
|
||||
? `Player proxy listening on UDP ${port}`
|
||||
: `Player proxy listening on UDP ${port} for instance ${instanceId}`
|
||||
);
|
||||
}
|
||||
|
||||
private async _closeListener(listener: Listener) {
|
||||
this._listeners.delete(listener.port);
|
||||
for (const session of [...this._sessions.values()]) {
|
||||
if (session.listener === listener) {
|
||||
this._closeSession(session);
|
||||
}
|
||||
}
|
||||
await new Promise<void>(resolve => listener.socket.close(() => resolve()));
|
||||
}
|
||||
|
||||
private _handleClientMessage(listener: Listener, message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = protocol.messageType(message);
|
||||
const key = `${listener.port}|${rinfo.address}|${rinfo.port}`;
|
||||
let session = this._sessions.get(key);
|
||||
|
||||
if (type === MessageType.ConnectionRequest) {
|
||||
let request: protocol.ConnectionRequest;
|
||||
try {
|
||||
request = protocol.parseConnectionRequest(message);
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Ignoring malformed connection request: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// A different request id means the client started a new connection from the same socket,
|
||||
// which is what Factorio does after LuaPlayer::connect_to_server.
|
||||
if (session && session.request && session.request.clientRequestId !== request.clientRequestId) {
|
||||
this._closeSession(session);
|
||||
session = undefined;
|
||||
}
|
||||
if (!session) {
|
||||
session = this._createSession(key, listener, rinfo);
|
||||
}
|
||||
session.lastSeenMs = Date.now();
|
||||
this._handleConnectionRequest(session, request, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
if (type === MessageType.Ping) {
|
||||
// Pings from the server browser are answered for the proxy itself
|
||||
this._replyToPing(listener, message, rinfo);
|
||||
}
|
||||
return;
|
||||
}
|
||||
session.lastSeenMs = Date.now();
|
||||
|
||||
if (type === MessageType.ConnectionRequestReplyConfirm) {
|
||||
this._handleConfirm(session, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.state === "connected") {
|
||||
this._sendUpstream(session, message);
|
||||
} else if (type === MessageType.Ping) {
|
||||
this._replyToPing(listener, message, rinfo);
|
||||
} else {
|
||||
this._queuePending(session, message);
|
||||
}
|
||||
}
|
||||
|
||||
private _replyToPing(listener: Listener, message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||
const reply = protocol.pingReplyFor(message);
|
||||
if (reply) {
|
||||
listener.socket.send(reply, rinfo.port, rinfo.address);
|
||||
}
|
||||
}
|
||||
|
||||
private _createSession(key: string, listener: Listener, rinfo: dgram.RemoteInfo): ClientSession {
|
||||
const session: ClientSession = {
|
||||
key,
|
||||
listener,
|
||||
clientAddress: rinfo.address,
|
||||
clientPort: rinfo.port,
|
||||
lastSeenMs: Date.now(),
|
||||
proxyServerRequestId: randomUInt32(),
|
||||
confirmFragments: new Map(),
|
||||
state: "handshake",
|
||||
pending: [],
|
||||
};
|
||||
this._sessions.set(key, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private _handleConnectionRequest(session: ClientSession, request: protocol.ConnectionRequest, buffer: Buffer) {
|
||||
session.request = { ...request, buffer };
|
||||
if (session.state !== "handshake" && session.state !== "routing") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reply with the client's own version so it proceeds to send its username. If the instance
|
||||
// it ends up on runs a different version the connection will fail at that point instead.
|
||||
this._sendToClient(session, protocol.encodeConnectionRequestReply({
|
||||
version: request.version,
|
||||
clientRequestId: request.clientRequestId,
|
||||
serverRequestId: session.proxyServerRequestId,
|
||||
maxPacketSize: protocol.DEFAULT_MAX_PACKET_SIZE,
|
||||
}));
|
||||
}
|
||||
|
||||
private _handleConfirm(session: ClientSession, message: Buffer) {
|
||||
let header: protocol.MessageHeader;
|
||||
try {
|
||||
header = protocol.parseHeader(message);
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Ignoring malformed connection confirm: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragmentNumber = header.fragmented ? header.fragmentNumber! : 0;
|
||||
if (fragmentNumber === 0) {
|
||||
let confirm: protocol.ConnectionRequestReplyConfirm;
|
||||
try {
|
||||
confirm = protocol.parseConnectionRequestReplyConfirm(message);
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Ignoring malformed connection confirm: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirm.serverRequestId !== session.proxyServerRequestId
|
||||
&& confirm.serverRequestId !== session.serverRequestId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (session.confirmMessageId !== undefined && session.confirmMessageId !== header.messageId) {
|
||||
// The client is sending a different confirm message, start over with it
|
||||
session.confirmFragments.clear();
|
||||
session.confirmLastFragment = undefined;
|
||||
}
|
||||
session.confirmMessageId = header.messageId;
|
||||
session.confirmServerRequestIdOffset = confirm.serverRequestIdOffset;
|
||||
session.username = confirm.username;
|
||||
} else if (session.confirmMessageId !== undefined && header.messageId !== session.confirmMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.serverRequestId !== undefined) {
|
||||
// Resends of the confirm are passed on directly once the instance has answered
|
||||
this._sendUpstream(session, this._patchConfirm(session, message, fragmentNumber));
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.confirmFragments.size < MAX_PENDING_PACKETS) {
|
||||
session.confirmFragments.set(fragmentNumber, message);
|
||||
}
|
||||
if (!header.fragmented || header.lastFragment) {
|
||||
session.confirmLastFragment = fragmentNumber;
|
||||
}
|
||||
|
||||
// Routing waits for the whole message as plugins may use the mods of the client listed in it
|
||||
if (
|
||||
session.state === "handshake"
|
||||
&& session.username !== undefined
|
||||
&& session.request
|
||||
&& this._confirmComplete(session)
|
||||
) {
|
||||
session.state = "routing";
|
||||
this._routeSession(session).catch(err => {
|
||||
this._logger.error(`Unexpected error routing player ${session.username}:\n${err.stack}`);
|
||||
session.state = "failed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _confirmComplete(session: ClientSession) {
|
||||
if (session.confirmLastFragment === undefined) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i <= session.confirmLastFragment; i++) {
|
||||
if (!session.confirmFragments.has(i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _confirmMods(session: ClientSession) {
|
||||
const fragments = [...session.confirmFragments].sort(([a], [b]) => a - b).map(([, fragment]) => fragment);
|
||||
try {
|
||||
return protocol.parseConfirmMods(protocol.joinPayload(fragments));
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Unable to read mods of ${session.username}: ${err.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _patchConfirm(session: ClientSession, message: Buffer, fragmentNumber: number) {
|
||||
if (fragmentNumber !== 0 || session.confirmServerRequestIdOffset === undefined) {
|
||||
return message;
|
||||
}
|
||||
const patched = Buffer.from(message);
|
||||
patched.writeUInt32LE(session.serverRequestId!, session.confirmServerRequestIdOffset);
|
||||
return patched;
|
||||
}
|
||||
|
||||
private async _routeSession(session: ClientSession) {
|
||||
const request: PlayerProxyRouteRequest = {
|
||||
username: session.username!,
|
||||
version: session.request!.version,
|
||||
clientAddress: session.clientAddress,
|
||||
clientPort: session.clientPort,
|
||||
listenPort: session.listener.port,
|
||||
portInstanceId: session.listener.instanceId,
|
||||
mods: this._confirmMods(session),
|
||||
};
|
||||
|
||||
const instanceId = await this._controller.resolvePlayerRoute(request);
|
||||
if (this._sessions.get(session.key) !== session) {
|
||||
return; // Session was replaced while routing
|
||||
}
|
||||
if (instanceId === undefined) {
|
||||
this._logger.warn(`No instance to route player ${request.username} to`);
|
||||
session.state = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
const route = await this._resolveInstanceAddress(instanceId);
|
||||
if (this._sessions.get(session.key) !== session) {
|
||||
return;
|
||||
}
|
||||
if (!route) {
|
||||
this._logger.warn(`Unable to route player ${request.username} to instance ${instanceId}`);
|
||||
session.state = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.verbose(
|
||||
`Routing player ${request.username} (Factorio ${protocol.formatVersion(request.version)}) ` +
|
||||
`to instance ${instanceId} at ${route.address}:${route.port}`
|
||||
);
|
||||
session.route = route;
|
||||
session.state = "connecting";
|
||||
const upstream = dgram.createSocket(isIPv6(route.address) ? "udp6" : "udp4");
|
||||
session.upstream = upstream;
|
||||
upstream.on("message", data => {
|
||||
try {
|
||||
this._handleUpstreamMessage(session, data);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unexpected error handling upstream packet:\n${err.stack}`);
|
||||
}
|
||||
});
|
||||
upstream.on("error", err => {
|
||||
this._logger.warn(`Upstream socket for ${request.username} errored: ${err.message}`);
|
||||
this._closeSession(session);
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const sendRequest = () => {
|
||||
attempts += 1;
|
||||
if (attempts > UPSTREAM_RETRY_COUNT) {
|
||||
this._logger.warn(`Instance ${instanceId} did not answer connection of ${request.username}`);
|
||||
this._closeSession(session);
|
||||
return;
|
||||
}
|
||||
this._sendUpstream(session, session.request!.buffer);
|
||||
};
|
||||
sendRequest();
|
||||
session.upstreamRetry = setInterval(sendRequest, UPSTREAM_RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the address of an instance, starting it if it is stopped.
|
||||
*
|
||||
* @param instanceId - ID of the instance to look up.
|
||||
* @returns address and port of the instance, or undefined if it's not reachable.
|
||||
*/
|
||||
private async _resolveInstanceAddress(instanceId: number): Promise<PlayerProxyRoute | undefined> {
|
||||
let instance = await this._ensureInstanceRunning(instanceId);
|
||||
if (!instance) {
|
||||
return undefined;
|
||||
}
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
const host = hostId !== null ? this._controller.hosts.get(hostId) : undefined;
|
||||
if (!host || !host.connected || !instance.gamePort) {
|
||||
return undefined;
|
||||
}
|
||||
// The address the host connected to the controller from is preferred as it is how the controller
|
||||
// reaches the host, the public address may not be reachable from inside the network.
|
||||
const address = normaliseAddress(host.remoteAddress || host.publicAddress);
|
||||
if (!address) {
|
||||
return undefined;
|
||||
}
|
||||
return { instanceId, address, port: instance.gamePort };
|
||||
}
|
||||
|
||||
private async _ensureInstanceRunning(instanceId: number): Promise<Readonly<InstanceRecord> | undefined> {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
if (!instance || instance.isDeleted || instance.config.get("instance.assigned_host") === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (instance.status === "running") {
|
||||
return instance;
|
||||
}
|
||||
|
||||
if (instance.status === "stopped") {
|
||||
this._logger.info(`Starting instance ${instanceId} for a player connecting through the proxy`);
|
||||
try {
|
||||
await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Failed to start instance ${instanceId}: ${err.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + INSTANCE_START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline && !this._closed) {
|
||||
const current = this._controller.instances.get(instanceId);
|
||||
if (!current || current.isDeleted) {
|
||||
return undefined;
|
||||
}
|
||||
if (current.status === "running") {
|
||||
return current;
|
||||
}
|
||||
if (current.status === "stopped") {
|
||||
return undefined;
|
||||
}
|
||||
await lib.wait(500);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _handleUpstreamMessage(session: ClientSession, data: Buffer) {
|
||||
if (this._sessions.get(session.key) !== session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocol.messageType(data) === MessageType.ConnectionRequestReply) {
|
||||
if (session.serverRequestId !== undefined) {
|
||||
return;
|
||||
}
|
||||
let reply: protocol.ConnectionRequestReply;
|
||||
try {
|
||||
reply = protocol.parseConnectionRequestReply(data);
|
||||
} catch (err: any) {
|
||||
this._logger.warn(
|
||||
`Ignoring malformed reply from instance ${session.route!.instanceId}: ${err.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reply.clientRequestId !== session.request!.clientRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(session.upstreamRetry);
|
||||
session.upstreamRetry = undefined;
|
||||
session.serverRequestId = reply.serverRequestId;
|
||||
this.serverVersions.set(session.route!.instanceId, reply.version);
|
||||
if (!protocol.versionsEqual(reply.version, session.request!.version)) {
|
||||
this._logger.warn(
|
||||
`Player ${session.username} runs Factorio ${protocol.formatVersion(session.request!.version)} ` +
|
||||
`(build ${session.request!.version.build}) but instance ${session.route!.instanceId} runs ` +
|
||||
`${protocol.formatVersion(reply.version)} (build ${reply.version.build})`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [fragmentNumber, fragment] of [...session.confirmFragments].sort(([a], [b]) => a - b)) {
|
||||
this._sendUpstream(session, this._patchConfirm(session, fragment, fragmentNumber));
|
||||
}
|
||||
session.confirmFragments.clear();
|
||||
session.state = "connected";
|
||||
for (const pending of session.pending) {
|
||||
this._sendUpstream(session, pending);
|
||||
}
|
||||
session.pending = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendToClient(session, data);
|
||||
}
|
||||
|
||||
private _sendToClient(session: ClientSession, data: Buffer) {
|
||||
session.listener.socket.send(data, session.clientPort, session.clientAddress);
|
||||
}
|
||||
|
||||
private _sendUpstream(session: ClientSession, data: Buffer) {
|
||||
if (session.upstream && session.route) {
|
||||
session.upstream.send(data, session.route.port, session.route.address);
|
||||
}
|
||||
}
|
||||
|
||||
private _queuePending(session: ClientSession, message: Buffer) {
|
||||
if (session.pending.length >= MAX_PENDING_PACKETS) {
|
||||
session.pending.shift();
|
||||
}
|
||||
session.pending.push(message);
|
||||
}
|
||||
|
||||
private _closeSession(session: ClientSession) {
|
||||
clearInterval(session.upstreamRetry);
|
||||
session.upstreamRetry = undefined;
|
||||
if (session.upstream) {
|
||||
session.upstream.close();
|
||||
session.upstream = undefined;
|
||||
}
|
||||
if (this._sessions.get(session.key) === session) {
|
||||
this._sessions.delete(session.key);
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanupSessions() {
|
||||
const now = Date.now();
|
||||
for (const session of [...this._sessions.values()]) {
|
||||
if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) {
|
||||
this._closeSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a Factorio server which version and build it runs
|
||||
*
|
||||
* Sends a connection request and reads the version from the reply, the
|
||||
* server forgets the request after its connection timeout.
|
||||
*
|
||||
* @param address - Address of the Factorio server.
|
||||
* @param port - Game port of the Factorio server.
|
||||
* @returns Version of the server, or undefined if it did not answer.
|
||||
*/
|
||||
static async probeVersion(address: string, port: number): Promise<protocol.ApplicationVersion | undefined> {
|
||||
const socket = dgram.createSocket(isIPv6(address) ? "udp6" : "udp4");
|
||||
const clientRequestId = randomUInt32();
|
||||
try {
|
||||
return await new Promise<protocol.ApplicationVersion | undefined>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => resolve(undefined), PROBE_TIMEOUT_MS);
|
||||
socket.on("error", err => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
socket.on("message", data => {
|
||||
try {
|
||||
const reply = protocol.parseConnectionRequestReply(data);
|
||||
if (reply.clientRequestId === clientRequestId) {
|
||||
clearTimeout(timeout);
|
||||
resolve(reply.version);
|
||||
}
|
||||
} catch { /* not the reply we're waiting for */ }
|
||||
});
|
||||
// The server replies with its own version regardless of what the client sends.
|
||||
const version = { major: 0, minor: 0, sub: 0, build: 0 };
|
||||
socket.send(protocol.encodeConnectionRequest(version, clientRequestId), port, address);
|
||||
});
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the version and build of an instance
|
||||
*
|
||||
* @param instanceId - ID of the instance.
|
||||
* @returns version of the running instance, or the last version seen if it is stopped.
|
||||
*/
|
||||
async instanceVersion(instanceId: number) {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
if (instance?.status === "running") {
|
||||
const route = await this._resolveInstanceAddress(instanceId);
|
||||
if (route) {
|
||||
const version = await PlayerProxyServer.probeVersion(route.address, route.port).catch(() => undefined);
|
||||
if (version) {
|
||||
this.serverVersions.set(instanceId, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.serverVersions.get(instanceId);
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export default class UserManager {
|
||||
}
|
||||
|
||||
// We intentionally write the roles first because if it fails then the old user file will still contain them
|
||||
await lib.atomicOverwriteFile(rolesFilePath, JSON.stringify(combinedJson.roles));
|
||||
await lib.atomicOverwriteFile(usersFilePath, JSON.stringify(combinedJson.users));
|
||||
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
|
||||
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
@@ -151,34 +151,11 @@ export default class UserManager {
|
||||
* @returns JWT access token for the user.
|
||||
*/
|
||||
signUserToken(user: UserRecord): string {
|
||||
return jwt.sign({ aud: "user", user: user.id }, this._authSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a user token
|
||||
*
|
||||
* @param token - User token to check.
|
||||
* @returns the user the token was issued to.
|
||||
* @throws if the token is invalid, its user does not exist or it was issued before tokenValidAfter.
|
||||
*/
|
||||
verifyUserToken(token: string): User {
|
||||
const payload = jwt.verify(token, this._authSecret, { audience: "user" });
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
const user = this.getByNameMutable(payload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
if (!payload.iat || payload.iat < user.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private get _authSecret() {
|
||||
// auth secret is never null after startup
|
||||
return Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64");
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: user.id },
|
||||
// auth secret is never null after startup
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear all player stats for specific instance */
|
||||
|
||||
@@ -252,9 +252,21 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.controller.hosts.verifyHostToken(data.token).id !== data.id) {
|
||||
// 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) {
|
||||
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();
|
||||
@@ -291,13 +303,6 @@ ${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
|
||||
@@ -320,7 +325,18 @@ ${err.stack}`
|
||||
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
|
||||
let user;
|
||||
try {
|
||||
user = this.controller.users.verifyUserToken(data.token);
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
user = this.controller.users.getByNameMutable(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
if ((tokenPayload.iat || 0) < user.tokenValidAfter) {
|
||||
throw new Error("invalid token");
|
||||
}
|
||||
user.checkPermission("core.control.connect");
|
||||
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Encoding and decoding of the parts of the Factorio network protocol the
|
||||
* player proxy needs to look at: the message header and the connection
|
||||
* handshake messages.
|
||||
*
|
||||
* Based on the Factorio 2.x wire format. Every multi-byte value is little
|
||||
* endian, "space optimized" values are a single byte unless that byte is 0xff,
|
||||
* in which case the full width value follows it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
export const MessageType = {
|
||||
Ping: 0,
|
||||
PingReply: 1,
|
||||
ConnectionRequest: 2,
|
||||
ConnectionRequestReply: 3,
|
||||
ConnectionRequestReplyConfirm: 4,
|
||||
ConnectionAcceptOrDeny: 5,
|
||||
} as const;
|
||||
|
||||
const TYPE_MASK = 0x1f;
|
||||
const FRAGMENTED_FLAG = 0x40;
|
||||
const LAST_FRAGMENT_FLAG = 0x80;
|
||||
const CONFIRM_FLAG = 0x8000;
|
||||
const MESSAGE_ID_MASK = 0x7fff;
|
||||
|
||||
/** Message types that carry a message id even when not fragmented */
|
||||
const RELIABLE_TYPES: ReadonlySet<number> = new Set([
|
||||
MessageType.ConnectionRequest,
|
||||
MessageType.ConnectionRequestReplyConfirm,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Max packet size Factorio servers tell clients to use, this is the largest
|
||||
* datagram every host on the Internet is required to be able to receive.
|
||||
*/
|
||||
export const DEFAULT_MAX_PACKET_SIZE = 508;
|
||||
|
||||
export interface ApplicationVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
sub: number;
|
||||
build: number;
|
||||
}
|
||||
|
||||
export function formatVersion(version: ApplicationVersion) {
|
||||
return `${version.major}.${version.minor}.${version.sub}`;
|
||||
}
|
||||
|
||||
export function versionsEqual(a: ApplicationVersion, b: ApplicationVersion) {
|
||||
return a.major === b.major && a.minor === b.minor && a.sub === b.sub && a.build === b.build;
|
||||
}
|
||||
|
||||
export interface MessageHeader {
|
||||
type: number;
|
||||
fragmented: boolean;
|
||||
lastFragment: boolean;
|
||||
messageId?: number;
|
||||
fragmentNumber?: number;
|
||||
confirms: number[];
|
||||
/** Offset of the payload of the message */
|
||||
payloadOffset: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRequest {
|
||||
header: MessageHeader;
|
||||
version: ApplicationVersion;
|
||||
clientRequestId: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRequestReply {
|
||||
version: ApplicationVersion;
|
||||
clientRequestId: number;
|
||||
serverRequestId: number;
|
||||
maxPacketSize: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRequestReplyConfirm {
|
||||
clientRequestId: number;
|
||||
serverRequestId: number;
|
||||
/** Offset of serverRequestId in the packet */
|
||||
serverRequestIdOffset: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export class BufferReader {
|
||||
constructor(
|
||||
private _buffer: Buffer,
|
||||
public offset = 0,
|
||||
) { }
|
||||
|
||||
private _take(length: number) {
|
||||
const start = this.offset;
|
||||
if (start + length > this._buffer.length) {
|
||||
throw new Error("Unexpected end of message");
|
||||
}
|
||||
this.offset += length;
|
||||
return start;
|
||||
}
|
||||
|
||||
readUInt8() {
|
||||
return this._buffer.readUInt8(this._take(1));
|
||||
}
|
||||
|
||||
readUInt16LE() {
|
||||
return this._buffer.readUInt16LE(this._take(2));
|
||||
}
|
||||
|
||||
readUInt32LE() {
|
||||
return this._buffer.readUInt32LE(this._take(4));
|
||||
}
|
||||
|
||||
/** Read a value stored as one byte or 0xff followed by a uint16 */
|
||||
readSpaceOptimizedUInt16() {
|
||||
const value = this.readUInt8();
|
||||
return value === 0xff ? this.readUInt16LE() : value;
|
||||
}
|
||||
|
||||
/** Read a value stored as one byte or 0xff followed by a uint32 */
|
||||
readSpaceOptimizedUInt32() {
|
||||
const value = this.readUInt8();
|
||||
return value === 0xff ? this.readUInt32LE() : value;
|
||||
}
|
||||
|
||||
readString() {
|
||||
const length = this.readSpaceOptimizedUInt32();
|
||||
const start = this._take(length);
|
||||
return this._buffer.toString("utf8", start, start + length);
|
||||
}
|
||||
|
||||
readVersion(): ApplicationVersion {
|
||||
return {
|
||||
major: this.readSpaceOptimizedUInt16(),
|
||||
minor: this.readSpaceOptimizedUInt16(),
|
||||
sub: this.readSpaceOptimizedUInt16(),
|
||||
build: this.readUInt32LE(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferWriter {
|
||||
private _parts: number[] = [];
|
||||
|
||||
writeUInt8(value: number) {
|
||||
this._parts.push(value & 0xff);
|
||||
}
|
||||
|
||||
writeUInt16LE(value: number) {
|
||||
this.writeUInt8(value);
|
||||
this.writeUInt8(value >>> 8);
|
||||
}
|
||||
|
||||
writeUInt32LE(value: number) {
|
||||
this.writeUInt16LE(value);
|
||||
this.writeUInt16LE(value >>> 16);
|
||||
}
|
||||
|
||||
writeSpaceOptimizedUInt16(value: number) {
|
||||
if (value >= 0xff) {
|
||||
this.writeUInt8(0xff);
|
||||
this.writeUInt16LE(value);
|
||||
} else {
|
||||
this.writeUInt8(value);
|
||||
}
|
||||
}
|
||||
|
||||
writeVersion(version: ApplicationVersion) {
|
||||
this.writeSpaceOptimizedUInt16(version.major);
|
||||
this.writeSpaceOptimizedUInt16(version.minor);
|
||||
this.writeSpaceOptimizedUInt16(version.sub);
|
||||
this.writeUInt32LE(version.build);
|
||||
}
|
||||
|
||||
toBuffer() {
|
||||
return Buffer.from(this._parts);
|
||||
}
|
||||
}
|
||||
|
||||
export function messageType(buffer: Buffer) {
|
||||
if (!buffer.length) {
|
||||
return undefined;
|
||||
}
|
||||
return buffer[0] & TYPE_MASK;
|
||||
}
|
||||
|
||||
export function parseHeader(buffer: Buffer): MessageHeader {
|
||||
const reader = new BufferReader(buffer);
|
||||
const typeAndFlags = reader.readUInt8();
|
||||
const type = typeAndFlags & TYPE_MASK;
|
||||
const fragmented = (typeAndFlags & FRAGMENTED_FLAG) !== 0;
|
||||
const lastFragment = (typeAndFlags & LAST_FRAGMENT_FLAG) !== 0;
|
||||
|
||||
let messageId: number | undefined;
|
||||
let hasConfirm = false;
|
||||
if (RELIABLE_TYPES.has(type) || fragmented) {
|
||||
const messageIdAndFlags = reader.readUInt16LE();
|
||||
messageId = messageIdAndFlags & MESSAGE_ID_MASK;
|
||||
hasConfirm = (messageIdAndFlags & CONFIRM_FLAG) !== 0;
|
||||
}
|
||||
|
||||
let fragmentNumber: number | undefined;
|
||||
if (fragmented) {
|
||||
fragmentNumber = reader.readSpaceOptimizedUInt16();
|
||||
}
|
||||
|
||||
const confirms: number[] = [];
|
||||
if (hasConfirm) {
|
||||
const count = reader.readUInt8();
|
||||
for (let i = 0; i < count; i++) {
|
||||
confirms.push(reader.readUInt32LE());
|
||||
}
|
||||
}
|
||||
|
||||
return { type, fragmented, lastFragment, messageId, fragmentNumber, confirms, payloadOffset: reader.offset };
|
||||
}
|
||||
|
||||
export function parseConnectionRequest(buffer: Buffer): ConnectionRequest {
|
||||
const header = parseHeader(buffer);
|
||||
if (header.type !== MessageType.ConnectionRequest) {
|
||||
throw new Error(`Expected ConnectionRequest, got message type ${header.type}`);
|
||||
}
|
||||
const reader = new BufferReader(buffer, header.payloadOffset);
|
||||
const version = reader.readVersion();
|
||||
const clientRequestId = reader.readUInt32LE();
|
||||
return { header, version, clientRequestId };
|
||||
}
|
||||
|
||||
export function parseConnectionRequestReply(buffer: Buffer): ConnectionRequestReply {
|
||||
const header = parseHeader(buffer);
|
||||
if (header.type !== MessageType.ConnectionRequestReply) {
|
||||
throw new Error(`Expected ConnectionRequestReply, got message type ${header.type}`);
|
||||
}
|
||||
const reader = new BufferReader(buffer, header.payloadOffset);
|
||||
return {
|
||||
version: reader.readVersion(),
|
||||
clientRequestId: reader.readUInt32LE(),
|
||||
serverRequestId: reader.readUInt32LE(),
|
||||
maxPacketSize: reader.readUInt16LE(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the start of a ConnectionRequestReplyConfirm
|
||||
*
|
||||
* The message contains the mod list and startup settings of the client and
|
||||
* is usually fragmented, but the fields read here are always in the first
|
||||
* fragment.
|
||||
*
|
||||
* @param buffer - Unfragmented message or first fragment of it.
|
||||
* @returns the parsed fields.
|
||||
*/
|
||||
export function parseConnectionRequestReplyConfirm(buffer: Buffer): ConnectionRequestReplyConfirm {
|
||||
const header = parseHeader(buffer);
|
||||
if (header.type !== MessageType.ConnectionRequestReplyConfirm) {
|
||||
throw new Error(`Expected ConnectionRequestReplyConfirm, got message type ${header.type}`);
|
||||
}
|
||||
if (header.fragmented && header.fragmentNumber !== 0) {
|
||||
throw new Error(`Expected first fragment, got fragment ${header.fragmentNumber}`);
|
||||
}
|
||||
const reader = new BufferReader(buffer, header.payloadOffset);
|
||||
const clientRequestId = reader.readUInt32LE();
|
||||
const serverRequestIdOffset = reader.offset;
|
||||
const serverRequestId = reader.readUInt32LE();
|
||||
reader.readUInt32LE(); // instanceID
|
||||
const username = reader.readString();
|
||||
return { clientRequestId, serverRequestId, serverRequestIdOffset, username };
|
||||
}
|
||||
|
||||
/** Mod enabled in a Factorio client */
|
||||
export interface ModId {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the payloads of the fragments of a message
|
||||
*
|
||||
* @param fragments -
|
||||
* Fragments of the message in order of fragment number, or the message
|
||||
* if it's not fragmented.
|
||||
* @returns the payload of the message.
|
||||
*/
|
||||
export function joinPayload(fragments: Buffer[]) {
|
||||
return Buffer.concat(fragments.map(fragment => fragment.subarray(parseHeader(fragment).payloadOffset)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the mods enabled in the client from a ConnectionRequestReplyConfirm
|
||||
*
|
||||
* @param payload - Complete payload of the message, see {@link joinPayload}.
|
||||
* @returns name and version of each mod.
|
||||
*/
|
||||
export function parseConfirmMods(payload: Buffer): ModId[] {
|
||||
const reader = new BufferReader(payload);
|
||||
reader.readUInt32LE(); // connectionRequestIDGeneratedOnClient
|
||||
reader.readUInt32LE(); // connectionRequestIDGeneratedOnServer
|
||||
reader.readUInt32LE(); // instanceID
|
||||
reader.readString(); // username
|
||||
reader.readString(); // password
|
||||
reader.readString(); // serverKey
|
||||
reader.readString(); // serverKeyTimestamp
|
||||
reader.readUInt32LE(); // coreChecksum
|
||||
reader.readUInt32LE(); // prototypeListChecksum
|
||||
const count = reader.readSpaceOptimizedUInt32();
|
||||
const mods: ModId[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = reader.readString();
|
||||
const version = [
|
||||
reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(),
|
||||
].join(".");
|
||||
reader.readUInt32LE(); // crc
|
||||
mods.push({ name, version });
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
export function encodeConnectionRequest(version: ApplicationVersion, clientRequestId: number, messageId = 0) {
|
||||
const writer = new BufferWriter();
|
||||
writer.writeUInt8(MessageType.ConnectionRequest);
|
||||
writer.writeUInt16LE(messageId & MESSAGE_ID_MASK);
|
||||
writer.writeVersion(version);
|
||||
writer.writeUInt32LE(clientRequestId);
|
||||
return writer.toBuffer();
|
||||
}
|
||||
|
||||
export function encodeConnectionRequestReply(reply: ConnectionRequestReply) {
|
||||
const writer = new BufferWriter();
|
||||
writer.writeUInt8(MessageType.ConnectionRequestReply);
|
||||
writer.writeVersion(reply.version);
|
||||
writer.writeUInt32LE(reply.clientRequestId);
|
||||
writer.writeUInt32LE(reply.serverRequestId);
|
||||
writer.writeUInt16LE(reply.maxPacketSize);
|
||||
return writer.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Ping into the PingReply a server would send back
|
||||
*
|
||||
* @param ping - Ping message received.
|
||||
* @returns PingReply message, or undefined if ping was malformed.
|
||||
*/
|
||||
export function pingReplyFor(ping: Buffer) {
|
||||
if (ping.length < 3) {
|
||||
return undefined;
|
||||
}
|
||||
const reply = Buffer.from(ping.subarray(0, 3));
|
||||
reply[0] = MessageType.PingReply;
|
||||
return reply;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import express, { type Application, type Request, type Response } from "express";
|
||||
import type { Application, Request, Response } from "express";
|
||||
import Controller from "./Controller.js";
|
||||
import HostPairingManager, { HostPairingError } from "./HostPairingManager.js";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "node:fs/promises";
|
||||
import jwt from "jsonwebtoken";
|
||||
import path from "path";
|
||||
import nodeStream from "stream";
|
||||
import util from "util";
|
||||
@@ -188,7 +188,17 @@ function validateHostToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
req.app.locals.controller.hosts.verifyHostToken(token);
|
||||
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");
|
||||
}
|
||||
} catch (err) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
@@ -205,7 +215,19 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
res.locals.user = req.app.locals.controller.users.verifyUserToken(token);
|
||||
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;
|
||||
|
||||
} catch (err) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
@@ -214,105 +236,6 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
next();
|
||||
}
|
||||
|
||||
// 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 = controller.users.verifyUserToken(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 host = controller.hosts.createHost(name);
|
||||
logger.audit(
|
||||
`User ${user.name} approved host ${host.name} (${host.id}) from ${remoteAddress} with their token`
|
||||
);
|
||||
res.status(200).json({ hostId: host.id, token: controller.hosts.signHostToken(host.id), name: host.name });
|
||||
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") {
|
||||
@@ -367,7 +290,7 @@ async function uploadExport(req: Request, res: Response) {
|
||||
let { name, ext } = path.posix.parse(filePath);
|
||||
let hash = await lib.hashStream(await zip.openStream(file));
|
||||
assets[name] = `${name}.${hash}${ext}`;
|
||||
await lib.atomicOverwriteFile(path.join("static", `${name}.${hash}${ext}`), content);
|
||||
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), content);
|
||||
}
|
||||
|
||||
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
|
||||
@@ -377,30 +300,6 @@ async function uploadExport(req: Request, res: Response) {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
|
||||
// Reached when no static file matched, drops exports whose files were removed
|
||||
export function missingExportAsset(req: Request, res: Response, next: any) {
|
||||
const file = req.path.slice(1);
|
||||
if (!["GET", "HEAD"].includes(req.method) || !file) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const modPacks = res.app.locals.controller.modPacks;
|
||||
const affected = [...modPacks.valuesMutable()].filter(
|
||||
modPack => modPack.exportManifest && Object.values(modPack.exportManifest.assets).includes(file)
|
||||
);
|
||||
if (!affected.length) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
for (const modPack of affected) {
|
||||
Controller.dropExport(modPack, `${file} was removed from disk`);
|
||||
}
|
||||
modPacks.setMany(affected);
|
||||
res.status(404).type("text/plain").send(
|
||||
`Export data file ${file} is missing, run export-data on an instance to regenerate it`
|
||||
);
|
||||
}
|
||||
|
||||
type ProxyStreamEvents = {
|
||||
"error": [ err: any ],
|
||||
"close": [],
|
||||
@@ -787,18 +686,6 @@ 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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -4,113 +4,3 @@ 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.
|
||||
+202
-180
@@ -1,14 +1,10 @@
|
||||
#!/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";
|
||||
@@ -20,18 +16,21 @@ 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, nodejs/node#30701 and nodejs/node#31762
|
||||
// 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
|
||||
// The solution below was adapted from aws-amplify/amplify-cli#12347
|
||||
if (process.platform === "win32") {
|
||||
const close = readline.Interface.prototype.close;
|
||||
readline.Interface.prototype.close = function() {
|
||||
this.terminal = false;
|
||||
close.call(this);
|
||||
const _inquirer_close = inquirer.ui.Prompt.prototype.close;
|
||||
inquirer.ui.Prompt.prototype.close = function() {
|
||||
this.rl.terminal = false;
|
||||
_inquirer_close.call(this);
|
||||
};
|
||||
}
|
||||
|
||||
const availablePlugins = [
|
||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||
{ name: "Lobby", value: "@clusterio/plugin-lobby" },
|
||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||
@@ -104,7 +103,7 @@ async function pathExists(filePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function atomicOverwriteFile(file, data, options={}) {
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
if (dir) {
|
||||
@@ -163,48 +162,21 @@ async function execCtl(args) {
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
|
||||
// Same characters as the controller refuses in host names.
|
||||
const hostNameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
|
||||
function validateHostToken(token) {
|
||||
let parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
|
||||
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;
|
||||
let parsed;
|
||||
try {
|
||||
url = new URL(value);
|
||||
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
|
||||
} catch (err) {
|
||||
return "Controller URL must be a URL like https://example.com/";
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return "Controller URL must start with http:// or https://";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The host requires the controller URL to end with a slash.
|
||||
function normaliseControllerUrl(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]);
|
||||
if (parsed.aud !== "host" || !Number.isInteger(parsed.host)) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +229,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function migrateConfig(source, destination) {
|
||||
if (await pathExists(source) && !await pathExists(destination)) {
|
||||
await atomicOverwriteFile(destination, JSON.stringify(
|
||||
await safeOutputFile(destination, JSON.stringify(
|
||||
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
||||
), { mode: 0o600 });
|
||||
logger.info(`Migrated ${source} to ${destination}`);
|
||||
@@ -269,7 +241,7 @@ async function migrateRename(args) {
|
||||
|
||||
let instancesFile = path.join("database", "instances.json");
|
||||
if (await pathExists(instancesFile)) {
|
||||
await atomicOverwriteFile(instancesFile, JSON.stringify(
|
||||
await safeOutputFile(instancesFile, JSON.stringify(
|
||||
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
|
||||
));
|
||||
logger.info(`Migrated ${instancesFile}`);
|
||||
@@ -281,7 +253,7 @@ async function migrateRename(args) {
|
||||
for (let role of users["roles"]) {
|
||||
role["permissions"] = role["permissions"].map(rename);
|
||||
}
|
||||
await atomicOverwriteFile(usersFile, JSON.stringify(users, null, "\t"));
|
||||
await safeOutputFile(usersFile, JSON.stringify(users, null, "\t"));
|
||||
logger.info(`Migrated ${usersFile}`);
|
||||
}
|
||||
|
||||
@@ -309,7 +281,7 @@ async function migrateRename(args) {
|
||||
lines.push(inputLine);
|
||||
}
|
||||
}
|
||||
atomicOverwriteFile(outputFile, Buffer.concat(lines));
|
||||
safeOutputFile(outputFile, Buffer.concat(lines));
|
||||
}
|
||||
|
||||
async function migrateLogsDir(inputLogs, outputLogs) {
|
||||
@@ -434,7 +406,7 @@ async function groupIdToName(gid) {
|
||||
async function writeScripts(mode, selfSignedCert) {
|
||||
if (["standalone", "controller"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await atomicOverwriteFile(
|
||||
await safeOutputFile(
|
||||
"run-controller.cmd",
|
||||
`\
|
||||
@echo off
|
||||
@@ -447,7 +419,7 @@ goto restart
|
||||
`
|
||||
);
|
||||
} else {
|
||||
await atomicOverwriteFile(
|
||||
await safeOutputFile(
|
||||
"run-controller.sh",
|
||||
`\
|
||||
#!/bin/bash
|
||||
@@ -459,7 +431,7 @@ done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await atomicOverwriteFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriocontroller.service",
|
||||
`\
|
||||
[Unit]
|
||||
@@ -486,7 +458,7 @@ WantedBy=multi-user.target
|
||||
if (["standalone", "host"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
||||
await atomicOverwriteFile(
|
||||
await safeOutputFile(
|
||||
"run-host.cmd",
|
||||
`\
|
||||
@echo off
|
||||
@@ -500,7 +472,7 @@ goto restart
|
||||
);
|
||||
} else {
|
||||
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
||||
await atomicOverwriteFile(
|
||||
await safeOutputFile(
|
||||
"run-host.sh",
|
||||
`\
|
||||
#!/bin/bash
|
||||
@@ -513,7 +485,7 @@ done
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
|
||||
await atomicOverwriteFile(
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriohost.service",
|
||||
`\
|
||||
[Unit]
|
||||
@@ -539,87 +511,96 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
async function promptMissingArgs(args) {
|
||||
async function inquirerMissingArgs(args) {
|
||||
let answers = {};
|
||||
if (args.mode) { answers.mode = args.mode; }
|
||||
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
|
||||
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" },
|
||||
],
|
||||
});
|
||||
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);
|
||||
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
if (args.admin) { answers.admin = args.admin; }
|
||||
answers.admin ??= await input({
|
||||
message: "Admin account name",
|
||||
validate: value => {
|
||||
if (!value) {
|
||||
return "May not be empty";
|
||||
}
|
||||
return true;
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "admin",
|
||||
message: "Admin account name",
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
], answers);
|
||||
|
||||
if (args.httpPort) { answers.httpPort = args.httpPort; }
|
||||
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 = 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
answers.hostName = args.hostName ?? "local";
|
||||
const result = validateHostName(answers.hostName);
|
||||
if (result !== true) {
|
||||
throw new InstallError(result);
|
||||
}
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
if (args.hostName) { answers.hostName = args.hostName; }
|
||||
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);
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "hostName",
|
||||
message: "Name of host",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "ctl") {
|
||||
if (["host", "ctl"].includes(answers.mode)) {
|
||||
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
||||
answers.controllerUrl ??= await input({ message: "Controller URL" });
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "controllerUrl",
|
||||
message: "Controller URL",
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.controllerToken) { answers.controllerToken = args.controllerToken; }
|
||||
answers.controllerToken ??= await input({ message: "Controller authentication Token" });
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "controllerToken",
|
||||
message: "Controller authentication Token",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
validateHostToken(answers.controllerToken);
|
||||
}
|
||||
|
||||
if (["standalone", "host"].includes(answers.mode)) {
|
||||
@@ -632,10 +613,14 @@ async function promptMissingArgs(args) {
|
||||
myIp = await result.text();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
answers.publicAddress ??= await input({
|
||||
message: "Public DNS/IP address of this server",
|
||||
default: myIp,
|
||||
});
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "publicAddress",
|
||||
message: "Public DNS/IP address of this server",
|
||||
default: myIp,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.factorioDir) { answers.factorioDir = args.factorioDir; }
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
@@ -646,17 +631,28 @@ async function promptMissingArgs(args) {
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
],
|
||||
});
|
||||
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);
|
||||
|
||||
if (answers.factorioDir === null) {
|
||||
answers.factorioDir = await input({ message: "Path to Factorio installation" });
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
askAnswered: true,
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
}
|
||||
@@ -664,10 +660,14 @@ async function promptMissingArgs(args) {
|
||||
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers.remoteNpm ??= await confirm({
|
||||
message: "Allow remote updates and plugin installs from the web interface?",
|
||||
default: true,
|
||||
});
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "remoteNpm",
|
||||
message: "Allow remote updates via npm?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "plugin-template") {
|
||||
@@ -678,23 +678,29 @@ async function promptMissingArgs(args) {
|
||||
answers.pluginName = args["plugin-name"];
|
||||
}
|
||||
answers.plugins = [];
|
||||
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()),
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
@@ -702,11 +708,15 @@ async function promptMissingArgs(args) {
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers.plugins ??= await checkbox({
|
||||
message: "Plugins to install",
|
||||
choices: availablePlugins,
|
||||
pageSize: 20,
|
||||
});
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "plugins",
|
||||
message: "Plugins to install",
|
||||
choices: availablePlugins,
|
||||
pageSize: 20,
|
||||
},
|
||||
], answers);
|
||||
|
||||
return answers;
|
||||
}
|
||||
@@ -734,13 +744,13 @@ async function main() {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [standalone/host]", type: "string",
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
.option("controller-url", {
|
||||
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
|
||||
})
|
||||
.option("controller-token", {
|
||||
nargs: 1, describe: "Controller authentication token [ctl]", type: "string",
|
||||
nargs: 1, describe: "Controller authentication token [host/ctl]", type: "string",
|
||||
})
|
||||
.option("public-address", {
|
||||
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/host]", type: "string",
|
||||
@@ -752,8 +762,7 @@ async function main() {
|
||||
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
|
||||
})
|
||||
.option("remote-npm", {
|
||||
nargs: 0, type: "boolean", description:
|
||||
"Allow remote updates and plugin installs from the web interface [standalone/controller/host]",
|
||||
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
@@ -790,7 +799,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let answers = await promptMissingArgs(args);
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (answers.pluginTemplate) {
|
||||
@@ -808,28 +817,44 @@ async function main() {
|
||||
await execController(["config", "create"]);
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
await setRemoteUpdates(execController, "controller", answers.remoteNpm);
|
||||
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
||||
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
|
||||
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
||||
adminToken = result.stdout.split("\n").slice(-2)[0];
|
||||
}
|
||||
|
||||
if (["standalone", "host"].includes(answers.mode)) {
|
||||
if (answers.mode === "standalone") {
|
||||
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];
|
||||
|
||||
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_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 setRemoteUpdates(execHost, "host", answers.remoteNpm);
|
||||
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]);
|
||||
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]);
|
||||
}
|
||||
|
||||
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
@@ -854,10 +879,7 @@ async function main() {
|
||||
if (import.meta.main) {
|
||||
main().catch(err => {
|
||||
process.exitCode = 1;
|
||||
if (err.name === "ExitPromptError") {
|
||||
// Ctrl+C at a prompt
|
||||
process.exitCode = 130;
|
||||
} else if (err instanceof InstallError) {
|
||||
if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
logger.fatal(`
|
||||
|
||||
@@ -28,15 +28,12 @@
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"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.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -50,78 +51,62 @@ 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: ["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: ["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",
|
||||
});
|
||||
definition: ["generate-token", "Generate token for a host", (yargs) => {
|
||||
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
|
||||
}],
|
||||
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}`);
|
||||
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);
|
||||
},
|
||||
}));
|
||||
|
||||
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" });
|
||||
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.send(new lib.HostRevokeRequest(hostId));
|
||||
await ctl.send(new lib.HostRevokeTokensRequest(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" });
|
||||
@@ -286,7 +271,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, state: p.state, npmPackage: p.npmPackage,
|
||||
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -20,14 +20,10 @@ export const instanceCommands = new lib.CommandTree({
|
||||
name: "instance", alias: ["i"], description: "Instance management",
|
||||
});
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List instances known to the controller", (yargs) => {
|
||||
yargs.options({
|
||||
"templates": { describe: "Include templates", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { templates: boolean }, ctl: Ctl) {
|
||||
definition: [["list", "l"], "List instances known to the controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let list = await ctl.send(new lib.InstanceDetailsListRequest());
|
||||
print(asTable(list.filter(instance => args.templates || !instance.isTemplate)));
|
||||
print(asTable(list));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,30 +34,18 @@ instanceCommands.add(new lib.Command({
|
||||
yargs.options({
|
||||
"id": { type: "number", nargs: 1, describe: "Instance id" },
|
||||
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
|
||||
"template": { type: "string", nargs: 1, describe: "Template to inherit config from" },
|
||||
"as-template": { type: "boolean", nargs: 0, describe: "Create the instance as a template" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
args: { name: string, id?: number, from?: number, template?: string, asTemplate?: boolean },
|
||||
ctl: Ctl,
|
||||
) {
|
||||
handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) {
|
||||
let instanceConfig = new lib.InstanceConfig("control");
|
||||
let fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"];
|
||||
if (args.id !== undefined) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
if (args.template !== undefined) {
|
||||
instanceConfig.set("instance.template_id", await lib.resolveInstance(ctl, args.template));
|
||||
fields.push("instance.template_id");
|
||||
}
|
||||
if (args.asTemplate) {
|
||||
instanceConfig.set("instance.is_template", true);
|
||||
fields.push("instance.is_template");
|
||||
}
|
||||
await ctl.send(new lib.InstanceCreateRequest(
|
||||
instanceConfig.toRemote("controller", fields),
|
||||
instanceConfig.toRemote("controller", [
|
||||
"instance.id", "instance.name",
|
||||
]),
|
||||
args.from
|
||||
));
|
||||
},
|
||||
@@ -77,42 +61,13 @@ instanceConfigCommands.add(new lib.Command({
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
|
||||
const inherited = config["instance.template_id"] !== null
|
||||
? config["instance.inherited_fields"] as Record<string, true | string[]> ?? {}
|
||||
: {};
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
if (name === "instance.inherited_fields") {
|
||||
continue;
|
||||
}
|
||||
const entry = inherited[name];
|
||||
let note = "";
|
||||
if (entry instanceof Array && entry.length) {
|
||||
note = ` (inherited except ${entry.join(", ")})`;
|
||||
} else if (entry) {
|
||||
note = " (inherited)";
|
||||
}
|
||||
print(`${name} ${JSON.stringify(value)}${note}`);
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"inherit <instance> <field> [prop]",
|
||||
"Make field or property in instance config use the value of its template",
|
||||
(yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to update config on", type: "string" });
|
||||
yargs.positional("field", { describe: "Field to inherit", type: "string" });
|
||||
yargs.positional("prop", { describe: "Property of object field to inherit", type: "string" });
|
||||
},
|
||||
],
|
||||
handler: async function(args: { instance: string, field: string, prop?: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.send(new lib.InstanceConfigInheritFieldRequest(instanceId, args.field, args.prop));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceConfigCommands.add(new lib.Command({
|
||||
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
|
||||
@@ -215,10 +170,8 @@ instanceConfigCommands.add(new lib.Command({
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
const original = await configToKeyVal(allConfigElements);
|
||||
for (let index in final) {
|
||||
// Unchanged fields are skipped so they keep inheriting from the template
|
||||
if (index in final && final[index] !== original[index]) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await ctl.send(new lib.InstanceConfigSetFieldRequest(
|
||||
instanceId,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -132,10 +132,6 @@ 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
|
||||
|
||||
+14
-41
@@ -20,14 +20,11 @@ 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 type Host from "./src/Host.js";
|
||||
import Host from "./src/Host.js";
|
||||
import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js";
|
||||
import { PairingError, ensurePaired } from "./src/pairing.js";
|
||||
|
||||
let host: Host | undefined;
|
||||
|
||||
@@ -137,10 +134,6 @@ 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()
|
||||
@@ -219,6 +212,19 @@ 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!");
|
||||
@@ -227,39 +233,6 @@ 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,7 +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";
|
||||
export { setSaveScenario } from "./src/patch.js";
|
||||
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
|
||||
@@ -33,17 +33,13 @@
|
||||
"clusteriohost": "./dist/node/host.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"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:",
|
||||
|
||||
@@ -24,6 +24,8 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
this.prepareControllerDisconnect = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
this.autoPauseChanged = this.newHook();
|
||||
// Long timeout as handlers may create a save
|
||||
this.chooseSave = this.newHook(logger, 300_000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,19 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
*/
|
||||
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
|
||||
|
||||
/**
|
||||
* Called to pick the save to start when none was requested
|
||||
*
|
||||
* Invoked after mods have been synced when the instance is started
|
||||
* without a save being specified. Handlers may create a save in the
|
||||
* saves directory of the instance and return its name to have it
|
||||
* started instead of the newest save. Return undefined to leave the
|
||||
* choice to other plugins and the default of the newest save.
|
||||
*
|
||||
* @returns name of save in the instance's saves directory to start.
|
||||
*/
|
||||
readonly chooseSave: lib.AsyncHook<[], string | undefined>;
|
||||
|
||||
/**
|
||||
* Called after the Factorio server is started
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import { logger } from "@clusterio/lib";
|
||||
import type { HostConnector } from "../host.js";
|
||||
import Instance from "./Instance.js";
|
||||
import InstanceConnection from "./InstanceConnection.js";
|
||||
import { listFactorioVersions } from "./server.js";
|
||||
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
|
||||
|
||||
|
||||
@@ -462,6 +463,7 @@ export default class Host extends lib.Link {
|
||||
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
|
||||
this.handle(lib.HostFactorioVersionsRequest, this.handleHostFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
|
||||
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||
@@ -1020,6 +1022,23 @@ export default class Host extends lib.Link {
|
||||
return null;
|
||||
}
|
||||
|
||||
async handleHostFactorioVersionsRequest() {
|
||||
let installed: Awaited<ReturnType<typeof listFactorioVersions>>;
|
||||
try {
|
||||
installed = await listFactorioVersions(this.config.get("host.factorio_directory"));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
installed = { direct: false, versions: new Set() };
|
||||
}
|
||||
return {
|
||||
versions: [...installed.versions],
|
||||
// Matches the conditions for downloads in FactorioServer.checkForUpdates
|
||||
canDownload: !installed.direct && process.platform === "linux",
|
||||
};
|
||||
}
|
||||
|
||||
async handleHostMetricsRequest() {
|
||||
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
|
||||
for (let instanceConnection of this.instanceConnections.values()) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const execAsync = util.promisify(exec);
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { FactorioServer, checkForUpdates } from "./server.js";
|
||||
import { loadModules, patch, unpatch } from "./patch.js";
|
||||
import { loadModules, patch } from "./patch.js";
|
||||
import { exportData } from "./export.js";
|
||||
import type Host from "./Host.js";
|
||||
import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js";
|
||||
@@ -693,7 +693,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
players: [...this.playerStats],
|
||||
player_autosave_slot: this._playerAutosaveSlot,
|
||||
}, null, "\t");
|
||||
await lib.atomicOverwriteFile(this.path("instance-stats.json"), content);
|
||||
await lib.safeOutputFile(this.path("instance-stats.json"), content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -870,7 +870,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
"_comment_warning": warning,
|
||||
...await this.resolveServerSettings(this.config.get("factorio.settings"), includeCredentials),
|
||||
};
|
||||
await lib.atomicOverwriteFile(
|
||||
await lib.safeOutputFile(
|
||||
this.server.writePath("server-settings.json"),
|
||||
JSON.stringify(serverSettings, null, "\t")
|
||||
);
|
||||
@@ -996,7 +996,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
|
||||
this.logger.verbose("Writing server-adminlist.json");
|
||||
await lib.atomicOverwriteFile(
|
||||
await lib.safeOutputFile(
|
||||
this.server.writePath("server-adminlist.json"),
|
||||
JSON.stringify([...this._host.adminlist], null, "\t")
|
||||
);
|
||||
@@ -1004,7 +1004,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
if (this.config.get("factorio.sync_banlist") !== "disabled") {
|
||||
this.logger.verbose("Writing server-banlist.json");
|
||||
await lib.atomicOverwriteFile(
|
||||
await lib.safeOutputFile(
|
||||
this.server.writePath("server-banlist.json"),
|
||||
JSON.stringify([...this._host.banlist].map(
|
||||
([username, reason]) => ({ username, reason })
|
||||
@@ -1014,7 +1014,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
|
||||
this.logger.verbose("Writing server-whitelist.json");
|
||||
await lib.atomicOverwriteFile(
|
||||
await lib.safeOutputFile(
|
||||
this.server.writePath("server-whitelist.json"),
|
||||
JSON.stringify([...this._host.whitelist], null, "\t")
|
||||
);
|
||||
@@ -1045,7 +1045,16 @@ end`.replace(/\r?\n/g, " ");
|
||||
* @returns Name of the save prepared.
|
||||
*/
|
||||
async prepareSave(saveName?: string) {
|
||||
// Use latest save if no save was specified
|
||||
// Let plugins pick the save, otherwise use latest save if no save was specified
|
||||
if (saveName === undefined) {
|
||||
for (const result of await this.hooks.chooseSave.collect()) {
|
||||
if (result) {
|
||||
checkRequestSaveName(result);
|
||||
saveName = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saveName === undefined) {
|
||||
saveName = await lib.getNewestFile(
|
||||
this.path("saves"), (name) => !name.endsWith(".tmp.zip")
|
||||
@@ -1082,9 +1091,6 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
if (!this.config.get("factorio.enable_save_patching")) {
|
||||
if (await unpatch(this.path("saves", saveName))) {
|
||||
this.logger.info("Removed Clusterio modules from save as save patching is disabled");
|
||||
}
|
||||
return saveName;
|
||||
}
|
||||
|
||||
@@ -1311,16 +1317,6 @@ 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;
|
||||
@@ -1507,7 +1503,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
|
||||
await this._stopWhenSettled();
|
||||
await this.stop();
|
||||
// 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);
|
||||
@@ -1515,7 +1511,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async handleInstanceStopRequest() {
|
||||
await this._stopWhenSettled();
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async handleInstanceKillRequest() {
|
||||
|
||||
+34
-117
@@ -1,16 +1,12 @@
|
||||
// Export of item icons and locale
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
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 { Jimp, JimpMime, type JimpInstance } from "jimp";
|
||||
import yazl from "yazl";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js";
|
||||
import { findVersion, isHeadless, type FactorioServer } from "./server.js";
|
||||
import type { FactorioServer } from "./server.js";
|
||||
import { definesPrototypesFallback } from "./export_fallback.js";
|
||||
|
||||
interface SimpleIconSpecification {
|
||||
@@ -95,16 +91,6 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
|
||||
return modZip;
|
||||
}
|
||||
|
||||
/** Where icon files are read from during an export. */
|
||||
interface ExportFiles {
|
||||
/** Mod zips opened during the export, closed when it ends. */
|
||||
zipCache: ZipCache;
|
||||
/** Data dir of a full install to fall back to for builtin mod files. */
|
||||
graphicsDataDir?: string;
|
||||
/** Why no full install was found for a headless server. */
|
||||
graphicsError?: string;
|
||||
}
|
||||
|
||||
function closeZipCache(zipCache: ZipCache) {
|
||||
for (const { zip } of zipCache.values()) {
|
||||
zip.close();
|
||||
@@ -117,7 +103,7 @@ function closeZipCache(zipCache: ZipCache) {
|
||||
*
|
||||
* @param server - The server to load the file from.
|
||||
* @param modVersions - Mapping of mod to version used.
|
||||
* @param files - Where to read files from.
|
||||
* @param zipCache - Mod zips opened by this export.
|
||||
* @param modPath - Factorio style path to the file to load.
|
||||
* @returns The content of the file or null if not found.
|
||||
* @internal
|
||||
@@ -125,7 +111,7 @@ function closeZipCache(zipCache: ZipCache) {
|
||||
async function loadFile(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
files: ExportFiles,
|
||||
zipCache: ZipCache,
|
||||
modPath: string,
|
||||
) {
|
||||
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
|
||||
@@ -138,25 +124,19 @@ async function loadFile(
|
||||
|
||||
const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)];
|
||||
if (builtinModNames.includes(mod)) {
|
||||
const dataDirs = [server.dataPath()];
|
||||
if (files.graphicsDataDir) {
|
||||
dataDirs.push(files.graphicsDataDir);
|
||||
}
|
||||
for (const dataDir of dataDirs) {
|
||||
try {
|
||||
return await fs.readFile(path.join(dataDir, mod, filePath));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await fs.readFile(server.dataPath(mod, filePath));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let modZip;
|
||||
try {
|
||||
modZip = await loadZip(server, modVersions, files.zipCache, mod);
|
||||
modZip = await loadZip(server, modVersions, zipCache, mod);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return null;
|
||||
@@ -167,14 +147,12 @@ async function loadFile(
|
||||
return await modZip.zip.readFile(path.posix.join(modZip.root, filePath));
|
||||
}
|
||||
|
||||
// Only the parts of jimp the export uses
|
||||
const Jimp = createJimp({ formats: [png], plugins: [crop.methods, resize.methods] });
|
||||
type Image = InstanceType<typeof Jimp>;
|
||||
type Image = JimpInstance;
|
||||
type IconCache = Map<string, Image | null>;
|
||||
async function loadIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
files: ExportFiles,
|
||||
zipCache: ZipCache,
|
||||
iconPath: string,
|
||||
iconSize: number,
|
||||
iconMipmaps: number,
|
||||
@@ -182,7 +160,7 @@ async function loadIcon(
|
||||
) {
|
||||
let icon = iconCache.get(iconPath);
|
||||
if (icon === undefined) {
|
||||
let fileContent = await loadFile(server, modVersions, files, iconPath);
|
||||
let fileContent = await loadFile(server, modVersions, zipCache, iconPath);
|
||||
if (fileContent) {
|
||||
icon = await Jimp.fromBuffer(fileContent) as Image;
|
||||
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
|
||||
@@ -198,12 +176,12 @@ async function loadIcon(
|
||||
async function loadSimpleIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
files: ExportFiles,
|
||||
zipCache: ZipCache,
|
||||
item: SimpleIconSpecification,
|
||||
size: number,
|
||||
iconCache: IconCache,
|
||||
): Promise<Image | null> {
|
||||
let icon = await loadIcon(server, modVersions, files, item.icon, item.icon_size ?? 64, 0, iconCache);
|
||||
let icon = await loadIcon(server, modVersions, zipCache, item.icon, item.icon_size ?? 64, 0, iconCache);
|
||||
if (icon) {
|
||||
let iconScale = size / (item.icon_size ?? 64);
|
||||
if (iconScale !== 1) {
|
||||
@@ -217,11 +195,11 @@ async function loadSimpleIcon(
|
||||
async function loadLayeredIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
files: ExportFiles,
|
||||
zipCache: ZipCache,
|
||||
item: LayeredIconSpecification,
|
||||
size: number,
|
||||
iconCache: IconCache,
|
||||
): Promise<Image | null> {
|
||||
): Promise<Image> {
|
||||
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
|
||||
let icon = new Jimp({ width: size, height: size });
|
||||
|
||||
@@ -234,12 +212,11 @@ async function loadLayeredIcon(
|
||||
for (let layer of item.icons) {
|
||||
let layerSize = (layer.icon_size || item.icon_size) ?? 64;
|
||||
let iconLayer = await loadIcon(
|
||||
server, modVersions, files, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache,
|
||||
server, modVersions, zipCache, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache,
|
||||
);
|
||||
|
||||
// A partial icon would be wrong, skip it like a missing simple icon
|
||||
if (!iconLayer) {
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
iconLayer = iconLayer.clone();
|
||||
@@ -354,7 +331,7 @@ async function growSheet(state: SheetState) {
|
||||
async function exportIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
files: ExportFiles,
|
||||
zipCache: ZipCache,
|
||||
state: SheetState,
|
||||
prototype: PrototypeWithIcon,
|
||||
) {
|
||||
@@ -365,11 +342,9 @@ async function exportIcon(
|
||||
|
||||
if (prototype.icons) {
|
||||
icon = await loadLayeredIcon(
|
||||
server, modVersions, files, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
server, modVersions, zipCache, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
if (icon) {
|
||||
iconPos = state.pos;
|
||||
}
|
||||
iconPos = state.pos;
|
||||
iconPath = prototype.icons[0].icon;
|
||||
|
||||
} else if (prototype.icon) {
|
||||
@@ -377,7 +352,7 @@ async function exportIcon(
|
||||
iconPath = prototype.icon;
|
||||
if (iconPos === undefined) {
|
||||
icon = await loadSimpleIcon(
|
||||
server, modVersions, files, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
server, modVersions, zipCache, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
if (icon) {
|
||||
iconPos = state.pos;
|
||||
@@ -413,7 +388,7 @@ async function exportIcon(
|
||||
*
|
||||
* @param server - The server to load icons from.
|
||||
* @param modVersions - Mapping of mod name to version.
|
||||
* @param files - Where to read icon files from.
|
||||
* @param zipCache - Mod zips opened by this export.
|
||||
* @param prototypes - Array of prototypes with icon to export for this base type.
|
||||
* @param baseName - name of base type (used in metadata).
|
||||
* @param state - Shared spritesheet state (mutated in place).
|
||||
@@ -423,7 +398,7 @@ async function exportIcon(
|
||||
async function exportIcons(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
files: ExportFiles,
|
||||
zipCache: ZipCache,
|
||||
prototypes: PrototypeWithIcon[],
|
||||
baseName: string,
|
||||
state: SheetState,
|
||||
@@ -432,13 +407,7 @@ async function exportIcons(
|
||||
state.metadata[baseName] ??= metadata;
|
||||
let count = 0;
|
||||
for (let prototype of prototypes) {
|
||||
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}`);
|
||||
}
|
||||
const icon = await exportIcon(server, modVersions, zipCache, state, prototype);
|
||||
if (icon) {
|
||||
count += 1;
|
||||
}
|
||||
@@ -509,7 +478,6 @@ 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;
|
||||
@@ -529,67 +497,20 @@ async function exportLocale(
|
||||
/**
|
||||
* Export locale, icons and metadata for the given factorio server
|
||||
*
|
||||
* If the server runs the headless build, builtin mod graphics are read
|
||||
* from a full install of the same major and minor version found next to
|
||||
* it, if there is one.
|
||||
*
|
||||
* @param server - The server to export the data from.
|
||||
* @returns zip file with exported data.
|
||||
*/
|
||||
export async function exportData(server: FactorioServer) {
|
||||
const files: ExportFiles = { zipCache: new Map() };
|
||||
if (await isHeadless(server.dataPath())) {
|
||||
const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion;
|
||||
try {
|
||||
[files.graphicsDataDir] = await findVersion(server._factorioDir, majorMinor, false);
|
||||
} catch (err: any) {
|
||||
// Without it the export still has everything but the base game icons
|
||||
files.graphicsError = err.message;
|
||||
}
|
||||
}
|
||||
const zipCache: ZipCache = new Map();
|
||||
try {
|
||||
return await buildExport(server, files);
|
||||
return await buildExport(server, zipCache);
|
||||
} finally {
|
||||
// Free up the zip files loaded during the export.
|
||||
closeZipCache(files.zipCache);
|
||||
closeZipCache(zipCache);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn if any builtin mod icon files could not be found
|
||||
*
|
||||
* @param server - The server the export ran on.
|
||||
* @param files - Where icon files were read from.
|
||||
* @param iconCache - Icons loaded by the export, null for missing files.
|
||||
* @internal
|
||||
*/
|
||||
function checkMissingBuiltinIcons(server: FactorioServer, files: ExportFiles, iconCache: IconCache) {
|
||||
const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)];
|
||||
let missing = 0;
|
||||
for (const [iconPath, icon] of iconCache) {
|
||||
const mod = /^__([^\/]+)__\//.exec(iconPath)?.[1];
|
||||
if (icon === null && mod && builtinModNames.includes(mod)) {
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
if (!missing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dirs = [server.dataPath(), files.graphicsDataDir].filter(Boolean).join(" or ");
|
||||
let message =
|
||||
`${missing} icon files of the base game were not found in ${dirs}, icons using them were not exported.`;
|
||||
if (files.graphicsError !== undefined) {
|
||||
const majorMinor = server.version!.replace(/\.\d+$/, "");
|
||||
message +=
|
||||
` The headless build of Factorio has no graphics and no full install was found (${files.graphicsError}).` +
|
||||
` Add a full install of Factorio ${majorMinor} to host.factorio_directory to read them from there,` +
|
||||
" or export the data on a host with a full install.";
|
||||
}
|
||||
server._logger.warn(message);
|
||||
}
|
||||
|
||||
async function buildExport(server: FactorioServer, files: ExportFiles) {
|
||||
async function buildExport(server: FactorioServer, zipCache: ZipCache) {
|
||||
await generateExportMod(server);
|
||||
|
||||
let settings: lib.ExportSettings = {};
|
||||
@@ -669,7 +590,7 @@ async function buildExport(server: FactorioServer, files: ExportFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
let locale = await exportLocale(server, modVersions, files.zipCache, modOrder, "en");
|
||||
let locale = await exportLocale(server, modVersions, zipCache, modOrder, "en");
|
||||
|
||||
let zip = new yazl.ZipFile();
|
||||
const addJson = (name: string, value: unknown) => zip.addBuffer(Buffer.from(JSON.stringify(value)), name);
|
||||
@@ -690,15 +611,14 @@ async function buildExport(server: FactorioServer, files: ExportFiles) {
|
||||
if (prototypesWithIcons.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const count = await exportIcons(server, modVersions, files, prototypesWithIcons, baseName, state);
|
||||
const count = await exportIcons(server, modVersions, zipCache, prototypesWithIcons, baseName, state);
|
||||
server._logger.info(`Exported ${count} ${baseName} icons`);
|
||||
}
|
||||
checkMissingBuiltinIcons(server, files, state.iconCache);
|
||||
|
||||
// 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("image/png"), "export/spritesheet.png");
|
||||
zip.addBuffer(await state.sheet.getBuffer(JimpMime.png), "export/spritesheet.png");
|
||||
addJson("export/metadata.json", state.metadata satisfies lib.ExportMetadata);
|
||||
zip.end();
|
||||
|
||||
@@ -709,9 +629,6 @@ 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;
|
||||
export const _checkMissingBuiltinIcons = checkMissingBuiltinIcons;
|
||||
@@ -1,305 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
+109
-141
@@ -227,7 +227,6 @@ export class PatchInfo {
|
||||
json.patch_number,
|
||||
lib.ModuleInfo.fromJSON(json.scenario),
|
||||
await Promise.all(json.modules.map(m => SaveModule.fromSave(m, zip, root))),
|
||||
json.version,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -383,38 +382,114 @@ const knownScenarios: Record<string, lib.ModuleInfo> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Names of the zip entries added by patching for the modules in the patch info
|
||||
* Patch a save with the given modules
|
||||
*
|
||||
* Adds the modules given by the modules parameter to the save located
|
||||
* at savePath and rewrites the control.lua in the save to load the
|
||||
* modules that were added. Will also remove any previous module
|
||||
* located in the save.
|
||||
*
|
||||
* @param savePath - Path to the Factorio save to patch.
|
||||
* @param modules - Description of the modules to patch.
|
||||
*/
|
||||
function moduleEntries(zip: lib.ZipArchive, patchInfo: PatchInfo, root: string) {
|
||||
const entries = new Set<string>();
|
||||
if (patchInfo.version === 0) {
|
||||
const modulesPath = path.posix.join(root, "modules/");
|
||||
for (const name of zip.entries.keys()) {
|
||||
if (name.startsWith(modulesPath)) {
|
||||
entries.add(name);
|
||||
export async function patch(savePath: string, modules: SaveModule[]) {
|
||||
const zip = await lib.ZipArchive.fromFile(savePath);
|
||||
let tempSavePath: string;
|
||||
try {
|
||||
const root = lib.findRoot(zip);
|
||||
const rootPath = (relativePath: string) => path.posix.join(root, relativePath);
|
||||
|
||||
// Files to add or replace in the save, relative to the root
|
||||
const files = new Map<string, Buffer>();
|
||||
let patchInfo: PatchInfo;
|
||||
const patchInfoContent = await zip.readFile(rootPath("clusterio.json"));
|
||||
if (patchInfoContent !== null) {
|
||||
patchInfo = await PatchInfo.fromSave(JSON.parse(patchInfoContent.toString("utf8")), zip, root);
|
||||
if (patchInfo.version > PatchInfo.currentVersion) {
|
||||
throw new Error(
|
||||
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
|
||||
`version of Clusterio can load (${PatchInfo.currentVersion})`
|
||||
);
|
||||
}
|
||||
|
||||
// No info file present, try to detect if it's a known compatible scenario.
|
||||
} else {
|
||||
const controlFile = zip.file(rootPath("control.lua"));
|
||||
if (!controlFile) {
|
||||
throw new Error("Unable to patch save, missing control.lua file.");
|
||||
}
|
||||
const controlHash = await lib.hashStream(await zip.openStream(controlFile));
|
||||
|
||||
if (controlHash in knownScenarios) {
|
||||
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
|
||||
files.set("scenario.lua", await zip.readEntry(controlFile));
|
||||
} else {
|
||||
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const module of patchInfo.modules) {
|
||||
for (const filepath of module.files.keys()) {
|
||||
entries.add(path.posix.join(root, filepath));
|
||||
|
||||
// Increment patch number
|
||||
patchInfo.patchNumber = patchInfo.patchNumber + 1;
|
||||
|
||||
// Remove any existing modules from the save
|
||||
const removed = new Set<string>();
|
||||
if (patchInfo.version === 0) {
|
||||
const modulesPath = rootPath("modules/");
|
||||
for (const name of zip.entries.keys()) {
|
||||
if (name.startsWith(modulesPath)) {
|
||||
removed.add(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const module of patchInfo.modules) {
|
||||
for (const filepath of module.files.keys()) {
|
||||
removed.add(rootPath(filepath));
|
||||
}
|
||||
}
|
||||
patchInfo.modules = [];
|
||||
}
|
||||
|
||||
reorderDependencies(modules);
|
||||
|
||||
// Add the modules to the save.
|
||||
for (const module of modules) {
|
||||
for (const [relativePath, contents] of module.files) {
|
||||
files.set(relativePath, contents);
|
||||
}
|
||||
patchInfo.modules.push(module);
|
||||
}
|
||||
|
||||
// Add loading code and patch info
|
||||
files.set("control.lua", Buffer.from(generateLoader(patchInfo), "utf8"));
|
||||
files.set("clusterio.json", Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8"));
|
||||
|
||||
// Write back the save, keeping the order of the entries in the original
|
||||
const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
||||
tempSavePath = await writeSaveCopy(zip, savePath, added, removed);
|
||||
} finally {
|
||||
zip.close();
|
||||
}
|
||||
return entries;
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a copy of the save next to it with entries removed and added
|
||||
* Write a copy of a save with entries replaced, added and removed
|
||||
*
|
||||
* Keeps the order of the entries in the original.
|
||||
* The copy is written next to the save, and must be renamed over it after
|
||||
* the save has been closed.
|
||||
*
|
||||
* @returns Path to the temporary file written.
|
||||
* @param zip - Opened save to copy.
|
||||
* @param savePath - Path the save was opened from.
|
||||
* @param added - Entries by full path in the zip to add or replace.
|
||||
* @param removed - Full paths of entries to leave out.
|
||||
* @returns path to the copy written.
|
||||
*/
|
||||
async function rewriteSave(
|
||||
zip: lib.ZipArchive, savePath: string, removed: Set<string>, added: Map<string, Buffer>
|
||||
async function writeSaveCopy(
|
||||
zip: lib.ZipArchive,
|
||||
savePath: string,
|
||||
added: Map<string, Buffer>,
|
||||
removed = new Set<string>(),
|
||||
) {
|
||||
added = new Map(added);
|
||||
const output = new lib.ZipWriter();
|
||||
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
||||
const writing = pipeline(output.outputStream, writeStream);
|
||||
@@ -449,141 +524,34 @@ async function rewriteSave(
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a save with the given modules
|
||||
* Set the scenario code the save patcher keeps loading in a save
|
||||
*
|
||||
* Adds the modules given by the modules parameter to the save located
|
||||
* at savePath and rewrites the control.lua in the save to load the
|
||||
* modules that were added. Will also remove any previous module
|
||||
* located in the save.
|
||||
* Saves created by Factorio run the freeplay scenario, which the patcher
|
||||
* keeps when patching them. This replaces it with the given scenario info,
|
||||
* for example a scenario that loads no modules to get a save without
|
||||
* freeplay. The save must not have been patched before.
|
||||
*
|
||||
* @param savePath - Path to the Factorio save to patch.
|
||||
* @param modules - Description of the modules to patch.
|
||||
* @param savePath - Path to the save to change.
|
||||
* @param scenario - Scenario modules to load in place of the original scenario.
|
||||
*/
|
||||
export async function patch(savePath: string, modules: SaveModule[]) {
|
||||
export async function setSaveScenario(savePath: string, scenario: lib.ModuleInfo) {
|
||||
const zip = await lib.ZipArchive.fromFile(savePath);
|
||||
let tempSavePath: string;
|
||||
try {
|
||||
const root = lib.findRoot(zip);
|
||||
const rootPath = (relativePath: string) => path.posix.join(root, relativePath);
|
||||
|
||||
// Files to add or replace in the save, relative to the root
|
||||
const files = new Map<string, Buffer>();
|
||||
let patchInfo: PatchInfo;
|
||||
const patchInfoContent = await zip.readFile(rootPath("clusterio.json"));
|
||||
if (patchInfoContent !== null) {
|
||||
patchInfo = await PatchInfo.fromSave(JSON.parse(patchInfoContent.toString("utf8")), zip, root);
|
||||
if (patchInfo.version > PatchInfo.currentVersion) {
|
||||
throw new Error(
|
||||
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
|
||||
`version of Clusterio can load (${PatchInfo.currentVersion})`
|
||||
);
|
||||
}
|
||||
|
||||
// Unpatching put the original control.lua back in place of scenario.lua
|
||||
const scenarioRequired = [...patchInfo.scenario.load, ...patchInfo.scenario.require].includes("scenario");
|
||||
if (scenarioRequired && !zip.file(rootPath("scenario.lua"))) {
|
||||
const control = await zip.readFile(rootPath("control.lua"));
|
||||
if (control === null) {
|
||||
throw new Error("Unable to patch save, missing control.lua file.");
|
||||
}
|
||||
files.set("scenario.lua", control);
|
||||
}
|
||||
|
||||
// No info file present, try to detect if it's a known compatible scenario.
|
||||
} else {
|
||||
const controlFile = zip.file(rootPath("control.lua"));
|
||||
if (!controlFile) {
|
||||
throw new Error("Unable to patch save, missing control.lua file.");
|
||||
}
|
||||
const controlHash = await lib.hashStream(await zip.openStream(controlFile));
|
||||
|
||||
if (controlHash in knownScenarios) {
|
||||
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
|
||||
files.set("scenario.lua", await zip.readEntry(controlFile));
|
||||
} else {
|
||||
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
|
||||
}
|
||||
if (await zip.readFile(path.posix.join(root, "clusterio.json")) !== null) {
|
||||
throw new Error("Unable to set scenario of a save that has already been patched");
|
||||
}
|
||||
|
||||
// Increment patch number
|
||||
patchInfo.patchNumber = patchInfo.patchNumber + 1;
|
||||
|
||||
// Remove any existing modules from the save
|
||||
const removed = moduleEntries(zip, patchInfo, root);
|
||||
patchInfo.modules = [];
|
||||
|
||||
reorderDependencies(modules);
|
||||
|
||||
// Add the modules to the save.
|
||||
for (const module of modules) {
|
||||
for (const [relativePath, contents] of module.files) {
|
||||
files.set(relativePath, contents);
|
||||
}
|
||||
patchInfo.modules.push(module);
|
||||
}
|
||||
|
||||
// Add loading code and patch info
|
||||
files.set("control.lua", Buffer.from(generateLoader(patchInfo), "utf8"));
|
||||
files.set("clusterio.json", Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8"));
|
||||
|
||||
const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
||||
tempSavePath = await rewriteSave(zip, savePath, removed, added);
|
||||
const patchInfo = new PatchInfo(0, scenario, []);
|
||||
tempSavePath = await writeSaveCopy(zip, savePath, new Map([[
|
||||
path.posix.join(root, "clusterio.json"), Buffer.from(JSON.stringify(patchInfo, null, " "), "utf8"),
|
||||
]]));
|
||||
} finally {
|
||||
zip.close();
|
||||
}
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the modules patched into a save
|
||||
*
|
||||
* Moves the original control.lua that patching kept as scenario.lua back
|
||||
* and removes the module files. The patch info is kept with no modules so that
|
||||
* the patch number keeps counting if the save is patched again.
|
||||
*
|
||||
* @param savePath - Path to the Factorio save to unpatch.
|
||||
* @returns true if the save was changed.
|
||||
*/
|
||||
export async function unpatch(savePath: string) {
|
||||
const zip = await lib.ZipArchive.fromFile(savePath);
|
||||
let tempSavePath: string;
|
||||
try {
|
||||
const root = lib.findRoot(zip);
|
||||
const rootPath = (relativePath: string) => path.posix.join(root, relativePath);
|
||||
const patchInfoContent = await zip.readFile(rootPath("clusterio.json"));
|
||||
if (patchInfoContent === null) {
|
||||
return false;
|
||||
}
|
||||
const patchInfo = await PatchInfo.fromSave(JSON.parse(patchInfoContent.toString("utf8")), zip, root);
|
||||
if (patchInfo.version > PatchInfo.currentVersion) {
|
||||
throw new Error(
|
||||
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
|
||||
`version of Clusterio can unpatch (${PatchInfo.currentVersion})`
|
||||
);
|
||||
}
|
||||
if (patchInfo.version !== 0 && !patchInfo.modules.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removed = moduleEntries(zip, patchInfo, root);
|
||||
patchInfo.modules = [];
|
||||
const scenario = await zip.readFile(rootPath("scenario.lua"));
|
||||
if (scenario !== null) {
|
||||
removed.add(rootPath("scenario.lua"));
|
||||
}
|
||||
// Scenarios made for Clusterio have no scenario.lua, keep loading them without modules
|
||||
const control = scenario ?? Buffer.from(generateLoader(patchInfo), "utf8");
|
||||
tempSavePath = await rewriteSave(zip, savePath, removed, new Map([
|
||||
[rootPath("control.lua"), control],
|
||||
[rootPath("clusterio.json"), Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8")],
|
||||
]));
|
||||
} finally {
|
||||
zip.close();
|
||||
}
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For testing only
|
||||
export const _generateLoader = generateLoader;
|
||||
export const _reorderDependencies = reorderDependencies;
|
||||
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
||||
* @param factorioDir - Path to Factorio installation dirs.
|
||||
* @returns Set of installed factorio versions
|
||||
*/
|
||||
async function listFactorioVersions(factorioDir: string) {
|
||||
export async function listFactorioVersions(factorioDir: string) {
|
||||
const versions = new Set<lib.FullVersion>();
|
||||
|
||||
const directVersion = await getFactorioVersion(factorioDir);
|
||||
@@ -262,33 +262,15 @@ export async function checkForUpdates(
|
||||
* @param targetVersion -
|
||||
* Version to look for, supports the special value "latest" for the
|
||||
* latest version available.
|
||||
* @param headless -
|
||||
* Only consider headless installs if true, or only full installs if
|
||||
* false.
|
||||
* @returns Array with path to data dir and version found.
|
||||
*/
|
||||
export async function findVersion(
|
||||
factorioDir: string,
|
||||
targetVersion: lib.TargetVersion,
|
||||
headless?: boolean,
|
||||
): Promise<[string, lib.FullVersion]> {
|
||||
let kind = "";
|
||||
if (headless !== undefined) {
|
||||
kind = headless ? "headless " : "full ";
|
||||
}
|
||||
const checkInstallType = async (dir: string) => (
|
||||
headless === undefined || await isHeadless(path.join(dir, "data")) === headless
|
||||
);
|
||||
|
||||
// Check if this is a direct installation dir
|
||||
const directVersion = await getFactorioVersion(factorioDir);
|
||||
if (directVersion !== null) {
|
||||
if (!await checkInstallType(factorioDir)) {
|
||||
throw new Error(
|
||||
`Unable to find a ${kind}install of Factorio ${targetVersion}: ${factorioDir} is a direct ` +
|
||||
`(single-version) install of the ${headless ? "full" : "headless"} build.`
|
||||
);
|
||||
}
|
||||
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
|
||||
return [path.join(factorioDir, "data"), directVersion];
|
||||
}
|
||||
@@ -305,7 +287,7 @@ export async function findVersion(
|
||||
// Avoid any fancy directory detection in order to follow symlinks
|
||||
const fullPath = path.join(factorioDir, name);
|
||||
const version = await getFactorioVersion(fullPath);
|
||||
if (version === null || !await checkInstallType(fullPath)) {
|
||||
if (version === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -317,7 +299,7 @@ export async function findVersion(
|
||||
}
|
||||
|
||||
if (!versions.size) {
|
||||
throw new Error(`Unable to find any ${kind}Factorio install in ${factorioDir}`);
|
||||
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
|
||||
}
|
||||
|
||||
if (lib.isPartialVersion(targetVersion)) {
|
||||
@@ -333,26 +315,7 @@ export async function findVersion(
|
||||
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
|
||||
}
|
||||
|
||||
throw new Error(`Unable to find ${kind}Factorio version ${targetVersion}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Factorio install is the headless build
|
||||
*
|
||||
* The headless build has no icons, which is what tells it apart.
|
||||
*
|
||||
* @param dataDir - Path to the data dir of the install.
|
||||
*/
|
||||
export async function isHeadless(dataDir: string) {
|
||||
try {
|
||||
await fs.access(path.join(dataDir, "base", "graphics", "icons"));
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
"compile_validator": "node scripts/compile_validator.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -153,9 +153,10 @@ export class AsyncHookCollection {
|
||||
Args extends unknown[],
|
||||
Result = void,
|
||||
> (
|
||||
_logger = this._logger
|
||||
_logger = this._logger,
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
const hook = new AsyncHook<Args, Result>(_logger);
|
||||
const hook = new AsyncHook<Args, Result>(_logger, timeoutMs);
|
||||
this.hooks.push(hook);
|
||||
return hook;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path";
|
||||
import events from "events";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { logger } from "./logging.js";
|
||||
import { downloadFile, atomicOverwriteFile } from "./file_ops.js";
|
||||
import { downloadFile, safeOutputFile } from "./file_ops.js";
|
||||
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod.js";
|
||||
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data/index.js";
|
||||
|
||||
@@ -148,7 +148,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
if (files.size) {
|
||||
logger.info(`Loaded info for ${files.size} mods`);
|
||||
const content: ModInfoCache = [...files.values()].map(mod => mod.toJSON());
|
||||
await atomicOverwriteFile(cacheFilename, JSON.stringify(content));
|
||||
await safeOutputFile(cacheFilename, JSON.stringify(content));
|
||||
}
|
||||
|
||||
return new this(modsDirectory, files);
|
||||
|
||||
@@ -7,7 +7,7 @@ import isDeepStrictEqual from "#is_deep_strict_equal";
|
||||
import { basicType } from "../helpers.js";
|
||||
import * as libSchema from "../schema.js";
|
||||
import { StringEnum } from "../data/composites.js";
|
||||
import { atomicOverwriteFile } from "../file_ops.js";
|
||||
import { safeOutputFile } from "../file_ops.js";
|
||||
import * as validators from "./validators.js";
|
||||
import { InvalidAccessError, InvalidFieldError, InvalidValueError } from "../errors.js";
|
||||
import type { SyncHook } from "../SyncHook.js";
|
||||
@@ -88,11 +88,10 @@ export interface FieldDefinition<
|
||||
optional?: Type extends null ? true : false;
|
||||
/**
|
||||
* Value this config field should take in a newly initialized config.
|
||||
* This can also be a synchronous function returning the value to use,
|
||||
* it is called after the stored fields are loaded so that it can read them.
|
||||
* This can also be a synchronous function returning the value to use.
|
||||
* Promise results are not awaited.
|
||||
*/
|
||||
initialValue?: unknown | ((config: Config<Fields>) => unknown);
|
||||
initialValue?: unknown | (() => unknown);
|
||||
/**
|
||||
* Array of all values that are acceptible. Useful for making
|
||||
* enumerated values.
|
||||
@@ -141,6 +140,9 @@ function defaultValue(def: FieldDefinition) {
|
||||
if (def.initialValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof def.initialValue === "function") {
|
||||
return def.initialValue();
|
||||
}
|
||||
return def.initialValue;
|
||||
}
|
||||
|
||||
@@ -244,12 +246,14 @@ export class Config<
|
||||
}
|
||||
super();
|
||||
|
||||
const defs = Object.entries(this.constructor.fieldDefinitions).filter(
|
||||
([name, def]) => this._checkAccess(name, def, location, ConfigAccess.read, false)
|
||||
);
|
||||
// Function initial values are left undefined until the stored fields are loaded
|
||||
this.fields = Object.fromEntries(
|
||||
defs.map(([name, def]) => [name, typeof def.initialValue === "function" ? undefined : defaultValue(def)])
|
||||
Object.entries(
|
||||
this.constructor.fieldDefinitions
|
||||
).filter(
|
||||
([name, def]) => this._checkAccess(name, def, location, ConfigAccess.read, false)
|
||||
).map(
|
||||
([name, def]) => [name, defaultValue(def)]
|
||||
)
|
||||
) as Fields;
|
||||
|
||||
if (fields) {
|
||||
@@ -260,12 +264,6 @@ export class Config<
|
||||
this.update(migrated, false, location);
|
||||
}
|
||||
|
||||
for (const [name, def] of defs) {
|
||||
if (this.fields[name as keyof Fields] === undefined) {
|
||||
this.fields[name as keyof Fields] = (def.initialValue as (config: Config<Fields>) => any)(this);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pluginName, version] of this.constructor.pluginVersions) {
|
||||
const name = `${pluginName}.version` as keyof Fields & string;
|
||||
if (name in this.fields && this.fields[name] !== version) {
|
||||
@@ -477,7 +475,7 @@ export class Config<
|
||||
if (!this.filepath) {
|
||||
throw new Error("Cannot save config which has no filepath");
|
||||
}
|
||||
await atomicOverwriteFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
|
||||
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,15 @@ export interface ControllerConfigFields {
|
||||
"controller.max_subscription_filters": number;
|
||||
"controller.system_metrics_interval": number;
|
||||
"controller.proxy_stream_timeout": number;
|
||||
"controller.player_proxy_port": number | null;
|
||||
"controller.player_proxy_default_instance_id": number | null;
|
||||
"controller.player_proxy_public_address": string | null;
|
||||
"controller.factorio_username": string | null;
|
||||
"controller.factorio_token": string | null;
|
||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||
"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;
|
||||
@@ -100,20 +101,6 @@ 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.",
|
||||
@@ -259,6 +246,34 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
initialValue: 15,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.player_proxy_port": {
|
||||
title: "Player Proxy Port",
|
||||
description:
|
||||
"UDP port the controller accepts Factorio player connections on and forwards them to an " +
|
||||
"instance, starting it if needed. Disabled when null.",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
initialValue: 34197,
|
||||
validator: validators.optional(validators.greaterThanZero),
|
||||
},
|
||||
"controller.player_proxy_default_instance_id": {
|
||||
title: "Player Proxy Default Instance",
|
||||
description:
|
||||
"Instance ID to send players connecting through the player proxy to when no plugin picks one " +
|
||||
"and they have not been on any instance before.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
"controller.player_proxy_public_address": {
|
||||
title: "Player Proxy Public Address",
|
||||
description:
|
||||
"Address players reach the player proxy on, for example play.example.com:34197. Used by plugins " +
|
||||
"to tell players where to connect. If null plugins may assume the host name of " +
|
||||
"controller.public_url and controller.player_proxy_port.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"controller.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
description: "Username to authenticate with Factorio API with.",
|
||||
@@ -429,7 +444,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
"Range of UDP ports to use for game connections. Supports both comma separated values and " +
|
||||
"ranges separated with a dash.",
|
||||
type: "string",
|
||||
initialValue: "34100-34199",
|
||||
initialValue: "34200-34299",
|
||||
// TODO extendedValidation, port range
|
||||
},
|
||||
"host.factorio_username": {
|
||||
@@ -487,10 +502,8 @@ export interface InstanceConfigFields {
|
||||
"instance.id": number;
|
||||
"instance.assigned_host": number | null;
|
||||
"instance.auto_start": boolean;
|
||||
"instance.player_proxy_port": number | null;
|
||||
"instance.exclude_from_start_all": boolean;
|
||||
"instance.is_template": boolean;
|
||||
"instance.template_id": number | null;
|
||||
"instance.inherited_fields": Record<string, true | string[]>;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
[key: `${string}.version`]: string;
|
||||
|
||||
@@ -542,38 +555,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Fields that are never inherited from a template, ports clash on a shared host and secrets stay per instance */
|
||||
static uninheritableFields = [
|
||||
"instance.name", "instance.is_template", "instance.template_id",
|
||||
"factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port",
|
||||
];
|
||||
|
||||
/** True if the field can take its value from a template */
|
||||
static isInheritable(name: string) {
|
||||
const def = this.fieldDefinitions[name as keyof InstanceConfigFields];
|
||||
return Boolean(def)
|
||||
&& !def.hidden
|
||||
&& (def.access ?? this.defaultAccess).includes("controller")
|
||||
&& !this.uninheritableFields.includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the field, or the given property of an object field, takes its value from the template
|
||||
*
|
||||
* @param name - Name of field to check.
|
||||
* @param prop - Property of an object field to check.
|
||||
*/
|
||||
isInherited(name: string, prop?: string) {
|
||||
if (this.get("instance.template_id") === null) {
|
||||
return false;
|
||||
}
|
||||
const entry = this.get("instance.inherited_fields")[name];
|
||||
if (entry instanceof Array) {
|
||||
return prop === undefined || !entry.includes(prop);
|
||||
}
|
||||
return Boolean(entry);
|
||||
}
|
||||
|
||||
static fieldDefinitions: classes.ConfigDefs<InstanceConfigFields> = {
|
||||
"instance.name": {
|
||||
type: "string",
|
||||
@@ -596,38 +577,19 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.player_proxy_port": {
|
||||
description:
|
||||
"Extra UDP port on the controller's player proxy that sends players straight to this instance, " +
|
||||
"starting it if needed. Requires controller.player_proxy_port to be set.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanZero),
|
||||
},
|
||||
"instance.exclude_from_start_all": {
|
||||
description: "Exclude this instance from the 'Start all' button operation",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.is_template": {
|
||||
description:
|
||||
"Mark this instance as a template other instances can inherit config from. " +
|
||||
"Templates cannot be assigned to a host or started.",
|
||||
access: ["controller", "control"],
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
hidden: true,
|
||||
},
|
||||
"instance.template_id": {
|
||||
title: "Template",
|
||||
description: "ID of the template to inherit config values from",
|
||||
access: ["controller", "control"],
|
||||
inputComponent: "instance_template",
|
||||
type: "number",
|
||||
optional: true,
|
||||
// Checked by the controller
|
||||
},
|
||||
"instance.inherited_fields": {
|
||||
description:
|
||||
"Fields whose value comes from the template, for object fields the properties set on this instance",
|
||||
access: ["controller", "control"],
|
||||
readonly: ["controller"],
|
||||
type: "object",
|
||||
initialValue: {},
|
||||
hidden: true,
|
||||
},
|
||||
"factorio.version": {
|
||||
description:
|
||||
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
|
||||
@@ -878,31 +840,36 @@ export function addPluginFieldDefinitions(
|
||||
|
||||
// The validator is dynamic based on the feature requirements of the plugin
|
||||
if (kind === "instanceConfigFields" && pluginInfo.features) {
|
||||
const requirements: [string, string][] = [];
|
||||
if (pluginInfo.features.includes("SavePatching")) {
|
||||
requirements.push(["factorio.enable_save_patching", "save patching"]);
|
||||
}
|
||||
if (pluginInfo.features.includes("ScriptCommands")) {
|
||||
requirements.push(["factorio.enable_script_commands", "script commands"]);
|
||||
}
|
||||
if (pluginInfo.features.includes("LuaUdp")) {
|
||||
requirements.push(["factorio.enable_lua_udp", "Lua UDP"]);
|
||||
fieldDef.dependsOn = [];
|
||||
|
||||
const savePatching = pluginInfo.features.includes("SavePatching");
|
||||
if (savePatching) {
|
||||
fieldDef.dependsOn.push("factorio.enable_save_patching");
|
||||
}
|
||||
|
||||
if (requirements.length) {
|
||||
fieldDef.dependsOn = requirements.map(([field]) => field);
|
||||
// Default to not loaded when the instance does not meet the requirements
|
||||
fieldDef.initialValue = (config: classes.Config<any>) => requirements.every(
|
||||
([field]) => config.get(field)
|
||||
);
|
||||
const scriptCommands = pluginInfo.features.includes("ScriptCommands");
|
||||
if (scriptCommands) {
|
||||
fieldDef.dependsOn.push("factorio.enable_script_commands");
|
||||
}
|
||||
|
||||
const luaUdp = pluginInfo.features.includes("LuaUdp");
|
||||
if (luaUdp) {
|
||||
fieldDef.dependsOn.push("factorio.enable_lua_udp");
|
||||
}
|
||||
|
||||
if (scriptCommands || savePatching || luaUdp) {
|
||||
fieldDef.validator = (enabled, config) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
for (const [field, description] of requirements) {
|
||||
if (!config.get(field)) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires ${description}`);
|
||||
}
|
||||
if (savePatching && !config.get("factorio.enable_save_patching")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires save patching`);
|
||||
}
|
||||
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
|
||||
}
|
||||
if (luaUdp && !config.get("factorio.enable_lua_udp")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires Lua UDP`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,7 @@ export default class InstanceDetails {
|
||||
public startedAtMs = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false,
|
||||
public isTemplate = false,
|
||||
public templateId?: number,
|
||||
/** Value of factorio.mod_pack_id, undefined means the controller default is used */
|
||||
public modPackId?: number,
|
||||
public excludeFromStartAll = false
|
||||
) {}
|
||||
|
||||
get isDeleted() {
|
||||
@@ -65,9 +61,6 @@ export default class InstanceDetails {
|
||||
"startedAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
"isTemplate": Type.Optional(Type.Boolean()),
|
||||
"templateId": Type.Optional(Type.Integer()),
|
||||
"modPackId": Type.Optional(Type.Integer()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -80,10 +73,7 @@ export default class InstanceDetails {
|
||||
json.factorioVersion,
|
||||
json.startedAtMs,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll,
|
||||
json.isTemplate,
|
||||
json.templateId,
|
||||
json.modPackId,
|
||||
json.excludeFromStartAll
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,15 +102,6 @@ export default class InstanceDetails {
|
||||
if (this.excludeFromStartAll !== false) {
|
||||
json.excludeFromStartAll = this.excludeFromStartAll;
|
||||
}
|
||||
if (this.isTemplate !== false) {
|
||||
json.isTemplate = this.isTemplate;
|
||||
}
|
||||
if (this.templateId !== undefined) {
|
||||
json.templateId = this.templateId;
|
||||
}
|
||||
if (this.modPackId !== undefined) {
|
||||
json.modPackId = this.modPackId;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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,10 +1,9 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonNumber, StringEnum, StringKey, jsonArray, plainJson } from "./composites.js";
|
||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js";
|
||||
import { type LogEntry, levels } from "../logging.js";
|
||||
import { ControllerConfig } from "../config/index.js";
|
||||
import { ControllerConfig, HostConfig } 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),
|
||||
@@ -121,81 +120,52 @@ export class ControllerConfigSetPropRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class HostPairingListRequest {
|
||||
declare ["constructor"]: typeof HostPairingListRequest;
|
||||
export class HostGenerateTokenRequest {
|
||||
declare ["constructor"]: typeof HostGenerateTokenRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.approve" as const;
|
||||
static Response = jsonArray(HostPairingDetails);
|
||||
}
|
||||
|
||||
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;
|
||||
static permission = "core.host.generate_token" as const;
|
||||
|
||||
constructor(
|
||||
public updates: HostPairingDetails[],
|
||||
public hostId?: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(HostPairingDetails.jsonSchema),
|
||||
"hostId": Type.Optional(Type.Integer()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(update => HostPairingDetails.fromJSON(update)));
|
||||
return new this(json.hostId);
|
||||
}
|
||||
|
||||
static Response = JsonString;
|
||||
}
|
||||
|
||||
export class HostPairingApproveRequest {
|
||||
declare ["constructor"]: typeof HostPairingApproveRequest;
|
||||
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.approve" as const;
|
||||
static permission = "core.host.create_config" 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,
|
||||
public id: number | undefined,
|
||||
public name: string | undefined,
|
||||
public generateToken: boolean,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"code": Type.String(),
|
||||
"id": Type.Optional(Type.Integer()),
|
||||
"name": Type.Optional(Type.String()),
|
||||
"generateToken": Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.code, json.name);
|
||||
return new this(json.id, json.name, json.generateToken);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
static Response = plainJson(HostConfig.jsonSchema);
|
||||
}
|
||||
|
||||
export class GetFactorioCredentialsRequest {
|
||||
|
||||
@@ -36,6 +36,19 @@ export class HostUpdateRequest {
|
||||
static permission = "core.host.update" as const;
|
||||
}
|
||||
|
||||
/** Factorio versions installed on a host, used by the controller to place instances */
|
||||
export class HostFactorioVersionsRequest {
|
||||
declare ["constructor"]: typeof HostFactorioVersionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "host" as const;
|
||||
static Response = plainJson(Type.Object({
|
||||
"versions": Type.Array(Type.String()),
|
||||
/** True if the host downloads versions that are not installed when an instance needs them */
|
||||
"canDownload": Type.Boolean(),
|
||||
}));
|
||||
}
|
||||
|
||||
export class HostConfigGetRequest {
|
||||
declare ["constructor"]: typeof HostConfigGetRequest;
|
||||
static type = "request" as const;
|
||||
@@ -259,12 +272,12 @@ export class SyncUserListsEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class HostRevokeRequest {
|
||||
declare ["constructor"]: typeof HostRevokeRequest;
|
||||
export class HostRevokeTokensRequest {
|
||||
declare["constructor"]: typeof HostRevokeTokensRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.host.revoke" as const;
|
||||
static permission = "core.host.revoke_token" as const;
|
||||
|
||||
constructor(
|
||||
public hostId: number,
|
||||
|
||||
@@ -176,30 +176,6 @@ export class InstanceConfigSetPropRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigInheritFieldRequest {
|
||||
declare ["constructor"]: typeof InstanceConfigInheritFieldRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.instance.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
public field: string,
|
||||
public prop?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instanceId": Type.Integer(),
|
||||
"field": Type.String(),
|
||||
"prop": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.instanceId, json.field, json.prop);
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceAssignRequest {
|
||||
declare ["constructor"]: typeof InstanceAssignRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -4,94 +4,57 @@ 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 state: PluginState,
|
||||
public loaded: boolean,
|
||||
public enabled: boolean,
|
||||
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.state ?? this.loadState(json.loaded, json.enabled),
|
||||
json.description, json.npmPackage, json.installedVersion, json.dependencyName,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) {
|
||||
return new this(
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, state,
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled,
|
||||
pluginInfo.description, pluginInfo.npmPackage,
|
||||
);
|
||||
}
|
||||
@@ -100,25 +63,10 @@ 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, "pendingInstall",
|
||||
description, npmPackage, packageJson.version, dependencyName,
|
||||
packageName, packageName, packageJson.version, false, false,
|
||||
description, npmPackage, packageJson.version, true, undefined, 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 {
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function loadJsonAsMap(filePath: string): Promise<Map<string, unkno
|
||||
*/
|
||||
export async function saveMapAsJson(filePath: string, map: Map<string, unknown>) {
|
||||
let obj = mapToObject(map);
|
||||
await libFileOps.atomicOverwriteFile(filePath, JSON.stringify(obj, null, "\t"));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, "\t"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +139,7 @@ export async function loadJsonArrayAsMap(filePath: string): Promise<Map<unknown,
|
||||
* @throws {Error} if an error occured writing to the file.
|
||||
*/
|
||||
export async function saveMapAsJsonArray(filePath: string, map: Map<unknown, { id: unknown }>) {
|
||||
await libFileOps.atomicOverwriteFile(filePath, JSON.stringify([...map.values()], null, "\t"));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, "\t"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import fs from "node:fs/promises";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { atomicOverwriteFile } from "./file_ops.js";
|
||||
import { safeOutputFile } from "./file_ops.js";
|
||||
import { SubscribableValue } from "./subscriptions.js";
|
||||
import { logger } from "./logging.js";
|
||||
import { ControllerConfig } from "./config/index.js";
|
||||
@@ -56,7 +56,7 @@ export class JsonDatastoreProvider<
|
||||
// Save the data to the json file
|
||||
async save(data: Map<string, V>) {
|
||||
logger.verbose(`Saving ${this.filePath}`);
|
||||
await atomicOverwriteFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
|
||||
await safeOutputFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
|
||||
}
|
||||
|
||||
// Load the data from the json file
|
||||
@@ -91,7 +91,7 @@ export class JsonDatastoreProvider<
|
||||
logger.warn(
|
||||
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
|
||||
);
|
||||
await atomicOverwriteFile(backupPath, JSON.stringify(rawJson, null, "\t"));
|
||||
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -115,7 +115,7 @@ export class JsonIdDatastoreProvider<
|
||||
// Save the data to the json file
|
||||
async save(data: Map<V["id"], V>) {
|
||||
logger.verbose(`Saving ${this.filePath}`);
|
||||
await atomicOverwriteFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
|
||||
await safeOutputFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
|
||||
}
|
||||
|
||||
// Load the data from the json file
|
||||
@@ -150,7 +150,7 @@ export class JsonIdDatastoreProvider<
|
||||
logger.warn(
|
||||
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
|
||||
);
|
||||
await atomicOverwriteFile(backupPath, JSON.stringify(rawJson, null, "\t"));
|
||||
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -166,7 +166,6 @@ export abstract class Datastore<
|
||||
"update": [ updates: U[] ],
|
||||
}> {
|
||||
private dirty = false;
|
||||
private saving: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private provider: DatastoreProvider<K, V> = new MemoryDatastoreProvider(),
|
||||
@@ -182,22 +181,10 @@ export abstract class Datastore<
|
||||
|
||||
// Save the datastore to the provider
|
||||
async save() {
|
||||
// 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.
|
||||
if (this.dirty) {
|
||||
await this.provider.save(this.data);
|
||||
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
|
||||
|
||||
@@ -153,7 +153,7 @@ function readMapPosition(state: MapReaderState) {
|
||||
y = state.last_position.y + y_diff;
|
||||
}
|
||||
state.last_position.x = x;
|
||||
state.last_position.x = y;
|
||||
state.last_position.y = y;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically overwrite file data
|
||||
* Safely overwrite file data
|
||||
*
|
||||
* Writes the passed data to a temporary file and rename it over the target
|
||||
* file. If the operation fails because the parent directory does not exist, it
|
||||
@@ -105,7 +105,7 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||
* @param data - Content to write.
|
||||
* @param options - see fs.writeFile, `flag` and `flush` may not be used.
|
||||
*/
|
||||
export async function atomicOverwriteFile(
|
||||
export async function safeOutputFile(
|
||||
file: string,
|
||||
data: string | Buffer,
|
||||
options?: Omit<
|
||||
|
||||
@@ -29,10 +29,8 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.ControllerConfigSetRequest,
|
||||
controller.ControllerConfigSetFieldRequest,
|
||||
controller.ControllerConfigSetPropRequest,
|
||||
controller.HostPairingListRequest,
|
||||
controller.HostPairingUpdatesEvent,
|
||||
controller.HostPairingApproveRequest,
|
||||
controller.HostPairingRejectRequest,
|
||||
controller.HostGenerateTokenRequest,
|
||||
controller.HostConfigCreateRequest,
|
||||
controller.GetFactorioCredentialsRequest,
|
||||
controller.LogSetSubscriptionsRequest,
|
||||
controller.LogQueryRequest,
|
||||
@@ -58,10 +56,11 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
host.HostInfoUpdateEvent,
|
||||
host.HostUpdatesEvent,
|
||||
host.HostMetricsRequest,
|
||||
host.HostFactorioVersionsRequest,
|
||||
host.ControllerConnectionEvent,
|
||||
host.PrepareControllerDisconnectRequest,
|
||||
host.SyncUserListsEvent,
|
||||
host.HostRevokeRequest,
|
||||
host.HostRevokeTokensRequest,
|
||||
|
||||
instance.InstanceDetailsGetRequest,
|
||||
instance.InstanceDetailsListRequest,
|
||||
@@ -71,7 +70,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceConfigSetRequest,
|
||||
instance.InstanceConfigSetFieldRequest,
|
||||
instance.InstanceConfigSetPropRequest,
|
||||
instance.InstanceConfigInheritFieldRequest,
|
||||
instance.InstanceAssignRequest,
|
||||
instance.InstanceMetricsRequest,
|
||||
instance.InstanceStartRequest,
|
||||
|
||||
@@ -328,7 +328,7 @@ export async function loadPluginList(
|
||||
hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges;
|
||||
|
||||
if (hasChanges) {
|
||||
await libFileOps.atomicOverwriteFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
}
|
||||
|
||||
return pluginList;
|
||||
|
||||
@@ -312,7 +312,7 @@ export class LogIndex {
|
||||
* was loaded/created from.
|
||||
*/
|
||||
async save() {
|
||||
await libFileOps.atomicOverwriteFile(
|
||||
await libFileOps.safeOutputFile(
|
||||
path.join(this.logDirectory, "index.json"),
|
||||
JSON.stringify(this.serialize(), null, "\t")
|
||||
);
|
||||
|
||||
@@ -196,16 +196,19 @@ export const corePermissions = [
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
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.generate_token",
|
||||
title: "Generate host token",
|
||||
description: "Generate tokens for hosts to connect to the cluster with.",
|
||||
},
|
||||
{
|
||||
name: "core.host.revoke",
|
||||
title: "Revoke hosts",
|
||||
description: "Remove hosts from the cluster, disconnecting them and making their access tokens invalid.",
|
||||
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.",
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -128,12 +128,14 @@ 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,
|
||||
hasEntrypoint(pluginInfo) ? PluginDetails.loadState(loaded, isEnabled(pluginInfo)) : "noEntrypoint",
|
||||
pluginInfo, isLoaded(pluginInfo), loadable && isEnabled(pluginInfo),
|
||||
);
|
||||
if (!loadable) {
|
||||
plugin.noEntrypoint = true;
|
||||
}
|
||||
if (Object.hasOwn(dependencies, pluginInfo.requirePath)) {
|
||||
plugin.dependencyName = pluginInfo.requirePath;
|
||||
}
|
||||
@@ -145,7 +147,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.state = loaded ? "removedLoaded" : "removed";
|
||||
plugin.removed = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignored: npm is still writing it
|
||||
@@ -183,7 +185,8 @@ 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.state = "nameTaken";
|
||||
plugin.pendingInstall = undefined;
|
||||
plugin.nameTaken = true;
|
||||
}
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ export async function handlePluginCommand(
|
||||
}
|
||||
|
||||
pluginList.set(pluginInfo.name, pluginPath);
|
||||
await libFileOps.atomicOverwriteFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
print(`Added ${pluginInfo.name}`);
|
||||
|
||||
} else if (command === "remove") {
|
||||
@@ -209,7 +209,7 @@ export async function handlePluginCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
await libFileOps.atomicOverwriteFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
print(`Removed ${args.name}`);
|
||||
|
||||
} else if (command === "list") {
|
||||
|
||||
@@ -480,8 +480,6 @@ 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
|
||||
@@ -672,7 +670,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 || !this.permitted()) {
|
||||
if (!this.control.connector.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -727,16 +725,6 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
@@ -45,6 +45,7 @@
|
||||
"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:",
|
||||
@@ -52,6 +53,7 @@
|
||||
"react-router-dom": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"typescript": "catalog:",
|
||||
"util": "^0.12.5",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
@@ -59,7 +61,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/semver": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"@types/semver": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import * as lib from "@clusterio/lib";
|
||||
import App from "./components/App";
|
||||
import InputRole from "./components/InputRole";
|
||||
import InputModPack from "./components/InputModPack";
|
||||
import { InputInstanceTemplate } from "./components/InputInstance";
|
||||
import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion";
|
||||
import { Control, ControlConnector } from "./util/websocket";
|
||||
import { loadedPluginSetKey } from "./util/pluginSet";
|
||||
@@ -149,7 +148,6 @@ async function inputComponentsFromHooks(control: Control) {
|
||||
"target_version": InputTargetVersion,
|
||||
"mod_pack": InputModPack,
|
||||
"role": InputRole,
|
||||
"instance_template": InputInstanceTemplate,
|
||||
}]);
|
||||
|
||||
const iterable = result.map(
|
||||
|
||||
@@ -2,8 +2,6 @@ import React, { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Button, Card, Checkbox, Form, FormInstance, Input, InputNumber, Select, Space, Spin, Tooltip, Tree, Typography,
|
||||
} from "antd";
|
||||
import DisconnectOutlined from "@ant-design/icons/DisconnectOutlined";
|
||||
import LinkOutlined from "@ant-design/icons/LinkOutlined";
|
||||
import ReloadOutlined from "@ant-design/icons/ReloadOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -67,28 +65,8 @@ function renderInput(inputComponents: Map<string, InputComponent>, def: lib.Fiel
|
||||
return `Unknown type ${def.type}`;
|
||||
}
|
||||
|
||||
/** Lets fields follow the value of another config, inherited values are locked until unlinking */
|
||||
export type ConfigInheritance = {
|
||||
canInherit: (config: lib.Config<any>, field: string) => boolean;
|
||||
isInherited: (config: lib.Config<any>, field: string, prop?: string) => boolean;
|
||||
inherit: (field: string, prop?: string) => Promise<void>;
|
||||
templateValue: (field: string, prop?: string) => unknown;
|
||||
notice?: (config: lib.Config<any>) => React.ReactNode;
|
||||
};
|
||||
|
||||
type Linking = Map<string, [field: string, prop?: string]>;
|
||||
|
||||
type LinkState = {
|
||||
inheritance?: ConfigInheritance;
|
||||
unlinking: Set<string>;
|
||||
setUnlinking: (unlinking: Set<string>) => void;
|
||||
linking: Linking;
|
||||
setLinking: (linking: Linking) => void;
|
||||
};
|
||||
|
||||
type BaseConfigTreeProps = {
|
||||
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
|
||||
inheritance?: ConfigInheritance;
|
||||
retrieveConfig: () => Promise<lib.ConfigSchema>;
|
||||
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
|
||||
id?: number;
|
||||
@@ -101,8 +79,6 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
let [changedFields, setChangedFields] = useState<Set<string>>(new Set());
|
||||
let [errorFields, setErrorFields] = useState(new Map<string, string>());
|
||||
let [applying, setApplying] = useState(false);
|
||||
let [unlinking, setUnlinking] = useState<Set<string>>(new Set());
|
||||
let [linking, setLinking] = useState<Linking>(new Map());
|
||||
const available = props.available ?? true;
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
@@ -160,18 +136,6 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChanges(fields: Record<string, string | Record<string, string>>) {
|
||||
if (Object.keys(fields).length) {
|
||||
await props.setConfig(fields);
|
||||
}
|
||||
for (const [field, prop] of linking.values()) {
|
||||
await props.inheritance!.inherit(field, prop);
|
||||
}
|
||||
setChangedFields(new Set());
|
||||
setUnlinking(new Set());
|
||||
setLinking(new Map());
|
||||
}
|
||||
|
||||
const [propsMap, treeData] = computeTreeData(
|
||||
control,
|
||||
form,
|
||||
@@ -181,7 +145,6 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
setConfig,
|
||||
props.ConfigClass,
|
||||
onValuesChange,
|
||||
{ inheritance: props.inheritance, unlinking, setUnlinking, linking, setLinking },
|
||||
);
|
||||
|
||||
return <>
|
||||
@@ -195,8 +158,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
onClick={async () => {
|
||||
setApplying(true);
|
||||
const fields: Record<string, string | Record<string, string>> = {};
|
||||
// Unlinked fields are sent even when unchanged so they stop following the template
|
||||
for (const field of new Set([...changedFields, ...unlinking])) {
|
||||
for (const field of changedFields) {
|
||||
const value = form.getFieldValue(field);
|
||||
if (propsMap.has(field)) {
|
||||
const [fieldName, prop] = propsMap.get(field)!;
|
||||
@@ -217,26 +179,21 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
}
|
||||
}
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
await applyChanges(fields);
|
||||
applied = true;
|
||||
await props.setConfig(fields);
|
||||
setChangedFields(new Set());
|
||||
} catch (err: any) {
|
||||
notify(err.message, "error");
|
||||
}
|
||||
|
||||
const newConfig = await updateConfig();
|
||||
if (applied) {
|
||||
// Linking can change other values
|
||||
form.setFieldsValue(getInitialValues(newConfig, props));
|
||||
}
|
||||
await updateConfig();
|
||||
setApplying(false);
|
||||
|
||||
if (props.onApply) {
|
||||
props.onApply();
|
||||
}
|
||||
}}
|
||||
disabled={changedFields.size === 0 && linking.size === 0 && unlinking.size === 0}
|
||||
disabled={changedFields.size === 0}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
@@ -245,8 +202,6 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setChangedFields(new Set());
|
||||
setUnlinking(new Set());
|
||||
setLinking(new Map());
|
||||
setErrorFields(new Map());
|
||||
}}
|
||||
>
|
||||
@@ -255,7 +210,6 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
</Space>
|
||||
</Title>
|
||||
<Card size="small" className="config-container">
|
||||
{props.inheritance?.notice?.(config)}
|
||||
<Form
|
||||
size="small"
|
||||
form={form}
|
||||
@@ -283,7 +237,6 @@ function computeTreeData(
|
||||
setConfig: (newConfig: lib.Config<any>) => void,
|
||||
ConfigClass: BaseConfigTreeProps["ConfigClass"],
|
||||
onValuesChange: (changedValues: object) => void,
|
||||
link: LinkState,
|
||||
) {
|
||||
let restartTip = <Tooltip
|
||||
className="ant-form-item-tooltip"
|
||||
@@ -301,64 +254,6 @@ function computeTreeData(
|
||||
children: ChildNode[];
|
||||
};
|
||||
|
||||
function linkButton(key: string, fieldName: string, prop?: string) {
|
||||
const { inheritance, unlinking, setUnlinking, linking, setLinking } = link;
|
||||
if (!inheritance?.canInherit(config, fieldName) || !config.canAccess(fieldName, lib.ConfigAccess.write)) {
|
||||
return null;
|
||||
}
|
||||
const button = (
|
||||
title: string, icon: React.ReactNode, label: string, pending: boolean, onClick: () => void,
|
||||
) => <Tooltip title={title}>
|
||||
<Button
|
||||
type={pending ? "link" : "text"} size="small" icon={icon} aria-label={`${label} ${key}`}
|
||||
onClick={e => {
|
||||
// The button sits in the label, which would toggle checkboxes
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>;
|
||||
const revert = () => {
|
||||
form.setFieldValue(key, initialValues[key]);
|
||||
onValuesChange({ [key]: initialValues[key] });
|
||||
};
|
||||
if (inheritance.isInherited(config, fieldName, prop)) {
|
||||
if (!unlinking.has(key)) {
|
||||
return button(
|
||||
"Linked to the template. Click to unlink it and set your own value.", <LinkOutlined />, "Unlink",
|
||||
false, () => setUnlinking(new Set([...unlinking, key])),
|
||||
);
|
||||
}
|
||||
return button(
|
||||
"Unlinked from the template when applied. Click to keep it linked.", <DisconnectOutlined />,
|
||||
"Keep linked", true,
|
||||
() => { setUnlinking(new Set([...unlinking].filter(k => k !== key))); revert(); },
|
||||
);
|
||||
}
|
||||
if (!linking.has(key)) {
|
||||
return button(
|
||||
"Not linked to the template. Click to link it again.", <DisconnectOutlined />, "Link", false,
|
||||
() => {
|
||||
setLinking(new Map([...linking, [key, [fieldName, prop]]]));
|
||||
revert();
|
||||
// Show the value the field takes after apply
|
||||
const value = inheritance.templateValue(fieldName, prop);
|
||||
form.setFieldValue(key, prop === undefined ? value : JSON.stringify(value));
|
||||
},
|
||||
);
|
||||
}
|
||||
return button(
|
||||
"Linked to the template when applied. Click to keep this value instead.", <LinkOutlined />,
|
||||
"Keep unlinked", true,
|
||||
() => { setLinking(new Map([...linking].filter(([k]) => k !== key))); revert(); },
|
||||
);
|
||||
}
|
||||
function isReadonly(canWrite: boolean, key: string, fieldName: string, prop?: string) {
|
||||
return !canWrite || link.linking.has(key) || Boolean(link.inheritance?.canInherit(config, fieldName))
|
||||
&& link.inheritance!.isInherited(config, fieldName, prop)
|
||||
&& !link.unlinking.has(key);
|
||||
}
|
||||
|
||||
let treeData = [];
|
||||
const propsMap = new Map<string, [fieldName: string, prop: string]>();
|
||||
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
|
||||
@@ -417,7 +312,6 @@ function computeTreeData(
|
||||
label={<>
|
||||
{`"${prop}"`}
|
||||
{restart && restartTip}
|
||||
{linkButton(propPath, fieldName, prop)}
|
||||
</>}
|
||||
dependencies={def.dependsOn}
|
||||
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
|
||||
@@ -451,7 +345,7 @@ function computeTreeData(
|
||||
def.validator(fieldValue, config.staging);
|
||||
}})]}
|
||||
>
|
||||
<Input className="json-input" disabled={isReadonly(canWrite, propPath, fieldName, prop)} />
|
||||
<Input className="json-input" disabled={!canWrite} />
|
||||
</Form.Item>,
|
||||
});
|
||||
|
||||
@@ -488,7 +382,6 @@ function computeTreeData(
|
||||
}
|
||||
|
||||
let propPath = `${groupName}.${field}.${propName}`;
|
||||
link.setUnlinking(new Set([...link.unlinking, propPath]));
|
||||
form.setFieldsValue({
|
||||
[`${newPropPath}.name`]: "",
|
||||
[`${newPropPath}.value`]: "",
|
||||
@@ -514,7 +407,6 @@ function computeTreeData(
|
||||
label={<>
|
||||
{def.title || field}
|
||||
{def.restartRequired && restartTip}
|
||||
{linkButton(fieldName, fieldName)}
|
||||
</>}
|
||||
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||
help={errorFields.get(fieldName)}
|
||||
@@ -544,7 +436,7 @@ function computeTreeData(
|
||||
},
|
||||
})]}
|
||||
>
|
||||
{renderInput(control.inputComponents, def, isReadonly(canWrite, fieldName, fieldName))}
|
||||
{renderInput(control.inputComponents, def, !canWrite)}
|
||||
</Form.Item>;
|
||||
initialValues[fieldName] = value;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Descriptions, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
|
||||
import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
@@ -17,13 +17,12 @@ 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";
|
||||
|
||||
@@ -99,55 +98,51 @@ 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, error] = useHost(hostId);
|
||||
const state = useResourceState(host, synced, error, hostId);
|
||||
const [host, synced] = useHost(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) {
|
||||
return <ResourceState state={state} type="Host" id={hostId} nav={nav} listPath="/hosts" error={error} />;
|
||||
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>;
|
||||
}
|
||||
|
||||
let hostButtons = <Space wrap>
|
||||
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{account.hasPermission("core.host.revoke") && <RevokeHostButton host={host} instances={hostInstances} />}
|
||||
{
|
||||
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>
|
||||
}
|
||||
{canShowNpm && (
|
||||
<NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
)}
|
||||
@@ -180,6 +175,13 @@ 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,9 +1,6 @@
|
||||
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 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 * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -19,10 +16,9 @@ import {
|
||||
MetricDiskUsed, MetricDiskRatio,
|
||||
RestartRequired,
|
||||
} from "./system_metrics";
|
||||
import { useHostPairings, useHosts } from "../model/host";
|
||||
import { 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";
|
||||
@@ -31,228 +27,133 @@ import Link from "./Link";
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
/** Current time in milliseconds, updated every second while active */
|
||||
function useNow(active: boolean) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
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[]>([]);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return undefined;
|
||||
}
|
||||
setNow(Date.now());
|
||||
const interval = setInterval(() => { setNow(Date.now()); }, 1000);
|
||||
return () => { clearInterval(interval); };
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/** 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."
|
||||
(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");
|
||||
}
|
||||
/>;
|
||||
} else {
|
||||
content = <>
|
||||
<Typography.Paragraph>
|
||||
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>
|
||||
<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>
|
||||
</>;
|
||||
})();
|
||||
}, []);
|
||||
|
||||
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: [] }]);
|
||||
}
|
||||
|
||||
let newToken = await control.send(new lib.HostGenerateTokenRequest(id));
|
||||
setToken(newToken);
|
||||
setHostId(id ?? null);
|
||||
}
|
||||
|
||||
return <Modal title="Approve host" open footer={null} onCancel={props.onClose} width="520px">
|
||||
{content}
|
||||
</Modal>;
|
||||
}
|
||||
// Generate a new random
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
generateToken().catch(notifyErrorHandler("Error generating token"));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function AddHostButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [requests] = useHostPairings();
|
||||
const pluginString = pluginList.filter(p => p.npmPackage).map(p => `"${p.npmPackage}"`).join(" ");
|
||||
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); }} />
|
||||
<Button
|
||||
onClick={() => { setOpen(true); }}
|
||||
>Generate Token</Button>
|
||||
<Modal
|
||||
title="Generate Host Token"
|
||||
open={open}
|
||||
footer={null}
|
||||
onCancel={() => {
|
||||
setOpen(false);
|
||||
setToken(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
width="700px"
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark="optional">
|
||||
<Form.Item name="hostId" label="Host ID">
|
||||
<InputNumber onChange={() => {
|
||||
generateToken().catch(notifyErrorHandler("Error generating token"));
|
||||
}} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Paragraph>
|
||||
Host IDs are randomly generated and unique, leave blank to use a new ID,
|
||||
otherwise the ID of an existing host can be found using:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message="Copied get host id to clipboard"
|
||||
text="npx clusteriohost config get host.id"
|
||||
/>
|
||||
npx clusteriohost config get host.id
|
||||
</div>
|
||||
{token !== null && <>
|
||||
<Typography.Paragraph>
|
||||
Host auth token:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message="Copied host token to clipboard"
|
||||
text={token}
|
||||
/>
|
||||
{token}
|
||||
</div>
|
||||
<Typography.Paragraph>
|
||||
You can set the token on an existing host with the following command:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message="Copied set host id to clipboard"
|
||||
text={`npx clusteriohost config set host.id ${token}`}
|
||||
/>
|
||||
npx clusteriohost config set host.id {token}
|
||||
</div>
|
||||
<Typography.Paragraph>
|
||||
A new host can be setup using the following commands:
|
||||
</Typography.Paragraph>
|
||||
<div className="codeblock">
|
||||
<CopyButton
|
||||
message={"Copied host setup commands to clipboard"}
|
||||
text={`\
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-headless \
|
||||
--controller-url ${document.location.origin}/ --host-name "${hostId ? `Host ${hostId}` : "Host"}" \
|
||||
--public-address localhost ${pluginString.length ? "--plugins" : ""} ${pluginString}`
|
||||
}/>
|
||||
<p>> 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>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -311,22 +212,11 @@ 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.approve") ? <AddHostButton /> : undefined}
|
||||
extra={account.hasPermission("core.host.generate_token") ? <GenerateHostTokenButton /> : undefined}
|
||||
/>
|
||||
<Table
|
||||
style={{ overflowX: "auto" }}
|
||||
|
||||
@@ -8,9 +8,6 @@ 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 useTableQueryState from "../util/useTableQueryState";
|
||||
import ResourceState from "./ResourceState";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -40,12 +37,9 @@ export default function IconReferencePage() {
|
||||
let params = useParams();
|
||||
let modPackId = Number(params.id);
|
||||
|
||||
// Filters live as you type, written to the URL on blur, enter or clear
|
||||
const queryState = useTableQueryState({ namespace: "icon" });
|
||||
const filter = queryState.filteredValue("search")?.[0] ?? "";
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [modPack, synced, error] = useModPack(modPackId);
|
||||
const state = useResourceState(modPack, synced, error, modPackId);
|
||||
const [modPack] = useModPack(modPackId);
|
||||
const manifest = modPack?.exportManifest;
|
||||
const prototypes = useExportPrototypeMetadata(modPack);
|
||||
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
|
||||
@@ -106,10 +100,6 @@ 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" />
|
||||
@@ -140,15 +130,7 @@ export default function IconReferencePage() {
|
||||
<Input
|
||||
placeholder="Filter by type or name…"
|
||||
value={filter}
|
||||
onChange={e => {
|
||||
const values = e.target.value ? [e.target.value] : null;
|
||||
queryState.setFilter("search", values);
|
||||
if (!values) {
|
||||
queryState.commitFilter("search", null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => queryState.commitFilter("search")}
|
||||
onPressEnter={() => queryState.commitFilter("search")}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useInstances } from "../model/instance";
|
||||
|
||||
interface BaseInputInstanceProps extends InputComponentProps {
|
||||
includeAll?: boolean;
|
||||
templates?: boolean;
|
||||
}
|
||||
|
||||
function BaseInputInstance(props: BaseInputInstanceProps) {
|
||||
@@ -13,9 +12,7 @@ function BaseInputInstance(props: BaseInputInstanceProps) {
|
||||
|
||||
const options = [
|
||||
...(props.includeAll ? [{ label: "All instances", value: null }] : []),
|
||||
...[...instances.values()].filter(
|
||||
instance => Boolean(instance.isTemplate) === Boolean(props.templates)
|
||||
).map(instance => ({
|
||||
...[...instances.values()].map(instance => ({
|
||||
label: instance.name ?? `Instance ${instance.id}`,
|
||||
value: instance.id,
|
||||
})),
|
||||
@@ -43,7 +40,3 @@ export function InputInstanceWithAll(props: InputComponentProps) {
|
||||
export function InputInstance(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} />;
|
||||
}
|
||||
|
||||
export function InputInstanceTemplate(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} templates />;
|
||||
}
|
||||
@@ -1,73 +1,30 @@
|
||||
import React, { useContext, useRef } from "react";
|
||||
import { Alert } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import BaseConfigTree, { ConfigInheritance } from "./BaseConfigTree";
|
||||
import BaseConfigTree from "./BaseConfigTree";
|
||||
import ControlContext from "./ControlContext";
|
||||
import Link from "./Link";
|
||||
import { useInstance } from "../model/instance";
|
||||
|
||||
|
||||
type InstanceConfigTreeProps = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
function TemplateNotice(props: { templateId: number }) {
|
||||
const [template] = useInstance(props.templateId);
|
||||
return <Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 8 }}
|
||||
message={<>
|
||||
Linked values are inherited from the template{" "}
|
||||
<Link to={`/instances/${props.templateId}/view`}>{template?.name ?? props.templateId}</Link>.
|
||||
Unlink a value to set your own on this instance.
|
||||
</>}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
|
||||
let control = useContext(ControlContext);
|
||||
const templateConfig = useRef<lib.ConfigSchema>({});
|
||||
|
||||
async function retrieveConfig() {
|
||||
const config = await control.send(new lib.InstanceConfigGetRequest(props.id));
|
||||
const templateId = config["instance.template_id"];
|
||||
templateConfig.current = typeof templateId === "number"
|
||||
? await control.send(new lib.InstanceConfigGetRequest(templateId))
|
||||
: {};
|
||||
return config;
|
||||
return await control.send(new lib.InstanceConfigGetRequest(props.id));
|
||||
}
|
||||
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.InstanceConfigSetRequest(props.id, fields));
|
||||
}
|
||||
|
||||
const inheritance: ConfigInheritance = {
|
||||
canInherit: (config, field) => (
|
||||
(config as lib.InstanceConfig).get("instance.template_id") !== null
|
||||
&& lib.InstanceConfig.isInheritable(field)
|
||||
),
|
||||
isInherited: (config, field, prop) => (config as lib.InstanceConfig).isInherited(field, prop),
|
||||
inherit: async (field, prop) => {
|
||||
await control.send(new lib.InstanceConfigInheritFieldRequest(props.id, field, prop));
|
||||
},
|
||||
templateValue: (field, prop) => {
|
||||
const value = templateConfig.current[field] as any;
|
||||
return prop === undefined ? value : value?.[prop];
|
||||
},
|
||||
notice: (config) => {
|
||||
const templateId = (config as lib.InstanceConfig).get("instance.template_id");
|
||||
return templateId !== null ? <TemplateNotice templateId={templateId} /> : null;
|
||||
},
|
||||
};
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.InstanceConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setConfig={setConfig}
|
||||
inheritance={inheritance}
|
||||
id={props.id}
|
||||
/>;
|
||||
}
|
||||
@@ -25,10 +25,8 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
|
||||
const instanceId = props.instance.id;
|
||||
const instanceStatus = props.instance.status;
|
||||
// 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);
|
||||
const showStart = ["stopped", "stopping"].includes(instanceStatus);
|
||||
const showStop = ["running", "starting"].includes(instanceStatus);
|
||||
|
||||
function actionFinish() {
|
||||
setSwitching(false);
|
||||
@@ -39,7 +37,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.instance.start") && showStart) {
|
||||
if (account.hasPermission("core.instance.start") && showStart !== switching) {
|
||||
actions.push({
|
||||
key: "start",
|
||||
label: "Start",
|
||||
@@ -103,7 +101,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching || transitioning}
|
||||
loading={switching}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>
|
||||
<Popconfirm
|
||||
|
||||
@@ -7,14 +7,12 @@ import type { ColumnType } from "antd/es/table/interface";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useSystems } from "../model/system";
|
||||
import { useHosts } from "../model/host";
|
||||
import { useDefaultModPack, useModPacks } from "../model/mod_pack";
|
||||
import { RestartRequired } from "./system_metrics";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import InstanceModPack from "./InstanceModPack";
|
||||
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import Link from "./Link";
|
||||
import { instanceModPack, instancePublicAddress } from "../util/instance";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
@@ -31,8 +29,6 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
let account = useAccount();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
const [modPacks] = useModPacks();
|
||||
const defaultModPack = useDefaultModPack();
|
||||
const tableState = useTableQueryState<lib.InstanceDetails>({
|
||||
namespace: "instance", defaultSortKey: "name",
|
||||
});
|
||||
@@ -48,15 +44,6 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
return hosts.get(hostId)?.name ?? String(hostId);
|
||||
}
|
||||
|
||||
function modPackOf(instance: lib.InstanceDetails) {
|
||||
return instanceModPack(instance.modPackId, modPacks, defaultModPack);
|
||||
}
|
||||
|
||||
function modPackName(instance: lib.InstanceDetails) {
|
||||
const { id, modPack } = modPackOf(instance);
|
||||
return modPack?.name ?? (id !== undefined ? String(id) : "");
|
||||
}
|
||||
|
||||
function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) {
|
||||
const v = instance.factorioVersion;
|
||||
if (v && lib.isPartialVersion(v)) {
|
||||
@@ -128,15 +115,6 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
sortOrder: tableState.sortOrder("version"),
|
||||
responsive: ["xl"],
|
||||
},
|
||||
{
|
||||
title: "Mod Pack",
|
||||
key: "modPack",
|
||||
className: "table-link-cell",
|
||||
render: (_, instance) => <InstanceModPack resolved={modPackOf(instance)} />,
|
||||
sorter: (a, b) => strcmp(modPackName(a), modPackName(b)),
|
||||
sortOrder: tableState.sortOrder("modPack"),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
key: "status",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from "react";
|
||||
import { Space, Tag, Tooltip } from "antd";
|
||||
|
||||
import Link from "./Link";
|
||||
import type { ResolvedModPack } from "../util/instance";
|
||||
|
||||
export default function InstanceModPack(props: { resolved: ResolvedModPack }) {
|
||||
const { id, modPack, isDefault } = props.resolved;
|
||||
if (id === undefined) {
|
||||
return <em>Controller default</em>;
|
||||
}
|
||||
return <Space size="small">
|
||||
<Link to={`/mods/mod-packs/${id}/view`} onClick={e => e.stopPropagation()}>
|
||||
{modPack?.name ?? id}
|
||||
</Link>
|
||||
{isDefault && <Tooltip title="Not set on this instance, the controller default mod pack is used">
|
||||
<Tag>Default</Tag>
|
||||
</Tooltip>}
|
||||
</Space>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tag, Tooltip, Typography,
|
||||
Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Spin, Tooltip, Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
@@ -16,7 +16,6 @@ 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";
|
||||
@@ -26,16 +25,12 @@ import LoadScenarioModal from "./LoadScenarioModal";
|
||||
import SavesList from "./SavesList";
|
||||
import OnlinePlayersList from "./OnlinePlayersList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance, useInstances } from "../model/instance";
|
||||
import { CreateInstanceButton } from "./InstancesPage";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useHost } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import Link from "./Link";
|
||||
import { instanceModPack, instancePublicAddress } from "../util/instance";
|
||||
import { useDefaultModPack, useModPacks } from "../model/mod_pack";
|
||||
import InstanceModPack from "./InstanceModPack";
|
||||
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];
|
||||
@@ -48,37 +43,6 @@ type InstanceDescriptionProps = {
|
||||
host?: Readonly<lib.HostDetails>;
|
||||
instance: Readonly<lib.InstanceDetails>;
|
||||
};
|
||||
function TemplateDescription(props: { instance: Readonly<lib.InstanceDetails> }) {
|
||||
const [instances] = useInstances();
|
||||
const inheritors = [...instances.values()].filter(i => i.templateId === props.instance.id);
|
||||
return <Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Inherited by">
|
||||
{inheritors.length
|
||||
? <Space wrap>
|
||||
{inheritors.map(i => <Link key={i.id} to={`/instances/${i.id}/view`}>{i.name}</Link>)}
|
||||
</Space>
|
||||
: <em>No instances</em>}
|
||||
</Descriptions.Item>
|
||||
{props.instance.templateId !== undefined && <Descriptions.Item label="Template">
|
||||
<TemplateLink id={props.instance.templateId} />
|
||||
</Descriptions.Item>}
|
||||
<Descriptions.Item label="Mod Pack">
|
||||
<ModPackDescription modPackId={props.instance.modPackId} />
|
||||
</Descriptions.Item>
|
||||
</Descriptions>;
|
||||
}
|
||||
|
||||
function ModPackDescription(props: { modPackId?: number }) {
|
||||
const [modPacks] = useModPacks();
|
||||
const defaultModPack = useDefaultModPack();
|
||||
return <InstanceModPack resolved={instanceModPack(props.modPackId, modPacks, defaultModPack)} />;
|
||||
}
|
||||
|
||||
function TemplateLink(props: { id: number }) {
|
||||
const [template] = useInstance(props.id);
|
||||
return <Link to={`/instances/${props.id}/view`}>{template?.name ?? props.id}</Link>;
|
||||
}
|
||||
|
||||
function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
let account = useAccount();
|
||||
|
||||
@@ -128,15 +92,9 @@ function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
<Descriptions.Item label="Version">
|
||||
{instance.factorioVersion ?? "unknown"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Mod Pack">
|
||||
<ModPackDescription modPackId={instance.modPackId} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Start Time">
|
||||
<MetricRelativeDate timeMs={instance.startedAtMs}/>
|
||||
</Descriptions.Item>
|
||||
{instance.templateId !== undefined && <Descriptions.Item label="Template">
|
||||
<TemplateLink id={instance.templateId} />
|
||||
</Descriptions.Item>}
|
||||
</Descriptions>;
|
||||
}
|
||||
|
||||
@@ -148,8 +106,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
let instance = props.instance;
|
||||
let instanceId = instance.id!;
|
||||
let [host] = useHost(instance.assignedHost);
|
||||
const [instances] = useInstances();
|
||||
const canCreate = account.hasPermission("core.instance.create");
|
||||
|
||||
let instanceButtonMenuItems: MenuItem[] = [];
|
||||
if (account.hasPermission("core.instance.export_data")) {
|
||||
@@ -178,21 +134,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
label: "Delete",
|
||||
});
|
||||
}
|
||||
function confirmDelete() {
|
||||
Modal.confirm({
|
||||
autoFocusButton: "cancel",
|
||||
content: "Permamently delete instance and server saves?",
|
||||
okText: "Delete",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
control.send(
|
||||
new lib.InstanceDeleteRequest(instanceId)
|
||||
).then(() => {
|
||||
navigate("/instances");
|
||||
}).catch(notifyErrorHandler("Error deleting instance"));
|
||||
},
|
||||
});
|
||||
}
|
||||
let instanceButtonsMenuProps: MenuProps = {
|
||||
items: instanceButtonMenuItems,
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
@@ -214,25 +155,22 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
).catch(notifyErrorHandler("Error extracting player data"));
|
||||
|
||||
} else if (key === "delete") {
|
||||
confirmDelete();
|
||||
Modal.confirm({
|
||||
autoFocusButton: "cancel",
|
||||
content: "Permamently delete instance and server saves?",
|
||||
okText: "Delete",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
control.send(
|
||||
new lib.InstanceDeleteRequest(instanceId)
|
||||
).then(() => {
|
||||
navigate("/instances");
|
||||
}).catch(notifyErrorHandler("Error deleting instance"));
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
if (instance.isTemplate) {
|
||||
return <Space wrap>
|
||||
{canCreate && <CreateInstanceButton
|
||||
instances={instances}
|
||||
initial={{ template: instanceId }}
|
||||
buttonText="Create instance from template"
|
||||
/>}
|
||||
{account.hasPermission("core.instance.delete") && <Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={confirmDelete}
|
||||
>Delete</Button>}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
return <Space wrap>
|
||||
{
|
||||
account.hasAnyPermission(...InstanceControlButtonPermissions)
|
||||
@@ -250,12 +188,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
Connect via Steam
|
||||
</Button>
|
||||
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
|
||||
{canCreate && <CreateInstanceButton
|
||||
instances={instances}
|
||||
initial={{ instanceClone: instanceId, asTemplate: true, linkSource: true }}
|
||||
buttonText="Create template"
|
||||
buttonType="default"
|
||||
/>}
|
||||
{account.hasAnyPermission(
|
||||
"core.instance.export_data",
|
||||
"core.instance.extract_players",
|
||||
@@ -276,30 +208,36 @@ export default function InstanceViewPage() {
|
||||
let navigate = useNavigate();
|
||||
|
||||
let account = useAccount();
|
||||
let [instance, synced, error] = useInstance(instanceId);
|
||||
const state = useResourceState(instance, synced, error, instanceId);
|
||||
let [instance, synced] = useInstance(instanceId);
|
||||
let [host] = useHost(instance?.assignedHost);
|
||||
|
||||
let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }];
|
||||
if (!instance) {
|
||||
return <ResourceState
|
||||
state={state} type="Instance" id={instanceId} nav={nav} listPath="/instances" error={error}
|
||||
/>;
|
||||
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 <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
title={instance.name ?? ""}
|
||||
status={instance.isTemplate
|
||||
? <Tag color="purple">Template</Tag>
|
||||
: <InstanceStatusTag status={instance.status} />}
|
||||
status={<InstanceStatusTag status={instance.status} />}
|
||||
extra={<InstanceButtons instance={instance} />}
|
||||
/>
|
||||
{instance.isTemplate
|
||||
? <TemplateDescription instance={instance} />
|
||||
: <InstanceDescription host={host} instance={instance} />}
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
<ResourceNotes resourceType="instance" resourceId={instanceId} />
|
||||
{!instance.isTemplate && <>
|
||||
|
||||
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
|
||||
|
||||
@@ -346,7 +284,6 @@ export default function InstanceViewPage() {
|
||||
account.hasPermission("core.instance.send_rcon")
|
||||
&& <InstanceRcon id={instanceId} disabled={instance.status !== "running"} />
|
||||
}
|
||||
</>}
|
||||
|
||||
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Tooltip, Typography } from "antd";
|
||||
import type { ColumnType } from "antd/es/table/interface";
|
||||
import { Button, Checkbox, Form, Input, Modal, Select, Space, Tooltip, Typography } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -14,89 +13,13 @@ import { useHosts } from "../model/host";
|
||||
import { useInstanceConfig, useInstances } from "../model/instance";
|
||||
import { useSaves } from "../model/saves";
|
||||
import InstanceList from "./InstanceList";
|
||||
import Link from "./Link";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import type { Control } from "../util/websocket";
|
||||
|
||||
type SaveMode = "none" | "copy" | "save_and_copy";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
function ConfigSourceFields(props: {
|
||||
instances: ReturnType<typeof useInstances>[0];
|
||||
templateId?: number | null;
|
||||
source?: Readonly<lib.InstanceDetails>;
|
||||
asTemplate: boolean;
|
||||
}) {
|
||||
const templates = [...props.instances.values()].filter(i => i.isTemplate);
|
||||
const hasTemplate = typeof props.templateId === "number";
|
||||
return <>
|
||||
{(templates.length > 0 || hasTemplate) && <Tooltip
|
||||
title="Inherit config from a template, later changes to the template apply to this instance too"
|
||||
>
|
||||
<Form.Item name="template" label="Template">
|
||||
<Select
|
||||
disabled={props.source !== undefined}
|
||||
options={[{ value: null, label: "None" }, ...templates.map(i => ({ value: i.id, label: i.name }))]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tooltip>}
|
||||
<Tooltip title="Perform a one time copy of the config of an existing instance">
|
||||
<Form.Item name="instanceClone" label="Copy Config">
|
||||
<Select
|
||||
disabled={hasTemplate}
|
||||
options={[
|
||||
{ id: -1, name: "Default Config" },
|
||||
...[...props.instances.values()].filter(i => !i.isTemplate),
|
||||
].map(i => ({ value: i.id, label: i.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
{props.asTemplate && props.source && <Form.Item name="linkSource" valuePropName="checked">
|
||||
<Checkbox>Make {props.source.name} inherit from the new template</Checkbox>
|
||||
</Form.Item>}
|
||||
</>;
|
||||
}
|
||||
|
||||
async function linkToTemplate(control: Control, source: lib.InstanceDetails | undefined, templateId: number) {
|
||||
if (source) {
|
||||
await control.send(new lib.InstanceConfigSetFieldRequest(
|
||||
source.id, "instance.template_id", String(templateId),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function setTemplateFields(
|
||||
config: lib.InstanceConfig,
|
||||
fields: (keyof lib.InstanceConfigFields)[],
|
||||
templateId: unknown,
|
||||
asTemplate: boolean,
|
||||
) {
|
||||
if (typeof templateId === "number") {
|
||||
config.set("instance.template_id", templateId);
|
||||
fields.push("instance.template_id");
|
||||
}
|
||||
if (asTemplate) {
|
||||
config.set("instance.is_template", true);
|
||||
fields.push("instance.is_template");
|
||||
}
|
||||
}
|
||||
|
||||
type CreateInstanceInitial = {
|
||||
instanceClone?: number;
|
||||
template?: number;
|
||||
asTemplate?: boolean;
|
||||
linkSource?: boolean;
|
||||
};
|
||||
|
||||
export function CreateInstanceButton({ buttonText = "Create", buttonType = "primary", ...props }: {
|
||||
instances: ReturnType<typeof useInstances>[0];
|
||||
initial?: CreateInstanceInitial;
|
||||
buttonText?: string;
|
||||
buttonType?: "primary" | "default";
|
||||
buttonSize?: "small";
|
||||
}) {
|
||||
function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances>[0] }) {
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
@@ -108,11 +31,9 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
|
||||
const canAssign = account.hasPermission("core.instance.assign");
|
||||
const cloneId: number | undefined = Form.useWatch("instanceClone", form);
|
||||
const templateId: number | null | undefined = Form.useWatch("template", form);
|
||||
const asTemplate = Boolean(Form.useWatch("asTemplate", form));
|
||||
const host: number | string | undefined = Form.useWatch("host", form);
|
||||
const saveMode: SaveMode = Form.useWatch("saveMode", form) ?? "none";
|
||||
const hostId = typeof host === "number" && !asTemplate ? host : undefined;
|
||||
const hostId = typeof host === "number" ? host : undefined;
|
||||
const source = cloneId !== undefined && cloneId >= 0 ? props.instances.get(cloneId) : undefined;
|
||||
const sourceRunning = source?.status === "running";
|
||||
const activeSave = source ? [...saves.values()].find(
|
||||
@@ -121,7 +42,6 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
const sourceConfig = useInstanceConfig(source?.id);
|
||||
const sourceSettings = sourceConfig?.["factorio.settings"] as Record<string, unknown> | undefined;
|
||||
const sourceServerName = typeof sourceSettings?.name === "string" ? sourceSettings.name : undefined;
|
||||
const copySettings = sourceSettings !== undefined && !asTemplate;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && sourceServerName !== undefined) {
|
||||
@@ -148,8 +68,7 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
let instanceConfig = new lib.InstanceConfig("control");
|
||||
instanceConfig.set("instance.name", values.instanceName);
|
||||
const fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"];
|
||||
setTemplateFields(instanceConfig, fields, values.template, asTemplate);
|
||||
if (copySettings) {
|
||||
if (source && sourceSettings) {
|
||||
if (!values.serverName) {
|
||||
form.setFields([{ name: "serverName", errors: ["Server name is required"] }]);
|
||||
return;
|
||||
@@ -168,9 +87,6 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
));
|
||||
created = true;
|
||||
|
||||
if (asTemplate && values.linkSource) {
|
||||
await linkToTemplate(control, source, instanceId);
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
|
||||
}
|
||||
@@ -198,14 +114,13 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type={buttonType}
|
||||
size={props.buttonSize}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>{buttonText}</Button>
|
||||
>Create</Button>
|
||||
<Modal
|
||||
title={asTemplate ? "Create Template" : "Create Instance"}
|
||||
title="Create Instance"
|
||||
okText="Create"
|
||||
open={open}
|
||||
confirmLoading={creating}
|
||||
@@ -215,20 +130,12 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{
|
||||
host: "null", instanceClone: -1, saveMode: "none", startAfter: false,
|
||||
template: null, asTemplate: false, linkSource: true, ...props.initial,
|
||||
}}
|
||||
initialValues={{ host: "null", instanceClone: -1, saveMode: "none", startAfter: false }}
|
||||
>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Tooltip title="Templates hold shared config for other instances and cannot be started">
|
||||
<Form.Item name="asTemplate" valuePropName="checked">
|
||||
<Checkbox>Create as template</Checkbox>
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
{canAssign && !asTemplate && <Form.Item name="host" label="Host">
|
||||
{canAssign && <Form.Item name="host" label="Host">
|
||||
<Select showSearch optionFilterProp="name">
|
||||
<Select.Option value={"null"}>
|
||||
<Typography.Text italic>Unassigned</Typography.Text>
|
||||
@@ -244,15 +151,16 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>}
|
||||
<ConfigSourceFields
|
||||
instances={props.instances}
|
||||
templateId={templateId}
|
||||
source={source}
|
||||
asTemplate={asTemplate}
|
||||
/>
|
||||
{copySettings && <Tooltip
|
||||
title="Name of the server as shown in the in-game server browser"
|
||||
>
|
||||
<Tooltip title="Perform a one time copy of the config of an existing instance">
|
||||
<Form.Item name="instanceClone" label="Copy Config">
|
||||
<Select
|
||||
options={[{ id: -1, name: "Default Config" }, ...props.instances.values()]
|
||||
.map(i => ({ value: i.id, label: i.name }))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tooltip>
|
||||
{source && sourceSettings && <Tooltip title="Name of the server as shown in the in-game server browser">
|
||||
<Form.Item name="serverName" label="Server Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
@@ -286,59 +194,10 @@ export function CreateInstanceButton({ buttonText = "Create", buttonType = "prim
|
||||
</>;
|
||||
}
|
||||
|
||||
function TemplateList(props: { instances: ReturnType<typeof useInstances>[0] }) {
|
||||
const account = useAccount();
|
||||
const rowNav = useRowNavigation();
|
||||
const all = [...props.instances.values()];
|
||||
const inheritors = (id: number) => all.filter(instance => instance.templateId === id).length;
|
||||
const columns: ColumnType<lib.InstanceDetails>[] = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
render: (_, template) => <Link to={`/instances/${template.id}/view`} style={{ color: "inherit" }}>
|
||||
{template.name}
|
||||
</Link>,
|
||||
},
|
||||
{
|
||||
title: "Inherited by",
|
||||
key: "inheritors",
|
||||
render: (_, template) => `${inheritors(template.id)} instance(s)`,
|
||||
sorter: (a, b) => inheritors(a.id) - inheritors(b.id),
|
||||
},
|
||||
];
|
||||
if (account.hasPermission("core.instance.create")) {
|
||||
columns.push({
|
||||
key: "action",
|
||||
align: "right",
|
||||
// Clicks inside the modal bubble through the React tree to the row
|
||||
render: (_, template) => <span onClick={e => e.stopPropagation()}>
|
||||
<CreateInstanceButton
|
||||
instances={props.instances}
|
||||
initial={{ template: template.id }}
|
||||
buttonText="Create instance"
|
||||
buttonType="default"
|
||||
buttonSize="small"
|
||||
/>
|
||||
</span>,
|
||||
});
|
||||
}
|
||||
return <Table
|
||||
columns={columns}
|
||||
dataSource={all.filter(instance => instance.isTemplate)}
|
||||
rowKey={template => template.id}
|
||||
pagination={false}
|
||||
onRow={record => rowNav(`/instances/${record.id}/view`)}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default function InstancesPage() {
|
||||
let control = useContext(ControlContext);
|
||||
let account = useAccount();
|
||||
let [instances] = useInstances();
|
||||
const plainInstances = new Map([...instances].filter(([, instance]) => !instance.isTemplate));
|
||||
const hasTemplates = plainInstances.size !== instances.size;
|
||||
|
||||
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
@@ -372,11 +231,7 @@ export default function InstancesPage() {
|
||||
</Space>}
|
||||
/>
|
||||
|
||||
<InstanceList instances={plainInstances} />
|
||||
{hasTemplates && <>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>Templates</Typography.Title>
|
||||
<TemplateList instances={instances} />
|
||||
</>}
|
||||
<InstanceList instances={instances} />
|
||||
<PluginExtra component="InstancesPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig, Alert, notification, FormProps, Collapse,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig, Alert, notification,
|
||||
} from "antd";
|
||||
|
||||
import {
|
||||
@@ -21,15 +22,11 @@ import { useAccount } from "../model/account";
|
||||
import { useModPack } from "../model/mod_pack";
|
||||
import { useMods } from "../model/mods";
|
||||
import { useClipboard } from "../util/clipboard";
|
||||
import { modSettingFields, ModSettingField, ModSettingScope } from "../util/mod_settings";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { modSettingDescription, modSettingName, modSettingValueName } from "../util/locale";
|
||||
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";
|
||||
@@ -38,8 +35,6 @@ 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"
|
||||
@@ -182,7 +177,6 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
|
||||
return <>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search Mods"
|
||||
style={{ marginBottom: 8 }}
|
||||
onChange={(event) => {
|
||||
@@ -859,6 +853,11 @@ function hasChange(changes: ModChange[], field:Partial<ModChange>) {
|
||||
.every(([key, value]) => (change as any)[key] === value));
|
||||
}
|
||||
|
||||
// picks out the value property from settings.
|
||||
function pickValue(map: Map<string, lib.ModSetting>): [string, lib.ModSetting["value"]][] {
|
||||
return [...map].map(([name, value]) => [name, value.value]);
|
||||
}
|
||||
|
||||
function groupToMap(array: any[], fn: (...args:any) => any) {
|
||||
const map = new Map();
|
||||
for (let index = 0; index < array.length; index++) {
|
||||
@@ -901,19 +900,10 @@ function InputColor(props: {
|
||||
type SettingsTableFieldProps = {
|
||||
name: string;
|
||||
type: string;
|
||||
scope: ModSettingScope;
|
||||
scope: string;
|
||||
allowedValues: any;
|
||||
localisedName?: lib.FactorioLocalisedString;
|
||||
localisedDescription?: lib.FactorioLocalisedString;
|
||||
locale: Map<string, any>;
|
||||
changed: boolean;
|
||||
/** Saved value of the setting, used when the field is created and when the form is reset */
|
||||
initialValue: lib.ModSetting["value"] | undefined;
|
||||
/** Show a remove link, or a revert link if removed is set */
|
||||
removable?: boolean;
|
||||
removed?: boolean;
|
||||
onChange?: (change: ModChange) => void;
|
||||
onRevert?: (change: ModChange) => void;
|
||||
};
|
||||
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const name = props.name;
|
||||
@@ -921,7 +911,6 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const isColor = props.type === "color-setting";
|
||||
const isNumber = ["int-setting", "double-setting"].includes(props.type);
|
||||
const isSelect = Boolean(props.allowedValues);
|
||||
const prototype = { name, localised_name: props.localisedName, localised_description: props.localisedDescription };
|
||||
let input;
|
||||
if (isBoolean) {
|
||||
input = <Checkbox />;
|
||||
@@ -929,61 +918,40 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const PartialInputColor = InputColor as () => React.JSX.Element;
|
||||
input = <PartialInputColor />;
|
||||
} else if (isSelect) {
|
||||
const options = Object.values(props.allowedValues as (string | number)[]).map(value => ({
|
||||
const options = Object.values(props.allowedValues).map(value => ({
|
||||
value,
|
||||
label: modSettingValueName(props.locale, name, value),
|
||||
label: props.locale.get(`string-mod-setting.${name}-${value}`) || value,
|
||||
}));
|
||||
input = <Select options={options} />;
|
||||
} else {
|
||||
input = <Input />;
|
||||
}
|
||||
|
||||
const label = modSettingName(props.locale, prototype);
|
||||
const change: ModChange = {
|
||||
type: "settings.delete", scope: props.scope, name, value: { value: props.initialValue! },
|
||||
};
|
||||
return <Form.Item
|
||||
label={props.removed ? <Text delete>{label}</Text> : label}
|
||||
tooltip={modSettingDescription(props.locale, prototype)}
|
||||
name={[props.scope, name]}
|
||||
label={props.locale.get(`mod-setting-name.${name}`) || name}
|
||||
tooltip={props.locale.get(`mod-setting-description.${name}`)}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
valuePropName={isBoolean ? "checked" : "value"}
|
||||
rules={[...isNumber ? [{
|
||||
validator(rule: any, value: any) {
|
||||
if (Number.isNaN(Number(value))) {
|
||||
return Promise.reject(new Error("Must be a number"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}] : []]}
|
||||
className={props.changed ? "changed" : ""}
|
||||
>
|
||||
<Space>
|
||||
<ConfigProvider componentDisabled={props.removed}><Form.Item
|
||||
noStyle
|
||||
name={[props.scope, name]}
|
||||
initialValue={props.initialValue}
|
||||
valuePropName={isBoolean ? "checked" : "value"}
|
||||
rules={[...isNumber ? [{
|
||||
validator(rule: any, value: any) {
|
||||
if (Number.isNaN(Number(value))) {
|
||||
return Promise.reject(new Error("Must be a number"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}] : []]}
|
||||
>
|
||||
{input}
|
||||
</Form.Item></ConfigProvider>
|
||||
{props.removable && !props.removed && <Typography.Link
|
||||
type="danger"
|
||||
onClick={() => props.onChange?.(change)}
|
||||
>remove</Typography.Link>}
|
||||
{props.removable && props.removed && <Typography.Link
|
||||
onClick={() => props.onRevert?.(change)}
|
||||
>revert</Typography.Link>}
|
||||
</Space>
|
||||
{input}
|
||||
</Form.Item>;
|
||||
});
|
||||
|
||||
|
||||
type SettingsTableProps = {
|
||||
modPack: lib.ModPack;
|
||||
/** Mod pack without unsaved changes, reverting the form goes back to its values */
|
||||
savedModPack: lib.ModPack;
|
||||
/** Exported setting prototypes, undefined if the mod pack has no export data */
|
||||
prototypes: lib.ExportSettings | undefined;
|
||||
prototypes: any;
|
||||
locale: any;
|
||||
changes: ModChange[];
|
||||
onChange: (change: ModChange) => void;
|
||||
@@ -997,52 +965,55 @@ function SettingsTable(props: SettingsTableProps) {
|
||||
.map(mod => [mod.name, mod.title])
|
||||
);
|
||||
|
||||
function field(scope: ModSettingScope, setting: ModSettingField, removable = false) {
|
||||
return <SettingsTableField
|
||||
key={`${scope} ${setting.name}`}
|
||||
name={setting.name}
|
||||
type={setting.type}
|
||||
scope={scope}
|
||||
allowedValues={setting.allowed_values}
|
||||
localisedName={setting.localised_name}
|
||||
localisedDescription={setting.localised_description}
|
||||
locale={props.locale}
|
||||
changed={hasChange(props.changes, { type: "settings.set", scope, name: setting.name })}
|
||||
initialValue={props.savedModPack.settings[scope].get(setting.name)?.value}
|
||||
removable={removable}
|
||||
removed={removable && hasChange(props.changes, { type: "settings.delete", scope, name: setting.name })}
|
||||
onChange={props.onChange}
|
||||
onRevert={props.onRevert}
|
||||
/>;
|
||||
}
|
||||
const types = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
|
||||
let prototypes = Object.entries(props.prototypes)
|
||||
.filter(([type, _]) => types.includes(type))
|
||||
.flatMap<any>(([_, settingPrototypes]: [string, any]) => Object.values(settingPrototypes))
|
||||
;
|
||||
|
||||
function controls(scope: ModSettingScope) {
|
||||
// Listed from the saved pack so removed settings stay visible until applied
|
||||
const { known, unknown } = modSettingFields(props.prototypes, scope, props.savedModPack.settings[scope]);
|
||||
const fieldsByMod = groupToMap(known, setting => setting.mod);
|
||||
return <>
|
||||
{[...fieldsByMod].map(([mod, fieldsForMod]) => <Fragment key={mod ?? ""}>
|
||||
{mod !== undefined && <Typography.Title level={5}>
|
||||
{props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod}
|
||||
</Typography.Title>}
|
||||
{fieldsForMod.map((setting: ModSettingField) => field(scope, setting))}
|
||||
</Fragment>)}
|
||||
{unknown.length > 0 && <Collapse
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
items={[{
|
||||
key: "unknown",
|
||||
label: `Unknown settings (${unknown.length})`,
|
||||
children: <>
|
||||
<Typography.Paragraph type="secondary">
|
||||
No mod in the exported data defines these settings. They are kept so the values come back
|
||||
if a mod that uses them is added again.
|
||||
</Typography.Paragraph>
|
||||
{unknown.map(setting => field(scope, setting, true))}
|
||||
</>,
|
||||
}]}
|
||||
/>}
|
||||
</>;
|
||||
function controls(
|
||||
scope: "startup"|"runtime-global"|"runtime-per-user",
|
||||
storedFields: [string, lib.ModSetting["value"]][]
|
||||
) {
|
||||
let fields = new Map(prototypes
|
||||
.filter(p => p.setting_type === scope)
|
||||
.sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name))
|
||||
.map(p => [p.name, p])
|
||||
);
|
||||
for (let [name, value] of storedFields) {
|
||||
if (!fields.has(name)) {
|
||||
let type;
|
||||
if (typeof value === "boolean") {
|
||||
type = "bool-setting";
|
||||
} else if (typeof value === "number") {
|
||||
type = "double-setting";
|
||||
} else if (typeof value === "string") {
|
||||
type = "string-setting";
|
||||
} else if (typeof value === "object" && "r" in value) {
|
||||
type = "color-setting";
|
||||
} else {
|
||||
throw Error(`Unhandled setting type ${typeof value}`);
|
||||
}
|
||||
fields.set(name, { mod: "Unknown Fields", name, setting_type: scope, type });
|
||||
}
|
||||
}
|
||||
let fieldsByMod = groupToMap([...fields.values()], prototype => prototype.mod);
|
||||
return [...fieldsByMod].map(([mod, fieldsForMod]) => <Fragment key={mod}>
|
||||
<Typography.Title level={5}>
|
||||
{props.locale.get(`mod-name.${mod}`) || modTitles.get(mod) || mod}
|
||||
</Typography.Title>
|
||||
{[...fieldsForMod].map(
|
||||
prototype => <SettingsTableField
|
||||
key={`${scope} ${prototype.name}`}
|
||||
name={prototype.name}
|
||||
type={prototype.type}
|
||||
scope={scope}
|
||||
allowedValues={prototype.allowed_values}
|
||||
locale={props.locale}
|
||||
changed={hasChange(props.changes, { type: "settings.set", scope, name: prototype.name })}
|
||||
/>
|
||||
)}
|
||||
</Fragment>);
|
||||
}
|
||||
|
||||
return <>
|
||||
@@ -1051,15 +1022,15 @@ function SettingsTable(props: SettingsTableProps) {
|
||||
/>
|
||||
<Typography.Paragraph>Run export data on an instance to update available fields.</Typography.Paragraph>
|
||||
<Typography.Title level={4}>Startup</Typography.Title>
|
||||
{controls("startup")}
|
||||
{controls("startup", pickValue(props.modPack.settings["startup"]))}
|
||||
|
||||
<Typography.Title level={4}>Map</Typography.Title>
|
||||
<Typography.Paragraph>These only apply for new saves.</Typography.Paragraph>
|
||||
{controls("runtime-global")}
|
||||
{controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))}
|
||||
|
||||
<Typography.Title level={4}>Per Player</Typography.Title>
|
||||
<Typography.Paragraph>Default settings for new players, these only apply for new saves.</Typography.Paragraph>
|
||||
{controls("runtime-per-user")}
|
||||
{controls("runtime-per-user", pickValue(props.modPack.settings["runtime-per-user"]))}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1121,16 +1092,14 @@ function ExportButton(props: { modPack: lib.ModPack }) {
|
||||
}
|
||||
|
||||
function useExportedAsset(modPack: lib.ModPack | undefined, asset: "settings"|"locale"): Map<any, any> | any {
|
||||
let [assetData, setAssetData] = useState<Map<any, any>|any>(asset === "locale" ? new Map() : undefined);
|
||||
let [assetData, setAssetData] = useState<Map<any, any>|any>(asset === "locale" ? new Map() : {});
|
||||
let assetFilename: string | undefined;
|
||||
if (modPack instanceof lib.ModPack && modPack.exportManifest?.assets[asset]) {
|
||||
assetFilename = modPack.exportManifest.assets[asset];
|
||||
}
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const empty = asset === "locale" ? new Map() : undefined;
|
||||
if (!assetFilename) {
|
||||
setAssetData(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1139,14 +1108,7 @@ function useExportedAsset(modPack: lib.ModPack | undefined, asset: "settings"|"l
|
||||
let data = await response.json();
|
||||
setAssetData(asset === "locale" ? new Map(data) : data);
|
||||
} else {
|
||||
if (response.status === 404) {
|
||||
logger.warn(`Mod pack asset "${asset}" is missing on the controller`);
|
||||
} else {
|
||||
logger.error(
|
||||
`Error loading mod pack asset "${asset}" (${response.status}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
setAssetData(empty);
|
||||
logger.error(`Error loading mod pack asset "${asset}" (${response.status}): ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,9 +1182,8 @@ export default function ModPackViewPage() {
|
||||
let modPackId = Number(params.id);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [modPack, synced, error] = useModPack(modPackId);
|
||||
const state = useResourceState(modPack, synced, error, modPackId);
|
||||
let prototypes: lib.ExportSettings | undefined = useExportedAsset(modPack, "settings");
|
||||
const [modPack, synced] = useModPack(modPackId);
|
||||
let prototypes: any = useExportedAsset(modPack, "settings");
|
||||
let locale: Map<any, any> = useExportedAsset(modPack, "locale");
|
||||
let [changes, setChanges] = useState<ModChange[]>([]);
|
||||
let syncTimeout = useRef<ReturnType<typeof setTimeout>|undefined>();
|
||||
@@ -1252,7 +1213,7 @@ export default function ModPackViewPage() {
|
||||
setChanges(oldChanges => {
|
||||
const newChanges = [...oldChanges];
|
||||
const index = newChanges.findLastIndex(reference => (
|
||||
change.type === reference.type && change.name === reference.name && change.scope === reference.scope
|
||||
change.type === reference.type && change.name === reference.name
|
||||
));
|
||||
if (index === -1) {
|
||||
logger.error(`Unable to revert ${change.type} ${change.name}: change not found`);
|
||||
@@ -1275,9 +1236,6 @@ export default function ModPackViewPage() {
|
||||
}
|
||||
} else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) {
|
||||
let [scope, settingName]: ["startup"|"runtime-global"|"runtime-per-user", string] = field.name;
|
||||
if (modPack!.settings[scope].has(settingName) && !modifiedModPack!.settings[scope].has(settingName)) {
|
||||
continue; // Removed
|
||||
}
|
||||
let value = field.value;
|
||||
if (typeof modPack!.settings[scope].get(settingName)?.value === "number") {
|
||||
value = Number(value);
|
||||
@@ -1294,7 +1252,17 @@ export default function ModPackViewPage() {
|
||||
|
||||
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }];
|
||||
if (!modifiedModPack || !modPack) {
|
||||
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
|
||||
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 <PageLayout nav={nav}>
|
||||
@@ -1332,12 +1300,13 @@ export default function ModPackViewPage() {
|
||||
syncTimeout.current = setTimeout(syncPack.bind(undefined, allFields), 500);
|
||||
}
|
||||
}}
|
||||
// Mod settings get their value on each field instead, the form only applies initialValues when it is
|
||||
// created, so settings added by an export while the page is open would show up empty.
|
||||
initialValues={{
|
||||
name: modPack.name,
|
||||
description: modPack.description,
|
||||
factorioVersion: modPack.factorioVersion,
|
||||
"startup": Object.fromEntries(pickValue(modPack.settings["startup"])),
|
||||
"runtime-global": Object.fromEntries(pickValue(modPack.settings["runtime-global"])),
|
||||
"runtime-per-user": Object.fromEntries(pickValue(modPack.settings["runtime-per-user"])),
|
||||
}}
|
||||
>
|
||||
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
|
||||
@@ -1386,7 +1355,6 @@ export default function ModPackViewPage() {
|
||||
/>
|
||||
<SettingsTable
|
||||
modPack={modifiedModPack}
|
||||
savedModPack={modPack}
|
||||
changes={changes}
|
||||
onChange={pushChange}
|
||||
onRevert={revertChange}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { Fragment, useState, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import React, { Fragment, useState, useContext, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button, Form, Input, InputRef, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip,
|
||||
notification,
|
||||
Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification,
|
||||
} from "antd";
|
||||
import {
|
||||
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
|
||||
@@ -148,7 +147,6 @@ function SearchModsButton() {
|
||||
const account = useAccount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const nameInputRef = useRef<InputRef>(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("2.0");
|
||||
|
||||
@@ -335,7 +333,6 @@ function SearchModsButton() {
|
||||
title="Search Mod Portal"
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
afterOpenChange={visible => { if (visible) { nameInputRef.current?.focus(); } }}
|
||||
width={1000}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => { setOpen(false); }}>
|
||||
@@ -352,7 +349,7 @@ function SearchModsButton() {
|
||||
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
|
||||
>
|
||||
<Form.Item name="name" label="Filter by Name or Title">
|
||||
<Input ref={nameInputRef} placeholder="Start typing to filter..." />
|
||||
<Input placeholder="Start typing to filter..." />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="factorioVersion"
|
||||
|
||||
@@ -35,8 +35,6 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -46,7 +44,7 @@ type MachinePluginsState = {
|
||||
export function useMachinePlugins(): MachinePluginsState {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [hosts, hostsSynced, hostsError] = useHosts();
|
||||
const [hosts, hostsSynced] = 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>());
|
||||
@@ -90,12 +88,8 @@ export function useMachinePlugins(): MachinePluginsState {
|
||||
}
|
||||
}, [canList, hostsSynced, connectedKey]);
|
||||
|
||||
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 };
|
||||
if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) {
|
||||
return { machines: undefined, unknown: [], refresh };
|
||||
}
|
||||
const machines: MachinePlugins[] = [];
|
||||
const unknown: Machine[] = [];
|
||||
@@ -111,7 +105,7 @@ export function useMachinePlugins(): MachinePluginsState {
|
||||
unknown.push(machine);
|
||||
}
|
||||
}
|
||||
return { machines, unknown, refresh, error: null };
|
||||
return { machines, unknown, refresh };
|
||||
}
|
||||
|
||||
/** Latest version published on npm of each package */
|
||||
@@ -166,7 +160,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.state === "missing" || machine.state === "dependencyTaken");
|
||||
const missing = count(machine => (!machine.plugin || Boolean(machine.plugin.removed)) && !machine.nameTaken);
|
||||
|
||||
return <>
|
||||
{versions.map(version => {
|
||||
@@ -181,14 +175,14 @@ export function PluginVersions(props: { status: PluginStatus, latest?: string })
|
||||
</div>;
|
||||
})}
|
||||
{count(isPending) > 0 && <CountLine label="Restart pending on" count={count(isPending)} />}
|
||||
{count(machine => machine.state === "disabled") > 0 && <CountLine
|
||||
label="Disabled on" count={count(machine => machine.state === "disabled")} type="secondary"
|
||||
{count(machine => machine.disabled) > 0 && <CountLine
|
||||
label="Disabled on" count={count(machine => machine.disabled)} type="secondary"
|
||||
/>}
|
||||
{count(machine => machine.state === "noEntrypoint") > 0 && <CountLine
|
||||
label="No entrypoint on" count={count(machine => machine.state === "noEntrypoint")} type="secondary"
|
||||
{count(machine => machine.noEntrypoint) > 0 && <CountLine
|
||||
label="No entrypoint on" count={count(machine => machine.noEntrypoint)} type="secondary"
|
||||
/>}
|
||||
{count(machine => machine.state === "nameTaken") > 0 && <CountLine
|
||||
label="From another package on" count={count(machine => machine.state === "nameTaken")} type="secondary"
|
||||
{count(machine => machine.nameTaken) > 0 && <CountLine
|
||||
label="From another package on" count={count(machine => machine.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 } from "antd";
|
||||
import { Alert, Descriptions, Spin } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { PluginWebApi } from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import ResourceState from "./ResourceState";
|
||||
import PageHeader from "./PageHeader";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import useResourceState from "../util/useResourceState";
|
||||
import notify from "../util/notify";
|
||||
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, error: machinesError } = useMachinePlugins();
|
||||
const { machines, unknown, refresh } = useMachinePlugins();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/plugins`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load plugin list: ${response.status} ${response.statusText}`);
|
||||
if (response.ok) {
|
||||
setPluginList(await response.json());
|
||||
} else {
|
||||
notify("Failed to load plugin list");
|
||||
}
|
||||
setPluginList(await response.json());
|
||||
})().catch(setPluginListError);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const pluginMeta = pluginList?.find(p => p.name === pluginName);
|
||||
@@ -110,16 +110,19 @@ 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 (state !== "ok") {
|
||||
return <ResourceState
|
||||
state={state} type="Plugin" id={pluginTitle} nav={nav} listPath="/plugins" error={error}
|
||||
/>;
|
||||
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>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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, Tooltip } from "antd";
|
||||
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd";
|
||||
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -12,9 +12,7 @@ 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 */
|
||||
@@ -90,7 +88,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, setPermissions,
|
||||
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
|
||||
}: {
|
||||
groupName: string;
|
||||
permissions: lib.Permission[];
|
||||
@@ -98,10 +96,17 @@ function PermissionGroup({
|
||||
|
||||
baselineState: Record<string, boolean>;
|
||||
permissionState: Record<string, boolean>;
|
||||
setPermissions: (names: string[], value: boolean) => void;
|
||||
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
setEdited?: (value: boolean) => void;
|
||||
}) {
|
||||
function setGroup(value: boolean) {
|
||||
setPermissions(permissions.map(p => p.name), value);
|
||||
setPermissionState((prev: any) => {
|
||||
const next = { ...prev };
|
||||
for (let p of permissions) {
|
||||
next[p.name] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,7 +129,7 @@ function PermissionGroup({
|
||||
checked={permissionState[p.name]}
|
||||
baseline={baselineState[p.name]}
|
||||
disabled={!canUpdate}
|
||||
onChange={v => setPermissions([p.name], v)}
|
||||
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -164,13 +169,12 @@ export default function RoleViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [role, synced, error] = useRole(roleId);
|
||||
const state = useResourceState(role, synced, error, roleId);
|
||||
const [role, synced] = useRole(roleId);
|
||||
|
||||
// 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 [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
|
||||
const [baselineState, setBaselineState] = 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();
|
||||
@@ -179,40 +183,37 @@ 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 || !canUpdate) {
|
||||
setNameEdit(undefined);
|
||||
setDescriptionEdit(undefined);
|
||||
setPermissionEdits(prev => (Object.keys(prev).length ? {} : prev));
|
||||
if (!role) {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setNameEdit(undefined);
|
||||
setDescriptionEdit(undefined);
|
||||
setPermissionEdits({});
|
||||
}, [roleId]);
|
||||
const base = Object.fromEntries(
|
||||
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
|
||||
);
|
||||
|
||||
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) }];
|
||||
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 };
|
||||
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>;
|
||||
}
|
||||
|
||||
// Apply the filter by searching all permission fields
|
||||
const filtered = allPermissions.filter(p => {
|
||||
@@ -242,35 +243,29 @@ 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(permissionEdits).some(k => permissionEdits[k] !== baselineState[k]);
|
||||
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
|
||||
const metaChanged = name !== role.name || description !== role.description;
|
||||
const edited = permissionChanged || metaChanged;
|
||||
|
||||
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() {
|
||||
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)
|
||||
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
|
||||
.then(() => {
|
||||
setBaselineState(permissionState);
|
||||
})
|
||||
.catch(notifyErrorHandler("Error applying changes"));
|
||||
}
|
||||
|
||||
/** Revert any pending changes */
|
||||
function revertChanges() {
|
||||
setPermissionState(baselineState);
|
||||
if (role) {
|
||||
setName(role.name);
|
||||
setDescription(role.description);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout nav={[{ name: "Roles", path: "/roles" }, { name: role.name }]}>
|
||||
<PageHeader
|
||||
@@ -325,12 +320,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 => setNameEdit(e.target.value)} />
|
||||
<Input value={name} disabled={!canUpdate} onChange={e => setName(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 => setDescriptionEdit(e.target.value)} />
|
||||
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<ResourceNotes resourceType="role" resourceId={roleId} />
|
||||
@@ -368,7 +363,7 @@ export default function RoleViewPage() {
|
||||
permissions={permissions}
|
||||
permissionState={permissionState}
|
||||
baselineState={baselineState}
|
||||
setPermissions={setPermissions}
|
||||
setPermissionState={setPermissionState}
|
||||
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, Switch,
|
||||
Row, Table, Tag, Select, Space, Spin, Switch,
|
||||
} from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
@@ -18,9 +18,7 @@ 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";
|
||||
@@ -42,9 +40,8 @@ export default function UserViewPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let [instances] = useInstances();
|
||||
const [user, synced, error] = useUser(userName);
|
||||
const state = useResourceState(user, synced, error, userName);
|
||||
let [roles, rolesSynced] = useRoles();
|
||||
const [user, synced] = useUser(userName);
|
||||
let [roles] = useRoles();
|
||||
let [form] = Form.useForm();
|
||||
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
|
||||
namespace: "stats", defaultSortKey: "instance",
|
||||
@@ -65,25 +62,22 @@ 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) {
|
||||
return <ResourceState state={state} type="User" id={userName} nav={nav} listPath="/users" error={error} />;
|
||||
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>;
|
||||
}
|
||||
|
||||
// TODO: Update displayed roles when user roles change and have not been edited locally
|
||||
let roleSelector = <Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
|
||||
@@ -20,7 +20,6 @@ import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import UsersTable from "./UsersTable";
|
||||
import { InputInstanceWithAll } from "./InputInstance";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
|
||||
function CreateUserButton() {
|
||||
let control = useContext(ControlContext);
|
||||
@@ -369,10 +368,7 @@ function BulkUserActionButton() {
|
||||
|
||||
export default function UsersPage() {
|
||||
const account = useAccount();
|
||||
// Kept in the URL with the users table state so back/forward restores it
|
||||
const tableState = useTableQueryState<lib.UserDetails>({ namespace: "user" });
|
||||
const instanceParam = Number(tableState.filteredValue("instance")?.[0]);
|
||||
const instanceId = Number.isInteger(instanceParam) ? instanceParam : null;
|
||||
const [instanceId, setInstanceId] = useState<number | null>(null);
|
||||
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<PageHeader
|
||||
@@ -381,9 +377,7 @@ export default function UsersPage() {
|
||||
{account.hasAllPermission("core.instance.list", "core.instance.subscribe")
|
||||
&& <InputInstanceWithAll
|
||||
value={instanceId}
|
||||
onChange={value => tableState.commitFilter(
|
||||
"instance", value === null ? null : [String(value)]
|
||||
)}
|
||||
onChange={value => setInstanceId(value as number)}
|
||||
fieldDefinition={{ optional: true } as any}
|
||||
disabled={false}
|
||||
/>}
|
||||
|
||||
@@ -40,7 +40,6 @@ 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";
|
||||
|
||||
@@ -49,4 +48,3 @@ 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";
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user