LibrePortal/containers/stoat/scripts/stoat_install_hooks.sh
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

451 lines
18 KiB
Bash

#!/bin/bash
# Stoat install hooks.
#
# Upstream configures an instance with an interactive generate_config.sh that
# asks for a domain and writes five files. This is the non-interactive
# equivalent, driven by the domain LibrePortal already knows and writing into
# the app's install directory.
#
# The one rule that matters here: secrets.env is generated ONCE and never
# rewritten. REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded to
# the instance, so regenerating it on a reinstall would permanently orphan the
# entire media store — which is exactly the failure upstream's script warns
# about at length.
stoat_install_pre()
{
local app_name="$1"
if ! appInstallCheckRequirements "$app_name" "$CFG_STOAT_REQUIRES"; then
stoat=n
return 1
fi
}
# The public host every generated file is derived from.
#
# Computed from the port arrays and $domain_full that variables_init_app puts in
# scope, NOT read back from the deployed compose: install_post_compose runs
# before dockerConfigSetupFileWithData, so the compose still holds raw
# placeholders at this point. port_subdomains[0] is CFG_STOAT_PORT_1 (the Caddy
# router); the empty/@/root cases mirror tagsProcessorPortSubdomains so this and
# the Traefik rule generated later cannot drift apart.
_stoatDomain()
{
local sub="${port_subdomains[0]}"
[[ -z "$domain_full" ]] && return 1
if [[ "$sub" == "@" || "$sub" == "root" ]]; then
echo "$domain_full"
elif [[ -n "$sub" ]]; then
echo "${sub}.${domain_full}"
else
echo "stoat.${domain_full}"
fi
}
# Scheme + host the client bundle is built against, with no trailing slash.
#
# https://<host> when Traefik is installed and a domain is configured;
# otherwise http://<lan-ip>:<allocated-port>, which is a perfectly good Stoat
# instance for LAN or WireGuard use — it just cannot do camera or microphone,
# because browsers only grant those to a secure context.
#
# The port is only assigned during compose-up, so a call from
# install_post_compose returns a best guess and install_post_start corrects it.
_stoatBaseUrl()
{
local app_name="$1"
local compose="$containers_dir$app_name/docker-compose.yml"
if [[ -d "${containers_dir}traefik" && -n "$domain_full" ]]; then
local host
host=$(_stoatDomain)
[[ -n "$host" ]] && { echo "https://${host}"; return 0; }
fi
local ports external
ports=$(tagsManagerGetTagContent "$compose" "PORTS_TAG_1")
external="${ports%%:*}"
if [[ -n "$external" && "$external" != PORTS_DATA* ]]; then
echo "http://${local_ip_v4:-${public_ip_v4:-localhost}}:${external}"
else
# $local_ip_v4, not $public_ip_v4: these URLs are compiled into the web
# client and handed to browsers, and LibrePortal does not forward ports —
# so the WAN address an external resolver reports is unreachable for
# exactly the LAN/VPN clients this branch serves.
echo "http://${local_ip_v4:-${public_ip_v4:-localhost}}"
fi
}
# Write the three files that carry the public URL. Called once with a guess
# before the stack starts (they are bind-mounted, so they must exist or docker
# would create directories in their place) and again once the port is known.
_stoatWriteUrlFiles()
{
local app_dir="$1" base="$2" video_enabled="$3" rabbit_pass="$4"
# ws:// for http, wss:// for https — a wss:// URL on a plain-HTTP origin
# fails to connect and the client hangs on "connecting".
local ws_scheme="wss"
[[ "$base" == http://* ]] && ws_scheme="ws"
local hostport="${base#*://}"
runFileWrite "$app_dir/.env.web" <<EOF
HOSTNAME=:80
REVOLT_PUBLIC_URL=${base}/api
VITE_API_URL=${base}/api
VITE_WS_URL=${ws_scheme}://${hostport}/ws
VITE_MEDIA_URL=${base}/autumn
VITE_PROXY_URL=${base}/january
VITE_GIFBOX_URL=${base}/gifbox
VITE_CFG_ENABLE_VIDEO=${video_enabled}
EOF
printf '{"api":"%s/api"}' "$base" | runFileWrite "$app_dir/stoat.json"
runFileWrite "$app_dir/Revolt.toml" <<EOF
# Generated by LibrePortal at install time. Secrets live in secrets.env, not
# here. Reinstalling the app rewrites this file — put custom configuration in a
# copy and merge it back if you change anything.
[hosts]
app = "${base}"
api = "${base}/api"
events = "${ws_scheme}://${hostport}/ws"
autumn = "${base}/autumn"
january = "${base}/january"
gifbox = "${base}/gifbox"
[hosts.livekit]
worldwide = "${ws_scheme}://${hostport}/livekit"
[api.livekit.nodes.worldwide]
url = "http://livekit:7880"
lat = 0.0
lon = 0.0
# Upstream's built-in defaults are rabbituser/rabbitpass. The compose gives the
# broker a generated password instead, so the clients have to be told — without
# this the API, crond, pushd and voice-ingress all panic on ACCESS_REFUSED and
# restart forever, while the services that do not touch RabbitMQ come up fine
# and make it look like a partial success.
[rabbit]
host = "rabbit"
port = 5672
username = "stoat"
password = "${rabbit_pass}"
EOF
if [[ -n "$video_enabled" ]]; then
runFileWrite -a "$app_dir/Revolt.toml" <<'EOF'
[features.limits.new_user]
video_resolution = [1920, 1080]
video_aspect_ratio = [0.3, 10]
[features.limits.default]
video_resolution = [1920, 1080]
video_aspect_ratio = [0.3, 10]
EOF
fi
}
# Generate secrets.env if it does not already exist. Returns without touching an
# existing file — see the warning at the top.
_stoatWriteSecrets()
{
local secrets_file="$1"
if [[ -s "$secrets_file" ]]; then
isNotice "Existing secrets.env found — keeping it (regenerating would orphan every uploaded file)."
return 0
fi
# VAPID keypair for web push. The public key is the uncompressed EC point,
# which is the last 65 bytes of the DER encoding, base64url-encoded without
# padding — that is what the browser Push API expects.
local vapid_pem vapid_private vapid_public
vapid_pem=$(mktemp)
openssl ecparam -name prime256v1 -genkey -noout -out "$vapid_pem" 2>/dev/null
vapid_private=$(base64 < "$vapid_pem" | tr -d '\n' | tr -d '=')
vapid_public=$(openssl ec -in "$vapid_pem" -outform DER 2>/dev/null | tail -c 65 | base64 | tr '/+' '_-' | tr -d '\n' | tr -d '=')
rm -f "$vapid_pem"
local files_key livekit_key livekit_secret
files_key=$(openssl rand -base64 32)
livekit_key=$(openssl rand -hex 6)
livekit_secret=$(openssl rand -hex 24)
runFileWrite "$secrets_file" <<EOF
# Generated by LibrePortal at install time. Treat this file as you would a
# private key: REVOLT__FILES__ENCRYPTION_KEY is the only thing that can decrypt
# the media store, and it is never regenerated once written.
REVOLT__PUSHD__VAPID__PRIVATE_KEY='${vapid_private}'
REVOLT__PUSHD__VAPID__PUBLIC_KEY='${vapid_public}'
REVOLT__FILES__ENCRYPTION_KEY='${files_key}'
REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='${livekit_key}'
REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='${livekit_secret}'
EOF
runFileOp chmod 600 "$secrets_file"
isSuccessful "Generated secrets.env"
}
# Own the LibrePortal-written config files (Caddyfile, Revolt.toml, .env.web,
# stoat.json, secrets.env, livekit.yml, the compose + app config) as the docker
# install user, so the containers can read their bind-mount sources.
#
# Top level ONLY. This used to be `chown -R "$app_dir"`, which walked into
# data/db, data/minio and friends — content created by the containers and owned
# by THEIR uids (mongo's, minio's; under rootless those are subuids the docker
# install user has no authority over). Every reinstall therefore printed a screen
# of "Operation not permitted" plus "Permission denied" on the 0700 dirs it
# couldn't even enter, and then failed the step outright — an ✗ Error on a
# healthy install, which is the kind of noise that teaches you to skip error
# lines. Those files must keep their container ownership anyway: chowning mongo's
# data away from mongo is what would actually break stoat.
_stoatOwnConfigFiles() {
local app_dir="$1"
runFileOp find "$app_dir" -maxdepth 1 -type f \
-exec chown "$docker_install_user":"$docker_install_user" {} +
}
stoat_install_post_compose()
{
local app_name="$1"
local app_dir="$containers_dir$app_name"
((menu_number++))
echo ""
echo "---- $menu_number. Generating the Stoat instance configuration"
echo ""
local result
result=$(createFolders "loud" "$docker_install_user" \
"$app_dir/data/db" "$app_dir/data/rabbit" "$app_dir/data/minio" \
"$app_dir/data/caddy-data" "$app_dir/data/caddy-config")
checkSuccess "Creating $app_name data folders"
# Ordering rule for everything below: every file bind-mounted into a
# container must be written before the first step that could fail. A missing
# mount source is not a soft failure — docker either creates a directory in
# its place or refuses to start the container, and both outcomes outlive the
# install and break every later run.
result=$(copyResource "$app_name" "Caddyfile" "" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying Caddyfile to $app_dir"
local video_enabled=""
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
# The port is not allocated yet, so this is a guess whenever there is no
# domain; stoat_install_post_start rewrites these once it is known.
local base
base=$(_stoatBaseUrl "$app_name")
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled" "$CFG_STOAT_RABBITMQ_PASSWORD_1"
checkSuccess "Writing .env.web, stoat.json and Revolt.toml for $base"
_stoatWriteSecrets "$app_dir/secrets.env"
# Read the LiveKit credentials back out — either the ones just generated or
# the ones preserved from a previous install — because livekit.yml has to
# carry the same pair the API is configured with.
#
# Read via runFileOp: secrets.env is chmod 600 and owned by the docker
# install user, while these hooks run as the manager, so a plain grep gets
# EACCES and silently yields nothing.
local livekit_key livekit_secret
livekit_key=$(runFileOp grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
livekit_secret=$(runFileOp grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
if [[ -z "$livekit_key" || -z "$livekit_secret" ]]; then
# Deliberately not fatal. livekit.yml still gets written below so the
# bind mount is a file; voice is broken until the keys are fixed, but
# the other fifteen services come up and text chat works.
isError "Could not read the LiveKit credentials from secrets.env — voice will not work."
isNotice "Fix the keys in $app_dir/secrets.env and livekit.yml, then restart $app_name."
fi
# use_external_ip lets LiveKit discover the address to advertise for WebRTC.
# The port range matches the literal UDP mapping in the compose file; change
# one and you must change the other.
runFileWrite "$app_dir/livekit.yml" <<EOF
rtc:
use_external_ip: true
port_range_start: 50000
port_range_end: 50100
tcp_port: 7881
redis:
address: redis:6379
turn:
enabled: false
keys:
${livekit_key}: ${livekit_secret}
webhook:
api_key: ${livekit_key}
urls:
- "http://voice-ingress:8500/worldwide"
EOF
checkSuccess "Writing livekit.yml"
_stoatOwnConfigFiles "$app_dir"
checkSuccess "Setting ownership on the $app_name config files"
}
stoat_install_post_start()
{
local app_name="$1"
local app_dir="$containers_dir$app_name"
# Ports are assigned during compose-up, so on a domain-less install the URL
# baked in a moment ago was a guess. Correct it now and restart, but only if
# it actually changed — restarting sixteen containers for nothing is not
# free, and a domain-backed install guessed right the first time.
local base current
base=$(_stoatBaseUrl "$app_name")
current=$(runFileOp grep -oP '^VITE_API_URL=\K.*' "$app_dir/.env.web" 2>/dev/null)
current="${current%/api}"
# Guarded, NOT an early return. Claiming the owner account below has to happen
# on every install, and a domain-backed one guesses the URL correctly first
# time — so returning here when nothing needed settling silently skipped
# provisioning on exactly the installs that went most smoothly.
if [[ "$base" != "$current" ]]; then
((menu_number++))
echo ""
echo "---- $menu_number. Settling the Stoat public URL"
echo ""
local video_enabled=""
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled" "$CFG_STOAT_RABBITMQ_PASSWORD_1"
_stoatOwnConfigFiles "$app_dir"
isSuccessful "Public URL settled as $base (was ${current:-unset})"
# The web client compiles VITE_* at container start, so it has to come
# back up before the corrected URL reaches a browser.
dockerComposeRestart "$app_name"
fi
_stoatProvisionOwner "$app_name"
}
# Register the configured owner account, closing the first-run land grab.
#
# Stoat is first-come-first-served — the first account registered on a fresh
# instance becomes the instance owner — and it ships invite_only=false with no
# captcha and no email verification. Until this ran, every install had a window
# between "the API answers" and "you got round to signing up" in which anyone who
# could reach the port could take ownership.
#
# Deliberately not fatal. A failure here leaves the instance exactly as it was
# before this hook existed (unclaimed, with the printed advice to go and register),
# which is worse than provisioning but no worse than the old behaviour — so it
# must not fail an otherwise good install of sixteen containers.
_stoatProvisionOwner()
{
local app_name="$1"
local email="${CFG_STOAT_ADMIN_EMAIL:-}"
local pass="${CFG_STOAT_ADMIN_PASSWORD_1:-}"
local user="${CFG_STOAT_ADMIN_USERNAME:-admin}"
if [[ -z "$email" || -z "$pass" ]]; then
isNotice "No Stoat owner configured (CFG_STOAT_ADMIN_EMAIL / _PASSWORD_1) — the first account to register will own this instance."
return 0
fi
((menu_number++))
echo ""
echo "---- $menu_number. Claiming the Stoat owner account"
echo ""
local api
api=$(_stoatApiLocal "$app_name")
if [[ -z "$api" ]]; then
isNotice "Could not work out Stoat's local API address — owner account not claimed."
return 0
fi
# Sixteen containers start in dependency order and the API restarts until
# Mongo and RabbitMQ are both healthy, so this is a wait, not a poll-once.
isNotice "Waiting for the Stoat API at ${api} ..."
local i=0 code=""
while ((i < 90)); do
code=$(runFileOp curl -sS -o /dev/null --max-time 3 -w '%{http_code}' "${api}/" 2>/dev/null)
[[ "$code" == "200" ]] && break
sleep 2
((i++))
done
if [[ "$code" != "200" ]]; then
isNotice "Stoat's API did not answer within $((90 * 2))s — owner account not claimed. Register at the URL below to take ownership."
return 0
fi
isSuccessful "Stoat API is up."
if _stoatAccountExists "$api"; then
isSuccessful "This instance already has accounts — leaving ownership alone."
return 0
fi
if _stoatCreateAccount "$api" "$email" "$pass" "$user"; then
isSuccessful "Stoat owner account created (${user} / ${email})."
stoat_owner_claimed="true"
else
isNotice "Could not create the Stoat owner account — register at the URL below to take ownership yourself."
fi
}
stoat_install_post()
{
local app_name="$1"
local base
base=$(_stoatBaseUrl "$app_name")
echo ""
isNotice "Stoat first run:"
echo ""
if [[ "${stoat_owner_claimed:-}" == "true" ]]; then
echo " Sign in at ${base}"
echo ""
echo " Handle : ${CFG_STOAT_ADMIN_USERNAME:-administrator}"
echo " Email : ${CFG_STOAT_ADMIN_EMAIL}"
echo " Password : ${CFG_STOAT_ADMIN_PASSWORD_1}"
echo ""
# Said plainly because it is the part people get wrong: creating this
# account does not close registration. Stoat ships invite_only=false with
# no captcha and no email verification, so anyone who can reach the URL
# can still make their own account.
echo " Registration is still OPEN — anyone who can reach that URL can"
echo " sign up. Keep the port off the internet, or set invite_only in"
echo " Revolt.toml, if that is not what you want."
else
echo " Open ${base} and create an account."
echo ""
echo " Registration is open and unverified, so do it before anyone"
echo " else can reach the URL."
fi
echo ""
if [[ "$base" == http://* ]]; then
echo " This install serves plain HTTP. Text chat, channels, roles and"
echo " uploads all work, but browsers refuse camera and microphone"
echo " access outside a secure context — so voice and video will not"
echo " work until it is served over HTTPS. A WireGuard tunnel does not"
echo " change that: the check is on the URL scheme, not the transport."
echo ""
fi
echo " Give it a few minutes on first boot: sixteen containers start in"
echo " dependency order, and the API restarts until MongoDB and RabbitMQ"
echo " both report healthy. 'docker compose ps' in the app directory"
echo " shows where it has got to."
echo ""
echo " Voice falls back to TCP 7881, which is already open. For proper"
echo " low-latency WebRTC from outside your LAN, also allow the UDP"
echo " media range — LibrePortal's firewall layer only emits TCP rules,"
echo " so this one is manual:"
echo ""
echo " sudo ufw allow 50000:50100/udp"
echo ""
}