#!/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:// when Traefik is installed and a domain is configured; # otherwise http://:, 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" </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" </dev/null) livekit_secret=$(grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='\K[^']*" "$app_dir/secrets.env" 2>/dev/null) if [[ -z "$livekit_key" || -z "$livekit_secret" ]]; then isError "Could not read the LiveKit credentials from secrets.env — voice will not work." return 1 fi 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. The files still have to exist now: they are bind-mounted, and # docker would silently create directories in their place otherwise. # stoat_install_post_start rewrites them once the real port 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" # 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" <&1) checkSuccess "Copying Caddyfile to $app_dir" 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 "" }