2026-01-08 01:55:58 -03:00
# services/comms/guest-name-job.yaml
2025-12-31 12:00:12 -03:00
apiVersion : batch/v1
kind : CronJob
metadata :
name : guest-name-randomizer
2026-01-01 16:29:11 -03:00
namespace : comms
2025-12-31 12:00:12 -03:00
spec :
schedule : "*/1 * * * *"
2026-01-08 00:13:40 -03:00
suspend : false
2026-01-08 05:34:03 -03:00
successfulJobsHistoryLimit : 1
failedJobsHistoryLimit : 1
2025-12-31 12:00:12 -03:00
jobTemplate :
spec :
2025-12-31 17:33:20 -03:00
backoffLimit : 0
2025-12-31 12:00:12 -03:00
template :
spec :
2025-12-31 17:33:20 -03:00
restartPolicy : Never
2026-01-08 00:13:40 -03:00
volumes :
- name : mas-admin-client
secret :
secretName : mas-admin-client-runtime
items :
- key : client_secret
path : client_secret
2025-12-31 12:00:12 -03:00
containers :
- name : rename
image : python:3.11-slim
2026-01-08 00:13:40 -03:00
volumeMounts :
- name : mas-admin-client
mountPath : /etc/mas-admin-client
readOnly : true
2025-12-31 12:00:12 -03:00
env :
- name : SYNAPSE_BASE
value : http://othrys-synapse-matrix-synapse:8008
2026-01-08 00:13:40 -03:00
- name : MAS_ADMIN_CLIENT_ID
value : 01KDXMVQBQ5JNY6SEJPZW6Z8BM
- name : MAS_ADMIN_CLIENT_SECRET_FILE
value : /etc/mas-admin-client/client_secret
- name : MAS_ADMIN_API_BASE
value : http://matrix-authentication-service:8081/api/admin/v1
- name : MAS_TOKEN_URL
value : http://matrix-authentication-service:8080/oauth2/token
2025-12-31 12:00:12 -03:00
- name : SEEDER_USER
value : othrys-seeder
2026-01-08 11:59:51 -03:00
- name : PGHOST
value : postgres-service.postgres.svc.cluster.local
- name : PGPORT
value : "5432"
- name : PGDATABASE
value : synapse
- name : PGUSER
value : synapse
- name : PGPASSWORD
valueFrom :
secretKeyRef :
name : synapse-db
key : POSTGRES_PASSWORD
2025-12-31 12:00:12 -03:00
command :
- /bin/sh
- -c
- |
set -euo pipefail
2026-01-08 11:59:51 -03:00
pip install --no-cache-dir requests psycopg2-binary >/dev/null
2025-12-31 12:00:12 -03:00
python - <<'PY'
2026-01-08 00:13:40 -03:00
import base64
import os
import random
import requests
2026-01-08 02:00:52 -03:00
import time
2026-01-08 00:13:40 -03:00
import urllib.parse
2026-01-08 11:59:51 -03:00
import psycopg2
2025-12-31 12:00:12 -03:00
2026-01-08 00:13:40 -03:00
ADJ = [
"brisk" , "calm" , "eager" , "gentle" , "merry" , "nifty" , "rapid" , "sunny" , "witty" , "zesty" ,
"amber" , "bold" , "bright" , "crisp" , "daring" , "frosty" , "glad" , "jolly" , "lively" , "mellow" ,
"quiet" , "ripe" , "serene" , "spry" , "tidy" , "vivid" , "warm" , "wild" , "clever" , "kind" ,
]
NOUN = [
"otter" , "falcon" , "comet" , "ember" , "grove" , "harbor" , "meadow" , "raven" , "river" , "summit" ,
"breeze" , "cedar" , "cinder" , "cove" , "delta" , "forest" , "glade" , "lark" , "marsh" , "peak" ,
"pine" , "quartz" , "reef" , "ridge" , "sable" , "sage" , "shore" , "thunder" , "vale" , "zephyr" ,
]
2025-12-31 12:00:12 -03:00
BASE = os.environ["SYNAPSE_BASE"]
2026-01-08 00:13:40 -03:00
MAS_ADMIN_CLIENT_ID = os.environ["MAS_ADMIN_CLIENT_ID"]
MAS_ADMIN_CLIENT_SECRET_FILE = os.environ["MAS_ADMIN_CLIENT_SECRET_FILE"]
MAS_ADMIN_API_BASE = os.environ["MAS_ADMIN_API_BASE"].rstrip("/")
MAS_TOKEN_URL = os.environ["MAS_TOKEN_URL"]
SEEDER_USER = os.environ["SEEDER_USER"]
2026-01-01 16:29:11 -03:00
ROOM_ALIAS = "#othrys:live.bstein.dev"
2026-01-08 11:59:51 -03:00
SERVER_NAME = "live.bstein.dev"
2025-12-31 12:00:12 -03:00
2026-01-08 00:13:40 -03:00
def mas_admin_token() :
with open(MAS_ADMIN_CLIENT_SECRET_FILE, "r", encoding="utf-8") as f:
secret = f.read().strip()
basic = base64.b64encode(f"{MAS_ADMIN_CLIENT_ID}:{secret}".encode()).decode()
2026-01-08 02:00:52 -03:00
last_err = None
for attempt in range(5) :
try :
r = requests.post(
MAS_TOKEN_URL,
headers={"Authorization": f"Basic {basic}"},
data={"grant_type": "client_credentials", "scope": "urn:mas:admin"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
except Exception as exc: # noqa : BLE001
last_err = exc
time.sleep(2 ** attempt)
raise last_err
2025-12-31 12:00:12 -03:00
2026-01-08 00:13:40 -03:00
def mas_user_id(token, username) :
r = requests.get(
f"{MAS_ADMIN_API_BASE}/users/by-username/{urllib.parse.quote(username)}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["data"]["id"]
def mas_personal_session(token, user_id) :
r = requests.post(
f"{MAS_ADMIN_API_BASE}/personal-sessions",
headers={"Authorization": f"Bearer {token}"},
json={
"actor_user_id": user_id,
"human_name": "guest-name-randomizer" ,
"scope": "urn:matrix:client:api:*" ,
"expires_in": 300 ,
},
timeout=30,
)
r.raise_for_status()
data = r.json().get("data", {}).get("attributes", {}) or {}
return data["access_token"], r.json()["data"]["id"]
def mas_revoke_session(token, session_id) :
requests.post(
f"{MAS_ADMIN_API_BASE}/personal-sessions/{urllib.parse.quote(session_id)}/revoke",
headers={"Authorization": f"Bearer {token}"},
json={},
timeout=30,
)
2026-01-01 16:29:11 -03:00
def resolve_alias(token, alias) :
headers = {"Authorization": f"Bearer {token}"}
enc = urllib.parse.quote(alias)
r = requests.get(f"{BASE}/_matrix/client/v3/directory/room/{enc}", headers=headers)
r.raise_for_status()
return r.json()["room_id"]
2026-01-08 00:26:20 -03:00
def room_members(token, room_id) :
2025-12-31 12:00:12 -03:00
headers = {"Authorization": f"Bearer {token}"}
2026-01-08 00:26:20 -03:00
r = requests.get(f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/members", headers=headers)
r.raise_for_status()
members = set()
2026-01-08 00:13:40 -03:00
existing_names = set()
2026-01-08 00:26:20 -03:00
for ev in r.json().get("chunk", []):
user_id = ev.get("state_key")
if user_id :
members.add(user_id)
disp = (ev.get("content") or {}).get("displayname")
if disp :
existing_names.add(disp)
return members, existing_names
def mas_list_users(token) :
headers = {"Authorization": f"Bearer {token}"}
users = []
cursor = None
2025-12-31 12:00:12 -03:00
while True :
2026-01-08 00:26:20 -03:00
url = f"{MAS_ADMIN_API_BASE}/users?page[size]=100"
if cursor :
url += f"&page[after]={urllib.parse.quote(cursor)}"
r = requests.get(url, headers=headers, timeout=30)
r.raise_for_status()
data = r.json().get("data", [])
if not data :
break
users.extend(data)
cursor = data[-1].get("meta", {}).get("page", {}).get("cursor")
if not cursor :
2025-12-31 12:00:12 -03:00
break
2026-01-08 00:26:20 -03:00
return users
2025-12-31 12:00:12 -03:00
2026-01-08 05:34:03 -03:00
def synapse_list_users(token) :
headers = {"Authorization": f"Bearer {token}"}
users = []
from_token = None
while True :
url = f"{BASE}/_synapse/admin/v2/users?local=true&deactivated=false&limit=100"
if from_token :
url += f"&from={urllib.parse.quote(from_token)}"
r = requests.get(url, headers=headers, timeout=30)
r.raise_for_status()
payload = r.json()
users.extend(payload.get("users", []))
from_token = payload.get("next_token")
if not from_token :
break
return users
2026-01-08 00:26:20 -03:00
def user_id_for_username(username) :
return f"@{username}:live.bstein.dev"
def get_displayname(token, user_id) :
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(f"{BASE}/_matrix/client/v3/profile/{urllib.parse.quote(user_id)}", headers=headers)
r.raise_for_status()
return r.json().get("displayname")
2026-01-08 05:34:03 -03:00
def get_displayname_admin(token, user_id) :
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}",
headers=headers,
timeout=30,
)
if r.status_code == 404 :
return None
r.raise_for_status()
return r.json().get("displayname")
2026-01-08 00:26:20 -03:00
def set_displayname(token, room_id, user_id, name, in_room) :
2025-12-31 12:00:12 -03:00
headers = {"Authorization": f"Bearer {token}"}
payload = {"displayname": name}
2026-01-08 00:26:20 -03:00
r = requests.put(
f"{BASE}/_matrix/client/v3/profile/{urllib.parse.quote(user_id)}/displayname",
headers=headers,
json=payload,
)
2025-12-31 12:00:12 -03:00
r.raise_for_status()
2026-01-08 00:26:20 -03:00
if not in_room :
return
2026-01-01 16:29:11 -03:00
state_url = f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/m.room.member/{urllib.parse.quote(user_id)}"
2026-01-08 00:26:20 -03:00
content = {"membership": "join", "displayname": name}
requests.put(state_url, headers=headers, json=content, timeout=30)
2025-12-31 12:00:12 -03:00
2026-01-08 05:34:03 -03:00
def set_displayname_admin(token, user_id, name) :
headers = {"Authorization": f"Bearer {token}"}
payload = {"displayname": name}
r = requests.put(
f"{BASE}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}",
headers=headers,
json=payload,
timeout=30,
)
if r.status_code in (200, 201, 204) :
return True
return False
def needs_rename_username(username) :
return username.isdigit() or username.startswith("guest-")
def needs_rename_display(display) :
return not display or display.isdigit() or display.startswith("guest-")
2026-01-08 11:59:51 -03:00
def db_rename_numeric(existing_names) :
user_ids = []
profiles = {}
conn = psycopg2.connect(
host=os.environ["PGHOST"],
port=int(os.environ["PGPORT"]),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
)
try :
with conn :
with conn.cursor() as cur :
cur.execute(
"SELECT name FROM users WHERE name ~ %s" ,
2026-01-08 12:03:53 -03:00
(f"^@\\d+:{SERVER_NAME}$",),
2026-01-08 11:59:51 -03:00
)
user_ids = [row[0] for row in cur.fetchall()]
if not user_ids :
return
cur.execute(
2026-01-08 12:03:53 -03:00
"SELECT user_id, displayname FROM profiles WHERE user_id = ANY(%s)" ,
2026-01-08 11:59:51 -03:00
(user_ids,),
)
profiles = {row[0]: row[1] for row in cur.fetchall()}
for user_id in user_ids :
display = profiles.get(user_id)
if display and not needs_rename_display(display) :
continue
new = None
for _ in range(30) :
2026-01-08 12:03:53 -03:00
candidate = f"{random.choice(ADJ)}-{random.choice(NOUN)}"
2026-01-08 11:59:51 -03:00
if candidate not in existing_names :
new = candidate
existing_names.add(candidate)
break
if not new :
continue
cur.execute(
2026-01-08 12:07:46 -03:00
"INSERT INTO profiles (user_id, displayname, full_user_id) VALUES (%s, %s, %s) ON CONFLICT (user_id) DO UPDATE SET displayname = EXCLUDED.displayname" ,
(user_id, new, user_id),
2026-01-08 11:59:51 -03:00
)
finally :
conn.close()
2026-01-08 00:13:40 -03:00
admin_token = mas_admin_token()
seeder_id = mas_user_id(admin_token, SEEDER_USER)
2026-01-08 00:26:20 -03:00
seeder_token, seeder_session = mas_personal_session(admin_token, seeder_id)
2026-01-08 00:13:40 -03:00
try :
2026-01-08 00:26:20 -03:00
room_id = resolve_alias(seeder_token, ROOM_ALIAS)
members, existing = room_members(seeder_token, room_id)
2026-01-08 05:47:21 -03:00
users = mas_list_users(admin_token)
mas_usernames = set()
for user in users :
attrs = user.get("attributes") or {}
username = attrs.get("username") or ""
if username :
mas_usernames.add(username)
legacy_guest = attrs.get("legacy_guest")
if not username :
continue
if not (legacy_guest or needs_rename_username(username)) :
continue
user_id = user_id_for_username(username)
access_token, session_id = mas_personal_session(admin_token, user["id"])
try :
display = get_displayname(access_token, user_id)
if display and not needs_rename_display(display) :
continue
new = None
for _ in range(30) :
candidate = f"{random.choice(ADJ)}-{random.choice(NOUN)}"
if candidate not in existing :
new = candidate
existing.add(candidate)
break
if not new :
continue
set_displayname(access_token, room_id, user_id, new, user_id in members)
finally :
mas_revoke_session(admin_token, session_id)
2026-01-08 00:26:20 -03:00
2026-01-08 06:14:32 -03:00
try :
entries = synapse_list_users(seeder_token)
except Exception as exc: # noqa : BLE001
print(f"synapse admin list skipped: {exc}")
entries = []
for entry in entries :
2026-01-08 05:47:21 -03:00
user_id = entry.get("name") or ""
if not user_id.startswith("@"):
continue
localpart = user_id.split(":", 1)[0].lstrip("@")
if localpart in mas_usernames :
continue
is_guest = entry.get("is_guest")
if not (is_guest or needs_rename_username(localpart)) :
continue
display = get_displayname_admin(seeder_token, user_id)
2026-01-08 05:34:03 -03:00
if display and not needs_rename_display(display) :
2026-01-08 00:26:20 -03:00
continue
2026-01-08 00:13:40 -03:00
new = None
for _ in range(30) :
2026-01-08 00:15:41 -03:00
candidate = f"{random.choice(ADJ)}-{random.choice(NOUN)}"
2026-01-08 00:13:40 -03:00
if candidate not in existing :
new = candidate
existing.add(candidate)
break
if not new :
continue
2026-01-08 05:47:21 -03:00
if not set_displayname_admin(seeder_token, user_id, new) :
continue
2026-01-08 11:59:51 -03:00
db_rename_numeric(existing)
2026-01-08 05:47:21 -03:00
finally :
mas_revoke_session(admin_token, seeder_session)
2025-12-31 12:00:12 -03:00
PY