#!/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 " " 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 [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_USERemailusernameroles 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." }