comms: issue guest tokens via MAS

This commit is contained in:
Brad Stein 2026-01-07 19:51:33 -03:00
parent cd4b963db4
commit 70e40b281f
2 changed files with 151 additions and 22 deletions

View File

@ -5,20 +5,28 @@ metadata:
name: matrix-guest-register name: matrix-guest-register
data: data:
server.py: | server.py: |
import base64
import json import json
import os import os
import random
import secrets
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib import error, parse, request from urllib import error, parse, request
SYNAPSE_BASE = os.environ.get("SYNAPSE_BASE", "http://othrys-synapse-matrix-synapse:8008").rstrip("/") MAS_BASE = os.environ.get("MAS_BASE", "http://matrix-authentication-service:8080").rstrip("/")
GUEST_REGISTER_SHARED_SECRET = os.environ["GUEST_REGISTER_SHARED_SECRET"] SERVER_NAME = os.environ.get("MATRIX_SERVER_NAME", "live.bstein.dev")
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_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_WINDOW_SEC = int(os.environ.get("RATE_WINDOW_SEC", "60"))
RATE_MAX = int(os.environ.get("RATE_MAX", "30")) RATE_MAX = int(os.environ.get("RATE_MAX", "30"))
_rate = {} # ip -> [window_start, count] _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): def _json(method, url, *, headers=None, body=None, timeout=20):
hdrs = {"Content-Type": "application/json"} hdrs = {"Content-Type": "application/json"}
if headers: if headers:
@ -40,6 +48,97 @@ data:
payload = {} payload = {}
return e.code, 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): def _rate_check(ip, now):
win, cnt = _rate.get(ip, (now, 0)) win, cnt = _rate.get(ip, (now, 0))
if now - win > RATE_WINDOW_SEC: if now - win > RATE_WINDOW_SEC:
@ -109,17 +208,39 @@ data:
body = {} body = {}
except Exception: except Exception:
body = {} body = {}
try:
admin_token = _mas_admin_access_token(now)
displayname = _generate_displayname()
status, payload = _json( localpart = None
"POST", mas_user_id = None
f"{SYNAPSE_BASE}{GUEST_REGISTER_PATH}", for _ in range(5):
headers={GUEST_REGISTER_HEADER: GUEST_REGISTER_SHARED_SECRET}, localpart = _generate_localpart()
body=body, status, mas_user_id, _ = _add_user(admin_token, localpart)
timeout=20, if status == "ADDED":
) break
if "refresh_token" in payload: mas_user_id = None
payload.pop("refresh_token", None) if not mas_user_id or not localpart:
return self._send_json(status, payload) 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(): def main():
port = int(os.environ.get("PORT", "8080")) port = int(os.environ.get("PORT", "8080"))

View File

@ -13,7 +13,7 @@ spec:
template: template:
metadata: metadata:
annotations: annotations:
checksum/config: guest-register-proxy-3 checksum/config: guest-register-proxy-4
labels: labels:
app.kubernetes.io/name: matrix-guest-register app.kubernetes.io/name: matrix-guest-register
spec: spec:
@ -37,13 +37,12 @@ spec:
value: "1" value: "1"
- name: PORT - name: PORT
value: "8080" value: "8080"
- name: SYNAPSE_BASE - name: MAS_BASE
value: http://othrys-synapse-matrix-synapse:8008 value: http://matrix-authentication-service:8080
- name: GUEST_REGISTER_SHARED_SECRET - name: MAS_ADMIN_CLIENT_ID
valueFrom: value: 01KDXMVQBQ5JNY6SEJPZW6Z8BM
secretKeyRef: - name: MAS_ADMIN_CLIENT_SECRET_FILE
name: guest-register-shared-secret-runtime value: /etc/mas/admin-client/client_secret
key: secret
- name: MATRIX_SERVER_NAME - name: MATRIX_SERVER_NAME
value: live.bstein.dev value: live.bstein.dev
- name: RATE_WINDOW_SEC - name: RATE_WINDOW_SEC
@ -80,6 +79,9 @@ spec:
mountPath: /app/server.py mountPath: /app/server.py
subPath: server.py subPath: server.py
readOnly: true readOnly: true
- name: mas-admin-client
mountPath: /etc/mas/admin-client
readOnly: true
command: command:
- python - python
- /app/server.py - /app/server.py
@ -90,3 +92,9 @@ spec:
items: items:
- key: server.py - key: server.py
path: server.py path: server.py
- name: mas-admin-client
secret:
secretName: mas-admin-client-runtime
items:
- key: client_secret
path: client_secret