#!/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" 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" </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" <&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" </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 "" }