diff --git a/docker/example-config.yaml b/docker/example-config.yaml
index a273629..6ac3492 100644
--- a/docker/example-config.yaml
+++ b/docker/example-config.yaml
@@ -35,6 +35,14 @@ server:
# Set to "generate" to generate and save a new token at startup.
unshared_secret: generate
+# Shared registration secrets to allow registering new users from the management UI
+registration_secrets:
+ example.com:
+ # Client-server API URL
+ url: https://example.com
+ # registration_shared_secret from synapse config
+ secret: synapse_shared_registration_secret
+
# List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
# to prevent normal login. Root is a special user that can't have a password and will always exist.
admins:
diff --git a/example-config.yaml b/example-config.yaml
index 1b02d67..384d877 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -35,6 +35,14 @@ server:
# Set to "generate" to generate and save a new token at startup.
unshared_secret: generate
+# Shared registration secrets to allow registering new users from the management UI
+registration_secrets:
+ example.com:
+ # Client-server API URL
+ url: https://example.com
+ # registration_shared_secret from synapse config
+ secret: synapse_shared_registration_secret
+
# List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
# to prevent normal login. Root is a special user that can't have a password and will always exist.
admins:
diff --git a/maubot/config.py b/maubot/config.py
index ea8dd3c..ab3e080 100644
--- a/maubot/config.py
+++ b/maubot/config.py
@@ -47,6 +47,7 @@ class Config(BaseFileConfig):
base["server.unshared_secret"] = self._new_token()
else:
base["server.unshared_secret"] = shared_secret
+ copy("registration_secrets")
copy("admins")
for username, password in base["admins"].items():
if password and not bcrypt_regex.match(password):
diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py
index 760299e..93e994d 100644
--- a/maubot/management/api/__init__.py
+++ b/maubot/management/api/__init__.py
@@ -23,6 +23,8 @@ from .auth import web as _
from .plugin import web as _
from .instance import web as _
from .client import web as _
+from .client_proxy import web as _
+from .client_auth import web as _
from .dev_open import web as _
from .log import stop_all as stop_log_sockets, init as init_log_listener
diff --git a/maubot/management/api/client.py b/maubot/management/api/client.py
index 2975581..8fbf894 100644
--- a/maubot/management/api/client.py
+++ b/maubot/management/api/client.py
@@ -15,7 +15,6 @@
# along with this program. If not, see .
from typing import Optional
from json import JSONDecodeError
-from http import HTTPStatus
from aiohttp import web
@@ -131,27 +130,3 @@ async def delete_client(request: web.Request) -> web.Response:
await client.stop()
client.delete()
return resp.deleted
-
-
-@routes.post("/client/{id}/avatar")
-async def upload_avatar(request: web.Request) -> web.Response:
- user_id = request.match_info.get("id", None)
- client = Client.get(user_id, None)
- if not client:
- return resp.client_not_found
- content = await request.read()
- return web.json_response({
- "content_uri": await client.client.upload_media(
- content, request.headers.get("Content-Type", None)),
- })
-
-
-@routes.get("/client/{id}/avatar")
-async def download_avatar(request: web.Request) -> web.Response:
- user_id = request.match_info.get("id", None)
- client = Client.get(user_id, None)
- if not client:
- return resp.client_not_found
- if not client.avatar_url or client.avatar_url == "disable":
- return web.Response()
- return web.Response(body=await client.client.download_media(client.avatar_url))
diff --git a/maubot/management/api/client_auth.py b/maubot/management/api/client_auth.py
new file mode 100644
index 0000000..ec5d4d3
--- /dev/null
+++ b/maubot/management/api/client_auth.py
@@ -0,0 +1,121 @@
+# 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 Dict, Tuple, NamedTuple, Optional
+from json import JSONDecodeError
+import hmac
+import hashlib
+
+from aiohttp import web
+from mautrix.api import HTTPAPI, Path, Method
+from mautrix.errors import MatrixRequestError
+
+from .base import routes, get_config, get_loop
+from .responses import resp
+
+
+def registration_secrets() -> Dict[str, Dict[str, str]]:
+ return get_config()["registration_secrets"]
+
+
+def generate_mac(secret: str, nonce: str, user: str, password: str, admin: bool = False):
+ mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1)
+ mac.update(nonce.encode("utf-8"))
+ mac.update(b"\x00")
+ mac.update(user.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")
+ return mac.hexdigest()
+
+
+@routes.get("/client/auth/servers")
+async def get_registerable_servers(_: web.Request) -> web.Response:
+ return web.json_response(list(registration_secrets().keys()))
+
+
+AuthRequestInfo = NamedTuple("AuthRequestInfo", api=HTTPAPI, secret=str, username=str, password=str)
+
+
+async def read_client_auth_request(request: web.Request) -> Tuple[Optional[AuthRequestInfo],
+ Optional[web.Response]]:
+ server_name = request.match_info.get("server", None)
+ server = registration_secrets().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
+ try:
+ username = body["username"]
+ password = body["password"]
+ except KeyError:
+ return None, resp.username_or_password_missing
+ try:
+ base_url = server["url"]
+ secret = server["secret"]
+ except KeyError:
+ return None, resp.invalid_server
+ api = HTTPAPI(base_url, "", loop=get_loop())
+ return (api, secret, username, password), None
+
+
+@routes.post("/client/auth/{server}/register")
+async def register(request: web.Request) -> web.Response:
+ info, err = await read_client_auth_request(request)
+ if err is not None:
+ return err
+ api, secret, username, password = info
+ res = await api.request(Method.GET, Path.admin.register)
+ nonce = res["nonce"]
+ mac = generate_mac(secret, nonce, username, password)
+ try:
+ return web.json_response(await api.request(Method.POST, Path.admin.register, content={
+ "nonce": nonce,
+ "username": username,
+ "password": password,
+ "admin": False,
+ "mac": mac,
+ }))
+ except MatrixRequestError as e:
+ return web.json_response({
+ "errcode": e.errcode,
+ "error": e.message,
+ }, status=e.http_status)
+
+
+@routes.post("/client/auth/{server}/login")
+async def login(request: web.Request) -> web.Response:
+ info, err = await read_client_auth_request(request)
+ if err is not None:
+ return err
+ api, _, username, password = info
+ try:
+ return web.json_response(await api.request(Method.POST, Path.login, content={
+ "type": "m.login.password",
+ "identifier": {
+ "type": "m.id.user",
+ "user": username,
+ },
+ "password": password,
+ "device_id": "maubot",
+ }))
+ except MatrixRequestError as e:
+ return web.json_response({
+ "errcode": e.errcode,
+ "error": e.message,
+ }, status=e.http_status)
diff --git a/maubot/management/api/client_proxy.py b/maubot/management/api/client_proxy.py
new file mode 100644
index 0000000..b6f5787
--- /dev/null
+++ b/maubot/management/api/client_proxy.py
@@ -0,0 +1,58 @@
+# 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, client as http
+
+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 = Client.get(user_id, None)
+ 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()
+ 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()
+ chunked = PROXY_CHUNK_SIZE if not data else None
+ async with http.request(request.method, f"{client.homeserver}/{path}", headers=headers,
+ params=query, chunked=chunked, data=data) as proxy_resp:
+ response = web.StreamResponse(status=proxy_resp.status, headers=proxy_resp.headers)
+ await response.prepare(request)
+ content = proxy_resp.content
+ chunk = await content.read(PROXY_CHUNK_SIZE)
+ while chunk:
+ await response.write(chunk)
+ chunk = await content.read(PROXY_CHUNK_SIZE)
+ await response.write_eof()
+ return response
diff --git a/maubot/management/api/middleware.py b/maubot/management/api/middleware.py
index f58dcd8..538dba5 100644
--- a/maubot/management/api/middleware.py
+++ b/maubot/management/api/middleware.py
@@ -20,15 +20,20 @@ from aiohttp import web
from .responses import resp
from .auth import check_token
+from .base import get_config
Handler = Callable[[web.Request], Awaitable[web.Response]]
@web.middleware
async def auth(request: web.Request, handler: Handler) -> web.Response:
- if "/auth/" in request.path:
+ subpath = request.path.lstrip(get_config()["server.base_path"])
+ if subpath.startswith("/auth/") or subpath == "/logs" or subpath == "logs":
return await handler(request)
- return check_token(request) or await handler(request)
+ err = check_token(request)
+ if err is not None:
+ return err
+ return await handler(request)
log = logging.getLogger("maubot.server")
diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py
index 5204927..34fd110 100644
--- a/maubot/management/api/responses.py
+++ b/maubot/management/api/responses.py
@@ -75,6 +75,13 @@ class _Response:
"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 bad_auth(self) -> web.Response:
return web.json_response({
@@ -138,6 +145,13 @@ class _Response:
"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 method_not_allowed(self) -> web.Response:
return web.json_response({
@@ -196,6 +210,13 @@ class _Response:
"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({
diff --git a/maubot/management/frontend/.eslintrc.json b/maubot/management/frontend/.eslintrc.json
index 4052abf..8451d44 100644
--- a/maubot/management/frontend/.eslintrc.json
+++ b/maubot/management/frontend/.eslintrc.json
@@ -34,9 +34,6 @@
"semi": ["error", "never"],
"comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 100],
- "camelcase": ["error", {
- "properties": "always"
- }],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js
index 49465af..d01952e 100644
--- a/maubot/management/frontend/src/api.js
+++ b/maubot/management/frontend/src/api.js
@@ -138,6 +138,7 @@ export const updateDebugOpenFileEnabled = async () => {
const resp = await defaultGet("/debug/open")
_debugOpenFileEnabled = resp["enabled"] || false
}
+
export async function debugOpenFile(path, line) {
const resp = await fetch(`${BASE_PATH}/debug/open`, {
headers: getHeaders(),
@@ -178,7 +179,7 @@ export const getClients = () => defaultGet("/clients")
export const getClient = id => defaultGet(`/clients/${id}`)
export async function uploadAvatar(id, data, mime) {
- const resp = await fetch(`${BASE_PATH}/client/${id}/avatar`, {
+ const resp = await fetch(`${BASE_PATH}/proxy/${id}/_matrix/media/r0/upload`, {
headers: getHeaders(mime),
body: data,
method: "POST",
@@ -186,8 +187,13 @@ export async function uploadAvatar(id, data, mime) {
return await resp.json()
}
-export function getAvatarURL(id) {
- return `${BASE_PATH}/client/${id}/avatar?access_token=${localStorage.accessToken}`
+export function getAvatarURL({ id, avatar_url }) {
+ avatar_url = avatar_url || ""
+ if (avatar_url.startsWith("mxc://")) {
+ avatar_url = avatar_url.substr("mxc://".length)
+ }
+ return `${BASE_PATH}/proxy/${id}/_matrix/media/r0/download/${avatar_url}?access_token=${
+ localStorage.accessToken}`
}
export const putClient = client => defaultPut("client", client)
diff --git a/maubot/management/frontend/src/pages/dashboard/Client.js b/maubot/management/frontend/src/pages/dashboard/Client.js
index e520c26..c4ed60d 100644
--- a/maubot/management/frontend/src/pages/dashboard/Client.js
+++ b/maubot/management/frontend/src/pages/dashboard/Client.js
@@ -31,7 +31,7 @@ const ClientListEntry = ({ entry }) => {
}
return (
-
+
{entry.displayname || entry.id}
@@ -129,7 +129,7 @@ class Client extends BaseMainView {
-
+
-
+
{client.displayname || client.id}
),