Add Cloudron packaging for Maubot

This commit is contained in:
Your Name
2025-11-13 21:00:23 -06:00
commit 1af2e64aae
179 changed files with 24749 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from asyncio import AbstractEventLoop
import importlib
from aiohttp import web
from ...config import Config
from .auth import check_token
from .base import get_config, routes, set_config
from .middleware import auth, error
@routes.get("/features")
def features(request: web.Request) -> web.Response:
data = get_config()["api_features"]
err = check_token(request)
if err is None:
return web.json_response(data)
else:
return web.json_response(
{
"login": data["login"],
}
)
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
set_config(cfg)
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

View File

@@ -0,0 +1,76 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from time import time
from aiohttp import web
from mautrix.types import UserID
from mautrix.util.signed_token import sign_token, verify_token
from .base import get_config, routes
from .responses import resp
def is_valid_token(token: str) -> bool:
data = verify_token(get_config()["server.unshared_secret"], token)
if not data:
return False
return get_config().is_admin(data.get("user_id", None))
def create_token(user: UserID) -> str:
return sign_token(
get_config()["server.unshared_secret"],
{
"user_id": user,
"created_at": int(time()),
},
)
def get_token(request: web.Request) -> str:
token = request.headers.get("Authorization", "")
if not token or not token.startswith("Bearer "):
token = request.query.get("access_token", "")
else:
token = token[len("Bearer ") :]
return token
def check_token(request: web.Request) -> web.Response | None:
token = get_token(request)
if not token:
return resp.no_token
elif not is_valid_token(token):
return resp.invalid_token
return None
@routes.post("/auth/ping")
async def ping(request: web.Request) -> web.Response:
token = get_token(request)
if not token:
return resp.no_token
data = verify_token(get_config()["server.unshared_secret"], token)
if not data:
return resp.invalid_token
user = data.get("user_id", None)
if not get_config().is_admin(user):
return resp.invalid_token
return resp.pong(user, get_config()["api_features"])

View File

@@ -0,0 +1,40 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from aiohttp import web
from ...__meta__ import __version__
from ...config import Config
routes: web.RouteTableDef = web.RouteTableDef()
_config: Config | None = None
def set_config(config: Config) -> None:
global _config
_config = config
def get_config() -> Config:
return _config
@routes.get("/version")
async def version(_: web.Request) -> web.Response:
return web.json_response({"version": __version__})

View File

@@ -0,0 +1,217 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from json import JSONDecodeError
import logging
from aiohttp import web
from mautrix.client import Client as MatrixClient
from mautrix.errors import MatrixConnectionError, MatrixInvalidToken, MatrixRequestError
from mautrix.types import FilterID, SyncToken, UserID
from ...client import Client
from .base import routes
from .responses import resp
log = logging.getLogger("maubot.server.client")
@routes.get("/clients")
async def get_clients(_: web.Request) -> web.Response:
return resp.found([client.to_dict() for client in Client.cache.values()])
@routes.get("/client/{id}")
async def get_client(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = await Client.get(user_id)
if not client:
return resp.client_not_found
return resp.found(client.to_dict())
async def _create_client(user_id: UserID | None, data: dict) -> web.Response:
homeserver = data.get("homeserver", None)
access_token = data.get("access_token", None)
device_id = data.get("device_id", None)
new_client = MatrixClient(
mxid="@not:a.mxid",
base_url=homeserver,
token=access_token,
client_session=Client.http_client,
)
try:
whoami = await new_client.whoami()
except MatrixInvalidToken as e:
return resp.bad_client_access_token
except MatrixRequestError:
log.warning(f"Failed to get whoami from {homeserver} for new client", exc_info=True)
return resp.bad_client_access_details
except MatrixConnectionError:
log.warning(f"Failed to connect to {homeserver} for new client", exc_info=True)
return resp.bad_client_connection_details
if user_id is None:
existing_client = await Client.get(whoami.user_id)
if existing_client is not None:
return resp.user_exists
elif whoami.user_id != user_id:
return resp.mxid_mismatch(whoami.user_id)
elif whoami.device_id and device_id and whoami.device_id != device_id:
return resp.device_id_mismatch(whoami.device_id)
client = await Client.get(
whoami.user_id, homeserver=homeserver, access_token=access_token, device_id=device_id
)
client.enabled = data.get("enabled", True)
client.sync = data.get("sync", True)
await client.update_autojoin(data.get("autojoin", True), save=False)
await client.update_online(data.get("online", True), save=False)
client.displayname = data.get("displayname", "disable")
client.avatar_url = data.get("avatar_url", "disable")
await client.update()
await client.start()
return resp.created(client.to_dict())
async def _update_client(client: Client, data: dict, is_login: bool = False) -> web.Response:
try:
await client.update_access_details(
data.get("access_token"), data.get("homeserver"), data.get("device_id")
)
except MatrixInvalidToken:
return resp.bad_client_access_token
except MatrixRequestError:
log.warning(
f"Failed to get whoami from homeserver to update client details", exc_info=True
)
return resp.bad_client_access_details
except MatrixConnectionError:
log.warning(f"Failed to connect to homeserver to update client details", exc_info=True)
return resp.bad_client_connection_details
except ValueError as e:
str_err = str(e)
if str_err.startswith("MXID mismatch"):
return resp.mxid_mismatch(str(e)[len("MXID mismatch: ") :])
elif str_err.startswith("Device ID mismatch"):
return resp.device_id_mismatch(str(e)[len("Device ID mismatch: ") :])
await client.update_avatar_url(data.get("avatar_url"), save=False)
await client.update_displayname(data.get("displayname"), save=False)
await client.update_started(data.get("started"))
await client.update_enabled(data.get("enabled"), save=False)
await client.update_autojoin(data.get("autojoin"), save=False)
await client.update_online(data.get("online"), save=False)
await client.update_sync(data.get("sync"), save=False)
await client.update()
return resp.updated(client.to_dict(), is_login=is_login)
async def _create_or_update_client(
user_id: UserID, data: dict, is_login: bool = False
) -> web.Response:
client = await Client.get(user_id)
if not client:
return await _create_client(user_id, data)
else:
return await _update_client(client, data, is_login=is_login)
@routes.post("/client/new")
async def create_client(request: web.Request) -> web.Response:
try:
data = await request.json()
except JSONDecodeError:
return resp.body_not_json
return await _create_client(None, data)
@routes.put("/client/{id}")
async def update_client(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
try:
data = await request.json()
except JSONDecodeError:
return resp.body_not_json
return await _create_or_update_client(user_id, data)
@routes.delete("/client/{id}")
async def delete_client(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
if len(client.references) > 0:
return resp.client_in_use
if client.started:
await client.stop()
await client.delete()
return resp.deleted
@routes.post("/client/{id}/clearcache")
async def clear_client_cache(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
await client.clear_cache()
return resp.ok
@routes.post("/client/{id}/verify")
async def verify_client(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
try:
req = await request.json()
except Exception:
return resp.body_not_json
try:
await client.crypto.verify_with_recovery_key(req["recovery_key"])
client.trust_state = await client.crypto.resolve_trust(
client.crypto.own_identity,
allow_fetch=False,
)
log.debug(f"Trust state after verifying {client.id}: {client.trust_state}")
except Exception as e:
log.exception("Failed to verify client with recovery key")
return resp.internal_crypto_error(str(e))
return resp.ok
@routes.post("/client/{id}/generate_recovery_key")
async def generate_recovery_key(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
try:
keys = await client.crypto.get_own_cross_signing_public_keys()
if keys:
return resp.client_has_keys
key = await client.crypto.generate_recovery_key()
client.trust_state = await client.crypto.resolve_trust(
client.crypto.own_identity,
allow_fetch=False,
)
log.debug(f"Trust state generating recovery key for {client.id}: {client.trust_state}")
except Exception as e:
log.exception("Failed to generate recovery key for client")
return resp.internal_crypto_error(str(e))
return resp.created({"success": True, "recovery_key": key})

View File

@@ -0,0 +1,273 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import NamedTuple
from http import HTTPStatus
from json import JSONDecodeError
import asyncio
import hashlib
import hmac
import random
import string
from aiohttp import web
from yarl import URL
from mautrix.api import Method, Path, SynapseAdminPath
from mautrix.client import ClientAPI
from mautrix.errors import MatrixRequestError
from mautrix.types import LoginResponse, LoginType
from .base import get_config, routes
from .client import _create_client, _create_or_update_client
from .responses import resp
def known_homeservers() -> dict[str, dict[str, str]]:
return get_config()["homeservers"]
@routes.get("/client/auth/servers")
async def get_known_servers(_: web.Request) -> web.Response:
return web.json_response({key: value["url"] for key, value in known_homeservers().items()})
class AuthRequestInfo(NamedTuple):
server_name: str
client: ClientAPI
secret: str
username: str
password: str
user_type: str
device_name: str
update_client: bool
sso: bool
truthy_strings = ("1", "true", "yes")
async def read_client_auth_request(
request: web.Request,
) -> tuple[AuthRequestInfo | None, web.Response | None]:
server_name = request.match_info.get("server", None)
server = known_homeservers().get(server_name, None)
if not server:
return None, resp.server_not_found
try:
body = await request.json()
except JSONDecodeError:
return None, resp.body_not_json
sso = request.query.get("sso", "").lower() in truthy_strings
try:
username = body["username"]
password = body["password"]
except KeyError:
if not sso:
return None, resp.username_or_password_missing
username = password = None
try:
base_url = server["url"]
except KeyError:
return None, resp.invalid_server
return (
AuthRequestInfo(
server_name=server_name,
client=ClientAPI(base_url=base_url),
secret=server.get("secret"),
username=username,
password=password,
user_type=body.get("user_type", "bot"),
device_name=body.get("device_name", "Maubot"),
update_client=request.query.get("update_client", "").lower() in truthy_strings,
sso=sso,
),
None,
)
def generate_mac(
secret: str,
nonce: str,
username: str,
password: str,
admin: bool = False,
user_type: str = None,
) -> str:
mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1)
mac.update(nonce.encode("utf-8"))
mac.update(b"\x00")
mac.update(username.encode("utf-8"))
mac.update(b"\x00")
mac.update(password.encode("utf-8"))
mac.update(b"\x00")
mac.update(b"admin" if admin else b"notadmin")
if user_type is not None:
mac.update(b"\x00")
mac.update(user_type.encode("utf8"))
return mac.hexdigest()
@routes.post("/client/auth/{server}/register")
async def register(request: web.Request) -> web.Response:
req, err = await read_client_auth_request(request)
if err is not None:
return err
if req.sso:
return resp.registration_no_sso
elif not req.secret:
return resp.registration_secret_not_found
path = SynapseAdminPath.v1.register
res = await req.client.api.request(Method.GET, path)
content = {
"nonce": res["nonce"],
"username": req.username,
"password": req.password,
"admin": False,
"user_type": req.user_type,
}
content["mac"] = generate_mac(**content, secret=req.secret)
try:
raw_res = await req.client.api.request(Method.POST, path, content=content)
except MatrixRequestError as e:
return web.json_response(
{
"errcode": e.errcode,
"error": e.message,
"http_status": e.http_status,
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
login_res = LoginResponse.deserialize(raw_res)
if req.update_client:
return await _create_client(
login_res.user_id,
{
"homeserver": str(req.client.api.base_url),
"access_token": login_res.access_token,
"device_id": login_res.device_id,
},
)
return web.json_response(login_res.serialize())
@routes.post("/client/auth/{server}/login")
async def login(request: web.Request) -> web.Response:
req, err = await read_client_auth_request(request)
if err is not None:
return err
if req.sso:
return await _do_sso(req)
else:
return await _do_login(req)
async def _do_sso(req: AuthRequestInfo) -> web.Response:
flows = await req.client.get_login_flows()
if not flows.supports_type(LoginType.SSO):
return resp.sso_not_supported
waiter_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
cfg = get_config()
public_url = (
URL(cfg["server.public_url"])
/ "_matrix/maubot/v1/client/auth_external_sso/complete"
/ waiter_id
)
sso_url = req.client.api.base_url.with_path(str(Path.v3.login.sso.redirect)).with_query(
{"redirectUrl": str(public_url)}
)
sso_waiters[waiter_id] = req, asyncio.get_running_loop().create_future()
return web.json_response({"sso_url": str(sso_url), "id": waiter_id})
async def _do_login(req: AuthRequestInfo, login_token: str | None = None) -> web.Response:
device_id = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
device_id = f"maubot_{device_id}"
try:
if req.sso:
res = await req.client.login(
token=login_token,
login_type=LoginType.TOKEN,
device_id=device_id,
store_access_token=False,
initial_device_display_name=req.device_name,
)
else:
res = await req.client.login(
identifier=req.username,
login_type=LoginType.PASSWORD,
password=req.password,
device_id=device_id,
initial_device_display_name=req.device_name,
store_access_token=False,
)
except MatrixRequestError as e:
return web.json_response(
{
"errcode": e.errcode,
"error": e.message,
},
status=e.http_status,
)
if req.update_client:
return await _create_or_update_client(
res.user_id,
{
"homeserver": str(req.client.api.base_url),
"access_token": res.access_token,
"device_id": res.device_id,
},
is_login=True,
)
return web.json_response(res.serialize())
sso_waiters: dict[str, tuple[AuthRequestInfo, asyncio.Future]] = {}
@routes.post("/client/auth/{server}/sso/{id}/wait")
async def wait_sso(request: web.Request) -> web.Response:
waiter_id = request.match_info["id"]
req, fut = sso_waiters[waiter_id]
try:
login_token = await fut
finally:
sso_waiters.pop(waiter_id, None)
return await _do_login(req, login_token)
@routes.get("/client/auth_external_sso/complete/{id}")
async def complete_sso(request: web.Request) -> web.Response:
try:
_, fut = sso_waiters[request.match_info["id"]]
except KeyError:
return web.Response(status=404, text="Invalid session ID\n")
if fut.cancelled():
return web.Response(status=200, text="The login was cancelled from the Maubot client\n")
elif fut.done():
return web.Response(status=200, text="The login token was already received\n")
try:
fut.set_result(request.query["loginToken"])
except KeyError:
return web.Response(status=400, text="Missing loginToken query parameter\n")
except asyncio.InvalidStateError:
return web.Response(status=500, text="Invalid state\n")
return web.Response(
status=200,
text="Login token received, please return to your Maubot client. "
"This tab can be closed.\n",
)

View File

@@ -0,0 +1,56 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from aiohttp import client as http, web
from ...client import Client
from .base import routes
from .responses import resp
PROXY_CHUNK_SIZE = 32 * 1024
@routes.view("/proxy/{id}/{path:_matrix/.+}")
async def proxy(request: web.Request) -> web.StreamResponse:
user_id = request.match_info.get("id", None)
client = await Client.get(user_id)
if not client:
return resp.client_not_found
path = request.match_info.get("path", None)
query = request.query.copy()
try:
del query["access_token"]
except KeyError:
pass
headers = request.headers.copy()
del headers["Host"]
headers["Authorization"] = f"Bearer {client.access_token}"
if "X-Forwarded-For" not in headers:
peer = request.transport.get_extra_info("peername")
if peer is not None:
host, port = peer
headers["X-Forwarded-For"] = f"{host}:{port}"
data = await request.read()
async with http.request(
request.method, f"{client.homeserver}/{path}", headers=headers, params=query, data=data
) as proxy_resp:
response = web.StreamResponse(status=proxy_resp.status, headers=proxy_resp.headers)
await response.prepare(request)
async for chunk in proxy_resp.content.iter_chunked(PROXY_CHUNK_SIZE):
await response.write(chunk)
await response.write_eof()
return response

View File

@@ -0,0 +1,57 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from string import Template
import asyncio
import re
from aiohttp import web
from ruamel.yaml import YAML
from .base import routes
enabled = False
@routes.get("/debug/open")
async def check_enabled(_: web.Request) -> web.Response:
return web.json_response({"enabled": enabled})
try:
yaml = YAML()
with open(".dev-open-cfg.yaml", "r") as file:
cfg = yaml.load(file)
editor_command = Template(cfg["editor"])
pathmap = [(re.compile(item["find"]), item["replace"]) for item in cfg["pathmap"]]
@routes.post("/debug/open")
async def open_file(request: web.Request) -> web.Response:
data = await request.json()
try:
path = data["path"]
for find, replace in pathmap:
path = find.sub(replace, path)
cmd = editor_command.substitute(path=path, line=data["line"])
except (KeyError, ValueError):
return web.Response(status=400)
res = await asyncio.create_subprocess_shell(cmd)
stdout, stderr = await res.communicate()
return web.json_response({"return": res.returncode, "stdout": stdout, "stderr": stderr})
enabled = True
except Exception:
pass

View File

@@ -0,0 +1,97 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from json import JSONDecodeError
from aiohttp import web
from ...client import Client
from ...instance import PluginInstance
from ...loader import PluginLoader
from .base import routes
from .responses import resp
@routes.get("/instances")
async def get_instances(_: web.Request) -> web.Response:
return resp.found([instance.to_dict() for instance in PluginInstance.cache.values()])
@routes.get("/instance/{id}")
async def get_instance(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
return resp.found(instance.to_dict())
async def _create_instance(instance_id: str, data: dict) -> web.Response:
plugin_type = data.get("type")
primary_user = data.get("primary_user")
if not plugin_type:
return resp.plugin_type_required
elif not primary_user:
return resp.primary_user_required
elif not await Client.get(primary_user):
return resp.primary_user_not_found
try:
PluginLoader.find(plugin_type)
except KeyError:
return resp.plugin_type_not_found
instance = await PluginInstance.get(instance_id, type=plugin_type, primary_user=primary_user)
instance.enabled = data.get("enabled", True)
instance.config_str = data.get("config") or ""
await instance.update()
await instance.load()
await instance.start()
return resp.created(instance.to_dict())
async def _update_instance(instance: PluginInstance, data: dict) -> web.Response:
if not await instance.update_primary_user(data.get("primary_user")):
return resp.primary_user_not_found
await instance.update_id(data.get("id"))
await instance.update_enabled(data.get("enabled"))
await instance.update_config(data.get("config"))
await instance.update_started(data.get("started"))
await instance.update_type(data.get("type"))
return resp.updated(instance.to_dict())
@routes.put("/instance/{id}")
async def update_instance(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
try:
data = await request.json()
except JSONDecodeError:
return resp.body_not_json
if not instance:
return await _create_instance(instance_id, data)
else:
return await _update_instance(instance, data)
@routes.delete("/instance/{id}")
async def delete_instance(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
if instance.started:
await instance.stop()
await instance.delete()
return resp.deleted

View File

@@ -0,0 +1,161 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from datetime import datetime
from aiohttp import web
from asyncpg import PostgresError
import aiosqlite
from mautrix.util.async_db import Database
from ...instance import PluginInstance
from ...lib.optionalalchemy import Engine, IntegrityError, OperationalError, asc, desc
from .base import routes
from .responses import resp
@routes.get("/instance/{id}/database")
async def get_database(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
return web.json_response(await instance.get_db_tables())
@routes.get("/instance/{id}/database/{table}")
async def get_table(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
tables = await instance.get_db_tables()
try:
table = tables[request.match_info.get("table", "")]
except KeyError:
return resp.table_not_found
try:
order = [tuple(order.split(":")) for order in request.query.getall("order")]
order = [
(
(asc if sort.lower() == "asc" else desc)(table.columns[column])
if sort
else table.columns[column]
)
for column, sort in order
]
except KeyError:
order = []
limit = int(request.query.get("limit", "100"))
if isinstance(instance.inst_db, Engine):
return _execute_query_sqlalchemy(instance, table.select().order_by(*order).limit(limit))
@routes.post("/instance/{id}/database/query")
async def query(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
data = await request.json()
try:
sql_query = data["query"]
except KeyError:
return resp.query_missing
rows_as_dict = data.get("rows_as_dict", False)
if isinstance(instance.inst_db, Engine):
return _execute_query_sqlalchemy(instance, sql_query, rows_as_dict)
elif isinstance(instance.inst_db, Database):
try:
return await _execute_query_asyncpg(instance, sql_query, rows_as_dict)
except (PostgresError, aiosqlite.Error) as e:
return resp.sql_error(e, sql_query)
else:
return resp.unsupported_plugin_database
def check_type(val):
if isinstance(val, datetime):
return val.isoformat()
return val
async def _execute_query_asyncpg(
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
) -> web.Response:
data = {"ok": True, "query": sql_query}
if sql_query.upper().startswith("SELECT"):
res = await instance.inst_db.fetch(sql_query)
data["rows"] = [
(
{key: check_type(value) for key, value in row.items()}
if rows_as_dict
else [check_type(value) for value in row]
)
for row in res
]
if len(res) > 0:
# TODO can we find column names when there are no rows?
data["columns"] = list(res[0].keys())
else:
res = await instance.inst_db.execute(sql_query)
if isinstance(res, str):
data["status_msg"] = res
elif isinstance(res, aiosqlite.Cursor):
data["rowcount"] = res.rowcount
# data["inserted_primary_key"] = res.lastrowid
else:
data["status_msg"] = "unknown status"
return web.json_response(data)
def _execute_query_sqlalchemy(
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
) -> web.Response:
assert isinstance(instance.inst_db, Engine)
try:
res = instance.inst_db.execute(sql_query)
except IntegrityError as e:
return resp.sql_integrity_error(e, sql_query)
except OperationalError as e:
return resp.sql_operational_error(e, sql_query)
data = {
"ok": True,
"query": str(sql_query),
}
if res.returns_rows:
data["rows"] = [
(
{key: check_type(value) for key, value in row.items()}
if rows_as_dict
else [check_type(value) for value in row]
)
for row in res
]
data["columns"] = res.keys()
else:
data["rowcount"] = res.rowcount
if res.is_insert:
data["inserted_primary_key"] = res.inserted_primary_key
return web.json_response(data)

View File

@@ -0,0 +1,172 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from collections import deque
from datetime import datetime
import asyncio
import logging
from aiohttp import web, web_ws
from mautrix.util import background_task
from .auth import is_valid_token
from .base import routes
BUILTIN_ATTRS = {
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
}
INCLUDE_ATTRS = {
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"name",
"pathname",
}
EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS
MAX_LINES = 2048
class LogCollector(logging.Handler):
lines: deque[dict]
formatter: logging.Formatter
listeners: list[web.WebSocketResponse]
loop: asyncio.AbstractEventLoop
def __init__(self, level=logging.NOTSET) -> None:
super().__init__(level)
self.lines = deque(maxlen=MAX_LINES)
self.formatter = logging.Formatter()
self.listeners = []
def emit(self, record: logging.LogRecord) -> None:
try:
self._emit(record)
except Exception as e:
print("Logging error:", e)
def _emit(self, record: logging.LogRecord) -> None:
# JSON conversion based on Marsel Mavletkulov's json-log-formatter (MIT license)
# https://github.com/marselester/json-log-formatter
content = {
name: value for name, value in record.__dict__.items() if name not in EXCLUDE_ATTRS
}
content["id"] = str(record.relativeCreated)
content["msg"] = record.getMessage()
content["time"] = datetime.fromtimestamp(record.created)
if record.exc_info:
content["exc_info"] = self.formatter.formatException(record.exc_info)
for name, value in content.items():
if isinstance(value, datetime):
content[name] = value.astimezone().isoformat()
asyncio.run_coroutine_threadsafe(self.send(content), loop=self.loop)
self.lines.append(content)
async def send(self, record: dict) -> None:
for ws in self.listeners:
try:
await ws.send_json(record)
except Exception as e:
print("Log sending error:", e)
handler = LogCollector()
log = logging.getLogger("maubot.server.websocket")
sockets = []
def init(loop: asyncio.AbstractEventLoop) -> None:
logging.root.addHandler(handler)
handler.loop = loop
async def stop_all() -> None:
log.debug("Closing log listener websockets")
logging.root.removeHandler(handler)
for socket in sockets:
try:
await socket.close(code=1012)
except Exception:
pass
@routes.get("/logs")
async def log_websocket(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
sockets.append(ws)
log.debug(f"Connection from {request.remote} opened")
authenticated = False
async def close_if_not_authenticated():
await asyncio.sleep(5)
if not authenticated:
await ws.close(code=4000)
log.debug(f"Connection from {request.remote} terminated due to no authentication")
background_task.create(close_if_not_authenticated())
try:
msg: web_ws.WSMessage
async for msg in ws:
if msg.type != web.WSMsgType.TEXT:
continue
if is_valid_token(msg.data):
await ws.send_json({"auth_success": True})
await ws.send_json({"history": list(handler.lines)})
if not authenticated:
log.debug(f"Connection from {request.remote} authenticated")
handler.listeners.append(ws)
authenticated = True
elif not authenticated:
await ws.send_json({"auth_success": False})
except Exception:
try:
await ws.close()
except Exception:
pass
if authenticated:
handler.listeners.remove(ws)
log.debug(f"Connection from {request.remote} closed")
sockets.remove(ws)
return ws

View File

@@ -0,0 +1,41 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import json
from aiohttp import web
from .auth import create_token
from .base import get_config, routes
from .responses import resp
@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

View File

@@ -0,0 +1,78 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from typing import Awaitable, Callable
import base64
import logging
from aiohttp import web
from .auth import check_token
from .base import get_config
from .responses import resp
Handler = Callable[[web.Request], Awaitable[web.Response]]
log = logging.getLogger("maubot.server")
@web.middleware
async def auth(request: web.Request, handler: Handler) -> web.Response:
subpath = request.path[len("/_matrix/maubot/v1") :]
if (
subpath.startswith("/auth/")
or subpath.startswith("/client/auth_external_sso/complete/")
or subpath == "/features"
or subpath == "/logs"
):
return await handler(request)
err = check_token(request)
if err is not None:
return err
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:
if ex.status_code == 404:
return resp.path_not_found
elif ex.status_code == 405:
return resp.method_not_allowed
return web.json_response(
{
"httpexception": {
"headers": {key: value for key, value in ex.headers.items()},
"class": type(ex).__name__,
"body": ex.text or base64.b64encode(ex.body),
},
"error": f"Unhandled HTTP {ex.status}: {ex.text[:128] or 'non-text response'}",
"errcode": f"unhandled_http_{ex.status}",
},
status=ex.status,
)
except Exception:
log.exception("Error in handler")
return resp.internal_server_error
req_no = 0
def get_req_no():
global req_no
req_no += 1
return req_no

View File

@@ -0,0 +1,64 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import traceback
from aiohttp import web
from ...loader import MaubotZipImportError, PluginLoader
from .base import routes
from .responses import resp
@routes.get("/plugins")
async def get_plugins(_) -> web.Response:
return resp.found([plugin.to_dict() 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["id"]
plugin = PluginLoader.id_cache.get(plugin_id)
if not plugin:
return resp.plugin_not_found
return resp.found(plugin.to_dict())
@routes.delete("/plugin/{id}")
async def delete_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
plugin = PluginLoader.id_cache.get(plugin_id)
if not plugin:
return resp.plugin_not_found
elif len(plugin.references) > 0:
return resp.plugin_in_use
await plugin.delete()
return resp.deleted
@routes.post("/plugin/{id}/reload")
async def reload_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
plugin = PluginLoader.id_cache.get(plugin_id)
if not plugin:
return resp.plugin_not_found
await plugin.stop_instances()
try:
await plugin.reload()
except MaubotZipImportError as e:
return resp.plugin_reload_error(str(e), traceback.format_exc())
await plugin.start_instances()
return resp.ok

View File

@@ -0,0 +1,145 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from io import BytesIO
from time import time
import logging
import os.path
import re
import traceback
from aiohttp import web
from packaging.version import Version
from ...loader import DatabaseType, MaubotZipImportError, PluginLoader, ZippedPluginLoader
from .base import get_config, routes
from .responses import resp
try:
import sqlalchemy
has_alchemy = True
except ImportError:
has_alchemy = False
log = logging.getLogger("maubot.server.upload")
@routes.put("/plugin/{id}")
async def put_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
content = await request.read()
file = BytesIO(content)
try:
pid, version, db_type = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
return resp.sqlalchemy_not_installed
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, db_type = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
return resp.sqlalchemy_not_installed
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 = (
str(new_version)
if plugin.meta.version != new_version
else f"{new_version}-ts{int(time() * 1000)}"
)
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:
log.exception(f"Error loading updated version of {plugin.meta.id}, rolling back")
try:
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except MaubotZipImportError:
log.warning(f"Failed to roll back update of {plugin.meta.id}", exc_info=True)
finally:
ZippedPluginLoader.trash(path, reason="failed_update")
return resp.plugin_import_error(str(e), traceback.format_exc())
try:
await plugin.start_instances()
except Exception as e:
log.exception(f"Error starting {plugin.meta.id} instances after update, rolling back")
try:
await plugin.stop_instances()
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except Exception:
log.warning(f"Failed to roll back update of {plugin.meta.id}", exc_info=True)
finally:
ZippedPluginLoader.trash(path, reason="failed_update")
return resp.plugin_reload_error(str(e), traceback.format_exc())
log.debug(f"Successfully updated {plugin.meta.id}, moving old version to trash")
ZippedPluginLoader.trash(old_path, reason="update")
return resp.updated(plugin.to_dict())

View File

@@ -0,0 +1,510 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING
from http import HTTPStatus
from aiohttp import web
from asyncpg import PostgresError
import aiosqlite
if TYPE_CHECKING:
from sqlalchemy.exc import IntegrityError, OperationalError
class _Response:
@property
def body_not_json(self) -> web.Response:
return web.json_response(
{
"error": "Request body is not JSON",
"errcode": "body_not_json",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def plugin_type_required(self) -> web.Response:
return web.json_response(
{
"error": "Plugin type is required when creating plugin instances",
"errcode": "plugin_type_required",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def primary_user_required(self) -> web.Response:
return web.json_response(
{
"error": "Primary user is required when creating plugin instances",
"errcode": "primary_user_required",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_client_access_token(self) -> web.Response:
return web.json_response(
{
"error": "Invalid access token",
"errcode": "bad_client_access_token",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_client_access_details(self) -> web.Response:
return web.json_response(
{
"error": "Invalid homeserver or access token",
"errcode": "bad_client_access_details",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_client_connection_details(self) -> web.Response:
return web.json_response(
{
"error": "Could not connect to homeserver",
"errcode": "bad_client_connection_details",
},
status=HTTPStatus.BAD_REQUEST,
)
def mxid_mismatch(self, found: str) -> web.Response:
return web.json_response(
{
"error": (
"The Matrix user ID of the client and the user ID of the access token don't "
f"match. Access token is for user {found}"
),
"errcode": "mxid_mismatch",
},
status=HTTPStatus.BAD_REQUEST,
)
def device_id_mismatch(self, found: str) -> web.Response:
return web.json_response(
{
"error": (
"The Matrix device ID of the client and the device ID of the access token "
f"don't match. Access token is for device {found}"
),
"errcode": "mxid_mismatch",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def pid_mismatch(self) -> web.Response:
return web.json_response(
{
"error": "The ID in the path does not match the ID of the uploaded plugin",
"errcode": "pid_mismatch",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def username_or_password_missing(self) -> web.Response:
return web.json_response(
{
"error": "Username or password missing",
"errcode": "username_or_password_missing",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def query_missing(self) -> web.Response:
return web.json_response(
{
"error": "Query missing",
"errcode": "query_missing",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def sql_error(error: PostgresError | aiosqlite.Error, query: str) -> web.Response:
return web.json_response(
{
"ok": False,
"query": query,
"error": str(error),
"errcode": "sql_error",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def sql_operational_error(error: OperationalError, query: str) -> web.Response:
return web.json_response(
{
"ok": False,
"query": query,
"error": str(error.orig),
"full_error": str(error),
"errcode": "sql_operational_error",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def sql_integrity_error(error: IntegrityError, query: str) -> web.Response:
return web.json_response(
{
"ok": False,
"query": query,
"error": str(error.orig),
"full_error": str(error),
"errcode": "sql_integrity_error",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_auth(self) -> web.Response:
return web.json_response(
{
"error": "Invalid username or password",
"errcode": "invalid_auth",
},
status=HTTPStatus.UNAUTHORIZED,
)
@property
def no_token(self) -> web.Response:
return web.json_response(
{
"error": "Authorization token missing",
"errcode": "auth_token_missing",
},
status=HTTPStatus.UNAUTHORIZED,
)
@property
def invalid_token(self) -> web.Response:
return web.json_response(
{
"error": "Invalid authorization token",
"errcode": "auth_token_invalid",
},
status=HTTPStatus.UNAUTHORIZED,
)
@property
def plugin_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Plugin not found",
"errcode": "plugin_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def client_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Client not found",
"errcode": "client_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def primary_user_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Client for given primary user not found",
"errcode": "primary_user_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def instance_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Plugin instance not found",
"errcode": "instance_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def plugin_type_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Given plugin type not found",
"errcode": "plugin_type_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def path_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Resource not found",
"errcode": "resource_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def server_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Registration target server not found",
"errcode": "server_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def registration_secret_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Config does not have a registration secret for that server",
"errcode": "registration_secret_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def registration_no_sso(self) -> web.Response:
return web.json_response(
{
"error": "The register operation is only for registering with a password",
"errcode": "registration_no_sso",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def sso_not_supported(self) -> web.Response:
return web.json_response(
{
"error": "That server does not seem to support single sign-on",
"errcode": "sso_not_supported",
},
status=HTTPStatus.FORBIDDEN,
)
@property
def plugin_has_no_database(self) -> web.Response:
return web.json_response(
{
"error": "Given plugin does not have a database",
"errcode": "plugin_has_no_database",
}
)
@property
def unsupported_plugin_database(self) -> web.Response:
return web.json_response(
{
"error": "The database type is not supported by this API",
"errcode": "unsupported_plugin_database",
}
)
@property
def sqlalchemy_not_installed(self) -> web.Response:
return web.json_response(
{
"error": "This plugin requires a legacy database, but SQLAlchemy is not installed",
"errcode": "unsupported_plugin_database",
},
status=HTTPStatus.NOT_IMPLEMENTED,
)
@property
def table_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Given table not found in plugin database",
"errcode": "table_not_found",
}
)
@property
def method_not_allowed(self) -> web.Response:
return web.json_response(
{
"error": "Method not allowed",
"errcode": "method_not_allowed",
},
status=HTTPStatus.METHOD_NOT_ALLOWED,
)
@property
def user_exists(self) -> web.Response:
return web.json_response(
{
"error": "There is already a client with the user ID of that token",
"errcode": "user_exists",
},
status=HTTPStatus.CONFLICT,
)
@property
def plugin_exists(self) -> web.Response:
return web.json_response(
{
"error": "A plugin with the same ID as the uploaded plugin already exists",
"errcode": "plugin_exists",
},
status=HTTPStatus.CONFLICT,
)
@property
def plugin_in_use(self) -> web.Response:
return web.json_response(
{
"error": "Plugin instances of this type still exist",
"errcode": "plugin_in_use",
},
status=HTTPStatus.PRECONDITION_FAILED,
)
@property
def client_in_use(self) -> web.Response:
return web.json_response(
{
"error": "Plugin instances with this client as their primary user still exist",
"errcode": "client_in_use",
},
status=HTTPStatus.PRECONDITION_FAILED,
)
@staticmethod
def plugin_import_error(error: str, stacktrace: str) -> web.Response:
return web.json_response(
{
"error": error,
"stacktrace": stacktrace,
"errcode": "plugin_invalid",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def plugin_reload_error(error: str, stacktrace: str) -> web.Response:
return web.json_response(
{
"error": error,
"stacktrace": stacktrace,
"errcode": "plugin_reload_fail",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def internal_server_error(self) -> web.Response:
return web.json_response(
{
"error": "Internal server error",
"errcode": "internal_server_error",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def invalid_server(self) -> web.Response:
return web.json_response(
{
"error": "Invalid registration server object in maubot configuration",
"errcode": "invalid_server",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def unsupported_plugin_loader(self) -> web.Response:
return web.json_response(
{
"error": "Existing plugin with same ID uses unsupported plugin loader",
"errcode": "unsupported_plugin_loader",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def client_has_keys(self) -> web.Response:
return web.json_response(
{
"error": "Client already has cross-signing keys",
"errcode": "client_has_keys",
},
status=HTTPStatus.CONFLICT,
)
def internal_crypto_error(self, message: str) -> web.Response:
return web.json_response(
{
"error": f"Internal crypto error: {message}",
"errcode": "internal_crypto_error",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def not_implemented(self) -> web.Response:
return web.json_response(
{
"error": "Not implemented",
"errcode": "not_implemented",
},
status=HTTPStatus.NOT_IMPLEMENTED,
)
@property
def ok(self) -> web.Response:
return web.json_response(
{"success": True},
status=HTTPStatus.OK,
)
@property
def deleted(self) -> web.Response:
return web.Response(status=HTTPStatus.NO_CONTENT)
@staticmethod
def found(data: dict) -> web.Response:
return web.json_response(data, status=HTTPStatus.OK)
@staticmethod
def updated(data: dict, is_login: bool = False) -> web.Response:
return web.json_response(data, status=HTTPStatus.ACCEPTED if is_login else HTTPStatus.OK)
def logged_in(self, token: str) -> web.Response:
return self.found({"token": token})
def pong(self, user: str, features: dict) -> web.Response:
return self.found({"username": user, "features": features})
@staticmethod
def created(data: dict) -> web.Response:
return web.json_response(data, status=HTTPStatus.CREATED)
resp = _Response()

View File

@@ -0,0 +1,2 @@
# Maubot Management API
This document has been moved to docs.mau.fi: <https://docs.mau.fi/maubot/management-api.html>

View File

@@ -0,0 +1,728 @@
openapi: 3.0.0
info:
title: Maubot Management
version: 0.1.0
description: The API to manage a [maubot](https://github.com/maubot/maubot) instance
license:
name: GNU Affero General Public License version 3
url: 'https://github.com/maubot/maubot/blob/master/LICENSE'
security:
- bearer: []
servers:
- url: /_matrix/maubot/v1
paths:
/auth/login:
post:
operationId: login
summary: Log in with the unshared secret or username+password
tags: [Authentication]
requestBody:
content:
application/json:
schema:
type: object
description: Set either username+password or secret.
properties:
secret:
type: string
description: The unshared server secret for root login
username:
type: string
description: The username for normal login
password:
type: string
description: The password for normal login
responses:
200:
description: Logged in successfully
content:
application/json:
schema:
type: object
properties:
token:
type: string
401:
description: Invalid credentials
/auth/ping:
post:
operationId: ping
summary: Check if the given token is valid
tags: [Authentication]
responses:
200:
description: Token is OK
content:
application/json:
schema:
type: object
properties:
username:
type: string
401:
description: Token is not OK
/plugins:
get:
operationId: get_plugins
summary: Get the list of installed plugins
tags: [Plugins]
responses:
200:
description: The list of plugins
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Plugin'
401:
$ref: '#/components/responses/Unauthorized'
/plugins/upload:
post:
operationId: upload_plugin
summary: Upload a new plugin
description: Upload a new plugin. If the plugin already exists, enabled instances will be restarted.
tags: [Plugins]
parameters:
- name: allow_override
in: query
description: Set to allow overriding existing plugins
required: false
schema:
type: boolean
default: false
requestBody:
content:
application/zip:
schema:
type: string
format: binary
example: The plugin maubot archive (.mbp)
responses:
200:
description: Plugin uploaded and replaced current version successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
201:
description: New plugin uploaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
409:
description: Plugin already exists and allow_override was not specified.
'/plugin/{id}':
parameters:
- name: id
in: path
description: The ID of the plugin to get
required: true
schema:
type: string
get:
operationId: get_plugin
summary: Get information about a specific plugin
tags: [Plugins]
responses:
200:
description: Plugin found
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
delete:
operationId: delete_plugin
summary: Delete a plugin
description: Delete a plugin. All instances of the plugin must be deleted before deleting the plugin.
tags: [Plugins]
responses:
204:
description: Plugin deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
412:
description: One or more plugin instances of this type exist
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
operationId: put_plugin
summary: Upload a new or replacement plugin
description: |
Upload a new or replacement plugin with the specified ID.
A HTTP 400 will be returned if the ID of the uploaded plugin
doesn't match the ID in the path. If the plugin already
exists, enabled instances will be restarted.
tags: [Plugins]
requestBody:
content:
application/zip:
schema:
type: string
format: binary
example: The plugin maubot archive (.mbp)
responses:
200:
description: Plugin uploaded and replaced current version successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
201:
description: New plugin uploaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
/plugin/{id}/reload:
parameters:
- name: id
in: path
description: The ID of the plugin to get
required: true
schema:
type: string
post:
operationId: reload_plugin
summary: Reload a plugin from disk
tags: [Plugins]
responses:
200:
description: Plugin reloaded
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
/instances:
get:
operationId: get_instances
summary: Get all plugin instances
tags: [Plugin instances]
responses:
200:
description: The list of instances
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/PluginInstance'
401:
$ref: '#/components/responses/Unauthorized'
'/instance/{id}':
parameters:
- name: id
in: path
description: The ID of the instance to get
required: true
schema:
type: string
get:
operationId: get_instance
summary: Get information about a specific plugin instance
tags: [Plugin instances]
responses:
200:
description: Plugin instance found
content:
application/json:
schema:
$ref: '#/components/schemas/PluginInstance'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/InstanceNotFound'
delete:
operationId: delete_instance
summary: Delete a specific plugin instance
tags: [Plugin instances]
responses:
204:
description: Plugin instance deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/InstanceNotFound'
put:
operationId: update_instance
summary: Create a plugin instance or edit the details of an existing plugin instance
tags: [Plugin instances]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PluginInstance'
responses:
200:
description: Plugin instance edited
201:
description: Plugin instance created
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
404:
description: The referenced client or plugin type could not be found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/clients':
get:
operationId: get_clients
summary: Get the list of Matrix clients
tags: [Clients]
responses:
200:
description: The list of plugins
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
/client/new:
post:
operationId: create_client
summary: Create a Matrix client
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
responses:
201:
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
409:
description: There is already a client with the user ID of that token.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/client/{id}':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to get
required: true
schema:
type: string
get:
operationId: get_client
summary: Get information about a specific Matrix client
tags: [Clients]
responses:
200:
description: Client found
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
put:
operationId: update_client
summary: Create or update a Matrix client
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
responses:
202:
description: Client updated
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
201:
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
delete:
operationId: delete_client
summary: Delete a Matrix client
tags: [Clients]
responses:
204:
description: Client deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
412:
description: One or more plugin instances with this as their primary client exist
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/client/{id}/clearcache':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to change
required: true
schema:
type: string
put:
operationId: clear_client_cache
summary: Clear the sync/state cache of a Matrix client
tags: [Clients]
responses:
200:
description: Cache cleared
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
/client/auth/servers:
get:
operationId: get_client_auth_servers
summary: Get the list of servers you can register or log in on via the maubot server
tags: [Clients]
responses:
200:
description: OK
content:
application/json:
schema:
type: object
description: Key-value map from server name to homeserver URL
additionalProperties:
type: string
description: The homeserver URL
example:
maunium.net: https://maunium.net
example.com: https://matrix.example.org
401:
$ref: '#/components/responses/Unauthorized'
'/client/auth/{server}/register':
parameters:
- name: server
in: path
description: The server name to register the account on.
required: true
schema:
type: string
- name: update_client
in: query
description: Should maubot store the access details in a Client instead of returning them?
required: false
schema:
type: boolean
post:
operationId: client_auth_register
summary: |
Register a new account on the given Matrix server using the shared registration
secret configured into the maubot server.
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixAuthentication'
responses:
200:
description: Registration successful
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
example: syt_123_456_789
user_id:
type: string
example: '@putkiteippi:maunium.net'
device_id:
type: string
example: maubot_F00BAR12
201:
description: Client created (when update_client is true)
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
409:
description: |
There is already a client with the user ID of that token.
This should usually not happen, because the user ID was just created.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
500:
$ref: '#/components/responses/MatrixServerError'
'/client/auth/{server}/login':
parameters:
- name: server
in: path
description: The server name to log in to.
required: true
schema:
type: string
- name: update_client
in: query
description: Should maubot store the access details in a Client instead of returning them?
required: false
schema:
type: boolean
post:
operationId: client_auth_login
summary: Log in to the given Matrix server via the maubot server
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixAuthentication'
responses:
200:
description: Login successful
content:
application/json:
schema:
type: object
properties:
user_id:
type: string
example: '@putkiteippi:maunium.net'
access_token:
type: string
example: syt_123_456_789
device_id:
type: string
example: maubot_F00BAR12
201:
description: Client created (when update_client is true)
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
202:
description: Client updated (when update_client is true)
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
500:
$ref: '#/components/responses/MatrixServerError'
components:
responses:
Unauthorized:
description: Invalid or missing access token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
PluginNotFound:
description: Plugin not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ClientNotFound:
description: Client not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
InstanceNotFound:
description: Plugin instance not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
BadRequest:
description: Bad request (e.g. bad request body)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
MatrixServerError:
description: The Matrix server returned an error
content:
application/json:
schema:
type: object
properties:
errcode:
type: string
description: The `errcode` returned by the server.
error:
type: string
description: The human-readable error returned by the server.
http_status:
type: integer
description: The HTTP status returned by the server.
securitySchemes:
bearer:
type: http
scheme: bearer
description: Required authentication for all endpoints
schemas:
Error:
type: object
properties:
error:
type: string
description: A human-readable error message
errcode:
type: string
description: A simple error code
Plugin:
type: object
properties:
id:
type: string
example: xyz.maubot.jesaribot
version:
type: string
example: 2.0.0
instances:
type: array
items:
$ref: '#/components/schemas/PluginInstance'
PluginInstance:
type: object
properties:
id:
type: string
example: jesaribot
type:
type: string
example: xyz.maubot.jesaribot
enabled:
type: boolean
example: true
started:
type: boolean
example: true
primary_user:
type: string
example: '@putkiteippi:maunium.net'
config:
type: string
example: "YAML"
MatrixClient:
type: object
properties:
id:
type: string
example: '@putkiteippi:maunium.net'
readOnly: true
description: The Matrix user ID of this client.
homeserver:
type: string
example: 'https://maunium.net'
description: The homeserver URL for this client.
access_token:
type: string
description: The Matrix access token for this client.
device_id:
type: string
description: The Matrix device ID corresponding to the access token.
fingerprint:
type: string
description: The encryption device fingerprint for verification.
enabled:
type: boolean
example: true
description: Whether or not this client is enabled.
started:
type: boolean
example: true
description: Whether or not this client and its instances have been started.
sync:
type: boolean
example: true
description: Whether or not syncing is enabled on this client.
sync_ok:
type: boolean
example: true
description: Whether or not the previous sync was successful on this client.
autojoin:
type: boolean
example: true
description: Whether or not this client should automatically join rooms when invited.
displayname:
type: string
example: J. E. Saarinen
description: The display name for this client.
avatar_url:
type: string
example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU'
description: The content URI of the avatar for this client.
instances:
type: array
readOnly: true
items:
$ref: '#/components/schemas/PluginInstance'
MatrixAuthentication:
type: object
properties:
username:
type: string
example: putkiteippi
description: The user ID localpart to register/log in as.
password:
type: string
example: p455w0rd
description: The password for/of the user.