From d6c315d3536d2993e5d992cd1662acf7ead7df29 Mon Sep 17 00:00:00 2001 From: Semprini Date: Sun, 21 Jun 2026 18:24:11 +1200 Subject: [PATCH] Provision Matrix bot via shared-secret admin API (OIDC-only homeserver) The homeserver authenticates humans via Keycloak OIDC and has password login disabled, so register-matrix-bot.sh's password-login flow could never obtain a token. Rewrite it to create the bot and mint a standalone access token through Synapse's shared-secret admin API (/_synapse/admin/v1/register), which works with registration + password login turned off. - Use the claude-code identity (@claude-code:semprini.me) as the bot. - Avoid the admin "login as user" API: it returns a puppet token that Synapse revokes when the issuing admin is deactivated. - Make re-runs idempotent: reuse a still-valid token/room; only recreate the account when no valid token is on hand. - Read the registration shared secret from the running synapse container. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.example.json | 2 +- register-matrix-bot.sh | 196 +++++++++++++++++++++++++++-------------- 2 files changed, 129 insertions(+), 69 deletions(-) diff --git a/config.example.json b/config.example.json index a6ad8e7..a26c7f0 100644 --- a/config.example.json +++ b/config.example.json @@ -22,7 +22,7 @@ "matrix": { "homeserver": "https://matrix.semprini.me", - "user_id": "@maintainer:semprini.me", + "user_id": "@claude-code:semprini.me", "access_token": "FILLED_BY_register-matrix-bot.sh", "room_id": "FILLED_BY_register-matrix-bot.sh", "admin_user_id": "@paul:semprini.me" diff --git a/register-matrix-bot.sh b/register-matrix-bot.sh index 0199e2d..1a1b169 100755 --- a/register-matrix-bot.sh +++ b/register-matrix-bot.sh @@ -2,11 +2,24 @@ # register-matrix-bot.sh — provision the Matrix bot account the maintenance # agent uses to talk to the operator, and a private room shared with the admin. # -# Idempotent-ish: re-running re-uses the existing account (login still works if -# the password is unchanged) and creates a fresh room only if one isn't set. +# This homeserver authenticates humans via Keycloak OIDC and has password login +# DISABLED (synapse `password_config.enabled: false`). Bot/agent accounts +# therefore cannot use the password-login flow — they are created and given an +# access token via Synapse's shared-secret admin API +# (/_synapse/admin/v1/register), which works even with registration + password +# login turned off and returns a standalone access token at creation time. +# +# (Note: the admin "login as user" API instead returns a *puppet* token that +# Synapse revokes the moment the issuing admin is deactivated, so we don't use +# it — we capture the standalone token from the bot's own register call.) +# +# Idempotent: re-running re-uses the existing access token in config.json when it +# still validates, and the existing room. If the account exists but no valid +# token is on hand (e.g. fresh checkout), it re-creates the account to mint a +# fresh standalone token. # # Requirements: run on the core-stack host, with the `user-synapse` container -# running. Uses the registration shared secret already in the synapse config. +# running (the registration shared secret is read from it). # # Usage: # ./register-matrix-bot.sh # uses config.json next to this script @@ -15,93 +28,140 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="${CONFIG:-$DIR/config.json}" -SECRETS="$DIR/.bot-secrets" # gitignored: stores the bot password +SECRETS="$DIR/.bot-secrets" # gitignored: records which bot account was provisioned +SYNAPSE_CONTAINER="${SYNAPSE_CONTAINER:-user-synapse}" if [ ! -f "$CONFIG" ]; then echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first." exit 1 fi -jq_get() { python3 -c "import json,sys; print(json.load(open('$CONFIG'))$1)"; } +cfg_get() { python3 -c "import json,sys; print(json.load(open('$CONFIG'))$1)"; } -HOMESERVER="$(jq_get "['matrix']['homeserver']")" -BOT_USER_ID="$(jq_get "['matrix']['user_id']")" -ADMIN_USER_ID="$(jq_get "['matrix']['admin_user_id']")" -SYNAPSE_CONTAINER="${SYNAPSE_CONTAINER:-user-synapse}" +HOMESERVER="$(cfg_get "['matrix']['homeserver']")" +BOT_USER_ID="$(cfg_get "['matrix']['user_id']")" +ADMIN_USER_ID="$(cfg_get "['matrix']['admin_user_id']")" +ROOM_ID="$(cfg_get "['matrix'].get('room_id','')" 2>/dev/null || echo "")" -# localpart from @maintainer:semprini.me -> maintainer +# localpart from @claude-code:semprini.me -> claude-code BOT_LOCAL="${BOT_USER_ID#@}"; BOT_LOCAL="${BOT_LOCAL%%:*}" echo "→ Homeserver : $HOMESERVER" echo "→ Bot : $BOT_USER_ID" echo "→ Admin : $ADMIN_USER_ID" -# 1. Password — reuse if we created one before, else generate and persist. -if [ -f "$SECRETS" ]; then - # shellcheck disable=SC1090 - source "$SECRETS" -fi -if [ -z "${BOT_PASSWORD:-}" ]; then - BOT_PASSWORD="$(openssl rand -hex 32)" - umask 077; echo "BOT_PASSWORD=$BOT_PASSWORD" > "$SECRETS" - echo "→ Generated new bot password (saved to $SECRETS)" -fi +# Read the registration shared secret straight out of the running synapse +# container so we don't depend on a host path to homeserver.yaml / .env. +echo "→ Reading registration shared secret from $SYNAPSE_CONTAINER…" +SHARED_SECRET="$(docker exec "$SYNAPSE_CONTAINER" \ + sh -c "grep -E '^[[:space:]]*registration_shared_secret:' /data/homeserver.yaml" \ + | sed -E 's/^[^:]*:[[:space:]]*//; s/^"//; s/"[[:space:]]*$//')" +[ -n "$SHARED_SECRET" ] || { echo "ERROR: could not read registration_shared_secret"; exit 1; } -# 2. Create the account (no-op if it already exists). -echo "→ Registering account in $SYNAPSE_CONTAINER…" -if docker exec "$SYNAPSE_CONTAINER" register_new_matrix_user \ - -u "$BOT_LOCAL" -p "$BOT_PASSWORD" --no-admin \ - -c /data/homeserver.yaml http://localhost:8008 2>&1 | tee /tmp/reg.out; then - : -fi -grep -qiE "already taken|User ID already" /tmp/reg.out && \ - echo " (account already exists — continuing)" +# All the Matrix work happens in one Python block (stdlib only): ensure the bot +# has a valid standalone access token, ensure the room, and write +# access_token + room_id back into config.json. +python3 - "$CONFIG" "$HOMESERVER" "$SHARED_SECRET" "$BOT_LOCAL" "$BOT_USER_ID" "$ADMIN_USER_ID" "$ROOM_ID" <<'PY' +import hmac, hashlib, json, secrets, sys, urllib.request, urllib.error -# 3. Log in to obtain an access token. -echo "→ Logging in…" -LOGIN_JSON="$(curl -fsS -X POST "$HOMESERVER/_matrix/client/v3/login" \ - -H 'Content-Type: application/json' \ - -d "{\"type\":\"m.login.password\", - \"identifier\":{\"type\":\"m.id.user\",\"user\":\"$BOT_LOCAL\"}, - \"password\":\"$BOT_PASSWORD\", - \"initial_device_display_name\":\"semprini-maintainer\"}")" -ACCESS_TOKEN="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['access_token'])" "$LOGIN_JSON")" -[ -n "$ACCESS_TOKEN" ] || { echo "ERROR: no access token"; exit 1; } -echo " got access token" +config, hs, shared, bot_local, bot_id, admin_id, room_id = sys.argv[1:8] +hs = hs.rstrip("/") +domain = bot_id.split(":", 1)[1] +REG = hs + "/_synapse/admin/v1/register" -# 4. Ensure a private room exists with the admin invited. -ROOM_ID="$(jq_get "['matrix'].get('room_id','')" 2>/dev/null || echo "")" -case "$ROOM_ID" in - ""|FILLED_BY_*|!*) : ;; -esac -if [[ "$ROOM_ID" != \!* ]]; then - echo "→ Creating maintenance room and inviting $ADMIN_USER_ID…" - ROOM_JSON="$(curl -fsS -X POST "$HOMESERVER/_matrix/client/v3/createRoom" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H 'Content-Type: application/json' \ - -d "{\"name\":\"Stack Maintenance\", - \"topic\":\"semprini-core autonomous stack maintenance\", - \"preset\":\"trusted_private_chat\", - \"is_direct\":true, - \"invite\":[\"$ADMIN_USER_ID\"]}")" - ROOM_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['room_id'])" "$ROOM_JSON")" - echo " room: $ROOM_ID" -else - echo "→ Re-using existing room $ROOM_ID" -fi +def api(url, data=None, token=None, method=None): + m = method or ("POST" if data is not None else "GET") + r = urllib.request.Request( + url, data=(json.dumps(data).encode() if data is not None else None), method=m) + r.add_header("Content-Type", "application/json") + if token: + r.add_header("Authorization", "Bearer " + token) + try: + with urllib.request.urlopen(r, timeout=60) as resp: + return resp.status, (json.loads(resp.read() or b"{}")) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read()) + except Exception: + return e.code, {} + +def shared_secret_register(username, admin): + """Create a user via the shared-secret admin API; returns (code, body). + + On success the body carries a standalone access_token for the new user.""" + nonce = api(REG)[1]["nonce"] + pw = secrets.token_hex(32) + mac = hmac.new(shared.encode(), digestmod=hashlib.sha1) + for part in (nonce, username, pw): + mac.update(part.encode()); mac.update(b"\x00") + mac.update(b"admin" if admin else b"notadmin") + return api(REG, {"nonce": nonce, "username": username, "password": pw, + "admin": admin, "mac": mac.hexdigest()}) + +def token_valid(token): + if not token or token.startswith("FILLED_BY_"): + return False + code, body = api(hs + "/_matrix/client/v3/account/whoami", token=token) + return code == 200 and body.get("user_id") == bot_id + +# 1. Obtain a valid standalone access token for the bot. +existing = json.load(open(config)).get("matrix", {}).get("access_token", "") +code, body = shared_secret_register(bot_local, admin=False) +if code == 200: + bot_token = body["access_token"] + print(f"→ Created bot account {bot_id} (new access token)") +elif token_valid(existing): + bot_token = existing + print(f"→ Bot account {bot_id} exists; reusing valid token from config.json") +else: + # Account exists but we have no valid token. The only password-free way to + # mint a fresh *standalone* token is to recreate the account: register a + # throwaway admin, deactivate the bot, recreate it (capturing the token), + # then deactivate the throwaway admin. + print(f"→ Bot account {bot_id} exists but no valid token — recreating it") + admin_token = shared_secret_register("maint-bootstrap-" + secrets.token_hex(4), admin=True)[1]["access_token"] + api(hs + f"/_synapse/admin/v1/deactivate/{bot_id}", {"erase": False}, token=admin_token) + c, b = shared_secret_register(bot_local, admin=False) + if c != 200: + sys.exit(f"ERROR recreating bot: {c} {b}") + bot_token = b["access_token"] + room_id = "" # the recreated account is in no rooms + # Find and deactivate the throwaway admin we just made. + for u in api(hs + "/_synapse/admin/v2/users?from=0&limit=500", token=admin_token)[1].get("users", []): + if u["name"].startswith("@maint-bootstrap-"): + api(hs + f"/_synapse/admin/v1/deactivate/{u['name']}", {"erase": True}, token=admin_token) + +# 2. Set a friendly display name. +api(hs + f"/_matrix/client/v3/profile/{bot_id}/displayname", + {"displayname": "Stack Maintainer"}, token=bot_token, method="PUT") + +# 4. Ensure a private DM room with the admin invited. +joined = api(hs + "/_matrix/client/v3/joined_rooms", token=bot_token)[1].get("joined_rooms", []) +if room_id and not room_id.startswith("FILLED_BY_") and room_id in joined: + print(f"→ Re-using existing room {room_id}") +else: + code, body = api(hs + "/_matrix/client/v3/createRoom", { + "name": "Stack Maintenance", + "topic": "semprini-core autonomous stack maintenance", + "preset": "trusted_private_chat", "is_direct": True, + "invite": [admin_id]}, token=bot_token) + room_id = body.get("room_id") + if not room_id: + sys.exit(f"ERROR creating room: {code} {body}") + print(f"→ Created room {room_id}, invited {admin_id}") # 5. Write token + room back into config.json. -python3 - "$CONFIG" "$ACCESS_TOKEN" "$ROOM_ID" <<'PY' -import json, sys -path, token, room = sys.argv[1], sys.argv[2], sys.argv[3] -cfg = json.load(open(path)) +cfg = json.load(open(config)) cfg.setdefault("matrix", {}) -cfg["matrix"]["access_token"] = token -cfg["matrix"]["room_id"] = room -json.dump(cfg, open(path, "w"), indent=2) -print(f" wrote access_token + room_id to {path}") +cfg["matrix"]["access_token"] = bot_token +cfg["matrix"]["room_id"] = room_id +json.dump(cfg, open(config, "w"), indent=2) +print(f"→ Wrote access_token + room_id to {config}") PY +# Record which account was provisioned (token lives only in config.json). +umask 077; printf 'BOT_USER=%s\n' "$BOT_USER_ID" > "$SECRETS" + echo echo "✓ Done." echo " ACTION REQUIRED (one-time): in Element (chat.semprini.me) as"