Implement basic proxy to connect to stopped instances

This commit is contained in:
Danielv123 committed 2026-10-29 20:53:31 +01:00
1 parent 296d1dce99
commit 80f244fddd
8 files changed
+768

No files matched your search

+105
View File
@@ -32,6 +32,8 @@ import HostRecord from "./HostRecord.js";
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
import ControllerRouter from "./ControllerRouter.js";
import InstanceManager from "./InstanceManager.js";
import PlayerRoutingStore from "./PlayerRoutingStore.js";
import PlayerProxyServer from "./PlayerProxyServer.js";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -84,6 +86,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: lib.SubscriptionController;
@@ -286,6 +290,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;
@@ -437,6 +444,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 => {
const url = lib.redactUrl(req.originalUrl);
@@ -592,6 +610,11 @@ export default class Controller {
await this.hooks.shutdown.invoke();
if (this.playerProxyServer) {
await this.playerProxyServer.stop();
this.playerProxyServer = null;
}
await this.wsServer.stop();
let stopTasks = [];
@@ -793,6 +816,7 @@ export default class Controller {
this.users.records.save(),
this.hooks.save.invoke(),
this.notes.save(),
this.playerRoutingStore.save(),
]);
}
@@ -1027,6 +1051,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: Readonly<InstanceRecord> | 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;
}
/**
* Removes all saves currently stored for the given instance, if any
* @param instanceId - Id of Instance to clear saves for.
+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));
@@ -411,6 +413,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.js";
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 "node:fs/promises";
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;
}
}
+7
View File
@@ -424,6 +424,13 @@ export default class Instance extends lib.Link {
if (wasPaused) {
this.hooks.autoPauseChanged.invoke(false);
}
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()),
);
}
this.hooks.playerEvent.invoke(event);
}
+16
View File
@@ -31,6 +31,8 @@ export interface ControllerConfigFields {
"controller.max_subscription_filters": number;
"controller.system_metrics_interval": number;
"controller.proxy_stream_timeout": number;
"controller.player_proxy_port": number | null;
"controller.player_proxy_default_instance_id": number | null;
"controller.factorio_username": string | null;
"controller.factorio_token": string | null;
"controller.share_factorio_credentials_with_hosts": boolean;
@@ -243,6 +245,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
initialValue: 15,
validator: validators.greaterThanZero,
},
"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.",
@@ -935,3 +935,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
View File
@@ -102,6 +102,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceAdminlistUpdateEvent,
instance.InstanceWhitelistUpdateEvent,
instance.InstancePlayerUpdateEvent,
instance.InstancePlayerRouteUpdateEvent,
mod.ModPackGetRequest,
mod.ModPackGetDefaultRequest,