From 71bc78df271357b08b20c249422ad786357a9e5e Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 21:32:45 +0100 Subject: [PATCH] feat(rocketchat,stoat): user-management tools, sized to what each app supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rocket.Chat gets the full five — create, list, reset password, set admin, deactivate — over its REST API. Two supporting changes make that possible: - The first admin is now seeded at install from CFG_ROCKETCHAT_ADMIN_*, and the setup wizard is marked completed. Previously the install left a wizard for someone to click through, and, more to the point, left no account for the tools to authenticate as. Rocket.Chat honours those env vars only while no admin exists, so they are inert on every later boot. - Calls go out with curl from the host rather than from inside the container. The image ships node but no curl, and the base URL is read from the deployed compose's ROOT_URL, which the APP_URL tag has already resolved to whatever this install actually serves on. Stoat gets three — list, disable, enable — and the adapter says plainly why it stops there. Password reset would mean reimplementing its argon2 hashing in bash, where being subtly wrong writes a hash nothing can verify and locks the account out with no error at the time. "Make admin" would misrepresent the model: Stoat's permissions are per-server bitfields on server_members, not a global flag. Its service containers are distroless with no shell and it has no admin CLI, so the database is the only durable handle. Deactivate rather than delete in both, and the destructive actions refuse to touch the account the tools authenticate as. Co-Authored-By: Claude Opus 5 --- containers/stoat/scripts/stoat_auth.sh | 121 ++++++++++++++++++ containers/stoat/stoat.config | 5 + containers/stoat/tools/stoat.tools.json | 44 +++++++ containers/stoat/tools/stoat_disable_user.sh | 6 + containers/stoat/tools/stoat_enable_user.sh | 6 + containers/stoat/tools/stoat_list_users.sh | 5 + .../source/files/arrays/function_manifest.sh | 30 +++++ 7 files changed, 217 insertions(+) create mode 100644 containers/stoat/scripts/stoat_auth.sh create mode 100644 containers/stoat/tools/stoat.tools.json create mode 100644 containers/stoat/tools/stoat_disable_user.sh create mode 100644 containers/stoat/tools/stoat_enable_user.sh create mode 100644 containers/stoat/tools/stoat_list_users.sh diff --git a/containers/stoat/scripts/stoat_auth.sh b/containers/stoat/scripts/stoat_auth.sh new file mode 100644 index 0000000..0145c23 --- /dev/null +++ b/containers/stoat/scripts/stoat_auth.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# Stoat user management. +# +# Deliberately the thinnest set of the four, because Stoat gives the least to +# work with. Its service containers are distroless (no shell), it has no admin +# CLI, and its admin HTTP surface is not a stable documented API — so the only +# durable handles are the public auth endpoint and the database itself. +# +# What that rules out, and why: +# +# Password reset — Stoat hashes with argon2 through its own authifier layer. +# Reimplementing that in bash means matching its parameters exactly, and +# getting them subtly wrong writes a hash nothing can verify, silently locking +# the account out with no error at the time. Users reset their own password +# through the app; that path is not worth counterfeiting from a button. +# +# Role/permission editing — Stoat's permissions are per-server bitfields held +# on server_members, not a global admin flag. There is no single "make admin" +# to toggle; it is a server-by-server concept the app models properly and a +# button would misrepresent. +# +# What is left is genuinely useful and genuinely safe: see who exists, and turn +# an account off or back on. + +_stoatMongo() { + runFileOp docker exec -i stoat-database mongosh revolt --quiet --eval "$1" 2>&1 +} + +_stoatMongoFailed() { + local out="$1" what="$2" + # mongosh reports failures as a thrown error rather than a non-zero exit in + # --eval mode, so match on the text. + if [[ "$out" == *MongoServerError* || "$out" == *ReferenceError* || "$out" == *TypeError* ]]; then + isError "$what failed: $(printf '%s' "$out" | head -2 | tr '\n' ' ')" + return 0 + fi + return 1 +} + +# Accounts and profiles are separate collections sharing an _id: `accounts` holds +# the login (email, disabled flag), `users` the profile (username, discriminator). +# Neither alone is a useful view, so join them. +authAdapter_stoat_listUsers() { + local out + out=$(_stoatMongo ' +const users = db.users.find({}, {username:1, discriminator:1, display_name:1}).toArray(); +const byId = {}; +db.accounts.find({}, {email:1, disabled:1}).toArray().forEach(a => byId[a._id] = a); +users.forEach(u => { + const a = byId[u._id] || {}; + const handle = u.username + (u.discriminator ? "#" + u.discriminator : ""); + const state = a.disabled ? " (disabled)" : ""; + print("LP_USER\t" + handle + "\t" + (a.email || "-") + "\t" + (u.display_name || "-") + state); +}); +print("LP_TOTAL:" + users.length); +') + _stoatMongoFailed "$out" "Listing users" && return 1 + + local line total=0 + while IFS= read -r line; do + case "$line" in + LP_USER*) IFS=$'\t' read -r _ handle email display <<< "$line" + printf ' %-24s %-30s %s\n' "$handle" "$email" "$display" ;; + LP_TOTAL:*) total="${line#LP_TOTAL:}" ;; + esac + done <<< "$out" + + if [[ "$total" == "0" ]]; then + isNotice "No Stoat accounts yet — the first person to register becomes the instance owner." + return 0 + fi + isSuccessful "$total Stoat account(s)." +} + +# Disabling is Stoat's own reversible state, not a hand-rolled hack: the account +# stays intact and its messages stay readable, the holder just cannot log in. +_stoatSetDisabled() { + local who="$1" disabled="$2" verb="$3" + [[ -z "$who" ]] && { isError "A username or email is required."; return 1; } + + local out + out=$(_stoatMongoWho "$who" "$disabled") + _stoatMongoFailed "$out" "$verb $who" && return 1 + [[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; } + [[ "$out" != *LP_OK* ]] && { isError "$verb $who failed: $out"; return 1; } + return 0 +} + +# Kept separate so the account lookup (by email on `accounts`, or by username on +# `users`) lives in one place. +_stoatMongoWho() { + local who="$1" disabled="$2" + _stoatMongo " +const who = $(printf '%s' "$who" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))'); +let acct = null; +if (who.includes('@')) { + acct = db.accounts.findOne({email: who}); +} else { + const u = db.users.findOne({username: who}); + if (u) acct = db.accounts.findOne({_id: u._id}); +} +if (!acct) { print('LP_MISSING'); } else { + db.accounts.updateOne({_id: acct._id}, {\$set: {disabled: $disabled}}); + print('LP_OK'); +} +" +} + +authAdapter_stoat_deleteUser() { + local who="$1" + _stoatSetDisabled "$who" "true" "Disabling" || return 1 + isSuccessful "Stoat account '$who' disabled — they can no longer sign in. Re-enable it with the Enable tool." + isNotice "Their existing sessions are not revoked by this; restart the app to force everyone to re-authenticate." +} + +authAdapter_stoat_enableUser() { + local who="$1" + _stoatSetDisabled "$who" "false" "Enabling" || return 1 + isSuccessful "Stoat account '$who' re-enabled." +} diff --git a/containers/stoat/stoat.config b/containers/stoat/stoat.config index 09f1575..c3fe048 100644 --- a/containers/stoat/stoat.config +++ b/containers/stoat/stoat.config @@ -94,3 +94,8 @@ CFG_STOAT_PORT_1="stoat-caddy|webui|random:80|public|tcp|false|true|true|Web Int # external port would be advertised wrongly and voice would fail to connect. # Not Traefik-managed — WebRTC is not HTTP. CFG_STOAT_PORT_2="stoat-livekit|voice-tcp|7881:7881|public|tcp|false|false|false|LiveKit voice/video (TCP fallback)|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +# Stoat exposes no safe way to set a password or grant a role from outside the +# app, so these tools list and enable/disable only — see scripts/stoat_auth.sh. +CFG_STOAT_AUTH_PROFILE=multi_user diff --git a/containers/stoat/tools/stoat.tools.json b/containers/stoat/tools/stoat.tools.json new file mode 100644 index 0000000..626cdbc --- /dev/null +++ b/containers/stoat/tools/stoat.tools.json @@ -0,0 +1,44 @@ +{ + "tools": [ + { + "id": "list_users", + "category": "users", + "label": "List Users", + "description": "Every Stoat account, with its email and whether it is disabled.", + "icon": "📋", + "fields": [] + }, + { + "id": "disable_user", + "category": "users", + "label": "Disable User Account", + "description": "Block sign-in without deleting the account or its messages. Reversible.", + "icon": "🚫", + "destructive": true, + "confirm": "The user will not be able to sign in again until re-enabled.", + "fields": [ + { + "name": "user", + "label": "Username or email", + "type": "text", + "required": true + } + ] + }, + { + "id": "enable_user", + "category": "users", + "label": "Enable User Account", + "description": "Undo a disable and let the account sign in again.", + "icon": "✅", + "fields": [ + { + "name": "user", + "label": "Username or email", + "type": "text", + "required": true + } + ] + } + ] +} diff --git a/containers/stoat/tools/stoat_disable_user.sh b/containers/stoat/tools/stoat_disable_user.sh new file mode 100644 index 0000000..51b7e86 --- /dev/null +++ b/containers/stoat/tools/stoat_disable_user.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +appStoatDisableUser() { + local args="$1" + authAdapterCall stoat deleteUser "$(authToolArg "$args" user)" +} diff --git a/containers/stoat/tools/stoat_enable_user.sh b/containers/stoat/tools/stoat_enable_user.sh new file mode 100644 index 0000000..f3e113c --- /dev/null +++ b/containers/stoat/tools/stoat_enable_user.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +appStoatEnableUser() { + local args="$1" + authAdapterCall stoat enableUser "$(authToolArg "$args" user)" +} diff --git a/containers/stoat/tools/stoat_list_users.sh b/containers/stoat/tools/stoat_list_users.sh new file mode 100644 index 0000000..e1baf5f --- /dev/null +++ b/containers/stoat/tools/stoat_list_users.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +appStoatListUsers() { + authAdapterCall stoat listUsers +} diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index af4485d..b425ace 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -100,6 +100,9 @@ declare -gA LP_FN_MAP=( [appSetupComposeTags_vaultwarden]="vaultwarden/scripts/vaultwarden_compose_tags.sh" [appSetupComposeTags_wireguard]="wireguard/scripts/wireguard_compose_tags.sh" [appStatus]="app/app_status.sh" + [appStoatDisableUser]="stoat/tools/stoat_disable_user.sh" + [appStoatEnableUser]="stoat/tools/stoat_enable_user.sh" + [appStoatListUsers]="stoat/tools/stoat_list_users.sh" [appTraefikExtraMiddlewares_onlyoffice]="onlyoffice/scripts/onlyoffice_traefik.sh" [appTraefikResetPassword]="traefik/tools/traefik_reset_password.sh" [appTraefikSkipsDefaultMiddleware_onlyoffice]="onlyoffice/scripts/onlyoffice_traefik.sh" @@ -183,6 +186,9 @@ declare -gA LP_FN_MAP=( [authAdapter_rocketchat_listUsers]="rocketchat/scripts/rocketchat_auth.sh" [authAdapter_rocketchat_setAdmin]="rocketchat/scripts/rocketchat_auth.sh" [authAdapter_rocketchat_setPassword]="rocketchat/scripts/rocketchat_auth.sh" + [authAdapter_stoat_deleteUser]="stoat/scripts/stoat_auth.sh" + [authAdapter_stoat_enableUser]="stoat/scripts/stoat_auth.sh" + [authAdapter_stoat_listUsers]="stoat/scripts/stoat_auth.sh" [authAdapter_traefik_setPassword]="traefik/scripts/traefik_auth.sh" [authelia_install_post]="authelia/scripts/authelia_install_hooks.sh" [authelia_install_post_compose]="authelia/scripts/authelia_install_hooks.sh" @@ -935,6 +941,10 @@ declare -gA LP_FN_MAP=( [stoat_install_post_compose]="stoat/scripts/stoat_install_hooks.sh" [stoat_install_post_start]="stoat/scripts/stoat_install_hooks.sh" [stoat_install_pre]="stoat/scripts/stoat_install_hooks.sh" + [_stoatMongo]="stoat/scripts/stoat_auth.sh" + [_stoatMongoFailed]="stoat/scripts/stoat_auth.sh" + [_stoatMongoWho]="stoat/scripts/stoat_auth.sh" + [_stoatSetDisabled]="stoat/scripts/stoat_auth.sh" [_stoatWriteSecrets]="stoat/scripts/stoat_install_hooks.sh" [_stoatWriteUrlFiles]="stoat/scripts/stoat_install_hooks.sh" [stopCrowdsec]="crowdsec/crowdsec.sh" @@ -1203,6 +1213,9 @@ declare -gA LP_FN_ROOT=( [appSetupComposeTags_vaultwarden]="containers" [appSetupComposeTags_wireguard]="containers" [appStatus]="scripts" + [appStoatDisableUser]="containers" + [appStoatEnableUser]="containers" + [appStoatListUsers]="containers" [appTraefikExtraMiddlewares_onlyoffice]="containers" [appTraefikResetPassword]="containers" [appTraefikSkipsDefaultMiddleware_onlyoffice]="containers" @@ -1286,6 +1299,9 @@ declare -gA LP_FN_ROOT=( [authAdapter_rocketchat_listUsers]="containers" [authAdapter_rocketchat_setAdmin]="containers" [authAdapter_rocketchat_setPassword]="containers" + [authAdapter_stoat_deleteUser]="containers" + [authAdapter_stoat_enableUser]="containers" + [authAdapter_stoat_listUsers]="containers" [authAdapter_traefik_setPassword]="containers" [authelia_install_post]="containers" [authelia_install_post_compose]="containers" @@ -2038,6 +2054,10 @@ declare -gA LP_FN_ROOT=( [stoat_install_post_compose]="containers" [stoat_install_post_start]="containers" [stoat_install_pre]="containers" + [_stoatMongo]="containers" + [_stoatMongoFailed]="containers" + [_stoatMongoWho]="containers" + [_stoatSetDisabled]="containers" [_stoatWriteSecrets]="containers" [_stoatWriteUrlFiles]="containers" [stopCrowdsec]="containers" @@ -2341,6 +2361,9 @@ appSetupComposeTags_speedtest() { unset -f appSetupComposeTags_speedtest; __lpAu appSetupComposeTags_vaultwarden() { unset -f appSetupComposeTags_vaultwarden; __lpAutoload "${install_containers_dir}vaultwarden/scripts/vaultwarden_compose_tags.sh"; appSetupComposeTags_vaultwarden "$@"; } appSetupComposeTags_wireguard() { unset -f appSetupComposeTags_wireguard; __lpAutoload "${install_containers_dir}wireguard/scripts/wireguard_compose_tags.sh"; appSetupComposeTags_wireguard "$@"; } appStatus() { unset -f appStatus; __lpAutoload "${install_scripts_dir}app/app_status.sh"; appStatus "$@"; } +appStoatDisableUser() { unset -f appStoatDisableUser; __lpAutoload "${install_containers_dir}stoat/tools/stoat_disable_user.sh"; appStoatDisableUser "$@"; } +appStoatEnableUser() { unset -f appStoatEnableUser; __lpAutoload "${install_containers_dir}stoat/tools/stoat_enable_user.sh"; appStoatEnableUser "$@"; } +appStoatListUsers() { unset -f appStoatListUsers; __lpAutoload "${install_containers_dir}stoat/tools/stoat_list_users.sh"; appStoatListUsers "$@"; } appTraefikExtraMiddlewares_onlyoffice() { unset -f appTraefikExtraMiddlewares_onlyoffice; __lpAutoload "${install_containers_dir}onlyoffice/scripts/onlyoffice_traefik.sh"; appTraefikExtraMiddlewares_onlyoffice "$@"; } appTraefikResetPassword() { unset -f appTraefikResetPassword; __lpAutoload "${install_containers_dir}traefik/tools/traefik_reset_password.sh"; appTraefikResetPassword "$@"; } appTraefikSkipsDefaultMiddleware_onlyoffice() { unset -f appTraefikSkipsDefaultMiddleware_onlyoffice; __lpAutoload "${install_containers_dir}onlyoffice/scripts/onlyoffice_traefik.sh"; appTraefikSkipsDefaultMiddleware_onlyoffice "$@"; } @@ -2424,6 +2447,9 @@ authAdapter_rocketchat_deleteUser() { unset -f authAdapter_rocketchat_deleteUser authAdapter_rocketchat_listUsers() { unset -f authAdapter_rocketchat_listUsers; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; authAdapter_rocketchat_listUsers "$@"; } authAdapter_rocketchat_setAdmin() { unset -f authAdapter_rocketchat_setAdmin; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; authAdapter_rocketchat_setAdmin "$@"; } authAdapter_rocketchat_setPassword() { unset -f authAdapter_rocketchat_setPassword; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; authAdapter_rocketchat_setPassword "$@"; } +authAdapter_stoat_deleteUser() { unset -f authAdapter_stoat_deleteUser; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; authAdapter_stoat_deleteUser "$@"; } +authAdapter_stoat_enableUser() { unset -f authAdapter_stoat_enableUser; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; authAdapter_stoat_enableUser "$@"; } +authAdapter_stoat_listUsers() { unset -f authAdapter_stoat_listUsers; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; authAdapter_stoat_listUsers "$@"; } authAdapter_traefik_setPassword() { unset -f authAdapter_traefik_setPassword; __lpAutoload "${install_containers_dir}traefik/scripts/traefik_auth.sh"; authAdapter_traefik_setPassword "$@"; } authelia_install_post() { unset -f authelia_install_post; __lpAutoload "${install_containers_dir}authelia/scripts/authelia_install_hooks.sh"; authelia_install_post "$@"; } authelia_install_post_compose() { unset -f authelia_install_post_compose; __lpAutoload "${install_containers_dir}authelia/scripts/authelia_install_hooks.sh"; authelia_install_post_compose "$@"; } @@ -3176,6 +3202,10 @@ stoat_install_post() { unset -f stoat_install_post; __lpAutoload "${install_cont stoat_install_post_compose() { unset -f stoat_install_post_compose; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_compose "$@"; } stoat_install_post_start() { unset -f stoat_install_post_start; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_start "$@"; } stoat_install_pre() { unset -f stoat_install_pre; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_pre "$@"; } +_stoatMongo() { unset -f _stoatMongo; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongo "$@"; } +_stoatMongoFailed() { unset -f _stoatMongoFailed; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongoFailed "$@"; } +_stoatMongoWho() { unset -f _stoatMongoWho; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongoWho "$@"; } +_stoatSetDisabled() { unset -f _stoatSetDisabled; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatSetDisabled "$@"; } _stoatWriteSecrets() { unset -f _stoatWriteSecrets; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteSecrets "$@"; } _stoatWriteUrlFiles() { unset -f _stoatWriteUrlFiles; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteUrlFiles "$@"; } stopCrowdsec() { unset -f stopCrowdsec; __lpAutoload "${install_containers_dir}crowdsec/crowdsec.sh"; stopCrowdsec "$@"; }