librelad 93ec260298 Provision a Stoat owner account, add create/reset user tools
Stoat shipped with no account and no way to make one from LibrePortal. It is
first-come-first-served, with invite_only=false, no captcha and no email
verification, so every install left a window between the API answering and
someone signing up in which anyone who could reach the port could take the
instance. The installer now claims the configured account as soon as the API
responds, and prints the credentials instead of "go and register".

Provisioning goes over HTTP, not Mongo: an account needs a login AND a
completed onboarding (accounts holds one, users the other) and passwords go
through Stoat's argon2 layer. Failure is deliberately non-fatal — it leaves the
instance exactly as it was before this existed, which must not fail an
otherwise good install of sixteen containers.

Both obvious config defaults are rejected by Stoat, which is only visible as a
failed install, so both are chosen against its rules: example.com comes back
DisallowedContactSupport (reserved domain) hence admin@stoat.local, and "admin"
comes back InvalidUsername (reserved) hence "administrator".

Two of the three missing adapter operations are now implemented:

- createUser: create, log in, complete onboarding. Without the last step an
  account can sign in and then sits on a pick-a-username screen forever.

- setPassword: previously excluded because hand-rolling argon2 risks writing a
  hash nothing can verify, locking the holder out with no error at the time.
  That objection is answered by refusing to hash at all — authifier already
  owns a reset flow, so this writes only its password_reset token to Mongo and
  lets PATCH /auth/account/reset_password do the hashing with the same code
  that verifies. Verified: reset by username and by email, new password logs
  in, token consumed.

setAdmin is still NOT implemented, and the header now says so with evidence
rather than assertion. Stoat has no instance-level admin flag: the user
document holds only _id/username/discriminator and GET /users/@me adds only
relationship and online. Permissions are per-server bitfields on server_members.
A "make admin" button would invent a concept the app does not have.

Also fixed two things found while testing:

- post_start returned early when the public URL needed no settling, which
  skipped everything after it — so provisioning would have been silently
  missed on exactly the domain-backed installs that guessed the URL right.

- _stoatBaseUrl advertised $public_ip_v4, the WAN address from an external
  resolver, in URLs compiled into the web client. Same fix as the APP_URL
  processor: prefer $local_ip_v4, since LibrePortal never forwards ports.

Verified end to end on a clean install: the owner account is created and
onboarded, the generated password logs in, both new tools run through
`libreportal app tool`, and a created account survives a password reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:37:32 +01:00

285 lines
12 KiB
Bash

#!/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:
#
# 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. Confirmed against a running instance: the user
# document holds only _id/username/discriminator, and GET /users/@me adds only
# relationship and online. There is no privileged bit to set.
#
# Password reset WAS excluded for the same reason — Stoat hashes with argon2
# through its own authifier layer, and a hand-rolled hash that is subtly wrong
# writes an account nothing can verify, with no error at the time. That objection
# is answered not by reimplementing the hash but by refusing to: authifier's own
# password_reset field takes a token, and its reset endpoint does the hashing.
# See authAdapter_stoat_setPassword.
# Host-local API base for this app, e.g. http://127.0.0.1:8860/api.
#
# 127.0.0.1 rather than the published LAN address: this only ever runs on the
# box itself, and the loopback route does not depend on which interface the
# advertised URL happens to name.
_stoatApiLocal() {
local app_name="${1:-stoat}"
local compose="$containers_dir$app_name/docker-compose.yml"
local ports external
ports=$(tagsManagerGetTagContent "$compose" "PORTS_TAG_1" 2>/dev/null)
external="${ports%%:*}"
[[ -z "$external" || "$external" == PORTS_DATA* ]] && return 1
printf 'http://127.0.0.1:%s/api' "$external"
}
# JSON-encode one value, so an apostrophe in a password cannot end the string.
_stoatJson() { printf '%s' "$1" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))'; }
# True when the instance already holds at least one account.
_stoatAccountExists() {
local n
n=$(_stoatMongo 'print(db.accounts.countDocuments({}))' | tr -dc '0-9')
[[ -n "$n" && "$n" != "0" ]]
}
# Register an account and finish onboarding, which is what actually makes it a
# usable identity: `accounts` holds the login, `users` the handle, and Stoat
# creates the second only when onboarding completes. An account left un-onboarded
# can sign in and then sits on a "pick a username" screen forever, and — worse for
# a first account — has not yet taken instance ownership.
#
# Done over HTTP rather than by writing Mongo directly because passwords go
# through Stoat's own argon2 layer. Reimplementing that in bash means matching
# its parameters exactly, and a subtly wrong hash writes an account nothing can
# verify: no error at the time, just a login that never works.
_stoatCreateAccount() {
local api="$1" email="$2" pass="$3" user="$4"
local body out token
body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass")}"
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \
-H 'Content-Type: application/json' -d "$body" 2>&1)
# A successful create returns 204 with no body; anything printed is an error.
if [[ -n "$out" && "$out" != *'"result"'* ]]; then
isError "Stoat account create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
return 1
fi
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/session/login" \
-H 'Content-Type: application/json' -d "$body" 2>&1)
token=$(printf '%s' "$out" | python3 -c 'import sys,json
try: print(json.load(sys.stdin).get("token",""))
except Exception: print("")' 2>/dev/null)
if [[ -z "$token" ]]; then
isError "Stoat login after create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
return 1
fi
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/onboard/complete" \
-H 'Content-Type: application/json' -H "X-Session-Token: ${token}" \
-d "{\"username\":$(_stoatJson "$user")}" 2>&1)
if [[ "$out" == *'"type"'*'"error"'* || "$out" == *UsernameTaken* || "$out" == *InvalidUsername* ]]; then
isError "Stoat onboarding failed for '${user}': $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
return 1
fi
return 0
}
_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)" : "";
// EZ_USER<TAB>identifier<TAB>display<TAB>roles, as the WebUI modal expects.
// Email first where there is one: it is the field a row action prefills.
print("EZ_USER\t" + (a.email || handle) + "\t" + handle + "\t" + (a.disabled ? "disabled" : "user"));
});
print("LP_TOTAL:" + users.length);
')
_stoatMongoFailed "$out" "Listing users" && return 1
local line total=0
while IFS= read -r line; do
case "$line" in
EZ_USER*) IFS=$'\t' read -r _ ident handle state <<< "$line"
# The marker line has to reach the task log for the WebUI
# user-list modal to build its rows from.
printf '%s\n' "$line"
printf ' %-30s %-24s %s\n' "$ident" "$handle" "$state" ;;
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."
}
authAdapter_stoat_createUser() {
local email="$1" pass="$2" user="$3"
[[ -z "$email" ]] && { isError "An email is required."; return 1; }
[[ -z "$pass" ]] && pass=$(generateRandomPassword)
# Stoat needs a handle as well as a login. Derive one from the email's local
# part when the caller did not supply it, so the generic "create user" form
# (which only asks for email + password) still produces a usable account
# rather than one stuck on the pick-a-username screen.
if [[ -z "$user" ]]; then
user="${email%%@*}"
user="${user//[^a-zA-Z0-9_.]/}"
fi
local api
api=$(_stoatApiLocal "${CFG_STOAT_APP_NAME:-stoat}") || {
isError "Could not work out Stoat's local API address."; return 1; }
_stoatCreateAccount "$api" "$email" "$pass" "$user" || return 1
# Keep the config truthful when this IS the configured owner: an install that
# could not reach the API leaves those fields describing an account that does
# not exist, and creating it by hand afterwards should reconcile the two.
if [[ "$email" == "${CFG_STOAT_ADMIN_EMAIL:-}" ]]; then
authPersistCfg stoat ADMIN_PASSWORD "$pass"
authPersistCfg stoat ADMIN_USERNAME "$user"
fi
isSuccessful "Stoat account created — Handle: $user — Email: $email — Password: $pass"
}
# Reset a password WITHOUT touching the hash ourselves.
#
# authifier already owns a reset flow: an account carries a password_reset token,
# and PATCH /auth/account/reset_password trades that token for a new password —
# hashing it with exactly the parameters the verifier expects, because it is the
# same code that verifies. So the only thing written directly is the token, which
# is inert on its own; Stoat does the part that has to be right.
#
# The alternative, writing an argon2 string into the account document, would mean
# reproducing $argon2i$v=19$m=4096,t=3,p=1 by hand — and a near miss there is
# silent, locking the holder out with no error at the time.
authAdapter_stoat_setPassword() {
local who="$1" pass="$2"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
[[ -z "$pass" ]] && pass=$(generateRandomPassword)
local api
api=$(_stoatApiLocal "${CFG_STOAT_APP_NAME:-stoat}") || {
isError "Could not work out Stoat's local API address."; return 1; }
# Short-lived and single-use: the reset endpoint consumes it, and the expiry
# bounds the window if the endpoint is never reached.
local token
token="lp$(tr -dc 'a-z0-9' </dev/urandom | head -c 40)"
local out
out=$(_stoatMongo "
const who = $(_stoatJson "$who");
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: {password_reset: {token: $(_stoatJson "$token"), expiry: new Date(Date.now() + 600000).toISOString()}}});
print('LP_OK');
}
")
_stoatMongoFailed "$out" "Password reset for $who" && return 1
[[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; }
[[ "$out" != *LP_OK* ]] && { isError "Password reset for $who failed: $out"; return 1; }
out=$(runFileOp curl -sS --max-time 20 -X PATCH "${api}/auth/account/reset_password" \
-H 'Content-Type: application/json' \
-d "{\"token\":$(_stoatJson "$token"),\"password\":$(_stoatJson "$pass")}" 2>&1)
if [[ -n "$out" ]]; then
# Clear the token so a failed attempt does not leave a live reset behind.
_stoatMongo "db.accounts.updateOne({\$or:[{email:$(_stoatJson "$who")}]}, {\$unset:{password_reset:''}})" >/dev/null 2>&1
isError "Stoat rejected the new password: $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
return 1
fi
if [[ "$who" == "${CFG_STOAT_ADMIN_EMAIL:-}" || "$who" == "${CFG_STOAT_ADMIN_USERNAME:-}" ]]; then
authPersistCfg stoat ADMIN_PASSWORD "$pass"
fi
isSuccessful "Stoat password set for $who — New password: $pass"
isNotice "Existing sessions stay valid; restart the app to force a re-login."
}