mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3f4d445f | ||
|
|
00823262d5 | ||
|
|
a5bd43637e | ||
|
|
3ae486143d | ||
|
|
b4856bdd55 | ||
|
|
80f244fddd | ||
|
|
296d1dce99 |
No files matched your search
@@ -31,6 +31,27 @@ Many thanks to the following for contributing to this release:
|
|||||||
[@username](https://github.com/username)
|
[@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
|
## Version 2.0.3
|
||||||
|
|
||||||
### Features
|
### 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:
|
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.
|
- [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.
|
- [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.
|
- [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.
|
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
|
||||||
|
|||||||
+32
-2
@@ -214,6 +214,25 @@ Must be greater than 0.
|
|||||||
Defaults to 15.
|
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
|
### controller.factorio_username
|
||||||
|
|
||||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||||
@@ -382,11 +401,13 @@ Defaults to "localhost".
|
|||||||
### host.factorio_port_range
|
### host.factorio_port_range
|
||||||
|
|
||||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
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.
|
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.
|
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
|
### host.factorio_username
|
||||||
@@ -490,6 +511,15 @@ Does not affect start or stop of the instance while the host is running.
|
|||||||
Defaults to false.
|
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
|
### instance.exclude_from_start_all
|
||||||
|
|
||||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -11,6 +11,10 @@ export { default as UserManager } from "./src/UserManager.js";
|
|||||||
export { default as UserRecord } from "./src/UserRecord.js";
|
export { default as UserRecord } from "./src/UserRecord.js";
|
||||||
export { default as User } from "./src/User.js";
|
export { default as User } from "./src/User.js";
|
||||||
export { default as WsServer } from "./src/WsServer.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) {
|
if (import.meta.main) {
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type Controller from "./Controller.js";
|
|||||||
import type InstanceRecord from "./InstanceRecord.js";
|
import type InstanceRecord from "./InstanceRecord.js";
|
||||||
import type ControlConnection from "./ControlConnection.js";
|
import type ControlConnection from "./ControlConnection.js";
|
||||||
import type HostConnection from "./HostConnection.js";
|
import type HostConnection from "./HostConnection.js";
|
||||||
|
import type { PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||||
@@ -28,6 +29,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
|||||||
this.modsUpdated = this.newHook();
|
this.modsUpdated = this.newHook();
|
||||||
this.rolesUpdated = this.newHook();
|
this.rolesUpdated = this.newHook();
|
||||||
this.playerEvent = 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.
|
* @param event - Information about the event.
|
||||||
*/
|
*/
|
||||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import HostRecord from "./HostRecord.js";
|
|||||||
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
|
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
|
||||||
import ControllerRouter from "./ControllerRouter.js";
|
import ControllerRouter from "./ControllerRouter.js";
|
||||||
import InstanceManager from "./InstanceManager.js";
|
import InstanceManager from "./InstanceManager.js";
|
||||||
|
import PlayerProxyServer, { type PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||||
|
|
||||||
const endpointDurationSummary = new Summary(
|
const endpointDurationSummary = new Summary(
|
||||||
"clusterio_controller_http_endpoint_duration_seconds",
|
"clusterio_controller_http_endpoint_duration_seconds",
|
||||||
@@ -84,6 +85,7 @@ export default class Controller {
|
|||||||
trustedProxies: BlockList;
|
trustedProxies: BlockList;
|
||||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||||
private _events = new events.EventEmitter<ControllerEvents>();
|
private _events = new events.EventEmitter<ControllerEvents>();
|
||||||
|
playerProxyServer: PlayerProxyServer | null = null;
|
||||||
|
|
||||||
/** Event subscription controller */
|
/** Event subscription controller */
|
||||||
subscriptions: lib.SubscriptionController;
|
subscriptions: lib.SubscriptionController;
|
||||||
@@ -286,7 +288,6 @@ export default class Controller {
|
|||||||
this.clusterLogger = clusterLogger;
|
this.clusterLogger = clusterLogger;
|
||||||
this.pluginInfos = pluginInfos;
|
this.pluginInfos = pluginInfos;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.locals.controller = this;
|
this.app.locals.controller = this;
|
||||||
this.app.locals.streams = new Map();
|
this.app.locals.streams = new Map();
|
||||||
@@ -435,7 +436,18 @@ export default class Controller {
|
|||||||
this.app.locals.staticRoot = this.config.get("controller.static_url");
|
this.app.locals.staticRoot = this.config.get("controller.static_url");
|
||||||
|
|
||||||
// Load plugins
|
// 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.loadPlugins();
|
||||||
|
await this.playerProxyServer?.start();
|
||||||
|
|
||||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
// 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 => {
|
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||||
@@ -592,6 +604,11 @@ export default class Controller {
|
|||||||
|
|
||||||
await this.hooks.shutdown.invoke();
|
await this.hooks.shutdown.invoke();
|
||||||
|
|
||||||
|
if (this.playerProxyServer) {
|
||||||
|
await this.playerProxyServer.stop();
|
||||||
|
this.playerProxyServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.wsServer.stop();
|
await this.wsServer.stop();
|
||||||
|
|
||||||
let stopTasks = [];
|
let stopTasks = [];
|
||||||
@@ -1027,6 +1044,94 @@ export default class Controller {
|
|||||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
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
|
* Removes all saves currently stored for the given instance, if any
|
||||||
* @param instanceId - Id of Instance to clear saves for.
|
* @param instanceId - Id of Instance to clear saves for.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ if (process.platform === "win32") {
|
|||||||
|
|
||||||
const availablePlugins = [
|
const availablePlugins = [
|
||||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||||
|
{ name: "Lobby", value: "@clusterio/plugin-lobby" },
|
||||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||||
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
||||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from ".
|
|||||||
export { default as Host, HostRouter } from "./src/Host.js";
|
export { default as Host, HostRouter } from "./src/Host.js";
|
||||||
export { default as Instance } from "./src/Instance.js";
|
export { default as Instance } from "./src/Instance.js";
|
||||||
export { default as InstanceConnection } from "./src/InstanceConnection.js";
|
export { default as InstanceConnection } from "./src/InstanceConnection.js";
|
||||||
|
export { setSaveScenario } from "./src/patch.js";
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
|||||||
this.prepareControllerDisconnect = this.newHook();
|
this.prepareControllerDisconnect = this.newHook();
|
||||||
this.playerEvent = this.newHook();
|
this.playerEvent = this.newHook();
|
||||||
this.autoPauseChanged = 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>;
|
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
|
* Called after the Factorio server is started
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { logger } from "@clusterio/lib";
|
|||||||
import type { HostConnector } from "../host.js";
|
import type { HostConnector } from "../host.js";
|
||||||
import Instance from "./Instance.js";
|
import Instance from "./Instance.js";
|
||||||
import InstanceConnection from "./InstanceConnection.js";
|
import InstanceConnection from "./InstanceConnection.js";
|
||||||
|
import { listFactorioVersions } from "./server.js";
|
||||||
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.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.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.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.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
|
||||||
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
||||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||||
@@ -1020,6 +1022,23 @@ export default class Host extends lib.Link {
|
|||||||
return null;
|
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() {
|
async handleHostMetricsRequest() {
|
||||||
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
|
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
|
||||||
for (let instanceConnection of this.instanceConnections.values()) {
|
for (let instanceConnection of this.instanceConnections.values()) {
|
||||||
|
|||||||
@@ -1045,7 +1045,16 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
* @returns Name of the save prepared.
|
* @returns Name of the save prepared.
|
||||||
*/
|
*/
|
||||||
async prepareSave(saveName?: string) {
|
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) {
|
if (saveName === undefined) {
|
||||||
saveName = await lib.getNewestFile(
|
saveName = await lib.getNewestFile(
|
||||||
this.path("saves"), (name) => !name.endsWith(".tmp.zip")
|
this.path("saves"), (name) => !name.endsWith(".tmp.zip")
|
||||||
|
|||||||
+77
-27
@@ -465,37 +465,87 @@ export async function patch(savePath: string, modules: SaveModule[]) {
|
|||||||
|
|
||||||
// Write back the save, keeping the order of the entries in the original
|
// Write back the save, keeping the order of the entries in the original
|
||||||
const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
||||||
const output = new lib.ZipWriter();
|
tempSavePath = await writeSaveCopy(zip, savePath, added, removed);
|
||||||
let writeStream;
|
} finally {
|
||||||
[tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
zip.close();
|
||||||
const writing = pipeline(output.outputStream, writeStream);
|
}
|
||||||
// Awaited below, but a write error can reject it before then.
|
await fs.rename(tempSavePath, savePath);
|
||||||
writing.catch(() => {});
|
}
|
||||||
try {
|
|
||||||
for (const entry of zip.entries.values()) {
|
/**
|
||||||
const name = entry.fileName;
|
* Write a copy of a save with entries replaced, added and removed
|
||||||
if (removed.has(name)) {
|
*
|
||||||
continue;
|
* The copy is written next to the save, and must be renamed over it after
|
||||||
}
|
* the save has been closed.
|
||||||
const contents = added.get(name);
|
*
|
||||||
if (contents !== undefined) {
|
* @param zip - Opened save to copy.
|
||||||
await output.addBuffer(contents, name);
|
* @param savePath - Path the save was opened from.
|
||||||
added.delete(name);
|
* @param added - Entries by full path in the zip to add or replace.
|
||||||
} else {
|
* @param removed - Full paths of entries to leave out.
|
||||||
await output.addEntry(zip, entry);
|
* @returns path to the copy written.
|
||||||
}
|
*/
|
||||||
|
async function writeSaveCopy(
|
||||||
|
zip: lib.ZipArchive,
|
||||||
|
savePath: string,
|
||||||
|
added: Map<string, Buffer>,
|
||||||
|
removed = new Set<string>(),
|
||||||
|
) {
|
||||||
|
const output = new lib.ZipWriter();
|
||||||
|
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
||||||
|
const writing = pipeline(output.outputStream, writeStream);
|
||||||
|
// Awaited below, but a write error can reject it before then.
|
||||||
|
writing.catch(() => {});
|
||||||
|
try {
|
||||||
|
for (const entry of zip.entries.values()) {
|
||||||
|
const name = entry.fileName;
|
||||||
|
if (removed.has(name)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
for (const [name, contents] of added) {
|
const contents = added.get(name);
|
||||||
|
if (contents !== undefined) {
|
||||||
await output.addBuffer(contents, name);
|
await output.addBuffer(contents, name);
|
||||||
|
added.delete(name);
|
||||||
|
} else {
|
||||||
|
await output.addEntry(zip, entry);
|
||||||
}
|
}
|
||||||
await output.end();
|
|
||||||
await writing;
|
|
||||||
} catch (err) {
|
|
||||||
output.outputStream.destroy();
|
|
||||||
await writing.catch(() => {});
|
|
||||||
await fs.rm(tempSavePath, { force: true });
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
for (const [name, contents] of added) {
|
||||||
|
await output.addBuffer(contents, name);
|
||||||
|
}
|
||||||
|
await output.end();
|
||||||
|
await writing;
|
||||||
|
} catch (err) {
|
||||||
|
output.outputStream.destroy();
|
||||||
|
await writing.catch(() => {});
|
||||||
|
await fs.rm(tempSavePath, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return tempSavePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the scenario code the save patcher keeps loading in a 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 save to change.
|
||||||
|
* @param scenario - Scenario modules to load in place of the original scenario.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
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 {
|
} finally {
|
||||||
zip.close();
|
zip.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
|||||||
* @param factorioDir - Path to Factorio installation dirs.
|
* @param factorioDir - Path to Factorio installation dirs.
|
||||||
* @returns Set of installed factorio versions
|
* @returns Set of installed factorio versions
|
||||||
*/
|
*/
|
||||||
async function listFactorioVersions(factorioDir: string) {
|
export async function listFactorioVersions(factorioDir: string) {
|
||||||
const versions = new Set<lib.FullVersion>();
|
const versions = new Set<lib.FullVersion>();
|
||||||
|
|
||||||
const directVersion = await getFactorioVersion(factorioDir);
|
const directVersion = await getFactorioVersion(factorioDir);
|
||||||
|
|||||||
@@ -153,9 +153,10 @@ export class AsyncHookCollection {
|
|||||||
Args extends unknown[],
|
Args extends unknown[],
|
||||||
Result = void,
|
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);
|
this.hooks.push(hook);
|
||||||
return hook;
|
return hook;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface ControllerConfigFields {
|
|||||||
"controller.max_subscription_filters": number;
|
"controller.max_subscription_filters": number;
|
||||||
"controller.system_metrics_interval": number;
|
"controller.system_metrics_interval": number;
|
||||||
"controller.proxy_stream_timeout": 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_username": string | null;
|
||||||
"controller.factorio_token": string | null;
|
"controller.factorio_token": string | null;
|
||||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||||
@@ -243,6 +246,34 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
|||||||
initialValue: 15,
|
initialValue: 15,
|
||||||
validator: validators.greaterThanZero,
|
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": {
|
"controller.factorio_username": {
|
||||||
title: "Factorio Username",
|
title: "Factorio Username",
|
||||||
description: "Username to authenticate with Factorio API with.",
|
description: "Username to authenticate with Factorio API with.",
|
||||||
@@ -413,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 " +
|
"Range of UDP ports to use for game connections. Supports both comma separated values and " +
|
||||||
"ranges separated with a dash.",
|
"ranges separated with a dash.",
|
||||||
type: "string",
|
type: "string",
|
||||||
initialValue: "34100-34199",
|
initialValue: "34200-34299",
|
||||||
// TODO extendedValidation, port range
|
// TODO extendedValidation, port range
|
||||||
},
|
},
|
||||||
"host.factorio_username": {
|
"host.factorio_username": {
|
||||||
@@ -471,6 +502,7 @@ export interface InstanceConfigFields {
|
|||||||
"instance.id": number;
|
"instance.id": number;
|
||||||
"instance.assigned_host": number | null;
|
"instance.assigned_host": number | null;
|
||||||
"instance.auto_start": boolean;
|
"instance.auto_start": boolean;
|
||||||
|
"instance.player_proxy_port": number | null;
|
||||||
"instance.exclude_from_start_all": boolean;
|
"instance.exclude_from_start_all": boolean;
|
||||||
[key: `${string}.load_plugin`]: boolean;
|
[key: `${string}.load_plugin`]: boolean;
|
||||||
[key: `${string}.version`]: string;
|
[key: `${string}.version`]: string;
|
||||||
@@ -545,6 +577,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
initialValue: false,
|
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": {
|
"instance.exclude_from_start_all": {
|
||||||
description: "Exclude this instance from the 'Start all' button operation",
|
description: "Exclude this instance from the 'Start all' button operation",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ export class HostUpdateRequest {
|
|||||||
static permission = "core.host.update" as const;
|
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 {
|
export class HostConfigGetRequest {
|
||||||
declare ["constructor"]: typeof HostConfigGetRequest;
|
declare ["constructor"]: typeof HostConfigGetRequest;
|
||||||
static type = "request" as const;
|
static type = "request" as const;
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function readMapPosition(state: MapReaderState) {
|
|||||||
y = state.last_position.y + y_diff;
|
y = state.last_position.y + y_diff;
|
||||||
}
|
}
|
||||||
state.last_position.x = x;
|
state.last_position.x = x;
|
||||||
state.last_position.x = y;
|
state.last_position.y = y;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
|||||||
host.HostInfoUpdateEvent,
|
host.HostInfoUpdateEvent,
|
||||||
host.HostUpdatesEvent,
|
host.HostUpdatesEvent,
|
||||||
host.HostMetricsRequest,
|
host.HostMetricsRequest,
|
||||||
|
host.HostFactorioVersionsRequest,
|
||||||
host.ControllerConnectionEvent,
|
host.ControllerConnectionEvent,
|
||||||
host.PrepareControllerDisconnectRequest,
|
host.PrepareControllerDisconnectRequest,
|
||||||
host.SyncUserListsEvent,
|
host.SyncUserListsEvent,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
vite.config.js
|
||||||
|
dist/browser
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Clusterio Lobby Plugin
|
||||||
|
|
||||||
|
Lobby servers where players pick the instance to join, and listing of the cluster in the Factorio server browser.
|
||||||
|
Works together with the [player proxy](../../docs/player-proxy.md) on the controller.
|
||||||
|
|
||||||
|
- Players connecting to the player proxy are sent to a lobby instance running their Factorio version.
|
||||||
|
The lobby shows a list of the instances in the cluster running that version, picking one moves the player there with `LuaPlayer.connect_to_server`.
|
||||||
|
Instances on other versions can be shown greyed out with a checkbox.
|
||||||
|
- If there's no lobby for the version and built in mods of a player, one is created on a host that has that version of Factorio installed, or can download it.
|
||||||
|
- Picking a stopped instance starts it, the player waits in the lobby and is moved once it's running.
|
||||||
|
- Other instances with the plugin loaded get a Servers button that opens the same list.
|
||||||
|
- If the [Player Auth](../player_auth/README.md) plugin is loaded the list has a button for logging into the web interface, which does the same as the `/web-login` command.
|
||||||
|
- The cluster can be listed in the Factorio server browser as one game per Factorio version a lobby runs, showing the players online in the whole cluster.
|
||||||
|
- Instances can also be listed in the server browser as their own game on a dedicated proxy port, including while they are stopped.
|
||||||
|
|
||||||
|
The [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) community plugin provides a similar in-game server list without the lobby, proxy and server browser parts.
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Run the following commands in the folder Clusterio is installed to:
|
||||||
|
|
||||||
|
npm install @clusterio/plugin-lobby
|
||||||
|
npx clusteriocontroller plugin add @clusterio/plugin-lobby
|
||||||
|
|
||||||
|
Substitute clusteriocontroller with clusteriohost or clusterioctl if this a dedicate host or ctl installation respectively.
|
||||||
|
|
||||||
|
|
||||||
|
## Setting up a lobby
|
||||||
|
|
||||||
|
Lobbies are created automatically the first time a player connects with a Factorio version and set of built in mods (base, Space Age and so on) no lobby runs.
|
||||||
|
The lobby is created on a connected host that has the version installed, or a Linux host with a versioned `host.factorio_directory` that can download it, with a mod pack that has the same built in mods as the player.
|
||||||
|
It's named after the version, has `instance.auto_start` set and is kept running.
|
||||||
|
Players with mods that are not built in are asked by Factorio to sync their mods to the lobby, and again to the instance they pick.
|
||||||
|
On a fast host the lobby is up before the Factorio client gives up on the connection, otherwise the first player has to connect again once it's running.
|
||||||
|
Set `lobby.auto_create` to false to only use lobbies you create yourself:
|
||||||
|
|
||||||
|
1. Make sure the player proxy is enabled, it listens on `controller.player_proxy_port` which defaults to 34197.
|
||||||
|
2. Set `controller.player_proxy_public_address` to the address and port players reach the proxy on, for example `play.example.com:34197`.
|
||||||
|
This is the address players are sent to when they pick an instance, and what's published in the server browser.
|
||||||
|
If it's not set the host name of `controller.public_url` and `controller.player_proxy_port` is used.
|
||||||
|
3. Create an instance to act as the lobby, set `lobby.is_lobby` to true on it and start it.
|
||||||
|
Lobbies always start the save `lobby.zip`, which is created the first time as an empty one tile map without the freeplay scenario, and players are made spectators in it.
|
||||||
|
To use a different map, replace `lobby.zip` with a save of your own.
|
||||||
|
Starting a specific save from the web interface or clusterioctl still works.
|
||||||
|
Create one lobby for each Factorio version you want to accept players on, by setting `factorio.version` on it.
|
||||||
|
4. Optionally set `lobby.listed` to false on instances that should not be shown in the list.
|
||||||
|
|
||||||
|
Players who connect to the proxy now end up in the lobby for their version.
|
||||||
|
If there's no lobby running their version they are routed as if the plugin wasn't loaded.
|
||||||
|
|
||||||
|
|
||||||
|
## Server browser listing
|
||||||
|
|
||||||
|
Set `lobby.server_browser_listing` to true to list the cluster in the Factorio server browser.
|
||||||
|
This needs `controller.factorio_username` and `controller.factorio_token` to be set, and a lobby instance to be running so its exact version and build can be listed.
|
||||||
|
Factorio only shows games running the exact same version and build as the client.
|
||||||
|
|
||||||
|
To list an instance as its own game, set `instance.player_proxy_port` to a port the proxy should listen on for it and `lobby.server_browser_listing` on the instance to true.
|
||||||
|
The instance stays listed when it's stopped, as long as it has been started once while the controller was running so its version is known.
|
||||||
|
Players joining it while it's stopped wait in a lobby running their version until the instance has started.
|
||||||
|
|
||||||
|
|
||||||
|
## Controller Configuration
|
||||||
|
|
||||||
|
### lobby.auto_create
|
||||||
|
|
||||||
|
Create a lobby when a player connects to the player proxy with a Factorio version and built in mods no lobby runs.
|
||||||
|
|
||||||
|
Defaults to true.
|
||||||
|
|
||||||
|
### lobby.server_browser_listing
|
||||||
|
|
||||||
|
List the cluster in the Factorio server browser as one game for each Factorio version a lobby is running.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
|
### lobby.server_browser_name
|
||||||
|
|
||||||
|
Name of the cluster in the server browser, uses `controller.name` if null.
|
||||||
|
|
||||||
|
Defaults to null.
|
||||||
|
|
||||||
|
### lobby.server_browser_description
|
||||||
|
|
||||||
|
Description of the cluster in the server browser.
|
||||||
|
|
||||||
|
Defaults to an empty string.
|
||||||
|
|
||||||
|
### lobby.server_browser_tags
|
||||||
|
|
||||||
|
Comma separated tags shown for the games listed in the server browser.
|
||||||
|
|
||||||
|
Defaults to "clusterio".
|
||||||
|
|
||||||
|
### lobby.server_browser_max_players
|
||||||
|
|
||||||
|
Max players shown for the games listed in the server browser, 0 for no limit.
|
||||||
|
This is only shown, it does not limit how many players can join.
|
||||||
|
|
||||||
|
Defaults to 0.
|
||||||
|
|
||||||
|
|
||||||
|
## Instance Configuration
|
||||||
|
|
||||||
|
### lobby.is_lobby
|
||||||
|
|
||||||
|
Players connecting to the player proxy are sent to a running lobby with their Factorio version, where they pick which instance to join.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
|
### lobby.listed
|
||||||
|
|
||||||
|
Show this instance in the server list of lobbies.
|
||||||
|
|
||||||
|
Defaults to true.
|
||||||
|
|
||||||
|
### lobby.server_browser_listing
|
||||||
|
|
||||||
|
List this instance as its own game in the Factorio server browser, also while it's stopped.
|
||||||
|
Requires `instance.player_proxy_port` to be set.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import dns from "node:dns/promises";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
import type {
|
||||||
|
Controller, ControllerPluginContext, InstanceRecord, PlayerProxyRouteRequest, proxyProtocol,
|
||||||
|
} from "@clusterio/controller";
|
||||||
|
|
||||||
|
import { Listing, MatchingServerClient } from "./matching_server.js";
|
||||||
|
import {
|
||||||
|
LobbyServer, ServerListRequest, TransferRequest, TransferState, TransferStatus, TransferStatusEvent,
|
||||||
|
} from "./messages.js";
|
||||||
|
|
||||||
|
/** How long a player has to connect to an instance once it's ready */
|
||||||
|
const TRANSFER_READY_TIMEOUT_MS = 120_000;
|
||||||
|
/** How long an instance has to start for a transfer to it */
|
||||||
|
const TRANSFER_START_TIMEOUT_MS = 600_000;
|
||||||
|
const LISTING_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
type ApplicationVersion = proxyProtocol.ApplicationVersion;
|
||||||
|
|
||||||
|
export interface Transfer {
|
||||||
|
player: string;
|
||||||
|
instanceId: number;
|
||||||
|
state: TransferState;
|
||||||
|
/** Lobby the player is waiting in */
|
||||||
|
lobbyId?: number;
|
||||||
|
expiresAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVersion(version: ApplicationVersion) {
|
||||||
|
return `${version.major}.${version.minor}.${version.sub}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split host:port, supporting bracketed IPv6 addresses */
|
||||||
|
export function parseAddress(address: string): { host: string, port?: number } {
|
||||||
|
const match = /^(?:\[([^\]]+)\]|([^:]+))(?::(\d+))?$/.exec(address.trim());
|
||||||
|
if (!match) {
|
||||||
|
return { host: address.trim() };
|
||||||
|
}
|
||||||
|
return { host: match[1] ?? match[2], port: match[3] !== undefined ? Number(match[3]) : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LobbyController {
|
||||||
|
/** Transfers in progress by lowercase player name */
|
||||||
|
transfers = new Map<string, Transfer>();
|
||||||
|
private _listingTimer?: ReturnType<typeof setInterval>;
|
||||||
|
private _listingWarning?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public controller: Controller,
|
||||||
|
public logger: lib.Logger,
|
||||||
|
public matchingServer: MatchingServerClient,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
get proxy() {
|
||||||
|
return this.controller.playerProxyServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isLobby(instance: Readonly<InstanceRecord>) {
|
||||||
|
return Boolean(instance.config.get("lobby.load_plugin") && instance.config.get("lobby.is_lobby"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static isRoutable(instance: Readonly<InstanceRecord> | undefined): instance is Readonly<InstanceRecord> {
|
||||||
|
return Boolean(instance && !instance.isDeleted && instance.config.get("instance.assigned_host") !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factorio version an instance runs, or ran last time it was seen
|
||||||
|
*
|
||||||
|
* @param instance - Instance to check.
|
||||||
|
* @returns version as major.minor.sub or null if unknown.
|
||||||
|
*/
|
||||||
|
instanceVersion(instance: Readonly<InstanceRecord>) {
|
||||||
|
const seen = this.proxy?.serverVersions.get(instance.id);
|
||||||
|
if (instance.status === "running" && instance.factorioVersion) {
|
||||||
|
return instance.factorioVersion;
|
||||||
|
}
|
||||||
|
if (seen) {
|
||||||
|
return formatVersion(seen);
|
||||||
|
}
|
||||||
|
const configured = instance.factorioVersion ?? instance.config.get("factorio.version");
|
||||||
|
return /^\d+\.\d+\.\d+$/.test(configured) ? configured : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
playersOnline(instanceId?: number) {
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const user of this.controller.users.records.values()) {
|
||||||
|
if (instanceId === undefined ? user.instances.size : user.instances.has(instanceId)) {
|
||||||
|
names.push(user.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
serverList(): LobbyServer[] {
|
||||||
|
const servers: LobbyServer[] = [];
|
||||||
|
for (const instance of this.controller.instances.values()) {
|
||||||
|
if (
|
||||||
|
!LobbyController.isRoutable(instance)
|
||||||
|
|| LobbyController.isLobby(instance)
|
||||||
|
|| !instance.config.get("lobby.listed")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
servers.push(new LobbyServer(
|
||||||
|
instance.id,
|
||||||
|
instance.config.get("instance.name"),
|
||||||
|
instance.status,
|
||||||
|
this.instanceVersion(instance),
|
||||||
|
this.playersOnline(instance.id).length,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return servers.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built in mods besides base enabled in a mod list, as a comparable key
|
||||||
|
*
|
||||||
|
* @param version - Factorio version the mods are for.
|
||||||
|
* @param mods - Mods to check.
|
||||||
|
* @returns sorted comma separated names.
|
||||||
|
*/
|
||||||
|
static builtinModsKey(version: string, mods: Iterable<{ name: string, enabled?: boolean }>) {
|
||||||
|
const builtin = new Set(lib.ModPack.getBuiltinModNames(version as lib.PartialVersion));
|
||||||
|
builtin.delete("base");
|
||||||
|
return [...mods]
|
||||||
|
.filter(mod => mod.enabled !== false && builtin.has(mod.name))
|
||||||
|
.map(mod => mod.name)
|
||||||
|
.sort()
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mod pack an instance runs with */
|
||||||
|
instanceModPack(instance: Readonly<InstanceRecord>) {
|
||||||
|
const id = instance.config.get("factorio.mod_pack_id")
|
||||||
|
?? this.controller.config.get("controller.default_mod_pack_id");
|
||||||
|
return id === null ? undefined : this.controller.modPacks.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a lobby running the given Factorio version
|
||||||
|
*
|
||||||
|
* @param version - Version of the client.
|
||||||
|
* @param mods -
|
||||||
|
* Mods enabled in the client, if known the lobby must have the same
|
||||||
|
* built in mods enabled for the client to be able to join it.
|
||||||
|
* @returns a running lobby if there is one, otherwise a stopped one configured for this version.
|
||||||
|
*/
|
||||||
|
pickLobby(version: ApplicationVersion, mods?: proxyProtocol.ModId[]) {
|
||||||
|
const wanted = formatVersion(version);
|
||||||
|
const wantedMods = mods ? LobbyController.builtinModsKey(wanted, mods) : undefined;
|
||||||
|
let stopped: Readonly<InstanceRecord> | undefined;
|
||||||
|
for (const instance of this.controller.instances.values()) {
|
||||||
|
if (!LobbyController.isRoutable(instance) || !LobbyController.isLobby(instance)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seen = this.proxy?.serverVersions.get(instance.id);
|
||||||
|
if (seen && formatVersion(seen) === wanted && seen.build !== version.build) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wantedMods !== undefined) {
|
||||||
|
const modPack = this.instanceModPack(instance);
|
||||||
|
if (modPack && LobbyController.builtinModsKey(wanted, modPack.mods.values()) !== wantedMods) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (instance.status === "running" && instance.factorioVersion === wanted) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
if (!stopped && instance.status !== "running" && this.instanceVersion(instance) === wanted) {
|
||||||
|
stopped = instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _hostVersions = new Map<number, { versions: Set<string>, canDownload: boolean, fetchedAtMs: number }>();
|
||||||
|
|
||||||
|
/** Factorio versions installed on a host, cached for a minute */
|
||||||
|
async hostVersions(hostId: number) {
|
||||||
|
const cached = this._hostVersions.get(hostId);
|
||||||
|
if (cached && cached.fetchedAtMs > Date.now() - 60_000) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const response: { versions: string[], canDownload: boolean } = await this.controller.sendTo(
|
||||||
|
{ hostId }, new lib.HostFactorioVersionsRequest()
|
||||||
|
);
|
||||||
|
const result = {
|
||||||
|
versions: new Set(response.versions),
|
||||||
|
canDownload: response.canDownload,
|
||||||
|
fetchedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
this._hostVersions.set(hostId, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a connected host that can run the given Factorio version
|
||||||
|
*
|
||||||
|
* @param version - Full Factorio version.
|
||||||
|
* @returns ID of a host with the version installed, or one that can download it.
|
||||||
|
*/
|
||||||
|
async pickHost(version: string) {
|
||||||
|
let downloadHost: number | undefined;
|
||||||
|
for (const host of this.controller.hosts.values()) {
|
||||||
|
if (!host.connected || host.isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let versions;
|
||||||
|
try {
|
||||||
|
versions = await this.hostVersions(host.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.verbose(`Unable to get Factorio versions of host ${host.id}: ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (versions.versions.has(version)) {
|
||||||
|
return host.id;
|
||||||
|
}
|
||||||
|
if (versions.canDownload && downloadHost === undefined) {
|
||||||
|
downloadHost = host.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (downloadHost !== undefined) {
|
||||||
|
const releases = await this.controller.factorioVersions.get().catch(() => []);
|
||||||
|
if (releases.some(release => release.version === version)) {
|
||||||
|
return downloadHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a mod pack for the version with the given built in mods, creating one if needed
|
||||||
|
*
|
||||||
|
* @param version - Full Factorio version.
|
||||||
|
* @param modsKey - Built in mods as given by {@link LobbyController.builtinModsKey}.
|
||||||
|
* @returns the mod pack.
|
||||||
|
*/
|
||||||
|
lobbyModPack(version: string, modsKey: string) {
|
||||||
|
const [major, minor] = version.split(".");
|
||||||
|
const majorMinor = `${major}.${minor}`;
|
||||||
|
const wanted = new Set(modsKey ? modsKey.split(",") : []);
|
||||||
|
const matches = (pack: Readonly<lib.ModPack>) => (
|
||||||
|
(pack.factorioVersion === version || pack.factorioVersion === majorMinor)
|
||||||
|
&& LobbyController.builtinModsKey(version, pack.mods.values()) === modsKey
|
||||||
|
// Lobbies get only built in mods, players with other mods are asked by Factorio to sync to them
|
||||||
|
&& [...pack.mods.values()].every(mod => !mod.enabled || mod.name === "base" || wanted.has(mod.name))
|
||||||
|
);
|
||||||
|
for (const pack of this.controller.modPacks.values()) {
|
||||||
|
if (!pack.isDeleted && matches(pack)) {
|
||||||
|
return pack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const builtin = lib.ModPack.getBuiltinMods(majorMinor as lib.PartialVersion)
|
||||||
|
.filter(mod => mod.name !== "base");
|
||||||
|
const pack = lib.ModPack.fromJSON({
|
||||||
|
name: `Lobby ${majorMinor}${wanted.size ? ` (${[...wanted].join(", ")})` : ""}`,
|
||||||
|
description: "Created by the lobby plugin for lobbies of players with these built in mods.",
|
||||||
|
factorio_version: majorMinor,
|
||||||
|
mods: builtin.map(mod => ({ ...mod, enabled: wanted.has(mod.name) })),
|
||||||
|
} as any);
|
||||||
|
this.controller.modPacks.set(pack);
|
||||||
|
this.logger.info(`Created mod pack ${pack.name} for lobbies`);
|
||||||
|
return pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _creatingLobbies = new Map<string, Promise<number | undefined>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a lobby for a Factorio version and set of built in mods
|
||||||
|
*
|
||||||
|
* Concurrent calls for the same version and mods share one lobby.
|
||||||
|
*
|
||||||
|
* @param version - Version of the client.
|
||||||
|
* @param mods - Mods enabled in the client.
|
||||||
|
* @returns ID of the lobby created, or undefined if no host can run the version.
|
||||||
|
*/
|
||||||
|
async createLobby(version: ApplicationVersion, mods?: proxyProtocol.ModId[]) {
|
||||||
|
const full = formatVersion(version);
|
||||||
|
// Without the mod list assume all built in mods, as the expansion is the most common setup
|
||||||
|
const modsKey = LobbyController.builtinModsKey(
|
||||||
|
full, mods ?? lib.ModPack.getBuiltinMods(full as lib.PartialVersion).map(mod => ({ name: mod.name })),
|
||||||
|
);
|
||||||
|
const key = `${full}|${modsKey}`;
|
||||||
|
let creating = this._creatingLobbies.get(key);
|
||||||
|
if (!creating) {
|
||||||
|
creating = this._createLobby(full, modsKey).finally(() => this._creatingLobbies.delete(key));
|
||||||
|
this._creatingLobbies.set(key, creating);
|
||||||
|
}
|
||||||
|
return await creating;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createLobby(version: string, modsKey: string) {
|
||||||
|
const hostId = await this.pickHost(version);
|
||||||
|
if (hostId === undefined) {
|
||||||
|
this.logger.verbose(`No host can run Factorio ${version}, not creating a lobby for it`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const modPack = this.lobbyModPack(version, modsKey);
|
||||||
|
const names = new Set([...this.controller.instances.values()].map(i => i.config.get("instance.name")));
|
||||||
|
const base = modsKey.includes("space-age") ? `Lobby ${version}` : `Lobby ${version} (${modsKey || "base"})`;
|
||||||
|
let name = base;
|
||||||
|
for (let i = 2; names.has(name); i++) {
|
||||||
|
name = `${base} ${i}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = new lib.InstanceConfig("controller", {
|
||||||
|
"instance.name": name,
|
||||||
|
"instance.auto_start": true,
|
||||||
|
"factorio.version": version,
|
||||||
|
"factorio.mod_pack_id": modPack.id,
|
||||||
|
// The cluster is listed in the server browser by this plugin, not each lobby
|
||||||
|
"factorio.settings": { visibility: { public: false, lan: false } },
|
||||||
|
"lobby.is_lobby": true,
|
||||||
|
});
|
||||||
|
const instance = await this.controller.instances.createInstance(config);
|
||||||
|
await this.controller.instances.assignInstance(instance.id, hostId);
|
||||||
|
this.logger.info(`Created lobby ${name} on host ${hostId} for players with Factorio ${version}`);
|
||||||
|
return instance.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address players use to connect to an instance, through the proxy if it's enabled */
|
||||||
|
connectAddress(instance: Readonly<InstanceRecord>) {
|
||||||
|
if (this.proxy) {
|
||||||
|
return this.controller.getPlayerProxyAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostId = instance.config.get("instance.assigned_host");
|
||||||
|
const host = hostId !== null ? this.controller.hosts.get(hostId) : undefined;
|
||||||
|
const address = host?.publicAddress || host?.remoteAddress;
|
||||||
|
if (!address || !instance.gamePort) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return `${address}:${instance.gamePort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getTransfer(player: string) {
|
||||||
|
const key = player.toLowerCase();
|
||||||
|
const transfer = this.transfers.get(key);
|
||||||
|
if (transfer && transfer.expiresAtMs < Date.now()) {
|
||||||
|
this.transfers.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _status(transfer: Transfer, message?: string) {
|
||||||
|
const instance = this.controller.instances.get(transfer.instanceId);
|
||||||
|
const name = instance?.config.get("instance.name") ?? String(transfer.instanceId);
|
||||||
|
const address = transfer.state === "ready" && instance ? this.connectAddress(instance) : undefined;
|
||||||
|
if (transfer.state === "ready" && !address) {
|
||||||
|
return new TransferStatus("failed", name, undefined, "The address to connect to is not configured");
|
||||||
|
}
|
||||||
|
return new TransferStatus(transfer.state, name, address, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _notify(transfer: Transfer, message?: string) {
|
||||||
|
if (transfer.lobbyId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lobby = this.controller.instances.get(transfer.lobbyId);
|
||||||
|
if (lobby?.status !== "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.controller.sendTo(
|
||||||
|
{ instanceId: transfer.lobbyId },
|
||||||
|
new TransferStatusEvent(transfer.player, transfer.instanceId, this._status(transfer, message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start sending a player to an instance
|
||||||
|
*
|
||||||
|
* @param player - Name of the player.
|
||||||
|
* @param instanceId - Instance to send the player to.
|
||||||
|
* @param lobbyId - Lobby the player is waiting in.
|
||||||
|
* @returns the transfer created.
|
||||||
|
*/
|
||||||
|
startTransfer(player: string, instanceId: number, lobbyId?: number) {
|
||||||
|
const instance = this.controller.instances.get(instanceId);
|
||||||
|
if (!LobbyController.isRoutable(instance)) {
|
||||||
|
throw new lib.UnavailableError(`Instance ${instanceId} is not available`);
|
||||||
|
}
|
||||||
|
const ready = instance.status === "running";
|
||||||
|
const transfer: Transfer = {
|
||||||
|
player,
|
||||||
|
instanceId,
|
||||||
|
state: ready ? "ready" : "starting",
|
||||||
|
lobbyId,
|
||||||
|
expiresAtMs: Date.now() + (ready ? TRANSFER_READY_TIMEOUT_MS : TRANSFER_START_TIMEOUT_MS),
|
||||||
|
};
|
||||||
|
this.transfers.set(player.toLowerCase(), transfer);
|
||||||
|
|
||||||
|
if (instance.status === "stopped") {
|
||||||
|
this.logger.info(`Starting instance ${instanceId} for ${player}`);
|
||||||
|
(async () => {
|
||||||
|
await this.controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
|
||||||
|
})().catch(err => {
|
||||||
|
this._failTransfers(instanceId, `Failed to start: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _failTransfers(instanceId: number, message: string) {
|
||||||
|
for (const [key, transfer] of this.transfers) {
|
||||||
|
if (transfer.instanceId === instanceId && transfer.state === "starting") {
|
||||||
|
transfer.state = "failed";
|
||||||
|
this._notify(transfer, message);
|
||||||
|
this.transfers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleServerListRequest(): Promise<LobbyServer[]> {
|
||||||
|
return this.serverList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleTransferRequest(request: TransferRequest, src: lib.Address): Promise<TransferStatus> {
|
||||||
|
const lobbyId = src.type === lib.Address.instance ? src.id : undefined;
|
||||||
|
return this._status(this.startTransfer(request.player, request.instanceId, lobbyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onInstanceStatusChanged(instance: InstanceRecord) {
|
||||||
|
if (instance.status === "running") {
|
||||||
|
for (const transfer of this.transfers.values()) {
|
||||||
|
if (transfer.instanceId === instance.id && transfer.state === "starting") {
|
||||||
|
transfer.state = "ready";
|
||||||
|
transfer.expiresAtMs = Date.now() + TRANSFER_READY_TIMEOUT_MS;
|
||||||
|
this._notify(transfer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (instance.status === "stopped" || instance.isDeleted) {
|
||||||
|
this._failTransfers(instance.id, "The server stopped while starting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
|
||||||
|
if (event.type !== "join") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transfer = this._getTransfer(event.name);
|
||||||
|
if (!transfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (transfer.instanceId === instance.id) {
|
||||||
|
this.transfers.delete(event.name.toLowerCase());
|
||||||
|
} else if (LobbyController.isLobby(instance)) {
|
||||||
|
// Player is waiting in this lobby for the instance to start
|
||||||
|
transfer.lobbyId = instance.id;
|
||||||
|
this._notify(transfer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPlayerProxyRoute(request: PlayerProxyRouteRequest): Promise<number | undefined> {
|
||||||
|
const transfer = this._getTransfer(request.username);
|
||||||
|
if (transfer?.state === "ready") {
|
||||||
|
return transfer.instanceId;
|
||||||
|
}
|
||||||
|
if (transfer?.state === "starting") {
|
||||||
|
return transfer.lobbyId ?? await this.lobbyFor(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.portInstanceId !== undefined) {
|
||||||
|
const target = this.controller.instances.get(request.portInstanceId);
|
||||||
|
if (!LobbyController.isRoutable(target) || target.status === "running") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Starting can take longer than the client waits, so let the player wait in a lobby
|
||||||
|
const lobbyId = await this.lobbyFor(request);
|
||||||
|
if (lobbyId === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.startTransfer(request.username, target.id, lobbyId);
|
||||||
|
return lobbyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.lobbyFor(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lobby for the version and mods of a player, created if needed and lobby.auto_create is enabled */
|
||||||
|
async lobbyFor(request: PlayerProxyRouteRequest) {
|
||||||
|
const lobby = this.pickLobby(request.version, request.mods);
|
||||||
|
if (lobby) {
|
||||||
|
return lobby.id;
|
||||||
|
}
|
||||||
|
if (!this.controller.config.get("lobby.auto_create")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return await this.createLobby(request.version, request.mods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the server browser listings that should exist */
|
||||||
|
async desiredListings() {
|
||||||
|
const listings = new Map<string, Listing>();
|
||||||
|
const proxy = this.proxy;
|
||||||
|
if (!proxy) {
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterListing = this.controller.config.get("lobby.server_browser_listing");
|
||||||
|
const instanceListings = [...this.controller.instances.values()].filter(
|
||||||
|
instance => LobbyController.isRoutable(instance)
|
||||||
|
&& instance.config.get("lobby.load_plugin")
|
||||||
|
&& instance.config.get("lobby.server_browser_listing")
|
||||||
|
&& instance.config.get("instance.player_proxy_port") !== null
|
||||||
|
);
|
||||||
|
if (!clusterListing && !instanceListings.length) {
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.controller.getPlayerProxyAddress();
|
||||||
|
if (!address) {
|
||||||
|
this._warnListing(
|
||||||
|
"controller.player_proxy_public_address or controller.public_url must be set to list servers"
|
||||||
|
);
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
const { host, port } = parseAddress(address);
|
||||||
|
let hostAddress = host;
|
||||||
|
if (!isIP(host)) {
|
||||||
|
try {
|
||||||
|
hostAddress = (await dns.lookup(host, { family: 4 })).address;
|
||||||
|
} catch (err: any) {
|
||||||
|
this._warnListing(`Unable to resolve ${host} for the server browser: ${err.message}`);
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = this.controller.config.get("lobby.server_browser_tags")
|
||||||
|
.split(",").map(tag => tag.trim()).filter(Boolean);
|
||||||
|
const common = {
|
||||||
|
description: this.controller.config.get("lobby.server_browser_description"),
|
||||||
|
tags,
|
||||||
|
maxPlayers: this.controller.config.get("lobby.server_browser_max_players"),
|
||||||
|
hostAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clusterListing) {
|
||||||
|
const name = this.controller.config.get("lobby.server_browser_name")
|
||||||
|
?? this.controller.config.get("controller.name");
|
||||||
|
const players = this.playersOnline();
|
||||||
|
for (const instance of this.controller.instances.values()) {
|
||||||
|
if (!LobbyController.isRoutable(instance) || !LobbyController.isLobby(instance)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const version = await proxy.instanceVersion(instance.id);
|
||||||
|
if (!version) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `cluster:${formatVersion(version)}:${version.build}`;
|
||||||
|
if (!listings.has(key)) {
|
||||||
|
listings.set(key, {
|
||||||
|
...common,
|
||||||
|
name,
|
||||||
|
hostPort: port ?? proxy.port,
|
||||||
|
version: { version: formatVersion(version), build: version.build },
|
||||||
|
players,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const instance of instanceListings) {
|
||||||
|
const version = await proxy.instanceVersion(instance.id);
|
||||||
|
if (!version) {
|
||||||
|
this.logger.verbose(`Version of instance ${instance.id} is not known yet, not listing it`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
listings.set(`instance:${instance.id}`, {
|
||||||
|
...common,
|
||||||
|
name: instance.config.get("instance.name"),
|
||||||
|
hostPort: instance.config.get("instance.player_proxy_port")!,
|
||||||
|
version: { version: formatVersion(version), build: version.build },
|
||||||
|
players: this.playersOnline(instance.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _warnListing(message: string) {
|
||||||
|
if (this._listingWarning !== message) {
|
||||||
|
this._listingWarning = message;
|
||||||
|
this.logger.warn(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateListings() {
|
||||||
|
const username = this.controller.config.get("controller.factorio_username");
|
||||||
|
const token = this.controller.config.get("controller.factorio_token");
|
||||||
|
const credentials = username && token ? { username, token } : undefined;
|
||||||
|
await this.matchingServer.sync(await this.desiredListings(), credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateListingsSerial = new lib.AsyncSerialMergingCallback(async () => {
|
||||||
|
try {
|
||||||
|
await this.updateListings();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Unexpected error updating server browser listings:\n${err.stack}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
startListing() {
|
||||||
|
this._listingTimer = setInterval(() => this._updateListingsSerial.invoke(), LISTING_INTERVAL_MS);
|
||||||
|
this._listingTimer.unref();
|
||||||
|
this._updateListingsSerial.invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
clearInterval(this._listingTimer);
|
||||||
|
await this.matchingServer.removeAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function loadControllerPlugin(context: ControllerPluginContext) {
|
||||||
|
const { controller, logger, plugin } = context;
|
||||||
|
const statePath = path.join(controller.config.get("controller.database_directory"), "lobby-listings.json");
|
||||||
|
const lobby = new LobbyController(controller, logger, new MatchingServerClient(logger, statePath));
|
||||||
|
|
||||||
|
controller.handle(ServerListRequest, lobby.handleServerListRequest.bind(lobby));
|
||||||
|
controller.handle(TransferRequest, lobby.handleTransferRequest.bind(lobby));
|
||||||
|
controller.hooks.instanceStatusChanged.attach(plugin.name, lobby.onInstanceStatusChanged.bind(lobby));
|
||||||
|
controller.hooks.playerEvent.attach(plugin.name, lobby.onPlayerEvent.bind(lobby));
|
||||||
|
controller.hooks.playerProxyRoute.attach(plugin.name, lobby.onPlayerProxyRoute.bind(lobby));
|
||||||
|
controller.hooks.shutdown.attach(plugin.name, lobby.shutdown.bind(lobby));
|
||||||
|
|
||||||
|
if (!controller.config.get("controller.player_proxy_port")) {
|
||||||
|
logger.warn("controller.player_proxy_port is not set, players can only reach lobbies directly");
|
||||||
|
}
|
||||||
|
lobby.startListing();
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
import * as messages from "./messages.js";
|
||||||
|
|
||||||
|
declare module "@clusterio/lib" {
|
||||||
|
export interface ControllerConfigFields {
|
||||||
|
"lobby.auto_create": boolean;
|
||||||
|
"lobby.server_browser_listing": boolean;
|
||||||
|
"lobby.server_browser_name": string | null;
|
||||||
|
"lobby.server_browser_description": string;
|
||||||
|
"lobby.server_browser_tags": string;
|
||||||
|
"lobby.server_browser_max_players": number;
|
||||||
|
}
|
||||||
|
export interface InstanceConfigFields {
|
||||||
|
"lobby.is_lobby": boolean;
|
||||||
|
"lobby.listed": boolean;
|
||||||
|
"lobby.server_browser_listing": boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plugin: lib.PluginDeclaration = {
|
||||||
|
name: "lobby",
|
||||||
|
title: "Lobby",
|
||||||
|
description:
|
||||||
|
"Lobby servers players pick the instance to join from, and listing of the cluster in the Factorio " +
|
||||||
|
"server browser. Works together with the player proxy on the controller.",
|
||||||
|
controllerEntrypoint: "dist/node/controller.js",
|
||||||
|
instanceEntrypoint: "dist/node/instance.js",
|
||||||
|
|
||||||
|
controllerConfigFields: {
|
||||||
|
"lobby.auto_create": {
|
||||||
|
title: "Create Lobbies",
|
||||||
|
description:
|
||||||
|
"Create a lobby when a player connects to the player proxy with a Factorio version and built in " +
|
||||||
|
"mods no lobby runs, on a connected host that has that version installed or can download it.",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: true,
|
||||||
|
},
|
||||||
|
"lobby.server_browser_listing": {
|
||||||
|
title: "Server Browser Listing",
|
||||||
|
description:
|
||||||
|
"List the cluster as one game in the Factorio server browser for each Factorio version a lobby " +
|
||||||
|
"instance runs. Uses controller.factorio_username and controller.factorio_token.",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
},
|
||||||
|
"lobby.server_browser_name": {
|
||||||
|
title: "Server Browser Name",
|
||||||
|
description: "Name of the cluster in the server browser, uses controller.name if null.",
|
||||||
|
type: "string",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
"lobby.server_browser_description": {
|
||||||
|
title: "Server Browser Description",
|
||||||
|
description: "Description of the cluster in the server browser.",
|
||||||
|
type: "string",
|
||||||
|
initialValue: "",
|
||||||
|
},
|
||||||
|
"lobby.server_browser_tags": {
|
||||||
|
title: "Server Browser Tags",
|
||||||
|
description: "Comma separated tags for the server browser listings.",
|
||||||
|
type: "string",
|
||||||
|
initialValue: "clusterio",
|
||||||
|
},
|
||||||
|
"lobby.server_browser_max_players": {
|
||||||
|
title: "Server Browser Max Players",
|
||||||
|
description: "Max players shown in the server browser listings, 0 for no limit.",
|
||||||
|
type: "number",
|
||||||
|
initialValue: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
instanceConfigFields: {
|
||||||
|
"lobby.is_lobby": {
|
||||||
|
title: "Is Lobby",
|
||||||
|
description:
|
||||||
|
"Players connecting to the player proxy are sent to a lobby instance running their Factorio " +
|
||||||
|
"version, where they pick which instance to join.",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
},
|
||||||
|
"lobby.listed": {
|
||||||
|
title: "Listed",
|
||||||
|
description: "Show this instance in the server list of lobbies.",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: true,
|
||||||
|
},
|
||||||
|
"lobby.server_browser_listing": {
|
||||||
|
title: "Server Browser Listing",
|
||||||
|
description:
|
||||||
|
"List this instance as its own game in the Factorio server browser, also while it's stopped. " +
|
||||||
|
"Requires instance.player_proxy_port to be set, players joining it while it's stopped wait in a " +
|
||||||
|
"lobby until it has started.",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
features: [
|
||||||
|
"SavePatching",
|
||||||
|
"ScriptCommands",
|
||||||
|
],
|
||||||
|
|
||||||
|
messages: [
|
||||||
|
messages.ServerListRequest,
|
||||||
|
messages.TransferRequest,
|
||||||
|
messages.TransferStatusEvent,
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
import { type InstancePluginContext, setSaveScenario } from "@clusterio/host";
|
||||||
|
|
||||||
|
import { ServerListRequest, TransferRequest, TransferStatus, TransferStatusEvent } from "./messages.js";
|
||||||
|
|
||||||
|
const SERVER_LIST_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
/** Save lobby instances start, created if it doesn't exist */
|
||||||
|
export const LOBBY_SAVE = "lobby.zip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map gen settings for an empty world
|
||||||
|
*
|
||||||
|
* A width or height of 0 means unlimited to Factorio, so the map is made
|
||||||
|
* a single tile with everything outside it out of map.
|
||||||
|
*/
|
||||||
|
export const lobbyMapGenSettings = {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
starting_points: [{ x: 0, y: 0 }],
|
||||||
|
peaceful_mode: true,
|
||||||
|
no_enemies_mode: true,
|
||||||
|
default_enable_all_autoplace_controls: false,
|
||||||
|
autoplace_controls: {},
|
||||||
|
autoplace_settings: {
|
||||||
|
entity: { treat_missing_as_default: false, settings: {} },
|
||||||
|
decorative: { treat_missing_as_default: false, settings: {} },
|
||||||
|
},
|
||||||
|
cliff_settings: { name: "cliff", richness: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ./module/lobby.lua
|
||||||
|
type IpcConnect = {
|
||||||
|
player: string,
|
||||||
|
instance_id: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function loadInstancePlugin(context: InstancePluginContext) {
|
||||||
|
const { instance, logger, plugin } = context;
|
||||||
|
let serverListTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let lastServerListJson = "";
|
||||||
|
|
||||||
|
// Commands are sent one at a time so they execute in order
|
||||||
|
let rconQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
function sendOrderedRcon(command: string) {
|
||||||
|
const result = rconQueue.then(() => instance.sendRcon(command, true, plugin.name));
|
||||||
|
rconQueue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function running() {
|
||||||
|
return ["starting", "running"].includes(instance.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configure() {
|
||||||
|
const isLobby = instance.config.get("lobby.is_lobby");
|
||||||
|
// The player_auth module opens its login dialog from the same IPC message as its /web-login command
|
||||||
|
const webLogin = [...instance.loadedPlugins].some(info => info.name === "player_auth");
|
||||||
|
await sendOrderedRcon(`/sc lobby.configure(${isLobby}, ${instance.id}, ${webLogin})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateServerList() {
|
||||||
|
if (!running()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const servers = await instance.sendTo("controller", new ServerListRequest());
|
||||||
|
const json = JSON.stringify(servers);
|
||||||
|
if (json === lastServerListJson) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastServerListJson = json;
|
||||||
|
await sendOrderedRcon(`/sc lobby.set_servers("${lib.escapeString(json)}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatus(player: string, status: TransferStatus) {
|
||||||
|
if (!running()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = lib.escapeString(player);
|
||||||
|
if (status.state === "ready" && status.address) {
|
||||||
|
await sendOrderedRcon(
|
||||||
|
`/sc lobby.connect_player("${name}", "${lib.escapeString(status.address)}", ` +
|
||||||
|
`"${lib.escapeString(status.name)}")`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = status.state === "starting"
|
||||||
|
? `Starting ${status.name}, you will be moved there once it's up.`
|
||||||
|
: `Unable to join ${status.name}: ${status.message ?? "unknown error"}`;
|
||||||
|
await sendOrderedRcon(`/sc lobby.set_player_status("${name}", "${lib.escapeString(text)}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.hooks.chooseSave.attach(plugin.name, async () => {
|
||||||
|
if (!instance.config.get("lobby.is_lobby")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const savePath = instance.path("saves", LOBBY_SAVE);
|
||||||
|
try {
|
||||||
|
await fs.access(savePath);
|
||||||
|
} catch {
|
||||||
|
logger.info("Creating empty lobby save");
|
||||||
|
await instance.server.create(LOBBY_SAVE, undefined, lobbyMapGenSettings);
|
||||||
|
// Saves are created with the freeplay scenario, replace it with one that does nothing
|
||||||
|
await setSaveScenario(savePath, new lib.ModuleInfo("lobby", "0.0.0"));
|
||||||
|
}
|
||||||
|
return LOBBY_SAVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.hooks.start.attach(plugin.name, async () => {
|
||||||
|
await configure();
|
||||||
|
lastServerListJson = "";
|
||||||
|
await updateServerList();
|
||||||
|
serverListTimer = setInterval(() => {
|
||||||
|
updateServerList().catch(err => logger.error(`Failed to update server list:\n${err.stack}`));
|
||||||
|
}, SERVER_LIST_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.hooks.stop.attach(plugin.name, async () => {
|
||||||
|
clearInterval(serverListTimer);
|
||||||
|
serverListTimer = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async field => {
|
||||||
|
if (field === "lobby.is_lobby" && running()) {
|
||||||
|
await configure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.server.handle("lobby:connect", async (data: IpcConnect) => {
|
||||||
|
let status: TransferStatus;
|
||||||
|
try {
|
||||||
|
status = await instance.sendTo("controller", new TransferRequest(data.player, data.instance_id));
|
||||||
|
} catch (err: any) {
|
||||||
|
status = new TransferStatus("failed", String(data.instance_id), undefined, err.message);
|
||||||
|
}
|
||||||
|
await applyStatus(data.player, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.handle(TransferStatusEvent, async event => {
|
||||||
|
await applyStatus(event.player, event.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
export const DEFAULT_MATCHING_SERVER_URL = "https://multiplayer.factorio.com";
|
||||||
|
|
||||||
|
/** Version of a game as listed in the server browser */
|
||||||
|
export interface ListingVersion {
|
||||||
|
/** major.minor.sub */
|
||||||
|
version: string;
|
||||||
|
build: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Game to list in the Factorio server browser */
|
||||||
|
export interface Listing {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
maxPlayers: number;
|
||||||
|
hostAddress: string;
|
||||||
|
hostPort: number;
|
||||||
|
version: ListingVersion;
|
||||||
|
players: string[];
|
||||||
|
/** Minutes the game has been running */
|
||||||
|
gameTimeElapsed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State of a listing kept between restarts so it can be updated or removed */
|
||||||
|
interface StoredListing {
|
||||||
|
gameId: number;
|
||||||
|
gameSecret: string;
|
||||||
|
serverSecret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveListing extends StoredListing {
|
||||||
|
/** Game details last sent to the matching server */
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchingServerCredentials {
|
||||||
|
username: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchingServerError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a set of games listed in the Factorio server browser
|
||||||
|
*
|
||||||
|
* Talks to the matching server the same way a Factorio server with
|
||||||
|
* visibility.public set does: create-game with the account credentials,
|
||||||
|
* update-game when the details change, post-game-heartbeat otherwise, and
|
||||||
|
* remove-game when a listing is no longer wanted.
|
||||||
|
*/
|
||||||
|
export class MatchingServerClient {
|
||||||
|
private _active = new Map<string, ActiveListing>();
|
||||||
|
/** Server secrets by key, kept after a listing is removed so it gets the same server_id back */
|
||||||
|
private _serverSecrets = new Map<string, string>();
|
||||||
|
private _loaded = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _logger: lib.Logger,
|
||||||
|
private _statePath?: string,
|
||||||
|
private _baseUrl = DEFAULT_MATCHING_SERVER_URL,
|
||||||
|
private _fetch: typeof fetch = fetch,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/** Keys of the listings currently registered */
|
||||||
|
get keys() {
|
||||||
|
return [...this._active.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
gameId(key: string) {
|
||||||
|
return this._active.get(key)?.gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _request(method: "GET" | "POST", uri: string, body?: object) {
|
||||||
|
const response = await this._fetch(`${this._baseUrl}/${uri}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new MatchingServerError(response.status, `${uri} failed with ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static _game(listing: Listing, gameSecret: string, gameId?: number) {
|
||||||
|
return {
|
||||||
|
...(gameId !== undefined ? { game_id: gameId } : {}),
|
||||||
|
game_secret: gameSecret,
|
||||||
|
name: listing.name.slice(0, 50),
|
||||||
|
description: listing.description.slice(0, 5000),
|
||||||
|
application_version: {
|
||||||
|
game_version: listing.version.version,
|
||||||
|
build_version: listing.version.build,
|
||||||
|
build_mode: "headless",
|
||||||
|
platform: "linux64",
|
||||||
|
},
|
||||||
|
max_players: listing.maxPlayers,
|
||||||
|
game_time_elapsed: listing.gameTimeElapsed ?? 0,
|
||||||
|
has_password: false,
|
||||||
|
host_address: listing.hostAddress,
|
||||||
|
host_port: listing.hostPort,
|
||||||
|
tags: listing.tags,
|
||||||
|
players: listing.players,
|
||||||
|
headless_server: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _loadState() {
|
||||||
|
if (this._loaded || !this._statePath) {
|
||||||
|
this._loaded = true;
|
||||||
|
return new Map<string, StoredListing>();
|
||||||
|
}
|
||||||
|
this._loaded = true;
|
||||||
|
let stored: Record<string, StoredListing> = {};
|
||||||
|
try {
|
||||||
|
stored = JSON.parse(await fs.readFile(this._statePath, "utf8"));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== "ENOENT") {
|
||||||
|
this._logger.warn(`Failed to read server browser listings: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Map(Object.entries(stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _saveState() {
|
||||||
|
if (!this._statePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stored: Record<string, StoredListing & { removed?: boolean }> = {};
|
||||||
|
for (const [key, secret] of this._serverSecrets) {
|
||||||
|
stored[key] = { gameId: -1, gameSecret: "", serverSecret: secret, removed: true };
|
||||||
|
}
|
||||||
|
for (const [key, { gameId, gameSecret, serverSecret }] of this._active) {
|
||||||
|
stored[key] = { gameId, gameSecret, serverSecret };
|
||||||
|
}
|
||||||
|
await lib.safeOutputFile(this._statePath, JSON.stringify(stored, null, "\t"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the registered listings match the given ones
|
||||||
|
*
|
||||||
|
* @param listings - Listings that should exist by a stable key.
|
||||||
|
* @param credentials - Factorio account to create listings with.
|
||||||
|
*/
|
||||||
|
async sync(listings: Map<string, Listing>, credentials?: MatchingServerCredentials) {
|
||||||
|
if (!this._loaded) {
|
||||||
|
// Listings from before a restart are taken over, and updated or removed below
|
||||||
|
for (const [key, stored] of await this._loadState()) {
|
||||||
|
if (stored.serverSecret) {
|
||||||
|
this._serverSecrets.set(key, stored.serverSecret);
|
||||||
|
}
|
||||||
|
if (stored.gameId >= 0) {
|
||||||
|
this._active.set(key, { ...stored, payload: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const [key, active] of [...this._active]) {
|
||||||
|
if (!listings.has(key)) {
|
||||||
|
this._active.delete(key);
|
||||||
|
await this._remove(key, active);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, listing] of listings) {
|
||||||
|
try {
|
||||||
|
changed = await this._syncListing(key, listing, credentials) || changed;
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.warn(`Failed to update server browser listing ${listing.name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await this._saveState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _syncListing(key: string, listing: Listing, credentials?: MatchingServerCredentials) {
|
||||||
|
const active = this._active.get(key);
|
||||||
|
const payload = JSON.stringify(MatchingServerClient._game(listing, ""));
|
||||||
|
if (!active) {
|
||||||
|
await this._create(key, listing, payload, credentials);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (active.payload !== payload) {
|
||||||
|
await this._request("POST", `update-game/${active.gameId}`, {
|
||||||
|
game: MatchingServerClient._game(listing, active.gameSecret, active.gameId),
|
||||||
|
game_secret: active.gameSecret,
|
||||||
|
});
|
||||||
|
active.payload = payload;
|
||||||
|
} else {
|
||||||
|
await this._request("POST", `post-game-heartbeat/${active.gameId}`, {
|
||||||
|
game_secret: active.gameSecret,
|
||||||
|
game_patch: { game_time_elapsed: listing.gameTimeElapsed ?? 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof MatchingServerError && err.status === 404) {
|
||||||
|
// The matching server forgot about the game, register it again
|
||||||
|
this._active.delete(key);
|
||||||
|
await this._create(key, listing, payload, credentials);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _create(
|
||||||
|
key: string,
|
||||||
|
listing: Listing,
|
||||||
|
payload: string,
|
||||||
|
credentials?: MatchingServerCredentials,
|
||||||
|
) {
|
||||||
|
if (!credentials) {
|
||||||
|
throw new Error("controller.factorio_username and controller.factorio_token must be set");
|
||||||
|
}
|
||||||
|
const gameSecret = crypto.randomBytes(16).toString("hex");
|
||||||
|
const serverSecret = this._serverSecrets.get(key);
|
||||||
|
const result = await this._request("POST", "create-game", {
|
||||||
|
game: MatchingServerClient._game(listing, gameSecret),
|
||||||
|
username: credentials.username,
|
||||||
|
token: credentials.token,
|
||||||
|
...(serverSecret ? { server_secret: serverSecret } : {}),
|
||||||
|
});
|
||||||
|
if (typeof result.game_id !== "number") {
|
||||||
|
throw new Error("create-game did not return a game_id");
|
||||||
|
}
|
||||||
|
if (typeof result.server_secret === "string") {
|
||||||
|
this._serverSecrets.set(key, result.server_secret);
|
||||||
|
}
|
||||||
|
this._active.set(key, {
|
||||||
|
gameId: result.game_id,
|
||||||
|
gameSecret,
|
||||||
|
serverSecret: this._serverSecrets.get(key),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
this._logger.info(`Listed ${listing.name} in the server browser as game ${result.game_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _remove(key: string, stored: StoredListing) {
|
||||||
|
try {
|
||||||
|
await this._request("POST", `remove-game/${stored.gameId}`, { game_secret: stored.gameSecret });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!(err instanceof MatchingServerError && err.status === 404)) {
|
||||||
|
this._logger.warn(`Failed to remove server browser listing ${stored.gameId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all listings */
|
||||||
|
async removeAll() {
|
||||||
|
await this.sync(new Map());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Type, Static } from "@sinclair/typebox";
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
/** Instance shown in the server list of a lobby */
|
||||||
|
export class LobbyServer {
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
public name: string,
|
||||||
|
public status: lib.InstanceStatus,
|
||||||
|
/** Factorio version the instance runs, or null if not known */
|
||||||
|
public version: string | null,
|
||||||
|
public players: number,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
"id": Type.Integer(),
|
||||||
|
"name": Type.String(),
|
||||||
|
"status": lib.InstanceStatus,
|
||||||
|
"version": Type.Union([Type.String(), Type.Null()]),
|
||||||
|
"players": Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof LobbyServer.jsonSchema>): LobbyServer {
|
||||||
|
return new this(json.id, json.name, json.status, json.version, json.players);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerListRequest {
|
||||||
|
declare ["constructor"]: typeof ServerListRequest;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = "instance" as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static plugin = "lobby" as const;
|
||||||
|
|
||||||
|
static Response = lib.jsonArray(LobbyServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransferState = "starting" | "ready" | "failed";
|
||||||
|
const TransferStateSchema = Type.Union([Type.Literal("starting"), Type.Literal("ready"), Type.Literal("failed")]);
|
||||||
|
|
||||||
|
export class TransferStatus {
|
||||||
|
constructor(
|
||||||
|
public state: TransferState,
|
||||||
|
/** Name of the instance transferred to */
|
||||||
|
public name: string,
|
||||||
|
/** Address to connect to when ready */
|
||||||
|
public address?: string,
|
||||||
|
/** Reason for a failed transfer */
|
||||||
|
public message?: string,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
"state": TransferStateSchema,
|
||||||
|
"name": Type.String(),
|
||||||
|
"address": Type.Optional(Type.String()),
|
||||||
|
"message": Type.Optional(Type.String()),
|
||||||
|
});
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof TransferStatus.jsonSchema>): TransferStatus {
|
||||||
|
return new this(json.state, json.name, json.address, json.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request from a lobby to send a player to an instance, starting it if needed */
|
||||||
|
export class TransferRequest {
|
||||||
|
declare ["constructor"]: typeof TransferRequest;
|
||||||
|
static type = "request" as const;
|
||||||
|
static src = "instance" as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
static plugin = "lobby" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public player: string,
|
||||||
|
public instanceId: number,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
"player": Type.String(),
|
||||||
|
"instanceId": Type.Integer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof TransferRequest.jsonSchema>): TransferRequest {
|
||||||
|
return new this(json.player, json.instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Response = TransferStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update on a transfer of a player sent to the lobby the player is on */
|
||||||
|
export class TransferStatusEvent {
|
||||||
|
declare ["constructor"]: typeof TransferStatusEvent;
|
||||||
|
static type = "event" as const;
|
||||||
|
static src = "controller" as const;
|
||||||
|
static dst = "instance" as const;
|
||||||
|
static plugin = "lobby" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public player: string,
|
||||||
|
public instanceId: number,
|
||||||
|
public status: TransferStatus,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
"player": Type.String(),
|
||||||
|
"instanceId": Type.Integer(),
|
||||||
|
"status": TransferStatus.jsonSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof TransferStatusEvent.jsonSchema>): TransferStatusEvent {
|
||||||
|
return new this(json.player, json.instanceId, TransferStatus.fromJSON(json.status));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
local clusterio_api = require("modules/clusterio/api")
|
||||||
|
local compat = require("modules/clusterio/compat")
|
||||||
|
local mod_gui = require("mod-gui")
|
||||||
|
|
||||||
|
--- @class LobbyServer
|
||||||
|
--- @field id integer
|
||||||
|
--- @field name string
|
||||||
|
--- @field status string
|
||||||
|
--- @field version string?
|
||||||
|
--- @field players integer
|
||||||
|
|
||||||
|
--- @class LobbyScriptData
|
||||||
|
--- @field is_lobby boolean
|
||||||
|
--- @field instance_id integer?
|
||||||
|
--- @field web_login boolean Show button for logging into the web interface with the player_auth plugin
|
||||||
|
--- @field servers LobbyServer[]
|
||||||
|
--- @field status table<string, string> Status text by player name
|
||||||
|
--- @field show_other_versions table<string, boolean> Players listing servers on other Factorio versions
|
||||||
|
|
||||||
|
local status_captions = {
|
||||||
|
running = "Online",
|
||||||
|
stopped = "Offline",
|
||||||
|
starting = "Starting",
|
||||||
|
stopping = "Stopping",
|
||||||
|
creating_save = "Creating save",
|
||||||
|
exporting_data = "Exporting data",
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Idempotent accessor for the persistent state of the module
|
||||||
|
--- @return LobbyScriptData
|
||||||
|
local function get_script_data()
|
||||||
|
local data = compat.script_data.lobby
|
||||||
|
if not data then
|
||||||
|
data = {}
|
||||||
|
compat.script_data.lobby = data
|
||||||
|
end
|
||||||
|
data.servers = data.servers or {}
|
||||||
|
data.status = data.status or {}
|
||||||
|
data.show_other_versions = data.show_other_versions or {}
|
||||||
|
if data.is_lobby == nil then
|
||||||
|
data.is_lobby = false
|
||||||
|
end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reason a server can't be joined from here, or nil if it can
|
||||||
|
--- @param data LobbyScriptData
|
||||||
|
--- @param server LobbyServer
|
||||||
|
--- @return string?
|
||||||
|
local function unjoinable_reason(data, server)
|
||||||
|
if server.id == data.instance_id then
|
||||||
|
return "You are on this server"
|
||||||
|
end
|
||||||
|
local version = compat.active_mods["base"]
|
||||||
|
if server.version and server.version ~= version then
|
||||||
|
return "Runs Factorio " .. server.version .. ", you have " .. version
|
||||||
|
end
|
||||||
|
if not status_captions[server.status] then
|
||||||
|
return "Not available"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- True if the server runs a different Factorio version than this one
|
||||||
|
--- @param server LobbyServer
|
||||||
|
--- @return boolean
|
||||||
|
local function other_version(server)
|
||||||
|
return server.version ~= nil and server.version ~= compat.active_mods["base"]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param player LuaPlayer
|
||||||
|
--- @param data LobbyScriptData
|
||||||
|
--- @param content LuaGuiElement
|
||||||
|
local function build_content(player, data, content)
|
||||||
|
content.clear()
|
||||||
|
local status = data.status[player.name]
|
||||||
|
if status then
|
||||||
|
local label = content.add { type = "label", caption = status }
|
||||||
|
label.style.single_line = false
|
||||||
|
label.style.maximal_width = 500
|
||||||
|
label.style.bottom_margin = 8
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Servers on other versions can't be joined from here, so they are only listed if asked for
|
||||||
|
local show_other = data.show_other_versions[player.name] == true
|
||||||
|
local shown = {}
|
||||||
|
local other_count = 0
|
||||||
|
for _, server in ipairs(data.servers) do
|
||||||
|
if not other_version(server) then
|
||||||
|
shown[#shown + 1] = server
|
||||||
|
else
|
||||||
|
other_count = other_count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if show_other then
|
||||||
|
for _, server in ipairs(data.servers) do
|
||||||
|
if other_version(server) then
|
||||||
|
shown[#shown + 1] = server
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #shown == 0 then
|
||||||
|
content.add {
|
||||||
|
type = "label",
|
||||||
|
caption = "There are no servers running Factorio " .. compat.active_mods["base"] .. " to join right now.",
|
||||||
|
}
|
||||||
|
else
|
||||||
|
local servers = content.add {
|
||||||
|
type = "table", name = "lobby_servers", column_count = 5, style = "bordered_table",
|
||||||
|
}
|
||||||
|
servers.style.minimal_width = 480
|
||||||
|
for _, caption in ipairs({ "Server", "Version", "Players", "Status", "" }) do
|
||||||
|
servers.add { type = "label", caption = caption, style = "caption_label" }
|
||||||
|
end
|
||||||
|
for _, server in ipairs(shown) do
|
||||||
|
local reason = unjoinable_reason(data, server)
|
||||||
|
local name = servers.add { type = "label", caption = server.name }
|
||||||
|
servers.add { type = "label", caption = server.version or "" }
|
||||||
|
servers.add { type = "label", caption = tostring(server.players) }
|
||||||
|
servers.add { type = "label", caption = status_captions[server.status] or server.status }
|
||||||
|
servers.add {
|
||||||
|
type = "button",
|
||||||
|
caption = server.status == "stopped" and "Start and join" or "Join",
|
||||||
|
tooltip = reason,
|
||||||
|
enabled = reason == nil,
|
||||||
|
tags = { lobby_instance_id = server.id },
|
||||||
|
}
|
||||||
|
if other_version(server) then
|
||||||
|
name.style.font_color = { 0.5, 0.5, 0.5 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if other_count > 0 then
|
||||||
|
local checkbox = content.add {
|
||||||
|
type = "checkbox",
|
||||||
|
name = "lobby_show_other_versions",
|
||||||
|
caption = "Show " .. other_count .. " servers on other Factorio versions",
|
||||||
|
state = show_other,
|
||||||
|
}
|
||||||
|
checkbox.style.top_margin = 8
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param player LuaPlayer
|
||||||
|
local function open_browser(player)
|
||||||
|
local data = get_script_data()
|
||||||
|
local frame = player.gui.screen.lobby_frame
|
||||||
|
if not frame then
|
||||||
|
frame = player.gui.screen.add { type = "frame", name = "lobby_frame", direction = "vertical",
|
||||||
|
caption = data.is_lobby and "Pick a server to join" or "Servers" }
|
||||||
|
frame.auto_center = true
|
||||||
|
local inner = frame.add { type = "frame", name = "content", direction = "vertical",
|
||||||
|
style = "inside_shallow_frame_with_padding" }
|
||||||
|
inner.style.horizontally_stretchable = true
|
||||||
|
local buttons = frame.add { type = "flow", name = "buttons", direction = "horizontal" }
|
||||||
|
buttons.style.top_margin = 8
|
||||||
|
if data.web_login then
|
||||||
|
buttons.add { type = "button", name = "lobby_web_login", caption = "Log in to web interface",
|
||||||
|
tooltip = "Same as the /web-login command" }
|
||||||
|
end
|
||||||
|
local filler = buttons.add { type = "empty-widget" }
|
||||||
|
filler.style.horizontally_stretchable = true
|
||||||
|
if not data.is_lobby then
|
||||||
|
buttons.add { type = "button", name = "lobby_close", caption = "Close" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
build_content(player, data, frame.content)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param player LuaPlayer
|
||||||
|
local function close_browser(player)
|
||||||
|
if player.gui.screen.lobby_frame then
|
||||||
|
player.gui.screen.lobby_frame.destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param player LuaPlayer
|
||||||
|
local function refresh_player(player)
|
||||||
|
local data = get_script_data()
|
||||||
|
local button_flow = mod_gui.get_button_flow(player)
|
||||||
|
if data.is_lobby then
|
||||||
|
if button_flow.lobby_toggle then
|
||||||
|
button_flow.lobby_toggle.destroy()
|
||||||
|
end
|
||||||
|
open_browser(player)
|
||||||
|
else
|
||||||
|
if not button_flow.lobby_toggle then
|
||||||
|
button_flow.add { type = "button", name = "lobby_toggle", caption = "Servers", style = mod_gui.button_style }
|
||||||
|
end
|
||||||
|
if player.gui.screen.lobby_frame then
|
||||||
|
open_browser(player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refresh_all()
|
||||||
|
for _, player in pairs(game.connected_players) do
|
||||||
|
refresh_player(player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Keep players in a lobby from interacting with its world
|
||||||
|
--- @param player LuaPlayer
|
||||||
|
local function make_spectator(player)
|
||||||
|
if player.controller_type == defines.controllers.cutscene then
|
||||||
|
player.exit_cutscene()
|
||||||
|
end
|
||||||
|
if player.character then
|
||||||
|
player.character.destroy()
|
||||||
|
end
|
||||||
|
if player.controller_type ~= defines.controllers.spectator then
|
||||||
|
player.set_controller { type = defines.controllers.spectator }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local lobby_module = {}
|
||||||
|
lobby_module.events = {}
|
||||||
|
|
||||||
|
lobby_module.events[clusterio_api.events.on_server_startup] = function()
|
||||||
|
get_script_data()
|
||||||
|
end
|
||||||
|
|
||||||
|
lobby_module.events[defines.events.on_player_joined_game] = function(event)
|
||||||
|
local player = game.get_player(event.player_index)
|
||||||
|
local data = get_script_data()
|
||||||
|
data.status[player.name] = nil
|
||||||
|
if data.is_lobby then
|
||||||
|
make_spectator(player)
|
||||||
|
end
|
||||||
|
refresh_player(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
lobby_module.events[defines.events.on_gui_click] = function(event)
|
||||||
|
local element = event.element
|
||||||
|
if not element.valid then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local player = game.get_player(event.player_index)
|
||||||
|
if element.name == "lobby_toggle" then
|
||||||
|
if player.gui.screen.lobby_frame then
|
||||||
|
close_browser(player)
|
||||||
|
else
|
||||||
|
open_browser(player)
|
||||||
|
end
|
||||||
|
elseif element.name == "lobby_close" then
|
||||||
|
close_browser(player)
|
||||||
|
elseif element.name == "lobby_web_login" then
|
||||||
|
clusterio_api.send_json("player_auth", { type = "open_dialog", player = player.name })
|
||||||
|
elseif element.tags and element.tags.lobby_instance_id then
|
||||||
|
local data = get_script_data()
|
||||||
|
local instance_id = element.tags.lobby_instance_id
|
||||||
|
local name = tostring(instance_id)
|
||||||
|
for _, server in ipairs(data.servers) do
|
||||||
|
if server.id == instance_id then
|
||||||
|
name = server.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
data.status[player.name] = "Connecting to " .. name .. "..."
|
||||||
|
clusterio_api.send_json("lobby:connect", { player = player.name, instance_id = instance_id })
|
||||||
|
open_browser(player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
lobby_module.events[defines.events.on_gui_checked_state_changed] = function(event)
|
||||||
|
local element = event.element
|
||||||
|
if not element.valid or element.name ~= "lobby_show_other_versions" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local player = game.get_player(event.player_index)
|
||||||
|
get_script_data().show_other_versions[player.name] = element.state or nil
|
||||||
|
open_browser(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Interface used by the instance plugin through RCON
|
||||||
|
lobby = {}
|
||||||
|
|
||||||
|
--- @param is_lobby boolean
|
||||||
|
--- @param instance_id integer
|
||||||
|
--- @param web_login boolean?
|
||||||
|
function lobby.configure(is_lobby, instance_id, web_login)
|
||||||
|
local data = get_script_data()
|
||||||
|
data.is_lobby = is_lobby
|
||||||
|
data.instance_id = instance_id
|
||||||
|
data.web_login = web_login == true
|
||||||
|
-- Rebuilt so the frame matches the new configuration
|
||||||
|
for _, player in pairs(game.players) do
|
||||||
|
close_browser(player)
|
||||||
|
end
|
||||||
|
refresh_all()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param json string
|
||||||
|
function lobby.set_servers(json)
|
||||||
|
local data = get_script_data()
|
||||||
|
data.servers = compat.json_to_table(json) or {}
|
||||||
|
refresh_all()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param player_name string
|
||||||
|
--- @param text string
|
||||||
|
function lobby.set_player_status(player_name, text)
|
||||||
|
local data = get_script_data()
|
||||||
|
data.status[player_name] = text
|
||||||
|
local player = game.get_player(player_name)
|
||||||
|
if player and player.connected then
|
||||||
|
open_browser(player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param player_name string
|
||||||
|
--- @param address string
|
||||||
|
--- @param server_name string
|
||||||
|
function lobby.connect_player(player_name, address, server_name)
|
||||||
|
local data = get_script_data()
|
||||||
|
local player = game.get_player(player_name)
|
||||||
|
if not player or not player.connected then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
data.status[player_name] = "Moving you to " .. server_name .. "..."
|
||||||
|
open_browser(player)
|
||||||
|
player.connect_to_server { address = address, name = server_name }
|
||||||
|
end
|
||||||
|
|
||||||
|
return lobby_module
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "lobby",
|
||||||
|
"load": ["lobby.lua"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "@clusterio/plugin-lobby",
|
||||||
|
"version": "2.0.3",
|
||||||
|
"description": "Clusterio plugin for a lobby server players pick the server to join from, and listing the cluster in the Factorio server browser",
|
||||||
|
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||||
|
"author": "Danielv123",
|
||||||
|
"license": "MIT",
|
||||||
|
|
||||||
|
"keywords": [
|
||||||
|
"clusterio",
|
||||||
|
"clusterio-plugin",
|
||||||
|
"factorio"
|
||||||
|
],
|
||||||
|
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/clusterio/clusterio"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/clusterio/clusterio/issues"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/node/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "tsc --build && vite build",
|
||||||
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
|
||||||
|
"peerDependencies": {
|
||||||
|
"@clusterio/controller": "workspace:^",
|
||||||
|
"@clusterio/host": "workspace:^",
|
||||||
|
"@clusterio/lib": "workspace:^"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@clusterio/controller": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@clusterio/host": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sinclair/typebox": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@clusterio/controller": "workspace:^",
|
||||||
|
"@clusterio/host": "workspace:^",
|
||||||
|
"@clusterio/lib": "workspace:^",
|
||||||
|
"@clusterio/web_ui": "workspace:^",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@types/react": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vite": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller";
|
||||||
|
|
||||||
|
import * as mock from "../../../test/mock.js";
|
||||||
|
import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js";
|
||||||
|
import { plugin as info } from "../dist/node/index.js";
|
||||||
|
import * as instancePlugin from "../dist/node/instance.js";
|
||||||
|
import { LobbyController, parseAddress } from "../dist/node/controller.js";
|
||||||
|
import { MatchingServerClient } from "../dist/node/matching_server.js";
|
||||||
|
import {
|
||||||
|
LobbyServer, TransferRequest, TransferStatus, TransferStatusEvent, ServerListRequest,
|
||||||
|
} from "../dist/node/messages.js";
|
||||||
|
|
||||||
|
const v2_1_19 = { major: 2, minor: 1, sub: 19, build: 84000 };
|
||||||
|
const v2_0_72 = { major: 2, minor: 0, sub: 72, build: 83000 };
|
||||||
|
|
||||||
|
/** Fake of multiplayer.factorio.com recording the requests made to it */
|
||||||
|
function fakeMatchingServer() {
|
||||||
|
const server = {
|
||||||
|
games: new Map(),
|
||||||
|
requests: [],
|
||||||
|
nextId: 100,
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const uri = new URL(url).pathname.slice(1);
|
||||||
|
const body = options.body ? JSON.parse(options.body) : undefined;
|
||||||
|
server.requests.push({ uri, body });
|
||||||
|
const [operation, id] = uri.split("/");
|
||||||
|
const reply = (status, json) => new Response(JSON.stringify(json), { status });
|
||||||
|
if (operation === "create-game") {
|
||||||
|
const gameId = server.nextId;
|
||||||
|
server.nextId += 1;
|
||||||
|
server.games.set(gameId, body.game);
|
||||||
|
return reply(200, { game_id: gameId, server_id: `id-${gameId}`, server_secret: `secret-${gameId}` });
|
||||||
|
}
|
||||||
|
const game = server.games.get(Number(id));
|
||||||
|
if (!game || game.game_secret !== body.game_secret) {
|
||||||
|
return reply(404, { message: "game not found" });
|
||||||
|
}
|
||||||
|
if (operation === "update-game") {
|
||||||
|
server.games.set(Number(id), body.game);
|
||||||
|
} else if (operation === "remove-game") {
|
||||||
|
server.games.delete(Number(id));
|
||||||
|
}
|
||||||
|
return reply(200, { status: "ok" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = (fields = {}) => ({
|
||||||
|
name: "Cluster",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
maxPlayers: 0,
|
||||||
|
hostAddress: "203.0.113.1",
|
||||||
|
hostPort: 34197,
|
||||||
|
version: { version: "2.1.19", build: 84000 },
|
||||||
|
players: [],
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lobby plugin", function () {
|
||||||
|
before(function () {
|
||||||
|
lib.addPluginConfigFields([info]);
|
||||||
|
try {
|
||||||
|
lib.registerPluginMessages([info]);
|
||||||
|
} catch (err) {
|
||||||
|
// Already registered by the full test suite
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("messages", function () {
|
||||||
|
it("should round trip LobbyServer", function () {
|
||||||
|
testRoundTripJsonSerialisable(LobbyServer, testMatrix(
|
||||||
|
[1], ["Server"], ["running", "stopped"], [null, "2.1.19"], [0, 5],
|
||||||
|
));
|
||||||
|
});
|
||||||
|
it("should round trip TransferStatus", function () {
|
||||||
|
testRoundTripJsonSerialisable(TransferStatus, testMatrix(
|
||||||
|
["starting", "ready", "failed"], ["Server"], [undefined, "example.com:34197"], [undefined, "error"],
|
||||||
|
));
|
||||||
|
});
|
||||||
|
it("should round trip TransferRequest and TransferStatusEvent", function () {
|
||||||
|
testRoundTripJsonSerialisable(TransferRequest, testMatrix(["Player"], [1, 2]));
|
||||||
|
testRoundTripJsonSerialisable(TransferStatusEvent, testMatrix(
|
||||||
|
["Player"], [1], [new TransferStatus("ready", "Server", "127.0.0.1:1")],
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseAddress()", function () {
|
||||||
|
it("should split host and port", function () {
|
||||||
|
assert.deepEqual(parseAddress("example.com:34197"), { host: "example.com", port: 34197 });
|
||||||
|
assert.deepEqual(parseAddress("1.2.3.4"), { host: "1.2.3.4", port: undefined });
|
||||||
|
assert.deepEqual(parseAddress("[::1]:5"), { host: "::1", port: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatchingServerClient", function () {
|
||||||
|
let server;
|
||||||
|
let client;
|
||||||
|
let statePath;
|
||||||
|
const credentials = { username: "user", token: "token" };
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
server = fakeMatchingServer();
|
||||||
|
statePath = path.join("temp", "test", "lobby", `listings-${Date.now()}-${Math.random()}.json`);
|
||||||
|
client = new MatchingServerClient(new mock.MockLogger(), statePath, "https://matching.test", server.fetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
await fs.rm(statePath, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create, heartbeat, update and remove games", async function () {
|
||||||
|
await client.sync(new Map([["a", listing()]]), credentials);
|
||||||
|
assert.equal(server.games.size, 1);
|
||||||
|
const [gameId] = server.games.keys();
|
||||||
|
assert.equal(server.games.get(gameId).host_port, 34197);
|
||||||
|
assert.equal(server.requests[0].body.username, "user");
|
||||||
|
|
||||||
|
await client.sync(new Map([["a", listing()]]), credentials);
|
||||||
|
assert.equal(server.requests.at(-1).uri, `post-game-heartbeat/${gameId}`);
|
||||||
|
|
||||||
|
await client.sync(new Map([["a", listing({ players: ["alice", "bob"] })]]), credentials);
|
||||||
|
assert.equal(server.requests.at(-1).uri, `update-game/${gameId}`);
|
||||||
|
assert.deepEqual(server.games.get(gameId).players, ["alice", "bob"]);
|
||||||
|
|
||||||
|
await client.sync(new Map(), credentials);
|
||||||
|
assert.equal(server.games.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should register a game again if the matching server forgot it", async function () {
|
||||||
|
await client.sync(new Map([["a", listing()]]), credentials);
|
||||||
|
server.games.clear();
|
||||||
|
await client.sync(new Map([["a", listing()]]), credentials);
|
||||||
|
assert.equal(server.games.size, 1);
|
||||||
|
const create = server.requests.filter(r => r.uri === "create-game");
|
||||||
|
assert.equal(create.length, 2);
|
||||||
|
assert.equal(create[1].body.server_secret, "secret-100", "should keep server_id between games");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should take over listings from before a restart", async function () {
|
||||||
|
await client.sync(new Map([["a", listing()], ["b", listing()]]), credentials);
|
||||||
|
const restarted = new MatchingServerClient(
|
||||||
|
new mock.MockLogger(), statePath, "https://matching.test", server.fetch
|
||||||
|
);
|
||||||
|
server.requests = [];
|
||||||
|
await restarted.sync(new Map([["a", listing({ name: "Renamed" })]]), credentials);
|
||||||
|
assert.equal(server.games.size, 1);
|
||||||
|
assert.deepEqual(server.requests.map(r => r.uri.split("/")[0]).sort(), ["remove-game", "update-game"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not create games without credentials", async function () {
|
||||||
|
await client.sync(new Map([["a", listing()]]));
|
||||||
|
assert.equal(server.games.size, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LobbyController", function () {
|
||||||
|
let controller;
|
||||||
|
let lobby;
|
||||||
|
let started;
|
||||||
|
let sent;
|
||||||
|
let proxy;
|
||||||
|
|
||||||
|
function addInstance(id, fields = {}, status = "running", factorioVersion = "2.1.19") {
|
||||||
|
const config = new lib.InstanceConfig("controller", {
|
||||||
|
"instance.id": id,
|
||||||
|
"instance.name": `Instance ${id}`,
|
||||||
|
"instance.assigned_host": 1,
|
||||||
|
"lobby.load_plugin": true,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
const instance = new InstanceRecord(config, status, 30000 + id, factorioVersion);
|
||||||
|
controller.instances.records.set(instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOnline(name, instanceId) {
|
||||||
|
const user = controller.users.getOrCreateUser(name);
|
||||||
|
user.instances = new Set(instanceId === undefined ? [] : [instanceId]);
|
||||||
|
controller.users.records.set(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
const config = new lib.ControllerConfig("controller", {
|
||||||
|
"controller.name": "Test Cluster",
|
||||||
|
"controller.public_url": "https://play.example.com/",
|
||||||
|
});
|
||||||
|
controller = new Controller(lib.logger, [], config);
|
||||||
|
controller.hosts.set(new HostRecord(1, "Host", "2.0.0", new Map(), true, "10.0.0.2", "198.51.100.2"));
|
||||||
|
proxy = {
|
||||||
|
port: 34197,
|
||||||
|
serverVersions: new Map(),
|
||||||
|
instanceVersion: async id => proxy.serverVersions.get(id),
|
||||||
|
};
|
||||||
|
controller.playerProxyServer = proxy;
|
||||||
|
started = [];
|
||||||
|
sent = [];
|
||||||
|
controller.sendTo = async (dst, message) => {
|
||||||
|
sent.push({ dst, message });
|
||||||
|
if (message instanceof lib.InstanceStartRequest) {
|
||||||
|
started.push(dst.instanceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lobby = new LobbyController(controller, new mock.MockLogger(), new MatchingServerClient(
|
||||||
|
new mock.MockLogger(), undefined, "https://matching.test", fakeMatchingServer().fetch,
|
||||||
|
));
|
||||||
|
|
||||||
|
addInstance(1, { "lobby.is_lobby": true });
|
||||||
|
addInstance(2, { "lobby.is_lobby": true }, "running", "2.0.72");
|
||||||
|
addInstance(3);
|
||||||
|
addInstance(4, { "instance.player_proxy_port": 34200 }, "stopped", "latest");
|
||||||
|
addInstance(5, { "lobby.listed": false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = (fields = {}) => lobby.onPlayerProxyRoute({
|
||||||
|
username: "Player", version: v2_1_19, clientAddress: "1.2.3.4", clientPort: 1, listenPort: 34197, ...fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should list instances that are not lobbies", function () {
|
||||||
|
setOnline("alice", 3);
|
||||||
|
const servers = lobby.serverList();
|
||||||
|
assert.deepEqual(servers.map(s => s.id), [3, 4]);
|
||||||
|
assert.equal(servers[0].players, 1);
|
||||||
|
assert.equal(servers[0].version, "2.1.19");
|
||||||
|
assert.equal(servers[1].version, null, "stopped instance with unknown version");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send players to the lobby for their version", async function () {
|
||||||
|
assert.equal(await route(), 1);
|
||||||
|
assert.equal(await route({ version: v2_0_72 }), 2);
|
||||||
|
assert.equal(await route({ version: { ...v2_1_19, sub: 1 } }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip lobbies running a different build", async function () {
|
||||||
|
proxy.serverVersions.set(1, { ...v2_1_19, build: 1 });
|
||||||
|
assert.equal(await route(), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transfer a player to a running instance", async function () {
|
||||||
|
const status = await lobby.handleTransferRequest(
|
||||||
|
new TransferRequest("Player", 3), lib.Address.fromShorthand({ instanceId: 1 })
|
||||||
|
);
|
||||||
|
assert.equal(status.state, "ready");
|
||||||
|
assert.equal(status.address, "play.example.com:34197");
|
||||||
|
assert.equal(await route(), 3, "both connection attempts should go to the target");
|
||||||
|
assert.equal(await route(), 3);
|
||||||
|
|
||||||
|
await lobby.onPlayerEvent(controller.instances.getMutable(3), { type: "join", name: "player" });
|
||||||
|
assert.equal(await route(), 1, "should go back to the lobby after joining");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start a stopped instance and tell the lobby when it's ready", async function () {
|
||||||
|
controller.instances.getMutable(4).config.set("factorio.version", "2.1.19");
|
||||||
|
const status = await lobby.handleTransferRequest(
|
||||||
|
new TransferRequest("Player", 4), lib.Address.fromShorthand({ instanceId: 1 })
|
||||||
|
);
|
||||||
|
assert.equal(status.state, "starting");
|
||||||
|
await lib.wait(0);
|
||||||
|
assert.deepEqual(started, [4]);
|
||||||
|
assert.equal(await route(), 1, "should wait in the lobby while starting");
|
||||||
|
|
||||||
|
const instance = controller.instances.getMutable(4);
|
||||||
|
instance.status = "running";
|
||||||
|
await lobby.onInstanceStatusChanged(instance);
|
||||||
|
const event = sent.find(s => s.message instanceof TransferStatusEvent);
|
||||||
|
assert.deepEqual(event.dst, { instanceId: 1 });
|
||||||
|
assert.equal(event.message.status.state, "ready");
|
||||||
|
assert.equal(await route(), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report a transfer failing when the instance stops", async function () {
|
||||||
|
await lobby.handleTransferRequest(
|
||||||
|
new TransferRequest("Player", 4), lib.Address.fromShorthand({ instanceId: 1 })
|
||||||
|
);
|
||||||
|
await lobby.onInstanceStatusChanged(controller.instances.getMutable(4));
|
||||||
|
const event = sent.find(s => s.message instanceof TransferStatusEvent);
|
||||||
|
assert.equal(event.message.status.state, "failed");
|
||||||
|
assert.equal(lobby.transfers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hold players for a stopped dedicated port instance in the lobby", async function () {
|
||||||
|
assert.equal(await route({ portInstanceId: 4 }), 1);
|
||||||
|
await lib.wait(0);
|
||||||
|
assert.deepEqual(started, [4]);
|
||||||
|
await lobby.onPlayerEvent(controller.instances.getMutable(1), { type: "join", name: "Player" });
|
||||||
|
const event = sent.find(s => s.message instanceof TransferStatusEvent);
|
||||||
|
assert.equal(event.message.status.state, "starting");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await route({ username: "Other", portInstanceId: 3 }), undefined,
|
||||||
|
"running instances are routed by the proxy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build server browser listings", async function () {
|
||||||
|
controller.config.set("lobby.server_browser_listing", true);
|
||||||
|
controller.config.set("controller.player_proxy_public_address", "203.0.113.5:40000");
|
||||||
|
controller.instances.getMutable(4).config.set("lobby.server_browser_listing", true);
|
||||||
|
proxy.serverVersions.set(1, v2_1_19);
|
||||||
|
proxy.serverVersions.set(2, v2_0_72);
|
||||||
|
setOnline("alice", 3);
|
||||||
|
setOnline("bob", 1);
|
||||||
|
|
||||||
|
let listings = await lobby.desiredListings();
|
||||||
|
assert.deepEqual([...listings.keys()], ["cluster:2.1.19:84000", "cluster:2.0.72:83000"]);
|
||||||
|
const cluster = listings.get("cluster:2.1.19:84000");
|
||||||
|
assert.equal(cluster.name, "Test Cluster");
|
||||||
|
assert.equal(cluster.hostAddress, "203.0.113.5");
|
||||||
|
assert.equal(cluster.hostPort, 40000);
|
||||||
|
assert.deepEqual(cluster.players, ["alice", "bob"]);
|
||||||
|
|
||||||
|
proxy.serverVersions.set(4, v2_1_19);
|
||||||
|
listings = await lobby.desiredListings();
|
||||||
|
const own = listings.get("instance:4");
|
||||||
|
assert.equal(own.hostPort, 34200);
|
||||||
|
assert.equal(own.name, "Instance 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not list anything without the proxy", async function () {
|
||||||
|
controller.config.set("lobby.server_browser_listing", true);
|
||||||
|
controller.playerProxyServer = null;
|
||||||
|
assert.equal((await lobby.desiredListings()).size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lobby creation", function () {
|
||||||
|
const spaceAge = ["base", "elevated-rails", "quality", "space-age"]
|
||||||
|
.map(name => ({ name, version: "2.0.77" }));
|
||||||
|
const vanilla = [{ name: "base", version: "2.0.77" }, { name: "some-mod", version: "1.0.0" }];
|
||||||
|
const v2_0_77 = { major: 2, minor: 0, sub: 77, build: 83500 };
|
||||||
|
let hostVersions;
|
||||||
|
let assigned;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
for (const pack of lib.ModPack.defaultModPacks) {
|
||||||
|
controller.modPacks.set(lib.ModPack.fromJSON(pack.toJSON()));
|
||||||
|
}
|
||||||
|
hostVersions = new Map([[1, { versions: ["2.1.19", "2.0.77"], canDownload: false }]]);
|
||||||
|
controller.hosts.set(new HostRecord(2, "Linux", "2.0.0", new Map(), true, "10.0.0.3"));
|
||||||
|
hostVersions.set(2, { versions: [], canDownload: true });
|
||||||
|
const sendTo = controller.sendTo;
|
||||||
|
controller.sendTo = async (dst, message) => {
|
||||||
|
if (message instanceof lib.HostFactorioVersionsRequest) {
|
||||||
|
return hostVersions.get(dst.hostId);
|
||||||
|
}
|
||||||
|
return await sendTo(dst, message);
|
||||||
|
};
|
||||||
|
controller.factorioVersions.get = async () => [{ version: "2.0.60" }];
|
||||||
|
assigned = [];
|
||||||
|
controller.instances.assignInstance = async (instanceId, hostId) => {
|
||||||
|
assigned.push([instanceId, hostId]);
|
||||||
|
const instance = controller.instances.getMutable(instanceId);
|
||||||
|
instance.config.set("instance.assigned_host", hostId);
|
||||||
|
instance.status = "stopped";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a lobby on a host with the version", async function () {
|
||||||
|
const id = await route({ version: v2_0_77, mods: spaceAge });
|
||||||
|
const instance = controller.instances.get(id);
|
||||||
|
assert.equal(instance.config.get("instance.name"), "Lobby 2.0.77");
|
||||||
|
assert.equal(instance.config.get("lobby.is_lobby"), true);
|
||||||
|
assert.equal(instance.config.get("factorio.version"), "2.0.77");
|
||||||
|
assert.equal(instance.config.get("instance.auto_start"), true);
|
||||||
|
assert.equal(instance.config.get("factorio.settings").visibility.public, false);
|
||||||
|
assert.deepEqual(assigned, [[id, 1]]);
|
||||||
|
const pack = controller.modPacks.get(instance.config.get("factorio.mod_pack_id"));
|
||||||
|
assert.equal(pack.name, "Space Age 2.0", "should reuse the default mod pack");
|
||||||
|
|
||||||
|
assert.equal(await route({ version: v2_0_77, mods: spaceAge }), id, "should reuse the lobby");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create one lobby for players connecting at the same time", async function () {
|
||||||
|
const ids = await Promise.all([
|
||||||
|
route({ version: v2_0_77, mods: spaceAge }),
|
||||||
|
route({ username: "Other", version: v2_0_77, mods: spaceAge }),
|
||||||
|
]);
|
||||||
|
assert.equal(ids[0], ids[1]);
|
||||||
|
assert.equal(assigned.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a separate lobby for other built in mods", async function () {
|
||||||
|
const expansion = await route({ version: v2_0_77, mods: spaceAge });
|
||||||
|
const base = await route({ version: v2_0_77, mods: vanilla });
|
||||||
|
assert.notEqual(base, expansion);
|
||||||
|
const instance = controller.instances.get(base);
|
||||||
|
assert.equal(instance.config.get("instance.name"), "Lobby 2.0.77 (base)");
|
||||||
|
const pack = controller.modPacks.get(instance.config.get("factorio.mod_pack_id"));
|
||||||
|
assert.equal(pack.name, "Base Game 2.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a mod pack if none matches", async function () {
|
||||||
|
const quality = [{ name: "base", version: "2.0.77" }, { name: "quality", version: "2.0.77" }];
|
||||||
|
const id = await route({ version: v2_0_77, mods: quality });
|
||||||
|
const pack = controller.modPacks.get(controller.instances.get(id).config.get("factorio.mod_pack_id"));
|
||||||
|
assert.equal(pack.name, "Lobby 2.0 (quality)");
|
||||||
|
assert.equal(pack.factorioVersion, "2.0");
|
||||||
|
assert.deepEqual(
|
||||||
|
[...pack.mods.values()].filter(mod => mod.enabled && mod.name !== "base").map(mod => mod.name),
|
||||||
|
["quality"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use a host that can download the version", async function () {
|
||||||
|
const id = await route({ version: { major: 2, minor: 0, sub: 60, build: 1 }, mods: [] });
|
||||||
|
assert.deepEqual(assigned, [[id, 2]]);
|
||||||
|
assert.equal(await route({ version: { major: 2, minor: 0, sub: 1, build: 1 } }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not create lobbies when disabled", async function () {
|
||||||
|
controller.config.set("lobby.auto_create", false);
|
||||||
|
assert.equal(await route({ version: v2_0_77, mods: spaceAge }), undefined);
|
||||||
|
assert.equal(assigned.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only pick lobbies with the same built in mods", async function () {
|
||||||
|
controller.config.set("controller.default_mod_pack_id", [...controller.modPacks.values()]
|
||||||
|
.find(pack => pack.name === "Space Age 2.1").id);
|
||||||
|
const mods = ["base", "elevated-rails", "quality", "recycler", "space-age"]
|
||||||
|
.map(name => ({ name, version: "2.1.19" }));
|
||||||
|
assert.equal(await route({ mods }), 1);
|
||||||
|
controller.config.set("lobby.auto_create", false);
|
||||||
|
assert.equal(await route({ mods: [{ name: "base", version: "2.1.19" }] }), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("instance plugin", function () {
|
||||||
|
let instance;
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
({ instance } = await mock.loadInstancePlugin(instancePlugin.default, info));
|
||||||
|
instance.mockConfigEntries.set("lobby.is_lobby", true);
|
||||||
|
instance.sendTo = async (dst, message) => {
|
||||||
|
if (message instanceof ServerListRequest) {
|
||||||
|
return [new LobbyServer(3, "Instance \"3\"", "running", "2.1.19", 0)];
|
||||||
|
}
|
||||||
|
if (message instanceof TransferRequest) {
|
||||||
|
return new TransferStatus("ready", "Instance 3", "play.example.com:34197");
|
||||||
|
}
|
||||||
|
throw new Error("unexpected message");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
await instance?.hooks.stop.invoke();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should configure the module and send the server list on start", async function () {
|
||||||
|
await instance.hooks.start.invoke();
|
||||||
|
const commands = instance.server.rconCommands;
|
||||||
|
assert.equal(commands[0], "/sc lobby.configure(true, 7357, false)");
|
||||||
|
assert(commands[1].startsWith("/sc lobby.set_servers(\""), commands[1]);
|
||||||
|
assert(commands[1].includes("Instance \\\\\\\"3\\\\\\\""), commands[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable the web login button when player_auth is loaded", async function () {
|
||||||
|
instance.loadedPlugins.add({ name: "player_auth" });
|
||||||
|
await instance.hooks.start.invoke();
|
||||||
|
assert.equal(instance.server.rconCommands[0], "/sc lobby.configure(true, 7357, true)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move players once their transfer is ready", async function () {
|
||||||
|
instance.server.emit("ipc-lobby:connect", { player: "Player", instance_id: 3 });
|
||||||
|
await lib.wait(10);
|
||||||
|
assert.deepEqual(instance.server.rconCommands, [
|
||||||
|
"/sc lobby.connect_player(\"Player\", \"play.example.com:34197\", \"Instance 3\")",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the status of a transfer that is starting", async function () {
|
||||||
|
const handler = mock.getHandler(instance, TransferStatusEvent);
|
||||||
|
await handler(new TransferStatusEvent("Player", 3, new TransferStatus("starting", "Instance 3")));
|
||||||
|
assert.match(instance.server.rconCommands[0], /^\/sc lobby.set_player_status\("Player", "Starting/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.browser.json",
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../packages/lib/tsconfig.browser.json" },
|
||||||
|
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||||
|
],
|
||||||
|
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.browser.json" },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.node.json",
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
|
{ "path": "../../packages/host/tsconfig.node.json" },
|
||||||
|
{ "path": "../../packages/controller/tsconfig.node.json" },
|
||||||
|
],
|
||||||
|
"include": ["./**/*.ts"],
|
||||||
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [clusterioPlugin()],
|
||||||
|
});
|
||||||
Whitespace-only changes.
@@ -34,5 +34,6 @@ Defaults to `120`.
|
|||||||
|
|
||||||
Show the address to connect to for each server in the server list on the login page of the web interface, made up of host.public_address and the game port of the instance.
|
Show the address to connect to for each server in the server list on the login page of the web interface, made up of host.public_address and the game port of the instance.
|
||||||
This list is visible to anyone who can reach the web interface, so leave it off if you don't want unauthenticated users to see where the servers are.
|
This list is visible to anyone who can reach the web interface, so leave it off if you don't want unauthenticated users to see where the servers are.
|
||||||
|
If `controller.player_proxy_public_address` is set the list shows that address once for all instances without an `instance.player_proxy_port`, as they are reached through the [player proxy](../../docs/player-proxy.md), and the proxy address with the dedicated port for the others.
|
||||||
|
|
||||||
Defaults to `false`.
|
Defaults to `false`.
|
||||||
@@ -46,6 +46,13 @@ export default async function loadControllerPlugin(context: ControllerPluginCont
|
|||||||
|
|
||||||
controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
|
controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
|
||||||
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
|
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
|
||||||
|
const includeAddress = controller.config.get("player_auth.show_connect_address");
|
||||||
|
// When players are meant to connect through the player proxy, instances without a dedicated proxy
|
||||||
|
// port are only reachable through its main port, which is listed once for all of them.
|
||||||
|
const proxyAddress = controller.config.get("controller.player_proxy_public_address")
|
||||||
|
? controller.getPlayerProxyAddress()
|
||||||
|
: undefined;
|
||||||
|
const proxiedVersions = new Set<string | undefined>();
|
||||||
|
|
||||||
for (const instance of controller.instances.values()) {
|
for (const instance of controller.instances.values()) {
|
||||||
const pluginLoaded = instance.config.get("player_auth.load_plugin");
|
const pluginLoaded = instance.config.get("player_auth.load_plugin");
|
||||||
@@ -59,16 +66,34 @@ export default async function loadControllerPlugin(context: ControllerPluginCont
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const address = instance.gamePort !== undefined
|
let address: string | undefined;
|
||||||
? `${host.publicAddress}:${instance.gamePort}`
|
if (proxyAddress) {
|
||||||
: host.publicAddress;
|
const proxyPort = instance.config.get("instance.player_proxy_port");
|
||||||
|
if (proxyPort === null) {
|
||||||
|
proxiedVersions.add(instance.factorioVersion);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
address = controller.getPlayerProxyAddress(proxyPort);
|
||||||
|
} else if (host.publicAddress !== "") {
|
||||||
|
address = instance.gamePort !== undefined
|
||||||
|
? `${host.publicAddress}:${instance.gamePort}`
|
||||||
|
: host.publicAddress;
|
||||||
|
}
|
||||||
|
|
||||||
const settings = instance.config.get("factorio.settings");
|
const settings = instance.config.get("factorio.settings");
|
||||||
const includeAddress = controller.config.get("player_auth.show_connect_address");
|
|
||||||
servers.push({
|
servers.push({
|
||||||
name: settings["name"] as string || "unnamed server",
|
name: settings["name"] as string || "unnamed server",
|
||||||
factorioVersion: instance.factorioVersion,
|
factorioVersion: instance.factorioVersion,
|
||||||
address: (includeAddress && host.publicAddress !== "") ? address : undefined,
|
address: includeAddress ? address : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxiedVersions.size) {
|
||||||
|
const [version] = proxiedVersions;
|
||||||
|
servers.unshift({
|
||||||
|
name: controller.config.get("controller.name"),
|
||||||
|
factorioVersion: proxiedVersions.size === 1 ? version : undefined,
|
||||||
|
address: includeAddress ? proxyAddress : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,32 +138,35 @@ describe("player_auth", function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("/api/player_auth/servers", function() {
|
describe("/api/player_auth/servers", function() {
|
||||||
it("should return a list of running servers with player_auth loaded", async function() {
|
function addInstance(id, status, load, name, version, gamePort, host=1, proxyPort=null) {
|
||||||
function addInstance(id, status, load, name, version, gamePort, host=1) {
|
mockController.instances.records.set(id, {
|
||||||
mockController.instances.records.set(id, {
|
config: {
|
||||||
config: {
|
get(field) {
|
||||||
get(field) {
|
if (field === "player_auth.load_plugin") {
|
||||||
if (field === "player_auth.load_plugin") {
|
return load;
|
||||||
return load;
|
}
|
||||||
}
|
if (field === "factorio.settings") {
|
||||||
if (field === "factorio.settings") {
|
return { name };
|
||||||
return { name };
|
}
|
||||||
}
|
if (field === "instance.id") {
|
||||||
if (field === "instance.id") {
|
return id;
|
||||||
return id;
|
}
|
||||||
}
|
if (field === "instance.assigned_host") {
|
||||||
if (field === "instance.assigned_host") {
|
return host;
|
||||||
return host;
|
}
|
||||||
}
|
if (field === "instance.player_proxy_port") {
|
||||||
throw new Error(`field ${field} not implemented`);
|
return proxyPort;
|
||||||
},
|
}
|
||||||
|
throw new Error(`field ${field} not implemented`);
|
||||||
},
|
},
|
||||||
status,
|
},
|
||||||
gamePort,
|
status,
|
||||||
factorioVersion: version,
|
gamePort,
|
||||||
});
|
factorioVersion: version,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should return a list of running servers with player_auth loaded", async function() {
|
||||||
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
|
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
|
||||||
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
|
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
|
||||||
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
|
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
|
||||||
@@ -186,6 +189,31 @@ describe("player_auth", function() {
|
|||||||
{ name: "no version", address: "127.0.0.1:34197" },
|
{ name: "no version", address: "127.0.0.1:34197" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should list the player proxy once for instances behind it", async function() {
|
||||||
|
for (const id of [...mockController.instances.records.keys()]) {
|
||||||
|
mockController.instances.records.delete(id);
|
||||||
|
}
|
||||||
|
mockController.mockConfigEntries.set("controller.name", "Cluster");
|
||||||
|
mockController.mockConfigEntries.set(
|
||||||
|
"controller.player_proxy_public_address", "play.example:34197"
|
||||||
|
);
|
||||||
|
mockController.getPlayerProxyAddress = port => `play.example:${port ?? 34197}`;
|
||||||
|
try {
|
||||||
|
addInstance(1, "running", true, "behind proxy", "2.1.19", 34201);
|
||||||
|
addInstance(2, "running", true, "also behind proxy", "2.1.19", 34202);
|
||||||
|
addInstance(3, "running", true, "dedicated port", "2.1.19", 34203, 1, 34300);
|
||||||
|
addInstance(4, "stopped", true, "stopped", "2.1.19", 34204, 1, 34301);
|
||||||
|
|
||||||
|
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
|
||||||
|
assert.deepEqual(await result.json(), [
|
||||||
|
{ name: "Cluster", address: "play.example:34197", factorioVersion: "2.1.19" },
|
||||||
|
{ name: "dedicated port", address: "play.example:34300", factorioVersion: "2.1.19" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
mockController.mockConfigEntries.set("controller.player_proxy_public_address", null);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/api/player_auth/player_code", function() {
|
describe("/api/player_auth/player_code", function() {
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import dgram from "node:dgram";
|
||||||
|
import events from "node:events";
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
import { Controller, HostRecord, InstanceRecord, PlayerProxyServer, proxyProtocol } from "@clusterio/controller";
|
||||||
|
|
||||||
|
const { MessageType } = proxyProtocol;
|
||||||
|
const clientVersion = { major: 2, minor: 0, sub: 72, build: 84292 };
|
||||||
|
const defaultMods = [
|
||||||
|
{ name: "base", version: "2.0.72" },
|
||||||
|
{ name: "space-age", version: "2.0.72" },
|
||||||
|
{ name: "big-mod", version: "1.300.2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a ConnectionRequestReplyConfirm the way a Factorio client does
|
||||||
|
*
|
||||||
|
* @param {object} options - fields of the message.
|
||||||
|
* @returns {Buffer[]} the message split into the given number of fragments.
|
||||||
|
*/
|
||||||
|
function encodeConfirm({
|
||||||
|
clientRequestId, serverRequestId, username, messageId = 1, fragments = 1, mods = defaultMods,
|
||||||
|
}) {
|
||||||
|
const payload = new proxyProtocol.BufferWriter();
|
||||||
|
const writeString = text => {
|
||||||
|
const bytes = Buffer.from(text);
|
||||||
|
payload.writeUInt8(bytes.length);
|
||||||
|
for (const byte of bytes) {
|
||||||
|
payload.writeUInt8(byte);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
payload.writeUInt32LE(clientRequestId);
|
||||||
|
payload.writeUInt32LE(serverRequestId);
|
||||||
|
payload.writeUInt32LE(1234); // instanceID
|
||||||
|
writeString(username);
|
||||||
|
writeString(""); // password
|
||||||
|
writeString("server-key");
|
||||||
|
writeString("1700000000");
|
||||||
|
payload.writeUInt32LE(0x11111111); // coreChecksum
|
||||||
|
payload.writeUInt32LE(0x22222222); // prototypeListChecksum
|
||||||
|
payload.writeUInt8(mods.length);
|
||||||
|
for (const mod of mods) {
|
||||||
|
writeString(mod.name);
|
||||||
|
for (const part of mod.version.split(".")) {
|
||||||
|
payload.writeSpaceOptimizedUInt16(Number(part));
|
||||||
|
}
|
||||||
|
payload.writeUInt32LE(0x33333333); // crc
|
||||||
|
}
|
||||||
|
// Stands in for the startup mod settings
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
payload.writeUInt8(i);
|
||||||
|
}
|
||||||
|
const body = payload.toBuffer();
|
||||||
|
|
||||||
|
if (fragments === 1) {
|
||||||
|
return [Buffer.concat([Buffer.from([MessageType.ConnectionRequestReplyConfirm, messageId, 0]), body])];
|
||||||
|
}
|
||||||
|
const size = Math.ceil(body.length / fragments);
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < fragments; i++) {
|
||||||
|
const last = i === fragments - 1;
|
||||||
|
const header = Buffer.from([
|
||||||
|
MessageType.ConnectionRequestReplyConfirm | 0x40 | (last ? 0x80 : 0),
|
||||||
|
messageId, 0, i,
|
||||||
|
]);
|
||||||
|
result.push(Buffer.concat([header, body.subarray(i * size, (i + 1) * size)]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal stand in for a Factorio server's side of the handshake */
|
||||||
|
class FakeFactorioServer {
|
||||||
|
constructor(version = clientVersion) {
|
||||||
|
this.version = version;
|
||||||
|
this.serverRequestId = 0xabcd1234;
|
||||||
|
this.received = [];
|
||||||
|
this.socket = dgram.createSocket("udp4");
|
||||||
|
this.socket.on("message", (message, rinfo) => this.handle(message, rinfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.socket.bind(0, "127.0.0.1");
|
||||||
|
await events.once(this.socket, "listening");
|
||||||
|
this.port = this.socket.address().port;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(message, rinfo) {
|
||||||
|
this.received.push(message);
|
||||||
|
const type = proxyProtocol.messageType(message);
|
||||||
|
if (type === MessageType.ConnectionRequest) {
|
||||||
|
const request = proxyProtocol.parseConnectionRequest(message);
|
||||||
|
this.socket.send(proxyProtocol.encodeConnectionRequestReply({
|
||||||
|
version: this.version,
|
||||||
|
clientRequestId: request.clientRequestId,
|
||||||
|
serverRequestId: this.serverRequestId,
|
||||||
|
maxPacketSize: 508,
|
||||||
|
}), rinfo.port, rinfo.address);
|
||||||
|
} else if (type === MessageType.ConnectionRequestReplyConfirm) {
|
||||||
|
const header = proxyProtocol.parseHeader(message);
|
||||||
|
if (!header.fragmented || header.lastFragment) {
|
||||||
|
const accept = Buffer.from([MessageType.ConnectionAcceptOrDeny, this.port & 0xff]);
|
||||||
|
this.socket.send(accept, rinfo.port, rinfo.address);
|
||||||
|
}
|
||||||
|
} else if (type === 6) { // ClientToServerHeartbeat
|
||||||
|
this.socket.send(Buffer.from([7, ...message.subarray(1)]), rinfo.port, rinfo.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirms() {
|
||||||
|
return this.received.filter(m => proxyProtocol.messageType(m) === MessageType.ConnectionRequestReplyConfirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeClient {
|
||||||
|
constructor(proxyPort) {
|
||||||
|
this.proxyPort = proxyPort;
|
||||||
|
this.socket = dgram.createSocket("udp4");
|
||||||
|
this.queue = [];
|
||||||
|
this.waiters = [];
|
||||||
|
this.socket.on("message", message => {
|
||||||
|
const waiter = this.waiters.shift();
|
||||||
|
if (waiter) {
|
||||||
|
waiter(message);
|
||||||
|
} else {
|
||||||
|
this.queue.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.socket.bind(0, "127.0.0.1");
|
||||||
|
await events.once(this.socket, "listening");
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message) {
|
||||||
|
this.socket.send(message, this.proxyPort, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
receive(timeoutMs = 2000) {
|
||||||
|
if (this.queue.length) {
|
||||||
|
return Promise.resolve(this.queue.shift());
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||||
|
reject(new Error("Timed out waiting for packet"));
|
||||||
|
}, timeoutMs);
|
||||||
|
const waiter = message => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(message);
|
||||||
|
};
|
||||||
|
this.waiters.push(waiter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Perform the handshake and return the first packet forwarded from the server */
|
||||||
|
async connect(username, { clientRequestId = 42, fragments = 1 } = {}) {
|
||||||
|
this.send(proxyProtocol.encodeConnectionRequest(clientVersion, clientRequestId));
|
||||||
|
const reply = proxyProtocol.parseConnectionRequestReply(await this.receive());
|
||||||
|
for (const fragment of encodeConfirm({
|
||||||
|
clientRequestId, serverRequestId: reply.serverRequestId, username, messageId: clientRequestId, fragments,
|
||||||
|
})) {
|
||||||
|
this.send(fragment);
|
||||||
|
}
|
||||||
|
return { reply, accept: await this.receive() };
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const socket = dgram.createSocket("udp4");
|
||||||
|
socket.bind(0, "127.0.0.1");
|
||||||
|
await events.once(socket, "listening");
|
||||||
|
const { port } = socket.address();
|
||||||
|
socket.close();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("controller/proxy_protocol", function () {
|
||||||
|
it("should round trip a connection request", function () {
|
||||||
|
const buffer = proxyProtocol.encodeConnectionRequest({ major: 2, minor: 1, sub: 300, build: 90000 }, 7, 5);
|
||||||
|
const request = proxyProtocol.parseConnectionRequest(buffer);
|
||||||
|
assert.deepEqual(request.version, { major: 2, minor: 1, sub: 300, build: 90000 });
|
||||||
|
assert.equal(request.clientRequestId, 7);
|
||||||
|
assert.equal(request.header.messageId, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should round trip a connection request reply", function () {
|
||||||
|
const reply = { version: clientVersion, clientRequestId: 1, serverRequestId: 0xffffffff, maxPacketSize: 508 };
|
||||||
|
const encoded = proxyProtocol.encodeConnectionRequestReply(reply);
|
||||||
|
assert.deepEqual(proxyProtocol.parseConnectionRequestReply(encoded), reply);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse confirms in the header", function () {
|
||||||
|
const buffer = Buffer.from([MessageType.ConnectionRequestReplyConfirm, 3, 0x80, 2, 1, 0, 0, 0, 2, 0, 0, 0]);
|
||||||
|
const header = proxyProtocol.parseHeader(buffer);
|
||||||
|
assert.equal(header.messageId, 3);
|
||||||
|
assert.deepEqual(header.confirms, [1, 2]);
|
||||||
|
assert.equal(header.payloadOffset, buffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse the first fragment of a fragmented confirm", function () {
|
||||||
|
const [first, second] = encodeConfirm({
|
||||||
|
clientRequestId: 1, serverRequestId: 2, username: "Player", fragments: 2,
|
||||||
|
});
|
||||||
|
const confirm = proxyProtocol.parseConnectionRequestReplyConfirm(first);
|
||||||
|
assert.equal(confirm.username, "Player");
|
||||||
|
assert.equal(first.readUInt32LE(confirm.serverRequestIdOffset), 2);
|
||||||
|
assert.throws(() => proxyProtocol.parseConnectionRequestReplyConfirm(second), /first fragment/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read the mods from a fragmented confirm", function () {
|
||||||
|
const fragments = encodeConfirm({ clientRequestId: 1, serverRequestId: 2, username: "Player", fragments: 3 });
|
||||||
|
assert.deepEqual(proxyProtocol.parseConfirmMods(proxyProtocol.joinPayload(fragments)), defaultMods);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject truncated messages", function () {
|
||||||
|
assert.throws(() => proxyProtocol.parseConnectionRequest(Buffer.from([MessageType.ConnectionRequest, 0])));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build a ping reply", function () {
|
||||||
|
const reply = proxyProtocol.pingReplyFor(Buffer.from([0x20, 5, 1]));
|
||||||
|
assert.deepEqual(reply, Buffer.from([MessageType.PingReply, 5, 1]));
|
||||||
|
assert.equal(proxyProtocol.pingReplyFor(Buffer.from([0])), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("controller/PlayerProxyServer", function () {
|
||||||
|
let controller;
|
||||||
|
let proxy;
|
||||||
|
let servers;
|
||||||
|
let clients;
|
||||||
|
let proxyPort;
|
||||||
|
|
||||||
|
function addInstance(id, server, status = "running", fields = {}) {
|
||||||
|
const config = new lib.InstanceConfig("controller", {
|
||||||
|
"instance.id": id,
|
||||||
|
"instance.name": `Instance ${id}`,
|
||||||
|
"instance.assigned_host": 1,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
controller.instances.records.set(new InstanceRecord(config, status, server?.port));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newClient(port = proxyPort) {
|
||||||
|
const client = new FakeClient(port);
|
||||||
|
await client.start();
|
||||||
|
clients.push(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
const config = new lib.ControllerConfig("controller", { "controller.name": "Test" });
|
||||||
|
controller = new Controller(lib.logger, [], config);
|
||||||
|
controller.hosts.set(new HostRecord(1, "Host", "2.0.0", new Map(), true, "::ffff:127.0.0.1"));
|
||||||
|
servers = [new FakeFactorioServer(), new FakeFactorioServer()];
|
||||||
|
await Promise.all(servers.map(server => server.start()));
|
||||||
|
addInstance(1, servers[0]);
|
||||||
|
addInstance(2, servers[1]);
|
||||||
|
clients = [];
|
||||||
|
proxyPort = await freePort();
|
||||||
|
proxy = new PlayerProxyServer(controller, lib.logger, proxyPort);
|
||||||
|
await proxy.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
await proxy.stop();
|
||||||
|
servers.forEach(server => server.close());
|
||||||
|
clients.forEach(client => client.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete the handshake through the proxy", async function () {
|
||||||
|
const client = await newClient();
|
||||||
|
const { reply, accept } = await client.connect("Player", { fragments: 3 });
|
||||||
|
|
||||||
|
assert.deepEqual(reply.version, clientVersion);
|
||||||
|
assert.equal(reply.maxPacketSize, 508);
|
||||||
|
assert.equal(accept[0], MessageType.ConnectionAcceptOrDeny);
|
||||||
|
|
||||||
|
const confirms = servers[0].confirms();
|
||||||
|
assert.equal(confirms.length, 3);
|
||||||
|
const confirm = proxyProtocol.parseConnectionRequestReplyConfirm(confirms[0]);
|
||||||
|
assert.equal(confirm.serverRequestId, servers[0].serverRequestId);
|
||||||
|
assert.equal(confirm.username, "Player");
|
||||||
|
|
||||||
|
client.send(Buffer.from([6, 1, 2, 3]));
|
||||||
|
assert.deepEqual(await client.receive(), Buffer.from([7, 1, 2, 3]));
|
||||||
|
assert.equal(proxy.serverVersions.get(1).build, clientVersion.build);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should route once the whole confirm has arrived", async function () {
|
||||||
|
const requests = [];
|
||||||
|
controller.hooks.playerProxyRoute.attach("test", request => {
|
||||||
|
requests.push(request);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const client = await newClient();
|
||||||
|
client.send(proxyProtocol.encodeConnectionRequest(clientVersion, 7));
|
||||||
|
const reply = proxyProtocol.parseConnectionRequestReply(await client.receive());
|
||||||
|
const [first, second, third] = encodeConfirm({
|
||||||
|
clientRequestId: 7, serverRequestId: reply.serverRequestId, username: "Player", messageId: 7, fragments: 3,
|
||||||
|
});
|
||||||
|
client.send(third);
|
||||||
|
client.send(first);
|
||||||
|
await lib.wait(50);
|
||||||
|
assert.equal(requests.length, 0, "routed before all fragments arrived");
|
||||||
|
client.send(second);
|
||||||
|
assert.equal((await client.receive())[0], MessageType.ConnectionAcceptOrDeny);
|
||||||
|
assert.deepEqual(requests[0].mods, defaultMods);
|
||||||
|
assert.equal(servers[0].confirms().length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should route by the playerProxyRoute hook", async function () {
|
||||||
|
controller.hooks.playerProxyRoute.attach("test", request => {
|
||||||
|
assert.deepEqual(request.version, clientVersion);
|
||||||
|
return request.username === "Special" ? 2 : undefined;
|
||||||
|
});
|
||||||
|
const client = await newClient();
|
||||||
|
await client.connect("Special");
|
||||||
|
assert.equal(servers[1].confirms().length, 1);
|
||||||
|
assert.equal(servers[0].confirms().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reroute a new connection from the same socket", async function () {
|
||||||
|
let target = 1;
|
||||||
|
controller.hooks.playerProxyRoute.attach("test", () => target);
|
||||||
|
const client = await newClient();
|
||||||
|
await client.connect("Player", { clientRequestId: 1 });
|
||||||
|
assert.equal(servers[0].confirms().length, 1);
|
||||||
|
|
||||||
|
target = 2;
|
||||||
|
await client.connect("Player", { clientRequestId: 2 });
|
||||||
|
assert.equal(servers[1].confirms().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should answer pings for clients that are not connected", async function () {
|
||||||
|
const client = await newClient();
|
||||||
|
client.send(Buffer.from([MessageType.Ping, 9, 0]));
|
||||||
|
assert.deepEqual(await client.receive(), Buffer.from([MessageType.PingReply, 9, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should route dedicated instance ports to their instance", async function () {
|
||||||
|
const port = await freePort();
|
||||||
|
controller.instances.getMutable(2).config.set("instance.player_proxy_port", port);
|
||||||
|
controller.instances.records.set(controller.instances.getMutable(2));
|
||||||
|
await proxy.syncListeners();
|
||||||
|
assert.equal(proxy.listeners.get(port), 2);
|
||||||
|
|
||||||
|
const client = await newClient(port);
|
||||||
|
await client.connect("Player");
|
||||||
|
assert.equal(servers[1].confirms().length, 1);
|
||||||
|
|
||||||
|
controller.instances.getMutable(2).config.set("instance.player_proxy_port", null);
|
||||||
|
await proxy.syncListeners();
|
||||||
|
assert.equal(proxy.listeners.has(port), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start a stopped instance", async function () {
|
||||||
|
controller.instances.getMutable(2).status = "stopped";
|
||||||
|
controller.instances.getMutable(2).gamePort = undefined;
|
||||||
|
controller.hooks.playerProxyRoute.attach("test", () => 2);
|
||||||
|
const started = [];
|
||||||
|
controller.sendTo = async (dst, message) => {
|
||||||
|
assert(message instanceof lib.InstanceStartRequest);
|
||||||
|
started.push(dst.instanceId);
|
||||||
|
const instance = controller.instances.getMutable(dst.instanceId);
|
||||||
|
instance.status = "running";
|
||||||
|
instance.gamePort = servers[1].port;
|
||||||
|
};
|
||||||
|
const client = await newClient();
|
||||||
|
await client.connect("Player");
|
||||||
|
assert.deepEqual(started, [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should probe the version of a server", async function () {
|
||||||
|
servers[0].version = { major: 2, minor: 1, sub: 5, build: 12345 };
|
||||||
|
assert.deepEqual(await PlayerProxyServer.probeVersion("127.0.0.1", servers[0].port), servers[0].version);
|
||||||
|
assert.deepEqual(await proxy.instanceVersion(1), servers[0].version);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("controller/Controller.resolvePlayerRoute()", function () {
|
||||||
|
let controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
const config = new lib.ControllerConfig("controller", { "controller.name": "Test" });
|
||||||
|
controller = new Controller(lib.logger, [], config);
|
||||||
|
for (const [id, status] of [[1, "running"], [2, "stopped"], [3, "stopped"], [4, "stopped"]]) {
|
||||||
|
controller.instances.records.set(new InstanceRecord(new lib.InstanceConfig("controller", {
|
||||||
|
"instance.id": id, "instance.assigned_host": 1,
|
||||||
|
}), status));
|
||||||
|
}
|
||||||
|
controller.instances.records.set(new InstanceRecord(new lib.InstanceConfig("controller", {
|
||||||
|
"instance.id": 5,
|
||||||
|
}), "unassigned"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = (fields = {}) => ({
|
||||||
|
username: "player", version: clientVersion, clientAddress: "127.0.0.1", clientPort: 1, listenPort: 1, ...fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer hooks, then dedicated port, then last instance, then default", async function () {
|
||||||
|
const user = controller.users.getOrCreateUser("Player");
|
||||||
|
user.instanceStats.set(3, Object.assign(new lib.PlayerStats(), { lastJoinAt: new Date(2000) }));
|
||||||
|
user.instanceStats.set(4, Object.assign(new lib.PlayerStats(), { lastJoinAt: new Date(1000) }));
|
||||||
|
controller.config.set("controller.player_proxy_default_instance_id", 4);
|
||||||
|
|
||||||
|
assert.equal(await controller.resolvePlayerRoute(request()), 3);
|
||||||
|
assert.equal(await controller.resolvePlayerRoute(request({ username: "other" })), 4);
|
||||||
|
assert.equal(await controller.resolvePlayerRoute(request({ portInstanceId: 2 })), 2);
|
||||||
|
controller.hooks.playerProxyRoute.attach("test", () => 4);
|
||||||
|
assert.equal(await controller.resolvePlayerRoute(request({ portInstanceId: 2 })), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should give the public address of the proxy", function () {
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(), undefined, "proxy disabled");
|
||||||
|
controller.playerProxyServer = { port: 34197 };
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(), undefined, "no address configured");
|
||||||
|
controller.config.set("controller.public_url", "https://cluster.example/");
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(), "cluster.example:34197");
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(34200), "cluster.example:34200");
|
||||||
|
controller.config.set("controller.player_proxy_public_address", "play.example:5000");
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(), "play.example:5000");
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(34200), "play.example:34200");
|
||||||
|
controller.config.set("controller.player_proxy_public_address", "[::1]");
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(34200), "[::1]:34200");
|
||||||
|
controller.config.set("controller.player_proxy_public_address", "::1");
|
||||||
|
assert.equal(controller.getPlayerProxyAddress(34200), "[::1]:34200");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip instances that can't be routed to", async function () {
|
||||||
|
controller.hooks.playerProxyRoute.attach("test", () => 5);
|
||||||
|
assert.equal(await controller.resolvePlayerRoute(request({ portInstanceId: 99 })), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -271,4 +271,35 @@ describe("host/patch", function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setSaveScenario()", function() {
|
||||||
|
it("should let a save with an unknown scenario be patched without loading it", async function() {
|
||||||
|
await fs.mkdir(path.join("temp", "test"), { recursive: true });
|
||||||
|
const zipPath = path.join("temp", "test", "set-scenario.zip");
|
||||||
|
await fs.writeFile(zipPath, await createZipBuffer([
|
||||||
|
["world/control.lua", "-- unknown\n"],
|
||||||
|
["world/level.dat", "level"],
|
||||||
|
]));
|
||||||
|
|
||||||
|
await patch.setSaveScenario(zipPath, new lib.ModuleInfo("empty", "0.0.0"));
|
||||||
|
await patch.patch(zipPath, []);
|
||||||
|
|
||||||
|
const zip = await lib.ZipArchive.fromFile(zipPath);
|
||||||
|
try {
|
||||||
|
const info = JSON.parse((await zip.readFile("world/clusterio.json")).toString());
|
||||||
|
assert.equal(info.scenario.name, "empty");
|
||||||
|
assert.deepEqual(info.scenario.load, []);
|
||||||
|
assert.equal(info.patch_number, 1);
|
||||||
|
assert.equal((await zip.readFile("world/level.dat")).toString(), "level");
|
||||||
|
assert.doesNotMatch((await zip.readFile("world/control.lua")).toString(), /unknown/);
|
||||||
|
} finally {
|
||||||
|
zip.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
patch.setSaveScenario(zipPath, new lib.ModuleInfo("empty", "0.0.0")),
|
||||||
|
new Error("Unable to set scenario of a save that has already been patched")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -102,6 +102,12 @@ export class MockServer extends events.EventEmitter {
|
|||||||
this.udpMessages.push(message);
|
this.udpMessages.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle(eventName, handler) {
|
||||||
|
this.on(`ipc-${eventName}`, event => handler(event).catch(err => {
|
||||||
|
this.emit("error", err);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async sendRcon(command) {
|
async sendRcon(command) {
|
||||||
this.rconCommands.push(command);
|
this.rconCommands.push(command);
|
||||||
let result = this.rconCommandResults.get(command);
|
let result = this.rconCommandResults.get(command);
|
||||||
@@ -176,6 +182,7 @@ export class MockController {
|
|||||||
["controller.auth_secret", "TestSecretDoNotUse"],
|
["controller.auth_secret", "TestSecretDoNotUse"],
|
||||||
["controller.proxy_stream_timeout", 1],
|
["controller.proxy_stream_timeout", 1],
|
||||||
["controller.metrics_timeout", 1],
|
["controller.metrics_timeout", 1],
|
||||||
|
["controller.player_proxy_public_address", null],
|
||||||
]);
|
]);
|
||||||
this.config = {
|
this.config = {
|
||||||
get: (name) => {
|
get: (name) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
{ "path": "./packages/ctl/" },
|
{ "path": "./packages/ctl/" },
|
||||||
{ "path": "./packages/web_ui/" },
|
{ "path": "./packages/web_ui/" },
|
||||||
{ "path": "./plugins/global_chat/" },
|
{ "path": "./plugins/global_chat/" },
|
||||||
|
{ "path": "./plugins/lobby/" },
|
||||||
{ "path": "./plugins/player_sync/" },
|
{ "path": "./plugins/player_sync/" },
|
||||||
{ "path": "./plugins/player_auth/" },
|
{ "path": "./plugins/player_auth/" },
|
||||||
{ "path": "./plugins/research_sync/" },
|
{ "path": "./plugins/research_sync/" },
|
||||||
|
|||||||
Reference in new issue
Block a user