comms: use bundled synapse admin ensure image

This commit is contained in:
Brad Stein 2026-01-28 17:47:58 -03:00
parent 06cda3f540
commit 1d248bf91a
2 changed files with 95 additions and 81 deletions

View File

@ -0,0 +1,3 @@
FROM python:3.11-slim
RUN pip install --no-cache-dir psycopg2-binary bcrypt

View File

@ -1,12 +1,12 @@
# services/comms/oneoffs/synapse-admin-ensure-job.yaml # services/comms/oneoffs/synapse-admin-ensure-job.yaml
# One-off job for comms/synapse-admin-ensure-5. # One-off job for comms/synapse-admin-ensure-6.
# Purpose: synapse admin ensure 5 (see container args/env in this file). # Purpose: synapse admin ensure 6 (see container args/env in this file).
# Run by setting spec.suspend to false, reconcile, then set it back to true. # 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. # Safe to delete the finished Job/pod; it should not run continuously.
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: synapse-admin-ensure-5 name: synapse-admin-ensure-6
namespace: comms namespace: comms
spec: spec:
suspend: true suspend: true
@ -32,7 +32,7 @@ spec:
values: ["arm64"] values: ["arm64"]
containers: containers:
- name: ensure - name: ensure
image: python:3.11-slim image: registry.bstein.dev/infra/synapse-admin-ensure:0.1.0
env: env:
- name: VAULT_ADDR - name: VAULT_ADDR
value: http://vault.vault.svc.cluster.local:8200 value: http://vault.vault.svc.cluster.local:8200
@ -40,62 +40,49 @@ spec:
value: comms-secrets value: comms-secrets
- name: SYNAPSE_ADMIN_URL - name: SYNAPSE_ADMIN_URL
value: http://othrys-synapse-matrix-synapse.comms.svc.cluster.local:8008 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: command:
- /bin/sh - /bin/sh
- -c - -c
- | - |
set -euo pipefail set -euo pipefail
python - <<'PY' python - <<'PY'
import hashlib
import hmac
import json import json
import os import os
import secrets import secrets
import string import string
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import bcrypt
import psycopg2
VAULT_ADDR = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200").rstrip("/") VAULT_ADDR = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200").rstrip("/")
VAULT_ROLE = os.environ.get("VAULT_ROLE", "comms-secrets") VAULT_ROLE = os.environ.get("VAULT_ROLE", "comms-secrets")
SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"
SYNAPSE_ADMIN_URL = os.environ.get("SYNAPSE_ADMIN_URL", "").rstrip("/") SYNAPSE_ADMIN_URL = os.environ.get("SYNAPSE_ADMIN_URL", "").rstrip("/")
MAS_AUTH_URL = os.environ.get("MAS_AUTH_URL", SYNAPSE_ADMIN_URL).rstrip("/") PGHOST = "postgres-service.postgres.svc.cluster.local"
PGPORT = 5432
PGDATABASE = "synapse"
PGUSER = "synapse"
def log(msg: str) -> None: def log(msg: str) -> None:
print(msg, flush=True) print(msg, flush=True)
def request_json(url: str, payload: dict | None = None, method: str | None = None) -> tuple[int, dict]: def request_json(url: str, payload: dict | None = None) -> dict:
data = None data = None
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if payload is not None: if payload is not None:
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(url, data=data, headers=headers, method="POST" if data else "GET")
url, with urllib.request.urlopen(req, timeout=30) as resp:
data=data, return json.loads(resp.read().decode("utf-8"))
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: def vault_login() -> str:
with open(SA_TOKEN_PATH, "r", encoding="utf-8") as f: with open(SA_TOKEN_PATH, "r", encoding="utf-8") as f:
jwt = f.read().strip() jwt = f.read().strip()
payload = {"jwt": jwt, "role": VAULT_ROLE} payload = {"jwt": jwt, "role": VAULT_ROLE}
status, resp = request_json(f"{VAULT_ADDR}/v1/auth/kubernetes/login", payload) resp = request_json(f"{VAULT_ADDR}/v1/auth/kubernetes/login", payload)
if status != 200:
raise RuntimeError(f"vault login failed: {status} {resp}")
token = resp.get("auth", {}).get("client_token") token = resp.get("auth", {}).get("client_token")
if not token: if not token:
raise RuntimeError("vault login failed") raise RuntimeError("vault login failed")
@ -141,53 +128,61 @@ spec:
vault_put(token, "comms/synapse-admin", data) vault_put(token, "comms/synapse-admin", data)
return data return data
def registration_mac(nonce: str, username: str, password: str, admin: bool, shared_secret: str) -> str: def ensure_user(cur, cols, user_id, password, admin):
admin_value = "admin" if admin else "notadmin" now_ms = int(time.time() * 1000)
msg = "\x00".join([nonce, username, password, admin_value]).encode("utf-8") values = {
return hmac.new(shared_secret.encode("utf-8"), msg=msg, digestmod=hashlib.sha1).hexdigest() "name": user_id,
"password_hash": bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode(),
def synapse_register(shared_secret: str, username: str, password: str, admin: bool) -> None: "creation_ts": now_ms,
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,
} }
status, resp = request_json(
f"{SYNAPSE_ADMIN_URL}/_synapse/admin/v1/register",
payload,
method="POST",
)
if status in (200, 201):
return
if resp.get("errcode") == "M_USER_IN_USE":
return
raise RuntimeError(f"register failed: {status} {resp}")
def synapse_login(username: str, password: str) -> str: def add_flag(name, flag):
payload = { if name not in cols:
"type": "m.login.password", return
"identifier": {"type": "m.id.user", "user": username}, if cols[name]["type"] in ("smallint", "integer"):
"password": password, values[name] = int(flag)
} else:
status, resp = request_json(f"{MAS_AUTH_URL}/_matrix/client/v3/login", payload, method="POST") values[name] = bool(flag)
if status not in (200, 201):
raise RuntimeError(f"login failed: {status} {resp}") add_flag("admin", admin)
token = resp.get("access_token") add_flag("deactivated", False)
if not token: add_flag("shadow_banned", False)
raise RuntimeError("login returned no access token") add_flag("is_guest", False)
return token
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'
"""
)
cols = {}
for name, is_nullable, default, data_type in cur.fetchall():
cols[name] = {
"nullable": is_nullable == "YES",
"default": default,
"type": data_type,
}
return cols
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"),
)
vault_token = vault_login() vault_token = vault_login()
admin_data = ensure_admin_creds(vault_token) admin_data = ensure_admin_creds(vault_token)
@ -195,13 +190,29 @@ spec:
log("synapse admin token already present") log("synapse admin token already present")
raise SystemExit(0) raise SystemExit(0)
reg_secret = vault_get(vault_token, "comms/synapse-registration") synapse_db = vault_get(vault_token, "comms/synapse-db")
shared_secret = (reg_secret.get("registration_shared_secret") or "").strip() pg_password = synapse_db.get("POSTGRES_PASSWORD")
if not shared_secret: if not pg_password:
raise RuntimeError("registration shared secret missing") 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()
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 admin_data["access_token"] = token_value
vault_put(vault_token, "comms/synapse-admin", admin_data) vault_put(vault_token, "comms/synapse-admin", admin_data)
log("synapse admin token stored") log("synapse admin token stored")