#!/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://${local_ip_v4:-${public_ip_v4:-localhost}}:${external}" else # $local_ip_v4, not $public_ip_v4: these URLs are compiled into the web # client and handed to browsers, and LibrePortal does not forward ports — # so the WAN address an external resolver reports is unreachable for # exactly the LAN/VPN clients this branch serves. echo "http://${local_ip_v4:-${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}" # Guarded, NOT an early return. Claiming the owner account below has to happen # on every install, and a domain-backed one guesses the URL correctly first # time — so returning here when nothing needed settling silently skipped # provisioning on exactly the installs that went most smoothly. if [[ "$base" != "$current" ]]; then ((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" _stoatOwnConfigFiles "$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" fi _stoatProvisionOwner "$app_name" } # Register the configured owner account, closing the first-run land grab. # # Stoat is first-come-first-served — the first account registered on a fresh # instance becomes the instance owner — and it ships invite_only=false with no # captcha and no email verification. Until this ran, every install had a window # between "the API answers" and "you got round to signing up" in which anyone who # could reach the port could take ownership. # # Deliberately not fatal. A failure here leaves the instance exactly as it was # before this hook existed (unclaimed, with the printed advice to go and register), # which is worse than provisioning but no worse than the old behaviour — so it # must not fail an otherwise good install of sixteen containers. _stoatProvisionOwner() { local app_name="$1" local email="${CFG_STOAT_ADMIN_EMAIL:-}" local pass="${CFG_STOAT_ADMIN_PASSWORD_1:-}" local user="${CFG_STOAT_ADMIN_USERNAME:-admin}" if [[ -z "$email" || -z "$pass" ]]; then isNotice "No Stoat owner configured (CFG_STOAT_ADMIN_EMAIL / _PASSWORD_1) — the first account to register will own this instance." return 0 fi ((menu_number++)) echo "" echo "---- $menu_number. Claiming the Stoat owner account" echo "" local api api=$(_stoatApiLocal "$app_name") if [[ -z "$api" ]]; then isNotice "Could not work out Stoat's local API address — owner account not claimed." return 0 fi # Sixteen containers start in dependency order and the API restarts until # Mongo and RabbitMQ are both healthy, so this is a wait, not a poll-once. isNotice "Waiting for the Stoat API at ${api} ..." local i=0 code="" while ((i < 90)); do code=$(runFileOp curl -sS -o /dev/null --max-time 3 -w '%{http_code}' "${api}/" 2>/dev/null) [[ "$code" == "200" ]] && break sleep 2 ((i++)) done if [[ "$code" != "200" ]]; then isNotice "Stoat's API did not answer within $((90 * 2))s — owner account not claimed. Register at the URL below to take ownership." return 0 fi isSuccessful "Stoat API is up." if _stoatAccountExists "$api"; then isSuccessful "This instance already has accounts — leaving ownership alone." return 0 fi if _stoatCreateAccount "$api" "$email" "$pass" "$user"; then isSuccessful "Stoat owner account created (${user} / ${email})." stoat_owner_claimed="true" else isNotice "Could not create the Stoat owner account — register at the URL below to take ownership yourself." fi } stoat_install_post() { local app_name="$1" local base base=$(_stoatBaseUrl "$app_name") echo "" isNotice "Stoat first run:" echo "" if [[ "${stoat_owner_claimed:-}" == "true" ]]; then echo " Sign in at ${base}" echo "" echo " Handle : ${CFG_STOAT_ADMIN_USERNAME:-administrator}" echo " Email : ${CFG_STOAT_ADMIN_EMAIL}" echo " Password : ${CFG_STOAT_ADMIN_PASSWORD_1}" echo "" # Said plainly because it is the part people get wrong: creating this # account does not close registration. Stoat ships invite_only=false with # no captcha and no email verification, so anyone who can reach the URL # can still make their own account. if [[ "${CFG_STOAT_INVITE_ONLY:-true}" == "false" ]]; then echo " Registration is OPEN — anyone who can reach that URL can sign" echo " up, with no captcha and no email check. Set" echo " CFG_STOAT_INVITE_ONLY=true to close it." else echo " Registration is closed. Add people with the Create User" echo " Account tool on this app's Tools tab, which issues its own" echo " single-use invite." fi else echo " Open ${base} and create an account." echo "" echo " Registration is open and unverified, so do it before anyone" echo " else can reach the URL." fi 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 "" }