Compare commits

..
15 changed files with 916 additions and 27 deletions

No files matched your search

+12 -3
View File
@@ -29,13 +29,22 @@ dist/
node_modules
/factorio*
Factorio*
!*Factorio*.ts
!*Factorio*.tsx
!*Factorio*.js
/factorio
!/test/file/factorio
!/test/file/html/*.html
!/test/lib/factorio
!/packages/lib/src/factorio
/instances
/mods
/database
/plugin-list.json
/config-*.json
/config-*.json.lock
/config-controller.json
/config-control.json
/config-host.json
pnpm-lock.yaml
mod_cache.json
/static/
-1
View File
@@ -1,3 +1,2 @@
*
!.gitignore
!README.md
+106
View File
@@ -28,6 +28,8 @@ import HostConnection from "./HostConnection";
import HostInfo from "./HostInfo";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import PlayerRoutingStore from "./PlayerRoutingStore";
import PlayerProxyServer from "./PlayerProxyServer";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -76,6 +78,8 @@ export default class Controller {
trustedProxies: BlockList;
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
private _events = new events.EventEmitter<ControllerEvents>();
playerRoutingStore: PlayerRoutingStore;
playerProxyServer: PlayerProxyServer | null = null;
/** Event subscription controller */
subscriptions = new lib.SubscriptionController();
@@ -209,6 +213,9 @@ export default class Controller {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
this.config = config;
this.playerRoutingStore = new PlayerRoutingStore(
path.join(this.config.get("controller.database_directory"), "player-routing.json"),
);
this.app = express();
this.app.locals.controller = this;
@@ -342,6 +349,17 @@ export default class Controller {
// Load plugins
await this.loadPlugins();
await this.playerRoutingStore.load();
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.playerProxyServer.start();
}
// Log all express errors from middleware or routes (app.use is missing the type overload)
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
logger.log({
@@ -484,6 +502,11 @@ export default class Controller {
await lib.invokeHook(this.plugins, "onShutdown");
if (this.playerProxyServer) {
await this.playerProxyServer.stop();
this.playerProxyServer = null;
}
await this.wsServer.stop();
let stopTasks = [];
@@ -651,6 +674,8 @@ export default class Controller {
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
}
await this.playerRoutingStore.save();
await lib.invokeHook(this.plugins, "onSaveData");
}
@@ -860,6 +885,87 @@ export default class Controller {
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
}
resolvePlayerRoute(username: string): { hostId: number; instanceId: number; publicAddress: string; gamePort: number } | null {
const key = username.toLowerCase();
let route = this.playerRoutingStore.get(key);
let instance: InstanceInfo | undefined;
if (!route) {
const fallbackId = this.config.get("controller.player_proxy_default_instance_id");
if (fallbackId !== null) {
instance = this.instances.get(fallbackId);
if (instance) {
route = {
name: username,
instanceId: instance.id,
hostId: instance.config.get("instance.assigned_host") ?? -1,
gamePort: instance.gamePort ?? 0,
timestampMs: Date.now(),
};
}
} else {
instance = this._chooseFallbackInstance();
if (instance) {
route = {
name: username,
instanceId: instance.id,
hostId: instance.config.get("instance.assigned_host") ?? -1,
gamePort: instance.gamePort ?? 0,
timestampMs: Date.now(),
};
}
}
}
if (!route) {
return null;
}
instance = instance ?? this.instances.get(route.instanceId);
if (!instance || instance.isDeleted) {
this.playerRoutingStore.delete(key);
return null;
}
const hostId = instance.config.get("instance.assigned_host");
if (hostId === null || route.hostId !== hostId) {
this.playerRoutingStore.delete(key);
return null;
}
const host = this.hosts.get(hostId);
if (!host || !host.connected) {
return null;
}
const gamePort = instance.gamePort ?? route.gamePort;
const publicAddress = host.publicAddress || host.remoteAddress;
if (!gamePort || !publicAddress) {
return null;
}
return {
hostId,
instanceId: instance.id,
publicAddress,
gamePort,
};
}
private _chooseFallbackInstance() {
for (const instance of this.instances.values()) {
if (instance.status === "running" && !instance.isDeleted) {
return instance;
}
}
for (const instance of this.instances.values()) {
if (!instance.isDeleted && instance.config.get("instance.assigned_host") !== null) {
return instance;
}
}
return undefined;
}
/**
* Get instance by ID for a request
*
+26
View File
@@ -72,6 +72,7 @@ export default class HostConnection extends BaseConnection {
this.info.connected = false;
this._controller.hosts.set(this.info);
this._controller.playerRoutingStore.dropHost(this.id);
});
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
@@ -81,6 +82,7 @@ export default class HostConnection extends BaseConnection {
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
this.handle(lib.InstancePlayerRouteUpdateEvent, this.handleInstancePlayerRouteUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
@@ -354,6 +356,30 @@ export default class HostConnection extends BaseConnection {
});
}
async handleInstancePlayerRouteUpdateEvent(event: lib.InstancePlayerRouteUpdateEvent, src: lib.Address) {
const instanceId = src.id;
if (event.instanceId !== instanceId) {
logger.warn(
`Received route update for instance ${event.instanceId} from ${instanceId}, ignoring mismatched id`
);
return;
}
if (event.hostId !== this.id) {
logger.warn(
`Received route update for host ${event.hostId} from ${this.id}, ignoring mismatched host`
);
return;
}
this._controller.playerRoutingStore.update(
event.name,
instanceId,
this.id,
event.gamePort,
event.timestampMs,
);
}
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
@@ -0,0 +1,511 @@
import dgram from "dgram";
import * as lib from "@clusterio/lib";
import type Controller from "./Controller";
const TYPE_MASK = 0x1f;
const FLAG_MASK = 0xe0;
const TYPE_CONNECTION_REQUEST = 2;
const TYPE_CONNECTION_REQUEST_REPLY = 3;
const TYPE_CONNECTION_REQUEST_REPLY_CONFIRM = 4;
const SESSION_IDLE_TIMEOUT_MS = 30_000;
const MAX_PENDING_PACKETS = 64;
const MAX_PACKET_SIZE = 1400;
type ClientKey = string;
type RouteTarget = {
hostId: number;
instanceId: number;
publicAddress: string;
gamePort: number;
};
type ClientSession = {
clientAddress: string;
clientPort: number;
lastSeenMs: number;
pending: Buffer[];
upstream: dgram.Socket | null;
route: RouteTarget | null;
requestBuffer?: Buffer;
requestFlags?: number;
requestMessageIdAndFlags?: number;
requestVersion?: ApplicationVersion;
clientRequestId?: number;
proxyServerId?: number;
pendingConfirm?: Buffer;
confirmServerIdOffset?: number;
serverConnectionId?: number;
username?: string;
activating?: Promise<void>;
};
type ApplicationVersion = {
major: number;
minor: number;
sub: number;
build: number;
};
class BufferReader {
private _offset = 0;
constructor(private _buffer: Buffer) {}
get offset() {
return this._offset;
}
readUInt8() {
const value = this._buffer.readUInt8(this._offset);
this._offset += 1;
return value;
}
readUInt16LE() {
const value = this._buffer.readUInt16LE(this._offset);
this._offset += 2;
return value;
}
readUInt32LE() {
const value = this._buffer.readUInt32LE(this._offset);
this._offset += 4;
return value;
}
readSpaceOptimized() {
const value = this.readUInt8();
if (value === 0xff) {
return this.readUInt16LE();
}
return value;
}
readContainerSize() {
const value = this.readUInt8();
if (value === 0xff) {
return this.readUInt32LE();
}
return value;
}
readBytes(length: number) {
const start = this._offset;
const end = start + length;
if (end > this._buffer.length) {
throw new Error("Buffer too short");
}
this._offset = end;
return this._buffer.slice(start, end);
}
}
class BufferWriter {
private _parts: Buffer[] = [];
writeUInt8(value: number) {
const buf = Buffer.allocUnsafe(1);
buf.writeUInt8(value, 0);
this._parts.push(buf);
}
writeUInt16LE(value: number) {
const buf = Buffer.allocUnsafe(2);
buf.writeUInt16LE(value, 0);
this._parts.push(buf);
}
writeUInt32LE(value: number) {
const buf = Buffer.allocUnsafe(4);
buf.writeUInt32LE(value >>> 0, 0);
this._parts.push(buf);
}
writeSpaceOptimized(value: number) {
if (value >= 0xff) {
this.writeUInt8(0xff);
this.writeUInt16LE(value);
} else {
this.writeUInt8(value);
}
}
concat() {
return Buffer.concat(this._parts);
}
}
function clientKey(address: string, port: number): ClientKey {
return `${address}:${port}`;
}
export default class PlayerProxyServer {
private _socket: dgram.Socket | null = null;
private _sessions = new Map<ClientKey, ClientSession>();
private _cleanupTimer: ReturnType<typeof setInterval> | undefined;
private _closed = false;
constructor(
private _controller: Controller,
private _logger: lib.Logger,
private _port: number,
) {}
async start() {
if (this._socket || this._closed) {
return;
}
const socket = dgram.createSocket("udp4");
this._socket = socket;
socket.on("message", this._handleClientMessage.bind(this));
socket.on("error", err => {
this._logger.error(`Player proxy socket error: ${err.message}`);
this.stop().catch(() => {});
});
await new Promise<void>((resolve, reject) => {
socket.once("listening", resolve);
socket.once("error", reject);
socket.bind(this._port, "0.0.0.0");
});
this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS);
this._cleanupTimer.unref();
this._logger.info(`Player proxy listening on UDP ${this._port}`);
}
async stop() {
if (this._closed) {
return;
}
this._closed = true;
if (this._cleanupTimer) {
clearInterval(this._cleanupTimer);
this._cleanupTimer = undefined;
}
for (const session of this._sessions.values()) {
session.upstream?.close();
}
this._sessions.clear();
if (this._socket) {
await new Promise<void>(resolve => this._socket!.close(() => resolve()));
this._socket = null;
}
}
private _handleClientMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
if (this._closed || !this._socket) {
return;
}
const key = clientKey(rinfo.address, rinfo.port);
let session = this._sessions.get(key);
if (!session) {
session = this._createSession(rinfo.address, rinfo.port);
this._sessions.set(key, session);
}
session.lastSeenMs = Date.now();
let type: number;
try {
type = message.readUInt8(0) & TYPE_MASK;
} catch {
return;
}
if (type === TYPE_CONNECTION_REQUEST) {
this._handleConnectionRequest(session, message);
return;
}
if (type === TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
this._handleConnectionRequestReplyConfirm(session, message);
return;
}
if (!session.route || !session.upstream) {
this._queuePending(session, message);
return;
}
this._flushPending(session);
session.upstream.send(message, session.route.gamePort, session.route.publicAddress);
}
private _createSession(address: string, port: number): ClientSession {
return {
clientAddress: address,
clientPort: port,
lastSeenMs: Date.now(),
pending: [],
upstream: null,
route: null,
};
}
private _handleConnectionRequest(session: ClientSession, message: Buffer) {
let parsed: {
flags: number;
messageIdAndFlags: number;
version: ApplicationVersion;
requestId: number;
};
try {
parsed = this._parseConnectionRequest(message);
} catch (err: any) {
this._logger.warn(`Failed to parse connection request: ${err.message ?? err}`);
return;
}
session.requestBuffer = message;
session.requestFlags = parsed.flags;
session.requestMessageIdAndFlags = parsed.messageIdAndFlags;
session.requestVersion = parsed.version;
session.clientRequestId = parsed.requestId;
if (!session.proxyServerId) {
session.proxyServerId = Math.floor(Math.random() * 2 ** 32);
}
const reply = this._buildConnectionRequestReply(session);
this._socket?.send(reply, session.clientPort, session.clientAddress);
}
private _handleConnectionRequestReplyConfirm(session: ClientSession, message: Buffer) {
let parsed: { username: string; serverIdOffset: number };
try {
parsed = this._parseConnectionRequestReplyConfirm(message);
} catch (err: any) {
this._logger.warn(`Failed to parse connection confirm: ${err.message ?? err}`);
return;
}
session.username = parsed.username;
session.pendingConfirm = message;
session.confirmServerIdOffset = parsed.serverIdOffset;
if (session.route || session.activating) {
return;
}
session.activating = this._activateRoute(session).finally(() => {
session.activating = undefined;
});
}
private _buildConnectionRequestReply(session: ClientSession) {
const flags = session.requestFlags ?? 0;
const version = session.requestVersion ?? { major: 0, minor: 0, sub: 0, build: 0 };
const requestId = session.clientRequestId ?? 0;
const serverId = session.proxyServerId ?? 0;
const writer = new BufferWriter();
writer.writeUInt8((flags & FLAG_MASK) | TYPE_CONNECTION_REQUEST_REPLY);
writer.writeSpaceOptimized(version.major);
writer.writeSpaceOptimized(version.minor);
writer.writeSpaceOptimized(version.sub);
writer.writeUInt32LE(version.build);
writer.writeUInt32LE(requestId);
writer.writeUInt32LE(serverId);
writer.writeUInt32LE(MAX_PACKET_SIZE);
return writer.concat();
}
private _parseConnectionRequest(buffer: Buffer) {
const reader = new BufferReader(buffer);
const header = reader.readUInt8();
const type = header & TYPE_MASK;
if (type !== TYPE_CONNECTION_REQUEST) {
throw new Error("Unexpected message type");
}
const flags = header & FLAG_MASK;
const messageIdAndFlags = reader.readUInt16LE();
const version = {
major: reader.readSpaceOptimized(),
minor: reader.readSpaceOptimized(),
sub: reader.readSpaceOptimized(),
build: reader.readUInt32LE(),
};
const requestId = reader.readUInt32LE();
return { flags, messageIdAndFlags, version, requestId };
}
private _parseConnectionRequestReplyConfirm(buffer: Buffer) {
const reader = new BufferReader(buffer);
const header = reader.readUInt8();
const type = header & TYPE_MASK;
if (type !== TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
throw new Error("Unexpected message type");
}
reader.readUInt16LE();
reader.readUInt32LE(); // client request id
const serverIdOffset = reader.offset;
reader.readUInt32LE();
reader.readUInt32LE(); // instance id
const nameLength = reader.readContainerSize();
const nameBuffer = reader.readBytes(nameLength);
const username = nameBuffer.toString("utf8");
return { username, serverIdOffset };
}
private _handleUpstreamMessage(session: ClientSession, data: Buffer) {
if (!this._socket || !session.route) {
return;
}
let type: number;
try {
type = data.readUInt8(0) & TYPE_MASK;
} catch {
return;
}
if (type === TYPE_CONNECTION_REQUEST_REPLY && session.serverConnectionId === undefined) {
try {
const serverConnectionId = this._parseConnectionRequestReply(data);
session.serverConnectionId = serverConnectionId;
this._sendPendingConfirm(session);
} catch (err: any) {
this._logger.warn(`Failed to parse upstream reply: ${err.message ?? err}`);
}
return;
}
this._socket.send(data, session.clientPort, session.clientAddress);
}
private _parseConnectionRequestReply(buffer: Buffer) {
const reader = new BufferReader(buffer);
const header = reader.readUInt8();
const type = header & TYPE_MASK;
if (type !== TYPE_CONNECTION_REQUEST_REPLY) {
throw new Error("Unexpected message type");
}
reader.readSpaceOptimized();
reader.readSpaceOptimized();
reader.readSpaceOptimized();
reader.readUInt32LE();
reader.readUInt32LE(); // client request id
return reader.readUInt32LE();
}
private async _activateRoute(session: ClientSession) {
if (!session.username) {
return;
}
const route = this._controller.resolvePlayerRoute(session.username);
if (!route) {
this._logger.warn(`No route for ${session.username}, dropping client ${session.clientAddress}:${session.clientPort}`);
return;
}
const instanceReady = await this._ensureInstanceRunning(route.instanceId);
if (!instanceReady) {
this._logger.warn(`Instance ${route.instanceId} not ready for ${session.username}`);
return;
}
session.route = route;
if (!session.upstream) {
const upstream = dgram.createSocket("udp4");
session.upstream = upstream;
upstream.on("message", data => this._handleUpstreamMessage(session, data));
upstream.on("error", err => {
this._logger.warn(
`Upstream error for ${session.clientAddress}:${session.clientPort}: ${err.message}`
);
upstream.close();
this._sessions.delete(clientKey(session.clientAddress, session.clientPort));
});
}
if (session.requestBuffer) {
session.upstream.send(session.requestBuffer, route.gamePort, route.publicAddress);
}
}
private async _ensureInstanceRunning(instanceId: number) {
const instance = this._controller.instances.get(instanceId);
if (!instance || instance.isDeleted) {
return false;
}
if (instance.status === "running") {
return true;
}
if (instance.config.get("instance.assigned_host") === null) {
return false;
}
try {
await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
} catch (err: any) {
this._logger.warn(`Failed to start instance ${instanceId}: ${err.message ?? err}`);
return false;
}
const deadline = Date.now() + SESSION_IDLE_TIMEOUT_MS;
while (Date.now() < deadline) {
const current = this._controller.instances.get(instanceId);
if (!current || current.isDeleted) {
return false;
}
if (current.status === "running") {
return true;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
return false;
}
private _sendPendingConfirm(session: ClientSession) {
if (!session.pendingConfirm || session.confirmServerIdOffset === undefined || !session.upstream || !session.route) {
return;
}
if (session.serverConnectionId === undefined) {
return;
}
const payload = Buffer.from(session.pendingConfirm);
payload.writeUInt32LE(session.serverConnectionId, session.confirmServerIdOffset);
session.upstream.send(payload, session.route.gamePort, session.route.publicAddress);
session.pendingConfirm = undefined;
this._flushPending(session);
}
private _queuePending(session: ClientSession, message: Buffer) {
if (session.pending.length >= MAX_PENDING_PACKETS) {
session.pending.shift();
}
session.pending.push(message);
}
private _flushPending(session: ClientSession) {
if (!session.route || !session.upstream || !session.pending.length) {
return;
}
for (const pending of session.pending) {
session.upstream.send(pending, session.route.gamePort, session.route.publicAddress);
}
session.pending = [];
}
private _cleanupSessions() {
const now = Date.now();
for (const [key, session] of this._sessions) {
if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) {
session.upstream?.close();
this._sessions.delete(key);
}
}
}
}
@@ -0,0 +1,75 @@
import fs from "fs-extra";
import * as lib from "@clusterio/lib";
export type StoredRoute = {
name: string;
instanceId: number;
hostId: number;
gamePort: number;
timestampMs: number;
};
export default class PlayerRoutingStore {
private _routes = new Map<string, StoredRoute>();
private _dirty = false;
constructor(private _filePath: string) {}
get(name: string): StoredRoute | undefined {
return this._routes.get(name.toLowerCase());
}
update(name: string, instanceId: number, hostId: number, gamePort: number, timestampMs: number) {
const key = name.toLowerCase();
this._routes.set(key, { name, instanceId, hostId, gamePort, timestampMs });
this._dirty = true;
}
delete(name: string) {
const key = name.toLowerCase();
if (this._routes.delete(key)) {
this._dirty = true;
}
}
dropHost(hostId: number) {
let removed = false;
for (const [key, route] of this._routes) {
if (route.hostId === hostId) {
this._routes.delete(key);
removed = true;
}
}
if (removed) {
this._dirty = true;
}
}
async load(): Promise<void> {
try {
const routes = JSON.parse(await fs.readFile(this._filePath, { encoding: "utf8" })) as StoredRoute[];
this._routes.clear();
for (const route of routes) {
if (!route || !route.name) {
continue;
}
this._routes.set(route.name.toLowerCase(), route);
}
this._dirty = false;
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
}
async save(): Promise<void> {
if (!this._dirty) {
return;
}
const payload = JSON.stringify([...this._routes.values()], null, "\t");
await lib.safeOutputFile(this._filePath, payload);
this._dirty = false;
}
}
+90 -2
View File
@@ -12,6 +12,7 @@ import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { HostConnector } from "../host";
import FactorioProxy from "./FactorioProxy";
import Instance from "./Instance";
import InstanceConnection from "./InstanceConnection";
import BaseHostPlugin from "./BaseHostPlugin";
@@ -266,6 +267,7 @@ export default class Host extends lib.Link {
instanceConnections = new Map<number, InstanceConnection>();
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
factorioProxies = new Map<number, FactorioProxy>();
adminlist = new Set<string>();
banlist = new Map<string, string>();
@@ -277,6 +279,7 @@ export default class Host extends lib.Link {
_startup = true;
_disconnecting = false;
_shuttingDown = false;
_proxyStartPromises = new Map<number, Promise<void>>();
static instanceConfigWarning = {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
@@ -690,7 +693,9 @@ export default class Host extends lib.Link {
new lib.InstanceStatusChangedEvent(
instanceId,
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.gamePort
: this.publicGamePort(instanceId, instanceInfo),
instanceConnection
? instanceConnection.instance.server.version
// TODO: factorio.version is not validated
@@ -701,6 +706,8 @@ export default class Host extends lib.Link {
// Send the new list of saves for this assigned instance to the controller.
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
await this._ensureFactorioProxy(instanceId, instanceInfo);
// save a copy of the instance config
await instanceInfo.config.save();
}
@@ -715,6 +722,10 @@ export default class Host extends lib.Link {
}
this.instanceInfos.delete(instanceId);
if (this.factorioProxies.has(instanceId)) {
await this.factorioProxies.get(instanceId)!.stop();
this.factorioProxies.delete(instanceId);
}
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
}
}
@@ -735,6 +746,23 @@ export default class Host extends lib.Link {
return instanceInfo;
}
/**
* Retrieved public port for clients to connect to for the given instance.
* @param instanceId - ID of the instance to get the port for.
* @returns Game port for the instance.
*/
publicGamePort(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
instanceInfo = instanceInfo ?? this.discoveredInstanceInfos.get(instanceId);
if (!instanceInfo) {
return undefined;
}
return (
instanceInfo.config.get("factorio.game_port")
?? instanceInfo.config.get("factorio.host_assigned_game_port")
?? undefined
);
}
/**
* Retrieved assigned port of the given instance
* @param instanceId - ID of the instance to get the assigned game port of:
@@ -752,6 +780,18 @@ export default class Host extends lib.Link {
);
}
ensureGamePortAssigned(instanceId: number, instanceInfo: { config: lib.InstanceConfig }) {
const configuredPort = instanceInfo.config.get("factorio.game_port");
const assignedPort = instanceInfo.config.get("factorio.host_assigned_game_port");
if (configuredPort !== null) {
return configuredPort;
}
if (assignedPort !== null) {
return assignedPort;
}
return this.assignGamePort(instanceId);
}
assignGamePort(instanceId: number) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const [id, instance] of this.instanceInfos) {
@@ -779,6 +819,42 @@ export default class Host extends lib.Link {
return newPort;
}
async startInstanceFromProxy(instanceId: number) {
const existing = this._proxyStartPromises.get(instanceId);
if (existing) {
return existing;
}
if (this._shuttingDown || this.recoveryMode) {
return;
}
const promise = (async () => {
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection && instanceConnection.instance.status === "running") {
return;
}
if (!instanceConnection) {
instanceConnection = await this._connectInstance(instanceId);
}
if (instanceConnection.instance.status !== "running") {
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
}
})().catch(err => {
logger.error(
`Error starting instance from proxy:\n${err.stack ?? err.message ?? err}`,
this.instanceLogMeta(instanceId)
);
}).finally(() => {
this._proxyStartPromises.delete(instanceId);
});
this._proxyStartPromises.set(instanceId, promise);
return promise;
}
/**
* Initialize and connect an unloaded instance
*
@@ -1146,7 +1222,9 @@ export default class Host extends lib.Link {
list.push(new lib.HostInstanceUpdate(
instanceInfo.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.gamePort
: this.publicGamePort(instanceId, instanceInfo),
instanceConnection ? instanceConnection.instance.server.version : undefined,
));
}
@@ -1174,6 +1252,7 @@ export default class Host extends lib.Link {
}
}
}
}
async prepareDisconnect() {
@@ -1204,6 +1283,15 @@ export default class Host extends lib.Link {
}
}
for (const proxy of this.factorioProxies.values()) {
try {
await proxy.stop();
} catch (err: any) {
logger.error(`Unexpected error stopping proxy:\n${err.stack ?? err.message ?? err}`);
}
}
this.factorioProxies.clear();
try {
await this.connector.disconnect();
} catch (err: any) {
+9 -1
View File
@@ -398,6 +398,13 @@ export default class Instance extends lib.Link {
stats,
};
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
const hostId = this.config.get("instance.assigned_host");
if (hostId !== null) {
this.sendTo(
"controller",
new lib.InstancePlayerRouteUpdateEvent(name, this.id, hostId, this.server.gamePort, Date.now()),
);
}
lib.invokeHook(this.plugins, "onPlayerEvent", event);
}
@@ -548,12 +555,13 @@ end`.replace(/\r?\n/g, " ");
notifyStatus(status: Instance["_status"]) {
this._status = status;
const publicPort = this.server.gamePort;
this.sendTo(
"controller",
new lib.InstanceStatusChangedEvent(
this.id,
status,
this.server.gamePort,
publicPort,
// TODO: factorio.version is not validated
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
),
+34 -1
View File
@@ -1363,7 +1363,40 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
if (process.platform === "darwin") {
return this.dataPath("..", "MacOS", "factorio");
}
return this.dataPath("..", "bin", "x64", "factorio");
const binDir = this.dataPath("..", "bin", "x64");
const runPath = path.join(binDir, "factorio-run");
if (fs.existsSync(runPath)) {
return runPath;
}
const runPathExe = `${runPath}.exe`;
if (fs.existsSync(runPathExe)) {
return runPathExe;
}
const factorioPath = path.join(binDir, "factorio");
if (fs.existsSync(factorioPath)) {
return factorioPath;
}
const factorioPathExe = `${factorioPath}.exe`;
if (fs.existsSync(factorioPathExe)) {
return factorioPathExe;
}
const directRunPath = path.join(this._factorioDir, "factorio-run");
if (fs.existsSync(directRunPath)) {
return directRunPath;
}
const directRunPathExe = `${directRunPath}.exe`;
if (fs.existsSync(directRunPathExe)) {
return directRunPathExe;
}
const directFactorioPath = path.join(this._factorioDir, "factorio");
if (fs.existsSync(directFactorioPath)) {
return directFactorioPath;
}
const directFactorioPathExe = `${directFactorioPath}.exe`;
if (fs.existsSync(directFactorioPathExe)) {
return directFactorioPathExe;
}
return factorioPath;
}
/**
+24
View File
@@ -24,6 +24,8 @@ export interface ControllerConfigFields {
"controller.metrics_timeout": number;
"controller.system_metrics_interval": number;
"controller.proxy_stream_timeout": number;
"controller.player_proxy_port": number | null;
"controller.player_proxy_default_instance_id": number | null;
"controller.factorio_username": string | null;
"controller.factorio_token": string | null;
"controller.share_factorio_credentials_with_hosts": boolean;
@@ -176,6 +178,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
type: "number",
initialValue: 15,
},
"controller.player_proxy_port": {
title: "Player Proxy Port",
description: "UDP port to accept player connections for the controller proxy, set to null to disable.",
restartRequired: true,
type: "number",
optional: true,
initialValue: 34197,
},
"controller.player_proxy_default_instance_id": {
title: "Player Proxy Default Instance",
description: "Instance ID to route players to when no routing history is available.",
type: "number",
optional: true,
},
"controller.factorio_username": {
title: "Factorio Username",
description: "Username to authenticate with Factorio API with.",
@@ -407,6 +423,7 @@ export interface InstanceConfigFields {
"factorio.executable_path": string | null;
"factorio.shutdown_timeout": number;
"factorio.game_port": number | null;
"factorio.proxy_port": number | null;
"factorio.host_assigned_game_port": number | null;
"factorio.rcon_port": number | null;
"factorio.rcon_password": string | null;
@@ -503,6 +520,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "number",
optional: true,
},
"factorio.proxy_port": {
description:
"UDP port to accept client connections on. When set, the host will proxy traffic to " +
"the instance game port and start the instance on first packet.",
type: "number",
optional: true,
},
"factorio.host_assigned_game_port": {
access: ["host"],
type: "number",
@@ -976,3 +976,30 @@ export class InstancePlayerUpdateEvent {
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
}
}
export class InstancePlayerRouteUpdateEvent {
declare ["constructor"]: typeof InstancePlayerRouteUpdateEvent;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
constructor(
public name: string,
public instanceId: number,
public hostId: number,
public gamePort: number,
public timestampMs: number,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"instanceId": Type.Integer(),
"hostId": Type.Integer(),
"gamePort": Type.Integer(),
"timestampMs": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.instanceId, json.hostId, json.gamePort, json.timestampMs);
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ function readMapPosition(state: MapReaderState) {
y = state.last_position.y + y_diff;
}
state.last_position.x = x;
state.last_position.x = y;
state.last_position.y = y;
return { x, y };
}
+1
View File
@@ -93,6 +93,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceAdminlistUpdateEvent,
instance.InstanceWhitelistUpdateEvent,
instance.InstancePlayerUpdateEvent,
instance.InstancePlayerRouteUpdateEvent,
mod.ModPackGetRequest,
mod.ModPackGetDefaultRequest,
-3
View File
@@ -275,9 +275,6 @@ function keyToLabels(key: string) {
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
if (!name || value === undefined) {
continue;
}
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
@@ -76,12 +76,6 @@ end
-- min
-- max
function serialize.serialize_personal_logistic_slots(player)
-- Check if logistics technology is researched
local force = player.force
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
return nil
end
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
@@ -135,18 +129,9 @@ function serialize.deserialize_personal_logistic_slots(player, serialized)
return
end
-- Check if logistics technology is researched
local force = player.force
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
return
end
-- Load personal logistic slots
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
return
end
-- Remove old sections up to section_count
for i = logistic_point.sections_count, 1, -1 do