diff --git a/example-config.yaml b/example-config.yaml index e76e430..9bcf2bf 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -5,24 +5,30 @@ # Postgres: postgres://username:password@hostname/dbname database: sqlite:///maubot.db -# The directory where plugin databases should be stored. -plugin_db_directory: ./plugins - -# If multiple directories have a plugin with the same name, the first directory is used. plugin_directories: -- ./plugins + # The directory where uploaded new plugins should be stored. + upload: ./plugins + # The directories from which plugins should be loaded. + # Duplicate plugin IDs will be moved to the trash. + load: + - ./plugins + # The directory where old plugin versions and conflicting plugins should be moved. + # Set to "delete" to delete files immediately. + trash: ./trash + # The directory where plugin databases should be stored. + db: ./plugins server: # The IP and port to listen to. hostname: 0.0.0.0 port: 29316 # The base management API path. - base_path: /_matrix/maubot + base_path: /_matrix/maubot/v1 # The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1. appservice_base_path: /_matrix/app/v1 - # The shared secret to authorize users of the API. + # The shared secret to sign API access tokens. # Set to "generate" to generate and save a new token at startup. - shared_secret: generate + unshared_secret: generate admins: - "@admin:example.com" diff --git a/maubot/__main__.py b/maubot/__main__.py index fe9f168..3c2b42e 100644 --- a/maubot/__main__.py +++ b/maubot/__main__.py @@ -14,20 +14,23 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from sqlalchemy import orm +from time import time import sqlalchemy as sql import logging.config import argparse import asyncio +import signal import copy import sys -import signal +import os from .config import Config from .db import Base, init as init_db from .server import MaubotServer from .client import Client, init as init_client -from .loader import ZippedPluginLoader -from .plugin import PluginInstance, init as init_plugin_instance_class +from .loader import ZippedPluginLoader, MaubotZipImportError, IDConflictError +from .instance import PluginInstance, init as init_plugin_instance_class +from .management.api import init as init_management from .__meta__ import __version__ parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.", @@ -57,9 +60,36 @@ loop = asyncio.get_event_loop() init_db(db_session) init_client(loop) -init_plugin_instance_class(config) -server = MaubotServer(config, loop) -ZippedPluginLoader.load_all(*config["plugin_directories"]) +init_plugin_instance_class(db_session, config) +management_api = init_management(config, loop) +server = MaubotServer(config, management_api, loop) + +trash_path = config["plugin_directories.trash"] + + +def trash(file_path: str, new_name: Optional[str] = None) -> None: + if trash_path == "delete": + os.remove(file_path) + else: + new_name = new_name or f"{int(time())}-{os.path.basename(file_path)}" + os.rename(file_path, os.path.abspath(os.path.join(trash_path, new_name))) + + +ZippedPluginLoader.log.debug("Preloading plugins...") +for directory in config["plugin_directories.load"]: + for file in os.listdir(directory): + if not file.endswith(".mbp"): + continue + path = os.path.abspath(os.path.join(directory, file)) + try: + ZippedPluginLoader.get(path) + except MaubotZipImportError: + ZippedPluginLoader.log.exception(f"Failed to load plugin at {path}, trashing...") + trash(path) + except IDConflictError: + ZippedPluginLoader.log.warn(f"Duplicate plugin ID at {path}, trashing...") + trash(path) + plugins = PluginInstance.all() for plugin in plugins: diff --git a/maubot/client.py b/maubot/client.py index 807e7a2..e96c918 100644 --- a/maubot/client.py +++ b/maubot/client.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set, TYPE_CHECKING from aiohttp import ClientSession import asyncio import logging @@ -24,6 +24,9 @@ from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStat from .db import DBClient from .matrix import MaubotMatrixClient +if TYPE_CHECKING: + from .instance import PluginInstance + log = logging.getLogger("maubot.client") @@ -32,6 +35,7 @@ class Client: cache: Dict[UserID, 'Client'] = {} http_client: ClientSession = None + references: Set['PluginInstance'] db_instance: DBClient client: MaubotMatrixClient @@ -39,6 +43,7 @@ class Client: self.db_instance = db_instance self.cache[self.id] = self self.log = log.getChild(self.id) + self.references = set() self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver, token=self.access_token, client_session=self.http_client, log=self.log, loop=self.loop, store=self.db_instance) diff --git a/maubot/config.py b/maubot/config.py index 6858482..699003c 100644 --- a/maubot/config.py +++ b/maubot/config.py @@ -16,7 +16,7 @@ import random import string -from mautrix.util import BaseFileConfig, ConfigUpdateHelper +from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper class Config(BaseFileConfig): diff --git a/maubot/plugin.py b/maubot/instance.py similarity index 86% rename from maubot/plugin.py rename to maubot/instance.py index 65875ef..0438713 100644 --- a/maubot/plugin.py +++ b/maubot/instance.py @@ -14,12 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Dict, List, Optional +from sqlalchemy.orm import Session from ruamel.yaml.comments import CommentedMap from ruamel.yaml import YAML import logging import io -from mautrix.util import BaseProxyConfig, RecursiveDict +from mautrix.util.config import BaseProxyConfig, RecursiveDict from mautrix.types import UserID from .db import DBPlugin @@ -35,6 +36,7 @@ yaml.indent(4) class PluginInstance: + db: Session = None mb_config: Config = None cache: Dict[str, 'PluginInstance'] = {} plugin_directories: List[str] = [] @@ -44,13 +46,24 @@ class PluginInstance: client: Client plugin: Plugin config: BaseProxyConfig + running: bool def __init__(self, db_instance: DBPlugin): self.db_instance = db_instance self.log = logging.getLogger(f"maubot.plugin.{self.id}") self.config = None + self.running = False self.cache[self.id] = self + def to_dict(self) -> dict: + return { + "id": self.id, + "type": self.type, + "enabled": self.enabled, + "running": self.running, + "primary_user": self.primary_user, + } + def load(self) -> None: try: self.loader = PluginLoader.find(self.type) @@ -63,6 +76,13 @@ class PluginInstance: self.log.error(f"Failed to get client for user {self.primary_user}") self.enabled = False self.log.debug("Plugin instance dependencies loaded") + self.loader.references.add(self) + self.client.references.add(self) + + def delete(self) -> None: + self.loader.references.remove(self) + self.db.delete(self.db_instance) + # TODO delete plugin db def load_config(self) -> CommentedMap: return yaml.load(self.db_instance.config) @@ -90,14 +110,14 @@ class PluginInstance: self.save_config) self.plugin = cls(self.client.client, self.id, self.log, self.config, self.mb_config["plugin_db_directory"]) - self.loader.references |= {self} await self.plugin.start() + self.running = True self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} " f"with user {self.client.id}") async def stop(self) -> None: self.log.debug("Stopping plugin instance...") - self.loader.references -= {self} + self.running = False await self.plugin.stop() self.plugin = None @@ -130,10 +150,6 @@ class PluginInstance: def type(self) -> str: return self.db_instance.type - @type.setter - def type(self, value: str) -> None: - self.db_instance.type = value - @property def enabled(self) -> bool: return self.db_instance.enabled @@ -153,5 +169,6 @@ class PluginInstance: # endregion -def init(config: Config): +def init(db: Session, config: Config): + PluginInstance.db = db PluginInstance.mb_config = config diff --git a/maubot/loader/__init__.py b/maubot/loader/__init__.py index 304cc57..e2a356e 100644 --- a/maubot/loader/__init__.py +++ b/maubot/loader/__init__.py @@ -1,2 +1,2 @@ -from .abc import PluginLoader, PluginClass +from .abc import PluginLoader, PluginClass, IDConflictError from .zip import ZippedPluginLoader, MaubotZipImportError diff --git a/maubot/loader/abc.py b/maubot/loader/abc.py index f41d848..6308a27 100644 --- a/maubot/loader/abc.py +++ b/maubot/loader/abc.py @@ -15,11 +15,12 @@ # along with this program. If not, see . from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING from abc import ABC, abstractmethod +import asyncio from ..plugin_base import Plugin if TYPE_CHECKING: - from ..plugin import PluginInstance + from ..instance import PluginInstance PluginClass = TypeVar("PluginClass", bound=Plugin) @@ -42,6 +43,12 @@ class PluginLoader(ABC): def find(cls, plugin_id: str) -> 'PluginLoader': return cls.id_cache[plugin_id] + def to_dict(self) -> dict: + return { + "id": self.id, + "version": self.version, + } + @property @abstractmethod def source(self) -> str: @@ -51,6 +58,12 @@ class PluginLoader(ABC): def read_file(self, path: str) -> bytes: pass + async def stop_instances(self) -> None: + await asyncio.gather([instance.stop() for instance in self.references if instance.running]) + + async def start_instances(self) -> None: + await asyncio.gather([instance.start() for instance in self.references if instance.enabled]) + @abstractmethod def load(self) -> Type[PluginClass]: pass @@ -62,3 +75,7 @@ class PluginLoader(ABC): @abstractmethod def unload(self) -> None: pass + + @abstractmethod + def delete(self) -> None: + pass diff --git a/maubot/loader/zip.py b/maubot/loader/zip.py index a6a107e..7630c9e 100644 --- a/maubot/loader/zip.py +++ b/maubot/loader/zip.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, Type +from typing import Dict, List, Type, Tuple from zipfile import ZipFile, BadZipFile import configparser import logging @@ -60,8 +60,15 @@ class ZippedPluginLoader(PluginLoader): self.id_cache[self.id] = self self.log.debug(f"Preloaded plugin {self.id} from {self.path}") + def to_dict(self) -> dict: + return { + **super().to_dict(), + "path": self.path + } + @classmethod def get(cls, path: str) -> 'ZippedPluginLoader': + path = os.path.abspath(path) try: return cls.path_cache[path] except KeyError: @@ -80,10 +87,11 @@ class ZippedPluginLoader(PluginLoader): def read_file(self, path: str) -> bytes: return self._file.read(path) - def _load_meta(self) -> None: + @staticmethod + def _open_meta(source) -> Tuple[ZipFile, configparser.ConfigParser]: try: - self._file = ZipFile(self.path) - data = self._file.read("maubot.ini") + file = ZipFile(source) + data = file.read("maubot.ini") except FileNotFoundError as e: raise MaubotZipImportError("Maubot plugin not found") from e except BadZipFile as e: @@ -92,7 +100,14 @@ class ZippedPluginLoader(PluginLoader): raise MaubotZipImportError("File does not contain a maubot plugin definition") from e config = configparser.ConfigParser() try: - config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini") + config.read_string(data.decode("utf-8")) + except (configparser.Error, KeyError, IndexError, ValueError) as e: + raise MaubotZipImportError("Maubot plugin definition in file is invalid") from e + return file, config + + @classmethod + def _read_meta(cls, config: configparser.ConfigParser) -> Tuple[str, str, List[str], str, str]: + try: meta = config["maubot"] meta_id = meta["ID"] version = meta["Version"] @@ -103,10 +118,21 @@ class ZippedPluginLoader(PluginLoader): main_module, main_class = main_class.split("/")[:2] except (configparser.Error, KeyError, IndexError, ValueError) as e: raise MaubotZipImportError("Maubot plugin definition in file is invalid") from e - if self.id and meta_id != self.id: + return meta_id, version, modules, main_class, main_module + + @classmethod + def verify_meta(cls, source) -> Tuple[str, str]: + _, config = cls._open_meta(source) + meta = cls._read_meta(config) + return meta[0], meta[1] + + def _load_meta(self) -> None: + file, config = self._open_meta(self.path) + meta = self._read_meta(config) + if self.id and meta[0] != self.id: raise MaubotZipImportError("Maubot plugin ID changed during reload") - self.id, self.version, self.modules = meta_id, version, modules - self.main_class, self.main_module = main_class, main_module + self.id, self.version, self.modules, self.main_class, self.main_module = meta + self._file = file def _get_importer(self, reset_cache: bool = False) -> zipimporter: try: @@ -161,7 +187,7 @@ class ZippedPluginLoader(PluginLoader): self._loaded = None self.log.debug(f"Unloaded plugin {self.id} at {self.path}") - def destroy(self) -> None: + def delete(self) -> None: self.unload() try: del self.path_cache[self.path] @@ -171,24 +197,12 @@ class ZippedPluginLoader(PluginLoader): del self.id_cache[self.id] except KeyError: pass - self.id = None - self.path = None - self.version = None - self.modules = None if self._importer: self._importer.remove_cache() self._importer = None self._loaded = None - - @classmethod - def load_all(cls, *args: str) -> None: - cls.log.debug("Preloading plugins...") - for directory in args: - for file in os.listdir(directory): - if not file.endswith(".mbp"): - continue - path = os.path.join(directory, file) - try: - ZippedPluginLoader.get(path) - except (MaubotZipImportError, IDConflictError): - cls.log.exception(f"Failed to load plugin at {path}") + os.remove(self.path) + self.id = None + self.path = None + self.version = None + self.modules = None diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py index e69de29..dc08a5f 100644 --- a/maubot/management/api/__init__.py +++ b/maubot/management/api/__init__.py @@ -0,0 +1,46 @@ +# 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 aiohttp import web +from asyncio import AbstractEventLoop + +from mautrix.types import UserID +from mautrix.util.signed_token import sign_token, verify_token + +from ...config import Config + +routes = web.RouteTableDef() +config: Config = None + + +def is_valid_token(token: str) -> bool: + data = verify_token(config["server.unshared_secret"], token) + user_id = data.get("user_id", None) + return user_id is not None and user_id in config["admins"] + + +def create_token(user: UserID) -> str: + return sign_token(config["server.unshared_secret"], { + "user_id": user, + }) + + +def init(cfg: Config, loop: AbstractEventLoop) -> web.Application: + global config + config = cfg + from .middleware import auth, error, log + app = web.Application(loop=loop, middlewares=[auth, log, error]) + app.add_routes(routes) + return app diff --git a/maubot/management/api/middleware.py b/maubot/management/api/middleware.py new file mode 100644 index 0000000..f1b76ad --- /dev/null +++ b/maubot/management/api/middleware.py @@ -0,0 +1,67 @@ +# 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 typing import Callable, Awaitable +from aiohttp import web +import logging + +from .responses import ErrNoToken, ErrInvalidToken +from . import is_valid_token + +Handler = Callable[[web.Request], Awaitable[web.Response]] + +req_log = logging.getLogger("maubot.mgmt.request") +resp_log = logging.getLogger("maubot.mgmt.response") + + +@web.middleware +async def auth(request: web.Request, handler: Handler) -> web.Response: + token = request.headers.get("Authorization", "") + if not token or not token.startswith("Bearer "): + req_log.debug(f"Request missing auth: {request.remote} {request.method} {request.path}") + return ErrNoToken + if not is_valid_token(token[len("Bearer "):]): + req_log.debug(f"Request invalid auth: {request.remote} {request.method} {request.path}") + return ErrInvalidToken + return await handler(request) + + +@web.middleware +async def error(request: web.Request, handler: Handler) -> web.Response: + try: + return await handler(request) + except web.HTTPException as ex: + return web.json_response({ + "error": f"Unhandled HTTP {ex.status}", + "errcode": f"unhandled_http_{ex.status}", + }, status=ex.status) + + +req_no = 0 + + +def get_req_no(): + global req_no + req_no += 1 + return req_no + + +@web.middleware +async def log(request: web.Request, handler: Handler) -> web.Response: + local_req_no = get_req_no() + req_log.info(f"Request {local_req_no}: {request.remote} {request.method} {request.path}") + resp = await handler(request) + resp_log.info(f"Responded to {local_req_no} from {request.remote}: {resp}") + return resp diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py new file mode 100644 index 0000000..c85f88b --- /dev/null +++ b/maubot/management/api/plugin.py @@ -0,0 +1,83 @@ +# 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 aiohttp import web +from io import BytesIO +import os.path + +from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError +from .responses import ErrPluginNotFound, ErrPluginInUse, RespDeleted +from . import routes, config + + +def _plugin_to_dict(plugin: PluginLoader) -> dict: + return { + **plugin.to_dict(), + "instances": [instance.to_dict() for instance in plugin.references] + } + + +@routes.get("/plugins") +async def get_plugins(_) -> web.Response: + return web.json_response([_plugin_to_dict(plugin) for plugin in PluginLoader.id_cache.values()]) + + +@routes.get("/plugin/{id}") +async def get_plugin(request: web.Request) -> web.Response: + plugin_id = request.match_info.get("id", None) + plugin = PluginLoader.id_cache.get(plugin_id, None) + if not plugin: + return ErrPluginNotFound + return web.json_response(_plugin_to_dict(plugin)) + + +@routes.delete("/plugin/{id}") +async def delete_plugin(request: web.Request) -> web.Response: + plugin_id = request.match_info.get("id", None) + plugin = PluginLoader.id_cache.get(plugin_id, None) + if not plugin: + return ErrPluginNotFound + elif len(plugin.references) > 0: + return ErrPluginInUse + plugin.delete() + return RespDeleted + + +@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 web.json_response({ + "error": str(e), + "errcode": "invalid_plugin", + }, status=web.HTTPBadRequest) + plugin = PluginLoader.id_cache.get(pid, None) + if not plugin: + path = os.path.join(config["plugin_directories.upload"], f"{pid}-{version}.mbp") + with open(path, "wb") as p: + p.write(content) + try: + ZippedPluginLoader.get(path) + except MaubotZipImportError as e: + trash(path) + return web.json_response({ + "error": str(e), + "errcode": "invalid_plugin", + }, status=web.HTTPBadRequest) + else: + pass diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py new file mode 100644 index 0000000..bab2b01 --- /dev/null +++ b/maubot/management/api/responses.py @@ -0,0 +1,38 @@ +# 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 aiohttp import web + +ErrNoToken = web.json_response({ + "error": "Authorization token missing", + "errcode": "auth_token_missing", +}, status=web.HTTPUnauthorized) + +ErrInvalidToken = web.json_response({ + "error": "Invalid authorization token", + "errcode": "auth_token_invalid", +}, status=web.HTTPUnauthorized) + +ErrPluginNotFound = web.json_response({ + "error": "Plugin not found", + "errcode": "plugin_not_found", +}, status=web.HTTPNotFound) + +ErrPluginInUse = web.json_response({ + "error": "Plugin instances of this type still exist", + "errcode": "plugin_in_use", +}) + +RespDeleted = web.Response(status=204) diff --git a/maubot/plugin_base.py b/maubot/plugin_base.py index b28ab4a..08d1211 100644 --- a/maubot/plugin_base.py +++ b/maubot/plugin_base.py @@ -24,7 +24,7 @@ import sqlalchemy as sql if TYPE_CHECKING: from .client import MaubotMatrixClient from .command_spec import CommandSpec - from mautrix.util import BaseProxyConfig + from mautrix.util.config import BaseProxyConfig class Plugin(ABC): diff --git a/maubot/server.py b/maubot/server.py index 7a666ec..4bd7bd2 100644 --- a/maubot/server.py +++ b/maubot/server.py @@ -23,13 +23,15 @@ from .__meta__ import __version__ class MaubotServer: - def __init__(self, config: Config, loop: asyncio.AbstractEventLoop): + def __init__(self, config: Config, management: web.Application, + loop: asyncio.AbstractEventLoop) -> None: self.loop = loop or asyncio.get_event_loop() self.app = web.Application(loop=self.loop) self.config = config path = PathBuilder(config["server.base_path"]) self.add_route(Method.GET, path.version, self.version) + self.app.add_subapp(config["server.base_path"], management) as_path = PathBuilder(config["server.appservice_base_path"]) self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)