librelad a96a3a69a1 fix(linkding): declare the admin keys its auth adapter writes
linkding_auth.sh persists ADMIN_USER and ADMIN_PASSWORD when the first admin is
created, and keeps the password in step on later resets of that account, but
linkding.config declared neither — so both writes were no-ops and the WebUI
credentials card never had anything to show. Predates the slot work; it only
became visible once authPersistCfg started warning instead of failing silently.

Added empty rather than RANDOMIZED*, because unlike bookstack or nextcloud
nothing seeds a linkding account at install — the first user is created from the
WebUI. A generated password would name an account that does not exist, and the
card would display a password that cannot log in. Unslotted for the same reason:
the slot number marks a value the installer generates, and this one is written at
runtime by the tool.

No AUTH_PROFILE key: nothing reads it (it exists only in a comment in
auth_adapter.sh), and adding an unread key is what was just cleaned up elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:24:28 +01:00

305 lines
12 KiB
Bash

#!/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: <python snippet> [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 - <<PY
import json, os, sys, time, urllib.request, urllib.error
BASE = "http://localhost:8008"
TOKEN = os.environ.get("LP_TOKEN") or ""
class Unauthorised(Exception):
pass
def _raw(method, path, body=None, token=None, quiet404=False):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method)
r.add_header("Content-Type", "application/json")
if token:
r.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(r, timeout=30) as resp:
return json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as e:
if quiet404 and e.code == 404:
return None
body_txt = e.read().decode()
try:
parsed = json.loads(body_txt)
except Exception:
parsed = {}
if e.code in (401, 403) and parsed.get("errcode") in ("M_UNKNOWN_TOKEN", "M_MISSING_TOKEN"):
raise Unauthorised()
# Synapse tells us exactly how long to wait; honour it rather than
# failing the whole tool on a transient throttle.
if e.code == 429:
wait = parsed.get("retry_after_ms", 2000) / 1000.0
time.sleep(min(wait + 0.25, 10))
return _raw(method, path, body, token, quiet404)
print("LP_ERR:" + str(parsed.get("error", body_txt)))
sys.exit(1)
except urllib.error.URLError as e:
print("LP_ERR:" + str(e))
sys.exit(1)
def _login():
global TOKEN
res = _raw("POST", "/_matrix/client/v3/login", {
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": os.environ["LP_ADMIN_USER"]},
"password": os.environ["LP_ADMIN_PASS"],
})
TOKEN = res["access_token"]
# Picked up by the caller and written to the cache file.
print("LP_NEWTOKEN:" + TOKEN)
if not TOKEN:
_login()
def call(method, path, body=None, quiet404=False):
try:
return _raw(method, path, body, TOKEN, quiet404)
except Unauthorised:
# Cached token was revoked, or the admin password changed under us.
_login()
return _raw(method, path, body, TOKEN, quiet404)
$script
PY
)
# Persist a freshly-minted token, then strip the marker so callers only ever
# see the snippet's own output.
local new_token
new_token=$(printf '%s\n' "$raw" | sed -n 's/^LP_NEWTOKEN://p' | head -1)
if [[ -n "$new_token" ]]; then
printf '%s' "$new_token" | runFileWrite "$cache"
runFileOp chmod 600 "$cache"
fi
printf '%s\n' "$raw" | grep -v '^LP_NEWTOKEN:'
return 0
}
# Shared post-processing: surface the API's own error text rather than a generic
# failure, since Synapse's messages are usually the actionable part.
_matrixApiFailed() {
local out="$1" what="$2"
if [[ "$out" == *LP_ERR:* ]]; then
isError "$what failed: ${out#*LP_ERR:}"
return 0
fi
return 1
}
authAdapter_matrix_createUser() {
local user="$1" password="$2" displayname="$3" isAdmin="$4"
[[ -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; }
[[ -z "$displayname" ]] && { displayname="${user#@}"; displayname="${displayname%%:*}"; }
local admin_flag="False"; [[ "$isAdmin" == "true" ]] && admin_flag="True"
local out
out=$(_matrixApi "
uid = os.environ['LP_UID']
if call('GET', '/_synapse/admin/v2/users/' + uid, quiet404=True):
print('LP_EXISTS')
else:
call('PUT', '/_synapse/admin/v2/users/' + uid, {
'password': os.environ['LP_NEWPASS'],
'displayname': os.environ['LP_DISPLAY'],
'admin': ${admin_flag},
})
print('LP_OK')
" -e LP_UID="$uid" -e LP_NEWPASS="$password" -e LP_DISPLAY="$displayname" 2>&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."
}