diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index a1b51e53..02e9c25a 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -3,11 +3,12 @@ import express, { type Request, type Response } from "express"; import util from "util"; import jwt from "jsonwebtoken"; +import { Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; const { basicType } = lib; -import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages"; +import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages"; async function generateCode(length: number): Promise { @@ -46,12 +47,33 @@ export class ControllerPlugin extends BaseControllerPlugin { }, 60e3).unref(); this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => { - let servers: string[] = []; - for (let instance of this.controller.instances.values()) { - if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) { - servers.push(instance.config.get("factorio.settings")["name"] as string || "unnamed server"); + const servers: Static[] = []; + + for (const instance of this.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 = this.controller.hosts.get(assignedHost); + if (!host) { + continue; + } + + const address = instance.gamePort !== undefined + ? `${host.publicAddress}:${instance.gamePort}` + : host.publicAddress; + + const settings = instance.config.get("factorio.settings"); + const includeAddress = this.controller.config.get("player_auth.show_connect_address"); + servers.push({ + name: settings["name"] as string || "unnamed server", + factorioVersion: instance.factorioVersion, + address: (includeAddress && host.publicAddress !== "") ? address : undefined, + }); } + res.send(servers); }); diff --git a/plugins/player_auth/index.ts b/plugins/player_auth/index.ts index 79fa909d..f8d860b1 100644 --- a/plugins/player_auth/index.ts +++ b/plugins/player_auth/index.ts @@ -8,6 +8,7 @@ declare module "@clusterio/lib" { export interface ControllerConfigFields { "player_auth.code_length": number; "player_auth.code_timeout": number; + "player_auth.show_connect_address": boolean; } } @@ -31,6 +32,12 @@ export const plugin: lib.PluginDeclaration = { type: "number", initialValue: 120, }, + "player_auth.show_connect_address": { + title: "Show Connect Address", + description: "Show server connect addresses to unauthenticated users.", + type: "boolean", + initialValue: false, + }, }, features: [ diff --git a/plugins/player_auth/messages.ts b/plugins/player_auth/messages.ts index 7cf9b2d1..742f4e40 100644 --- a/plugins/player_auth/messages.ts +++ b/plugins/player_auth/messages.ts @@ -1,5 +1,36 @@ import { Type, Static } from "@sinclair/typebox"; +export class PlayerAuthServer { + declare ["constructor"]: typeof PlayerAuthServer; + + static jsonSchema = Type.Object({ + name: Type.String(), + address: Type.Optional(Type.String()), + factorioVersion: Type.Optional(Type.String()), + }); + + constructor( + public name: string, + public address?: string, + public factorioVersion?: string, + ) {} + + toJSON(): Static { + const json = { name: this.name } as Static; + if (this.address !== undefined) { + json.address = this.address; + } + if (this.factorioVersion !== undefined) { + json.factorioVersion = this.factorioVersion; + } + return json; + } + + static fromJSON(json: Static) { + return new this(json.name, json.address, json.factorioVersion); + } +} + class FetchPlayerCodeResponse { constructor( public playerCode: string, diff --git a/plugins/player_auth/test/plugin.js b/plugins/player_auth/test/plugin.js index c63536e5..dda21aa2 100644 --- a/plugins/player_auth/test/plugin.js +++ b/plugins/player_auth/test/plugin.js @@ -7,9 +7,11 @@ const mock = require("../../../test/mock"); const controller = require("../dist/node/controller"); const instance = require("../dist/node/instance"); const info = require("../dist/node/index").plugin; -const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/node/messages"); +const { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } = require("../dist/node/messages"); +const { testRoundTripJsonSerialisable, testMatrix } = require("../../../test/common"); const lib = require("@clusterio/lib"); + function postJSON(url, body) { return fetch(url, { method: "POST", @@ -19,6 +21,44 @@ function postJSON(url, body) { } describe("player_auth", function() { + describe("messages.js", function() { + describe("PlayerAuthServer", function() { + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix( + ["server1", "server2"], // name + [undefined, "127.0.0.1", "10.0.0.1:1234"], // address + [undefined, "1.1.0", "2.0.0"], // version + )); + }); + }); + + describe("FetchPlayerCodeResponse", function() { + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix( + ["abc123", "player-code"], // playerCode + ["http://localhost", "https://example.com"], // controllerUrl + )); + }); + }); + + describe("FetchPlayerCodeRequest", function() { + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix( + ["player1", "player2"], // player + )); + }); + }); + + describe("SetVerifyCodeRequest", function() { + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix( + ["player1", "player2"], // player + ["code123", "verify456"], // verifyCode + )); + }); + }); + }); + describe("controller.js", function() { describe("generateCode()", function() { it("should generate a code of the given length", async function() { @@ -60,33 +100,52 @@ describe("player_auth", function() { describe("/api/player_auth/servers", function() { it("should return a list of running servers with player_auth loaded", async function() { - function addInstance(id, status, load, name) { + function addInstance(id, status, load, name, version, gamePort, host=1) { controllerPlugin.controller.instances.records.set(id, { config: { get(field) { if (field === "player_auth.load_plugin") { return load; - } else if (field === "factorio.settings") { + } + if (field === "factorio.settings") { return { name }; - } else if (field === "instance.id") { + } + if (field === "instance.id") { return id; } + if (field === "instance.assigned_host") { + return host; + } throw new Error(`field ${field} not implemented`); }, }, status, + gamePort, + factorioVersion: version, }); } - addInstance(1, "running", true, "running loaded"); - addInstance(2, "stopped", true, "stopped loaded"); - addInstance(3, "running", false, "running unloaded"); - addInstance(4, "stopped", false, "stopped unloaded"); - addInstance(5, "running", true, undefined); + + addInstance(1, "running", true, "running loaded", "1.1.1", 34197); + addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197); + addInstance(3, "running", false, "running unloaded", "1.1.3", 34197); + addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197); + addInstance(5, "running", true, undefined, "1.1.5", 34197); + addInstance(6, "running", true, "no port", "1.1.6", undefined); + addInstance(7, "running", true, "no host", "1.1.7", 34197, null); + addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2); + addInstance(9, "running", true, "no version", undefined, 34197); + + controllerPlugin.controller.hosts.set(1, { + publicAddress: "127.0.0.1", + }); + const result = await fetch(`${controllerUrl}/api/player_auth/servers`); - assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]); - for (let id of [1, 2, 3, 4, 5]) { - controllerPlugin.controller.instances.records.delete(id); - } + assert.deepEqual(await result.json(), [ + { name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" }, + { name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" }, + { name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" }, + { name: "no version", address: "127.0.0.1:34197" }, + ]); }); }); diff --git a/plugins/player_auth/web/index.tsx b/plugins/player_auth/web/index.tsx index 12118708..1c5cd20a 100644 --- a/plugins/player_auth/web/index.tsx +++ b/plugins/player_auth/web/index.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui"; -import { Button, Form, Input, Spin, Typography } from "antd"; +import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd"; +import { PlayerAuthServer } from "../messages"; import "./style.css"; const { Paragraph, Text } = Typography; @@ -12,60 +13,95 @@ interface LoginFormProps { } function LoginForm(props: LoginFormProps) { - let [servers, setServers] = useState(null); + const [servers, setServers] = useState(null); let [playerCode, setPlayerCode] = useState(null); let [playerCodeError, setPlayerCodeError] = useState(null); let [verifyCode, setVerifyCode] = useState(); let [verifyToken, setVerifyToken] = useState(null); + const [connectServer, setConnectServer] = useState<{ name: string, address: string } | null>(null); useEffect(() => { - (async () => { - let response = await fetch(`${webRoot}api/player_auth/servers`); - if (response.ok) { - setServers(await response.json()); - } else { - setServers([]); + let cancelled = false; + async function loadServers() { + try { + const response = await fetch(`${webRoot}api/player_auth/servers`); + if (!response.ok) { + throw new Error(`Bad server response: ${response.status}`); + } + + const data = await response.json(); + if (!cancelled) { + setServers(data); + } + } catch (err) { + if (!cancelled && err instanceof Error) { + setServers([]); + notifyErrorHandler("Error fetching servers")(err); + } } - })(); + } + + loadServers(); + const interval = setInterval(loadServers, 15 * 1000); + + return () => { + cancelled = true; + clearInterval(interval); + }; }, []); useEffect(() => { - if (verifyToken) { - let checkLoop = setInterval(() => { - (async () => { - let response = await fetch(`${webRoot}api/player_auth/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - verify_token: verifyToken, - verify_code: verifyCode, - player_code: playerCode, - }), - }); - if (!response.ok) { - throw new Error(`Bad server response: ${response.status}`); - } - let json = await response.json(); - if (json.error) { - // While there are other possibilites the most likely - // cause of an error response here is that the code expired. - setPlayerCodeError("Code expired"); - setVerifyToken(null); - clearInterval(checkLoop); - return; - } - if (json.verified) { - props.setToken(json.token); - } - })().catch(err => { - clearInterval(checkLoop); - notifyErrorHandler("Error verifying code")(err); - }); - }, 2000); - return () => { clearInterval(checkLoop); }; + if (!verifyToken) { + return undefined; } - return undefined; + let cancelled = false; + async function verify() { + try { + const response = await fetch(`${webRoot}api/player_auth/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + verify_token: verifyToken, + verify_code: verifyCode, + player_code: playerCode, + }), + }); + + if (!response.ok) { + throw new Error(`Bad server response: ${response.status}`); + } + + const json = await response.json(); + if (cancelled) { + return; + } + + if (json.error) { + setPlayerCodeError("Code expired"); + setVerifyToken(null); + clearInterval(interval); + return; + } + + if (json.verified) { + props.setToken(json.token); + } + } catch (err) { + if (!cancelled && err instanceof Error) { + clearInterval(interval); + notifyErrorHandler("Error verifying code")(err); + } + } + } + + verify(); + const interval = setInterval(verify, 2 * 1000); + + return () => { + cancelled = true; + clearInterval(interval); + }; }, [verifyToken]); if (!servers) { @@ -74,12 +110,47 @@ function LoginForm(props: LoginFormProps) { if (servers.length === 0) { return - There are no servers in the cluster currently running that can complete - the login via Factorio. Please try again later. + There are no running servers available for login via Factorio at the moment. + Please try again later. ; } return <> + setConnectServer(null)} + onOk={() => { + if (!connectServer) { + return; + } + + window.location.href = `steam://run/427520//--mp-connect=${connectServer.address}`; + setConnectServer(null); + }} + > + + + This button launches Factorio through Steam. It only works if + you own and have installed the Steam version of Factorio, and + Factorio is not already running. + + + + If you are using the standalone version, or Factorio is already + running, use the following address with Multiplayer → Connect to address: + + + + {connectServer?.address} + + + Login using your Factorio account is a 3 step process: @@ -89,9 +160,39 @@ function LoginForm(props: LoginFormProps) { Start Factorio, join one of the following multiplayer servers and type /web-login into the chat: -
-
    - {servers.map(text =>
  • {text}
  • )} +
    +
      + {servers.map(server => ( +
    • +
      + + {server.name} + {server.factorioVersion && ( + ({server.factorioVersion}) + )} + + + {server.address && ( + + )} +
      +
    • + ))}