The install read the generated LiveKit credentials with a plain grep, but secrets.env is chmod 600 and owned by the docker install user while the hooks run as the manager — so the read returned nothing, the hook errored out, and Caddyfile and livekit.yml were never written. Compose then refused to start, because a bind mount whose source does not exist is not a soft failure. Read secrets through runFileOp, and reorder so the Caddyfile and the three URL-bearing files are written first: any step that can fail now comes after every mount source already exists. The missing LiveKit keys are downgraded from fatal to a warning for the same reason — losing voice is worth reporting, but it is no reason to take the other fifteen services down with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
12 KiB
Bash
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"
|
|
|
|
# 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 ""
|
|
}
|