diff --git a/services/comms/oneoffs/synapse-admin-ensure-job.yaml b/services/comms/oneoffs/synapse-admin-ensure-job.yaml index 136ab9c..6ea7798 100644 --- a/services/comms/oneoffs/synapse-admin-ensure-job.yaml +++ b/services/comms/oneoffs/synapse-admin-ensure-job.yaml @@ -1,12 +1,12 @@ # services/comms/oneoffs/synapse-admin-ensure-job.yaml -# One-off job for comms/synapse-admin-ensure-3. -# Purpose: synapse admin ensure 3 (see container args/env in this file). +# One-off job for comms/synapse-admin-ensure-4. +# Purpose: synapse admin ensure 4 (see container args/env in this file). # Run by setting spec.suspend to false, reconcile, then set it back to true. # Safe to delete the finished Job/pod; it should not run continuously. apiVersion: batch/v1 kind: Job metadata: - name: synapse-admin-ensure-3 + name: synapse-admin-ensure-4 namespace: comms spec: suspend: false @@ -40,43 +40,54 @@ spec: value: comms-secrets - name: SYNAPSE_ADMIN_URL value: http://othrys-synapse-matrix-synapse.comms.svc.cluster.local:8008 + - name: MAS_AUTH_URL + value: http://matrix-authentication-service.comms.svc.cluster.local:8080 command: - /bin/sh - -c - | set -euo pipefail - pip install --no-cache-dir psycopg2-binary bcrypt python - <<'PY' + import hashlib + import hmac import json import os import secrets import string - import time import urllib.error import urllib.request - import bcrypt - import psycopg2 - VAULT_ADDR = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200").rstrip("/") VAULT_ROLE = os.environ.get("VAULT_ROLE", "comms-secrets") SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" - PGHOST = "postgres-service.postgres.svc.cluster.local" - PGPORT = 5432 - PGDATABASE = "synapse" - PGUSER = "synapse" + SYNAPSE_ADMIN_URL = os.environ.get("SYNAPSE_ADMIN_URL", "").rstrip("/") + MAS_AUTH_URL = os.environ.get("MAS_AUTH_URL", SYNAPSE_ADMIN_URL).rstrip("/") def log(msg: str) -> None: print(msg, flush=True) - def request_json(url: str, payload: dict | None = None) -> dict: + def request_json(url: str, payload: dict | None = None, method: str | None = None) -> tuple[int, dict]: data = None headers = {"Content-Type": "application/json"} if payload is not None: data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(url, data=data, headers=headers, method="POST" if data else "GET") - with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) + req = urllib.request.Request( + url, + data=data, + headers=headers, + method=method or ("POST" if data else "GET"), + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8") + return resp.getcode(), json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8") + try: + payload = json.loads(body) if body else {} + except json.JSONDecodeError: + payload = {} + return exc.code, payload def vault_login() -> str: with open(SA_TOKEN_PATH, "r", encoding="utf-8") as f: @@ -128,61 +139,53 @@ spec: vault_put(token, "comms/synapse-admin", data) return data - def ensure_user(cur, cols, user_id, password, admin): - now_ms = int(time.time() * 1000) - values = { - "name": user_id, - "password_hash": bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode(), - "creation_ts": now_ms, + def registration_mac(nonce: str, username: str, password: str, admin: bool, shared_secret: str) -> str: + admin_value = "admin" if admin else "notadmin" + msg = "\x00".join([nonce, username, password, admin_value]).encode("utf-8") + return hmac.new(shared_secret.encode("utf-8"), msg=msg, digestmod=hashlib.sha1).hexdigest() + + def synapse_register(shared_secret: str, username: str, password: str, admin: bool) -> None: + status, nonce_payload = request_json( + f"{SYNAPSE_ADMIN_URL}/_synapse/admin/v1/register", + method="GET", + ) + if status != 200: + raise RuntimeError(f"register nonce failed: {status}") + nonce = (nonce_payload or {}).get("nonce") + if not nonce: + raise RuntimeError("register nonce missing") + mac = registration_mac(nonce, username, password, admin, shared_secret) + payload = { + "nonce": nonce, + "username": username, + "password": password, + "admin": admin, + "mac": mac, } - - def add_flag(name, flag): - if name not in cols: - return - if cols[name]["type"] in ("smallint", "integer"): - values[name] = int(flag) - else: - values[name] = bool(flag) - - add_flag("admin", admin) - add_flag("deactivated", False) - add_flag("shadow_banned", False) - add_flag("is_guest", False) - - columns = list(values.keys()) - placeholders = ", ".join(["%s"] * len(columns)) - updates = ", ".join([f"{col}=EXCLUDED.{col}" for col in columns if col != "name"]) - query = f"INSERT INTO users ({', '.join(columns)}) VALUES ({placeholders}) ON CONFLICT (name) DO UPDATE SET {updates};" - cur.execute(query, [values[c] for c in columns]) - - def get_cols(cur): - cur.execute( - """ - SELECT column_name, is_nullable, column_default, data_type - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'users' - """ + status, resp = request_json( + f"{SYNAPSE_ADMIN_URL}/_synapse/admin/v1/register", + payload, + method="POST", ) - cols = {} - for name, is_nullable, default, data_type in cur.fetchall(): - cols[name] = { - "nullable": is_nullable == "YES", - "default": default, - "type": data_type, - } - return cols + if status in (200, 201): + return + if resp.get("errcode") == "M_USER_IN_USE": + return + raise RuntimeError(f"register failed: {status} {resp}") - def ensure_access_token(cur, user_id, token_value): - cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM access_tokens") - token_id = cur.fetchone()[0] - cur.execute( - """ - INSERT INTO access_tokens (id, user_id, token, device_id, valid_until_ms) - VALUES (%s, %s, %s, %s, NULL) - ON CONFLICT (token) DO NOTHING - """, - (token_id, user_id, token_value, "ariadne-admin"), - ) + def synapse_login(username: str, password: str) -> str: + payload = { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": username}, + "password": password, + } + status, resp = request_json(f"{MAS_AUTH_URL}/_matrix/client/v3/login", payload, method="POST") + if status not in (200, 201): + raise RuntimeError(f"login failed: {status} {resp}") + token = resp.get("access_token") + if not token: + raise RuntimeError("login returned no access token") + return token vault_token = vault_login() admin_data = ensure_admin_creds(vault_token) @@ -190,29 +193,13 @@ spec: log("synapse admin token already present") raise SystemExit(0) - synapse_db = vault_get(vault_token, "comms/synapse-db") - pg_password = synapse_db.get("POSTGRES_PASSWORD") - if not pg_password: - raise RuntimeError("synapse db password missing") - - user_id = f"@{admin_data['username']}:live.bstein.dev" - conn = psycopg2.connect( - host=PGHOST, - port=PGPORT, - dbname=PGDATABASE, - user=PGUSER, - password=pg_password, - ) - token_value = secrets.token_urlsafe(32) - try: - with conn: - with conn.cursor() as cur: - cols = get_cols(cur) - ensure_user(cur, cols, user_id, admin_data["password"], True) - ensure_access_token(cur, user_id, token_value) - finally: - conn.close() + reg_secret = vault_get(vault_token, "comms/synapse-registration") + shared_secret = (reg_secret.get("registration_shared_secret") or "").strip() + if not shared_secret: + raise RuntimeError("registration shared secret missing") + synapse_register(shared_secret, admin_data["username"], admin_data["password"], True) + token_value = synapse_login(admin_data["username"], admin_data["password"]) admin_data["access_token"] = token_value vault_put(vault_token, "comms/synapse-admin", admin_data) log("synapse admin token stored")