Both hooks read their host (and matrix its database password) back out of the deployed docker-compose.yml. That cannot work: install_post_compose runs after the compose TEMPLATE is copied but before dockerConfigSetupFileWithData fills the tags, so at that point the file still holds raw placeholders. Matrix aborted with "Database password was not generated in the compose file" even though the password had been generated correctly — it just was not in the compose yet. Derive the host from port_subdomains[0] + domain_full instead, both already in scope from variables_init_app, applying the same empty/@/root rule as tagsProcessorPortSubdomains so the computed name and the Traefik rule generated later cannot drift apart. Matrix takes its database password from CFG_MATRIX_DB_PASSWORD_1, which is where the secret is generated and remembered and is the same variable the compose tag is filled from a step later. The error messages now name the actual missing thing — the domain — rather than blaming the compose file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
8.4 KiB
Bash
240 lines
8.4 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
|
|
}
|
|
|
|
# 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 domain
|
|
domain=$(_stoatDomain)
|
|
if [[ -z "$domain" ]]; then
|
|
isError "No domain is configured — cannot determine the public host for Stoat."
|
|
isNotice "Set CFG_DOMAIN_${CFG_STOAT_DOMAIN:-1} (General → Network), then reinstall."
|
|
return 1
|
|
fi
|
|
|
|
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"
|
|
|
|
_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.
|
|
local livekit_key livekit_secret
|
|
livekit_key=$(grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='\K[^']*" "$app_dir/secrets.env" 2>/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
|
|
|
|
# HOSTNAME=:80 is what puts Caddy in plain-HTTP mode behind Traefik. The
|
|
# VITE_* values are compiled into the browser bundle, so they must be the
|
|
# public https:// URLs, not internal container addresses.
|
|
local video_enabled=""
|
|
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
|
|
|
|
runFileWrite "$app_dir/.env.web" <<EOF
|
|
HOSTNAME=:80
|
|
REVOLT_PUBLIC_URL=https://${domain}/api
|
|
VITE_API_URL=https://${domain}/api
|
|
VITE_WS_URL=wss://${domain}/ws
|
|
VITE_MEDIA_URL=https://${domain}/autumn
|
|
VITE_PROXY_URL=https://${domain}/january
|
|
VITE_GIFBOX_URL=https://${domain}/gifbox
|
|
VITE_CFG_ENABLE_VIDEO=${video_enabled}
|
|
EOF
|
|
checkSuccess "Writing .env.web for https://$domain"
|
|
|
|
# Client discovery document, served at /.well-known/stoat.
|
|
printf '{"api":"https://%s/api"}' "$domain" | runFileWrite "$app_dir/stoat.json"
|
|
checkSuccess "Writing 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 = "https://${domain}"
|
|
api = "https://${domain}/api"
|
|
events = "wss://${domain}/ws"
|
|
autumn = "https://${domain}/autumn"
|
|
january = "https://${domain}/january"
|
|
gifbox = "https://${domain}/gifbox"
|
|
|
|
[hosts.livekit]
|
|
worldwide = "wss://${domain}/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
|
|
checkSuccess "Writing Revolt.toml (video=${video_enabled:-false})"
|
|
|
|
# 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"
|
|
|
|
result=$(copyResource "$app_name" "Caddyfile" "" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&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()
|
|
{
|
|
local app_name="$1"
|
|
local domain
|
|
domain=$(_stoatDomain)
|
|
|
|
echo ""
|
|
isNotice "Stoat first run:"
|
|
echo ""
|
|
echo " Open https://${domain} and create an account — the first account"
|
|
echo " registered on a fresh instance becomes the instance owner."
|
|
echo ""
|
|
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 ""
|
|
}
|