The compose hands RabbitMQ a generated password, but the Stoat services fall back to the defaults compiled into them — rabbituser/rabbitpass — so api, crond, pushd and voice-ingress panicked on ACCESS_REFUSED and restarted forever. The failure was easy to misread: the eleven services that never touch RabbitMQ came up healthy and the web client answered on port 80, so the stack looked almost fine while none of the messaging worked. Write a [rabbit] section into Revolt.toml carrying the same credentials the broker was given. Verified after the fix: all sixteen containers up, /api returns the instance descriptor, /autumn answers, and /.well-known/stoat carries the right URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
13 KiB
Bash
338 lines
13 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
|
|
|
|
# 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"
|
|
}
|
|
|
|
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"
|
|
|
|
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" "$CFG_STOAT_RABBITMQ_PASSWORD_1"
|
|
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 ""
|
|
}
|