From 70e40b281f4c91ec6cdc937fd48698380ebe2f08 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 7 Jan 2026 19:51:33 -0300 Subject: [PATCH] comms: issue guest tokens via MAS --- .../guest-register-configmap.yaml | 149 ++++++++++++++++-- .../guest-register-deployment.yaml | 24 ++- 2 files changed, 151 insertions(+), 22 deletions(-) diff --git a/services/communication/guest-register-configmap.yaml b/services/communication/guest-register-configmap.yaml index 78cc789..b051a10 100644 --- a/services/communication/guest-register-configmap.yaml +++ b/services/communication/guest-register-configmap.yaml @@ -5,20 +5,28 @@ metadata: name: matrix-guest-register data: server.py: | + import base64 import json import os + import random + import secrets from http.server import BaseHTTPRequestHandler, HTTPServer from urllib import error, parse, request - SYNAPSE_BASE = os.environ.get("SYNAPSE_BASE", "http://othrys-synapse-matrix-synapse:8008").rstrip("/") - GUEST_REGISTER_SHARED_SECRET = os.environ["GUEST_REGISTER_SHARED_SECRET"] - GUEST_REGISTER_HEADER = os.environ.get("GUEST_REGISTER_HEADER", "x-guest-register-secret") - GUEST_REGISTER_PATH = os.environ.get("GUEST_REGISTER_PATH", "/_matrix/_guest_register") + MAS_BASE = os.environ.get("MAS_BASE", "http://matrix-authentication-service:8080").rstrip("/") + SERVER_NAME = os.environ.get("MATRIX_SERVER_NAME", "live.bstein.dev") + + MAS_ADMIN_CLIENT_ID = os.environ["MAS_ADMIN_CLIENT_ID"] + MAS_ADMIN_CLIENT_SECRET_FILE = os.environ.get("MAS_ADMIN_CLIENT_SECRET_FILE", "/etc/mas/admin-client/client_secret") + MAS_ADMIN_SCOPE = os.environ.get("MAS_ADMIN_SCOPE", "urn:mas:admin") RATE_WINDOW_SEC = int(os.environ.get("RATE_WINDOW_SEC", "60")) RATE_MAX = int(os.environ.get("RATE_MAX", "30")) _rate = {} # ip -> [window_start, count] + ADJ = ["brisk", "calm", "eager", "gentle", "merry", "nifty", "rapid", "sunny", "witty", "zesty"] + NOUN = ["otter", "falcon", "comet", "ember", "grove", "harbor", "meadow", "raven", "river", "summit"] + def _json(method, url, *, headers=None, body=None, timeout=20): hdrs = {"Content-Type": "application/json"} if headers: @@ -40,6 +48,97 @@ data: payload = {} return e.code, payload + def _form(method, url, *, headers=None, fields=None, timeout=20): + hdrs = {"Content-Type": "application/x-www-form-urlencoded"} + if headers: + hdrs.update(headers) + data = parse.urlencode(fields or {}).encode() + req = request.Request(url, data=data, headers=hdrs, method=method) + try: + with request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + payload = json.loads(raw.decode()) if raw else {} + return resp.status, payload + except error.HTTPError as e: + raw = e.read() + try: + payload = json.loads(raw.decode()) if raw else {} + except Exception: + payload = {} + return e.code, payload + + _admin_token = None + _admin_token_at = 0.0 + + def _mas_admin_access_token(now): + global _admin_token, _admin_token_at + if _admin_token and (now - _admin_token_at) < 300: + return _admin_token + + with open(MAS_ADMIN_CLIENT_SECRET_FILE, encoding="utf-8") as fh: + client_secret = fh.read().strip() + basic = base64.b64encode(f"{MAS_ADMIN_CLIENT_ID}:{client_secret}".encode()).decode() + + status, payload = _form( + "POST", + f"{MAS_BASE}/oauth2/token", + headers={"Authorization": f"Basic {basic}"}, + fields={"grant_type": "client_credentials", "scope": MAS_ADMIN_SCOPE}, + timeout=20, + ) + if status != 200 or "access_token" not in payload: + raise RuntimeError("mas_admin_token_failed") + + _admin_token = payload["access_token"] + _admin_token_at = now + return _admin_token + + def _gql(admin_token, query, variables): + status, payload = _json( + "POST", + f"{MAS_BASE}/graphql", + headers={"Authorization": f"Bearer {admin_token}"}, + body={"query": query, "variables": variables}, + timeout=20, + ) + if status != 200: + raise RuntimeError("gql_http_failed") + if payload.get("errors"): + raise RuntimeError("gql_error") + return payload.get("data") or {} + + def _generate_localpart(): + return "guest-" + secrets.token_hex(6) + + def _generate_displayname(): + return f"{random.choice(ADJ)}-{random.choice(NOUN)}" + + def _add_user(admin_token, username): + data = _gql( + admin_token, + "mutation($input:AddUserInput!){addUser(input:$input){status user{id username}}}", + {"input": {"username": username, "skipHomeserverCheck": True}}, + ) + res = data.get("addUser") or {} + status = res.get("status") + user = res.get("user") or {} + return status, user.get("id"), user.get("username") + + def _set_display_name(admin_token, user_id, displayname): + _gql( + admin_token, + "mutation($input:SetDisplayNameInput!){setDisplayName(input:$input){status}}", + {"input": {"userId": user_id, "displayName": displayname}}, + ) + + def _create_oauth2_session(admin_token, user_id, scope): + data = _gql( + admin_token, + "mutation($input:CreateOAuth2SessionInput!){createOauth2Session(input:$input){accessToken}}", + {"input": {"userId": user_id, "scope": scope, "permanent": False}}, + ) + return (data.get("createOauth2Session") or {}).get("accessToken") + def _rate_check(ip, now): win, cnt = _rate.get(ip, (now, 0)) if now - win > RATE_WINDOW_SEC: @@ -109,17 +208,39 @@ data: body = {} except Exception: body = {} + try: + admin_token = _mas_admin_access_token(now) + displayname = _generate_displayname() - status, payload = _json( - "POST", - f"{SYNAPSE_BASE}{GUEST_REGISTER_PATH}", - headers={GUEST_REGISTER_HEADER: GUEST_REGISTER_SHARED_SECRET}, - body=body, - timeout=20, - ) - if "refresh_token" in payload: - payload.pop("refresh_token", None) - return self._send_json(status, payload) + localpart = None + mas_user_id = None + for _ in range(5): + localpart = _generate_localpart() + status, mas_user_id, _ = _add_user(admin_token, localpart) + if status == "ADDED": + break + mas_user_id = None + if not mas_user_id or not localpart: + raise RuntimeError("add_user_failed") + + try: + _set_display_name(admin_token, mas_user_id, displayname) + except Exception: + pass + + access_token = _create_oauth2_session(admin_token, mas_user_id, "urn:matrix:client:api:*") + if not access_token: + raise RuntimeError("session_failed") + except Exception: + return self._send_json(502, {"errcode": "M_UNKNOWN", "error": "guest_provision_failed"}) + + resp = { + "user_id": f"@{localpart}:{SERVER_NAME}", + "access_token": access_token, + "device_id": "guest_device", + "home_server": SERVER_NAME, + } + return self._send_json(200, resp) def main(): port = int(os.environ.get("PORT", "8080")) diff --git a/services/communication/guest-register-deployment.yaml b/services/communication/guest-register-deployment.yaml index cd13f63..41833b2 100644 --- a/services/communication/guest-register-deployment.yaml +++ b/services/communication/guest-register-deployment.yaml @@ -13,7 +13,7 @@ spec: template: metadata: annotations: - checksum/config: guest-register-proxy-3 + checksum/config: guest-register-proxy-4 labels: app.kubernetes.io/name: matrix-guest-register spec: @@ -37,13 +37,12 @@ spec: value: "1" - name: PORT value: "8080" - - name: SYNAPSE_BASE - value: http://othrys-synapse-matrix-synapse:8008 - - name: GUEST_REGISTER_SHARED_SECRET - valueFrom: - secretKeyRef: - name: guest-register-shared-secret-runtime - key: secret + - name: MAS_BASE + value: http://matrix-authentication-service:8080 + - name: MAS_ADMIN_CLIENT_ID + value: 01KDXMVQBQ5JNY6SEJPZW6Z8BM + - name: MAS_ADMIN_CLIENT_SECRET_FILE + value: /etc/mas/admin-client/client_secret - name: MATRIX_SERVER_NAME value: live.bstein.dev - name: RATE_WINDOW_SEC @@ -80,6 +79,9 @@ spec: mountPath: /app/server.py subPath: server.py readOnly: true + - name: mas-admin-client + mountPath: /etc/mas/admin-client + readOnly: true command: - python - /app/server.py @@ -90,3 +92,9 @@ spec: items: - key: server.py path: server.py + - name: mas-admin-client + secret: + secretName: mas-admin-client-runtime + items: + - key: client_secret + path: client_secret