LibrePortal/containers/stoat/scripts/stoat_install_hooks.sh
librelad e8a2aa453e fix(auth): resolve slot-numbered credential keys, drop two dead ones
Only two of the four keys flagged as unused actually were. gitea and invidious
ADMIN_PASSWORD are written by their auth adapters through authPersistCfg, which
builds the name as CFG_${app^^}_${key} from a parameter — invisible to a literal
grep, which is why the earlier pass called them dead. They stay.

Worse, the slot rename broke that write path for five apps: adguard, bookstack,
gitea, invidious and nextcloud all persist ADMIN_PASSWORD, and the config now
holds ADMIN_PASSWORD_1. updateConfigOption only rewrites a key that already
exists, so the write became a no-op — the app's password would really change
while the config and the WebUI kept showing the old one.

authPersistCfg now falls back to the numbered slot when the bare key is absent,
so adapters never need to know how a credential is numbered and adding a slot
can't silently disconnect the adapter that writes it. When neither name exists
it warns and returns non-zero instead of failing silently, which surfaces a
pre-existing case: linkding's adapter persists ADMIN_USER and ADMIN_PASSWORD but
its config declares neither, and never did.

Deleted the two that really are dead: CFG_TRAEFIK_ADMIN_PASSWORD_1 (its adapter
uses CFG_TRAEFIK_USER/CFG_TRAEFIK_PASS from the system config) and
CFG_GLUETUN_CONTROL_SERVER_API_KEY_1, plus their WebUI field mappings.

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

327 lines
12 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://${public_ip_v4:-localhost}:${external}"
else
echo "http://${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
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"
}
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"
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"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir"
checkSuccess "Setting ownership on the $app_name install directory"
}
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}"
[[ "$base" == "$current" ]] && return 0
((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"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$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"
}
stoat_install_post()
{
local app_name="$1"
local base
base=$(_stoatBaseUrl "$app_name")
echo ""
isNotice "Stoat first run:"
echo ""
echo " Open ${base} and create an account — the first account"
echo " registered on a fresh instance becomes the instance owner."
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 ""
}