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) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"homeserver": "https://matrix.semprini.me",
|
"homeserver": "https://matrix.semprini.me",
|
||||||
"user_id": "@maintainer:semprini.me",
|
"user_id": "@claude-code:semprini.me",
|
||||||
"access_token": "FILLED_BY_register-matrix-bot.sh",
|
"access_token": "FILLED_BY_register-matrix-bot.sh",
|
||||||
"room_id": "FILLED_BY_register-matrix-bot.sh",
|
"room_id": "FILLED_BY_register-matrix-bot.sh",
|
||||||
"admin_user_id": "@paul:semprini.me"
|
"admin_user_id": "@paul:semprini.me"
|
||||||
|
|||||||
+128
-68
@@ -2,11 +2,24 @@
|
|||||||
# register-matrix-bot.sh — provision the Matrix bot account the maintenance
|
# 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.
|
# 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
|
# This homeserver authenticates humans via Keycloak OIDC and has password login
|
||||||
# the password is unchanged) and creates a fresh room only if one isn't set.
|
# 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
|
# 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:
|
# Usage:
|
||||||
# ./register-matrix-bot.sh # uses config.json next to this script
|
# ./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)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
CONFIG="${CONFIG:-$DIR/config.json}"
|
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
|
if [ ! -f "$CONFIG" ]; then
|
||||||
echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first."
|
echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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']")"
|
HOMESERVER="$(cfg_get "['matrix']['homeserver']")"
|
||||||
BOT_USER_ID="$(jq_get "['matrix']['user_id']")"
|
BOT_USER_ID="$(cfg_get "['matrix']['user_id']")"
|
||||||
ADMIN_USER_ID="$(jq_get "['matrix']['admin_user_id']")"
|
ADMIN_USER_ID="$(cfg_get "['matrix']['admin_user_id']")"
|
||||||
SYNAPSE_CONTAINER="${SYNAPSE_CONTAINER:-user-synapse}"
|
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%%:*}"
|
BOT_LOCAL="${BOT_USER_ID#@}"; BOT_LOCAL="${BOT_LOCAL%%:*}"
|
||||||
|
|
||||||
echo "→ Homeserver : $HOMESERVER"
|
echo "→ Homeserver : $HOMESERVER"
|
||||||
echo "→ Bot : $BOT_USER_ID"
|
echo "→ Bot : $BOT_USER_ID"
|
||||||
echo "→ Admin : $ADMIN_USER_ID"
|
echo "→ Admin : $ADMIN_USER_ID"
|
||||||
|
|
||||||
# 1. Password — reuse if we created one before, else generate and persist.
|
# Read the registration shared secret straight out of the running synapse
|
||||||
if [ -f "$SECRETS" ]; then
|
# container so we don't depend on a host path to homeserver.yaml / .env.
|
||||||
# shellcheck disable=SC1090
|
echo "→ Reading registration shared secret from $SYNAPSE_CONTAINER…"
|
||||||
source "$SECRETS"
|
SHARED_SECRET="$(docker exec "$SYNAPSE_CONTAINER" \
|
||||||
fi
|
sh -c "grep -E '^[[:space:]]*registration_shared_secret:' /data/homeserver.yaml" \
|
||||||
if [ -z "${BOT_PASSWORD:-}" ]; then
|
| sed -E 's/^[^:]*:[[:space:]]*//; s/^"//; s/"[[:space:]]*$//')"
|
||||||
BOT_PASSWORD="$(openssl rand -hex 32)"
|
[ -n "$SHARED_SECRET" ] || { echo "ERROR: could not read registration_shared_secret"; exit 1; }
|
||||||
umask 077; echo "BOT_PASSWORD=$BOT_PASSWORD" > "$SECRETS"
|
|
||||||
echo "→ Generated new bot password (saved to $SECRETS)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Create the account (no-op if it already exists).
|
# All the Matrix work happens in one Python block (stdlib only): ensure the bot
|
||||||
echo "→ Registering account in $SYNAPSE_CONTAINER…"
|
# has a valid standalone access token, ensure the room, and write
|
||||||
if docker exec "$SYNAPSE_CONTAINER" register_new_matrix_user \
|
# access_token + room_id back into config.json.
|
||||||
-u "$BOT_LOCAL" -p "$BOT_PASSWORD" --no-admin \
|
python3 - "$CONFIG" "$HOMESERVER" "$SHARED_SECRET" "$BOT_LOCAL" "$BOT_USER_ID" "$ADMIN_USER_ID" "$ROOM_ID" <<'PY'
|
||||||
-c /data/homeserver.yaml http://localhost:8008 2>&1 | tee /tmp/reg.out; then
|
import hmac, hashlib, json, secrets, sys, urllib.request, urllib.error
|
||||||
:
|
|
||||||
fi
|
|
||||||
grep -qiE "already taken|User ID already" /tmp/reg.out && \
|
|
||||||
echo " (account already exists — continuing)"
|
|
||||||
|
|
||||||
# 3. Log in to obtain an access token.
|
config, hs, shared, bot_local, bot_id, admin_id, room_id = sys.argv[1:8]
|
||||||
echo "→ Logging in…"
|
hs = hs.rstrip("/")
|
||||||
LOGIN_JSON="$(curl -fsS -X POST "$HOMESERVER/_matrix/client/v3/login" \
|
domain = bot_id.split(":", 1)[1]
|
||||||
-H 'Content-Type: application/json' \
|
REG = hs + "/_synapse/admin/v1/register"
|
||||||
-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"
|
|
||||||
|
|
||||||
# 4. Ensure a private room exists with the admin invited.
|
def api(url, data=None, token=None, method=None):
|
||||||
ROOM_ID="$(jq_get "['matrix'].get('room_id','')" 2>/dev/null || echo "")"
|
m = method or ("POST" if data is not None else "GET")
|
||||||
case "$ROOM_ID" in
|
r = urllib.request.Request(
|
||||||
""|FILLED_BY_*|!*) : ;;
|
url, data=(json.dumps(data).encode() if data is not None else None), method=m)
|
||||||
esac
|
r.add_header("Content-Type", "application/json")
|
||||||
if [[ "$ROOM_ID" != \!* ]]; then
|
if token:
|
||||||
echo "→ Creating maintenance room and inviting $ADMIN_USER_ID…"
|
r.add_header("Authorization", "Bearer " + token)
|
||||||
ROOM_JSON="$(curl -fsS -X POST "$HOMESERVER/_matrix/client/v3/createRoom" \
|
try:
|
||||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
with urllib.request.urlopen(r, timeout=60) as resp:
|
||||||
-H 'Content-Type: application/json' \
|
return resp.status, (json.loads(resp.read() or b"{}"))
|
||||||
-d "{\"name\":\"Stack Maintenance\",
|
except urllib.error.HTTPError as e:
|
||||||
\"topic\":\"semprini-core autonomous stack maintenance\",
|
try:
|
||||||
\"preset\":\"trusted_private_chat\",
|
return e.code, json.loads(e.read())
|
||||||
\"is_direct\":true,
|
except Exception:
|
||||||
\"invite\":[\"$ADMIN_USER_ID\"]}")"
|
return e.code, {}
|
||||||
ROOM_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['room_id'])" "$ROOM_JSON")"
|
|
||||||
echo " room: $ROOM_ID"
|
def shared_secret_register(username, admin):
|
||||||
else
|
"""Create a user via the shared-secret admin API; returns (code, body).
|
||||||
echo "→ Re-using existing room $ROOM_ID"
|
|
||||||
fi
|
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.
|
# 5. Write token + room back into config.json.
|
||||||
python3 - "$CONFIG" "$ACCESS_TOKEN" "$ROOM_ID" <<'PY'
|
cfg = json.load(open(config))
|
||||||
import json, sys
|
|
||||||
path, token, room = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
||||||
cfg = json.load(open(path))
|
|
||||||
cfg.setdefault("matrix", {})
|
cfg.setdefault("matrix", {})
|
||||||
cfg["matrix"]["access_token"] = token
|
cfg["matrix"]["access_token"] = bot_token
|
||||||
cfg["matrix"]["room_id"] = room
|
cfg["matrix"]["room_id"] = room_id
|
||||||
json.dump(cfg, open(path, "w"), indent=2)
|
json.dump(cfg, open(config, "w"), indent=2)
|
||||||
print(f" wrote access_token + room_id to {path}")
|
print(f"→ Wrote access_token + room_id to {config}")
|
||||||
PY
|
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
|
||||||
echo "✓ Done."
|
echo "✓ Done."
|
||||||
echo " ACTION REQUIRED (one-time): in Element (chat.semprini.me) as"
|
echo " ACTION REQUIRED (one-time): in Element (chat.semprini.me) as"
|
||||||
|
|||||||
Reference in New Issue
Block a user