Merge pull request #367 from Hornwitser/player-auth-plugin-preparations

Web Plugin preparations
This commit is contained in:
Hornwitser authored and GitHub committed 2021-03-17 18:16:19 +01:00
commit f07082b52d
19 files changed
+313 -105

No files matched your search

+63
View File
@@ -423,6 +423,68 @@ class BaseControlPlugin {
async addCommands(rootCommand) { }
}
/**
* Plugin supplied login form
* @typedef {Object} module:lib/plugin~LoginForm
* @property {string} name -
* Internal name of the login form, this should start with the
* plugin name followed by a dot.
* @property {string} title -
* Name displayed above this form in the login window.
* @property {React.Component} Component -
* React component that's rendered for this login form. This is
* supplied the setToken function via its props which should be
* called when an authentication token is aquired via this
* form.
*/
/**
* Base class for web interface plugins
*
* @static
*/
class BaseWebPlugin {
constructor(container, packageData, info, logger) {
/**
* Webpack container for this plugin
*/
this.container = container;
/**
* Contents of the plugin's package.json file
* @type {Object}
*/
this.package = packageData;
/**
* The plugin's own info module
*/
this.info = info;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
/**
* List of login forms provided by this plugin
*
* @type {Array<module:lib/plugin~LoginForm>}
*/
this.loginForms = [];
}
/**
* Called immediately after the class is instantiated.
*/
async init() { }
}
/**
* Attach plugin messages
*
@@ -489,6 +551,7 @@ module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
BaseControlPlugin,
BaseWebPlugin,
attachPluginMessages,
invokeHook,
+60 -27
View File
@@ -41,8 +41,10 @@ let loadedPlugins = {};
let devMiddleware;
let clusterLogger;
let db = {
instances: new Map(),
slaves: new Map(),
instances: null,
slaves: null,
roles: null,
users: null,
};
let slaveConnections = new Map();
let controlConnections = [];
@@ -78,18 +80,6 @@ let httpServerCloser;
let httpsServer;
let httpsServerCloser;
app.use(cookieParser());
app.use(bodyParser.json({
limit: "10mb",
}));
app.use(bodyParser.urlencoded({
parameterLimit: 100000,
limit: "10mb",
extended: true,
}));
app.use(fileUpload());
app.use(compression());
// Servers the web interface with the root path set apropriately.
function serveWeb(route) {
@@ -110,10 +100,6 @@ function serveWeb(route) {
};
}
// Set folder to serve static content from (the website)
app.use(express.static(path.join(__dirname, "dist", "web")));
app.use(express.static("static")); // Used for data export files
const slaveMappingGauge = new libPrometheus.Gauge(
"clusterio_master_slave_mapping",
"Mapping of Slave ID to name",
@@ -149,6 +135,11 @@ const instanceMappingGauge = new libPrometheus.Gauge(
}
);
const hitCounter = new libPrometheus.Counter(
"clusterio_master_http_hits_total",
"How many HTTP requests in total have been received"
);
const endpointHitCounter = new libPrometheus.Counter(
"clusterio_master_http_endpoint_hits_total",
"How many requests a particular HTTP endpoint has gotten",
@@ -181,6 +172,28 @@ const wsActiveSlavesGauge = new libPrometheus.Gauge(
"How many slaves are currently connected to the master"
);
app.use((req, res, next) => {
hitCounter.inc();
next();
});
app.use(cookieParser());
app.use(bodyParser.json({
limit: "10mb",
}));
app.use(bodyParser.urlencoded({
parameterLimit: 100000,
limit: "10mb",
extended: true,
}));
app.use(fileUpload());
app.use(compression());
// Set folder to serve static content from (the website)
app.use(express.static(path.join(__dirname, "dist", "web")));
app.use(express.static("static")); // Used for data export files
// Merges samples from sourceResult to destinationResult
function mergeSamples(destinationResult, sourceResult) {
// Merge metrics received by multiple slaves
@@ -275,7 +288,8 @@ app.get("/api/plugins", (req, res) => {
for (let pluginInfo of pluginInfos) {
let name = pluginInfo.name;
let loaded = masterPlugins.has(name);
plugins.push({ name, version: pluginInfo.version, loaded });
let enabled = loaded && masterConfig.group(pluginInfo.name).get("load_plugin");
plugins.push({ name, version: pluginInfo.version, enabled, loaded });
}
res.send(plugins);
});
@@ -488,6 +502,14 @@ async function shutdown() {
logger.info("Saving configs");
await fs.outputFile(masterConfigPath, JSON.stringify(masterConfig.serialize(), null, 4));
if (devMiddleware) {
await new Promise((resolve, reject) => { devMiddleware.close(resolve); });
}
if (!db.slaves) {
return;
}
await saveMap(masterConfig.get("master.database_directory"), "slaves.json", db.slaves);
await saveInstances(masterConfig.get("master.database_directory"), "instances.json", db.instances);
await saveUsers(masterConfig.get("master.database_directory"), "users.json");
@@ -496,10 +518,6 @@ async function shutdown() {
stopAcceptingNewSessions = true;
if (devMiddleware) {
await new Promise((resolve, reject) => { devMiddleware.close(resolve); });
}
let disconnectTasks = [];
for (let controlConnection of controlConnections) {
controlConnection.connector.setTimeout(masterConfig.get("master.connector_shutdown_timeout"));
@@ -1861,6 +1879,7 @@ async function initialize() {
})
.command("run", "Run master server", yargs => {
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
.demandCommand(1, "You need to specify a command to run")
.strict()
@@ -1953,15 +1972,29 @@ async function initialize() {
async function startServer(args) {
// Start webpack development server if enabled
if (args.dev) {
if (args.dev || args.devPlugin) {
logger.warn("Webpack development mode enabled");
/* eslint-disable global-require */
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfig = require("./webpack.config");
const webpackConfigs = [];
if (args.dev) {
webpackConfigs.push(require("./webpack.config"));
}
if (args.devPlugin) {
for (let name of args.devPlugin) {
let info = pluginInfos.find(i => i.name === name);
if (!info) {
throw new libErrors.StartupError(`No plugin named ${name}`);
}
webpackConfigs.push(
require(path.posix.join(info.requirePath, "webpack.config"))
);
}
}
/* eslint-enable global-require */
const compiler = webpack(webpackConfig({}));
const compiler = webpack(webpackConfigs.map(config => config({})));
devMiddleware = webpackDevMiddleware(compiler, {});
app.use(devMiddleware);
}
@@ -2033,7 +2066,7 @@ async function startServer(args) {
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web");
app.use(`/plugin/${pluginInfo.name}`, express.static(webPath));
app.use(`/plugins/${pluginInfo.name}`, express.static(webPath));
}
// Load plugins
+1 -1
View File
@@ -41,7 +41,7 @@
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"webpack-cli": "^3.3.12",
"webpack-dev-middleware": "^3.7.2",
"webpack-dev-middleware": "^4.1.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clusterio</title>
<script>
window.webRoot = "__WEB_ROOT__";
window.webRoot = new URL("__WEB_ROOT__", document.location).pathname;
</script>
</head>
<body>
+3 -1
View File
@@ -1 +1,3 @@
import("@clusterio/web_ui/src/bootstrap");
import("@clusterio/web_ui").then(webUi => {
return webUi.bootstrap();
}).catch(console.error);
+1 -1
View File
@@ -12,6 +12,7 @@ module.exports = (env = {}) => merge(common(env), {
contentBase: "./dist/web",
},
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
@@ -28,7 +29,6 @@ module.exports = (env = {}) => merge(common(env), {
"@clusterio/lib/schema": { singleton: true },
"@clusterio/lib/users": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"ajv": {},
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
+1
View File
@@ -4,6 +4,7 @@
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.4",
"browser": "src/index.jsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
-6
View File
@@ -1,6 +0,0 @@
// Calculate basedir of application based on current location and
// the provided web_root from master server.
const location = new URL(window.webRoot, document.location);
const basename = location.pathname !== "/" ? location.pathname : "";
export default basename;
+25 -18
View File
@@ -4,6 +4,7 @@ import React from "react";
import ReactDOM from "react-dom";
import libConfig from "@clusterio/lib/config";
import libPlugin from "@clusterio/lib/plugin";
import { ConsoleTransport, WebConsoleFormat, logger } from "@clusterio/lib/logging";
import App from "./components/App";
@@ -36,51 +37,57 @@ async function loadPlugins() {
pluginList = [];
}
let plugins = [];
let plugins = new Map();
await __webpack_init_sharing__("default");
for (let meta of pluginList) {
try {
await loadScript(`${webRoot}plugin/${meta.name}/remoteEntry.js`);
await loadScript(`${webRoot}plugins/${meta.name}/remoteEntry.js`);
let container = window[`plugin_${meta.name}`];
if (!container) {
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
}
await container.init(__webpack_share_scopes__.default);
let pluginInfo = (await container.get("./info"))();
let pluginPackage = (await container.get("./package.json"))();
plugins.push({
meta,
info: pluginInfo,
package: pluginPackage,
container,
});
let WebPluginClass = libPlugin.BaseWebPlugin;
if (meta.enabled && pluginInfo.webEntrypoint) {
let webModule = (await container.get(pluginInfo.webEntrypoint))();
if (!webModule.WebPlugin) {
throw new Error("Plugin webEntrypoint does not export WebPlugin class");
}
WebPluginClass = webModule.WebPlugin;
}
let plugin = new WebPluginClass(container, pluginPackage, pluginInfo, logger);
await plugin.init();
plugins.set(pluginInfo.name, plugin);
} catch (err) {
logger.error(`Failed to load plugin info for ${meta.name}`);
if (err) {
logger.error(`Failed to load plugin ${meta.name}`);
if (err.stack) {
logger.error(err.stack);
}
plugins.push({
meta,
});
}
}
return plugins;
}
async function load() {
export default async function bootstrap() {
logger.add(new ConsoleTransport({
level: "verbose",
format: new WebConsoleFormat(),
}));
let plugins = await loadPlugins();
libConfig.registerPluginConfigGroups(plugins.filter(p => p.info).map(p => p.info));
let pluginInfos = [...plugins.values()].map(p => p.info);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10);
let control = new Control(controlConnector, []);
let control = new Control(controlConnector, plugins);
ReactDOM.render(<App control={control} plugins={plugins}/>, document.getElementById("root"));
}
load().catch((err) => logger.fatal(err.stack));
+23 -13
View File
@@ -4,14 +4,25 @@ import { BrowserRouter } from "react-router-dom";
import { logger } from "@clusterio/lib/logging";
import basename from "../basename";
import ErrorBoundary from "./ErrorBoundary";
import SiteLayout from "./SiteLayout";
import ControlContext from "./ControlContext";
import PluginsContext from "./PluginsContext";
import LoginForm from "./LoginForm";
import { Card, Spin } from "antd";
import { Card, Spin, Typography } from "antd";
const { Paragraph } = Typography;
function ErrorCard(props) {
return <div className="login-container">
<Card>
<h1>An unexpected error occured</h1>
<Paragraph code className="error-traceback">{props.error.stack}</Paragraph>
</Card>
</div>;
}
export default function App(props) {
let [connected, setConnected] = useState(false);
@@ -69,20 +80,19 @@ export default function App(props) {
} else {
page = <div className="login-container">
<Card>
<h1>Clusterio</h1>
<LoginForm setToken={setToken} />
</Card>
<LoginForm setToken={setToken} />
</div>;
}
return (
<ControlContext.Provider value={props.control}>
<PluginsContext.Provider value={props.plugins}>
<BrowserRouter basename={basename}>
{page}
</BrowserRouter>
</PluginsContext.Provider>
</ControlContext.Provider>
<ErrorBoundary Component={ErrorCard}>
<ControlContext.Provider value={props.control}>
<PluginsContext.Provider value={props.plugins}>
<BrowserRouter basename={webRoot}>
{page}
</BrowserRouter>
</PluginsContext.Provider>
</ControlContext.Provider>
</ErrorBoundary>
);
}
+43 -9
View File
@@ -1,27 +1,61 @@
import React from "react";
import { Form, Input, Button } from "antd";
import React, { Fragment, useContext } from "react";
import { Button, Card, Divider, Form, Image, Input, List, Space, Spin, Typography } from "antd";
import LockOutlined from "@ant-design/icons/LockOutlined";
export default function LoginForm(props) {
return (
import logo from "../images/logo.png";
import PluginsContext from "./PluginsContext";
const { Paragraph, Text } = Typography;
function TokenAuth(props) {
return <>
<Paragraph>If you have generated an authentication token from the master server, use that here.</Paragraph>
<Form
name="login"
onFinish={(values) => { props.setToken(values.token); }}
layout="inline"
>
<Form.Item
name="token"
rules={[
{ required: true, message: "Please input authentication token" },
]}
rules={[{ required: true }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
style={{ width: "14em" }}
prefix=<LockOutlined className="site-form-item-icon" />
type="password"
placeholder="Authentication Token"
/>
</Form.Item>
<Button type="primary" htmlType="submit">Log in</Button>
</Form>
);
</>;
}
export default function LoginForm(props) {
let plugins = useContext(PluginsContext);
let loginForms = [];
for (let plugin of plugins.values()) {
loginForms.push(...plugin.loginForms);
}
loginForms.push({
name: "core.token",
title: "Token",
Component: TokenAuth,
});
return <Card style={{ maxWidth: "30em" }}>
<Space style={{ width: "100%" }} direction="vertical" align="center">
<h1>Clusterio</h1>
<Image width={128} height={123} src={logo} />
Log in to the cluster using one of the methods below.
</Space>
{loginForms.map(form => <Fragment key={form.name}>
<Divider orientation="center">{form.title}</Divider>
<form.Component setToken={props.setToken} />
</Fragment>)}
</Card>;
}
@@ -1,6 +1,6 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Alert, Descriptions } from "antd";
import { Alert, Descriptions, Spin } from "antd";
import PluginsContext from "./PluginsContext";
import PageLayout from "./PageLayout";
@@ -10,27 +10,43 @@ import { notifyErrorHandler } from "../util/notify";
export default function PluginViewPage() {
let params = useParams();
let plugins = useContext(PluginsContext);
let [pluginList, setPluginList] = useState(null);
let pluginName = params.name;
let plugin = plugins.find(p => p.meta.name === pluginName);
let pluginTitle = plugin.info ? plugin.info.title : plugin.meta.name;
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
let plugin = plugins.get(pluginName);
let pluginTitle = plugin ? plugin.info.title : pluginName;
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!plugin) {
if (!pluginList) {
return <PageLayout nav={nav}>
<Descriptions borderd size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
let pluginMeta = pluginList.find(p => p.name === pluginName);
if (!pluginMeta) {
return <PageLayout nav={nav}>
<h2>Plugin not found</h2>
<p>Plugin with name {pluginName} was not found on the master server.</p>
</PageLayout>;
}
if (!plugin.info) {
if (!plugin) {
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={pluginTitle}
>
<Descriptions.Item label="Version">{plugin.meta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded">{plugin.meta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
</Descriptions>
<Alert
style={{
@@ -50,13 +66,9 @@ export default function PluginViewPage() {
}
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={pluginTitle}
>
<Descriptions.Item label="Version">{plugin.meta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded" span={2}>{plugin.meta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded" span={2}>{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions.Item label="Description" span={3}>{plugin.info.description}</Descriptions.Item>
{plugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
<a href={plugin.package.homepage}>{plugin.package.homepage}</a>
@@ -66,14 +78,14 @@ export default function PluginViewPage() {
</Descriptions.Item> : null}
<Descriptions.Item label="License" span={3}>{plugin.package.license}</Descriptions.Item>
</Descriptions>
{plugin.meta.version !== plugin.package.version ? <Alert
{pluginMeta.version !== plugin.package.version ? <Alert
style={{
marginTop: "1em",
}}
message="Version missmatch detected"
description={
`The version of the web interface module for this plugin (${plugin.package.version}) `+
`does not match the version running on the master server (${plugin.meta.version}), `+
`does not match the version running on the master server (${pluginMeta.version}), `+
"spurious errors may occur. This usually happens when the plugin is updated but the "+
"master server has not been restarted yet, but may also be due to an outdated build."
}
+31 -2
View File
@@ -1,7 +1,8 @@
import React, { useContext } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { Table } from "antd";
import notify from "../util/notify";
import PluginsContext from "./PluginsContext";
import PageLayout from "./PageLayout";
@@ -9,6 +10,34 @@ import PageLayout from "./PageLayout";
export default function PluginsPage() {
let plugins = useContext(PluginsContext);
let history = useHistory();
let [pluginList, setPluginList] = useState([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
let tableContents = [];
for (let meta of pluginList) {
if (plugins.has(meta.name)) {
let plugin = plugins.get(meta.name);
tableContents.push({
meta,
info: plugin.info,
package: plugin.package,
});
} else {
tableContents.push({
meta,
});
}
}
return <PageLayout nav={[{ name: "Plugins" }]}>
<h2>Plugins</h2>
@@ -39,7 +68,7 @@ export default function PluginsPage() {
responsive: ["sm"],
},
]}
dataSource={plugins}
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
pagination={false}
onRow={plugin => ({
+19
View File
@@ -0,0 +1,19 @@
export { default as bootstrap } from "./bootstrap.jsx";
export { default as notify, notifyErrorHandler } from "./util/notify.jsx";
export * from "./model/instance.jsx";
export * from "./model/slave.jsx";
export { default as ControlContext } from "./components/ControlContext";
export { default as PluginsContext } from "./components/PluginsContext";
export { default as BaseConfigTree } from "./components/BaseConfigTree";
export { default as InstanceConfigTree } from "./components/InstanceConfigTree";
export { default as MasterConfigTree } from "./components/MasterConfigTree";
export { default as AssignInstanceModal } from "./components/AssignInstanceModal";
export { default as InstanceConsole } from "./components/InstanceConsole";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as PageLayout } from "./components/PageLayout";
+5 -5
View File
@@ -36,17 +36,17 @@ export class ControlConnector extends libLink.WebSocketClientConnector {
* @static
*/
export class Control extends libLink.Link {
constructor(connector, controlPlugins) {
constructor(connector, plugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
* @type {Map<string, module:lib/plugin.BaseWebPlugin>}
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
this.plugins = plugins;
for (let plugin of plugins.values()) {
libPlugin.attachPluginMessages(this, plugin);
}
this.instanceLogHandlers = new Map();
+1
View File
@@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
+1
View File
@@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
@@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
@@ -9,6 +9,7 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},