librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

315 lines
13 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="$(appDir 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')
# EZ_USER<TAB>identifier<TAB>display<TAB>roles — the exact shape the WebUI's
# user-list modal parses. The first column is what a row action gets
# prefilled with, so it must be the Matrix ID, not the display name.
name = u.get('displayname') or ''
print('EZ_USER\t' + u['name'] + '\t' + name + '\t' + (','.join(flags) or 'user'))
print(' %-34s %-20s %s' % (u['name'], name or '-', ','.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
# Both the marker line (for the WebUI modal, which reads the task
# log) and the aligned line (for a human) are printed by the python
# above — pass them straight through. Re-splitting the marker here
# used to shift the columns whenever a field was empty, because TAB
# is IFS whitespace and bash collapses a run of it into one
# delimiter.
EZ_USER*|' '*) printf '%s\n' "$line" ;;
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."
}