# services/comms/guest-name-job.yaml apiVersion: batch/v1 kind: CronJob metadata: name: guest-name-randomizer namespace: comms labels: atlas.bstein.dev/glue: "true" spec: schedule: "*/1 * * * *" suspend: false concurrencyPolicy: Forbid successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 1 jobTemplate: spec: backoffLimit: 0 template: metadata: annotations: vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/role: "comms" vault.hashicorp.com/agent-inject-secret-turn-secret: "kv/data/atlas/comms/turn-shared-secret" vault.hashicorp.com/agent-inject-template-turn-secret: | {{- with secret "kv/data/atlas/comms/turn-shared-secret" -}}{{ .Data.data.TURN_STATIC_AUTH_SECRET }}{{- end -}} vault.hashicorp.com/agent-inject-secret-livekit-primary: "kv/data/atlas/comms/livekit-api" vault.hashicorp.com/agent-inject-template-livekit-primary: | {{- with secret "kv/data/atlas/comms/livekit-api" -}}{{ .Data.data.primary }}{{- end -}} vault.hashicorp.com/agent-inject-secret-bot-pass: "kv/data/atlas/comms/atlasbot-credentials-runtime" vault.hashicorp.com/agent-inject-template-bot-pass: | {{- with secret "kv/data/atlas/comms/atlasbot-credentials-runtime" -}}{{ index .Data.data "bot-password" }}{{- end -}} vault.hashicorp.com/agent-inject-secret-seeder-pass: "kv/data/atlas/comms/atlasbot-credentials-runtime" vault.hashicorp.com/agent-inject-template-seeder-pass: | {{- with secret "kv/data/atlas/comms/atlasbot-credentials-runtime" -}}{{ index .Data.data "seeder-password" }}{{- end -}} vault.hashicorp.com/agent-inject-secret-chat-matrix: "kv/data/atlas/shared/chat-ai-keys-runtime" vault.hashicorp.com/agent-inject-template-chat-matrix: | {{- with secret "kv/data/atlas/shared/chat-ai-keys-runtime" -}}{{ .Data.data.matrix }}{{- end -}} vault.hashicorp.com/agent-inject-secret-chat-homepage: "kv/data/atlas/shared/chat-ai-keys-runtime" vault.hashicorp.com/agent-inject-template-chat-homepage: | {{- with secret "kv/data/atlas/shared/chat-ai-keys-runtime" -}}{{ .Data.data.homepage }}{{- end -}} vault.hashicorp.com/agent-inject-secret-mas-admin-secret: "kv/data/atlas/comms/mas-admin-client-runtime" vault.hashicorp.com/agent-inject-template-mas-admin-secret: | {{- with secret "kv/data/atlas/comms/mas-admin-client-runtime" -}}{{ .Data.data.client_secret }}{{- end -}} vault.hashicorp.com/agent-inject-secret-synapse-db-pass: "kv/data/atlas/comms/synapse-db" vault.hashicorp.com/agent-inject-template-synapse-db-pass: | {{- with secret "kv/data/atlas/comms/synapse-db" -}}{{ .Data.data.POSTGRES_PASSWORD }}{{- end -}} vault.hashicorp.com/agent-inject-secret-mas-db-pass: "kv/data/atlas/comms/mas-db" vault.hashicorp.com/agent-inject-template-mas-db-pass: | {{- with secret "kv/data/atlas/comms/mas-db" -}}{{ .Data.data.password }}{{- end -}} vault.hashicorp.com/agent-inject-secret-mas-matrix-shared: "kv/data/atlas/comms/mas-secrets-runtime" vault.hashicorp.com/agent-inject-template-mas-matrix-shared: | {{- with secret "kv/data/atlas/comms/mas-secrets-runtime" -}}{{ .Data.data.matrix_shared_secret }}{{- end -}} vault.hashicorp.com/agent-inject-secret-mas-kc-secret: "kv/data/atlas/comms/mas-secrets-runtime" vault.hashicorp.com/agent-inject-template-mas-kc-secret: | {{- with secret "kv/data/atlas/comms/mas-secrets-runtime" -}}{{ .Data.data.keycloak_client_secret }}{{- end -}} spec: restartPolicy: Never serviceAccountName: comms-vault nodeSelector: hardware: rpi5 volumes: - name: vault-scripts configMap: name: comms-vault-env defaultMode: 0555 containers: - name: rename image: registry.bstein.dev/bstein/comms-guest-tools:0.1.0 volumeMounts: - name: vault-scripts mountPath: /vault/scripts readOnly: true env: - name: SYNAPSE_BASE value: http://othrys-synapse-matrix-synapse:8008 - name: MAS_ADMIN_CLIENT_ID value: 01KDXMVQBQ5JNY6SEJPZW6Z8BM - name: MAS_ADMIN_CLIENT_SECRET_FILE value: /vault/secrets/mas-admin-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 - name: SEEDER_USER value: othrys-seeder - name: PGHOST value: postgres-service.postgres.svc.cluster.local - name: PGPORT value: "5432" - name: PGDATABASE value: synapse - name: PGUSER value: synapse command: - /bin/sh - -c - | set -euo pipefail . /vault/scripts/comms_vault_env.sh python - <<'PY' import base64 import os import random import requests import time import urllib.parse import psycopg2 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", ] BASE = os.environ["SYNAPSE_BASE"] 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"] ROOM_ALIAS = "#othrys:live.bstein.dev" SERVER_NAME = "live.bstein.dev" STALE_GUEST_MS = 14 * 24 * 60 * 60 * 1000 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() 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 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, ) 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"] def room_members(token, room_id): headers = {"Authorization": f"Bearer {token}"} r = requests.get(f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/members", headers=headers) r.raise_for_status() members = set() existing_names = set() 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 while True: 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: break return users 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 def should_prune_guest(entry, now_ms): if not entry.get("is_guest"): return False last_seen = entry.get("last_seen_ts") if last_seen is None: return False try: last_seen = int(last_seen) except (TypeError, ValueError): return False return now_ms - last_seen > STALE_GUEST_MS def prune_guest(token, user_id): headers = {"Authorization": f"Bearer {token}"} try: r = requests.delete( f"{BASE}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}", headers=headers, params={"erase": "true"}, timeout=30, ) except Exception as exc: # noqa: BLE001 print(f"guest prune failed for {user_id}: {exc}") return False if r.status_code in (200, 202, 204, 404): return True print(f"guest prune failed for {user_id}: {r.status_code} {r.text}") return False 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") 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") def set_displayname(token, room_id, user_id, name, in_room): headers = {"Authorization": f"Bearer {token}"} payload = {"displayname": name} r = requests.put( f"{BASE}/_matrix/client/v3/profile/{urllib.parse.quote(user_id)}/displayname", headers=headers, json=payload, ) r.raise_for_status() if not in_room: return state_url = f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/m.room.member/{urllib.parse.quote(user_id)}" content = {"membership": "join", "displayname": name} requests.put(state_url, headers=headers, json=content, timeout=30) 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-") def db_rename_numeric(existing_names): profile_rows = [] profile_index = {} users = [] 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 user_id, full_user_id, displayname FROM profiles WHERE full_user_id ~ %s", (f"^@\\d+:{SERVER_NAME}$",), ) profile_rows = cur.fetchall() profile_index = {row[1]: row for row in profile_rows} for user_id, full_user_id, display in profile_rows: 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_names: new = candidate existing_names.add(candidate) break if not new: continue cur.execute( "UPDATE profiles SET displayname = %s WHERE full_user_id = %s", (new, full_user_id), ) cur.execute( "SELECT name FROM users WHERE name ~ %s", (f"^@\\d+:{SERVER_NAME}$",), ) users = [row[0] for row in cur.fetchall()] if not users: return cur.execute( "SELECT user_id, full_user_id FROM profiles WHERE full_user_id = ANY(%s)", (users,), ) for existing_full in cur.fetchall(): profile_index.setdefault(existing_full[1], existing_full) for full_user_id in users: if full_user_id in profile_index: continue localpart = full_user_id.split(":", 1)[0].lstrip("@") new = None for _ in range(30): candidate = f"{random.choice(ADJ)}-{random.choice(NOUN)}" if candidate not in existing_names: new = candidate existing_names.add(candidate) break if not new: continue cur.execute( "INSERT INTO profiles (user_id, displayname, full_user_id) VALUES (%s, %s, %s) " "ON CONFLICT (full_user_id) DO UPDATE SET displayname = EXCLUDED.displayname", (localpart, new, full_user_id), ) finally: conn.close() admin_token = mas_admin_token() seeder_id = mas_user_id(admin_token, SEEDER_USER) seeder_token, seeder_session = mas_personal_session(admin_token, seeder_id) try: room_id = resolve_alias(seeder_token, ROOM_ALIAS) members, existing = room_members(seeder_token, room_id) 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) try: entries = synapse_list_users(seeder_token) except Exception as exc: # noqa: BLE001 print(f"synapse admin list skipped: {exc}") entries = [] now_ms = int(time.time() * 1000) for entry in entries: 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 is_guest and should_prune_guest(entry, now_ms): if prune_guest(seeder_token, user_id): continue if not (is_guest or needs_rename_username(localpart)): continue display = get_displayname_admin(seeder_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 if not set_displayname_admin(seeder_token, user_id, new): continue db_rename_numeric(existing) finally: mas_revoke_session(admin_token, seeder_session) PY