From ff395f7cf2eb5c6f15f9d7d543ad920959905472 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 7 Jan 2026 09:17:45 -0300 Subject: [PATCH] comms: restore Matrix guest join --- services/communication/element-rendered.yaml | 2 +- .../guest-register-configmap.yaml | 195 ++++++++++++++++++ .../guest-register-deployment.yaml | 95 +++++++++ .../communication/guest-register-ingress.yaml | 34 +++ .../communication/guest-register-service.yaml | 16 ++ services/communication/kustomization.yaml | 4 + 6 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 services/communication/guest-register-configmap.yaml create mode 100644 services/communication/guest-register-deployment.yaml create mode 100644 services/communication/guest-register-ingress.yaml create mode 100644 services/communication/guest-register-service.yaml diff --git a/services/communication/element-rendered.yaml b/services/communication/element-rendered.yaml index f04dda2..0d3200e 100644 --- a/services/communication/element-rendered.yaml +++ b/services/communication/element-rendered.yaml @@ -60,7 +60,7 @@ metadata: app.kubernetes.io/managed-by: Helm data: config.json: | - {"brand":"Othrys","default_server_config":{"m.homeserver":{"base_url":"https://matrix.live.bstein.dev","server_name":"live.bstein.dev"},"m.identity_server":{"base_url":"https://vector.im"}},"default_theme":"dark","disable_custom_urls":true,"disable_login_language_selector":true,"disable_guests":false,"show_labs_settings":true,"features":{"feature_group_calls":true,"feature_video_rooms":true,"feature_element_call_video_rooms":true},"room_directory":{"servers":["live.bstein.dev"]},"jitsi":{},"element_call":{"url":"https://call.live.bstein.dev","participant_limit":16,"brand":"Othrys Call"}} + {"brand":"Othrys","default_server_config":{"m.homeserver":{"base_url":"https://matrix.live.bstein.dev","server_name":"live.bstein.dev"},"m.identity_server":{"base_url":"https://vector.im"}},"default_theme":"dark","disable_custom_urls":true,"disable_login_language_selector":true,"disable_guests":false,"registration_url":"https://bstein.dev/request-access","show_labs_settings":true,"features":{"feature_group_calls":true,"feature_video_rooms":true,"feature_element_call_video_rooms":true},"room_directory":{"servers":["live.bstein.dev"]},"jitsi":{},"element_call":{"url":"https://call.live.bstein.dev","participant_limit":16,"brand":"Othrys Call"}} --- # Source: element-web/templates/service.yaml apiVersion: v1 diff --git a/services/communication/guest-register-configmap.yaml b/services/communication/guest-register-configmap.yaml new file mode 100644 index 0000000..5d6e2a2 --- /dev/null +++ b/services/communication/guest-register-configmap.yaml @@ -0,0 +1,195 @@ +# services/communication/guest-register-configmap.yaml +apiVersion: v1 +kind: ConfigMap +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("/") + AUTH_BASE = os.environ.get("AUTH_BASE", "http://matrix-authentication-service:8080").rstrip("/") + SERVER_NAME = os.environ.get("MATRIX_SERVER_NAME", "live.bstein.dev") + + SEEDER_USER = os.environ["SEEDER_USER"] + SEEDER_PASS = os.environ["SEEDER_PASS"] + + # Basic rate limiting (best-effort) to avoid accidental abuse. + # Count requests per client IP over a short window. + 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, *, token=None, body=None, timeout=20): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + data = None + if body is not None: + data = json.dumps(body).encode() + req = request.Request(url, data=data, headers=headers, 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 + + _seeder_token = None + _seeder_token_at = 0.0 + + def _login(user, password): + status, payload = _json( + "POST", + f"{AUTH_BASE}/_matrix/client/v3/login", + body={ + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": user}, + "password": password, + }, + timeout=20, + ) + if status != 200 or "access_token" not in payload: + raise RuntimeError("login_failed") + return payload + + def _seeder_access_token(now): + global _seeder_token, _seeder_token_at + if _seeder_token and (now - _seeder_token_at) < 300: + return _seeder_token + payload = _login(SEEDER_USER, SEEDER_PASS) + _seeder_token = payload["access_token"] + _seeder_token_at = now + return _seeder_token + + def _create_user(admin_token, localpart, password, displayname): + user_id = f"@{localpart}:{SERVER_NAME}" + status, payload = _json( + "PUT", + f"{SYNAPSE_BASE}/_synapse/admin/v2/users/{parse.quote(user_id)}", + token=admin_token, + body={ + "password": password, + "admin": False, + "deactivated": False, + "displayname": displayname, + }, + timeout=25, + ) + if status not in (200, 201): + raise RuntimeError("user_create_failed") + return user_id + + def _generate_localpart(): + return "guest-" + secrets.token_hex(6) + + def _generate_displayname(): + return f"{random.choice(ADJ)}-{random.choice(NOUN)}" + + def _rate_check(ip, now): + win, cnt = _rate.get(ip, (now, 0)) + if now - win > RATE_WINDOW_SEC: + _rate[ip] = (now, 1) + return True + if cnt >= RATE_MAX: + return False + _rate[ip] = (win, cnt + 1) + return True + + class Handler(BaseHTTPRequestHandler): + server_version = "matrix-guest-register" + + def _send_json(self, code, payload): + body = json.dumps(payload).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self): # noqa: N802 + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + self.end_headers() + + def do_GET(self): # noqa: N802 + if self.path in ("/healthz", "/"): + return self._send_json(200, {"ok": True}) + return self._send_json(404, {"errcode": "M_NOT_FOUND", "error": "not_found"}) + + def do_POST(self): # noqa: N802 + # We only implement guest registration (used by Element Web "Join as guest"). + parsed = parse.urlparse(self.path) + if parsed.path not in ("/_matrix/client/v3/register", "/_matrix/client/r0/register"): + return self._send_json(404, {"errcode": "M_NOT_FOUND", "error": "not_found"}) + + qs = parse.parse_qs(parsed.query) + kind = (qs.get("kind") or ["user"])[0] + if kind != "guest": + return self._send_json( + 403, + { + "errcode": "M_FORBIDDEN", + "error": "Registration is disabled; use https://bstein.dev/request-access for accounts.", + }, + ) + + # Best-effort client IP from X-Forwarded-For (Traefik). + xfwd = self.headers.get("x-forwarded-for", "") + ip = (xfwd.split(",")[0].strip() if xfwd else "") or self.client_address[0] + now = __import__("time").time() + if not _rate_check(ip, now): + return self._send_json(429, {"errcode": "M_LIMIT_EXCEEDED", "error": "rate_limited"}) + + # Consume request body (Element may send fields; we ignore). + length = int(self.headers.get("content-length", "0") or "0") + _ = self.rfile.read(length) if length else b"{}" + + # Create a short-lived "guest" account by provisioning a normal user with a random password. + # This keeps MAS/OIDC intact while restoring a no-signup guest UX. + try: + admin_token = _seeder_access_token(now) + displayname = _generate_displayname() + localpart = _generate_localpart() + password = base64.urlsafe_b64encode(secrets.token_bytes(24)).decode().rstrip("=") + user_id = _create_user(admin_token, localpart, password, displayname) + login_payload = _login(localpart, password) + except Exception: + return self._send_json(502, {"errcode": "M_UNKNOWN", "error": "guest_provision_failed"}) + + resp = { + "user_id": login_payload.get("user_id") or user_id, + "access_token": login_payload.get("access_token"), + "device_id": login_payload.get("device_id"), + "home_server": SERVER_NAME, + } + # Do not expose refresh tokens for guests. + return self._send_json(200, resp) + + def main(): + port = int(os.environ.get("PORT", "8080")) + HTTPServer(("0.0.0.0", port), Handler).serve_forever() + + if __name__ == "__main__": + main() + diff --git a/services/communication/guest-register-deployment.yaml b/services/communication/guest-register-deployment.yaml new file mode 100644 index 0000000..720fe76 --- /dev/null +++ b/services/communication/guest-register-deployment.yaml @@ -0,0 +1,95 @@ +# services/communication/guest-register-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: matrix-guest-register + labels: + app.kubernetes.io/name: matrix-guest-register +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: matrix-guest-register + template: + metadata: + labels: + app.kubernetes.io/name: matrix-guest-register + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + containers: + - name: guest-register + image: python:3.11-slim + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + env: + - name: PYTHONDONTWRITEBYTECODE + value: "1" + - name: PYTHONUNBUFFERED + value: "1" + - name: PORT + value: "8080" + - name: SYNAPSE_BASE + value: http://othrys-synapse-matrix-synapse:8008 + - name: AUTH_BASE + value: http://matrix-authentication-service:8080 + - name: MATRIX_SERVER_NAME + value: live.bstein.dev + - name: SEEDER_USER + value: othrys-seeder + - name: SEEDER_PASS + valueFrom: + secretKeyRef: + name: atlasbot-credentials-runtime + key: seeder-password + - name: RATE_WINDOW_SEC + value: "60" + - name: RATE_MAX + value: "30" + ports: + - name: http + containerPort: 8080 + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + timeoutSeconds: 2 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + volumeMounts: + - name: app + mountPath: /app/server.py + subPath: server.py + readOnly: true + command: + - python + - /app/server.py + volumes: + - name: app + configMap: + name: matrix-guest-register + items: + - key: server.py + path: server.py + diff --git a/services/communication/guest-register-ingress.yaml b/services/communication/guest-register-ingress.yaml new file mode 100644 index 0000000..c3f38c1 --- /dev/null +++ b/services/communication/guest-register-ingress.yaml @@ -0,0 +1,34 @@ +# services/communication/guest-register-ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: matrix-guest-register + annotations: + kubernetes.io/ingress.class: traefik + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + cert-manager.io/cluster-issuer: letsencrypt +spec: + tls: + - hosts: + - matrix.live.bstein.dev + secretName: matrix-live-tls + rules: + - host: matrix.live.bstein.dev + http: + paths: + - path: /_matrix/client/v3/register + pathType: Prefix + backend: + service: + name: matrix-guest-register + port: + number: 8080 + - path: /_matrix/client/r0/register + pathType: Prefix + backend: + service: + name: matrix-guest-register + port: + number: 8080 + diff --git a/services/communication/guest-register-service.yaml b/services/communication/guest-register-service.yaml new file mode 100644 index 0000000..776e3ab --- /dev/null +++ b/services/communication/guest-register-service.yaml @@ -0,0 +1,16 @@ +# services/communication/guest-register-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: matrix-guest-register + labels: + app.kubernetes.io/name: matrix-guest-register +spec: + selector: + app.kubernetes.io/name: matrix-guest-register + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP + diff --git a/services/communication/kustomization.yaml b/services/communication/kustomization.yaml index 5b71f75..e651976 100644 --- a/services/communication/kustomization.yaml +++ b/services/communication/kustomization.yaml @@ -23,6 +23,10 @@ resources: - bstein-force-leave-job.yaml - pin-othrys-job.yaml - guest-name-job.yaml + - guest-register-configmap.yaml + - guest-register-deployment.yaml + - guest-register-service.yaml + - guest-register-ingress.yaml - atlasbot-configmap.yaml - atlasbot-deployment.yaml - seed-othrys-room.yaml