mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898b4b738c | ||
|
|
15fc41a801 | ||
|
|
fc0279ad11 | ||
|
|
9bf817e665 |
No files matched your search
@@ -1,3 +1,2 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!README.md
|
!README.md
|
||||||
@@ -28,6 +28,8 @@ import HostConnection from "./HostConnection";
|
|||||||
import HostInfo from "./HostInfo";
|
import HostInfo from "./HostInfo";
|
||||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||||
import ControllerRouter from "./ControllerRouter";
|
import ControllerRouter from "./ControllerRouter";
|
||||||
|
import PlayerRoutingStore from "./PlayerRoutingStore";
|
||||||
|
import PlayerProxyServer from "./PlayerProxyServer";
|
||||||
|
|
||||||
const endpointDurationSummary = new Summary(
|
const endpointDurationSummary = new Summary(
|
||||||
"clusterio_controller_http_endpoint_duration_seconds",
|
"clusterio_controller_http_endpoint_duration_seconds",
|
||||||
@@ -76,6 +78,8 @@ 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>();
|
||||||
|
playerRoutingStore: PlayerRoutingStore;
|
||||||
|
playerProxyServer: PlayerProxyServer | null = null;
|
||||||
|
|
||||||
/** Event subscription controller */
|
/** Event subscription controller */
|
||||||
subscriptions = new lib.SubscriptionController();
|
subscriptions = new lib.SubscriptionController();
|
||||||
@@ -209,6 +213,9 @@ export default class Controller {
|
|||||||
this.clusterLogger = clusterLogger;
|
this.clusterLogger = clusterLogger;
|
||||||
this.pluginInfos = pluginInfos;
|
this.pluginInfos = pluginInfos;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.playerRoutingStore = new PlayerRoutingStore(
|
||||||
|
path.join(this.config.get("controller.database_directory"), "player-routing.json"),
|
||||||
|
);
|
||||||
|
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.locals.controller = this;
|
this.app.locals.controller = this;
|
||||||
@@ -342,6 +349,17 @@ export default class Controller {
|
|||||||
// Load plugins
|
// Load plugins
|
||||||
await this.loadPlugins();
|
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)
|
// 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 => {
|
||||||
logger.log({
|
logger.log({
|
||||||
@@ -484,6 +502,11 @@ export default class Controller {
|
|||||||
|
|
||||||
await lib.invokeHook(this.plugins, "onShutdown");
|
await lib.invokeHook(this.plugins, "onShutdown");
|
||||||
|
|
||||||
|
if (this.playerProxyServer) {
|
||||||
|
await this.playerProxyServer.stop();
|
||||||
|
this.playerProxyServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.wsServer.stop();
|
await this.wsServer.stop();
|
||||||
|
|
||||||
let stopTasks = [];
|
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.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.playerRoutingStore.save();
|
||||||
|
|
||||||
await lib.invokeHook(this.plugins, "onSaveData");
|
await lib.invokeHook(this.plugins, "onSaveData");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,6 +885,87 @@ export default class Controller {
|
|||||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
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
|
* Get instance by ID for a request
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export default class HostConnection extends BaseConnection {
|
|||||||
|
|
||||||
this.info.connected = false;
|
this.info.connected = false;
|
||||||
this._controller.hosts.set(this.info);
|
this._controller.hosts.set(this.info);
|
||||||
|
this._controller.playerRoutingStore.dropHost(this.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
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.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||||
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.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.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.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) {
|
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
||||||
if (src.type !== lib.Address.instance) {
|
if (src.type !== lib.Address.instance) {
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import * as lib from "@clusterio/lib";
|
|||||||
import { logger } from "@clusterio/lib";
|
import { logger } from "@clusterio/lib";
|
||||||
|
|
||||||
import type { HostConnector } from "../host";
|
import type { HostConnector } from "../host";
|
||||||
|
import FactorioProxy from "./FactorioProxy";
|
||||||
import Instance from "./Instance";
|
import Instance from "./Instance";
|
||||||
import InstanceConnection from "./InstanceConnection";
|
import InstanceConnection from "./InstanceConnection";
|
||||||
import BaseHostPlugin from "./BaseHostPlugin";
|
import BaseHostPlugin from "./BaseHostPlugin";
|
||||||
@@ -266,6 +267,7 @@ export default class Host extends lib.Link {
|
|||||||
instanceConnections = new Map<number, InstanceConnection>();
|
instanceConnections = new Map<number, InstanceConnection>();
|
||||||
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||||
instanceInfos = 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>();
|
adminlist = new Set<string>();
|
||||||
banlist = new Map<string, string>();
|
banlist = new Map<string, string>();
|
||||||
@@ -277,6 +279,7 @@ export default class Host extends lib.Link {
|
|||||||
_startup = true;
|
_startup = true;
|
||||||
_disconnecting = false;
|
_disconnecting = false;
|
||||||
_shuttingDown = false;
|
_shuttingDown = false;
|
||||||
|
_proxyStartPromises = new Map<number, Promise<void>>();
|
||||||
|
|
||||||
static instanceConfigWarning = {
|
static instanceConfigWarning = {
|
||||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
"_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(
|
new lib.InstanceStatusChangedEvent(
|
||||||
instanceId,
|
instanceId,
|
||||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
instanceConnection
|
||||||
|
? instanceConnection.instance.server.gamePort
|
||||||
|
: this.publicGamePort(instanceId, instanceInfo),
|
||||||
instanceConnection
|
instanceConnection
|
||||||
? instanceConnection.instance.server.version
|
? instanceConnection.instance.server.version
|
||||||
// TODO: factorio.version is not validated
|
// 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.
|
// Send the new list of saves for this assigned instance to the controller.
|
||||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||||
|
|
||||||
|
await this._ensureFactorioProxy(instanceId, instanceInfo);
|
||||||
|
|
||||||
// save a copy of the instance config
|
// save a copy of the instance config
|
||||||
await instanceInfo.config.save();
|
await instanceInfo.config.save();
|
||||||
}
|
}
|
||||||
@@ -715,6 +722,10 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.instanceInfos.delete(instanceId);
|
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")}`);
|
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +746,23 @@ export default class Host extends lib.Link {
|
|||||||
return instanceInfo;
|
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
|
* Retrieved assigned port of the given instance
|
||||||
* @param instanceId - ID of the instance to get the assigned game port of:
|
* @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) {
|
assignGamePort(instanceId: number) {
|
||||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||||
for (const [id, instance] of this.instanceInfos) {
|
for (const [id, instance] of this.instanceInfos) {
|
||||||
@@ -779,6 +819,42 @@ export default class Host extends lib.Link {
|
|||||||
return newPort;
|
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
|
* Initialize and connect an unloaded instance
|
||||||
*
|
*
|
||||||
@@ -1146,7 +1222,9 @@ export default class Host extends lib.Link {
|
|||||||
list.push(new lib.HostInstanceUpdate(
|
list.push(new lib.HostInstanceUpdate(
|
||||||
instanceInfo.config.toRemote("controller"),
|
instanceInfo.config.toRemote("controller"),
|
||||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
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,
|
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1174,6 +1252,7 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareDisconnect() {
|
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 {
|
try {
|
||||||
await this.connector.disconnect();
|
await this.connector.disconnect();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ export default class Instance extends lib.Link {
|
|||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, 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);
|
lib.invokeHook(this.plugins, "onPlayerEvent", event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,12 +555,13 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
|
|
||||||
notifyStatus(status: Instance["_status"]) {
|
notifyStatus(status: Instance["_status"]) {
|
||||||
this._status = status;
|
this._status = status;
|
||||||
|
const publicPort = this.server.gamePort;
|
||||||
this.sendTo(
|
this.sendTo(
|
||||||
"controller",
|
"controller",
|
||||||
new lib.InstanceStatusChangedEvent(
|
new lib.InstanceStatusChangedEvent(
|
||||||
this.id,
|
this.id,
|
||||||
status,
|
status,
|
||||||
this.server.gamePort,
|
publicPort,
|
||||||
// TODO: factorio.version is not validated
|
// TODO: factorio.version is not validated
|
||||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1363,7 +1363,40 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
return this.dataPath("..", "MacOS", "factorio");
|
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,6 +24,8 @@ export interface ControllerConfigFields {
|
|||||||
"controller.metrics_timeout": number;
|
"controller.metrics_timeout": 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.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;
|
||||||
@@ -176,6 +178,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
|||||||
type: "number",
|
type: "number",
|
||||||
initialValue: 15,
|
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": {
|
"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.",
|
||||||
@@ -407,6 +423,7 @@ export interface InstanceConfigFields {
|
|||||||
"factorio.executable_path": string | null;
|
"factorio.executable_path": string | null;
|
||||||
"factorio.shutdown_timeout": number;
|
"factorio.shutdown_timeout": number;
|
||||||
"factorio.game_port": number | null;
|
"factorio.game_port": number | null;
|
||||||
|
"factorio.proxy_port": number | null;
|
||||||
"factorio.host_assigned_game_port": number | null;
|
"factorio.host_assigned_game_port": number | null;
|
||||||
"factorio.rcon_port": number | null;
|
"factorio.rcon_port": number | null;
|
||||||
"factorio.rcon_password": string | null;
|
"factorio.rcon_password": string | null;
|
||||||
@@ -503,6 +520,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
type: "number",
|
type: "number",
|
||||||
optional: true,
|
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": {
|
"factorio.host_assigned_game_port": {
|
||||||
access: ["host"],
|
access: ["host"],
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
@@ -976,3 +976,30 @@ export class InstancePlayerUpdateEvent {
|
|||||||
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
|||||||
instance.InstanceAdminlistUpdateEvent,
|
instance.InstanceAdminlistUpdateEvent,
|
||||||
instance.InstanceWhitelistUpdateEvent,
|
instance.InstanceWhitelistUpdateEvent,
|
||||||
instance.InstancePlayerUpdateEvent,
|
instance.InstancePlayerUpdateEvent,
|
||||||
|
instance.InstancePlayerRouteUpdateEvent,
|
||||||
|
|
||||||
mod.ModPackGetRequest,
|
mod.ModPackGetRequest,
|
||||||
mod.ModPackGetDefaultRequest,
|
mod.ModPackGetDefaultRequest,
|
||||||
|
|||||||
Reference in new issue
Block a user