mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
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.
238 lines
6.8 KiB
TypeScript
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;
|