Stoat shipped with no account and no way to make one from LibrePortal. It is first-come-first-served, with invite_only=false, no captcha and no email verification, so every install left a window between the API answering and someone signing up in which anyone who could reach the port could take the instance. The installer now claims the configured account as soon as the API responds, and prints the credentials instead of "go and register". Provisioning goes over HTTP, not Mongo: an account needs a login AND a completed onboarding (accounts holds one, users the other) and passwords go through Stoat's argon2 layer. Failure is deliberately non-fatal — it leaves the instance exactly as it was before this existed, which must not fail an otherwise good install of sixteen containers. Both obvious config defaults are rejected by Stoat, which is only visible as a failed install, so both are chosen against its rules: example.com comes back DisallowedContactSupport (reserved domain) hence admin@stoat.local, and "admin" comes back InvalidUsername (reserved) hence "administrator". Two of the three missing adapter operations are now implemented: - createUser: create, log in, complete onboarding. Without the last step an account can sign in and then sits on a pick-a-username screen forever. - setPassword: previously excluded because hand-rolling argon2 risks writing a hash nothing can verify, locking the holder out with no error at the time. That objection is answered by refusing to hash at all — authifier already owns a reset flow, so this writes only its password_reset token to Mongo and lets PATCH /auth/account/reset_password do the hashing with the same code that verifies. Verified: reset by username and by email, new password logs in, token consumed. setAdmin is still NOT implemented, and the header now says so with evidence rather than assertion. Stoat has no instance-level admin flag: the user document holds only _id/username/discriminator and GET /users/@me adds only relationship and online. Permissions are per-server bitfields on server_members. A "make admin" button would invent a concept the app does not have. Also fixed two things found while testing: - post_start returned early when the public URL needed no settling, which skipped everything after it — so provisioning would have been silently missed on exactly the domain-backed installs that guessed the URL right. - _stoatBaseUrl advertised $public_ip_v4, the WAN address from an external resolver, in URLs compiled into the web client. Same fix as the APP_URL processor: prefer $local_ip_v4, since LibrePortal never forwards ports. Verified end to end on a clean install: the owner account is created and onboarded, the generated password logs in, both new tools run through `libreportal app tool`, and a created account survives a password reset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
451 lines
18 KiB
Bash
451 lines
18 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
|
|
}
|
|
|
|
# Scheme + host the client bundle is built against, with no trailing slash.
|
|
#
|
|
# https://<host> when Traefik is installed and a domain is configured;
|
|
# otherwise http://<lan-ip>:<allocated-port>, 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" <<EOF
|
|
HOSTNAME=:80
|
|
REVOLT_PUBLIC_URL=${base}/api
|
|
VITE_API_URL=${base}/api
|
|
VITE_WS_URL=${ws_scheme}://${hostport}/ws
|
|
VITE_MEDIA_URL=${base}/autumn
|
|
VITE_PROXY_URL=${base}/january
|
|
VITE_GIFBOX_URL=${base}/gifbox
|
|
VITE_CFG_ENABLE_VIDEO=${video_enabled}
|
|
EOF
|
|
|
|
printf '{"api":"%s/api"}' "$base" | runFileWrite "$app_dir/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 = "${base}"
|
|
api = "${base}/api"
|
|
events = "${ws_scheme}://${hostport}/ws"
|
|
autumn = "${base}/autumn"
|
|
january = "${base}/january"
|
|
gifbox = "${base}/gifbox"
|
|
|
|
[hosts.livekit]
|
|
worldwide = "${ws_scheme}://${hostport}/livekit"
|
|
|
|
[api.livekit.nodes.worldwide]
|
|
url = "http://livekit:7880"
|
|
lat = 0.0
|
|
lon = 0.0
|
|
|
|
# Upstream's built-in defaults are rabbituser/rabbitpass. The compose gives the
|
|
# broker a generated password instead, so the clients have to be told — without
|
|
# this the API, crond, pushd and voice-ingress all panic on ACCESS_REFUSED and
|
|
# restart forever, while the services that do not touch RabbitMQ come up fine
|
|
# and make it look like a partial success.
|
|
[rabbit]
|
|
host = "rabbit"
|
|
port = 5672
|
|
username = "stoat"
|
|
password = "${rabbit_pass}"
|
|
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
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
# Own the LibrePortal-written config files (Caddyfile, Revolt.toml, .env.web,
|
|
# stoat.json, secrets.env, livekit.yml, the compose + app config) as the docker
|
|
# install user, so the containers can read their bind-mount sources.
|
|
#
|
|
# Top level ONLY. This used to be `chown -R "$app_dir"`, which walked into
|
|
# data/db, data/minio and friends — content created by the containers and owned
|
|
# by THEIR uids (mongo's, minio's; under rootless those are subuids the docker
|
|
# install user has no authority over). Every reinstall therefore printed a screen
|
|
# of "Operation not permitted" plus "Permission denied" on the 0700 dirs it
|
|
# couldn't even enter, and then failed the step outright — an ✗ Error on a
|
|
# healthy install, which is the kind of noise that teaches you to skip error
|
|
# lines. Those files must keep their container ownership anyway: chowning mongo's
|
|
# data away from mongo is what would actually break stoat.
|
|
_stoatOwnConfigFiles() {
|
|
local app_dir="$1"
|
|
runFileOp find "$app_dir" -maxdepth 1 -type f \
|
|
-exec chown "$docker_install_user":"$docker_install_user" {} +
|
|
}
|
|
|
|
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 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"
|
|
|
|
# Ordering rule for everything below: every file bind-mounted into a
|
|
# container must be written before the first step that could fail. A missing
|
|
# mount source is not a soft failure — docker either creates a directory in
|
|
# its place or refuses to start the container, and both outcomes outlive the
|
|
# install and break every later run.
|
|
result=$(copyResource "$app_name" "Caddyfile" "" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&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" <<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"
|
|
|
|
_stoatOwnConfigFiles "$app_dir"
|
|
checkSuccess "Setting ownership on the $app_name config files"
|
|
}
|
|
|
|
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}"
|
|
|
|
# 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.
|
|
echo " Registration is still OPEN — anyone who can reach that URL can"
|
|
echo " sign up. Keep the port off the internet, or set invite_only in"
|
|
echo " Revolt.toml, if that is not what you want."
|
|
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 ""
|
|
}
|