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 "$@"; }