LibrePortal/containers/stoat/scripts/stoat_install_hooks.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

470 lines
19 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="$(appDir "$app_name")/docker-compose.yml"
if [[ -d "$(appDir 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"
# Anything but an explicit "false" closes registration: a blank or misspelled
# value should fail safe, not silently open the instance to the internet.
local invite_only="true"
[[ "${CFG_STOAT_INVITE_ONLY:-true}" == "false" ]] && invite_only="false"
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}"
# Whether a stranger who reaches this URL can sign themselves up. Upstream
# defaults to open, with no captcha and no email verification to slow it down.
# The section name is load-bearing and not guessable: invite_only under
# [features] or [api.security.authifier] is accepted silently and does nothing —
# the API keeps reporting invite_only=false — so only [api.registration] here.
[api.registration]
invite_only = ${invite_only}
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="$(appDir "$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="$(appDir "$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.
if [[ "${CFG_STOAT_INVITE_ONLY:-true}" == "false" ]]; then
echo " Registration is OPEN — anyone who can reach that URL can sign"
echo " up, with no captcha and no email check. Set"
echo " CFG_STOAT_INVITE_ONLY=true to close it."
else
echo " Registration is closed. Add people with the Create User"
echo " Account tool on this app's Tools tab, which issues its own"
echo " single-use invite."
fi
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 ""
}