Files
Danielv123 a5bd43637e Enable the player proxy by default and give lobbies an empty save
The player proxy now listens on 34197 by default. New hosts default to
the port range 34200-34299 so they don't hand that port to instances,
and the controller logs an error and runs without the proxy if the port
is taken, as hosts with the old range only skip it when the controller
already holds it.

Lobby instances start the save lobby.zip, created on first start as a
one tile map with no autoplace and with the freeplay scenario replaced by
an empty one. This uses the new chooseSave instance hook, which lets
plugins pick or create the save to start, and setSaveScenario from
@clusterio/host, which sets the scenario the save patcher keeps loading.
Map gen settings can't use a width and height of 0 for this, as Factorio
takes 0 to mean an unlimited map.

The address players reach the proxy on moves from lobby.public_address
to controller.player_proxy_public_address. When it's set the player_auth
login page lists it once for the instances behind the proxy instead of
their own addresses, and lobbies with player_auth loaded get a button
that opens the web interface login dialog.
2026-10-29 20:54:11 +01:00

238 lines
6.8 KiB
TypeScript

import crypto from "crypto";
import express, { type Request, type Response } from "express";
import util from "util";
import jwt from "jsonwebtoken";
import { Static } from "@sinclair/typebox";
import type { ControllerPluginContext } from "@clusterio/controller";
import { basicType, NotFoundError } from "@clusterio/lib";
import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages.js";
async function generateCode(length: number): Promise<string> {
// ji1lI, 0oOQ, and 2Z are not present to ease reading.
let letters = "abcdefghkmnpqrstuvwxyzABCDEFGHJKLMNPRSTUVWXY3456789";
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let code = [];
for (let byte of await asyncRandomBytes(length)) {
// Due to the 51 characters in the letters not fitting perfectly in
// 256 there's an ever so slight bias towards a with this algorithm.
code.push(letters[byte % letters.length]);
}
return code.join("");
}
type PlayerCode = { playerCode: string, verifyCode: string | null, expiresMs: number };
export default async function loadControllerPlugin(context: ControllerPluginContext) {
const { controller } = context;
// Store of validation attempts by players
const players = new Map<string, PlayerCode>();
// Periodically remove expired entries
setInterval(() => {
let now = Date.now();
for (let [player, entry] of players) {
if (entry.expiresMs < now) {
players.delete(player);
}
}
}, 60e3).unref();
controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => {
const servers: Static<typeof PlayerAuthServer.jsonSchema>[] = [];
const includeAddress = controller.config.get("player_auth.show_connect_address");
// When players are meant to connect through the player proxy, instances without a dedicated proxy
// port are only reachable through its main port, which is listed once for all of them.
const proxyAddress = controller.config.get("controller.player_proxy_public_address")
? controller.getPlayerProxyAddress()
: undefined;
const proxiedVersions = new Set<string | undefined>();
for (const instance of controller.instances.values()) {
const pluginLoaded = instance.config.get("player_auth.load_plugin");
const assignedHost = instance.config.get("instance.assigned_host");
if (instance.status !== "running" || !pluginLoaded || assignedHost === null) {
continue;
}
const host = controller.hosts.get(assignedHost);
if (!host) {
continue;
}
let address: string | undefined;
if (proxyAddress) {
const proxyPort = instance.config.get("instance.player_proxy_port");
if (proxyPort === null) {
proxiedVersions.add(instance.factorioVersion);
continue;
}
address = controller.getPlayerProxyAddress(proxyPort);
} else if (host.publicAddress !== "") {
address = instance.gamePort !== undefined
? `${host.publicAddress}:${instance.gamePort}`
: host.publicAddress;
}
const settings = instance.config.get("factorio.settings");
servers.push({
name: settings["name"] as string || "unnamed server",
factorioVersion: instance.factorioVersion,
address: includeAddress ? address : undefined,
});
}
if (proxiedVersions.size) {
const [version] = proxiedVersions;
servers.unshift({
name: controller.config.get("controller.name"),
factorioVersion: proxiedVersions.size === 1 ? version : undefined,
address: includeAddress ? proxyAddress : undefined,
});
}
res.send(servers);
});
controller.app.post(
"/api/player_auth/player_code",
express.json(),
(req: Request, res: Response, next: any) => {
handlePlayerCode(req, res).catch(next);
}
);
controller.app.post(
"/api/player_auth/verify",
express.json(),
(req: Request, res: Response, next: any) => {
handleVerify(req, res).catch(next);
}
);
controller.handle(FetchPlayerCodeRequest, async (request: FetchPlayerCodeRequest) => {
let playerCode = await generateCode(controller.config.get("player_auth.code_length"));
let expiresMs = Date.now() + controller.config.get("player_auth.code_timeout") * 1000;
players.set(request.player, { playerCode, verifyCode: null, expiresMs });
return { playerCode, controllerUrl: controller.getControllerUrl() };
});
controller.handle(SetVerifyCodeRequest, async (request: SetVerifyCodeRequest) => {
let { player, verifyCode } = request;
let entry = players.get(player);
if (!entry || entry.expiresMs < Date.now()) {
throw new NotFoundError("invalid player");
}
entry.verifyCode = verifyCode;
});
async function handlePlayerCode(req: Request, res: Response) {
if (basicType(req.body) !== "object") {
res.sendStatus(400);
return;
}
let playerCode = req.body.player_code;
if (typeof playerCode !== "string") {
res.sendStatus(400);
return;
}
for (let entry of players.values()) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
let verifyCode = await generateCode(controller.config.get("player_auth.code_length"));
let verifyToken = jwt.sign(
{
aud: "player_auth.verify_code",
exp: Math.floor(entry.expiresMs / 1000),
verify_code: verifyCode,
player_code: playerCode,
},
controller.authSecret
);
res.send({ verify_code: verifyCode, verify_token: verifyToken });
return;
}
}
res.send({ error: true, message: "invalid player_code" });
}
async function handleVerify(req: Request, res: Response) {
if (basicType(req.body) !== "object") {
res.sendStatus(400);
return;
}
let playerCode = req.body.player_code;
if (typeof playerCode !== "string") {
res.sendStatus(400);
return;
}
let verifyCode = req.body.verify_code;
if (typeof verifyCode !== "string") {
res.sendStatus(400);
return;
}
let verifyToken = req.body.verify_token;
if (typeof verifyToken !== "string") {
res.sendStatus(400);
return;
}
try {
let payload = jwt.verify(
verifyToken, controller.authSecret, { audience: "player_auth.verify_code" }
) as jwt.JwtPayload;
if (payload.verify_code !== verifyCode) {
throw new Error("invalid verify_code");
}
if (payload.player_code !== playerCode) {
throw new Error("invalid player_code");
}
} catch (err: any) {
res.send({ error: true, message: err.message });
return;
}
for (let [player, entry] of players) {
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
if (entry.verifyCode === verifyCode) {
let user = controller.users.getByName(player);
if (!user) {
res.send({ error: true, message: "invalid user" });
return;
}
let token = controller.users.signUserToken(user);
res.send({ verified: true, token });
return;
}
res.send({ verified: false });
return;
}
}
res.send({ error: true, message: "invalid player_code" });
}
}
// For testing only
export const _generateCode = generateCode;