librelad 5835fa09d7 fix(auth): only generate an admin password where something creates the account
Answers "should we stop creating an admin/pass on start" with the split the
catalog actually has, rather than one way for everything.

Ten apps need it: adguard, authelia, bookstack, matrix, nextcloud, owncloud,
pihole, rocketchat, stalwart, speedtest and headscale either pass the generated
password into the container or hand it to an install hook that creates the
account. There the password IS the working credential — dropping it would lock
you out. Left alone.

Three do not create an account at all: gitea, invidious and mattermost seed no
user (the first one comes from their own signup flow or the Create Account tool),
so the password minted at install named nothing. The WebUI credentials card
showed a password that could not log in. They now match linkding — an empty,
unslotted ADMIN_PASSWORD the auth adapter fills when the operator makes the first
admin, and keeps in step on later resets. Unslotted because the slot number marks
a value the installer generates.

mattermost's adapter also had linkding's bug: it persists ADMIN_PASSWORD but the
config declared only ADMIN_EMAIL, so the write was a no-op.

WebUI: rocketchat's generated admin password had no field mapping, so the card
could not show it. Added, plus a generic ADMIN_USER entry — six apps record an
admin username the card had no way to display.

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

221 lines
9.3 KiB
Bash

#!/bin/bash
# Rocket.Chat user management, via its REST API.
#
# Called with curl from the host rather than from inside the container: the
# Rocket.Chat image ships node but no curl or wget, and shelling out to node
# just to make an HTTP request would mean embedding JavaScript in bash for no
# benefit. The base URL is read from ROOT_URL in the deployed compose, which the
# APP_URL tag has already resolved to whatever this install actually serves on —
# https://host behind Traefik, http://ip:port on a LAN-only box.
#
# Authentication uses the admin seeded at install (CFG_ROCKETCHAT_ADMIN_*).
# Rocket.Chat has no local/socket admin path like Mattermost's, so there is no
# way round needing a real account here.
_rocketchatBaseUrl() {
local compose="${containers_dir}rocketchat/docker-compose.yml"
local url
url=$(runFileOp grep -oP '^\s*-\s*ROOT_URL=\K\S+' "$compose" 2>/dev/null | head -1)
url="${url%%#*}"
printf '%s' "${url%/}"
}
# Echoes "<userId> <authToken>" on success. Both are needed: Rocket.Chat wants
# them as separate X-User-Id / X-Auth-Token headers on every subsequent call.
_rocketchatLogin() {
local base="$1"
local user="${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}"
local pass="${CFG_ROCKETCHAT_ADMIN_PASSWORD_1}"
if [[ -z "$pass" || "$pass" == RANDOMIZEDPASSWORD* ]]; then
isError "No Rocket.Chat admin password in rocketchat.config — cannot authenticate."
return 1
fi
local res
res=$(curl -sS -m 20 -X POST "$base/api/v1/login" \
-H 'Content-Type: application/json' \
--data-binary "$(printf '{"user":%s,"password":%s}' \
"$(_rcJson "$user")" "$(_rcJson "$pass")")" 2>&1)
local id token
id=$(printf '%s' "$res" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('userId',''))" 2>/dev/null)
token=$(printf '%s' "$res" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('authToken',''))" 2>/dev/null)
if [[ -z "$id" || -z "$token" ]]; then
isError "Rocket.Chat admin login failed. Check CFG_ROCKETCHAT_ADMIN_USERNAME / _PASSWORD_1."
return 1
fi
printf '%s %s' "$id" "$token"
}
# JSON-encode a bash string so a password containing quotes or backslashes
# cannot break the request body.
_rcJson() {
printf '%s' "$1" | python3 -c "import sys,json;print(json.dumps(sys.stdin.read()))"
}
# _rocketchatApi <METHOD> <path> [json body]
_rocketchatApi() {
local method="$1" path="$2" body="$3"
local base; base=$(_rocketchatBaseUrl)
[[ -z "$base" ]] && { isError "Could not read Rocket.Chat's ROOT_URL from its compose file."; return 1; }
local creds; creds=$(_rocketchatLogin "$base") || return 1
local id="${creds%% *}" token="${creds##* }"
if [[ -n "$body" ]]; then
curl -sS -m 30 -X "$method" "$base$path" \
-H "X-User-Id: $id" -H "X-Auth-Token: $token" \
-H 'Content-Type: application/json' --data-binary "$body"
else
curl -sS -m 30 -X "$method" "$base$path" \
-H "X-User-Id: $id" -H "X-Auth-Token: $token"
fi
}
# Rocket.Chat answers 200 with {"success":false,"error":"..."} rather than an
# HTTP error, so success has to be read out of the body.
_rocketchatOk() {
printf '%s' "$1" | python3 -c "import sys,json
try: print('yes' if json.load(sys.stdin).get('success') else 'no')
except Exception: print('no')" 2>/dev/null
}
_rocketchatError() {
printf '%s' "$1" | python3 -c "import sys,json
try:
d=json.load(sys.stdin); print(d.get('error') or d.get('message') or 'unknown error')
except Exception: print('could not parse the API response')" 2>/dev/null
}
authAdapter_rocketchat_createUser() {
local email="$1" password="$2" username="$3" isAdmin="$4"
[[ -z "$email" ]] && { isError "An email address is required."; return 1; }
[[ -z "$username" ]] && username="${email%@*}"
[[ -z "$password" ]] && password=$(generateRandomPassword)
local roles='["user"]'
[[ "$isAdmin" == "true" ]] && roles='["admin","user"]'
local body out
body=$(printf '{"email":%s,"name":%s,"password":%s,"username":%s,"roles":%s,"joinDefaultChannels":true,"requirePasswordChange":false,"verified":true}' \
"$(_rcJson "$email")" "$(_rcJson "$username")" "$(_rcJson "$password")" "$(_rcJson "$username")" "$roles")
out=$(_rocketchatApi POST /api/v1/users.create "$body") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Creating $email failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user created — Email: $email — Username: $username — Password: $password"
}
# Resolve a username or email to Rocket.Chat's internal user id, which is what
# every mutating endpoint wants.
_rocketchatUserId() {
local who="$1" field="username"
[[ "$who" == *@* ]] && field="email"
local out
out=$(_rocketchatApi GET "/api/v1/users.info?${field}=${who}") || return 1
printf '%s' "$out" | python3 -c "import sys,json
try: print(json.load(sys.stdin).get('user',{}).get('_id',''))
except Exception: print('')" 2>/dev/null
}
authAdapter_rocketchat_setPassword() {
local who="$1" password="$2"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
[[ -z "$password" ]] && password=$(generateRandomPassword)
local uid; uid=$(_rocketchatUserId "$who") || return 1
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local body out
body=$(printf '{"userId":%s,"data":{"password":%s,"requirePasswordChange":false}}' \
"$(_rcJson "$uid")" "$(_rcJson "$password")")
out=$(_rocketchatApi POST /api/v1/users.update "$body") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Resetting $who failed: $(_rocketchatError "$out")"; return 1; }
# If this is the account the tools authenticate as, the config has to follow
# or every later tool call fails to log in.
if [[ "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ]]; then
authPersistCfg rocketchat ADMIN_PASSWORD "$password"
fi
isSuccessful "Rocket.Chat password set for $who — New password: $password"
}
authAdapter_rocketchat_listUsers() {
local out
out=$(_rocketchatApi GET '/api/v1/users.list?count=500') || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Listing users failed: $(_rocketchatError "$out")"; return 1; }
local rendered
rendered=$(printf '%s' "$out" | python3 -c "
import sys, json
d = json.load(sys.stdin)
users = d.get('users', [])
for u in users:
roles = ','.join(u.get('roles') or []) or 'user'
state = '' if u.get('active', True) else ' (deactivated)'
email = (u.get('emails') or [{}])[0].get('address', '-')
print(' %-22s %-30s %s%s' % (u.get('username', '-'), email, roles, state))
print('LP_TOTAL:%d' % d.get('total', len(users)))
" 2>/dev/null)
local line total=0
while IFS= read -r line; do
case "$line" in
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
*) [[ -n "$line" ]] && printf '%s\n' "$line" ;;
esac
done <<< "$rendered"
isSuccessful "$total Rocket.Chat account(s)."
}
# Deactivate, not delete. users.delete purges the account and its messages with
# no undo; setting active=false revokes access and is reversible from the admin
# UI, which is the safer default behind a single button.
authAdapter_rocketchat_deleteUser() {
local who="$1"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
if [[ "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ]]; then
isError "Refusing to deactivate '$who' — it is the admin these tools authenticate as."
return 1
fi
local uid; uid=$(_rocketchatUserId "$who") || return 1
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local out
out=$(_rocketchatApi POST /api/v1/users.setActiveStatus "$(printf '{"userId":%s,"activeStatus":false}' "$(_rcJson "$uid")")") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Deactivating $who failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user '$who' deactivated. Re-enable them from Admin → Users."
}
authAdapter_rocketchat_setAdmin() {
local who="$1" isAdmin="$2"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
local target="false"; [[ "$isAdmin" == "true" ]] && target="true"
if [[ "$target" == "false" && ( "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ) ]]; then
isError "Refusing to demote '$who' — it is the admin these tools authenticate as."
return 1
fi
local uid; uid=$(_rocketchatUserId "$who") || return 1
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local endpoint="/api/v1/roles.addUserToRole"
local body
body=$(printf '{"roleName":"admin","username":%s}' "$(_rcJson "${who%%@*}")")
if [[ "$target" == "false" ]]; then
endpoint="/api/v1/roles.removeUserFromRole"
fi
local out
out=$(_rocketchatApi POST "$endpoint" "$body") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Changing admin status for $who failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user '$who' admin → $target."
}