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>
269 lines
12 KiB
Bash
269 lines
12 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="$(appDir 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"
|
|
}
|
|
|
|
# Rocket.Chat enables a password policy by default that demands lower, upper,
|
|
# digit AND special, at 14+ characters. generateRandomPassword is alphanumeric,
|
|
# so a generated password is rejected by users.update with "does not meet the
|
|
# server's password policy" — note that users.create does NOT enforce it, which
|
|
# is why creating worked while resetting did not.
|
|
#
|
|
# Appending one character from each class guarantees compliance without
|
|
# weakening anything: the entropy of the generated part is untouched.
|
|
_rocketchatPassword() {
|
|
printf '%s%s' "$(generateRandomPassword)" 'aZ7#'
|
|
}
|
|
|
|
# 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=$(_rocketchatPassword)
|
|
|
|
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
|
|
}
|
|
|
|
_rocketchatUsernameOf() {
|
|
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('username',''))
|
|
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=$(_rocketchatPassword)
|
|
|
|
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', '-')
|
|
# EZ_USER<TAB>email<TAB>username<TAB>roles drives the WebUI's user-list
|
|
# modal; the aligned line below it is what a human reads in the log.
|
|
# Empty, not '-': the modal falls back to the username with
|
|
# \`email || username\`, and a '-' placeholder is truthy so it would win.
|
|
print('EZ_USER\t%s\t%s\t%s' % ('' if email == '-' else email, u.get('username', ''), roles + state))
|
|
print(' %-30s %-22s %s%s' % (email, u.get('username', '-'), 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."
|
|
}
|
|
|
|
# The counterpart to deleteUser. Deactivation is only a safe default if undoing
|
|
# it is equally easy — otherwise the "reversible" claim is theoretical.
|
|
authAdapter_rocketchat_enableUser() {
|
|
local who="$1"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
|
|
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":true}' "$(_rcJson "$uid")")") || return 1
|
|
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Enabling $who failed: $(_rocketchatError "$out")"; return 1; }
|
|
isSuccessful "Rocket.Chat user '$who' re-enabled."
|
|
}
|
|
|
|
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
|
|
|
|
# The endpoint takes roleId + username, and nothing else: passing roleName
|
|
# fails schema validation with "must NOT have additional properties", and
|
|
# roleId + userId is rejected for a missing username. For built-in roles the
|
|
# id and the name happen to be the same string ("admin").
|
|
#
|
|
# username is resolved from the account rather than assumed from the input,
|
|
# so passing an email works here too.
|
|
local username
|
|
username=$(_rocketchatUsernameOf "$who") || return 1
|
|
[[ -z "$username" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
|
|
|
|
local endpoint="/api/v1/roles.addUserToRole"
|
|
[[ "$target" == "false" ]] && endpoint="/api/v1/roles.removeUserFromRole"
|
|
local body
|
|
body=$(printf '{"roleId":"admin","username":%s}' "$(_rcJson "$username")")
|
|
|
|
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."
|
|
}
|