From 75b5ac8ebd90ba400f2b10c768de030120a7d179 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 30 Dec 2018 19:16:30 +0200 Subject: [PATCH] Add config option to disable parts of management API --- example-config.yaml | 13 +++ maubot/__main__.py | 15 ++- maubot/config.py | 10 ++ maubot/instance.py | 3 +- maubot/management/api/__init__.py | 19 +-- maubot/management/api/auth.py | 20 ---- maubot/management/api/client_proxy.py | 1 + maubot/management/api/login.py | 40 +++++++ maubot/management/api/plugin.py | 91 +-------------- maubot/management/api/plugin_upload.py | 109 ++++++++++++++++++ maubot/management/frontend/src/api.js | 15 ++- maubot/management/frontend/src/pages/Login.js | 8 ++ maubot/management/frontend/src/pages/Main.js | 2 + .../src/pages/dashboard/BaseMainView.js | 3 +- .../frontend/src/pages/dashboard/Instance.js | 28 +++-- .../frontend/src/pages/dashboard/Plugin.js | 3 +- .../frontend/src/pages/dashboard/index.js | 56 ++++++--- 17 files changed, 287 insertions(+), 149 deletions(-) create mode 100644 maubot/management/api/login.py create mode 100644 maubot/management/api/plugin_upload.py diff --git a/example-config.yaml b/example-config.yaml index 384d877..d4701b8 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -48,6 +48,19 @@ registration_secrets: admins: root: "" +# API feature switches. +api_features: + login: true + plugin: true + plugin_upload: true + instance: true + instance_database: true + client: true + client_proxy: true + client_auth: true + dev_open: true + log: true + # Python logging configuration. # # See section 16.7.2 of the Python documentation for more info: diff --git a/maubot/__main__.py b/maubot/__main__.py index 4d6d525..2a6a3f2 100644 --- a/maubot/__main__.py +++ b/maubot/__main__.py @@ -26,7 +26,7 @@ from .server import MaubotServer from .client import Client, init as init_client_class from .loader.zip import init as init_zip_loader from .instance import init as init_plugin_instance_class -from .management.api import init as init_mgmt_api, stop as stop_mgmt_api, init_log_listener +from .management.api import init as init_mgmt_api from .__meta__ import __version__ parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.", @@ -43,7 +43,13 @@ config.load() config.update() logging.config.dictConfig(copy.deepcopy(config["logging"])) -init_log_listener() + +stop_log_listener = None +if config["api_features.log"]: + from .management.api.log import init as init_log_listener, stop_all as stop_log_listener + + init_log_listener() + log = logging.getLogger("maubot.init") log.info(f"Initializing maubot {__version__}") @@ -88,8 +94,9 @@ except KeyboardInterrupt: loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()], loop=loop)) db_session.commit() - log.debug("Closing websockets") - loop.run_until_complete(stop_mgmt_api()) + if stop_log_listener is not None: + log.debug("Closing websockets") + loop.run_until_complete(stop_log_listener()) log.debug("Stopping server") try: loop.run_until_complete(asyncio.wait_for(server.stop(), 5, loop=loop)) diff --git a/maubot/config.py b/maubot/config.py index b36a5c5..f3b56e3 100644 --- a/maubot/config.py +++ b/maubot/config.py @@ -56,6 +56,16 @@ class Config(BaseFileConfig): password = self._new_token() base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + copy("api_features.login") + copy("api_features.plugin") + copy("api_features.plugin_upload") + copy("api_features.instance") + copy("api_features.instance_database") + copy("api_features.client") + copy("api_features.client_proxy") + copy("api_features.client_auth") + copy("api_features.dev_open") + copy("api_features.log") copy("logging") def is_admin(self, user: str) -> bool: diff --git a/maubot/instance.py b/maubot/instance.py index 3d51bbd..fd6fa57 100644 --- a/maubot/instance.py +++ b/maubot/instance.py @@ -77,7 +77,8 @@ class PluginInstance: "started": self.started, "primary_user": self.primary_user, "config": self.db_instance.config, - "database": self.inst_db is not None, + "database": (self.inst_db is not None + and self.mb_config["api_features.instance_database"]), } def get_db_tables(self) -> Dict[str, sql.Table]: diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py index 667d0fe..1f0d420 100644 --- a/maubot/management/api/__init__.py +++ b/maubot/management/api/__init__.py @@ -15,21 +15,24 @@ # along with this program. If not, see . from aiohttp import web from asyncio import AbstractEventLoop +import importlib from ...config import Config -from .base import routes, set_config, set_loop +from .base import routes, get_config, set_config, set_loop from .middleware import auth, error -from . import auth, plugin, instance, instance_database, client, client_proxy, client_auth, dev_open -from .log import stop_all as stop_log_sockets, init as init_log_listener + + +@routes.get("/features") +def features(_: web.Request) -> web.Response: + return web.json_response(get_config()["api_features"]) def init(cfg: Config, loop: AbstractEventLoop) -> web.Application: set_config(cfg) set_loop(loop) - app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100*1024*1024) + for pkg, enabled in cfg["api_features"].items(): + if enabled: + importlib.import_module(f"maubot.management.api.{pkg}") + app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100 * 1024 * 1024) app.add_routes(routes) return app - - -async def stop() -> None: - await stop_log_sockets() diff --git a/maubot/management/api/auth.py b/maubot/management/api/auth.py index e754809..aab1c4e 100644 --- a/maubot/management/api/auth.py +++ b/maubot/management/api/auth.py @@ -15,7 +15,6 @@ # along with this program. If not, see . from typing import Optional from time import time -import json from aiohttp import web @@ -71,22 +70,3 @@ async def ping(request: web.Request) -> web.Response: if not get_config().is_admin(user): return resp.invalid_token return resp.pong(user) - - -@routes.post("/auth/login") -async def login(request: web.Request) -> web.Response: - try: - data = await request.json() - except json.JSONDecodeError: - return resp.body_not_json - secret = data.get("secret") - if secret and get_config()["server.unshared_secret"] == secret: - user = data.get("user") or "root" - return resp.logged_in(create_token(user)) - - username = data.get("username") - password = data.get("password") - if get_config().check_password(username, password): - return resp.logged_in(create_token(username)) - - return resp.bad_auth diff --git a/maubot/management/api/client_proxy.py b/maubot/management/api/client_proxy.py index 8dbb650..d34f236 100644 --- a/maubot/management/api/client_proxy.py +++ b/maubot/management/api/client_proxy.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from aiohttp import web, client as http + from ...client import Client from .base import routes from .responses import resp diff --git a/maubot/management/api/login.py b/maubot/management/api/login.py new file mode 100644 index 0000000..1229e6b --- /dev/null +++ b/maubot/management/api/login.py @@ -0,0 +1,40 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import json + +from aiohttp import web + +from .base import routes, get_config +from .responses import resp +from .auth import create_token + +@routes.post("/auth/login") +async def login(request: web.Request) -> web.Response: + try: + data = await request.json() + except json.JSONDecodeError: + return resp.body_not_json + secret = data.get("secret") + if secret and get_config()["server.unshared_secret"] == secret: + user = data.get("user") or "root" + return resp.logged_in(create_token(user)) + + username = data.get("username") + password = data.get("password") + if get_config().check_password(username, password): + return resp.logged_in(create_token(username)) + + return resp.bad_auth diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py index d0c79f8..92bd744 100644 --- a/maubot/management/api/plugin.py +++ b/maubot/management/api/plugin.py @@ -13,18 +13,13 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from io import BytesIO -from time import time import traceback -import os.path -import re from aiohttp import web -from packaging.version import Version -from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError +from ...loader import PluginLoader, MaubotZipImportError from .responses import resp -from .base import routes, get_config +from .base import routes @routes.get("/plugins") @@ -67,85 +62,3 @@ async def reload_plugin(request: web.Request) -> web.Response: return resp.plugin_reload_error(str(e), traceback.format_exc()) await plugin.start_instances() return resp.ok - - -@routes.put("/plugin/{id}") -async def put_plugin(request: web.Request) -> web.Response: - plugin_id = request.match_info.get("id", None) - content = await request.read() - file = BytesIO(content) - try: - pid, version = ZippedPluginLoader.verify_meta(file) - except MaubotZipImportError as e: - return resp.plugin_import_error(str(e), traceback.format_exc()) - if pid != plugin_id: - return resp.pid_mismatch - plugin = PluginLoader.id_cache.get(plugin_id, None) - if not plugin: - return await upload_new_plugin(content, pid, version) - elif isinstance(plugin, ZippedPluginLoader): - return await upload_replacement_plugin(plugin, content, version) - else: - return resp.unsupported_plugin_loader - - -@routes.post("/plugins/upload") -async def upload_plugin(request: web.Request) -> web.Response: - content = await request.read() - file = BytesIO(content) - try: - pid, version = ZippedPluginLoader.verify_meta(file) - except MaubotZipImportError as e: - return resp.plugin_import_error(str(e), traceback.format_exc()) - plugin = PluginLoader.id_cache.get(pid, None) - if not plugin: - return await upload_new_plugin(content, pid, version) - elif not request.query.get("allow_override"): - return resp.plugin_exists - elif isinstance(plugin, ZippedPluginLoader): - return await upload_replacement_plugin(plugin, content, version) - else: - return resp.unsupported_plugin_loader - - -async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.Response: - path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp") - with open(path, "wb") as p: - p.write(content) - try: - plugin = ZippedPluginLoader.get(path) - except MaubotZipImportError as e: - ZippedPluginLoader.trash(path) - return resp.plugin_import_error(str(e), traceback.format_exc()) - return resp.created(plugin.to_dict()) - - -async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes, - new_version: Version) -> web.Response: - dirname = os.path.dirname(plugin.path) - old_filename = os.path.basename(plugin.path) - if str(plugin.meta.version) in old_filename: - replacement = (new_version if plugin.meta.version != new_version - else f"{new_version}-ts{int(time())}") - filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?", - replacement, old_filename) - else: - filename = old_filename.rstrip(".mbp") - filename = f"{filename}-v{new_version}.mbp" - path = os.path.join(dirname, filename) - with open(path, "wb") as p: - p.write(content) - old_path = plugin.path - await plugin.stop_instances() - try: - await plugin.reload(new_path=path) - except MaubotZipImportError as e: - try: - await plugin.reload(new_path=old_path) - await plugin.start_instances() - except MaubotZipImportError: - pass - return resp.plugin_import_error(str(e), traceback.format_exc()) - await plugin.start_instances() - ZippedPluginLoader.trash(old_path, reason="update") - return resp.updated(plugin.to_dict()) diff --git a/maubot/management/api/plugin_upload.py b/maubot/management/api/plugin_upload.py new file mode 100644 index 0000000..c82fdcb --- /dev/null +++ b/maubot/management/api/plugin_upload.py @@ -0,0 +1,109 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from io import BytesIO +from time import time +import traceback +import os.path +import re + +from aiohttp import web +from packaging.version import Version + +from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError +from .responses import resp +from .base import routes, get_config + + +@routes.put("/plugin/{id}") +async def put_plugin(request: web.Request) -> web.Response: + plugin_id = request.match_info.get("id", None) + content = await request.read() + file = BytesIO(content) + try: + pid, version = ZippedPluginLoader.verify_meta(file) + except MaubotZipImportError as e: + return resp.plugin_import_error(str(e), traceback.format_exc()) + if pid != plugin_id: + return resp.pid_mismatch + plugin = PluginLoader.id_cache.get(plugin_id, None) + if not plugin: + return await upload_new_plugin(content, pid, version) + elif isinstance(plugin, ZippedPluginLoader): + return await upload_replacement_plugin(plugin, content, version) + else: + return resp.unsupported_plugin_loader + + +@routes.post("/plugins/upload") +async def upload_plugin(request: web.Request) -> web.Response: + content = await request.read() + file = BytesIO(content) + try: + pid, version = ZippedPluginLoader.verify_meta(file) + except MaubotZipImportError as e: + return resp.plugin_import_error(str(e), traceback.format_exc()) + plugin = PluginLoader.id_cache.get(pid, None) + if not plugin: + return await upload_new_plugin(content, pid, version) + elif not request.query.get("allow_override"): + return resp.plugin_exists + elif isinstance(plugin, ZippedPluginLoader): + return await upload_replacement_plugin(plugin, content, version) + else: + return resp.unsupported_plugin_loader + + +async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.Response: + path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp") + with open(path, "wb") as p: + p.write(content) + try: + plugin = ZippedPluginLoader.get(path) + except MaubotZipImportError as e: + ZippedPluginLoader.trash(path) + return resp.plugin_import_error(str(e), traceback.format_exc()) + return resp.created(plugin.to_dict()) + + +async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes, + new_version: Version) -> web.Response: + dirname = os.path.dirname(plugin.path) + old_filename = os.path.basename(plugin.path) + if str(plugin.meta.version) in old_filename: + replacement = (new_version if plugin.meta.version != new_version + else f"{new_version}-ts{int(time())}") + filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?", + replacement, old_filename) + else: + filename = old_filename.rstrip(".mbp") + filename = f"{filename}-v{new_version}.mbp" + path = os.path.join(dirname, filename) + with open(path, "wb") as p: + p.write(content) + old_path = plugin.path + await plugin.stop_instances() + try: + await plugin.reload(new_path=path) + except MaubotZipImportError as e: + try: + await plugin.reload(new_path=old_path) + await plugin.start_instances() + except MaubotZipImportError: + pass + return resp.plugin_import_error(str(e), traceback.format_exc()) + await plugin.start_instances() + ZippedPluginLoader.trash(old_path, reason="update") + return resp.updated(plugin.to_dict()) diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js index aee7fd4..33cd551 100644 --- a/maubot/management/frontend/src/api.js +++ b/maubot/management/frontend/src/api.js @@ -61,7 +61,12 @@ export async function login(username, password) { return await resp.json() } +let features = null + export async function ping() { + if (!features) { + await remoteGetFeatures() + } const response = await fetch(`${BASE_PATH}/auth/ping`, { method: "POST", headers: getHeaders(), @@ -75,6 +80,12 @@ export async function ping() { throw json } +export const remoteGetFeatures = async () => { + features = await defaultGet("/features") +} + +export const getFeatures = () => features + export async function openLogSocket() { let protocol = window.location.protocol === "https:" ? "wss:" : "ws:" const url = `${protocol}//${window.location.host}${BASE_PATH}/logs` @@ -211,7 +222,9 @@ export const deleteClient = id => defaultDelete("client", id) export default { BASE_PATH, - login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled, + login, ping, getFeatures, remoteGetFeatures, + openLogSocket, + debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled, getInstances, getInstance, putInstance, deleteInstance, getInstanceDatabase, queryInstanceDatabase, getPlugins, getPlugin, uploadPlugin, deletePlugin, diff --git a/maubot/management/frontend/src/pages/Login.js b/maubot/management/frontend/src/pages/Login.js index 5b97f14..6e24af0 100644 --- a/maubot/management/frontend/src/pages/Login.js +++ b/maubot/management/frontend/src/pages/Login.js @@ -44,6 +44,14 @@ class Login extends Component { } render() { + if (!api.getFeatures().login) { + return
+
+

Maubot Manager

+
Login has been disabled in the maubot config.
+
+
+ } return

Maubot Manager

diff --git a/maubot/management/frontend/src/pages/Main.js b/maubot/management/frontend/src/pages/Main.js index 63f419c..210b4f0 100644 --- a/maubot/management/frontend/src/pages/Main.js +++ b/maubot/management/frontend/src/pages/Main.js @@ -33,6 +33,8 @@ class Main extends Component { async componentWillMount() { if (localStorage.accessToken) { await this.ping() + } else { + await api.remoteGetFeatures() } this.setState({ pinged: true }) } diff --git a/maubot/management/frontend/src/pages/dashboard/BaseMainView.js b/maubot/management/frontend/src/pages/dashboard/BaseMainView.js index 47c1cd8..0fe44b7 100644 --- a/maubot/management/frontend/src/pages/dashboard/BaseMainView.js +++ b/maubot/management/frontend/src/pages/dashboard/BaseMainView.js @@ -1,5 +1,6 @@ import React, { Component } from "react" import { Link } from "react-router-dom" +import api from "../../api" class BaseMainView extends Component { constructor(props) { @@ -74,7 +75,7 @@ class BaseMainView extends Component {
) - renderLogButton = (filter) => !this.isNew &&
+ renderLogButton = (filter) => !this.isNew && api.getFeatures().log &&
} diff --git a/maubot/management/frontend/src/pages/dashboard/Instance.js b/maubot/management/frontend/src/pages/dashboard/Instance.js index f68a07b..e80e0bc 100644 --- a/maubot/management/frontend/src/pages/dashboard/Instance.js +++ b/maubot/management/frontend/src/pages/dashboard/Instance.js @@ -157,13 +157,24 @@ class Instance extends BaseMainView { this.setState({ started })}/> - this.setState({ primary_user: id })}/> - this.setState({ type: id })}/> + {api.getFeatures().client ? ( + this.setState({ primary_user: id })}/> + ) : ( + + )} + {api.getFeatures().plugin ? ( + this.setState({ type: id })}/> + ) : ( + + )} {!this.isNew && this.setState({ config })} @@ -190,10 +201,11 @@ class Instance extends BaseMainView { View database )} + {api.getFeatures().log && + }
}
{this.state.error}
diff --git a/maubot/management/frontend/src/pages/dashboard/Plugin.js b/maubot/management/frontend/src/pages/dashboard/Plugin.js index e3ea809..568a804 100644 --- a/maubot/management/frontend/src/pages/dashboard/Plugin.js +++ b/maubot/management/frontend/src/pages/dashboard/Plugin.js @@ -78,6 +78,7 @@ class Plugin extends BaseMainView { } + {api.getFeatures().plugin_upload &&
evt.target.parentElement.classList.add("drag")} onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/> {this.state.uploading && } -
+ } {!this.isNew &&
) + renderDisabled = (thing = "path") => ( +
+ The {thing} API has been disabled in the maubot config. +
+ ) + renderMain() { return
@@ -157,31 +181,31 @@ class Dashboard extends Component {