Commit Graph
3197 Commits
Author SHA1 Message Date
Cooldude2606 775dd8126f Merge pull request #1029 from bbassie/fix/instance-assign-failure
Keep instances assignable after a failed assign or create
2026-09-13 17:47:48 +01:00
bbassie daa39fd7ec Merge remote-tracking branch 'upstream/master' into fix/instance-assign-failure 2026-09-13 14:43:43 +00:00
Cooldude2606andClaude Opus 5 a28e2bf9f6 Terminate control connections of deleted users (#1030)
* Terminate control connections of deleted users

ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Terminate deleted user sessions immediately

Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Handle SessionLost when deleting your own account

Deleting your own account now terminates the session immediately, so the
web UI received a SessionLost rejection and reported "Error deleting
user" for a delete that had succeeded. Use the same branch revoke does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-13 15:31:46 +01:00
Cooldude2606 c355fc6888 Merge branch 'master' into fix/terminate-deleted-user-sessions 2026-09-13 15:18:25 +01:00
d32d857eb3 Re-apply debug dump ignore flag on control connection resume (#1035)
handleDebugDumpWsRequest sets clusterio_ignore_dump on the current
socket so the WsServer send wrapper does not echo the dumper's own
frames back into debugEvents. The flag was only set on the connector
"connect" event, but WsServerConnector.continue() installs a fresh
socket and emits "resume", not "connect", so a resumed session lost
the flag.

After a resume the next outgoing frame was echoed to debugEvents,
ws_dumper sent a DebugWsMessageEvent, the wrapper echoed that too,
and so on: infinite synchronous recursion ending in a RangeError:
Invalid string length from JSON.stringify in link.ts sendEvent. That
uncaught error made handleUnhandledErrors exit the controller.

Repro: admin sends DebugDumpWsRequest, the control connection drops
and resumes (e.g. a network blip), controller crashes on resume.

Set the flag on "resume" as well as "connect".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-13 15:13:24 +01:00
BastiaanandClaude Opus 4.8 fc73433ffb Stop host crash on malformed action log lines (#1034)
_watchServerLogActions dereferenced two regex results with ! and
threw on null. The name regex /^([^ ]+)/ returns null when a line
classified as an action starts with a space, and the BAN reason
regex /\. Reason: (.+)\.$/ returns null for a [BAN] line with no
". Reason: X." tail. The output listener runs synchronously off the
stdout LineSplitter, so the throw becomes an uncaughtException and
takes the whole host down with every instance on it.

Repro: instance with enable_save_patching off and enable_script_commands
on; a control client sends /sc print("2000-01-01 00:00:00 [BAN] evil").
The raw print reaches stdout, parses as a dated BAN action, and the
reason deref throws.

Skip the line when the name regex misses and default the ban reason
to empty when the tail is absent. Apply the same name guard to
_watchPlayerPromote. The autosave ![1] in server.ts is safe because
its filter regex already guarantees the capture.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:53:49 +01:00
BastiaanandClaude Opus 4.8 8239a96509 Deny instead of crashing when a control permission check throws (#1033)
A permission function runs during ingress permission checking, before
the request body is validated. UserBulkImportRequest.permission read the
raw, unvalidated message.data and threw a bare Error for an unknown
importType. That error is not a PermissionError, so _processMessage
rethrew it to the connector "message" listener, which emitted "error" on
the connector. Controller-side connectors have no "error" listener, so
Node rethrew and handleUnhandledErrors exited the process.

Repro: a control client with client-side validateSent disabled sends
UserBulkImportRequest with importType "foo"; the client gets a normal
RequestError response and the controller then logs a fatal uncaught
exception and exits.

Fix both layers: _processMessage now logs and drops an unexpected error
from the permission check (control connections only, where a response
was already sent) instead of rethrowing, and UserBulkImportRequest
throws a PermissionError for an unknown importType so it is denied
cleanly. A genuine PermissionError still denies silently as before.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:51:16 +01:00
BastiaanandClaude Opus 5 558999faa8 Parse the Map Settings field in the load scenario modal (#1032)
The Map Settings block parsed values.mapGenSettings, so filling in only
Map Settings failed with "undefined" is not valid JSON and the modal
would not submit, while filling in both sent the map gen settings JSON
to Factorio as --map-settings.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:45:50 +01:00
bbassieandClaude Opus 5 c9e8aeee5c Handle SessionLost when deleting your own account
Deleting your own account now terminates the session immediately, so the
web UI received a SessionLost rejection and reported "Error deleting
user" for a delete that had succeeded. Use the same branch revoke does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:34:58 +00:00
bbassieandClaude Opus 5 1da7c1da67 Terminate deleted user sessions immediately
Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:31:09 +00:00
bbassieandClaude Opus 5 fb71ee7282 Terminate control connections of deleted users
ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:30:45 +00:00
Cooldude2606 3c692c4321 Merge branch 'master' into fix/instance-assign-failure 2026-09-13 14:04:45 +01:00
BastiaanandClaude Opus 5 5b63fd27fe Fix listener leaks when a control connection closes (#1027)
The close handler removed the log subscription transport from the global
lib logger, but updateLogSubscriptions adds it to the controller's
clusterLogger. Every control connection that subscribed to logs stayed
attached to clusterLogger after closing, keeping the connection alive and
running every later cluster log line through its filter.

handleDebugDumpWsRequest added a new debugEvents listener on each call
and overwrote ws_dumper, so the close handler only removed the last one.
Repeated requests stacked listeners, triggered MaxListenersExceededWarning
after 11 calls, and left the earlier listeners behind. Ignore the request
if the connection is already dumping.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:44:18 +01:00
bbassieandClaude Opus 5 fbdd647168 Keep instances assignable after a failed assign or create
assignInstance sets instance.assigned_host to the new host before
sending InstanceAssignInternalRequest. If the host fails that request
the controller kept the new host id while the host never added the
instance. Assigning to the same host again then returned early as a
no-op. Repro: make the host's instances directory read-only, assign
instance 3001 to host 10 (EACCES on mkdir), make it writable, assign
again (ok, nothing happens), start (instance does not exist on host).
Now a failed send leaves the instance unassigned and rethrows.

InstanceCreateRequest applied instance.assigned_host from the request
config, creating an instance assigned to a host that never heard of
it. Repro: InstanceCreateRequest({"instance.id": 4001, "instance.name":
"c1", "instance.assigned_host": 10}). Reject a non-null value with the
same error the config set requests use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:36:54 +00:00
BastiaanandClaude Fable 5.1 f4aa894fe7 Give each safeOutputFile call its own temporary file (#1026)
Two writes to the same file at the same time shared one temporary name, so the second write clobbered the first's temporary and the first rename left the second call with nothing to rename, failing with ENOENT. The host does exactly that when it saves an instance config while the instance saves it too.

The temporary now carries a random tag and is created exclusively, so concurrent writers never touch each other's file. Since the temporary is always new, the mode is applied on creation and the chmod for reused leftovers goes away. A failed rename removes its temporary instead of leaving it for the next write to overwrite.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:37:26 +01:00
Cooldude2606 a7024c8f72 Merge pull request #1025 from bbassie/fix/esm-coverage
Collect coverage with c8 instead of nyc
2026-09-13 01:15:12 +01:00
Cooldude2606 820bafa11f Merge branch 'master' into fix/esm-coverage 2026-09-13 01:08:43 +01:00
Cooldude2606 82a9d0ee9e Merge pull request #1024 from bbassie/ci/pnpm-store-cache
Cache builds in CI and skip them where unused
2026-09-13 01:06:13 +01:00
Cooldude2606 5a3a0a189c Merge pull request #1023 from bbassie/chore/dependency-updates
Bump dependency versions
2026-09-13 00:41:25 +01:00
bbassieandClaude Fable 5.1 fd05ea0e9c Move shared dependencies into the workspace catalog
yargs, @types/yargs, semver and @types/semver are used by several packages at the same version. Also point the remaining direct references to @swc/core and react-router-dom at their existing catalog entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:36:25 +00:00
bbassieandClaude Fable 5.1 cf6c0ae873 Collect coverage with c8 instead of nyc
nyc instruments through the CommonJS require hook, so since the ESM migration (#984) it has seen nothing and Codecov has reported 0%. c8 reads V8's own coverage, which covers ES modules and child processes alike, and maps it back to the TypeScript sources through the emitted source maps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:29:45 +00:00
bbassieandClaude Fable 5.1 f3daaf7b15 Bump dependency versions
Raise every caret range to the current latest within its major, in the
root package.json, the pnpm-workspace.yaml catalog, the per-package
package.json files and the create plugin template.

Majors taken: eslint 10, @eslint/js 10, globals 17, nyc 18,
@types/express 5, @types/node 24, finalhandler 2, pidusage 4,
style-loader 4, css-loader 7, yargs 18 (all packages), chalk 6.

Majors skipped: mocha 12 (integration suite fails under it, 59 tests,
starting with a 30 s timeout in clusteriocontroller run; kept at
^11.8.0), inquirer (10+ is a rewrite on @inquirer/prompts where
ui.Prompt.prototype.close no longer exists, so the win32 readline
workaround in create.js would have to go and cannot be verified here),
react/antd/react-router-dom/@types/react, typescript 7,
@sinclair/typebox, webpack-merge (exact pin), webpack-dev-middleware,
webpack-manifest-plugin, winston-daily-rotate-file.

Code adaptations: the two /api/stream/:id handlers in routes.ts are
typed Request<{ id: string }> because @types/express 5 types
req.params values as string | string[]; eslint.config.mjs adds
newIsCapExceptions: ["constructor"] because eslint 10.10 new-cap now
reports `new this.constructor()`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:24:13 +00:00
bbassieandClaude Fable 5.1 992fc0fed7 Cache build output and skip builds where unused
The prepare scripts (tsc --build and webpack) are most of the install time in every job, so their output is cached between runs. tsc's tsbuildinfo tracks file hashes and webpack's filesystem cache keeps its node_modules modules, so a restored cache makes both incremental.

The lint and build-mod jobs never read the compiled output: eslint is not type-aware here and build_mod.js is plain JavaScript. They now install with --ignore-scripts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:16:12 +00:00
Cooldude2606 68d47ec507 Merge pull request #1022 from bbassie/chore/bump-deps
Bump pnpm, GitHub Actions and CI Factorio version
2026-09-13 00:08:55 +01:00
bbassieandClaude Fable 5.1 b38211469a Cache the pnpm store between CI runs
The lockfile is not committed so the usual lockfile hash key does not work. Instead the store is snapshotted whenever a package.json changes or a week passes, and every run restores the most recent snapshot through a prefix restore key. pnpm still resolves the latest versions within the ranges; the cache only saves the tarball downloads. A composite action holds the setup so the four jobs share it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:02:41 +00:00
bbassieandClaude Fable 5.1 9bd12d70e9 Bump Dockerfile base image to Node 24
Node 20 reached end of life in April 2026.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
bbassieandClaude Fable 5.1 c0be963f9c Test against Factorio 2.1.17 and emmylua 0.25.1
2.1.17 is the current experimental release. The lint gate still reports 0 diagnostics over 28 files with emmylua_check 0.25.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
bbassieandClaude Fable 5.1 74e8d89081 Bump GitHub Actions to current majors
checkout, setup-node, upload-artifact and codecov-action to v7, pnpm/action-setup to v6 and action-gh-release to v3. All of them now run on the node24 actions runtime. No inputs changed for how the workflows use them. pnpm/action-setup v6 is also what makes trusted publishing work with pnpm 12.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
bbassieandClaude Fable 5.1 8468f8505a Bump pnpm to 12
pnpm 11 removed onlyBuiltDependencies in favour of allowBuilds and stopped reading non-auth settings from .npmrc, so auto-install-peers moves to pnpm-workspace.yaml. The native publish flow in pnpm 11+ keeps npm trusted publishing working as long as pnpm/action-setup is v6 or later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
Cooldude2606 8be251447a Merge pull request #1018 from bbassie/feature/plugin-install
Add plugin install command to controller, host and ctl
2026-09-12 23:35:26 +01:00
Cooldude2606 008e1d7805 Merge pull request #1019 from bbassie/feature/metrics-auth
Require a token to scrape /metrics
2026-09-12 23:34:59 +01:00
bbassieandClaude Fable 5.1 310071df1b Use a service account name for the metrics scraper
Any valid Factorio player name can be claimed by a player joining the
cluster, so the docs now create the scraper user as #prometheus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:28:43 +00:00
bbassieandClaude Fable 5.1 87aa7b3b4c Require a token to scrape /metrics
The metrics endpoint fans out a gather to every connected host, so
anyone who could reach the controller could read cluster statistics and
put load on all hosts at will. Scraping now needs a user token holding
the new core.controller.metrics permission. The token is accepted via
Authorization: Bearer as well as X-Access-Token because Prometheus can
only send the former. Concurrent scrapes share one gather so hosts see
at most one metrics request at a time.

The three connection gauges in controller.ts now no-op before the
controller exists, like the other gauges there, so the endpoint can be
tested against the mock controller.

Closes #1009

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:28:43 +00:00
bbassieandClaude Fable 5.1 2776f3c37a Convert shared_commands test to ES module
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 21:58:20 +00:00
Cooldude2606 2b981a5734 Merge pull request #1020 from bbassie/refactor/plugin-hooks-v2
Add typed hook system for plugins
2026-09-12 22:57:14 +01:00
89c51f0f74 Document missing config options (#995)
configuration.md was missing most options added over the last few years. Add the controller, host and instance options that weren't listed, mark the internal ones, note the validation rule under each field that has one, and fix entries that had gone stale: the tls defaults, the proxy_stream_timeout name, game port assignment from host.factorio_port_range, the three sync list modes, and the player_online_autosave_slots heading.

Plugin options live in the plugin READMEs, so add the missing ones for inventory_sync, player_auth and subspace_storage there. config-system.md gets the field definition properties it didn't list yet.

Co-authored-by: bbassie <fqv4b97f@bastiaanbreemer.nl>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:47:13 +01:00
Cooldude2606 e6b642fc19 Merge pull request #1021 from Hornwitser/package-dependencies
Fix plugin dependencies
2026-09-12 22:11:06 +01:00
Cooldude2606 512f62c22b Merge branch 'master' into feature/plugin-install 2026-09-12 22:10:52 +01:00
bbassie db84455c34 Merge remote-tracking branch 'upstream/master' into refactor/plugin-hooks-v2
# Conflicts:
#	packages/lib/src/data/messages_plugin.ts
#	packages/web_ui/src/components/PluginViewPage.tsx
#	test/lib/plugin.js
2026-09-12 18:07:12 +00:00
bbassieandClaude Fable 5.1 7c0f77499c Address review on plugin install
Rethrow errors other than ENOENT and ENOTDIR when reading the package
spec as a directory, mark the tests that run npm as slow so FAST_TEST
skips them, and use ./external_plugins/my_plugin in the README examples.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 17:59:01 +00:00
Hornwitser d30e06b268 Set controller, host and ctl as optional peers
Configure the controller, host and ctl packages as optional peer
dependencies to prevent them being pulled in and installed by plugins in
environments that don't have them.  This means that a host only install
with a plugin that uses both host and controller will not cause the
controller package to be installed as well.
2026-09-12 16:19:03 +02:00
Hornwitser 4ac3d2c868 Remove @clusterio/web_ui from peer dependencies
The web_ui package is only used when building the web interface.  This
only happens during development and is thus adequatly covered by the
existing entry of web_ui in the devDependencies specification.

Removing web_ui from peer dependencies prevents it from being pulled in
by npm during a regular installation.  Since it's never used in a
regular installation this removed a whole lot of bloat.
2026-09-12 15:46:16 +02:00
bbassieandClaude Fable 5.1 8363b221bb Address review on plugin docs and web plugins map
Point the invalid configuration section at the features flags and
config validators instead of throwing from the entrypoint, use const in
the examples, add a metrics hook example including a returned registry,
and mark Control.plugins as deprecated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 08:29:16 +00:00
Cooldude2606 9f67d7ab76 Merge pull request #998 from bbassie/feature/update-all
Add update all for the controller, hosts and plugins
2026-09-12 02:19:04 +01:00
Cooldude2606 c2325a8b6e Add missing as const for permission string 2026-09-12 02:12:49 +01:00
Cooldude2606 06a6a7edf3 Merge branch 'master' into feature/update-all 2026-09-12 02:07:54 +01:00
BastiaanandClaude Fable 5.1 1a10b38743 Only report a missing plugin web build when one is expected (#1013)
Plugins with only host, instance or ctl code have no reason to ship a
webpack build, yet the plugin list showed "Missing dist/web/manifest.json"
as an error for them and the controller logged a warning on every start.

Decide whether a build is expected from the plugin declaration, using the
same rule the create tool uses to decide when to generate one: a web or
controller entrypoint, or any config fields. Plugins outside that rule get
no bundle path and no error, the web UI skips loading them, and the plugin
pages show them as having no web module instead of an error or a prompt to
reload.

Fixes #1004

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:59:16 +01:00
BastiaanandClaude Fable 5.1 9c97eeb597 Refuse mod packs for a newer Factorio than the instance runs (#1015)
The mod pack's Factorio version ends up in mod-settings.dat. Factorio
refuses to read that file when the version is newer than the game and
falls back to default settings, which can wreck the save. Check the
versions in syncMods before touching the mods folder.

Closes #1006

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:53:45 +01:00
Bastiaan 5b0f629b6e Merge pull request #1016 from bbassie/fix/improve-logging
Add context to connection and response log messages
2026-09-12 01:51:39 +01:00
Cooldude2606andClaude Fable 5.1 a80b035a0a Send only recipe notification changes on inventory download (#997)
* Send only recipe notification changes on inventory download

Lua includes its current cleared recipe list in the download request. The instance plugin diffs it against the stored list and sends back the names to add and remove instead of the full list. Lua rebuilds the full list from the snapshot kept in the download record and runs the existing deserializer, so the end state is unchanged.

Also stop the serializer from appending to the stored failed recipe list in place, which grew the list on every upload without a download in between.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test the inventory sync download handler

Covers the empty response, the full list without a snapshot, the delta with a snapshot, and the IPC registration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Apply recipe notification deltas directly in Lua

Review feedback: the download field now always carries the delta and the deserializer applies it against the current state instead of rebuilding the full list first. The previous failed recipe names are passed in so they survive the round trip. The version check moved to the call site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Fix failed not being cleared

* Send only the local state on download init

The current state sent with the download request no longer includes the failed names from the previous deserialization. Stored names unknown on this instance always end up in the add list, so the deserializer recomputes the failed list from that on every download and needs no previous state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Convert inventory sync test to ES module

Master migrated to ESM after this branch was created, so the CommonJS test failed to load on the merge commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-12 01:49:40 +01:00