Sixteen containers: MongoDB, Valkey, RabbitMQ, MinIO and eleven Stoat services. Servers, channels, roles and voice/video through LiveKit — the nearest thing in the catalogue to Discord itself, at the price of being much the heaviest app in it. Does not federate. The compose service keys are deliberately kept identical to upstream's (database, redis, api, autumn, ...) while container_name is prefixed stoat-. Compose registers both on the network, so upstream's internal defaults keep resolving and LibrePortal still gets the prefixed names its port, firewall and backup layers key on. Upstream's Caddy is kept as the internal path router and Traefik simply proxies to it, which is upstream's own supported behind-a-reverse-proxy mode — reimplementing eight path routes as Traefik labels would be a second copy to keep in sync for nothing. The install hook is a non-interactive port of generate_config.sh, and it never rewrites an existing secrets.env: REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded, so regenerating it would orphan the whole media store. LiveKit's UDP media range is published literally rather than through the port table, because the firewall rebuild emits /tcp rules only and a range declared there would produce a wrong rule rather than no rule. Voice falls back to TCP 7881 until the range is opened by hand; the post-install notice says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
7.9 KiB
Bash
227 lines
7.9 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, read back from the deployed compose once tag substitution has
|
|
# filled it in. Everything else in this file is derived from it.
|
|
_stoatDomain()
|
|
{
|
|
local app_name="$1"
|
|
tagsManagerGetTagContent "$containers_dir$app_name/docker-compose.yml" "DOMAINSUBNAME_TAG_1"
|
|
}
|
|
|
|
# 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 "$app_name")
|
|
if [[ -z "$domain" ]]; then
|
|
isError "Could not determine the public host from the compose file — aborting Stoat configuration."
|
|
isNotice "Check that CFG_STOAT_PORT_1 is public and Traefik-managed, 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 "$app_name")
|
|
|
|
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 ""
|
|
}
|