#!/bin/bash # Matrix user management, via Synapse's admin API. # # Everything goes through `docker exec matrix-synapse python`: the image is # debian-slim with no curl or wget, but python is what Synapse itself runs on, # so it is always present. Talking to localhost:8008 inside the container also # means these tools behave identically whether the install is LAN-only or behind # Traefik, and never depend on the published port. # # Two Matrix facts shape what can be offered here: # - A user ID is permanent. There is no rename; "changing a username" means # creating a new account. # - There is no true delete. Deactivation is the terminal state — it revokes # access, devices and profile, but the ID stays burned so it can never be # re-registered and old messages still resolve. # Localpart -> full ID. Accepts either form, so a caller can pass "alice" or # "@alice:example.com" and get the same result. _matrixUserId() { local user="$1" server [[ "$user" == @* ]] && { echo "$user"; return 0; } server=$(_matrixServerName) [[ -z "$server" ]] && return 1 echo "@${user}:${server}" } # Where the cached admin access token lives. Beside homeserver.yaml, which # already holds the registration shared secret and the database password, so # this adds no new class of secret to the install. _matrix_token_cache="data/.lp-admin-token" # Run a python snippet inside the Synapse container with an admin access token # already in scope and a call(method, path, body) helper available. # # Args: [extra docker -e flags...] # # Values are handed over as environment variables rather than interpolated into # the snippet, so a password containing quotes or backslashes cannot break out # into the python source. # # The token is CACHED between invocations. Logging in each time seemed tidier, # but Synapse rate-limits /login (rc_login defaults to a burst of 5), so running # a few tools in succession failed with "Too Many Requests" — the tools were # throttling themselves. One login, reused until it stops working, and a single # re-login on 401 if the token was revoked or the password changed. _matrixApi() { local script="$1"; shift local admin_user="${CFG_MATRIX_ADMIN_USERNAME:-admin}" local admin_pass="${CFG_MATRIX_ADMIN_PASSWORD_1}" local cache="${containers_dir}matrix/${_matrix_token_cache}" if [[ -z "$admin_pass" || "$admin_pass" == RANDOMIZEDPASSWORD* ]]; then isError "No Matrix admin password in matrix.config — cannot authenticate to the admin API." return 1 fi local cached="" [[ -s "$cache" ]] && cached=$(runFileOp cat "$cache" 2>/dev/null) local raw raw=$(runFileOp docker exec -i "$@" \ -e LP_ADMIN_USER="$admin_user" \ -e LP_ADMIN_PASS="$admin_pass" \ -e LP_TOKEN="$cached" \ matrix-synapse python - <&1) _matrixApiFailed "$out" "Creating $uid" && return 1 [[ "$out" == *LP_EXISTS* ]] && { isError "$uid already exists."; return 1; } [[ "$out" != *LP_OK* ]] && { isError "Creating $uid failed: $out"; return 1; } isSuccessful "Matrix user created — ID: $uid — Password: $password" } authAdapter_matrix_setPassword() { local user="$1" password="$2" [[ -z "$user" ]] && { isError "A username is required."; return 1; } [[ -z "$password" ]] && password=$(generateRandomPassword) local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; } local out out=$(_matrixApi " uid = os.environ['LP_UID'] if not call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True): print('LP_MISSING') else: # logout_devices invalidates every existing session, which is the whole # point of a reset — otherwise a stolen token keeps working afterwards. call('PUT', '/_synapse/admin/v2/users/' + uid, { 'password': os.environ['LP_NEWPASS'], 'logout_devices': True, }) print('LP_OK') " -e LP_UID="$uid" -e LP_NEWPASS="$password" 2>&1) _matrixApiFailed "$out" "Resetting $uid" && return 1 [[ "$out" == *LP_MISSING* ]] && { isError "No Matrix user $uid."; return 1; } [[ "$out" != *LP_OK* ]] && { isError "Resetting $uid failed: $out"; return 1; } # Keep the config in step when the admin's own password changes, or the # WebUI card and these very tools would carry on offering the old one. local admin_uid; admin_uid=$(_matrixUserId "${CFG_MATRIX_ADMIN_USERNAME:-admin}") [[ "$uid" == "$admin_uid" ]] && authPersistCfg matrix ADMIN_PASSWORD "$password" isSuccessful "Matrix password set for $uid — New password: $password — all their sessions were signed out." } authAdapter_matrix_listUsers() { local out out=$(_matrixApi " res = call('GET', '/_synapse/admin/v2/users?from=0&limit=500&deactivated=true') for u in res.get('users', []): flags = [] if u.get('admin'): flags.append('admin') if u.get('deactivated'): flags.append('deactivated') print('LP_USER\t' + u['name'] + '\t' + (u.get('displayname') or '-') + '\t' + (','.join(flags) or 'user')) print('LP_TOTAL:' + str(res.get('total', 0))) " 2>&1) _matrixApiFailed "$out" "Listing users" && return 1 local line total=0 while IFS= read -r line; do case "$line" in LP_USER*) IFS=$'\t' read -r _ uid name flags <<< "$line" printf ' %-34s %-20s %s\n' "$uid" "$name" "$flags" ;; LP_TOTAL:*) total="${line#LP_TOTAL:}" ;; esac done <<< "$out" isSuccessful "$total Matrix account(s)." } # Matrix has no delete — deactivation is as far as it goes, and it is # irreversible. Named deleteUser to match the adapter contract the other apps # use, but the message is explicit about what actually happens. authAdapter_matrix_deleteUser() { local user="$1" [[ -z "$user" ]] && { isError "A username is required."; return 1; } local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; } local admin_uid; admin_uid=$(_matrixUserId "${CFG_MATRIX_ADMIN_USERNAME:-admin}") if [[ "$uid" == "$admin_uid" ]]; then isError "Refusing to deactivate $uid — it is the admin these tools authenticate as." isNotice "Promote another account to admin and point CFG_MATRIX_ADMIN_USERNAME at it first." return 1 fi local out out=$(_matrixApi " uid = os.environ['LP_UID'] if not call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True): print('LP_MISSING') else: call('POST', '/_synapse/admin/v1/deactivate/' + uid, {'erase': True}) print('LP_OK') " -e LP_UID="$uid" 2>&1) _matrixApiFailed "$out" "Deactivating $uid" && return 1 [[ "$out" == *LP_MISSING* ]] && { isError "No Matrix user $uid."; return 1; } [[ "$out" != *LP_OK* ]] && { isError "Deactivating $uid failed: $out"; return 1; } isSuccessful "Matrix user $uid deactivated and erased. The ID is permanently taken and cannot be re-registered." } authAdapter_matrix_setAdmin() { local user="$1" isAdmin="$2" [[ -z "$user" ]] && { isError "A username is required."; return 1; } local target="false"; [[ "$isAdmin" == "true" ]] && target="true" local uid; uid=$(_matrixUserId "$user") || { isError "Could not determine the homeserver name."; return 1; } local admin_uid; admin_uid=$(_matrixUserId "${CFG_MATRIX_ADMIN_USERNAME:-admin}") if [[ "$uid" == "$admin_uid" && "$target" == "false" ]]; then isError "Refusing to demote $uid — it is the admin these tools authenticate as, and demoting it would lock them out." return 1 fi local py_bool="False"; [[ "$target" == "true" ]] && py_bool="True" local out out=$(_matrixApi " uid = os.environ['LP_UID'] if not call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True): print('LP_MISSING') else: call('PUT', '/_synapse/admin/v2/users/' + uid, {'admin': ${py_bool}}) print('LP_OK') " -e LP_UID="$uid" 2>&1) _matrixApiFailed "$out" "Changing admin status for $uid" && return 1 [[ "$out" == *LP_MISSING* ]] && { isError "No Matrix user $uid."; return 1; } [[ "$out" != *LP_OK* ]] && { isError "Changing admin status for $uid failed: $out"; return 1; } isSuccessful "Matrix user $uid admin → $target." }