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>
305 lines
12 KiB
Bash
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."
|
|
}
|